Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/PRODUCT_PLAN_AND_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ Codex / Claude Code / Other Agent

## 5. 已完成并有代码支撑的能力

### 项目设置(2026-10-18)

- Projects 卡片和工作空间顶部提供项目设置,可修改名称、140 字简介、手动封面或恢复自动封面;展示只读 ID、创建日期和本地存储信息,项目链接保持稳定。
- 手动封面不会被后续上传或生成覆盖;自动封面按首张导入/生成图片、首个本地视频缩略图、占位图回退。旧项目已有封面在升级时保留为手动选择。
- UI 和 `bdesign_project_update` MCP 使用相同 `project.update` 命令、项目素材归属检查和独立设置版本;旧版本写入返回冲突。详见 `docs/PROJECT_SETTINGS.md`。


### Project / Local-first

- 项目创建、打开、重命名和本地 SQLite 持久化。
Expand Down
62 changes: 62 additions & 0 deletions docs/PROJECT_SETTINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Project settings

Project settings are available from the gear on each Projects card and the
workspace header in Studio, Canvas, Editor and Assets. They edit the same local
project: name (1–240 characters), description (up to 140 characters), and cover.
Project ID, creation date and local storage information are read-only. Project
URLs keep their stable IDs. This does not introduce accounts, sharing or publishing.
Image tiles in the project Asset library also offer **Set as cover**.

## Covers

- A manual cover must be an image Asset belonging to this project. Later imports,
generations and re-linking assets never replace it.
- Upload cover uses the existing local project Asset import endpoint. Uploads stay
in the Asset library even when the settings dialog is cancelled.
- Automatic cover selects the earliest uploaded/generated image by Asset creation
timestamp, then stable Asset ID. Reference-only images are excluded. If there is
no eligible image, it uses the first local video poster. No usable media or a
failed poster shows a placeholder; the original video is never loaded by a card.
- Missing/deleted manual cover Assets fall back automatically. Removing the cover
choice never deletes an Asset. Project cards and the settings preview use 16:9.
- Existing covers are preserved as manual during additive SQLite startup upgrades.
The schema default also preserves legacy covers when applying `db:push` first;
new projects explicitly start in automatic mode. Upgrade tests use isolated data.

Video fallback uses the optional server `ffmpeg` decoder from `PATH` or
`BEATDESIGN_FFMPEG`. A membership-checked endpoint caches bounded 512px JPEG
posters under the data directory, with at most two concurrent decodes. Missing
decoders or failed decoding return a placeholder; they do not prevent ordinary
browser playback or require downloading the original video in Projects.

## Shared UI/MCP command

`GET /api/app/projects/:id` returns `{ settings }`, including effective cover,
automatic fallback, and `settingsRevision`.

`PATCH /api/app/projects/:id` accepts:

```json
{
"expectedRevision": 1,
"commandId": "unique-command-id",
"patch": {
"name": "My film",
"description": "A short film",
"cover": { "mode": "manual", "assetId": "project-image-id" }
}
}
```

Use `cover: { "mode": "auto" }` to reset the choice. The route, generic command
endpoint and `bdesign_project_update` MCP tool use the same `project.update`
Command Kernel path. `bdesign_project_get` returns `settings`. Unknown fields,
external URLs, non-image covers and foreign-project Assets are rejected. Settings
have a separate revision from Canvas/Editor. A stale update returns
`REVISION_CONFLICT` (HTTP 409); the dialog retains edits and asks the user to
reopen before saving. It never automatically overwrites a newer change.

