diff --git a/docs/PRODUCT_PLAN_AND_STATUS.md b/docs/PRODUCT_PLAN_AND_STATUS.md index 367f27c..f4c55cf 100644 --- a/docs/PRODUCT_PLAN_AND_STATUS.md +++ b/docs/PRODUCT_PLAN_AND_STATUS.md @@ -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 持久化。 diff --git a/docs/PROJECT_SETTINGS.md b/docs/PROJECT_SETTINGS.md new file mode 100644 index 0000000..2503ce4 --- /dev/null +++ b/docs/PROJECT_SETTINGS.md @@ -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. diff --git a/messages/en.json b/messages/en.json index 3ca8c74..ee70d40 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." } } diff --git a/messages/ja.json b/messages/ja.json index 6a05122..97f571c 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -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": "設定が別の場所で更新されました。閉じて再度開いてから保存してください。" } } diff --git a/messages/zh.json b/messages/zh.json index 1ae6210..45b50f8 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -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": "项目设置已在其他位置更新。请关闭后重新打开,再保存更改。" } } diff --git a/src/components/app/product-page-shell.tsx b/src/components/app/product-page-shell.tsx index 48f1e11..19a9af5 100644 --- a/src/components/app/product-page-shell.tsx +++ b/src/components/app/product-page-shell.tsx @@ -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'; @@ -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); @@ -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); @@ -180,6 +193,11 @@ export function ProductPageShell({ ) : null} + {projectId && { + setDraftWorkspaceName(settings.name); + lastPersistedWorkspaceNameRef.current = settings.name; + void projectSettings.refetch(); + }} />} diff --git a/src/components/app/project-assets-dialog.tsx b/src/components/app/project-assets-dialog.tsx index d1b3bfc..84cb0b3 100644 --- a/src/components/app/project-assets-dialog.tsx +++ b/src/components/app/project-assets-dialog.tsx @@ -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, @@ -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, @@ -147,6 +164,7 @@ function AssetTile({ {canAddToCanvas || mediaType === 'image' || mediaType === 'video' || mediaType === 'audio' ? (
+ {mediaType === 'image' && } {canAddToCanvas ? ( +
+ )} + {!loaded ? ( +

{t('loading')}

+ ) : ( +
{ + event.preventDefault(); + void save(); + }} + className="space-y-6" + > +
+

+ {t('details')} +

+
+ + setName(e.target.value)} + maxLength={240} + required + /> +
+
+ +
+