Browse Source

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
feature_login^2
jiajia 2 months ago
parent
commit
6b5c6450fc
  1. 29
      src/components/__tests__/GenerationComposer.test.tsx
  2. 57
      src/components/composer/GenerationComposer.tsx

29
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(<GenerationComposer />);
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(<GenerationComposer />);
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(<GenerationComposer />);

57
src/components/composer/GenerationComposer.tsx

@ -85,6 +85,7 @@ interface ComposerAdapter<TData extends WorkflowNodeData> {
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<ComposerDraft>, 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<GenerateVideoNodeData> = {};
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<HTMLElement>) => {
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) => (
<option key={seconds} value={seconds} className="bg-neutral-900">
{t("node.seconds", { count: seconds })}
</option>
@ -1567,7 +1616,7 @@ export function GenerationComposer() {
<select
aria-label={t("node.resolution")}
value={draft.resolution}
onChange={(event) => markDraft({ resolution: event.target.value }, ["resolution"])}
onChange={(event) => updateVideoQuickDraft({ resolution: event.target.value }, ["resolution"])}
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"
>
{VIDEO_RESOLUTION_OPTIONS.map((item) => (
@ -1582,7 +1631,7 @@ export function GenerationComposer() {
type="button"
role="switch"
aria-checked={draft.soundEnabled ?? true}
onClick={() => markDraft({ soundEnabled: !(draft.soundEnabled ?? true) }, ["soundEnabled"])}
onClick={() => updateVideoQuickDraft({ soundEnabled: !(draft.soundEnabled ?? true) }, ["soundEnabled"])}
className={`rounded-md px-1.5 py-1 transition-colors ${
(draft.soundEnabled ?? true)
? "bg-cyan-500/15 text-cyan-200"

Loading…
Cancel
Save