The command uses an atomic revision comparison inside a database transaction and
the existing command receipt/idempotency service. Reuse both command ID and
idempotency key for retries. List/header queries refresh every five seconds while
visible; opening settings always loads the current record.
32 changes: 32 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -753,5 +753,37 @@
},
"Common": {
"loading": "Loading"
},
"ProjectSettings": {
"title": "Project settings",
"subtitle": "Manage the details and cover shown in your project list.",
"details": "Details",
"name": "Project name",
"description": "Description",
"descriptionHint": "A short summary shown on project cards.",
"descriptionPlaceholder": "Describe your project",
"cover": "Cover image",
"coverHint": "Choose a project image or upload a cover. 16:9 works best. Uploads stay in the asset library.",
"automatic": "Automatic · first image, then a video thumbnail",
"manual": "Custom cover · stays fixed when new assets arrive",
"upload": "Upload cover",
"reset": "Use automatic cover",
"choose": "Choose from project images",
"info": "Project info",
"id": "Project ID",
"created": "Created date",
"storage": "Storage",
"local": "Local workspace",
"cancel": "Cancel",
"save": "Save changes",
"saving": "Saving…",
"loading": "Loading settings…",
"loadError": "Unable to load settings.",
"saveError": "Unable to save settings.",
"imageOnly": "Choose a PNG, JPEG, WebP or GIF image.",
"closeReload": "Close and reopen to reload",
"setAsCover": "Set as cover",
"coverSaved": "Project cover updated",
"conflict": "Project settings changed elsewhere. Close and reopen settings before saving."
}
}
32 changes: 32 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -753,5 +753,37 @@
},
"Common": {
"loading": "読み込み中"
},
"ProjectSettings": {
"title": "プロジェクト設定",
"subtitle": "一覧に表示する情報とカバーを管理します。",
"details": "基本情報",
"name": "プロジェクト名",
"description": "説明",
"descriptionHint": "プロジェクトカードに表示する短い説明。",
"descriptionPlaceholder": "プロジェクトの説明",
"cover": "カバー画像",
"coverHint": "画像を選択するかアップロードします。16:9を推奨。画像は素材に保存されます。",
"automatic": "自動 · 最初の画像、次に動画サムネイル",
"manual": "カスタム · 新しい素材で上書きされません",
"upload": "カバーをアップロード",
"reset": "自動カバーに戻す",
"choose": "プロジェクトの画像から選択",
"info": "プロジェクト情報",
"id": "プロジェクト ID",
"created": "作成日",
"storage": "保存先",
"local": "ローカルワークスペース",
"cancel": "キャンセル",
"save": "変更を保存",
"saving": "保存中…",
"loading": "設定を読み込み中…",
"loadError": "設定を読み込めません。",
"saveError": "設定を保存できません。",
"imageOnly": "PNG、JPEG、WebP、GIFを選択してください。",
"closeReload": "閉じて再度開くと更新されます",
"setAsCover": "カバーに設定",
"coverSaved": "カバーを更新しました",
"conflict": "設定が別の場所で更新されました。閉じて再度開いてから保存してください。"
}
}
32 changes: 32 additions & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -753,5 +753,37 @@
},
"Common": {
"loading": "加载中"
},
"ProjectSettings": {
"title": "项目设置",
"subtitle": "管理项目列表中的基本信息和封面。",
"details": "基本信息",
"name": "项目名称",
"description": "简介",
"descriptionHint": "显示在项目卡片上的简短说明。",
"descriptionPlaceholder": "描述这个项目",
"cover": "封面图片",
"coverHint": "选择项目图片或上传封面,推荐 16:9。上传的图片会保留在素材库。",
"automatic": "自动选择 · 优先首张图片,其次视频缩略图",
"manual": "自定义封面 · 后续新增素材不会覆盖",
"upload": "上传封面",
"reset": "恢复自动封面",
"choose": "从项目图片中选择",
"info": "项目信息",
"id": "项目 ID",
"created": "创建日期",
"storage": "存储位置",
"local": "本地工作空间",
"cancel": "取消",
"save": "保存更改",
"saving": "保存中…",
"loading": "正在加载设置…",
"loadError": "无法加载设置。",
"saveError": "无法保存设置。",
"imageOnly": "请选择 PNG、JPEG、WebP 或 GIF 图片。",
"closeReload": "关闭后重新打开以刷新",
"setAsCover": "设为项目封面",
"coverSaved": "项目封面已更新",
"conflict": "项目设置已在其他位置更新。请关闭后重新打开,再保存更改。"
}
}
22 changes: 20 additions & 2 deletions src/components/app/product-page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Images, LayoutPanelTop, Scissors, Workflow } from 'lucide-react';

