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