fix(design): distinguish saved video settings from preparation needs
This commit is contained in:
@@ -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.
|
||||||
@@ -73,19 +73,25 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
afterEach(cleanup);
|
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', [
|
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: 'content.concept', value: '沙漠里的小动物围圈玩游戏,天上的云在飘。' },
|
||||||
{ kind: 'set', path: 'output.aspect_ratio', value: '9:16' },
|
{ 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({
|
let workspace = designWorkspaceFixture({ form: designFormFixture({
|
||||||
specification: initial.specification, activeQuotes: [],
|
specification: initial.specification, activeQuotes: [],
|
||||||
}) });
|
}), assets: incompleteVideo ? [asset] : [] });
|
||||||
const commands: string[] = [];
|
const commands: string[] = [];
|
||||||
expect(initial.ready).toBe(true);
|
expect(initial.ready).toBe(!incompleteVideo);
|
||||||
useImageWorkspaceStore.setState({
|
useImageWorkspaceStore.setState({
|
||||||
workspace, activeWorkspaceId: workspace.workspace.workspaceId, status: 'ready',
|
workspace, activeWorkspaceId: workspace.workspace.workspaceId, status: 'ready',
|
||||||
|
quoteBlockers: initial.blockers,
|
||||||
});
|
});
|
||||||
vi.mocked(uploadImageWorkspaceAsset).mockResolvedValue(asset);
|
vi.mocked(uploadImageWorkspaceAsset).mockResolvedValue(asset);
|
||||||
vi.mocked(submitImageWorkspaceCommand).mockImplementation(async (command) => {
|
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 };
|
return { workspace, runId: 'offline-run', clientOperationId: command.clientOperationId };
|
||||||
});
|
});
|
||||||
render(<LiveCard />);
|
render(<LiveCard />);
|
||||||
fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } });
|
if (incompleteVideo) {
|
||||||
await waitFor(() => expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.intent.media).toBe('video'));
|
expect(initial.blockers.map((issue) => issue.code)).toEqual(expect.arrayContaining([
|
||||||
fireEvent.change(screen.getByLabelText('选择参考图片'), {
|
'video_duration_required', 'video_shots_required',
|
||||||
target: { files: [new File(['offline-upload'], 'start.png', { type: 'image/png' })] },
|
]));
|
||||||
});
|
expect(screen.getByRole('combobox', { name: '时长' })).toHaveValue('');
|
||||||
await screen.findByText('已选用');
|
expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled();
|
||||||
expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.first_frame_asset_id).toBe('image-1');
|
expect(screen.queryByText('至少写一个视频画面。')).not.toBeInTheDocument();
|
||||||
expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.shots.length).toBe(1);
|
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());
|
await waitFor(() => expect(screen.getByRole('button', { name: '准备制作方案' })).toBeEnabled());
|
||||||
fireEvent.click(screen.getByRole('button', { name: '准备制作方案' }));
|
fireEvent.click(screen.getByRole('button', { name: '准备制作方案' }));
|
||||||
await waitFor(() => expect(commands).toContain('request_quote'));
|
await waitFor(() => expect(commands).toContain('request_quote'));
|
||||||
console.log('FLOW_COMMANDS', commands);
|
console.log('FLOW_COMMANDS', commands);
|
||||||
console.log('FLOW_BLOCKERS', useImageWorkspaceStore.getState().quoteBlockers.map((item) => item.code));
|
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(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]);
|
||||||
expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeEnabled();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' }));
|
fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' }));
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function YouthCreationCard({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [medium, setMedium] = useState(values.intent.media ?? 'image');
|
const [medium, setMedium] = useState(values.intent.media ?? 'image');
|
||||||
const [aspectRatio, setAspectRatio] = useState(values.output.aspect_ratio ?? '');
|
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 [variantCount, setVariantCount] = useState(values.output.variant_count ?? 1);
|
||||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -183,7 +183,7 @@ export function YouthCreationCard({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMedium(values.intent.media ?? 'image');
|
setMedium(values.intent.media ?? 'image');
|
||||||
setAspectRatio(values.output.aspect_ratio ?? '');
|
setAspectRatio(values.output.aspect_ratio ?? '');
|
||||||
setDuration(values.video.total_duration_seconds ?? 6);
|
setDuration(values.video.total_duration_seconds);
|
||||||
setVariantCount(values.output.variant_count ?? 1);
|
setVariantCount(values.output.variant_count ?? 1);
|
||||||
}, [
|
}, [
|
||||||
values.intent.media,
|
values.intent.media,
|
||||||
@@ -205,6 +205,9 @@ export function YouthCreationCard({
|
|||||||
));
|
));
|
||||||
const busy = localBusy || submitting || uploading;
|
const busy = localBusy || submitting || uploading;
|
||||||
const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker');
|
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 options = generationOptions ?? UNAVAILABLE_GENERATION_OPTIONS;
|
||||||
const activeOptions = options[medium];
|
const activeOptions = options[medium];
|
||||||
const supportedAspectRatios = activeOptions.supportedAspectRatios;
|
const supportedAspectRatios = activeOptions.supportedAspectRatios;
|
||||||
@@ -228,7 +231,7 @@ export function YouthCreationCard({
|
|||||||
&& !unsupportedReferences
|
&& !unsupportedReferences
|
||||||
&& unboundReferenceNumbers.length === 0
|
&& unboundReferenceNumbers.length === 0
|
||||||
&& variantCount <= activeOptions.maxOutputCount
|
&& variantCount <= activeOptions.maxOutputCount
|
||||||
&& !(medium === 'video' && !supportedDurations.includes(duration));
|
&& !(medium === 'video' && (duration === null || !supportedDurations.includes(duration)));
|
||||||
const canAddReference = medium === 'video'
|
const canAddReference = medium === 'video'
|
||||||
? activeOptions.supportedReferenceRoles.includes('first_frame')
|
? activeOptions.supportedReferenceRoles.includes('first_frame')
|
||||||
&& (!selectedFirstFrameAsset
|
&& (!selectedFirstFrameAsset
|
||||||
@@ -313,7 +316,15 @@ export function YouthCreationCard({
|
|||||||
) => {
|
) => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
optimisticUpdate();
|
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) => {
|
const uploadReference = async (file: File) => {
|
||||||
@@ -424,9 +435,10 @@ export function YouthCreationCard({
|
|||||||
if (saveState === 'repricing') return { text: '正在重新核价', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
|
if (saveState === 'repricing') return { text: '正在重新核价', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
|
||||||
if (saveState === 'error') return { text: '方案需要重试', icon: <RefreshCw className="h-4 w-4" /> };
|
if (saveState === 'error') return { text: '方案需要重试', icon: <RefreshCw className="h-4 w-4" /> };
|
||||||
if (promptDirty) return { text: '提示词尚未保存', icon: <Sparkles className="h-4 w-4" /> };
|
if (promptDirty) return { text: '提示词尚未保存', icon: <Sparkles className="h-4 w-4" /> };
|
||||||
if (blockers.length > 0) return { text: '还需要补充一点信息', icon: <Sparkles className="h-4 w-4" /> };
|
if (inputBlockers.length > 0 || (medium === 'video' && duration === null)) return { text: '还需要补充一点信息', icon: <Sparkles className="h-4 w-4" /> };
|
||||||
|
if (needsMotionPreparation) return { text: '视频动作待整理', icon: <Sparkles className="h-4 w-4" /> };
|
||||||
return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: <Check className="h-4 w-4" /> };
|
return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: <Check className="h-4 w-4" /> };
|
||||||
}, [blockers.length, offeredQuote, promptDirty, saveState]);
|
}, [inputBlockers.length, medium, duration, needsMotionPreparation, offeredQuote, promptDirty, saveState]);
|
||||||
|
|
||||||
if (!hasActiveCreationPlan(workspace)) return null;
|
if (!hasActiveCreationPlan(workspace)) return null;
|
||||||
|
|
||||||
@@ -707,7 +719,7 @@ export function YouthCreationCard({
|
|||||||
void runOperations(operations, { prepare: true }).then((updated) => {
|
void runOperations(operations, { prepare: true }).then((updated) => {
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
setMedium(values.intent.media ?? 'image');
|
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({
|
|||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{medium === 'video' && (
|
{medium === 'video' && duration === null && (
|
||||||
|
<label className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||||
|
时长
|
||||||
|
<Select
|
||||||
|
aria-label="时长"
|
||||||
|
value=""
|
||||||
|
disabled={busy || supportedDurations.length === 0}
|
||||||
|
className="h-8 w-[110px] rounded-lg px-2 py-0 pr-7 text-xs"
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.currentTarget.value);
|
||||||
|
changeParameter(
|
||||||
|
[{ kind: 'set', path: 'video.total_duration_seconds', value: next }],
|
||||||
|
() => setDuration(next),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择时长</option>
|
||||||
|
{supportedDurations.map((seconds) => (
|
||||||
|
<option key={seconds} value={seconds}>{seconds} 秒</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{medium === 'video' && duration !== null && (
|
||||||
<Stepper
|
<Stepper
|
||||||
label="时长"
|
label="时长"
|
||||||
value={duration}
|
value={duration}
|
||||||
@@ -786,9 +822,15 @@ export function YouthCreationCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{blockers.length > 0 && (
|
{needsMotionPreparation && (
|
||||||
|
<p className="rounded-xl bg-surface-subtle px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
{youthCompilationIssueMessage({ code: 'video_shots_required', message: '', severity: 'blocker' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inputBlockers.length > 0 && (
|
||||||
<div className="rounded-xl border border-destructive/15 bg-destructive/[0.035] px-3 py-2 text-xs leading-5 text-destructive">
|
<div className="rounded-xl border border-destructive/15 bg-destructive/[0.035] px-3 py-2 text-xs leading-5 text-destructive">
|
||||||
{blockers.map((blocker) => (
|
{inputBlockers.map((blocker) => (
|
||||||
<p key={`${blocker.code}-${blocker.path ?? ''}`}>{youthCompilationIssueMessage(blocker)}</p>
|
<p key={`${blocker.code}-${blocker.path ?? ''}`}>{youthCompilationIssueMessage(blocker)}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const COMPILATION_COPY: Readonly<Record<string, string>> = {
|
|||||||
image_direction_required: '再说一句你想画什么,或想把图片改成什么样。',
|
image_direction_required: '再说一句你想画什么,或想把图片改成什么样。',
|
||||||
video_duration_required: '先选择视频时长。',
|
video_duration_required: '先选择视频时长。',
|
||||||
video_first_frame_required: '先选择视频从哪张图片开始。',
|
video_first_frame_required: '先选择视频从哪张图片开始。',
|
||||||
video_shots_required: '至少写一个视频画面。',
|
video_shots_required: '视频动作还没整理好。点击“准备制作方案”,AI 会根据你的描述继续整理。',
|
||||||
video_shot_timing_required: '多个视频画面需要安排好先后时间,请让 AI 帮你整理。',
|
video_shot_timing_required: '多个视频画面需要安排好先后时间,请让 AI 帮你整理。',
|
||||||
video_timeline_must_start_at_zero: '第一个视频画面需要从开头开始。',
|
video_timeline_must_start_at_zero: '第一个视频画面需要从开头开始。',
|
||||||
video_timeline_gap: '视频画面之间有空缺,请让 AI 帮你重新安排。',
|
video_timeline_gap: '视频画面之间有空缺,请让 AI 帮你重新安排。',
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ test.describe('AI Design V2 workspace', () => {
|
|||||||
preserve_instructions: [],
|
preserve_instructions: [],
|
||||||
change_instructions: [],
|
change_instructions: [],
|
||||||
},
|
},
|
||||||
video: { shots: [] },
|
video: { shots: [], total_duration_seconds: null as number | null },
|
||||||
output: {
|
output: {
|
||||||
aspect_ratio: '3:4',
|
aspect_ratio: '3:4',
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
@@ -186,11 +186,19 @@ test.describe('AI Design V2 workspace', () => {
|
|||||||
__designE2eAuthReady?: boolean;
|
__designE2eAuthReady?: boolean;
|
||||||
__designE2eProfileReady?: boolean;
|
__designE2eProfileReady?: boolean;
|
||||||
__designE2eCompleteTask?: () => void;
|
__designE2eCompleteTask?: () => void;
|
||||||
|
__designE2eOpenIncompleteVideo?: () => void;
|
||||||
__designE2eDownloadCount?: number;
|
__designE2eDownloadCount?: number;
|
||||||
};
|
};
|
||||||
e2eGlobal.__designE2eAuthReady = false;
|
e2eGlobal.__designE2eAuthReady = false;
|
||||||
e2eGlobal.__designE2eProfileReady = false;
|
e2eGlobal.__designE2eProfileReady = false;
|
||||||
e2eGlobal.__designE2eDownloadCount = 0;
|
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 = () => {
|
e2eGlobal.__designE2eCompleteTask = () => {
|
||||||
workspace.tasks[0] = {
|
workspace.tasks[0] = {
|
||||||
...workspace.tasks[0],
|
...workspace.tasks[0],
|
||||||
@@ -303,6 +311,9 @@ test.describe('AI Design V2 workspace', () => {
|
|||||||
if (operation.kind === 'set' && operation.path === 'output.orientation') {
|
if (operation.kind === 'set' && operation.path === 'output.orientation') {
|
||||||
workspace.form.specification.values.output.orientation = String(operation.value ?? '');
|
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) {
|
if (command.input?.kind === 'chat' && command.input.message) {
|
||||||
@@ -589,6 +600,20 @@ test.describe('AI Design V2 workspace', () => {
|
|||||||
expect(await app.evaluate(() => (
|
expect(await app.evaluate(() => (
|
||||||
globalThis as typeof globalThis & { __designE2eDownloadCount?: number }
|
globalThis as typeof globalThis & { __designE2eDownloadCount?: number }
|
||||||
).__designE2eDownloadCount)).toBe(0);
|
).__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 {
|
} finally {
|
||||||
await closeElectronApp(app);
|
await closeElectronApp(app);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { DesignPlanHistory } from '@/pages/ImageCanvas/DesignPlanHistory';
|
import { DesignPlanHistory } from '@/pages/ImageCanvas/DesignPlanHistory';
|
||||||
import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard';
|
import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard';
|
||||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||||
import type { DesignGenerationTask } from '../../shared/image-workspace';
|
import type { DesignCompilationIssue, DesignGenerationTask } from '../../shared/image-workspace';
|
||||||
import {
|
import {
|
||||||
designCapabilitiesFixture,
|
designCapabilitiesFixture,
|
||||||
designFormFixture,
|
designFormFixture,
|
||||||
@@ -58,7 +58,7 @@ function taskFixture(overrides: Partial<DesignGenerationTask> = {}): DesignGener
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(workspace = designWorkspaceFixture()) {
|
function renderCard(workspace = designWorkspaceFixture(), quoteBlockers: DesignCompilationIssue[] = []) {
|
||||||
const actions = {
|
const actions = {
|
||||||
applyFieldOperations: vi.fn().mockResolvedValue(workspace),
|
applyFieldOperations: vi.fn().mockResolvedValue(workspace),
|
||||||
prepareGeneration: vi.fn().mockResolvedValue(workspace),
|
prepareGeneration: vi.fn().mockResolvedValue(workspace),
|
||||||
@@ -74,7 +74,7 @@ function renderCard(workspace = designWorkspaceFixture()) {
|
|||||||
render(
|
render(
|
||||||
<YouthCreationCard
|
<YouthCreationCard
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
quoteBlockers={[]}
|
quoteBlockers={quoteBlockers}
|
||||||
generationAvailable
|
generationAvailable
|
||||||
generationOptions={designCapabilitiesFixture.generationOptions}
|
generationOptions={designCapabilitiesFixture.generationOptions}
|
||||||
onQuoteOffered={vi.fn()}
|
onQuoteOffered={vi.fn()}
|
||||||
@@ -123,6 +123,49 @@ describe('YouthCreationCard', () => {
|
|||||||
useImageWorkspaceStore.getState().reset();
|
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 () => {
|
it('persists the displayed supported duration when entering video mode', async () => {
|
||||||
const actions = renderCard();
|
const actions = renderCard();
|
||||||
fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } });
|
fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } });
|
||||||
|
|||||||
Reference in New Issue
Block a user