From 6b5c6450fc20857a2b5e555d5209ecb434f49743 Mon Sep 17 00:00:00 2001 From: jiajia Date: Wed, 20 May 2026 21:14:01 +0800 Subject: [PATCH] Keep video quick controls in sync with node settings The bottom composer and right-side node settings both expose video duration, resolution, and sound. Selected video nodes now receive quick-control changes immediately, so both surfaces read the same node data instead of diverging until blur or generate. Constraint: Bottom composer remains the primary quick control while existing node settings continue to read workflow store data Rejected: Remove the node settings panel | broader product change outside this fix Rejected: Convert all composer draft fields to immediate store writes | higher risk and unnecessary for the reported video parameter mismatch Confidence: high Scope-risk: narrow Directive: Keep shared video parameters on GenerateVideoNodeData; do not introduce a second parameter source for quick controls Tested: npm run test:run -- src/components/__tests__/GenerationComposer.test.tsx Tested: npm run test:run -- src/components/__tests__/GenerateVideoNode.test.tsx Tested: git diff --check Not-tested: full lint/typecheck still blocked by existing repo script/type issues from prior verification --- .../__tests__/GenerationComposer.test.tsx | 29 ++++++++++ .../composer/GenerationComposer.tsx | 57 +++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 395e7d86..f12d530b 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -408,6 +408,35 @@ describe("GenerationComposer", () => { expect(data.soundEnabled).toBe(false); }); + it("writes selected video quick settings to the node immediately", () => { + useWorkflowStore.setState({ + nodes: [videoNode("vid-1", true, { + durationSeconds: 6, + resolution: "720p", + soundEnabled: true, + })], + }); + render(); + + fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "4" } }); + fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1080p" } }); + fireEvent.click(screen.getByRole("switch", { name: "Sound" })); + + const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData; + expect(data.durationSeconds).toBe(4); + expect(data.resolution).toBe("1080p"); + expect(data.soundEnabled).toBe(false); + }); + + it("shows custom selected video duration in quick settings", () => { + useWorkflowStore.setState({ + nodes: [videoNode("vid-1", true, { durationSeconds: 6 })], + }); + render(); + + expect(screen.getByLabelText("Duration")).toHaveValue("6"); + }); + it("uses the audio model filter and reruns the selected audio node", async () => { useWorkflowStore.setState({ nodes: [audioNode("aud-1", true)] }); render(); diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index a8a250a3..9137e2dd 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -85,6 +85,7 @@ interface ComposerAdapter { const ASPECT_RATIOS: AspectRatio[] = ["1:1", "3:4", "4:3", "9:16", "16:9", "21:9"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; +const VIDEO_DURATION_QUICK_OPTIONS = [4, 5, 8, 10, 15]; const IMAGE_COUNTS: ImageGenerationCount[] = [1, 2, 4]; const MAX_REFERENCE_IMAGES = 4; const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024; @@ -645,6 +646,12 @@ export function GenerationComposer() { ? inferCapabilityFromModel(draft.selectedModel) ?? "image" : context.capability; const videoSoundSupported = activeCapability === "video" && modelSupportsVideoSound(draft.selectedModel); + const videoDurationOptions = useMemo(() => { + const duration = normalizeVideoDurationSeconds(draft.durationSeconds); + return VIDEO_DURATION_QUICK_OPTIONS.includes(duration) + ? VIDEO_DURATION_QUICK_OPTIONS + : [...VIDEO_DURATION_QUICK_OPTIONS, duration].sort((a, b) => a - b); + }, [draft.durationSeconds]); const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit"; const modelIsValid = context.mode === "empty-create" ? Boolean(inferNodeTypeFromModel(draft.selectedModel)) @@ -759,6 +766,48 @@ export function GenerationComposer() { }); }, []); + const updateVideoQuickDraft = useCallback( + (patch: Partial, fields: DraftField[]) => { + const activeContext = contextRef.current; + setDraft((current) => { + const next = { ...current, ...patch }; + draftRef.current = next; + return next; + }); + + if (activeContext.mode === "node-edit" && activeContext.node?.type === "generateVideo") { + const nodePatch: Partial = {}; + if (fields.includes("durationSeconds") && patch.durationSeconds !== undefined) { + nodePatch.durationSeconds = normalizeVideoDurationSeconds(patch.durationSeconds); + } + if (fields.includes("resolution") && patch.resolution !== undefined) { + nodePatch.resolution = patch.resolution; + } + if (fields.includes("soundEnabled") && Object.prototype.hasOwnProperty.call(patch, "soundEnabled")) { + nodePatch.soundEnabled = patch.soundEnabled; + } + if (Object.keys(nodePatch).length > 0) { + updateNodeData(activeContext.node.id, nodePatch); + } + setDirtyFields((current) => { + const next = new Set(current); + fields.forEach((field) => next.delete(field)); + dirtyFieldsRef.current = next; + return next; + }); + return; + } + + setDirtyFields((current) => { + const next = new Set(current); + fields.forEach((field) => next.add(field)); + dirtyFieldsRef.current = next; + return next; + }); + }, + [updateNodeData] + ); + const handleComposerBlur = useCallback( (event: FocusEvent) => { const nextTarget = event.relatedTarget as Node | null; @@ -1553,11 +1602,11 @@ export function GenerationComposer() { aria-label={t("node.duration")} value={draft.durationSeconds} onChange={(event) => - markDraft({ durationSeconds: normalizeVideoDurationSeconds(event.target.value) }, ["durationSeconds"]) + updateVideoQuickDraft({ durationSeconds: normalizeVideoDurationSeconds(event.target.value) }, ["durationSeconds"]) } className="rounded-md border border-transparent bg-transparent px-1.5 py-1 text-neutral-200 outline-none transition-colors hover:border-neutral-700 hover:bg-neutral-700/60" > - {[4, 5, 8, 10, 15].map((seconds) => ( + {videoDurationOptions.map((seconds) => ( @@ -1567,7 +1616,7 @@ export function GenerationComposer() {