From bbea6eba82a625f3d8b34ecb3d2747e7a19766e3 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 27 Sep 2026 16:32:38 +0800 Subject: [PATCH] fix(design): distinguish saved video settings from preparation needs --- .../20260927-design-video-form-d2b96e41.md | 45 ++++++++++++++ .../diagnostics/design-video-flow.test.tsx | 46 ++++++++++---- src/pages/ImageCanvas/YouthCreationCard.tsx | 62 ++++++++++++++++--- src/pages/ImageCanvas/youth-issue-copy.ts | 2 +- tests/e2e/image-workspace-v2.spec.ts | 27 +++++++- tests/unit/youth-creation-card.test.tsx | 49 ++++++++++++++- 6 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260927-design-video-form-d2b96e41.md diff --git a/.project-docs/30-worklog/tasks/20260927-design-video-form-d2b96e41.md b/.project-docs/30-worklog/tasks/20260927-design-video-form-d2b96e41.md new file mode 100644 index 00000000..1fe81a0a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260927-design-video-form-d2b96e41.md @@ -0,0 +1,45 @@ +# Task: Diagnose stale video form validation messages + +## Identity + +- Task ID: 20260927-design-video-form-d2b96e41 +- Mode: Feature +- Branch: codex/20260927-design-video-form-d2b96e41-design-video-form +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260927-design-video-form-d2b96e41 +- Base commit: 0eed70fa637f70b22be5eda7af400a702ee4a7f2 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Reproduce the visible six-second video duration alongside missing-duration/missing-shots errors through YouthCreationCard and its real store. Own only that form/quote preparation path, focused tests and this record; no navigation, billing or provider changes. + +## Intent And Constraints + +- Concurrent and Planning Gates Passed: official check/start/status verified the exact isolated feature identity and base above. Read entry, own task, positioning/current integrated snapshot, Design ADR, relevant architecture/domain/evidence/reflection/commitments. Peer task records inspected read-only; historical Design tasks share code paths but retain the same server-owned Specification and prepare-before-quote contract. Placeholder peer scopes remain unknown; no concrete dependency or semantic conflict affects this isolated work. Preserve unrelated dirty root documents and completed draft-diagnosis task. No subagents or live model calls. + +## Outcome + +- Reproduced the screenshot's contradictory duration display: a canonical null duration rendered as six seconds while video_duration_required remained. Independently reproduced an unsuccessful parameter save retaining the unaccepted optimistic duration. +- Missing duration now renders an explicit selector populated solely from server-supported durations; quotation remains disabled until selection is saved. Existing accepted durations retain the stepper, including normal video-mode conversion's existing explicit duration write. +- Parameter controls reconcile to accepted Workspace values after completion or failure, without applying a response to another selected Workspace. +- video_shots_required remains visible as actionable AI-preparation guidance, not a red instruction for a child to write internal shots. Other compilation blockers remain unchanged; the server's compile/confirm authority is not relaxed. +- Product ownership: src/pages/ImageCanvas/YouthCreationCard.tsx and youth-issue-copy.ts. Regression ownership: tests/unit/youth-creation-card.test.tsx, tests/e2e/image-workspace-v2.spec.ts and scripts/diagnostics/design-video-flow.test.tsx. + +## Verification + +- Red evidence: real React component test rendered six seconds for null; a second test rendered the misleading shot error with a valid saved duration; a failed-save test retained seven instead of saved six. Each was run before its correction. +- pnpm 10.33.4: 71 tests passed across youth-creation-card, image-workspace-store and image-canvas-page. Existing store regression confirms blockers clear only on a new canonical Specification revision; preparation failure still stops quotation. +- Offline cross-repository flow: 3 passed using real React controls, Zustand commands, server reducer/preparation orchestration/capability compiler, fixture model output and in-memory asset. New incomplete-video case starts with a selected first frame, null duration and no shots, sees the placeholder, saves six seconds, clears the old duration error, prepares one shot, and reaches explicit confirmation. Also reran original image-to-video path and playback projection. +- Server companion 20260927-design-video-validation-a9e4317b: 10 preparation tests passed. +- pnpm run typecheck, scoped ESLint, pnpm run build:vite passed. Build reports existing Browserslist age, chunk-size and mixed dynamic/static import warnings. +- Electron image-workspace-v2 spec: 2 passed, including new duration selection/reload persistence assertions in the existing production-plan flow. Host API/model/provider transport remain fixtures; no paid generation or production database access. + +## Follow-ups + +- Main is unchanged. This fix needs a client merge and rebuilt client rollout; it requires no server product patch, database migration or environment variable. The separate server Reasoner fix 7799a746699571d71ac0b009d6faa5fd989b501a remains independently ready, not included here. +- The screenshot alone cannot prove the deployed Workspace's exact stored duration or that a save failed; the supported code paths and matching contradiction were reproduced locally. Real-account installed-client generation is not claimed. + +## Promotion Candidates + +- Target: ADR-007/README video UI clarification during integration. Proposal: an absent duration must never look accepted, rejected edits must reconcile to accepted state, and AI-owned motion preparation must not be phrased as user-required internal shots. Evidence: red/green component tests, paired offline compiler flow and Electron persistence test. Future impact: preserve the existing single Specification authority and prepare-before-quote contract; no semantic conflict or new product-direction decision. diff --git a/scripts/diagnostics/design-video-flow.test.tsx b/scripts/diagnostics/design-video-flow.test.tsx index 10d484a0..b17d97b1 100644 --- a/scripts/diagnostics/design-video-flow.test.tsx +++ b/scripts/diagnostics/design-video-flow.test.tsx @@ -73,19 +73,25 @@ beforeEach(() => { }); afterEach(cleanup); -it('reaches video confirmation through the real prepare-generation bridge', async () => { +it.each(['image', 'incomplete-video'])('reaches video confirmation from %s through the real prepare-generation bridge', async (start) => { + const incompleteVideo = start === 'incomplete-video'; const initial = serverStep(null, 'direct_edit', [ - { kind: 'set', path: 'intent.media', value: 'image' }, + { kind: 'set', path: 'intent.media', value: incompleteVideo ? 'video' : 'image' }, { kind: 'set', path: 'content.concept', value: '沙漠里的小动物围圈玩游戏,天上的云在飘。' }, { kind: 'set', path: 'output.aspect_ratio', value: '9:16' }, + ...(incompleteVideo ? [ + { kind: 'set', path: 'video.first_frame_asset_id', value: 'image-1' }, + { kind: 'set', path: 'references', value: [{ id: 'start', asset_id: 'image-1', role: 'first_frame' }] }, + ] satisfies DesignUserFieldOperation[] : []), ]); let workspace = designWorkspaceFixture({ form: designFormFixture({ specification: initial.specification, activeQuotes: [], - }) }); + }), assets: incompleteVideo ? [asset] : [] }); const commands: string[] = []; - expect(initial.ready).toBe(true); + expect(initial.ready).toBe(!incompleteVideo); useImageWorkspaceStore.setState({ workspace, activeWorkspaceId: workspace.workspace.workspaceId, status: 'ready', + quoteBlockers: initial.blockers, }); vi.mocked(uploadImageWorkspaceAsset).mockResolvedValue(asset); vi.mocked(submitImageWorkspaceCommand).mockImplementation(async (command) => { @@ -118,20 +124,34 @@ it('reaches video confirmation through the real prepare-generation bridge', asyn return { workspace, runId: 'offline-run', clientOperationId: command.clientOperationId }; }); render(); - fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } }); - await waitFor(() => expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.intent.media).toBe('video')); - fireEvent.change(screen.getByLabelText('选择参考图片'), { - target: { files: [new File(['offline-upload'], 'start.png', { type: 'image/png' })] }, - }); - await screen.findByText('已选用'); - expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.first_frame_asset_id).toBe('image-1'); - expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.shots.length).toBe(1); + if (incompleteVideo) { + expect(initial.blockers.map((issue) => issue.code)).toEqual(expect.arrayContaining([ + 'video_duration_required', 'video_shots_required', + ])); + expect(screen.getByRole('combobox', { name: '时长' })).toHaveValue(''); + expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled(); + expect(screen.queryByText('至少写一个视频画面。')).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole('combobox', { name: '时长' }), { target: { value: '6' } }); + await waitFor(() => expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.total_duration_seconds).toBe(6)); + await waitFor(() => expect(screen.queryByText('先选择视频时长。')).not.toBeInTheDocument()); + } else { + fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } }); + await waitFor(() => expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.intent.media).toBe('video')); + fireEvent.change(screen.getByLabelText('选择参考图片'), { + target: { files: [new File(['offline-upload'], 'start.png', { type: 'image/png' })] }, + }); + await screen.findByText('已选用'); + expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.first_frame_asset_id).toBe('image-1'); + expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.shots.length).toBe(1); + } await waitFor(() => expect(screen.getByRole('button', { name: '准备制作方案' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '准备制作方案' })); await waitFor(() => expect(commands).toContain('request_quote')); console.log('FLOW_COMMANDS', commands); console.log('FLOW_BLOCKERS', useImageWorkspaceStore.getState().quoteBlockers.map((item) => item.code)); - expect(commands.filter((kind) => kind === 'prepare_generation')).toHaveLength(3); + expect(commands.filter((kind) => kind === 'prepare_generation')).toHaveLength(incompleteVideo ? 1 : 3); + expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.shots.length).toBe(1); + expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.total_duration_seconds).toBe(6); expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]); expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeEnabled(); fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' })); diff --git a/src/pages/ImageCanvas/YouthCreationCard.tsx b/src/pages/ImageCanvas/YouthCreationCard.tsx index 2978bd62..c1c95b57 100644 --- a/src/pages/ImageCanvas/YouthCreationCard.tsx +++ b/src/pages/ImageCanvas/YouthCreationCard.tsx @@ -169,7 +169,7 @@ export function YouthCreationCard({ const [uploading, setUploading] = useState(false); const [medium, setMedium] = useState(values.intent.media ?? 'image'); const [aspectRatio, setAspectRatio] = useState(values.output.aspect_ratio ?? ''); - const [duration, setDuration] = useState(values.video.total_duration_seconds ?? 6); + const [duration, setDuration] = useState(values.video.total_duration_seconds); const [variantCount, setVariantCount] = useState(values.output.variant_count ?? 1); const promptRef = useRef(null); const uploadInputRef = useRef(null); @@ -183,7 +183,7 @@ export function YouthCreationCard({ useEffect(() => { setMedium(values.intent.media ?? 'image'); setAspectRatio(values.output.aspect_ratio ?? ''); - setDuration(values.video.total_duration_seconds ?? 6); + setDuration(values.video.total_duration_seconds); setVariantCount(values.output.variant_count ?? 1); }, [ values.intent.media, @@ -205,6 +205,9 @@ export function YouthCreationCard({ )); const busy = localBusy || submitting || uploading; const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker'); + const inputBlockers = blockers.filter((issue) => medium !== 'video' || issue.code !== 'video_shots_required'); + const needsMotionPreparation = medium === 'video' + && (values.video.shots.length === 0 || blockers.some((issue) => issue.code === 'video_shots_required')); const options = generationOptions ?? UNAVAILABLE_GENERATION_OPTIONS; const activeOptions = options[medium]; const supportedAspectRatios = activeOptions.supportedAspectRatios; @@ -228,7 +231,7 @@ export function YouthCreationCard({ && !unsupportedReferences && unboundReferenceNumbers.length === 0 && variantCount <= activeOptions.maxOutputCount - && !(medium === 'video' && !supportedDurations.includes(duration)); + && !(medium === 'video' && (duration === null || !supportedDurations.includes(duration))); const canAddReference = medium === 'video' ? activeOptions.supportedReferenceRoles.includes('first_frame') && (!selectedFirstFrameAsset @@ -313,7 +316,15 @@ export function YouthCreationCard({ ) => { if (busy) return; optimisticUpdate(); - void runOperations(operations); + void runOperations(operations).then((updated) => { + // The controls must reflect accepted values, including after a failed save. + const current = useImageWorkspaceStore.getState().workspace; + if (current?.workspace.workspaceId !== workspace.workspace.workspaceId) return; + const accepted = updated?.form.specification.values ?? current.form.specification.values; + setAspectRatio(accepted.output.aspect_ratio ?? ''); + setDuration(accepted.video.total_duration_seconds); + setVariantCount(accepted.output.variant_count ?? 1); + }); }; const uploadReference = async (file: File) => { @@ -424,9 +435,10 @@ export function YouthCreationCard({ if (saveState === 'repricing') return { text: '正在重新核价', icon: }; if (saveState === 'error') return { text: '方案需要重试', icon: }; if (promptDirty) return { text: '提示词尚未保存', icon: }; - if (blockers.length > 0) return { text: '还需要补充一点信息', icon: }; + if (inputBlockers.length > 0 || (medium === 'video' && duration === null)) return { text: '还需要补充一点信息', icon: }; + if (needsMotionPreparation) return { text: '视频动作待整理', icon: }; return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: }; - }, [blockers.length, offeredQuote, promptDirty, saveState]); + }, [inputBlockers.length, medium, duration, needsMotionPreparation, offeredQuote, promptDirty, saveState]); if (!hasActiveCreationPlan(workspace)) return null; @@ -707,7 +719,7 @@ export function YouthCreationCard({ void runOperations(operations, { prepare: true }).then((updated) => { if (!updated) { setMedium(values.intent.media ?? 'image'); - setDuration(values.video.total_duration_seconds ?? 6); + setDuration(values.video.total_duration_seconds); } }); }} @@ -751,7 +763,31 @@ export function YouthCreationCard({ - {medium === 'video' && ( + {medium === 'video' && duration === null && ( + + )} + + {medium === 'video' && duration !== null && ( )} - {blockers.length > 0 && ( + {needsMotionPreparation && ( +

