From c11b7e32a5cdc8734885f09c7fbc7aa34999d08d Mon Sep 17 00:00:00 2001 From: JaysonAlbert <6207775+JaysonAlbert@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:51:15 +0800 Subject: [PATCH 1/8] fix: create projects directly from the workspace choice --- docs/PRODUCT_PLAN_AND_STATUS.md | 2 + messages/en.json | 4 +- messages/ja.json | 4 +- messages/zh.json | 4 +- .../app/create-project-route-page.test.ts | 47 +++++++- .../app/create-project-route-page.tsx | 88 ++++----------- src/components/home/workspace-page.test.ts | 5 +- src/components/home/workspace-page.tsx | 102 +++++++++++++----- .../workspace-hooks/use-create-project.ts | 65 +++++++++++ .../workspace-lib/app/workspace-client-api.ts | 12 ++- tests/e2e/project-creation.spec.ts | 93 ++++++++++++++++ 11 files changed, 327 insertions(+), 99 deletions(-) create mode 100644 src/core/workspace-hooks/use-create-project.ts create mode 100644 tests/e2e/project-creation.spec.ts diff --git a/docs/PRODUCT_PLAN_AND_STATUS.md b/docs/PRODUCT_PLAN_AND_STATUS.md index 367f27c..163c3f1 100644 --- a/docs/PRODUCT_PLAN_AND_STATUS.md +++ b/docs/PRODUCT_PLAN_AND_STATUS.md @@ -97,6 +97,8 @@ Codex / Claude Code / Other Agent ### Project / Local-first - 项目创建、打开、重命名和本地 SQLite 持久化。 +- 项目列表中选择 Studio 或 Canvas 后直接创建并进入对应视图,创建期间防止重复点击,失败重试保留原选择。 +- 直接访问 `/studio` 或 `/canvas` 时只提供对应视图的创建按钮,访问本身不创建项目;创建后保留语言、模型和提示词入口参数。 - 界面支持英语、中文和日语,并可在主页、项目列表及工作区顶栏切换;英文使用无前缀路径,中文和日语分别使用 `/zh` 与 `/ja`。 - Canvas Snapshot 自动保存、页面隐藏时刷新和版本冲突保护。 - 本地上传图片、视频、音频后立即复制到项目目录。 diff --git a/messages/en.json b/messages/en.json index 3ca8c74..af811a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -745,7 +745,9 @@ "retry": "Try again", "backToHistory": "Back to history", "startTitle": "Start a new project", - "startDescription": "Choose Studio or Canvas to create this project.", + "startDescription": "Create your project here. You can switch between Studio and Canvas at any time.", + "createStudio": "Create in Studio", + "createCanvas": "Create in Canvas", "startStudio": "Studio", "startCanvas": "Canvas", "defaultName": "Untitled project" diff --git a/messages/ja.json b/messages/ja.json index 6a05122..2444b39 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -745,7 +745,9 @@ "retry": "もう一度試す", "backToHistory": "履歴に戻る", "startTitle": "新しいプロジェクトを始める", - "startDescription": "StudioまたはCanvasを選択してプロジェクトを作成します。", + "startDescription": "ここでプロジェクトを作成します。StudioとCanvasはいつでも切り替えられます。", + "createStudio": "Studioで作成", + "createCanvas": "Canvasで作成", "startStudio": "Studio", "startCanvas": "Canvas", "defaultName": "無題のプロジェクト" diff --git a/messages/zh.json b/messages/zh.json index 1ae6210..bef98a6 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -745,7 +745,9 @@ "retry": "重试", "backToHistory": "返回历史", "startTitle": "开始新项目", - "startDescription": "选择创作台或画布来创建这个项目。", + "startDescription": "创建项目后即可开始,创作台和画布可随时切换。", + "createStudio": "创建并进入创作台", + "createCanvas": "创建并进入画布", "startStudio": "创作台", "startCanvas": "画布", "defaultName": "未命名项目" diff --git a/src/components/app/create-project-route-page.test.ts b/src/components/app/create-project-route-page.test.ts index 94bf924..c47bf6a 100644 --- a/src/components/app/create-project-route-page.test.ts +++ b/src/components/app/create-project-route-page.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CreateProjectRoutePage } from './create-project-route-page'; const source = readFileSync( new URL('./create-project-route-page.tsx', import.meta.url), @@ -9,10 +12,48 @@ const source = readFileSync( test('visiting Studio or Canvas does not create a project automatically', () => { assert.doesNotMatch(source, /useEffect/); - assert.match(source, /createProject\('studio'\)/); - assert.match(source, /createProject\('canvas'\)/); + const hook = readFileSync( + new URL( + '../../core/workspace-hooks/use-create-project.ts', + import.meta.url + ), + 'utf8' + ); + assert.doesNotMatch(hook, /useEffect/); + assert.match( + source, + /onClick=\{\(\) => void createProject\(workspaceMode\)\}/ + ); assert.match(source, /appConfig\.app_logo/); - assert.match(source, /'\/api\/app\/projects'/); +}); + +test('direct entry only offers the workspace selected by its route', () => { + for (const workspaceMode of ['studio', 'canvas'] as const) { + const html = renderToStaticMarkup( + createElement(CreateProjectRoutePage, { + data: { + locale: 'en', + target: null, + model: null, + prompt: null, + projectName: 'Entry test', + workspaceMode, + }, + }) + ); + assert.equal((html.match(/ { diff --git a/src/components/app/create-project-route-page.tsx b/src/components/app/create-project-route-page.tsx index a03c53d..5548127 100644 --- a/src/components/app/create-project-route-page.tsx +++ b/src/components/app/create-project-route-page.tsx @@ -1,20 +1,12 @@ -import { useState } from 'react'; import { AlertCircleIcon, Loader2Icon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { appConfig } from '@/config'; import { normalizeLocale } from '@/config/locale'; import type { WorkspaceMode } from '@/config/workspace-modes'; -import { buildPostCreateProjectDetailPath } from '@/core/projects/project-entry'; -import { apiJsonPost } from '@/lib/api-client'; +import { useCreateProject } from '@/core/workspace-hooks/use-create-project'; import { m } from '@/paraglide/messages.js'; -type CreateProjectResponse = { - id?: string; - name?: string; - error?: string; -}; - export type CreateProjectRouteData = { locale: string; target: string | null; @@ -42,11 +34,11 @@ function getCopy(locale: string) { {}, { locale: messageLocale } ), - startStudio: m['BeatAPI.project.startStudio']( + startStudio: m['BeatAPI.project.createStudio']( {}, { locale: messageLocale } ), - startCanvas: m['BeatAPI.project.startCanvas']( + startCanvas: m['BeatAPI.project.createCanvas']( {}, { locale: messageLocale } ), @@ -58,43 +50,17 @@ export function CreateProjectRoutePage({ }: { data: CreateProjectRouteData; }) { - const { locale, target, model, prompt, projectName } = data; + const { locale, target, model, prompt, projectName, workspaceMode } = data; const copy = getCopy(locale); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - async function createProject(workspaceMode: WorkspaceMode) { - if (creating) return; - setCreating(true); - setError(null); - - try { - const payload = await apiJsonPost( - '/api/app/projects', - { name: projectName, workspaceMode } - ); - - if (!payload?.id) { - throw new Error(payload?.error || copy.errorTitle); - } - - window.location.replace( - buildPostCreateProjectDetailPath({ - locale, - projectId: payload.id, - target: target ?? undefined, - model: model ?? undefined, - prompt: prompt ?? undefined, - mode: workspaceMode, - }) - ); - } catch (createError) { - setError( - createError instanceof Error ? createError.message : copy.errorTitle - ); - setCreating(false); - } - } + const { creating, error, createProject, retryProject } = useCreateProject({ + locale, + projectName, + errorMessage: copy.errorTitle, + replace: true, + target: target ?? undefined, + model: model ?? undefined, + prompt: prompt ?? undefined, + }); return (
@@ -128,28 +94,20 @@ export function CreateProjectRoutePage({ ) : ( - <> - - - + )} ) : null} diff --git a/src/components/home/workspace-page.test.ts b/src/components/home/workspace-page.test.ts index 0993616..579820f 100644 --- a/src/components/home/workspace-page.test.ts +++ b/src/components/home/workspace-page.test.ts @@ -12,7 +12,8 @@ test('projects page stays simple and resumes each project in its last workspace' assert.doesNotMatch(source, /copy\.allProjects/); assert.match(source, /mode: project\.lastWorkspaceMode/); assert.match(source, /copy\.createTitle/); - assert.match(source, /href="\/studio"/); - assert.match(source, /href="\/canvas"/); + assert.match(source, /onClick=\{\(\) => void createProject\('studio'\)\}/); + assert.match(source, /onClick=\{\(\) => void createProject\('canvas'\)\}/); + assert.doesNotMatch(source, /href="\/(studio|canvas)"/); assert.match(source, /sm:whitespace-nowrap/); }); diff --git a/src/components/home/workspace-page.tsx b/src/components/home/workspace-page.tsx index b30ff4a..1b5dd25 100644 --- a/src/components/home/workspace-page.tsx +++ b/src/components/home/workspace-page.tsx @@ -4,6 +4,7 @@ import { Plus, Trash2, Workflow, + Loader2, } from 'lucide-react'; import { useState } from 'react'; @@ -20,6 +21,7 @@ import { import { Link } from '@/core/i18n/navigation'; import { buildLocalizedProjectDetailPath } from '@/core/projects/project-entry'; import { useWorkspaceProjects } from '@/core/workspace-hooks/use-workspace-projects'; +import { useCreateProject } from '@/core/workspace-hooks/use-create-project'; import type { WorkspaceProjectCardItem } from '@/core/workspace-lib/app/workspace-client-api'; import { m } from '@/paraglide/messages.js'; @@ -32,6 +34,16 @@ function getHistoryCopy(locale: string) { newProject: m['product.history.newProject']({}, { locale: messageLocale }), newStudio: m['product.history.newStudio']({}, { locale: messageLocale }), newCanvas: m['product.history.newCanvas']({}, { locale: messageLocale }), + creating: m['BeatAPI.project.newTitle']({}, { locale: messageLocale }), + createError: m['BeatAPI.project.newErrorTitle']( + {}, + { locale: messageLocale } + ), + retry: m['BeatAPI.project.retry']({}, { locale: messageLocale }), + defaultName: m['BeatAPI.project.defaultName']( + {}, + { locale: messageLocale } + ), createTitle: m['product.history.createTitle']( {}, { locale: messageLocale } @@ -68,10 +80,7 @@ function getHistoryCopy(locale: string) { {}, { locale: messageLocale } ), - startFirst: m['product.history.startFirst']( - {}, - { locale: messageLocale } - ), + startFirst: m['product.history.startFirst']({}, { locale: messageLocale }), emptyDescription: m['product.history.emptyDescription']( {}, { locale: messageLocale } @@ -97,13 +106,28 @@ function CreateProjectDialog({ open, onOpenChange, copy, + locale, }: { open: boolean; onOpenChange: (open: boolean) => void; copy: HistoryCopy; + locale: string; }) { + const { creating, error, createProject, retryProject, clearError } = + useCreateProject({ + locale, + projectName: copy.defaultName, + errorMessage: copy.createError, + }); return ( - + { + if (creating) return; + clearError(); + onOpenChange(nextOpen); + }} + > @@ -115,36 +139,62 @@ function CreateProjectDialog({
- void createProject('studio')} + className="group relative min-h-[190px] overflow-hidden rounded-[20px] border border-[#ff7a33]/35 bg-[radial-gradient(circle_at_82%_8%,rgba(255,122,51,0.22),transparent_42%),#171719] p-5 text-left transition hover:-translate-y-0.5 hover:border-[#ff7a33]/65 hover:bg-[#1b1b1e] disabled:cursor-wait disabled:opacity-60" > -

+ {copy.newStudio} -

-

+ + {copy.studioDescription} -

- + + - void createProject('canvas')} + className="group relative min-h-[190px] overflow-hidden rounded-[20px] border border-white/[0.10] bg-[radial-gradient(circle_at_82%_8%,rgba(255,255,255,0.09),transparent_42%),#171719] p-5 text-left transition hover:-translate-y-0.5 hover:border-white/[0.22] hover:bg-[#1b1b1e] disabled:cursor-wait disabled:opacity-60" > -

+ {copy.newCanvas} -

-

+ + {copy.canvasDescription} -

- + +
+ {creating ? ( +

+ + {copy.creating} +

+ ) : error ? ( +
+

+ {error} +

+ +
+ ) : null}
); @@ -228,7 +278,10 @@ export function WorkspacePage({ /> ) : (
- +
)}
@@ -278,6 +331,7 @@ export function WorkspacePage({ open={createOpen} onOpenChange={setCreateOpen} copy={copy} + locale={locale} /> (null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + async function createProject(workspaceMode: WorkspaceMode) { + // State updates may not render before a second click arrives. + if (inFlight.current) return; + inFlight.current = true; + lastMode.current = workspaceMode; + setCreating(true); + setError(null); + try { + const payload = await createWorkspaceProject({ + name: projectName, + workspaceMode, + }); + if (!payload?.id) throw new Error(payload?.error || errorMessage); + const path = buildPostCreateProjectDetailPath({ + locale, + projectId: payload.id, + ...intent, + mode: workspaceMode, + }); + if (replace) window.location.replace(path); + else window.location.assign(path); + } catch (createError) { + setError( + createError instanceof Error ? createError.message : errorMessage + ); + inFlight.current = false; + setCreating(false); + } + } + + return { + creating, + error, + createProject, + clearError: () => setError(null), + retryProject: () => + lastMode.current ? createProject(lastMode.current) : Promise.resolve(), + }; +} diff --git a/src/core/workspace-lib/app/workspace-client-api.ts b/src/core/workspace-lib/app/workspace-client-api.ts index c4bb41c..b6acd05 100644 --- a/src/core/workspace-lib/app/workspace-client-api.ts +++ b/src/core/workspace-lib/app/workspace-client-api.ts @@ -1,6 +1,6 @@ import type { WorkspaceMode } from '@/config/workspace-modes'; import type { ProjectGenerationItem } from '@/core/effects/project-generations'; -import { apiJsonDelete, apiJsonGet } from '@/lib/api-client'; +import { apiJsonDelete, apiJsonGet, apiJsonPost } from '@/lib/api-client'; export type WorkspaceProjectCardItem = { id: string; @@ -51,6 +51,16 @@ export async function deleteWorkspaceProjects(projectIds: string[]) { return apiJsonDelete<{ success: true }>('/api/app/projects', { projectIds }); } +export async function createWorkspaceProject(input: { + name: string; + workspaceMode: WorkspaceMode; +}) { + return apiJsonPost<{ id?: string; name?: string; error?: string }>( + '/api/app/projects', + input + ); +} + export async function fetchProjectGenerations(projectId: string) { return apiJsonGet<{ items: ProjectGenerationItem[] }>( `/api/app/projects/${encodeURIComponent(projectId)}/generations` diff --git a/tests/e2e/project-creation.spec.ts b/tests/e2e/project-creation.spec.ts new file mode 100644 index 0000000..753285b --- /dev/null +++ b/tests/e2e/project-creation.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +test.use({ locale: 'en-US' }); + +for (const mode of ['canvas', 'studio'] as const) { + test(`choosing ${mode} creates one project and opens it without a second chooser`, async ({ + page, + }) => { + const creations: string[] = []; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/app/projects' + ) { + creations.push(request.postData() ?? ''); + } + }); + await page.goto('/projects'); + await page + .getByRole('button', { name: 'New project', exact: true }) + .first() + .click(); + const choice = mode === 'canvas' ? 'Start in Canvas' : 'Start in Studio'; + await page.getByRole('button', { name: new RegExp(`^${choice}`) }).click(); + await expect(page).toHaveURL(new RegExp(`/${mode}/[^/?]+$`)); + await expect( + page.getByRole('heading', { name: 'Start a new project', exact: true }) + ).toHaveCount(0); + expect(creations).toHaveLength(1); + expect(JSON.parse(creations[0]).workspaceMode).toBe(mode); + }); +} + +test('failed Canvas creation retries in Canvas', async ({ page }) => { + let attempts = 0; + await page.route('**/api/app/projects', async (route) => { + if (route.request().method() !== 'POST') return route.continue(); + expect(route.request().postDataJSON().workspaceMode).toBe('canvas'); + attempts += 1; + if (attempts === 1) { + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'Creation temporarily unavailable' }), + }); + } + return route.continue(); + }); + await page.goto('/projects'); + await page + .getByRole('button', { name: 'New project', exact: true }) + .first() + .click(); + await page.getByRole('button', { name: /^Start in Canvas/ }).click(); + await expect(page.getByRole('alert')).toHaveText( + 'Creation temporarily unavailable' + ); + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page).toHaveURL(/\/canvas\/[^/?]+$/); + expect(attempts).toBe(2); +}); + +test('direct Canvas entry requires one explicit create action and retains entry parameters', async ({ + page, +}) => { + let creations = 0; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/app/projects' + ) + creations += 1; + }); + await page.goto( + '/canvas?target=image&model=nano-banana-pro&prompt=Entry+test' + ); + const create = page.getByRole('button', { + name: 'Create in Canvas', + exact: true, + }); + await expect(create).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Create in Studio', exact: true }) + ).toHaveCount(0); + expect(creations).toBe(0); + await create.click(); + await expect(page).toHaveURL(/\/canvas\/[^/?]+\?/); + const url = new URL(page.url()); + expect(url.searchParams.get('target')).toBe('image'); + expect(url.searchParams.get('model')).toBe('nano-banana-pro'); + expect(url.searchParams.get('prompt')).toBe('Entry test'); + expect(creations).toBe(1); +}); From 946179c230950865abaf53c223284ad1cb59da03 Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Sun, 18 Oct 2026 08:48:12 +0800 Subject: [PATCH 2/8] fix: preserve clip gain in timeline audio export --- src/core/editor/render-project-timeline.ts | 4 +-- src/core/editor/timeline-audio-filter.test.ts | 36 +++++++++++++++++++ src/core/editor/timeline-audio-filter.ts | 10 ++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/core/editor/timeline-audio-filter.test.ts create mode 100644 src/core/editor/timeline-audio-filter.ts diff --git a/src/core/editor/render-project-timeline.ts b/src/core/editor/render-project-timeline.ts index b3524fc..930c326 100644 --- a/src/core/editor/render-project-timeline.ts +++ b/src/core/editor/render-project-timeline.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { buildTimelineAudioMixFilter } from './timeline-audio-filter'; import { getResolvedCaptionStyle } from './captions'; import { @@ -363,8 +364,7 @@ async function buildFfmpegArgs({ } if (audioLabels.length > 0) { filters.push( - `${audioLabels.join('')}amix=inputs=${audioLabels.length}:duration=longest:dropout_transition=0,` + - `atrim=duration=${time(document.duration)},apad=whole_dur=${time(document.duration)}[aout]` + `${audioLabels.join('')}${buildTimelineAudioMixFilter(audioLabels.length, document.duration)}[aout]` ); } diff --git a/src/core/editor/timeline-audio-filter.test.ts b/src/core/editor/timeline-audio-filter.test.ts new file mode 100644 index 0000000..08565be --- /dev/null +++ b/src/core/editor/timeline-audio-filter.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { buildTimelineAudioMixFilter } from './timeline-audio-filter'; + +const ffmpeg = process.env.BEATDESIGN_FFMPEG?.trim() || 'ffmpeg'; +const available = spawnSync(ffmpeg,['-version'],{stdio:'ignore'}).status === 0; +function render(count:number, overlap=false) { + const args=['-hide_banner','-loglevel','error']; + for(let i=0;i`[${i}:a]volume=${overlap?10:1},adelay=${overlap?0:i*1000}:all=1[a${i}]`); + filters.push(Array.from({length:count},(_,i)=>`[a${i}]`).join('')+buildTimelineAudioMixFilter(count,overlap?1:count)+'[out]'); + args.push('-filter_complex',filters.join(';'),'-map','[out]','-ac','1','-c:a','pcm_f32le','-f','f32le','pipe:1'); + const result=spawnSync(ffmpeg,args,{maxBuffer:4*1024*1024}); + assert.equal(result.status,0,result.stderr?.toString()); + const samples=new Float32Array(result.stdout.buffer.slice(result.stdout.byteOffset,result.stdout.byteOffset+result.stdout.byteLength)); + return samples; +} +function rms(samples:Float32Array,start:number,end:number) { + let sum=0;for(let i=start;i{ + const one=render(1),three=render(3),expected=rms(one,4800,43200); + assert.ok(expected>0.08); + for(let i=0;i<3;i++)assert.ok(Math.abs(rms(three,48000*i+4800,48000*i+43200)-expected)<0.0001); + assert.equal(three.length,3*48000); +}); + +test('overlapping loud clips are limited without automatic makeup or timeline delay',{skip:!available},()=>{ + const samples=render(2,true); + let peak=0;for(const sample of samples)peak=Math.max(peak,Math.abs(sample)); + assert.ok(peak<=0.951 && peak>0.90,`Peak was ${peak}`); + assert.equal(samples.length,48000); +}); diff --git a/src/core/editor/timeline-audio-filter.ts b/src/core/editor/timeline-audio-filter.ts new file mode 100644 index 0000000..f866e08 --- /dev/null +++ b/src/core/editor/timeline-audio-filter.ts @@ -0,0 +1,10 @@ +/** Preserve clip gains independently of silent/delayed timeline inputs. */ +export function buildTimelineAudioMixFilter(inputCount: number, duration: number) { + if (!Number.isSafeInteger(inputCount) || inputCount < 1 || !Number.isFinite(duration) || duration <= 0) + throw new Error('Audio mixing requires inputs and a positive timeline duration.'); + const seconds = duration.toFixed(3); + return `amix=inputs=${inputCount}:duration=longest:dropout_transition=0:normalize=0,` + + // Disable the limiter's automatic makeup gain; compensate its lookahead. + 'alimiter=limit=0.95:level=0:latency=1,' + + `atrim=duration=${seconds},apad=whole_dur=${seconds}`; +} From f709970858b0bc1bb334ab53919d81e6fc0569df Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Sun, 18 Oct 2026 18:12:08 +0800 Subject: [PATCH 3/8] Fix Canvas generation previews to select the latest successful take --- .../use-beatcanvas-react-flow-adapter.test.ts | 27 ++++++++ .../use-beatcanvas-react-flow-adapter.ts | 40 +++++------ .../beatcanvas/generation-history.test.ts | 36 ++++++++++ src/core/beatcanvas/generation-history.ts | 21 +++--- tests/e2e/workspace.spec.ts | 65 ++++++++++++++++++ tests/fixtures/tiny-black.mp4 | Bin 0 -> 1590 bytes 6 files changed, 157 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/tiny-black.mp4 diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts index 39184a4..677d94e 100644 --- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts +++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.test.ts @@ -52,6 +52,33 @@ const makeDraft = ( sourceGenerationId: null, }); +for (const type of ['image', 'video'] as const) { + test(`${type} preview and selected thumbnail agree before and after restoring manual selection`, () => { + const draft = makeDraft(type, '16:9'); + const outputs: CanvasOutputCard[] = [0, 1, 2].map((index) => ({ + ...draft, + id: `take-${index}`, + kind: 'output', + url: index === 2 ? null : `https://example.com/${index}.${type === 'image' ? 'png' : 'mp4'}`, + status: index === 2 ? 'processing' : 'succeeded', + sourceConfigCardId: draft.id, + generationRunId: `run-${index}`, + generationSnapshot: { + ...draft, + capturedAt: `2026-10-18T00:0${index}:00.000Z`, + }, + })); + for (const pinnedOutputId of [null, outputs[0].id, 'missing']) { + const restored = JSON.parse(JSON.stringify({ ...draft, pinnedOutputId })); + const presentation = buildGenerationCardPresentation({ card: restored, outputs }); + const selected = presentation.takes.find((take) => take.isPinned); + assert.equal(selected?.id, pinnedOutputId === outputs[0].id ? outputs[0].id : outputs[1].id); + assert.equal(presentation.latestOutputUrl, selected?.url); + assert.equal(presentation.status, 'processing'); + } + }); +} + test('draft frame size follows portrait and landscape ratios', () => { assert.deepEqual(resolveDraftShapeSize(null, makeDraft('video', '9:16')), { w: 236, diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts index 92807ab..3f4e62a 100644 --- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts +++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts @@ -20,7 +20,10 @@ import type { CanvasDraftCard, CanvasOutputCard, } from '@/core/beatcanvas/canvas-types'; -import { buildGenerationTakes } from '@/core/beatcanvas/generation-history'; +import { + buildGenerationTakes, + resolvePinnedGenerationOutputId, +} from '@/core/beatcanvas/generation-history'; import { isCanvasDraftCard, isCanvasOutputCard, @@ -186,21 +189,13 @@ export const buildGenerationCardPresentation = ({ ) || left.id.localeCompare(right.id) ); const latestOutput = orderedOutputs[orderedOutputs.length - 1] ?? null; - const pinnedOutput = card.pinnedOutputId - ? orderedOutputs.find( - (output) => - output.id === card.pinnedOutputId && - output.status === 'succeeded' && - Boolean(output.url || output.resultText) - ) ?? null - : null; - const latestSucceededOutput = [...orderedOutputs] - .reverse() - .find( - (output) => - output.status === 'succeeded' && - Boolean(output.url || output.resultText) - ); + const selectedOutputId = resolvePinnedGenerationOutputId({ + outputs: orderedOutputs, + pinnedOutputId: card.pinnedOutputId, + }); + const selectedOutput = orderedOutputs.find( + (output) => output.id === selectedOutputId && output.status === 'succeeded' + ); const isAnalysis = card.generationMode === 'analysis'; const analysisReportCount = isAnalysis ? orderedOutputs.filter( @@ -213,14 +208,10 @@ export const buildGenerationCardPresentation = ({ isAnalysis, latestOutputUrl: isAnalysis ? null - : pinnedOutput?.url ?? latestSucceededOutput?.url ?? card.url ?? null, - latestOutputText: - isAnalysis - ? null - : pinnedOutput?.resultText ?? - latestSucceededOutput?.resultText ?? - card.resultText ?? - null, + : (selectedOutput?.url ?? card.url ?? null), + latestOutputText: isAnalysis + ? null + : (selectedOutput?.resultText ?? card.resultText ?? null), analysisReportCount, takes: buildGenerationTakes({ outputs: orderedOutputs, @@ -1224,6 +1215,7 @@ export function useBeatCanvasReactFlowAdapter({ setCanvasCard(completedOutputCard); setCanvasCard({ ...draftCard, + pinnedOutputId: outputCardId, url: isAnalysisOutput ? null : (url ?? null), resultText: isAnalysisOutput ? null : resultText, }); diff --git a/src/core/beatcanvas/generation-history.test.ts b/src/core/beatcanvas/generation-history.test.ts index 028242a..db12546 100644 --- a/src/core/beatcanvas/generation-history.test.ts +++ b/src/core/beatcanvas/generation-history.test.ts @@ -50,6 +50,42 @@ const makeOutput = ( }, }); +for (const type of ['image', 'video'] as const) { + test(`${type} defaults to the newest usable take regardless of input order`, () => { + const older = { ...makeOutput('old', '2026-10-18T00:00:00.000Z'), type }; + const newer = { ...makeOutput('new', '2026-10-18T00:01:00.000Z'), type }; + const pending = { ...makeOutput('pending', '2026-10-18T00:02:00.000Z', 'processing'), type }; + const empty = { ...makeOutput('empty', '2026-10-18T00:03:00.000Z'), type, url: null }; + const failed = { ...makeOutput('failed', '2026-10-18T00:04:00.000Z', 'failed'), type }; + for (const outputs of [[older, pending, newer, empty, failed], [failed, empty, newer, pending, older]]) { + const originalOrder = outputs.map((output) => output.id); + for (const pinnedOutputId of [null, 'missing', failed.id, empty.id, pending.id]) { + assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId }), newer.id); + const takes = buildGenerationTakes({ outputs, pinnedOutputId }); + assert.equal(takes.find((take) => take.isPinned)?.id, newer.id); + assert.equal(takes.find((take) => take.id === newer.id)?.takeNumber, 2); + } + assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId: older.id }), older.id); + assert.deepEqual(outputs.map((output) => output.id), originalOrder); + } + const draft = { ...older, id: 'draft-1', kind: 'generation' as const, pinnedOutputId: null }; + const cards = Object.fromEntries([draft, older, newer, pending, failed].map((card) => [card.id, card])); + assert.equal(resolveConcreteCanvasMediaCard({ cards, card: draft })?.id, newer.id); + }); +} + +test('fallback handles empty histories, only failed outputs and equal timestamps deterministically', () => { + const time = '2026-10-18T00:00:00.000Z'; + assert.equal(resolvePinnedGenerationOutputId({ outputs: [] }), null); + assert.equal(resolvePinnedGenerationOutputId({ outputs: [makeOutput('failed', time, 'failed')] }), null); + for (const status of ['succeeded', 'pending'] as const) { + const a = makeOutput('a', time, status); + const b = makeOutput('b', time, status); + assert.equal(resolvePinnedGenerationOutputId({ outputs: [a, b] }), 'b'); + assert.equal(resolvePinnedGenerationOutputId({ outputs: [b, a] }), 'b'); + } +}); + test('resolves a generation node to its pinned concrete output asset', () => { const older = makeOutput('output-old', '2026-08-16T00:00:00.000Z'); const newer = makeOutput('output-new', '2026-08-16T00:02:00.000Z'); diff --git a/src/core/beatcanvas/generation-history.ts b/src/core/beatcanvas/generation-history.ts index 6cc7fbd..cbdca54 100644 --- a/src/core/beatcanvas/generation-history.ts +++ b/src/core/beatcanvas/generation-history.ts @@ -40,21 +40,26 @@ export const resolvePinnedGenerationOutputId = ({ outputs: CanvasOutputCard[]; pinnedOutputId?: string | null; }) => { + const newestFirst = [...outputs] + .filter((output) => output.status !== 'failed') + .sort( + (left, right) => + right.generationSnapshot.capturedAt.localeCompare( + left.generationSnapshot.capturedAt + ) || right.id.localeCompare(left.id) + ); + const hasResult = (output: CanvasOutputCard) => + output.status === 'succeeded' && Boolean(output.url || output.resultText); if ( pinnedOutputId && - outputs.some((output) => output.id === pinnedOutputId) + newestFirst.some((output) => output.id === pinnedOutputId && hasResult(output)) ) { return pinnedOutputId; } return ( - outputs.find( - (output) => - output.status === 'succeeded' && - Boolean(output.url || output.resultText) - ) - ?.id ?? - outputs[0]?.id ?? + newestFirst.find(hasResult)?.id ?? + newestFirst[0]?.id ?? null ); }; diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts index 648ddc9..bedf5a9 100644 --- a/tests/e2e/workspace.spec.ts +++ b/tests/e2e/workspace.spec.ts @@ -22,6 +22,71 @@ async function project(request: APIRequestContext, name: string) { return (await response.json()).id as string; } +for (const type of ['image', 'video'] as const) { + test(`Canvas ${type} defaults to latest take and persists manual history selection`, async ({ page, request }) => { + const projectId = await project(request, `Latest ${type} take QA`); + const fixture = await readFile(type === 'image' ? 'public/logo.png' : 'tests/fixtures/tiny-black.mp4'); + const urls: string[] = []; + const assetIds: string[] = []; + for (const index of [1, 2]) { + const uploaded = await request.post(`/api/app/projects/${projectId}/assets`, { + headers, + multipart: { file: { + name: `take-${index}.${type === 'image' ? 'png' : 'mp4'}`, + mimeType: type === 'image' ? 'image/png' : 'video/mp4', + buffer: Buffer.concat([fixture, Buffer.from(`take-${index}`)]), + } }, + }); + expect(uploaded.ok()).toBeTruthy(); + const { asset } = await uploaded.json(); + urls.push(asset.publicUrl); + assetIds.push(asset.id); + } + const draft = { + id: 'qa-latest-draft', kind: 'generation', type, name: 'Latest take QA', url: null, + prompt: 'Local version selection test', referenceCardIds: [], workflowTemplateId: null, + status: 'idle', error: null, modelId: type === 'image' ? 'gpt-image-2.5-flare' : 'seedance-2.0', + aspectRatio: '16:9', outputQuality: '720p', duration: '8s', mode: 'quality', + variant: 'standard', quality: 'standard', sourceGenerationId: null, + }; + const outputs = [0, 1, 2].map((index) => ({ + ...draft, id: `qa-take-${index}`, kind: 'output', + assetId: assetIds[index] ?? null, + status: index === 2 ? 'processing' : 'succeeded', url: urls[index] ?? null, + sourceConfigCardId: draft.id, generationRunId: `qa-run-${index}`, + generationSnapshot: { ...draft, capturedAt: `2026-10-18T00:0${index}:00.000Z` }, + })); + const applied = await mcp(request, 'bdesign_canvas_apply', { projectId, operations: [draft, ...outputs].map((card) => ({ + type: 'upsert_card', card, frame: { x: 200, y: 160, w: 420, h: 236 }, + })) }); + expect(applied.structuredContent.result.ok, JSON.stringify(applied)).toBe(true); + const snapshot = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json(); + expect(snapshot.document.cards).toHaveLength(4); + await page.goto(`/canvas/${projectId}`); + const node = page.locator(`[data-card-id="${draft.id}"]`); + await expect(node).toBeVisible(); + await expect(node.getByRole('button', { name: 'Take 2', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'false'); + if (type === 'image') { + await expect(node.locator('img').first()).toHaveAttribute('src', urls[1]); + } else { + await node.getByRole('button', { name: /^View result:/ }).click(); + await expect(page.locator('video').first()).toHaveAttribute('src', urls[1]); + await page.getByRole('dialog').getByRole('button').click(); + } + await node.getByRole('button', { name: 'Take 1', exact: true }).click(); + await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.getByRole('dialog').getByRole('button').click(); + await expect.poll(async () => { + const state = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json(); + return state.document.cards.find((card: { id: string }) => card.id === draft.id)?.pinnedOutputId; + }).toBe(outputs[0].id); + await page.reload(); + await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.screenshot({ path: `test-results/latest-${type}-take.png` }); + }); +} + test('Canvas UI commands and MCP edits share durable state; unchanged polling is lightweight', async ({ page, request }) => { const id = await project(request, 'Canvas concurrency QA'); const initial = { id: 'qa-draft', kind: 'generation', type: 'image', name: 'QA draft', prompt: 'Original prompt', modelId: 'gpt-image-2.5-flare' }; diff --git a/tests/fixtures/tiny-black.mp4 b/tests/fixtures/tiny-black.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..a20c0609ab825e5f51a2ce03a3ceaa51d44385f6 GIT binary patch literal 1590 zcmZux&1)n@6tCH=i%1T8h`5IwYBe`AGu=J2vztIm*02~s(Zd=Lg{Hc?I^9fvRH`b; z%pR5%6!Z_!ga3gdxq9;BuqXE@f&mdk5M^P#$sTma-|NmKG5F}J_x-D?_o|vP##1-d zxvfjaE->O4f!LRRsfRve?CI1f1>etQA{_YPrKe`I&wsl0$5+1{ZGHL4>W}>Bg-3r* zg3*w7xvY)iK14I{*8(nsq4Gu&j;$U7`meuv>y_@1-`w6rWTMEiS?g(`QpW?&8*~FN zAehWKz25I1931ra^F-B!sCu>8>C-B`%#{UVYVGp6vg=%mSjf=lMy26^Cn_##IS&1G zZ{6df62;W2(3=d_y@~G+c&WlnO}O2QAzkO%PLYZ^GD+z7JS1V_Wj;|!M2J2zA|t9D z75XDCGgFr$LaNW5QALqkNH-=o66qkw-7@sB1(DpTD-{O*s_*kuSQlwK&NW4}0J}P> z)6^;#b_4D*gA4*H>Uu0PDAB3S7P-V;<_3>frddPgrEs*5ymHDE0zt6H#hwwdBM{zDJEs`76oa;d4JDH2&tF@}4 zom#`&8Z}bKY6|QyxaJ*;QJGgEI%%bw zJ^AbK?*}isyC1Lq@WC7J(apa3>DA)pZ=P&3w&0a@y$@Sa?q>=38Cv)q%5BzR^qtaw z6qwKH8>f-~CtbixdItp=!}+coH)}4O37n(dK4WXexo7S;r*Dv*9}mV>RN^eOWmQ#JB04Ziy->6vcGzqEjh^ zrTwx2ZI+@y&`Q$!mJ?iu*3^`5anBSSI$}F7Sr-?OzqZcK8$uX|^lJhqI*Zv_IOA*! zA`PO>v#%k37>J zA)4ne!zNqPiH(BEPrf_HK4OIP9rz*!uw+d}5XDl@#9rbA_}iswI7D{!Z2Qo1BebRp epU`UF-jS8hTup$>_~8=>fil!FMIEnG8UF>!c4|Wa literal 0 HcmV?d00001 From dd581cf612aaa1a90b29a598e3d5192a320e253c Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Sun, 18 Oct 2026 21:43:19 +0800 Subject: [PATCH 4/8] Add project settings with stable covers and shared MCP updates --- docs/PRODUCT_PLAN_AND_STATUS.md | 7 + docs/PROJECT_SETTINGS.md | 56 +++ messages/en.json | 32 ++ messages/ja.json | 32 ++ messages/zh.json | 32 ++ src/components/app/product-page-shell.tsx | 22 +- src/components/app/project-assets-dialog.tsx | 20 +- src/components/app/project-cover-preview.tsx | 28 ++ .../app/project-settings-dialog.tsx | 362 ++++++++++++++++++ src/components/home/workspace-page.tsx | 19 +- src/config/db/schema.sqlite.ts | 4 + src/core/commands/asset-boundary.ts | 2 +- src/core/commands/executor.ts | 6 + src/core/commands/persist.ts | 26 +- src/core/commands/schema.ts | 2 + src/core/db/sqlite.ts | 75 +++- src/core/media/video-thumbnail-cache.test.ts | 73 ++++ src/core/media/video-thumbnail-cache.ts | 93 +++++ src/core/projects/project-entry.ts | 2 + .../projects/project-settings-contract.ts | 36 ++ src/core/projects/project-settings.test.ts | 193 ++++++++++ src/core/projects/project-settings.ts | 201 ++++++++++ src/core/projects/project-video-thumbnail.ts | 27 ++ src/core/projects/projects.ts | 18 +- .../workspace-hooks/use-workspace-projects.ts | 2 + .../app/project-settings-client.ts | 30 ++ .../workspace-lib/app/workspace-client-api.ts | 1 + src/core/workspace-lib/assets/user-assets.ts | 47 --- src/mcp/server.runtime.test.ts | 4 +- src/mcp/server.ts | 17 + src/mcp/tools.ts | 1 + src/routes/api/app/projects/$projectId.ts | 48 +-- .../$projectId/assets/$assetId/thumbnail.ts | 6 + 33 files changed, 1405 insertions(+), 119 deletions(-) create mode 100644 docs/PROJECT_SETTINGS.md create mode 100644 src/components/app/project-cover-preview.tsx create mode 100644 src/components/app/project-settings-dialog.tsx create mode 100644 src/core/media/video-thumbnail-cache.test.ts create mode 100644 src/core/media/video-thumbnail-cache.ts create mode 100644 src/core/projects/project-settings-contract.ts create mode 100644 src/core/projects/project-settings.test.ts create mode 100644 src/core/projects/project-settings.ts create mode 100644 src/core/projects/project-video-thumbnail.ts create mode 100644 src/core/workspace-lib/app/project-settings-client.ts create mode 100644 src/routes/api/app/projects/$projectId/assets/$assetId/thumbnail.ts 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..d3bb8cd --- /dev/null +++ b/docs/PROJECT_SETTINGS.md @@ -0,0 +1,56 @@ +# 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. + +## 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 + /> +
+
+ +
+