|
|
|
@ -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<string, unknown> | undefined |
|
|
|
): { parameters: Record<string, unknown>; 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<Set<DraftField>>(() => 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<ComposerDraft> = {}, fields: DraftField[] = []) => { |
|
|
|
( |
|
|
|
name: string, |
|
|
|
value: unknown, |
|
|
|
patch: Partial<ComposerDraft> = {}, |
|
|
|
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<DraftField>(["selectedModel"]) |
|
|
|
new Set<DraftField>(["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] |
|
|
|
|