import { WorkspaceApiConfigDialog } from '@/components/app/workspace-api-config-dialog';
import { ProjectSettingsDialog } from './project-settings-dialog';
import { fetchProjectSettings, saveProjectSettings } from '@/core/workspace-lib/app/project-settings-client';
import { useQuery } from '@tanstack/react-query';
import { LanguageSwitcher } from '@/components/app/language-switcher';
import { GitHubIcon } from '@/components/icons/github';
import { Link } from '@/core/i18n/navigation';
import { useTranslations } from '@/core/workspace-lib/shims/next-intl';
import { apiJsonPatch, apiJsonPost } from '@/lib/api-client';
import { apiJsonPost } from '@/lib/api-client';
import { appConfig } from '@/config';
import { ACTIVE_GENERATION_PROVIDER_ID } from '@/config/generation-providers';
import type { WorkspaceMode } from '@/config/workspace-modes';
Expand Down Expand Up @@ -40,6 +43,14 @@ export function ProductPageShell({
const [draftWorkspaceName, setDraftWorkspaceName] =
useState(defaultWorkspaceName);
const lastPersistedWorkspaceNameRef = useRef(defaultWorkspaceName);
const projectSettings = useQuery({ queryKey: ['project-settings', projectId], queryFn: () => fetchProjectSettings(projectId!), enabled: Boolean(projectId), refetchInterval: 5000, refetchIntervalInBackground: false });

useEffect(() => {
const name = projectSettings.data?.name;
if (!name) return;
setDraftWorkspaceName((draft) => draft === lastPersistedWorkspaceNameRef.current ? name : draft);
lastPersistedWorkspaceNameRef.current = name;
}, [projectSettings.data?.name]);

useEffect(() => {
const nextName = getDisplayWorkspaceName(workspaceName);
Expand Down Expand Up @@ -72,7 +83,9 @@ export function ProductPageShell({
}

try {
await apiJsonPatch(`/api/app/projects/${projectId}`, { name: nextName });
const current = projectSettings.data ?? await fetchProjectSettings(projectId);
await saveProjectSettings(projectId, current.settingsRevision, { name: nextName });
void projectSettings.refetch();
lastPersistedWorkspaceNameRef.current = nextName;
} catch (error) {
console.error('rename project failed:', error);
Expand Down Expand Up @@ -180,6 +193,11 @@ export function ProductPageShell({
</nav>
) : null}
<LanguageSwitcher variant="workspace" />
{projectId && <ProjectSettingsDialog projectId={projectId} onSaved={(settings) => {
setDraftWorkspaceName(settings.name);
lastPersistedWorkspaceNameRef.current = settings.name;
void projectSettings.refetch();
}} />}
<WorkspaceApiConfigDialog
providerId={ACTIVE_GENERATION_PROVIDER_ID}
/>
Expand Down
20 changes: 19 additions & 1 deletion src/components/app/project-assets-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchProjectSettings, saveProjectSettings } from '@/core/workspace-lib/app/project-settings-client';
import { invalidateWorkspaceAfterProjectMutation } from '@/core/workspace-lib/app/workspace-query-invalidation';
import {
AudioLines,
ExternalLink,
Expand Down Expand Up @@ -63,6 +65,21 @@ function AssetTile({
openLabel: string;
}) {
const [isAddingToTimeline, setIsAddingToTimeline] = useState(false);
const [isSettingCover, setIsSettingCover] = useState(false);
const settingsCopy = useTranslations('ProjectSettings');
const queryClient = useQueryClient();
const setAsCover = async () => {
if (isSettingCover) return;
setIsSettingCover(true);
try {
const settings = await fetchProjectSettings(projectId);
await saveProjectSettings(projectId, settings.settingsRevision, { cover: { mode: 'manual', assetId: asset.id } });
await invalidateWorkspaceAfterProjectMutation(queryClient);
await queryClient.invalidateQueries({ queryKey: ['project-settings', projectId] });
toast.success(settingsCopy('coverSaved'));
} catch (error) { toast.error(error instanceof Error ? error.message : settingsCopy('saveError')); }
finally { setIsSettingCover(false); }
};
const transfer: ProjectAssetTransfer = {
...asset,
projectId,
Expand Down Expand Up @@ -147,6 +164,7 @@ function AssetTile({

{canAddToCanvas || mediaType === 'image' || mediaType === 'video' || mediaType === 'audio' ? (
<div className="absolute inset-0 flex items-end justify-center gap-1.5 bg-gradient-to-t from-black/85 via-black/10 to-transparent px-2 pb-2 opacity-0 transition duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
{mediaType === 'image' && <button type="button" disabled={isSettingCover} onClick={() => void setAsCover()} className="inline-flex h-7 items-center rounded-full border border-white/15 bg-white/12 px-2.5 text-[10px] font-semibold text-white disabled:opacity-40">{settingsCopy('setAsCover')}</button>}
{canAddToCanvas ? (
<button
type="button"
Expand Down
28 changes: 28 additions & 0 deletions src/components/app/project-cover-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { FolderClock } from 'lucide-react';

/** Failed or unavailable video posters retain the same lightweight placeholder. */
export function ProjectCoverPreview({
url,
alt = '',
}: {
url?: string | null;
alt?: string;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [url]);
return url && !failed ? (
<img
src={url}
alt={alt}
className="size-full object-cover"
onError={() => setFailed(true)}
/>
) : (
<FolderClock
aria-hidden="true"
className="size-11 text-white/15"
strokeWidth={1.15}
/>
);
}
Loading