+ {youthCompilationIssueMessage({ code: 'video_shots_required', message: '', severity: 'blocker' })} +

+ )} + + {inputBlockers.length > 0 && (
- {blockers.map((blocker) => ( + {inputBlockers.map((blocker) => (

{youthCompilationIssueMessage(blocker)}

))}
diff --git a/src/pages/ImageCanvas/youth-issue-copy.ts b/src/pages/ImageCanvas/youth-issue-copy.ts index fcfa4786..a9ec19c9 100644 --- a/src/pages/ImageCanvas/youth-issue-copy.ts +++ b/src/pages/ImageCanvas/youth-issue-copy.ts @@ -33,7 +33,7 @@ const COMPILATION_COPY: Readonly> = { image_direction_required: '再说一句你想画什么,或想把图片改成什么样。', video_duration_required: '先选择视频时长。', video_first_frame_required: '先选择视频从哪张图片开始。', - video_shots_required: '至少写一个视频画面。', + video_shots_required: '视频动作还没整理好。点击“准备制作方案”,AI 会根据你的描述继续整理。', video_shot_timing_required: '多个视频画面需要安排好先后时间,请让 AI 帮你整理。', video_timeline_must_start_at_zero: '第一个视频画面需要从开头开始。', video_timeline_gap: '视频画面之间有空缺,请让 AI 帮你重新安排。', diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index ce05ff93..f7c9bb43 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -123,7 +123,7 @@ test.describe('AI Design V2 workspace', () => { preserve_instructions: [], change_instructions: [], }, - video: { shots: [] }, + video: { shots: [], total_duration_seconds: null as number | null }, output: { aspect_ratio: '3:4', orientation: 'portrait', @@ -186,11 +186,19 @@ test.describe('AI Design V2 workspace', () => { __designE2eAuthReady?: boolean; __designE2eProfileReady?: boolean; __designE2eCompleteTask?: () => void; + __designE2eOpenIncompleteVideo?: () => void; __designE2eDownloadCount?: number; }; e2eGlobal.__designE2eAuthReady = false; e2eGlobal.__designE2eProfileReady = false; e2eGlobal.__designE2eDownloadCount = 0; + e2eGlobal.__designE2eOpenIncompleteVideo = () => { + workspace.form.specification.values.intent.media = 'video'; + workspace.form.specification.values.video.total_duration_seconds = null; + workspace.form.directionRevision += 1; + workspace.form.specificationRevision += 1; + workspace.workspace.specificationRevision = workspace.form.specificationRevision; + }; e2eGlobal.__designE2eCompleteTask = () => { workspace.tasks[0] = { ...workspace.tasks[0], @@ -303,6 +311,9 @@ test.describe('AI Design V2 workspace', () => { if (operation.kind === 'set' && operation.path === 'output.orientation') { workspace.form.specification.values.output.orientation = String(operation.value ?? ''); } + if (operation.kind === 'set' && operation.path === 'video.total_duration_seconds') { + workspace.form.specification.values.video.total_duration_seconds = Number(operation.value); + } } } if (command.input?.kind === 'chat' && command.input.message) { @@ -589,6 +600,20 @@ test.describe('AI Design V2 workspace', () => { expect(await app.evaluate(() => ( globalThis as typeof globalThis & { __designE2eDownloadCount?: number } ).__designE2eDownloadCount)).toBe(0); + await app.evaluate(() => { + (globalThis as typeof globalThis & { __designE2eOpenIncompleteVideo?: () => void }) + .__designE2eOpenIncompleteVideo?.(); + }); + await page.reload(); + const duration = page.getByRole('combobox', { name: '时长' }); + await expect(duration).toHaveValue(''); + await expect(page.getByRole('button', { name: '增加时长' })).toHaveCount(0); + await expect(page.getByText('至少写一个视频画面。')).toHaveCount(0); + await duration.selectOption('6'); + await expect(page.getByRole('button', { name: '增加时长' })).toBeEnabled(); + await page.reload(); + await expect(page.getByRole('combobox', { name: '时长' })).toHaveCount(0); + await expect(page.getByText('6 秒', { exact: true })).toBeVisible(); } finally { await closeElectronApp(app); } diff --git a/tests/unit/youth-creation-card.test.tsx b/tests/unit/youth-creation-card.test.tsx index c4d22a18..4c8a5b15 100644 --- a/tests/unit/youth-creation-card.test.tsx +++ b/tests/unit/youth-creation-card.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DesignPlanHistory } from '@/pages/ImageCanvas/DesignPlanHistory'; import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; -import type { DesignGenerationTask } from '../../shared/image-workspace'; +import type { DesignCompilationIssue, DesignGenerationTask } from '../../shared/image-workspace'; import { designCapabilitiesFixture, designFormFixture, @@ -58,7 +58,7 @@ function taskFixture(overrides: Partial = {}): DesignGener }; } -function renderCard(workspace = designWorkspaceFixture()) { +function renderCard(workspace = designWorkspaceFixture(), quoteBlockers: DesignCompilationIssue[] = []) { const actions = { applyFieldOperations: vi.fn().mockResolvedValue(workspace), prepareGeneration: vi.fn().mockResolvedValue(workspace), @@ -74,7 +74,7 @@ function renderCard(workspace = designWorkspaceFixture()) { render( { useImageWorkspaceStore.getState().reset(); }); + it('does not present a missing saved video duration as an already selected six seconds', () => { + const workspace = referenceWorkspace(); + workspace.form.specification.values.intent.media = 'video'; + workspace.form.specification.values.video.first_frame_asset_id = 'asset-reference'; + workspace.form.specification.values.references[0].role = 'first_frame'; + renderCard(workspace, [ + { code: 'video_duration_required', message: 'required', severity: 'blocker', path: 'video.total_duration_seconds' }, + { code: 'video_shots_required', message: 'required', severity: 'blocker', path: 'video.shots' }, + ]); + expect(screen.getByRole('combobox', { name: '时长' })).toHaveValue(''); + expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '增加时长' })).not.toBeInTheDocument(); + expect(screen.queryByText('至少写一个视频画面。')).not.toBeInTheDocument(); + }); + + it('allows AI preparation without requiring the user to write internal video shots', async () => { + const workspace = referenceWorkspace(); + workspace.form.specification.values.intent.media = 'video'; + workspace.form.specification.values.video.first_frame_asset_id = 'asset-reference'; + workspace.form.specification.values.references[0].role = 'first_frame'; + workspace.form.specification.values.video.total_duration_seconds = 6; + const actions = renderCard(workspace, [ + { code: 'video_shots_required', message: 'required', severity: 'blocker', path: 'video.shots' }, + ]); + expect(screen.getByText('6 秒')).toBeInTheDocument(); + expect(screen.queryByText('至少写一个视频画面。')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '准备制作方案' })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '准备制作方案' })); + await waitFor(() => expect(actions.requestQuote).toHaveBeenCalledOnce()); + }); + + it('restores the saved duration after a failed parameter edit', async () => { + const workspace = referenceWorkspace(); + workspace.form.specification.values.intent.media = 'video'; + workspace.form.specification.values.video.total_duration_seconds = 6; + const actions = renderCard(workspace); + actions.applyFieldOperations.mockRejectedValueOnce(new Error('save failed')); + fireEvent.click(screen.getByRole('button', { name: '增加时长' })); + await waitFor(() => expect(screen.getByText('方案需要重试')).toBeInTheDocument()); + expect(screen.getByText('6 秒')).toBeInTheDocument(); + expect(screen.queryByText('7 秒')).not.toBeInTheDocument(); + }); + it('persists the displayed supported duration when entering video mode', async () => { const actions = renderCard(); fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } });