fix(design): connect video preparation and playable history

This commit is contained in:
2026-09-16 15:42:39 +08:00
parent 4321c77613
commit 1acca836fe
21 changed files with 900 additions and 115 deletions

View File

@@ -22,7 +22,30 @@ test.describe('AI Design V2 workspace', () => {
return respond({
success: true,
data: {
capabilities: { conversation: true, generation: true, image: true, video: true },
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
generationOptions: {
image: {
available: true,
supportedAspectRatios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supportedDurationSeconds: [],
maxOutputCount: 4,
requiresFirstFrame: false,
supportedReferenceRoles: ['inspiration'],
},
video: {
available: true,
supportedAspectRatios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supportedDurationSeconds: [1, 6, 12],
maxOutputCount: 1,
requiresFirstFrame: true,
supportedReferenceRoles: ['first_frame'],
},
},
},
workspaces: [],
},
});
@@ -227,7 +250,30 @@ test.describe('AI Design V2 workspace', () => {
}
if (path === '/api/works/image-workspace') {
return envelope({
capabilities: { conversation: true, generation: true, image: true, video: true },
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
generationOptions: {
image: {
available: true,
supportedAspectRatios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supportedDurationSeconds: [],
maxOutputCount: 4,
requiresFirstFrame: false,
supportedReferenceRoles: ['inspiration'],
},
video: {
available: true,
supportedAspectRatios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supportedDurationSeconds: [1, 6, 12],
maxOutputCount: 1,
requiresFirstFrame: true,
supportedReferenceRoles: ['first_frame'],
},
},
},
workspaces: [workspace.workspace],
});
}
@@ -246,6 +292,9 @@ test.describe('AI Design V2 workspace', () => {
};
if (command.kind === 'apply_input') {
if (command.input?.kind === 'prepare_generation') {
(globalThis as typeof globalThis & { __designPreparedRevision?: number }).__designPreparedRevision = workspace.form.specificationRevision + 1;
}
if (command.input?.kind === 'direct_edit') {
for (const operation of command.input.operations ?? []) {
if (operation.kind === 'set' && operation.path === 'output.aspect_ratio') {
@@ -275,6 +324,9 @@ test.describe('AI Design V2 workspace', () => {
}
if (command.kind === 'request_quote') {
if ((globalThis as typeof globalThis & { __designPreparedRevision?: number }).__designPreparedRevision !== workspace.form.specificationRevision) {
throw new Error('Quote must use the latest prepared revision');
}
workspace.form.activeQuotes = [{
quoteId: 'quote-1',
status: 'offered',

View File

@@ -13,6 +13,24 @@ export const designCapabilitiesFixture: DesignCapabilities = {
generation: true,
image: true,
video: true,
generationOptions: {
image: {
available: true,
supportedAspectRatios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supportedDurationSeconds: [],
maxOutputCount: 4,
requiresFirstFrame: false,
supportedReferenceRoles: ['inspiration', 'subject_identity'],
},
video: {
available: true,
supportedAspectRatios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supportedDurationSeconds: Array.from({ length: 30 }, (_, index) => index + 1),
maxOutputCount: 4,
requiresFirstFrame: true,
supportedReferenceRoles: ['first_frame'],
},
},
};
export const designValuesFixture: DesignSpecificationValues = {

View File

@@ -203,6 +203,23 @@ describe('AI design V2 Main route boundary', () => {
clientOperationId: 'operation-1',
},
},
{
body: {
kind: 'apply_input',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-1',
input: { kind: 'prepare_generation' },
},
expected: {
kind: 'apply_input',
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-1',
input: { kind: 'prepare_generation', operations: [] },
},
},
{
body: {
kind: 'confirm_generation',

View File

@@ -1427,7 +1427,7 @@ describe('V2 Living Form store', () => {
expect(useImageWorkspaceStore.getState().workspace?.form.specificationRevision).toBe(4);
});
it('treats a terminal quote-blocked response as definitive instead of unknown', async () => {
it('stops before quoting when preparation fails even without a blocker event', async () => {
await loadedStore();
submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
422,
@@ -1441,7 +1441,11 @@ describe('V2 Living Form store', () => {
});
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
expect(useImageWorkspaceStore.getState().error).toBeNull();
expect(useImageWorkspaceStore.getState().error).toBe('方案还没整理好,请稍后重试');
expect(submitCommandMock).toHaveBeenCalledTimes(1);
expect(submitCommandMock).toHaveBeenCalledWith(expect.objectContaining({
kind: 'apply_input', input: { kind: 'prepare_generation', operations: [] },
}));
});
it('settles an unknown quote from its blocked event, then allows a revised Quote', async () => {
@@ -1528,14 +1532,23 @@ describe('V2 Living Form store', () => {
it('retains an uncertain accepted write and retries the exact same command identity', async () => {
await loadedStore();
submitCommandMock.mockRejectedValueOnce(new Error('network disconnected'));
operationIdMock
.mockReturnValueOnce('operation-prepare')
.mockReturnValueOnce('operation-quote');
submitCommandMock
.mockResolvedValueOnce({
clientOperationId: 'operation-prepare',
runId: 'run-prepare',
workspace: designWorkspaceFixture(),
})
.mockRejectedValueOnce(new Error('network disconnected'));
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toThrow(
'network disconnected',
);
const pending = useImageWorkspaceStore.getState().pendingOperations['operation-1'];
const pending = useImageWorkspaceStore.getState().pendingOperations['operation-quote'];
expect(pending).toMatchObject({
id: 'operation-1',
id: 'operation-quote',
status: 'unknown',
command: {
kind: 'request_quote',
@@ -1545,13 +1558,13 @@ describe('V2 Living Form store', () => {
});
submitCommandMock.mockResolvedValueOnce({
clientOperationId: 'operation-1',
clientOperationId: 'operation-quote',
runId: 'run-original',
workspace: designWorkspaceFixture(),
});
await useImageWorkspaceStore.getState().retryOperation('operation-1');
await useImageWorkspaceStore.getState().retryOperation('operation-quote');
expect(submitCommandMock).toHaveBeenNthCalledWith(2, pending.command);
expect(submitCommandMock).toHaveBeenLastCalledWith(pending?.command);
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { createVideoFirstFrameOperations } from '@/pages/ImageCanvas/video-first-frame';
import {
designFormFixture,
designValuesFixture,
designWorkspaceFixture,
} from '../fixtures/design-workspace-v2';
import type { DesignAsset } from '../../shared/image-workspace';
const imageAsset: DesignAsset = {
assetId: 'asset-first-frame',
workspaceId: 'workspace-1',
role: 'uploaded',
mediaType: 'image',
mimeType: 'image/png',
width: 720,
height: 1280,
durationMilliseconds: null,
generationTaskId: null,
createdAt: '2026-09-16T00:00:00Z',
contentPath: '/api/design/assets/asset-first-frame/content',
};
describe('video first-frame binding', () => {
it('adds an explicit first-frame reference without deleting the asset', () => {
const workspace = designWorkspaceFixture();
const result = createVideoFirstFrameOperations(workspace, imageAsset);
expect(result.nextPrompt).toContain('@图片1');
expect(result.operations).toEqual(expect.arrayContaining([
{ kind: 'set', path: 'video.first_frame_asset_id', value: imageAsset.assetId },
expect.objectContaining({ kind: 'set', path: 'references' }),
expect.objectContaining({ kind: 'set', path: 'content.concept' }),
]));
const referencesOperation = result.operations.find((operation) => operation.path === 'references');
expect(referencesOperation).toMatchObject({
value: [expect.objectContaining({ asset_id: imageAsset.assetId, role: 'first_frame' })],
});
});
it('promotes an existing reference and demotes the old first frame in place', () => {
const values = structuredClone(designValuesFixture);
values.intent.media = 'video';
values.references = [
{
id: 'reference-old',
asset_id: 'asset-old',
asset_revision: null,
role: 'first_frame',
preserve: [],
adapt: [],
do_not_copy: [],
reviewed_observations: [],
},
{
id: 'reference-next',
asset_id: imageAsset.assetId,
asset_revision: null,
role: 'inspiration',
preserve: [],
adapt: [],
do_not_copy: [],
reviewed_observations: [],
},
];
values.video.first_frame_asset_id = 'asset-old';
values.content.concept = '参考 @图片1 和 @图片2 的画面';
const workspace = designWorkspaceFixture({
form: designFormFixture({
specification: { schema_version: 1, values, field_decisions: {} },
}),
});
const result = createVideoFirstFrameOperations(workspace, imageAsset);
const referencesOperation = result.operations.find((operation) => operation.path === 'references');
expect(referencesOperation).toMatchObject({
value: [
expect.objectContaining({ asset_id: 'asset-old', role: 'inspiration' }),
expect.objectContaining({ asset_id: imageAsset.assetId, role: 'first_frame' }),
],
});
expect(result.operations).toContainEqual({
kind: 'set',
path: 'video.first_frame_asset_id',
value: imageAsset.assetId,
});
expect(result.nextPrompt).toBe('参考 @图片1 和 @图片2 的画面');
});
});

View File

@@ -94,7 +94,30 @@ describe('Works Square V2 Design Workspace adapter', () => {
const fetchMock = vi.fn(async (input: string | URL) => {
const url = String(input);
if (url.endsWith('/api/design/capabilities')) {
return jsonResponse({ conversation: true, generation: true, image: true, video: true });
return jsonResponse({
conversation: true,
generation: true,
image: true,
video: true,
generation_options: {
image: {
available: true,
supported_aspect_ratios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supported_duration_seconds: [],
max_output_count: 4,
requires_first_frame: false,
supported_reference_roles: ['inspiration'],
},
video: {
available: true,
supported_aspect_ratios: ['1:1', '3:4', '9:16', '4:3', '16:9'],
supported_duration_seconds: [1, 6, 12],
max_output_count: 1,
requires_first_frame: true,
supported_reference_roles: ['first_frame'],
},
},
});
}
if (url.includes('/api/design/workspaces?')) return jsonResponse([serverSummary]);
if (url.endsWith('/api/design/workspaces/workspace-1')) return jsonResponse(serverWorkspace);
@@ -168,6 +191,33 @@ describe('Works Square V2 Design Workspace adapter', () => {
},
},
},
{
command: {
kind: 'apply_input' as const,
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-prepare-1',
input: {
kind: 'prepare_generation' as const,
operations: [
{ kind: 'set' as const, path: 'intent.media', value: 'video' },
],
},
},
expected: {
client_command_id: 'operation-prepare-1',
name: 'design.input.apply',
input: {
expected_direction_revision: 4,
client_operation_id: 'operation-prepare-1',
input: {
kind: 'prepare_generation',
operations: [{ kind: 'set', path: 'intent.media', value: 'video' }],
},
},
},
},
{
command: {
kind: 'confirm_generation' as const,

View File

@@ -5,6 +5,7 @@ import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignGenerationTask } from '../../shared/image-workspace';
import {
designCapabilitiesFixture,
designFormFixture,
designQuoteFixture,
designValuesFixture,
@@ -60,6 +61,7 @@ function taskFixture(overrides: Partial<DesignGenerationTask> = {}): DesignGener
function renderCard(workspace = designWorkspaceFixture()) {
const actions = {
applyFieldOperations: vi.fn().mockResolvedValue(workspace),
prepareGeneration: vi.fn().mockResolvedValue(workspace),
requestQuote: vi.fn().mockResolvedValue(workspace),
confirmGeneration: vi.fn().mockResolvedValue(workspace),
};
@@ -74,6 +76,7 @@ function renderCard(workspace = designWorkspaceFixture()) {
workspace={workspace}
quoteBlockers={[]}
generationAvailable
generationOptions={designCapabilitiesFixture.generationOptions}
onQuoteOffered={vi.fn()}
/>,
);
@@ -120,6 +123,42 @@ describe('YouthCreationCard', () => {
useImageWorkspaceStore.getState().reset();
});
it('persists the displayed supported duration when entering video mode', async () => {
const actions = renderCard();
fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } });
await waitFor(() => expect(actions.prepareGeneration).toHaveBeenCalledWith([
{ kind: 'set', path: 'intent.media', value: 'video' },
{ kind: 'set', path: 'video.total_duration_seconds', value: 6 },
]));
expect(actions.confirmGeneration).not.toHaveBeenCalled();
});
it('does not invent generation capabilities while the capability response is missing', () => {
render(<YouthCreationCard workspace={designWorkspaceFixture()} quoteBlockers={[]} generationAvailable />);
expect(screen.getByRole('button', { name: '准备制作方案' })).toBeDisabled();
expect(screen.getByRole('option', { name: '视频' })).toBeDisabled();
});
it('restores the saved medium if conversion cannot be prepared', async () => {
const actions = renderCard();
actions.prepareGeneration.mockRejectedValueOnce(new Error('preparation failed'));
fireEvent.change(screen.getByRole('combobox', { name: '类型' }), { target: { value: 'video' } });
await waitFor(() => expect(screen.getByRole('combobox', { name: '类型' })).toHaveValue('image'));
expect(screen.getByText('方案需要重试')).toBeInTheDocument();
});
it('unbinds the starting picture when its reference is explicitly removed', 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';
const actions = renderCard(workspace);
fireEvent.click(screen.getByRole('button', { name: '删除参考图 1' }));
await waitFor(() => expect(actions.applyFieldOperations).toHaveBeenCalledWith(expect.arrayContaining([
{ kind: 'clear', path: 'video.first_frame_asset_id' },
])));
});
it('keeps the editable prompt and visible parameters in a lightweight inline plan', () => {
renderCard();