diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 8af1ff20..2db11b97 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -188,13 +188,15 @@ function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] { } function getSelectedSchemaOptionKey(options: SchemaSelectOption[], value: unknown): string | undefined { + const normalizedValue = String(value ?? "").toLowerCase(); return options.find((option) => - Object.is(option.value, value) || String(option.value) === String(value ?? "") + Object.is(option.value, value) || String(option.value).toLowerCase() === normalizedValue )?.key; } function valueMatchesSchemaOptions(value: unknown, options: string[]): boolean { - return options.some((option) => String(option) === String(value)); + const normalizedValue = String(value ?? "").toLowerCase(); + return options.some((option) => String(option).toLowerCase() === normalizedValue); } function buildGatewayDimensionSelects( @@ -207,8 +209,14 @@ function buildGatewayDimensionSelects( .filter((param) => nameSet.has(param.name) && param.options && param.options.length > 0) .map((parameter) => { const options = getSchemaSelectOptions(parameter); - const value = draft.parameters?.[parameter.name] - ?? options[0]?.value; + const value = + parameter.name === "resolution" + ? draft.resolution + : parameter.name === "ratio" || parameter.name === "videoRatio" + ? draft.aspectRatio + : parameter.name === "duration" + ? draft.durationSeconds + : draft.parameters?.[parameter.name] ?? options[0]?.value; return { parameter, options, @@ -217,28 +225,6 @@ function buildGatewayDimensionSelects( }); } -function getSchemaParameterFallbackValue(parameter: ModelParameter): unknown { - return parameter.default ?? parameter.options?.[0]?.value ?? parameter.enum?.[0]; -} - -function applySchemaParameterDefaults( - schema: ModelParameter[] | undefined, - parameters: Record | undefined -): { parameters: Record; changed: boolean } { - const next = { ...(parameters ?? {}) }; - let changed = false; - - for (const parameter of schema ?? []) { - if (next[parameter.name] !== undefined && next[parameter.name] !== null && next[parameter.name] !== "") continue; - const value = getSchemaParameterFallbackValue(parameter); - if (value === undefined || value === null || value === "") continue; - next[parameter.name] = value; - changed = true; - } - - return { parameters: next, changed }; -} - function getImageCountOptions(schema: ModelParameter[] | undefined): ImageGenerationCount[] { return getSchemaOptionValues(schema, IMAGE_COUNT_PARAMETER_NAMES) .map((value) => Number(value)) @@ -602,13 +588,14 @@ function buildPopiserverTaskPricePayload( if (!subType) return null; const aspectRatio = + draft.aspectRatio || stringFromUnknown(draft.parameters?.aspectRatio) || stringFromUnknown(draft.parameters?.aspect_ratio) || - draft.aspectRatio || "16:9"; const dimensions = dimensionsForAspectRatio(aspectRatio); - const duration = finiteIntegerFromUnknown(draft.parameters?.duration) ?? - (type === 2 ? normalizeVideoDurationSeconds(draft.durationSeconds) : null); + const duration = type === 2 + ? normalizeVideoDurationSeconds(draft.durationSeconds) + : finiteIntegerFromUnknown(draft.parameters?.duration); const modelName = getPopiserverModelName(model); return { @@ -620,14 +607,11 @@ function buildPopiserverTaskPricePayload( model: modelName, aspectRatio, ratio: aspectRatio, - resolution: - stringFromUnknown(draft.parameters?.resolution) || - draft.resolution || - (type === 2 ? DEFAULT_VIDEO_RESOLUTION : "2K"), + resolution: draft.resolution || stringFromUnknown(draft.parameters?.resolution) || (type === 2 ? DEFAULT_VIDEO_RESOLUTION : "2K"), duration: duration ?? undefined, - width: finiteIntegerFromUnknown(draft.parameters?.width) ?? dimensions.width, - height: finiteIntegerFromUnknown(draft.parameters?.height) ?? dimensions.height, - batchSize: finiteIntegerFromUnknown(draft.parameters?.batchSize) ?? (type === 1 ? draft.imageCount : 1), + width: dimensions.width, + height: dimensions.height, + batchSize: type === 1 ? draft.imageCount : 1, }; } @@ -925,6 +909,20 @@ function readDraftFromContext(context: ComposerContext): ComposerDraft { return adapter && context.node ? adapter.readDraft(context.node) : createEmptyDraft(); } +function composerDraftShallowEqual(a: ComposerDraft, b: ComposerDraft): boolean { + return ( + a.prompt === b.prompt && + a.aspectRatio === b.aspectRatio && + a.resolution === b.resolution && + a.selectedModel === b.selectedModel && + a.inputImages === b.inputImages && + a.imageCount === b.imageCount && + a.durationSeconds === b.durationSeconds && + a.soundEnabled === b.soundEnabled && + a.parameters === b.parameters + ); +} + function nodeDraftFingerprint(context: ComposerContext): string { if (context.mode !== "node-edit" || !context.node) return contextIdentity(context); const data = context.node.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData; @@ -1051,6 +1049,7 @@ export function GenerationComposer() { const identity = contextIdentity(context); const fingerprint = nodeDraftFingerprint(context); const [draft, setDraft] = useState(() => readDraftFromContext(context)); + const [draftIdentity, setDraftIdentity] = useState(() => identity); const [dirtyFields, setDirtyFields] = useState>(() => new Set()); const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null); const [modelDialogOpen, setModelDialogOpen] = useState(false); @@ -1114,7 +1113,16 @@ export function GenerationComposer() { flushDraftForContext(previousContext); contextRef.current = context; - setDraft(readDraftFromContext(context)); + const nextDraft = readDraftFromContext(context); + setDraft((current) => { + if (composerDraftShallowEqual(current, nextDraft)) { + draftRef.current = current; + return current; + } + draftRef.current = nextDraft; + return nextDraft; + }); + setDraftIdentity(identity); clearDirtyFields(); }, [clearDirtyFields, context, flushDraftForContext, identity]); @@ -1123,7 +1131,7 @@ export function GenerationComposer() { const nextDraft = readDraftFromContext(context); setDraft((current) => { const dirty = dirtyFieldsRef.current; - return { + const mergedDraft = { prompt: dirty.has("prompt") ? current.prompt : nextDraft.prompt, aspectRatio: dirty.has("aspectRatio") ? current.aspectRatio : nextDraft.aspectRatio, resolution: dirty.has("resolution") ? current.resolution : nextDraft.resolution, @@ -1134,7 +1142,14 @@ export function GenerationComposer() { soundEnabled: dirty.has("soundEnabled") ? current.soundEnabled : nextDraft.soundEnabled, parameters: dirty.has("parameters") ? current.parameters : nextDraft.parameters, }; + if (composerDraftShallowEqual(current, mergedDraft)) { + draftRef.current = current; + return current; + } + draftRef.current = mergedDraft; + return mergedDraft; }); + setDraftIdentity(identity); }, [context, fingerprint]); const connectedInputs = useMemo(() => { @@ -1571,20 +1586,6 @@ export function GenerationComposer() { ? context.node?.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | undefined : undefined; const activeParameterSchema = generationNodeData?.parameterSchema ?? []; - useEffect(() => { - if (context.mode !== "node-edit" || !context.node || activeParameterSchema.length === 0) return; - const normalized = applySchemaParameterDefaults(activeParameterSchema, draftRef.current.parameters); - if (!normalized.changed) return; - - setDraft((current) => { - const currentNormalized = applySchemaParameterDefaults(activeParameterSchema, current.parameters); - if (!currentNormalized.changed) return current; - const next = { ...current, parameters: currentNormalized.parameters }; - draftRef.current = next; - return next; - }); - updateNodeData(context.node.id, { parameters: normalized.parameters }); - }, [activeParameterSchema, context, updateNodeData]); const gatewayDimensionSelects = buildGatewayDimensionSelects(activeParameterSchema, draft); const imageCountOptions = getImageCountOptions(activeParameterSchema); const visibleImageCountOptions = imageCountOptions.length > 0 @@ -1592,7 +1593,7 @@ export function GenerationComposer() { : DEFAULT_IMAGE_COUNT_OPTIONS; const imageCountParameterName = getSchemaParameterName(activeParameterSchema, IMAGE_COUNT_PARAMETER_NAMES); const selectedImageCountValue = imageCountParameterName - ? Number(draft.parameters?.[imageCountParameterName] ?? draft.imageCount) + ? draft.imageCount : draft.imageCount; const videoSoundParameterName = getSchemaParameterName(activeParameterSchema, VIDEO_SOUND_PARAMETER_NAMES); const audioVoiceSelect = useMemo(() => { @@ -1733,11 +1734,18 @@ export function GenerationComposer() { }, []); const updateSchemaDraftParameter = useCallback( - (name: string, value: unknown, patch: Partial = {}, fields: DraftField[] = []) => { + ( + name: string, + value: unknown, + patch: Partial = {}, + fields: DraftField[] = [], + expectedContextIdentity?: string + ) => { const parameters = updateParameterValue(draftRef.current.parameters, name, value); const nextPatch = { ...patch, parameters }; const updatedFields: DraftField[] = [...fields, "parameters"]; const activeContext = contextRef.current; + if (expectedContextIdentity && contextIdentity(activeContext) !== expectedContextIdentity) return; setDraft((current) => { const next = { ...current, ...nextPatch }; @@ -1770,14 +1778,15 @@ export function GenerationComposer() { const resolutionSelect = gatewayDimensionSelects.find((select) => select.parameter.name === "resolution"); if ( isProcessNode || - activeCapability !== "image" || + (activeCapability !== "image" && activeCapability !== "video") || + draftIdentity !== identity || !resolutionSelect || resolutionSelect.options.length === 0 ) { return; } - const currentResolution = draft.parameters?.[resolutionSelect.parameter.name] ?? draft.resolution; + const currentResolution = draft.resolution ?? draft.parameters?.[resolutionSelect.parameter.name]; if (valueMatchesSchemaOptions( currentResolution, resolutionSelect.options.map((option) => String(option.value)) @@ -1792,14 +1801,17 @@ export function GenerationComposer() { resolutionSelect.parameter.name, nextResolution, { resolution: String(nextResolution) }, - ["resolution"] + ["resolution"], + identity ); }, [ activeCapability, activeParameterSchema, draft.parameters, draft.resolution, + draftIdentity, gatewayDimensionSelects, + identity, isProcessNode, updateSchemaDraftParameter, ]); @@ -1832,6 +1844,8 @@ export function GenerationComposer() { const activeContext = contextRef.current; const nextResolution = modelSupportsCapability(nextModel, "video") ? DEFAULT_VIDEO_RESOLUTION + : modelSupportsCapability(nextModel, "image") + ? "2K" : draftRef.current.resolution; if (activeContext.mode === "node-edit") { @@ -1857,37 +1871,48 @@ export function GenerationComposer() { ? DEFAULT_VIDEO_DURATION_SECONDS : draftRef.current.durationSeconds, soundEnabled: getDefaultVideoSoundEnabled(nextModel), + parameters: {}, }, - new Set(["selectedModel"]) + new Set(["selectedModel", "resolution", "parameters"]) ); updateNodeData(activeContext.node.id, modelPatch); - setDraft((current) => ({ - ...current, - selectedModel: nextModel, - resolution: nextResolution, - durationSeconds: modelSupportsCapability(nextModel, "video") - ? DEFAULT_VIDEO_DURATION_SECONDS - : current.durationSeconds, - soundEnabled: getDefaultVideoSoundEnabled(nextModel), - parameters: {}, - })); + setDraft((current) => { + const next = { + ...current, + selectedModel: nextModel, + resolution: nextResolution, + durationSeconds: modelSupportsCapability(nextModel, "video") + ? DEFAULT_VIDEO_DURATION_SECONDS + : current.durationSeconds, + soundEnabled: getDefaultVideoSoundEnabled(nextModel), + parameters: {}, + }; + draftRef.current = next; + return next; + }); clearDirtyFields(); return; } if (activeContext.mode === "empty-create") { - setDraft((current) => ({ - ...current, - selectedModel: nextModel, - resolution: modelSupportsCapability(nextModel, "video") - ? DEFAULT_VIDEO_RESOLUTION - : current.resolution, - durationSeconds: modelSupportsCapability(nextModel, "video") - ? DEFAULT_VIDEO_DURATION_SECONDS - : current.durationSeconds, - soundEnabled: getDefaultVideoSoundEnabled(nextModel), - parameters: {}, - })); + setDraft((current) => { + const next = { + ...current, + selectedModel: nextModel, + resolution: modelSupportsCapability(nextModel, "video") + ? DEFAULT_VIDEO_RESOLUTION + : modelSupportsCapability(nextModel, "image") + ? "2K" + : current.resolution, + durationSeconds: modelSupportsCapability(nextModel, "video") + ? DEFAULT_VIDEO_DURATION_SECONDS + : current.durationSeconds, + soundEnabled: getDefaultVideoSoundEnabled(nextModel), + parameters: {}, + }; + draftRef.current = next; + return next; + }); } }, [clearDirtyFields, updateNodeData] diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx index 9ba01c88..574d10fa 100644 --- a/src/components/nodes/GenerateAudioNode.tsx +++ b/src/components/nodes/GenerateAudioNode.tsx @@ -25,7 +25,6 @@ import { useI18n } from "@/i18n"; import { formatUserFacingError } from "@/utils/userFacingErrors"; import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection"; import { useModelStore } from "@/store/modelStore"; -import { buildDefaultParametersFromSchema } from "@/utils/modelSchemaDefaults"; import { withSpeechVoiceParameter } from "@/utils/speechVoiceParameter"; type GenerateAudioNodeType = Node; @@ -149,7 +148,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps { - const schemaDefaults = buildImageDefaultsFromSchema(parameters); updateNodeData(id, { inputSchema: inputs, parameterSchema: parameters, - ...schemaDefaults, ...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model) ? { selectedModel: toSelectedModel(model) } : {}), diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index fd51ec54..68cc7120 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -27,7 +27,6 @@ import { useI18n } from "@/i18n"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode"; import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection"; -import { buildVideoDefaultsFromSchema } from "@/utils/modelSchemaDefaults"; import { useModelStore } from "@/store/modelStore"; import { formatUserFacingError } from "@/utils/userFacingErrors"; import { @@ -343,11 +342,9 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps { - const schemaDefaults = buildVideoDefaultsFromSchema(parameters); updateNodeData(id, { inputSchema: inputs, parameterSchema: parameters, - ...schemaDefaults, ...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model) ? { selectedModel: toSelectedModel(model) } : {}), diff --git a/src/store/execution/generateAudioExecutor.ts b/src/store/execution/generateAudioExecutor.ts index 8a5770cc..28bfa7da 100644 --- a/src/store/execution/generateAudioExecutor.ts +++ b/src/store/execution/generateAudioExecutor.ts @@ -11,6 +11,7 @@ import { pollGenerateTask } from "./pollTaskCompletion"; import { runWithFallback } from "./runWithFallback"; import type { NodeExecutionContext } from "./types"; import { uploadPopiserverDynamicInputsForGeneration } from "@/utils/temporaryImageUpload"; +import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults"; export interface GenerateAudioOptions { /** When true, falls back to stored inputPrompt if no connections provide it. */ @@ -83,7 +84,10 @@ export async function executeGenerateAudio( images: [], prompt: text, selectedModel: modelToUse, - parameters: parametersOverride ?? nodeData.parameters, + parameters: mergeSchemaDefaults( + buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []), + parametersOverride ?? nodeData.parameters + ), dynamicInputs: preparedDynamicInputs.dynamicInputs, mediaType: "audio" as const, }; diff --git a/src/store/execution/generateVideoExecutor.ts b/src/store/execution/generateVideoExecutor.ts index ebf03081..cffc1852 100644 --- a/src/store/execution/generateVideoExecutor.ts +++ b/src/store/execution/generateVideoExecutor.ts @@ -32,7 +32,8 @@ import { uploadTemporaryDynamicInputsForGeneration, uploadTemporaryImagesForGeneration, } from "@/utils/temporaryImageUpload"; -import { mergeConfiguredParameters } from "./generationParameters"; +import { mergeConfiguredParametersOverride } from "./generationParameters"; +import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults"; export interface GenerateVideoOptions { /** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ @@ -44,13 +45,16 @@ function buildVideoParameters( modelToUse: SelectedModel, parametersOverride?: Record ): Record | undefined { - const parameters = { ...(parametersOverride ?? nodeData.parameters ?? {}) }; + const parameters = mergeSchemaDefaults( + buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []), + parametersOverride ?? nodeData.parameters + ); const durationSeconds = normalizeVideoDurationSeconds(nodeData.durationSeconds); const resolution = nodeData.resolution ?? DEFAULT_VIDEO_RESOLUTION; const soundParameterName = getVideoSoundParameterName(modelToUse); if (modelToUse.provider === "popiserver") { - const merged = mergeConfiguredParameters(parameters, { + const merged = mergeConfiguredParametersOverride(parameters, { ...(nodeData.aspectRatio ? { aspectRatio: nodeData.aspectRatio, aspect_ratio: nodeData.aspectRatio } : {}), resolution, duration: durationSeconds, diff --git a/src/store/execution/generationParameters.ts b/src/store/execution/generationParameters.ts index 67a5a4b0..01563370 100644 --- a/src/store/execution/generationParameters.ts +++ b/src/store/execution/generationParameters.ts @@ -30,6 +30,19 @@ export function mergeConfiguredParameters( return parameters; } +export function mergeConfiguredParametersOverride( + base: Record | undefined, + configured: Record +): Record { + const parameters = { ...(base ?? {}) }; + for (const [key, value] of Object.entries(configured)) { + if (value !== undefined && value !== null) { + parameters[key] = value; + } + } + return parameters; +} + export function resolveImageGenerationCount( parameters: Record | undefined, nodeImageCount: number | undefined diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index fa3cba3f..5c4ba8cf 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -35,10 +35,10 @@ import { uploadTemporaryImagesForGeneration, } from "@/utils/temporaryImageUpload"; import { - mergeConfiguredParameters, - mergeImageGenerationParameters, + mergeConfiguredParametersOverride, resolveImageGenerationCount, } from "./generationParameters"; +import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults"; export interface NanoBananaOptions { /** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ @@ -166,10 +166,11 @@ export async function executeNanoBanana( ...preparedDynamicInputs.cleanupIds, ]; - const requestedImageCount = resolveImageGenerationCount( - parametersOverride ?? nodeData.parameters, - nodeData.imageCount + const baseParameters = mergeSchemaDefaults( + buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []), + parametersOverride ?? nodeData.parameters ); + const requestedImageCount = resolveImageGenerationCount(baseParameters, nodeData.imageCount); const configuredParameters = { aspectRatio: (parametersOverride?.aspectRatio as string | undefined) ?? nodeData.aspectRatio, aspect_ratio: (parametersOverride?.aspectRatio as string | undefined) ?? nodeData.aspectRatio, @@ -179,12 +180,11 @@ export async function executeNanoBanana( useImageSearch: (parametersOverride?.useImageSearch as boolean | undefined) ?? nodeData.useImageSearch, }; const effectiveParameters = provider === "popiserver" - ? mergeImageGenerationParameters( - parametersOverride ?? nodeData.parameters, - configuredParameters, - requestedImageCount - ) - : mergeConfiguredParameters(parametersOverride ?? nodeData.parameters, configuredParameters); + ? { + ...mergeConfiguredParametersOverride(baseParameters, configuredParameters), + batchSize: requestedImageCount, + } + : mergeConfiguredParametersOverride(baseParameters, configuredParameters); const requestPayload = { images: preparedImages.images, prompt: finalPrompt, diff --git a/src/utils/__tests__/modelSchemaDefaults.test.ts b/src/utils/__tests__/modelSchemaDefaults.test.ts new file mode 100644 index 00000000..f8c3910f --- /dev/null +++ b/src/utils/__tests__/modelSchemaDefaults.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { mergeSchemaDefaults } from "@/utils/modelSchemaDefaults"; + +describe("mergeSchemaDefaults", () => { + it("fills missing schema defaults without overwriting saved parameters", () => { + expect( + mergeSchemaDefaults( + { + resolution: "720p", + duration: 5, + quality: "standard", + }, + { + resolution: "1080p", + prompt_strength: 0.8, + } + ) + ).toEqual({ + resolution: "1080p", + duration: 5, + quality: "standard", + prompt_strength: 0.8, + }); + }); + + it("returns defaults when there are no saved parameters", () => { + expect(mergeSchemaDefaults({ voice: "default" }, undefined)).toEqual({ + voice: "default", + }); + }); +}); diff --git a/src/utils/modelSchemaDefaults.ts b/src/utils/modelSchemaDefaults.ts index e8c01392..60891387 100644 --- a/src/utils/modelSchemaDefaults.ts +++ b/src/utils/modelSchemaDefaults.ts @@ -26,6 +26,16 @@ export function buildDefaultParametersFromSchema(schema: ModelParameter[]): Reco return parameters; } +export function mergeSchemaDefaults( + defaults: Record, + existing: Record | undefined +): Record { + return { + ...defaults, + ...(existing ?? {}), + }; +} + export function buildImageDefaultsFromSchema(schema: ModelParameter[]): { aspectRatio?: AspectRatio; resolution?: string;