fix(canvas): cap video duration choices
This commit is contained in:
1 parent
9e1e03f583
commit
566f99f5ed
4 files changed
+135
-6
No files matched your search
@@ -0,0 +1,55 @@
|
|||||||
|
# Task: Fix AI design video duration selector
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260818-video-duration-client-a7c42f
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: main
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore
|
||||||
|
- Base commit: 9e1e03f58312dcdff53f520be7247c9921ec288b
|
||||||
|
- Owner: codex-video-duration-client
|
||||||
|
- Status: Ready for integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Audit and fix the AI Design video confirmation duration selector in `src/pages/ImageCanvas/index.tsx`.
|
||||||
|
- Defensively expose only enabled server-provided duration options from 2 through 6 seconds, including when an older server Quote still selects or advertises 7-15 seconds.
|
||||||
|
- Verify that selecting 6 seconds survives Quote repricing and is sent by both the Quote PATCH and final confirmation request.
|
||||||
|
- Add focused Renderer regression coverage in `tests/unit/image-canvas-page.test.tsx` and verify the existing Main-to-Works snake-case request mapping in `tests/unit/works-square-design-workspace.test.ts`.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Own only `src/pages/ImageCanvas/index.tsx`, `tests/unit/image-canvas-page.test.tsx`, `tests/unit/works-square-design-workspace.test.ts`, and this task record.
|
||||||
|
- Preserve Conversation-owned Quote state, server-authoritative repricing, and existing request field naming across Renderer and Main.
|
||||||
|
- Do not modify the Works Square server, Electron Main routes/adapters, unrelated AI Design interactions, or files owned by peer tasks.
|
||||||
|
- Keep invalid legacy selected durations visibly unselected until the user chooses an allowed value; do not silently submit a replacement duration without a fresh server Quote.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- The confirmation card now filters enabled server-provided video durations to the inclusive 2-6 second client boundary.
|
||||||
|
- A legacy Quote whose selected duration is outside that boundary renders an explicit disabled `请选择时长` placeholder instead of visually selecting one allowed value while retaining a different hidden draft value.
|
||||||
|
- Selecting 6 seconds updates the controlled draft, survives the debounced server re-quote, and is used by final confirmation.
|
||||||
|
- Existing request seams were verified: Renderer sends `durationSeconds: 6`, and the Main cloud adapter sends `duration_seconds: 6` to Works Square.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Red test: the new legacy-duration regression initially failed because the native select visually resolved the unmatched controlled value `10` to option `2`.
|
||||||
|
- `corepack pnpm exec vitest run tests/unit/image-canvas-page.test.tsx -t "limits legacy video durations"` — passed after the fix (1 passed, 46 skipped).
|
||||||
|
- `corepack pnpm exec vitest run tests/unit/image-canvas-page.test.tsx tests/unit/image-workspace-api.test.ts tests/unit/works-square-design-workspace.test.ts` — passed (3 files, 92 tests).
|
||||||
|
- `corepack pnpm run typecheck` — passed.
|
||||||
|
- `corepack pnpm exec eslint src/pages/ImageCanvas/index.tsx tests/unit/image-canvas-page.test.tsx tests/unit/works-square-design-workspace.test.ts` — passed with no output.
|
||||||
|
- `corepack pnpm run build:vite` — passed for Renderer, Electron Main, and Preload; only the existing dynamic-import and chunk-size warnings were reported.
|
||||||
|
- `git diff --check` — passed; Git reported only the repository's LF-to-CRLF checkout warning.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Production acceptance still requires a real server Quote containing the supported duration options; the client intentionally does not synthesize missing 2-6 second choices.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: `.project-docs/40-domain/business-rules.md` during Integration Gate.
|
||||||
|
- Proposal: record that the AI Design client displays only enabled server-provided video duration options in the inclusive 2-6 second range; legacy selected values outside the range require an explicit allowed selection and fresh server Quote before confirmation.
|
||||||
|
- Evidence: focused controlled-select regression, Quote PATCH assertion, confirmation assertion, and Main-to-Works `duration_seconds: 6` contract test.
|
||||||
|
- Future impact: prevents older server configuration from re-exposing unsupported long video durations or silently submitting a hidden value different from the visible selection.
|
||||||
|
- Semantic conflicts: none; server pricing and option authority remain unchanged.
|
||||||
|
- Human confirmation required: no.
|
||||||
@@ -265,6 +265,9 @@ type EditableGenerationParameters = Partial<Pick<
|
|||||||
'resolution' | 'aspectRatio' | 'durationSeconds'
|
'resolution' | 'aspectRatio' | 'durationSeconds'
|
||||||
>>;
|
>>;
|
||||||
|
|
||||||
|
const MIN_VIDEO_DURATION_SECONDS = 2;
|
||||||
|
const MAX_VIDEO_DURATION_SECONDS = 6;
|
||||||
|
|
||||||
function quoteDraftFromQuote(quote: DesignGenerationQuote): GenerationQuoteDraft {
|
function quoteDraftFromQuote(quote: DesignGenerationQuote): GenerationQuoteDraft {
|
||||||
return {
|
return {
|
||||||
quoteId: quote.quoteId,
|
quoteId: quote.quoteId,
|
||||||
@@ -728,8 +731,15 @@ function QuoteCard({
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}) {
|
}) {
|
||||||
const availableResolutions = quote.generationOptions.resolutions.filter((option) => !option.disabled);
|
const availableResolutions = quote.generationOptions.resolutions.filter((option) => !option.disabled);
|
||||||
const availableDurations = quote.generationOptions.durations.filter((option) => !option.disabled);
|
const availableDurations = quote.generationOptions.durations.filter((option) => (
|
||||||
|
!option.disabled
|
||||||
|
&& option.value >= MIN_VIDEO_DURATION_SECONDS
|
||||||
|
&& option.value <= MAX_VIDEO_DURATION_SECONDS
|
||||||
|
));
|
||||||
const availableAspectRatios = quote.generationOptions.aspectRatios.filter((option) => !option.disabled);
|
const availableAspectRatios = quote.generationOptions.aspectRatios.filter((option) => !option.disabled);
|
||||||
|
const selectedDuration = availableDurations.some(
|
||||||
|
(option) => option.value === draft.generationParameters.durationSeconds,
|
||||||
|
) ? String(draft.generationParameters.durationSeconds) : '';
|
||||||
const updateParameters = (next: EditableGenerationParameters) => {
|
const updateParameters = (next: EditableGenerationParameters) => {
|
||||||
onDraftChange({
|
onDraftChange({
|
||||||
...draft,
|
...draft,
|
||||||
@@ -819,9 +829,7 @@ function QuoteCard({
|
|||||||
aria-label="视频时长"
|
aria-label="视频时长"
|
||||||
data-testid="design-quote-duration"
|
data-testid="design-quote-duration"
|
||||||
className="mt-1.5 h-9 bg-background text-xs"
|
className="mt-1.5 h-9 bg-background text-xs"
|
||||||
value={draft.generationParameters.durationSeconds === null
|
value={selectedDuration}
|
||||||
? ''
|
|
||||||
: String(draft.generationParameters.durationSeconds)}
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const option = availableDurations.find(
|
const option = availableDurations.find(
|
||||||
@@ -830,6 +838,9 @@ function QuoteCard({
|
|||||||
if (option) updateParameters({ durationSeconds: option.value });
|
if (option) updateParameters({ durationSeconds: option.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{selectedDuration === '' ? (
|
||||||
|
<option value="" disabled>请选择时长</option>
|
||||||
|
) : null}
|
||||||
{availableDurations.map((option) => (
|
{availableDurations.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -510,6 +510,69 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
|||||||
expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('limits legacy video durations to 2-6 seconds and preserves 6 seconds through confirmation', async () => {
|
||||||
|
const videoConversation = conversationFixture();
|
||||||
|
const videoQuote = videoConversation.messages[1].generationQuote!;
|
||||||
|
videoConversation.brief.medium = 'video';
|
||||||
|
videoQuote.medium = 'video';
|
||||||
|
videoQuote.generationParameters = {
|
||||||
|
...videoQuote.generationParameters,
|
||||||
|
durationSeconds: 10,
|
||||||
|
};
|
||||||
|
videoQuote.generationOptions.durations = [2, 6, 7, 15].map((value) => ({
|
||||||
|
value,
|
||||||
|
label: `${value} 秒`,
|
||||||
|
default: value === 10,
|
||||||
|
disabled: false,
|
||||||
|
multiplier: 1,
|
||||||
|
}));
|
||||||
|
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(videoConversation);
|
||||||
|
updateImageWorkspaceGenerationQuoteMock.mockImplementationOnce(async (
|
||||||
|
_workspaceId: string,
|
||||||
|
_quoteId: string,
|
||||||
|
finalPrompt: string,
|
||||||
|
generationParameters: DesignGenerationQuote['generationParameters'],
|
||||||
|
) => ({
|
||||||
|
...videoQuote,
|
||||||
|
finalPrompt,
|
||||||
|
generationParameters,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
|
||||||
|
const durationSelect = await screen.findByTestId('design-quote-duration');
|
||||||
|
expect(durationSelect).toHaveValue('');
|
||||||
|
expect(within(durationSelect).getAllByRole('option').map((option) => option.getAttribute('value')))
|
||||||
|
.toEqual(['', '2', '6']);
|
||||||
|
|
||||||
|
fireEvent.change(durationSelect, { target: { value: '6' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledWith(
|
||||||
|
'workspace-cloud',
|
||||||
|
'quote-one',
|
||||||
|
videoQuote.finalPrompt,
|
||||||
|
{
|
||||||
|
...videoQuote.generationParameters,
|
||||||
|
durationSeconds: 6,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('design-quote-duration')).toHaveValue('6'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledWith(
|
||||||
|
'workspace-cloud',
|
||||||
|
'conversation-cloud',
|
||||||
|
1,
|
||||||
|
'quote-one',
|
||||||
|
expect.stringMatching(/^turn-/),
|
||||||
|
videoQuote.finalPrompt,
|
||||||
|
{
|
||||||
|
...videoQuote.generationParameters,
|
||||||
|
durationSeconds: 6,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
it('offers an actionable retry when re-quoting fails', async () => {
|
it('offers an actionable retry when re-quoting fails', async () => {
|
||||||
updateImageWorkspaceGenerationQuoteMock.mockRejectedValueOnce(
|
updateImageWorkspaceGenerationQuoteMock.mockRejectedValueOnce(
|
||||||
new ImageWorkspaceApiError(409, 'generation_quote_invalid', '当前生成方案已失效'),
|
new ImageWorkspaceApiError(409, 'generation_quote_invalid', '当前生成方案已失效'),
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ describe('Works Square AI design adapter', () => {
|
|||||||
model: 'image-model-one',
|
model: 'image-model-one',
|
||||||
resolution: '1024x1024',
|
resolution: '1024x1024',
|
||||||
aspectRatio: '16:9',
|
aspectRatio: '16:9',
|
||||||
durationSeconds: null,
|
durationSeconds: 6,
|
||||||
},
|
},
|
||||||
})).resolves.toMatchObject({
|
})).resolves.toMatchObject({
|
||||||
quoteId: 'quote-one',
|
quoteId: 'quote-one',
|
||||||
@@ -586,7 +586,7 @@ describe('Works Square AI design adapter', () => {
|
|||||||
model: 'image-model-one',
|
model: 'image-model-one',
|
||||||
resolution: '1024x1024',
|
resolution: '1024x1024',
|
||||||
aspect_ratio: '16:9',
|
aspect_ratio: '16:9',
|
||||||
duration_seconds: null,
|
duration_seconds: 6,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user