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' && 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')}} {canAddToCanvas ? ( setFailed(false), [url]); + return url && !failed ? ( + setFailed(true)} + /> + ) : ( + + ); +} diff --git a/src/components/app/project-settings-dialog.tsx b/src/components/app/project-settings-dialog.tsx new file mode 100644 index 0000000..99fc886 --- /dev/null +++ b/src/components/app/project-settings-dialog.tsx @@ -0,0 +1,362 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Settings2, Upload } from 'lucide-react'; +import { ProjectCoverPreview } from './project-cover-preview'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + useTranslations, + useLocale, +} from '@/core/workspace-lib/shims/next-intl'; +import { + fetchProjectSettings, + saveProjectSettings, +} from '@/core/workspace-lib/app/project-settings-client'; +import { uploadLocalProjectAsset } from '@/core/workspace-lib/app/local-project-asset-client'; +import { + fetchRecentAssets, + type RecentAsset, +} from '@/core/workspace-lib/app/workspace-client-api'; +import { + invalidateWorkspaceAfterProjectMutation, + invalidateWorkspaceAfterAssetMutation, +} from '@/core/workspace-lib/app/workspace-query-invalidation'; +import type { ProjectSettings } from '@/core/projects/project-settings-contract'; +import { ApiError } from '@/lib/api-client'; + +export function ProjectSettingsDialog({ + projectId, + onSaved, +}: { + projectId: string; + onSaved?: (settings: ProjectSettings) => void; +}) { + const t = useTranslations('ProjectSettings'); + const locale = useLocale(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [settings, setSettings] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [coverId, setCoverId] = useState(null); + const [coverUrl, setCoverUrl] = useState(null); + const [autoCover, setAutoCover] = useState(true); + const [images, setImages] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [loaded, setLoaded] = useState(false); + const fileInput = useRef(null); + const generation = useRef(0); + + useEffect(() => { + if (!open) return; + const request = ++generation.current; + setLoaded(false); + setSettings(null); + setError(''); + void Promise.all([ + fetchProjectSettings(projectId), + fetchRecentAssets(projectId), + ]) + .then(([value, assets]) => { + if (request !== generation.current) return; + setSettings(value); + setName(value.name); + setDescription(value.description); + setCoverId(value.coverAssetId); + setCoverUrl(value.coverImageUrl); + setAutoCover(value.coverMode === 'auto'); + setImages(assets.images); + setLoaded(true); + }) + .catch((err) => { + if (request === generation.current) + setError(err instanceof Error ? err.message : t('loadError')); + }); + return () => { + generation.current++; + }; + }, [open, projectId, locale]); + + async function upload(file?: File) { + if (!file || busy) return; + if ( + !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes( + file.type, + ) + ) { + setError(t('imageOnly')); + return; + } + setBusy(true); + setError(''); + try { + const asset = await uploadLocalProjectAsset({ projectId, file }); + setCoverId(asset.id); + setCoverUrl(asset.publicUrl); + setAutoCover(false); + setImages((old) => [ + { + ...asset, + width: asset.width ?? null, + height: asset.height ?? null, + createdAt: new Date().toISOString(), + }, + ...old, + ]); + await invalidateWorkspaceAfterAssetMutation(queryClient); + } catch (err) { + setError(err instanceof Error ? err.message : t('saveError')); + } finally { + setBusy(false); + if (fileInput.current) fileInput.current.value = ''; + } + } + + async function save() { + if (!settings || busy || !name.trim()) return; + setBusy(true); + setError(''); + try { + const next = await saveProjectSettings( + projectId, + settings.settingsRevision, + { + name, + description, + cover: autoCover + ? { mode: 'auto' } + : { mode: 'manual', assetId: coverId! }, + }, + ); + await invalidateWorkspaceAfterProjectMutation(queryClient); + onSaved?.(next); + setOpen(false); + } catch (err) { + setError( + err instanceof ApiError && err.code === 409 + ? t('conflict') + : err instanceof Error + ? err.message + : t('saveError'), + ); + } finally { + setBusy(false); + } + } + + const control = + 'w-full rounded-lg border border-white/10 bg-[#202126] px-3 py-2 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-[#ff6b1a]'; + const button = + 'rounded-lg border border-white/10 px-3 py-2 text-xs text-white/80 hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#ff6b1a] disabled:opacity-40'; + return ( + { + if (!busy) setOpen(value); + }} + > + + + + + + {t('title')} + + {t('subtitle')} + + + {error && ( + + {error}{' '} + { + setOpen(false); + }} + > + {t('closeReload')} + + + )} + {!loaded ? ( + {t('loading')} + ) : ( + { + event.preventDefault(); + void save(); + }} + className="space-y-6" + > + + + {t('details')} + + + + {t('name')} + + setName(e.target.value)} + maxLength={240} + required + /> + + + + {t('description')} + + {t('descriptionHint')} + + + + setDescription(e.target.value)} + maxLength={140} + placeholder={t('descriptionPlaceholder')} + /> + + {description.length}/140 + + + + + + {t('cover')} + + {t('coverHint')} + + + + + + + + {autoCover ? t('automatic') : t('manual')} + + + fileInput.current?.click()} + > + + {t('upload')} + + { + setAutoCover(true); + setCoverUrl(settings?.automaticCoverImageUrl ?? null); + }} + > + {t('reset')} + + + void upload(e.target.files?.[0])} + /> + {images.length > 0 && ( + <> + {t('choose')} + + {images.map((image) => ( + { + setCoverId(image.id); + setCoverUrl(image.publicUrl); + setAutoCover(false); + }} + className={`aspect-video overflow-hidden rounded-lg border-2 focus-visible:ring-2 focus-visible:ring-[#ff6b1a] ${!autoCover && coverId === image.id ? 'border-[#ff6b1a]' : 'border-transparent'}`} + > + + + ))} + + > + )} + + + + {t('info')} + + + {t('id')} + + {settings?.id} + + {t('created')} + + {settings && + new Date(settings.createdAt).toLocaleDateString()} + + {t('storage')} + {t('local')} + + + + setOpen(false)} + > + {t('cancel')} + + + {busy ? t('saving') : t('save')} + + + + )} + + + ); +} diff --git a/src/components/home/workspace-page.tsx b/src/components/home/workspace-page.tsx index b30ff4a..163385c 100644 --- a/src/components/home/workspace-page.tsx +++ b/src/components/home/workspace-page.tsx @@ -9,6 +9,8 @@ import { useState } from 'react'; import { BeatApiProductShell } from '@/components/marketing/beatapi-product-shell'; import { normalizeLocale } from '@/config/locale'; +import { ProjectSettingsDialog } from '@/components/app/project-settings-dialog'; +import { ProjectCoverPreview } from '@/components/app/project-cover-preview'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { Dialog, @@ -219,25 +221,18 @@ export function WorkspacePage({ aria-label={copy.openProject(name)} className="block" > - - {project.coverImageUrl ? ( - - ) : ( - - - - )} + + {name} + {project.description && {project.description}} + + void removeProject(project)} diff --git a/src/config/db/schema.sqlite.ts b/src/config/db/schema.sqlite.ts index 3b2aebd..9c98b68 100644 --- a/src/config/db/schema.sqlite.ts +++ b/src/config/db/schema.sqlite.ts @@ -26,6 +26,10 @@ export const project = sqliteTable( { id: text('id').primaryKey(), name: text('name').notNull(), + description: text('description').notNull().default(''), + // Legacy rows with covers also remain manual when upgraded via db:push. + coverMode: text('cover_mode').notNull().default('manual'), + settingsRevision: integer('settings_revision').notNull().default(1), coverAssetId: text('cover_asset_id'), status: text('status').notNull().default('active'), currentStateVersion: integer('current_state_version').notNull().default(1), diff --git a/src/core/commands/asset-boundary.ts b/src/core/commands/asset-boundary.ts index 1e51345..3a0a83a 100644 --- a/src/core/commands/asset-boundary.ts +++ b/src/core/commands/asset-boundary.ts @@ -39,7 +39,7 @@ export async function normalizeCommandAssetReferences({ projectId: string; command: BeatDesignCommand; }): Promise { - if (command.type === 'editor.validate') return command; + if (command.type === 'editor.validate' || command.type === 'project.update') return command; if (command.type === 'canvas.apply') { return { diff --git a/src/core/commands/executor.ts b/src/core/commands/executor.ts index 05532d7..0e99950 100644 --- a/src/core/commands/executor.ts +++ b/src/core/commands/executor.ts @@ -4,6 +4,7 @@ import { type TimelineDocument, } from '@/core/editor/timeline-document'; import type { ProjectSnapshotDocument } from '@/core/projects/project-snapshot'; +import type { ProjectSettings, ProjectSettingsPatch } from '@/core/projects/project-settings-contract'; import { applyCanvasOperations, @@ -23,6 +24,7 @@ import { } from './contracts'; export type BeatDesignCommand = + | { type: 'project.update'; patch: ProjectSettingsPatch } | { type: 'canvas.apply'; operations: CanvasOperation[] } | { type: 'editor.apply'; operations: EditorOperation[] } | { type: 'editor.replace_document'; document: TimelineDocument } @@ -34,6 +36,7 @@ export type BeatDesignCommandDocuments = { }; export type BeatDesignCommandData = BeatDesignCommandDocuments & { + projectSettings?: ProjectSettings; diagnostics?: ReturnType; }; @@ -47,6 +50,9 @@ export function executeBeatDesignCommand({ const { commandId, projectId, origin, command } = envelope; try { + if (command.type === 'project.update') { + throw new BeatDesignCommandError('INVALID_COMMAND', 'Project settings commands require the persistence boundary.'); + } if (command.type === 'canvas.apply') { if (!documents.canvas) { throw new BeatDesignCommandError( diff --git a/src/core/commands/persist.ts b/src/core/commands/persist.ts index d569a5a..60d8bd5 100644 --- a/src/core/commands/persist.ts +++ b/src/core/commands/persist.ts @@ -2,6 +2,7 @@ import { loadProjectTimeline, saveProjectTimeline, } from '@/core/editor/timeline-state'; +import { updateProjectSettings } from '@/core/projects/project-settings'; import { loadProjectWithLatestSnapshot, saveProjectSnapshot, @@ -13,6 +14,7 @@ import { BeatDesignCommandError, createCommandFailure, createCommandId, + createCommandSuccess, type BeatDesignCommandEnvelope, type BeatDesignCommandOrigin, type BeatDesignCommandResult, @@ -28,6 +30,15 @@ import { storeCommandReceipt, } from './receipts'; +// Keep a settings transaction and its receipt together before another settings +// command starts on libsql's shared connection. SQLite CAS still guards processes. +let pendingSettingsCommand: Promise = Promise.resolve(); +function enqueueSettingsCommand(run: () => Promise): Promise { + const pending = pendingSettingsCommand.catch(() => undefined).then(run); + pendingSettingsCommand = pending; + return pending; +} + const inFlightCommands = new Map< string, { @@ -40,6 +51,7 @@ const inFlightCommands = new Map< const isVersionConflict = (error: unknown) => error instanceof Error && (error.name === 'TimelineVersionConflict' || + error.name === 'ProjectSettingsVersionConflict' || error.name === 'ProjectSnapshotVersionConflict'); const getConflictRevision = (error: unknown) => { @@ -222,7 +234,10 @@ async function persistBeatDesignCommandOnce({ }; let result: BeatDesignCommandResult; - if (normalizedCommand.type === 'canvas.apply') { + if (normalizedCommand.type === 'project.update') { + const settings = await updateProjectSettings({ projectId, patch: normalizedCommand.patch, expectedRevision }); + result = createCommandSuccess({ commandId, projectId, origin, changedIds: [projectId], revision: settings.settingsRevision, data: { projectSettings: settings } }); + } else if (normalizedCommand.type === 'canvas.apply') { const state = await loadProjectWithLatestSnapshot({ projectId }); if (!state) { return createCommandFailure({ @@ -319,7 +334,9 @@ async function persistBeatDesignCommandOnce({ code: 'REVISION_CONFLICT', revision: getConflictRevision(error), message: - command.type === 'canvas.apply' + command.type === 'project.update' + ? 'Project settings changed. Reload before saving.' + : command.type === 'canvas.apply' ? 'Project snapshot version conflict' : 'Timeline version conflict', }); @@ -412,14 +429,15 @@ export function persistBeatDesignCommand({ return existing.promise; } - const promise = persistBeatDesignCommandOnce({ + const run = () => persistBeatDesignCommandOnce({ projectId, origin, commandId, expectedRevision, idempotencyKey: stableIdempotencyKey, command, - }).finally(() => { + }); + const promise = (command.type === 'project.update' ? enqueueSettingsCommand(run) : run()).finally(() => { if (inFlightCommands.get(lockKey)?.promise === promise) { inFlightCommands.delete(lockKey); } diff --git a/src/core/commands/schema.ts b/src/core/commands/schema.ts index aed5125..996a206 100644 --- a/src/core/commands/schema.ts +++ b/src/core/commands/schema.ts @@ -4,6 +4,7 @@ import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; import { normalizeTimelineDocument } from '@/core/editor/timeline-document'; import { repairCanvasCardInput } from './canvas-card-repair'; +import { projectSettingsPatchSchema } from '@/core/projects/project-settings-contract'; import type { BeatDesignCommand } from './executor'; const commandIdSchema = z.string().trim().min(1).max(200); @@ -443,6 +444,7 @@ export const editorOperationSchema = z.discriminatedUnion('type', [ export const beatDesignCommandSchema: z.ZodType = z.discriminatedUnion('type', [ + z.object({ type: z.literal('project.update'), patch: projectSettingsPatchSchema }).strict(), z .object({ type: z.literal('canvas.apply'), diff --git a/src/core/db/sqlite.ts b/src/core/db/sqlite.ts index 5025f71..a962f02 100644 --- a/src/core/db/sqlite.ts +++ b/src/core/db/sqlite.ts @@ -1,14 +1,71 @@ import { createClient } from '@libsql/client'; import { drizzle } from 'drizzle-orm/libsql'; +import { setTimeout as delay } from 'node:timers/promises'; import type { DbConfig } from './types'; // SQLite/libsql singleton let sqliteDbInstance: Promise> | null = null; -export async function configureLocalSqlite(client: ReturnType) { - await client.execute('PRAGMA busy_timeout = 5000'); - await client.execute('PRAGMA journal_mode = WAL'); +export async function configureLocalSqlite( + client: ReturnType, +) { + // Keep startup lock waits short: synchronous libsql busy waits would otherwise + // block another Web/MCP connection from finishing its migration in this process. + await client.execute('PRAGMA busy_timeout = 50'); + try { + for (let attempt = 0; attempt < 6; attempt++) { + try { + await client.execute('PRAGMA journal_mode = WAL'); + const columns = await client.execute('PRAGMA table_info(project)'); + const names = new Set(columns.rows.map((row) => String(row.name))); + if ( + !names.size || + ['description', 'settings_revision', 'cover_mode'].every((name) => + names.has(name), + ) + ) + break; + const tx = await client.transaction('write'); + try { + // Another process may have upgraded while this connection was waiting. + const latest = await tx.execute('PRAGMA table_info(project)'); + const existing = new Set(latest.rows.map((row) => String(row.name))); + if (!existing.has('description')) + await tx.execute( + "ALTER TABLE project ADD COLUMN description TEXT NOT NULL DEFAULT ''", + ); + if (!existing.has('settings_revision')) + await tx.execute( + 'ALTER TABLE project ADD COLUMN settings_revision INTEGER NOT NULL DEFAULT 1', + ); + if (!existing.has('cover_mode')) { + await tx.execute( + "ALTER TABLE project ADD COLUMN cover_mode TEXT NOT NULL DEFAULT 'manual'", + ); + await tx.execute( + "UPDATE project SET cover_mode = 'auto' WHERE cover_asset_id IS NULL", + ); + } + await tx.commit(); + } catch (error) { + await tx.rollback(); + throw error; + } + break; + } catch (error) { + if ( + (error as { code?: string }).code !== 'SQLITE_BUSY' || + attempt === 5 + ) + throw error; + await delay(50 * (attempt + 1)); + } + } + } finally { + // libsql can return a different pooled connection after a transaction. + await client.execute('PRAGMA busy_timeout = 5000'); + } } export async function createSqliteDb(config: DbConfig) { @@ -19,10 +76,12 @@ export async function createSqliteDb(config: DbConfig) { if (sqliteDbInstance) return sqliteDbInstance; const client = createClient({ url: databaseUrl }); - sqliteDbInstance = configureLocalSqlite(client).then(() => drizzle({ client })).catch((error) => { - client.close(); - sqliteDbInstance = null; - throw error; - }); + sqliteDbInstance = configureLocalSqlite(client) + .then(() => drizzle({ client })) + .catch((error) => { + client.close(); + sqliteDbInstance = null; + throw error; + }); return sqliteDbInstance; } diff --git a/src/core/media/video-thumbnail-cache.test.ts b/src/core/media/video-thumbnail-cache.test.ts new file mode 100644 index 0000000..0382063 --- /dev/null +++ b/src/core/media/video-thumbnail-cache.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { getCachedVideoThumbnail } from './video-thumbnail-cache'; + +const jpeg = Uint8Array.from([0xff, 0xd8, 1, 2, 0xff, 0xd9]); + +test('poster cache persists once, coalesces requests, and invalidates changed sources', async () => { + const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-')); + try { + const filePath = join(root, 'source.mp4'); + await writeFile(filePath, 'source'); + let calls = 0; + const render = async () => { calls++; return jpeg; }; + const options = { filePath, assetId: 'video-a', cacheRoot: join(root, 'cache'), render }; + const first = await Promise.all(Array.from({ length: 5 }, () => getCachedVideoThumbnail(options))); + assert.equal(calls, 1); + assert.equal(new Set(first.map((result) => result.key)).size, 1); + const reused = await getCachedVideoThumbnail({ ...options, render: async () => { throw new Error('must use disk cache'); } }); + assert.equal(reused.cached, true); + assert.deepEqual(reused.bytes, jpeg); + await writeFile(filePath, 'changed source content'); + const changed = await getCachedVideoThumbnail(options); + assert.notEqual(changed.key, first[0].key); + assert.equal(calls, 2); + assert.ok((await readdir(options.cacheRoot)).every((file) => file.endsWith('.jpg'))); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('failed thumbnail jobs do not poison the cache or leave in-flight work behind', async () => { + const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-fail-')); + try { + const filePath = join(root, 'source.mp4'); + await writeFile(filePath, 'source'); + const options = { filePath, assetId: 'video-a', cacheRoot: join(root, 'cache') }; + await assert.rejects(getCachedVideoThumbnail({ ...options, render: async () => { throw new Error('decoder unavailable'); } }), /decoder unavailable/); + const retry = await getCachedVideoThumbnail({ ...options, render: async () => jpeg }); + assert.deepEqual(retry.bytes, jpeg); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('poster decoding is limited to two jobs even for different videos', async () => { + const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-queue-')); + try { + const filePath = join(root, 'source.mp4'); + await writeFile(filePath, 'source'); + let active = 0; + let peak = 0; + const render = async () => { + peak = Math.max(peak, ++active); + await new Promise((done) => setTimeout(done, 20)); + active--; + return jpeg; + }; + await Promise.all(Array.from({ length: 5 }, (_, index) => getCachedVideoThumbnail({ filePath, assetId: `video-${index}`, cacheRoot: root, render }))); + assert.equal(peak, 2); + assert.equal(active, 0); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +const hasFfmpeg = spawnSync(process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; +test('real video decoder produces a small cached JPEG without ffprobe', { skip: !hasFfmpeg }, async () => { + const root = await mkdtemp(join(tmpdir(), 'beatdesign-posters-real-')); + try { + const poster = await getCachedVideoThumbnail({ filePath: resolve('tests/fixtures/tiny-black.mp4'), assetId: 'real-video', cacheRoot: root }); + assert.deepEqual(Array.from(poster.bytes.slice(0, 2)), [0xff, 0xd8]); + assert.ok(poster.bytes.length < 100_000); + assert.equal((await getCachedVideoThumbnail({ filePath: resolve('tests/fixtures/tiny-black.mp4'), assetId: 'real-video', cacheRoot: root })).cached, true); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/core/media/video-thumbnail-cache.ts b/src/core/media/video-thumbnail-cache.ts new file mode 100644 index 0000000..8798376 --- /dev/null +++ b/src/core/media/video-thumbnail-cache.ts @@ -0,0 +1,93 @@ +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getBeatDesignDataRoot } from '@/config/data-root'; + +const MAX_CONCURRENT_DECODES = 2; +const inFlight = new Map>(); +const waiting: Array<() => void> = []; +let activeDecodes = 0; + +async function withDecoderSlot(work: () => Promise) { + if (activeDecodes >= MAX_CONCURRENT_DECODES) { + await new Promise((resolve) => waiting.push(resolve)); + } else activeDecodes++; + try { return await work(); } + finally { + const next = waiting.shift(); + if (next) next(); + else activeDecodes--; + } +} + +/** Optional server decoder: lack of ffmpeg never blocks browser playback. */ +export function renderVideoThumbnail(filePath: string, timeSeconds: number) { + return new Promise((resolve, reject) => { + const child = spawn(process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg', [ + '-hide_banner', '-loglevel', 'error', '-nostdin', '-threads', '1', + '-protocol_whitelist', 'file,pipe', '-ss', String(timeSeconds), '-i', filePath, + '-map', '0:v:0', '-frames:v', '1', '-vf', + 'scale=512:512:force_original_aspect_ratio=decrease', + '-q:v', '4', '-f', 'image2pipe', '-c:v', 'mjpeg', 'pipe:1', + ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + const chunks: Buffer[] = []; + let length = 0; + let stderr = ''; + const timer = setTimeout(() => { + child.kill(); + reject(new Error('Video thumbnail decode timed out')); + }, 15_000); + child.stdout.on('data', (chunk: Buffer) => { + length += chunk.length; + if (length > 2 * 1024 * 1024) { + child.kill(); + reject(new Error('Video thumbnail is too large')); + } else chunks.push(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2000); }); + child.once('error', (error) => { clearTimeout(timer); reject(error); }); + child.once('close', (code) => { + clearTimeout(timer); + const bytes = Buffer.concat(chunks); + if (code === 0 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes.at(-2) === 0xff && bytes.at(-1) === 0xd9) resolve(bytes); + else reject(new Error(stderr || 'Video thumbnail decode failed')); + }); + }); +} + +/** A disposable derived-file cache; never changes a Project or its original Asset. */ +export async function getCachedVideoThumbnail({ + filePath, assetId, durationMs, cacheRoot = join(getBeatDesignDataRoot(), 'cache', 'video-posters'), + render = renderVideoThumbnail, +}: { + filePath: string; + assetId: string; + durationMs?: number | null; + cacheRoot?: string; + render?: typeof renderVideoThumbnail; +}) { + const source = await stat(filePath); + if (!source.isFile()) throw new Error('Video file is missing'); + const key = createHash('sha256').update(JSON.stringify(['poster-v1', assetId, filePath, source.size, source.mtimeMs, durationMs ?? null])).digest('hex'); + const target = join(cacheRoot, `${key}.jpg`); + try { return { bytes: new Uint8Array(await readFile(target)), key, cached: true }; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + let pending = inFlight.get(target); + if (!pending) { + pending = withDecoderSlot(async () => { + const seconds = durationMs && durationMs > 0 ? Math.min(1, Math.max(0, durationMs / 1000 - 0.05)) : 0; + const bytes = await render(filePath, seconds); + await mkdir(cacheRoot, { recursive: true }); + const temporary = `${target}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, bytes, { flag: 'wx' }); + await rename(temporary, target); + } finally { await unlink(temporary).catch(() => undefined); } + return bytes; + }); + inFlight.set(target, pending); + } + try { return { bytes: await pending, key, cached: false }; } + finally { if (inFlight.get(target) === pending) inFlight.delete(target); } +} diff --git a/src/core/projects/project-entry.ts b/src/core/projects/project-entry.ts index cc48fd9..d38f6dc 100644 --- a/src/core/projects/project-entry.ts +++ b/src/core/projects/project-entry.ts @@ -150,6 +150,7 @@ export function serializeProjectCenterCard(project: { lastOpenedAt?: Date | null; updatedAt: Date; coverImageUrl?: string | null; + description?: string; }) { const activityAt = getProjectActivityDate(project); @@ -160,5 +161,6 @@ export function serializeProjectCenterCard(project: { activityAt: activityAt.toISOString(), activityLabel: formatProjectActivityDate(activityAt), coverImageUrl: project.coverImageUrl ?? null, + description: project.description ?? '', }; } diff --git a/src/core/projects/project-settings-contract.ts b/src/core/projects/project-settings-contract.ts new file mode 100644 index 0000000..38ab657 --- /dev/null +++ b/src/core/projects/project-settings-contract.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const projectSettingsPatchSchema = z + .object({ + name: z.string().trim().min(1).max(240).optional(), + description: z.string().trim().max(140).optional(), + cover: z + .discriminatedUnion('mode', [ + z.object({ mode: z.literal('auto') }).strict(), + z + .object({ + mode: z.literal('manual'), + assetId: z.string().trim().min(1).max(200), + }) + .strict(), + ]) + .optional(), + }) + .strict() + .refine( + (value) => Object.keys(value).length > 0, + 'Include at least one setting.', + ); + +export type ProjectSettingsPatch = z.infer; +export type ProjectSettings = { + id: string; + name: string; + description: string; + coverMode: 'auto' | 'manual'; + coverAssetId: string | null; + coverImageUrl: string | null; + automaticCoverImageUrl: string | null; + settingsRevision: number; + createdAt: string; +}; diff --git a/src/core/projects/project-settings.test.ts b/src/core/projects/project-settings.test.ts new file mode 100644 index 0000000..32dc631 --- /dev/null +++ b/src/core/projects/project-settings.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import test from 'node:test'; +import { createClient } from '@libsql/client'; +import { configureLocalSqlite } from '@/core/db/sqlite'; +import { projectSettingsPatchSchema } from './project-settings-contract'; + +test('settings validation rejects empty updates, blank names, long descriptions and arbitrary URLs', () => { + for (const patch of [ + {}, + { name: ' ' }, + { description: 'a'.repeat(141) }, + { cover: { mode: 'manual', url: 'https://example.com' } }, + { slug: 'new' }, + ]) { + assert.equal(projectSettingsPatchSchema.safeParse(patch).success, false); + } + assert.deepEqual( + projectSettingsPatchSchema.parse({ description: ' Film ' }), + { description: 'Film' }, + ); +}); + +test('additive SQLite upgrade preserves legacy covers and is repeatable', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'beatdesign-settings-migration-'), + ); + const client = createClient({ url: `file:${directory}/migration.db` }); + try { + await client.execute( + 'CREATE TABLE project (id TEXT PRIMARY KEY, cover_asset_id TEXT)', + ); + await client.execute( + "INSERT INTO project VALUES ('legacy', 'poster'), ('empty', NULL)", + ); + const second = createClient({ url: `file:${directory}/migration.db` }); + try { + await Promise.all([ + configureLocalSqlite(client), + configureLocalSqlite(second), + ]); + } finally { + second.close(); + } + await configureLocalSqlite(client); + const rows = (await client.execute('SELECT * FROM project ORDER BY id')) + .rows; + assert.equal(rows[0].cover_mode, 'auto'); + assert.equal(rows[1].cover_mode, 'manual'); + assert.equal(rows[1].cover_asset_id, 'poster'); + assert.equal(rows[1].settings_revision, 1); + assert.equal(rows[1].description, ''); + } finally { + client.close(); + } +}); + +test('project settings persist through the command kernel, lock covers and reject stale or foreign writes', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'beatdesign-project-settings-'), + ); + process.env.BEATDESIGN_DATA_DIR = directory; + execFileSync( + process.execPath, + ['node_modules/drizzle-kit/bin.cjs', 'push', '--force'], + { env: process.env, stdio: 'pipe' }, + ); + const { createProject, loadProjects, deleteProjects } = await import( + './projects' + ); + const { getProjectSettings } = await import('./project-settings'); + const { persistBeatDesignCommand } = await import('@/core/commands/persist'); + const { recordUserAsset, linkProjectAsset, deleteUserAssetById } = + await import('@/core/workspace-lib/assets/user-assets'); + const first = await createProject({ name: 'Original' }); + const other = await createProject({ name: 'Other' }); + async function asset( + id: string, + projectId = first.id, + type: 'image' | 'video' = 'image', + ) { + await recordUserAsset({ + id, + type, + source: 'upload', + bucket: 'local-project-assets', + objectKey: id, + publicUrl: `/media/${id}`, + filename: `${id}.png`, + }); + await linkProjectAsset({ projectId, assetId: id, role: 'upload' }); + return id; + } + const update = (patch: any, revision: number, commandId: string) => + persistBeatDesignCommand({ + projectId: first.id, + origin: 'mcp', + commandId, + expectedRevision: revision, + command: { type: 'project.update', patch }, + }); + await asset('a-first'); + await asset('b-poster'); + assert.equal((await getProjectSettings(first.id))?.coverAssetId, 'a-first'); + const saved = await update( + { + name: 'Film', + description: 'A short film', + cover: { mode: 'manual', assetId: 'b-poster' }, + }, + 1, + 'save-settings', + ); + assert.equal(saved.ok, true); + assert.equal(saved.revision, 2); + assert.deepEqual( + await update( + { + name: 'Film', + description: 'A short film', + cover: { mode: 'manual', assetId: 'b-poster' }, + }, + 1, + 'save-settings', + ), + saved, + ); + await asset('c-later'); + await linkProjectAsset({ + projectId: first.id, + assetId: 'c-later', + role: 'generated', + }); + assert.equal((await getProjectSettings(first.id))?.coverAssetId, 'b-poster'); + const stale = await update({ name: 'Lost update' }, 1, 'stale-save'); + assert.equal(stale.ok, false); + if (!stale.ok) assert.equal(stale.code, 'REVISION_CONFLICT'); + await asset('foreign', other.id); + await asset('video', first.id, 'video'); + for (const id of ['foreign', 'video', 'missing']) { + const invalid = await update( + { cover: { mode: 'manual', assetId: id } }, + 2, + `invalid-${id}`, + ); + assert.equal(invalid.ok, false); + if (!invalid.ok) assert.equal(invalid.code, 'INVALID_COMMAND'); + } + assert.equal((await getProjectSettings(first.id))?.settingsRevision, 2); + assert.equal( + (await update({ cover: { mode: 'auto' } }, 2, 'auto-cover')).ok, + true, + ); + assert.equal((await getProjectSettings(first.id))?.coverAssetId, 'a-first'); + assert.equal( + ( + await update( + { cover: { mode: 'manual', assetId: 'b-poster' } }, + 3, + 'manual-again', + ) + ).ok, + true, + ); + await deleteUserAssetById('b-poster'); + const fallback = await getProjectSettings(first.id); + assert.equal(fallback?.coverMode, 'auto'); + assert.equal(fallback?.coverAssetId, 'a-first'); + const listed = (await loadProjects({})).find( + (item: any) => item.id === first.id, + ); + assert.equal(listed?.coverImageUrl, '/media/a-first'); + assert.equal(listed?.description, 'A short film'); + assert.equal(listed?.name, 'Film'); + const concurrent = await Promise.all([ + update({ description: 'Concurrent A' }, 4, 'concurrent-a'), + update({ description: 'Concurrent B' }, 4, 'concurrent-b'), + ]); + assert.equal(concurrent.filter((result) => result.ok).length, 1); + const rejected = concurrent.find((result) => !result.ok); + assert.equal( + rejected && !rejected.ok ? rejected.code : null, + 'REVISION_CONFLICT', + ); + await deleteProjects({ projectIds: [first.id] }); + assert.equal(await getProjectSettings(first.id), null); + const deleted = await update({ name: 'Restore' }, 4, 'deleted-save'); + assert.equal(deleted.ok, false); + if (!deleted.ok) assert.equal(deleted.code, 'NOT_FOUND'); +}); diff --git a/src/core/projects/project-settings.ts b/src/core/projects/project-settings.ts new file mode 100644 index 0000000..99efe8c --- /dev/null +++ b/src/core/projects/project-settings.ts @@ -0,0 +1,201 @@ +import { and, asc, eq, inArray, or, sql } from 'drizzle-orm'; +import { project, projectAssetMembership, userAsset } from '@/config/db/schema'; +import { getDb } from '@/core/workspace-lib/db-adapter'; +import { BeatDesignCommandError } from '@/core/commands/contracts'; +import { + projectSettingsPatchSchema, + type ProjectSettings, +} from './project-settings-contract'; +import { LOCAL_PROJECT_ASSET_PROVIDER } from './local-project-assets'; + +export async function resolveProjectCover( + db: any, + row: { id: string; coverMode: string; coverAssetId: string | null }, +) { + if (row.coverMode === 'manual' && row.coverAssetId) { + const [asset] = await db + .select({ id: userAsset.id, publicUrl: userAsset.publicUrl }) + .from(userAsset) + .innerJoin( + projectAssetMembership, + eq(projectAssetMembership.assetId, userAsset.id), + ) + .where( + and( + eq(projectAssetMembership.projectId, row.id), + eq(userAsset.id, row.coverAssetId), + eq(userAsset.type, 'image'), + ), + ) + .limit(1); + if (asset) return { ...asset, mode: 'manual' as const }; + } + // Images first, then videos. Stable order never changes on re-link or generation completion. + const [asset] = await db + .select({ + id: userAsset.id, + publicUrl: userAsset.publicUrl, + type: userAsset.type, + storageProvider: userAsset.storageProvider, + }) + .from(userAsset) + .innerJoin( + projectAssetMembership, + eq(projectAssetMembership.assetId, userAsset.id), + ) + .where( + and( + eq(projectAssetMembership.projectId, row.id), + inArray(projectAssetMembership.category, [ + 'upload', + 'generated', + 'cover', + ]), + or( + eq(userAsset.type, 'image'), + and( + eq(userAsset.type, 'video'), + eq(userAsset.storageProvider, LOCAL_PROJECT_ASSET_PROVIDER), + ), + ), + ), + ) + .orderBy( + sql`case when ${userAsset.type} = 'image' then 0 else 1 end`, + asc(userAsset.createdAt), + asc(userAsset.id), + ) + .limit(1); + if (!asset) return null; + return { + id: asset.id, + mode: 'auto' as const, + publicUrl: + asset.type === 'image' + ? asset.publicUrl + : asset.storageProvider === LOCAL_PROJECT_ASSET_PROVIDER + ? `/api/app/projects/${encodeURIComponent(row.id)}/assets/${encodeURIComponent(asset.id)}/thumbnail` + : null, + }; +} + +export async function getProjectSettings( + projectId: string, + database?: any, +): Promise { + const db = database ?? (await getDb()); + const [row] = await db + .select() + .from(project) + .where(and(eq(project.id, projectId), eq(project.status, 'active'))) + .limit(1); + if (!row) return null; + const automaticCover = await resolveProjectCover(db, { + ...row, + coverMode: 'auto', + }); + const cover = + row.coverMode === 'manual' + ? await resolveProjectCover(db, row) + : automaticCover; + return { + id: row.id, + name: row.name, + description: row.description, + coverMode: cover?.mode ?? 'auto', + coverAssetId: cover?.id ?? null, + coverImageUrl: cover?.publicUrl ?? null, + automaticCoverImageUrl: automaticCover?.publicUrl ?? null, + settingsRevision: row.settingsRevision, + createdAt: row.createdAt.toISOString(), + }; +} + +export async function updateProjectSettings({ + projectId, + patch, + expectedRevision, +}: { + projectId: string; + patch: unknown; + expectedRevision?: number | null; +}): Promise { + const parsed = projectSettingsPatchSchema.safeParse(patch); + if (!parsed.success) + throw new BeatDesignCommandError( + 'INVALID_COMMAND', + parsed.error.issues[0]?.message ?? 'Invalid settings.', + ); + if (!Number.isInteger(expectedRevision) || (expectedRevision ?? 0) < 1) + throw new BeatDesignCommandError( + 'INVALID_COMMAND', + 'Project settings require an expectedRevision from the latest settings.', + ); + const db = await getDb(); + return db.transaction(async (tx: any) => { + const current = await getProjectSettings(projectId, tx); + if (!current) + throw new BeatDesignCommandError('NOT_FOUND', 'Project not found'); + if (current.settingsRevision !== expectedRevision) { + const error = new Error( + 'Project settings changed. Reload before saving.', + ); + error.name = 'ProjectSettingsVersionConflict'; + Object.assign(error, { currentVersion: current.settingsRevision }); + throw error; + } + const next = parsed.data; + if (next.cover?.mode === 'manual') { + const [asset] = await tx + .select({ type: userAsset.type }) + .from(userAsset) + .innerJoin( + projectAssetMembership, + eq(projectAssetMembership.assetId, userAsset.id), + ) + .where( + and( + eq(projectAssetMembership.projectId, projectId), + eq(userAsset.id, next.cover.assetId), + ), + ) + .limit(1); + if (!asset || asset.type !== 'image') + throw new BeatDesignCommandError( + 'INVALID_COMMAND', + 'Cover must be an image belonging to this project.', + ); + } + const changed = await tx + .update(project) + .set({ + ...(next.name !== undefined ? { name: next.name } : {}), + ...(next.description !== undefined + ? { description: next.description } + : {}), + ...(next.cover + ? { + coverMode: next.cover.mode, + coverAssetId: + next.cover.mode === 'manual' ? next.cover.assetId : null, + } + : {}), + settingsRevision: current.settingsRevision + 1, + updatedAt: new Date(), + }) + .where( + and( + eq(project.id, projectId), + eq(project.settingsRevision, expectedRevision as number), + eq(project.status, 'active'), + ), + ) + .returning({ id: project.id }); + if (!changed.length) + throw new BeatDesignCommandError( + 'REVISION_CONFLICT', + 'Project settings changed. Reload before saving.', + ); + return (await getProjectSettings(projectId, tx))!; + }); +} diff --git a/src/core/projects/project-video-thumbnail.ts b/src/core/projects/project-video-thumbnail.ts new file mode 100644 index 0000000..a802d19 --- /dev/null +++ b/src/core/projects/project-video-thumbnail.ts @@ -0,0 +1,27 @@ +import { getCachedVideoThumbnail } from '@/core/media/video-thumbnail-cache'; +import { getProjectAssetById } from '@/core/workspace-lib/assets/user-assets'; +import { LOCAL_PROJECT_ASSET_BUCKET, LOCAL_PROJECT_ASSET_PROVIDER, resolveLocalProjectAssetPath } from './local-project-assets'; + +export async function getProjectVideoThumbnailResponse(request: Request, params: { projectId: string; assetId: string }) { + // Recheck project membership even when an image is already cached on disk. + const asset = await getProjectAssetById(params); + if (!asset || asset.type !== 'video' || asset.bucket !== LOCAL_PROJECT_ASSET_BUCKET || asset.storageProvider !== LOCAL_PROJECT_ASSET_PROVIDER) { + return new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } }); + } + try { + const thumbnail = await getCachedVideoThumbnail({ + filePath: resolveLocalProjectAssetPath({ objectKey: asset.objectKey }), + assetId: asset.id, durationMs: asset.durationMs, + }); + const headers = new Headers({ + 'content-type': 'image/jpeg', 'cache-control': 'private, max-age=86400', + 'etag': `"${thumbnail.key}"`, 'x-content-type-options': 'nosniff', + }); + if (request.headers.get('if-none-match') === headers.get('etag')) return new Response(null, { status: 304, headers }); + headers.set('content-length', String(thumbnail.bytes.byteLength)); + return new Response(thumbnail.bytes as BodyInit, { headers }); + } catch { + // Show the poster placeholder; never redirect to or return the original video. + return new Response(null, { status: 503, headers: { 'cache-control': 'no-store', 'retry-after': '60' } }); + } +} diff --git a/src/core/projects/projects.ts b/src/core/projects/projects.ts index 40132f2..3556419 100644 --- a/src/core/projects/projects.ts +++ b/src/core/projects/projects.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'crypto'; +import { resolveProjectCover } from './project-settings'; import { getDb } from '@/core/workspace-lib/db-adapter'; -import { project, projectCanvasState, userAsset } from '@/config/db/schema'; +import { project, projectCanvasState } from '@/config/db/schema'; import { type ProjectSnapshotDocument, createEmptyProjectSnapshot, @@ -9,7 +10,7 @@ import { isDestructiveEmptyProjectSnapshot, normalizeProjectSnapshotDocument, } from '@/core/projects/project-snapshot'; -import { and, desc, eq, getTableColumns } from 'drizzle-orm'; +import { and, desc, eq } from 'drizzle-orm'; import { defaultWorkspaceMode, resolveWorkspaceMode, @@ -41,6 +42,7 @@ export const createProject = async ({ id, name: trimProjectName(name), coverAssetId: null, + coverMode: 'auto', status: 'active', currentStateVersion: 1, lastWorkspaceMode: resolveWorkspaceMode(workspaceMode), @@ -70,12 +72,8 @@ export const loadProjects = async ({ }) => { const db = await getDb(); const projects = await db - .select({ - ...getTableColumns(project), - coverImageUrl: userAsset.publicUrl, - }) + .select() .from(project) - .leftJoin(userAsset, eq(project.coverAssetId, userAsset.id)) .where(eq(project.status, 'active')) .orderBy( desc(project.lastOpenedAt), @@ -84,10 +82,10 @@ export const loadProjects = async ({ ) .limit(limit); - return projects.map((entry: any) => ({ + return Promise.all(projects.map(async (entry: any) => ({ ...entry, - coverImageUrl: entry.coverImageUrl?.trim() || null, - })); + coverImageUrl: (await resolveProjectCover(db, entry))?.publicUrl ?? null, + }))); }; export const getProject = async ({ diff --git a/src/core/workspace-hooks/use-workspace-projects.ts b/src/core/workspace-hooks/use-workspace-projects.ts index 2228bb4..ab9b363 100644 --- a/src/core/workspace-hooks/use-workspace-projects.ts +++ b/src/core/workspace-hooks/use-workspace-projects.ts @@ -30,6 +30,8 @@ export function workspaceProjectsQueryOptions({ enabled, initialData: initialProjects, staleTime: 30 * 1000, + refetchInterval: 5000, + refetchIntervalInBackground: false, }); } diff --git a/src/core/workspace-lib/app/project-settings-client.ts b/src/core/workspace-lib/app/project-settings-client.ts new file mode 100644 index 0000000..1047077 --- /dev/null +++ b/src/core/workspace-lib/app/project-settings-client.ts @@ -0,0 +1,30 @@ +import { apiJsonGet, apiJsonPatch } from '@/lib/api-client'; +import type { BeatDesignCommandResult } from '@/core/commands/contracts'; +import type { + ProjectSettings, + ProjectSettingsPatch, +} from '@/core/projects/project-settings-contract'; + +export async function fetchProjectSettings(projectId: string) { + return ( + await apiJsonGet<{ settings: ProjectSettings }>( + `/api/app/projects/${encodeURIComponent(projectId)}`, + ) + ).settings; +} + +export async function saveProjectSettings( + projectId: string, + expectedRevision: number, + patch: ProjectSettingsPatch, +) { + const result = await apiJsonPatch< + BeatDesignCommandResult<{ projectSettings: ProjectSettings }> + >(`/api/app/projects/${encodeURIComponent(projectId)}`, { + expectedRevision, + patch, + commandId: crypto.randomUUID(), + }); + if (!result.ok) throw new Error(result.message); + return result.data.projectSettings; +} diff --git a/src/core/workspace-lib/app/workspace-client-api.ts b/src/core/workspace-lib/app/workspace-client-api.ts index c4bb41c..e58993c 100644 --- a/src/core/workspace-lib/app/workspace-client-api.ts +++ b/src/core/workspace-lib/app/workspace-client-api.ts @@ -9,6 +9,7 @@ export type WorkspaceProjectCardItem = { activityAt: string; activityLabel: string; coverImageUrl?: string | null; + description?: string; }; export type RecentAsset = { diff --git a/src/core/workspace-lib/assets/user-assets.ts b/src/core/workspace-lib/assets/user-assets.ts index 5243033..e99c5de 100644 --- a/src/core/workspace-lib/assets/user-assets.ts +++ b/src/core/workspace-lib/assets/user-assets.ts @@ -15,36 +15,6 @@ export type AssetSource = 'upload' | 'provider' | 'derived'; export type AssetRole = 'input' | 'output' | 'thumbnail' | 'reference'; export type ProjectAssetRole = 'upload' | 'reference' | 'generated' | 'cover'; -function shouldSyncProjectCoverAsset({ - role, - assetType, -}: { - role: ProjectAssetRole; - assetType: AssetType; -}) { - return ( - assetType === 'image' && - (role === 'cover' || role === 'generated' || role === 'upload') - ); -} - -async function syncProjectCoverAsset({ - projectId, - assetId, -}: { - projectId: string; - assetId: string; -}) { - const db = await getDb(); - await db - .update(project) - .set({ - coverAssetId: assetId, - updatedAt: new Date(), - }) - .where(eq(project.id, projectId)); -} - export const recordUserAsset = async ({ id: requestedId, type, @@ -379,14 +349,6 @@ export const linkProjectAsset = async ({ .limit(1); if (existing[0]?.id) { - if ( - shouldSyncProjectCoverAsset({ - role, - assetType: targetAsset.type as AssetType, - }) - ) { - await syncProjectCoverAsset({ projectId, assetId }); - } return existing[0].id; } @@ -405,14 +367,5 @@ export const linkProjectAsset = async ({ createdAt: new Date(), }); - if ( - shouldSyncProjectCoverAsset({ - role, - assetType: targetAsset.type as AssetType, - }) - ) { - await syncProjectCoverAsset({ projectId, assetId }); - } - return id; }; diff --git a/src/mcp/server.runtime.test.ts b/src/mcp/server.runtime.test.ts index f9dc3b1..9a851a0 100644 --- a/src/mcp/server.runtime.test.ts +++ b/src/mcp/server.runtime.test.ts @@ -14,12 +14,12 @@ import { listGenerationModelDescriptors } from '@/core/generation-providers'; import { BEATDESIGN_MCP_TOOL_NAMES } from './tools'; -test('MCP tool catalog is exactly 29 named tools', () => { +test('MCP tool catalog is exactly 30 named tools', () => { const source = readFileSync(new URL('./server.ts', import.meta.url), 'utf8'); const registered = [...source.matchAll(/server\.registerTool\(\s*'([^']+)'/g)].map( (match) => match[1] ); - assert.equal(BEATDESIGN_MCP_TOOL_NAMES.length, 29); + assert.equal(BEATDESIGN_MCP_TOOL_NAMES.length, 30); assert.deepEqual(registered, [...BEATDESIGN_MCP_TOOL_NAMES]); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0178e69..4b62a2b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,6 +22,9 @@ import { extractProjectVideoFrame } from '@/core/projects/extract-project-video- import { importLocalProjectAsset } from '@/core/projects/import-local-asset'; import { parseLocalProjectAssetUrl } from '@/core/projects/local-project-asset-url'; import { createCommandId } from '@/core/commands/contracts'; +import { getProjectSettings } from '@/core/projects/project-settings'; +import { projectSettingsPatchSchema } from '@/core/projects/project-settings-contract'; +import { persistBeatDesignCommand } from '@/core/commands/persist'; import { listCommandReceipts } from '@/core/commands/receipts'; import { findCaptionAtTime, @@ -551,6 +554,7 @@ export function createBeatDesignMcpServer() { if (!canvas) throw new Error('Project not found.'); return { project: canvas.project, + settings: await getProjectSettings(scopedProjectId), canvas: { ...canvas, snapshot: { @@ -564,6 +568,19 @@ export function createBeatDesignMcpServer() { }) ); + server.registerTool( + 'bdesign_project_update', + { + description: 'Update project name, short description or cover through the shared revision-checked command kernel. Get settingsRevision from project_get; manual coverAssetId must belong to the project. Auto cover uses the first image, then a local video poster.', + inputSchema: z.object({ projectId: idSchema.optional(), expectedRevision: z.number().int().min(1), patch: projectSettingsPatchSchema, commandId: idSchema.optional(), idempotencyKey: idSchema.optional() }), + annotations: { destructiveHint: false, idempotentHint: true }, + }, + withToolErrors(async ({ projectId, expectedRevision, patch, commandId, idempotencyKey }) => { + const project = await resolveScopedProject(projectId); + return persistBeatDesignCommand({ projectId: project.id, origin: 'mcp', expectedRevision, commandId, idempotencyKey, command: { type: 'project.update', patch } }); + }) + ); + server.registerTool( 'bdesign_project_create', { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 02f6952..4d7ab98 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3,6 +3,7 @@ export const BEATDESIGN_MCP_TOOL_NAMES = [ 'bdesign_skill_get', 'bdesign_project_list', 'bdesign_project_get', + 'bdesign_project_update', 'bdesign_project_create', 'bdesign_project_target', 'bdesign_project_open', diff --git a/src/routes/api/app/projects/$projectId.ts b/src/routes/api/app/projects/$projectId.ts index 34edc0a..950abf3 100644 --- a/src/routes/api/app/projects/$projectId.ts +++ b/src/routes/api/app/projects/$projectId.ts @@ -6,8 +6,11 @@ import { import { getProject, markProjectOpened, - renameProject, } from '@/core/projects/projects'; +import { getProjectSettings } from '@/core/projects/project-settings'; +import { persistBeatDesignCommand } from '@/core/commands/persist'; +import { projectSettingsPatchSchema } from '@/core/projects/project-settings-contract'; +import { z } from 'zod'; import { validateTrustedWorkspaceJsonMutation } from '@/lib/trusted-local-request'; import { MAX_WORKSPACE_JSON_REQUEST_BYTES, @@ -15,11 +18,6 @@ import { RequestBodyTooLargeError, } from '@/lib/request-body-limit'; -type UpdateProjectRequest = { - name?: string; - workspaceMode?: string; -}; - type OpenProjectRequest = { workspaceMode?: string; }; @@ -89,37 +87,23 @@ async function PATCH({ return Response.json({ error: 'Project not found' }, { status: 404 }); } - let payload: UpdateProjectRequest | null = null; + let payload: unknown; try { - payload = await readRequestJsonWithLimit( - request, - MAX_WORKSPACE_JSON_REQUEST_BYTES - ); + payload = await readRequestJsonWithLimit(request, MAX_WORKSPACE_JSON_REQUEST_BYTES); } catch (error) { - if (error instanceof RequestBodyTooLargeError) { - return Response.json({ error: 'Request body is too large' }, { status: 413 }); - } - return Response.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const nextName = payload?.name?.trim(); - if (!nextName) { - return Response.json( - { error: 'Project name is required' }, - { status: 400 } - ); + return Response.json({ error: error instanceof RequestBodyTooLargeError ? 'Request body is too large' : 'Invalid JSON' }, { status: error instanceof RequestBodyTooLargeError ? 413 : 400 }); } + const parsed = z.object({ patch: projectSettingsPatchSchema, expectedRevision: z.number().int().min(1), commandId: z.string().min(1).max(200).optional() }).strict().safeParse(payload); + if (!parsed.success) return Response.json({ error: parsed.error.issues[0]?.message ?? 'Invalid settings' }, { status: 400 }); + const result = await persistBeatDesignCommand({ projectId, origin: 'ui', expectedRevision: parsed.data.expectedRevision, commandId: parsed.data.commandId, command: { type: 'project.update', patch: parsed.data.patch } }); + return Response.json(result, { status: result.ok ? 200 : result.code === 'REVISION_CONFLICT' ? 409 : result.code === 'NOT_FOUND' ? 404 : 400 }); +} - const name = await renameProject({ - projectId, - name: nextName, - }); - - return Response.json({ id: projectId, name }); +async function GET({ params }: { params: { projectId: string } }) { + const settings = await getProjectSettings(params.projectId); + return settings ? Response.json({ settings }) : Response.json({ error: 'Project not found' }, { status: 404 }); } export const Route = createFileRoute('/api/app/projects/$projectId')({ - server: { - handlers: { POST, PATCH }, - }, + server: { handlers: { GET, POST, PATCH } }, }); diff --git a/src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts b/src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts new file mode 100644 index 0000000..8cd3882 --- /dev/null +++ b/src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { getProjectVideoThumbnailResponse } from '@/core/projects/project-video-thumbnail'; + +export const Route = createFileRoute('/api/app/projects/$projectId/assets/$assetId/thumbnail')({ + server: { handlers: { GET: ({ request, params }) => getProjectVideoThumbnailResponse(request, params) } }, +}); diff --git a/tests/fixtures/tiny-black.mp4 b/tests/fixtures/tiny-black.mp4 new file mode 100644 index 0000000..a20c060 Binary files /dev/null and b/tests/fixtures/tiny-black.mp4 differ
{t('loading')}
+ {description.length}/140 +
+ {t('coverHint')} +
+ {autoCover ? t('automatic') : t('manual')} +
{t('choose')}
{project.description}