fix(design): connect video preparation and playable history
This commit is contained in:
@@ -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',
|
||||
|
||||
18
tests/fixtures/design-workspace-v2.ts
vendored
18
tests/fixtures/design-workspace-v2.ts
vendored
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
|
||||
89
tests/unit/video-first-frame.test.ts
Normal file
89
tests/unit/video-first-frame.test.ts
Normal 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 的画面');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user