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() {