Browse Source

删除不必要的字段

feature/composer
TianYun 1 month ago
parent
commit
2dddbd51b6
  1. 5
      src/app/api/generate/providers/newapiwg.ts
  2. 112
      src/app/api/generate/providers/popiserver.ts
  3. 20
      src/components/WorkflowCanvas.tsx
  4. 17
      src/components/nodes/Generate3DNode.tsx
  5. 87
      src/components/nodes/GenerateAudioNode.tsx
  6. 11
      src/components/nodes/GenerateImageNode.tsx
  7. 43
      src/components/nodes/GenerateVideoNode.tsx
  8. 3
      src/components/nodes/ModelParameters.tsx
  9. 79
      src/hooks/useDynamicModelInputEdges.ts
  10. 6
      src/store/execution/generate3dExecutor.ts
  11. 13
      src/store/execution/generateAudioExecutor.ts
  12. 87
      src/store/execution/generateVideoExecutor.ts
  13. 17
      src/store/execution/nanoBananaExecutor.ts
  14. 9
      src/store/utils/__tests__/connectedInputs.test.ts
  15. 54
      src/store/utils/connectedInputs.ts
  16. 56
      src/store/workflowStore.ts
  17. 8
      src/types/nodes.ts
  18. 5
      src/utils/generationConfig.ts
  19. 12
      src/utils/generationInputSupport.ts
  20. 36
      src/utils/nodeHandles.ts

5
src/app/api/generate/providers/newapiwg.ts

@ -883,8 +883,8 @@ function normalizeNewApiWGVideoInputs(
const voices: string[] = [];
const shouldUseViduReferenceVideoField = isViduVideoModel(input.model.id);
const selectedModel = toSelectedModel(input.model);
const supportsVideoReference = modelSupportsReferenceMedia(selectedModel, "video", undefined, input.parameters);
const supportsAudioReference = modelSupportsReferenceMedia(selectedModel, "audio", undefined, input.parameters);
const supportsVideoReference = modelSupportsReferenceMedia(selectedModel, "video", input.parameters);
const supportsAudioReference = modelSupportsReferenceMedia(selectedModel, "audio", input.parameters);
for (const [key, value] of Object.entries(dynamicInputs)) {
if (NEWAPIWG_VIDEO_IMAGE_INPUTS.has(key)) {
@ -941,7 +941,6 @@ function sanitizeNewApiWGVideoParameters(
normalized.referenceSubjectList = filterReferenceSubjectList(
normalized.referenceSubjectList,
toSelectedModel(model),
undefined,
parameters
);
if (Array.isArray(normalized.referenceSubjectList) && normalized.referenceSubjectList.length === 0) {

112
src/app/api/generate/providers/popiserver.ts

@ -14,31 +14,6 @@ const POPISERVER_TASK_CREATE_PATH = "/api_client/anime/task/create";
const POPISERVER_TASK_LIST_PATH = "/api_client/anime/task/list";
const POPISERVER_MEDIA_UPLOAD_PATH = "/api_client/media/upload";
const POPISERVER_IMAGE_DYNAMIC_KEYS = [
"image",
"images",
"image_url",
"image_urls",
"first_frame",
"first_frame_url",
"last_frame",
"last_frame_url",
"reference_image",
"reference_image_url",
"reference_image_urls",
];
const POPISERVER_VIDEO_DYNAMIC_KEYS = [
"video",
"videos",
"video_url",
"video_urls",
];
const POPISERVER_VOICE_DYNAMIC_KEYS = [
"voices",
];
const POPISERVER_LEGACY_AUDIO_KEYS = [
"audio",
"audios",
@ -49,17 +24,6 @@ const POPISERVER_LEGACY_AUDIO_KEYS = [
"reference_audio_urls",
];
const POPISERVER_VOICE_ID_DYNAMIC_KEYS = [
"voiceId",
"voice_id",
];
const POPISERVER_MEDIA_DYNAMIC_KEYS = [
...POPISERVER_IMAGE_DYNAMIC_KEYS,
...POPISERVER_VIDEO_DYNAMIC_KEYS,
...POPISERVER_VOICE_DYNAMIC_KEYS,
];
type PopiserverResponse<T> = {
status?: string;
message?: string;
@ -162,37 +126,6 @@ function collectStrings(values: unknown[]): string[] {
return items;
}
function stringArray(value: string | string[] | undefined): string[] {
if (typeof value === "string") return value.length > 0 ? [value] : [];
if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
return [];
}
function firstDynamicString(
dynamicInputs: Record<string, string | string[]> | undefined,
keys: string[]
): string | null {
if (!dynamicInputs) return null;
for (const key of keys) {
const value = dynamicInputs[key];
const found = firstString(Array.isArray(value) ? value : [value]);
if (found) return found;
}
return null;
}
function collectDynamicMedia(
dynamicInputs: Record<string, string | string[]> | undefined,
keys: string[]
): string[] {
if (!dynamicInputs) return [];
const items: string[] = [];
for (const key of keys) {
items.push(...stringArray(dynamicInputs[key]));
}
return items;
}
function mediaResourceUrl(media: PopiUploadedMedia): string | null {
return media.url || media.originUrl || media.path || media.originPath || null;
}
@ -327,9 +260,6 @@ async function preparePopiTaskMedia(
token: string,
input: GenerationInput
): Promise<GenerationInput> {
const dynamicInputs = input.dynamicInputs
? { ...input.dynamicInputs }
: undefined;
const mediaValues: string[] = [];
const assignments: Array<(uploaded: string) => void> = [];
@ -357,31 +287,12 @@ async function preparePopiTaskMedia(
});
});
if (dynamicInputs) {
for (const key of POPISERVER_MEDIA_DYNAMIC_KEYS) {
const value = dynamicInputs[key];
if (typeof value === "string") {
collect(value, (uploaded) => {
dynamicInputs[key] = uploaded;
});
} else if (Array.isArray(value)) {
const values = [...value];
values.forEach((item, index) => {
collect(item, (uploaded) => {
values[index] = uploaded;
dynamicInputs[key] = values;
});
});
}
}
}
const uploadedValues = mediaValues.length > 0
? await uploadPopiMediaBatch(request, token, mediaValues)
: [];
uploadedValues.forEach((uploaded, index) => assignments[index]?.(uploaded));
return { ...input, images, videos, voices, dynamicInputs };
return { ...input, images, videos, voices };
}
function getCapabilityMediaType(capabilities: ModelCapability[]): "image" | "video" | "audio" {
@ -571,28 +482,19 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu
asNumber(input.model.metadata?.aiModelId) ??
asNumber(input.model.id) ??
modelDetail?.id;
const images = [
...(input.images || []),
...collectDynamicMedia(input.dynamicInputs, POPISERVER_IMAGE_DYNAMIC_KEYS),
];
const videos = modelSupportsReferenceMedia(selectedModel, "video", undefined, rawParameters)
? [
...(input.videos || []),
...collectDynamicMedia(input.dynamicInputs, POPISERVER_VIDEO_DYNAMIC_KEYS),
]
const images = input.images || [];
const videos = modelSupportsReferenceMedia(selectedModel, "video", rawParameters)
? input.videos || []
: [];
const voices = modelSupportsReferenceMedia(selectedModel, "audio", undefined, rawParameters)
? [
...(input.voices || []),
...collectDynamicMedia(input.dynamicInputs, POPISERVER_VOICE_DYNAMIC_KEYS),
]
const voices = modelSupportsReferenceMedia(selectedModel, "audio", rawParameters)
? input.voices || []
: [];
const referenceSubjectList = type === 1 || type === 2
? buildReferenceSubjectList([...new Set(images)], [...new Set(videos)], [...new Set(voices)], rawParameters)
: [];
const voiceId =
firstDynamicString(input.dynamicInputs, POPISERVER_VOICE_ID_DYNAMIC_KEYS) ??
asString(parameters.voiceId) ??
asString(parameters.voice_id) ??
"";
const commonBody = {

20
src/components/WorkflowCanvas.tsx

@ -365,7 +365,6 @@ function buildMultiAngleNodeData(sourceImage: string, settings: MultiAngleSettin
model: getLegacyImageModel(DEFAULT_MULTI_ANGLE_IMAGE_MODEL),
selectedModel: DEFAULT_MULTI_ANGLE_IMAGE_MODEL,
parameters: {},
inputSchema: undefined,
useGoogleSearch: false,
useImageSearch: false,
status: "idle",
@ -1220,24 +1219,6 @@ export function WorkflowCanvas() {
}
return capability.outputs.includes(SINGLE_OUTPUT_HANDLE_ID) ? SINGLE_OUTPUT_HANDLE_ID : null;
// Check for dynamic inputSchema first
const nodeData = node.data as { inputSchema?: Array<{ name: string; type: string }> };
const inputSchema = nodeData.inputSchema ?? [];
if (inputSchema.length > 0) {
if (needInput) {
// Find input handles matching the type
const matchingInputs = inputSchema.filter(i => i.type === handleType);
const numHandles = matchingInputs.length;
if (numHandles > 0) {
return handleType;
}
}
// Output handle - check for video, 3d, or image type
if (handleType === "video") return "video";
if (handleType === "3d") return "3d";
return handleType === "image" ? "image" : null;
}
// VideoStitch has dynamic indexed video input handles (video-0, video-1, ...)
if (node.type === "videoStitch" && needInput && handleType === "video") {
for (let i = 0; i < 50; i++) {
@ -3327,7 +3308,6 @@ export function WorkflowCanvas() {
updateNodeData(fallbackDialogState.nodeId, {
selectedModel,
parameters: {},
inputSchema: undefined,
fallbackModel: undefined,
fallbackParameters: undefined,
status: currentData?.status === "error" ? "idle" : currentData?.status,

17
src/components/nodes/Generate3DNode.tsx

@ -5,7 +5,7 @@ import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { ModelParameters } from "./ModelParameters";
import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore";
import { Generate3DNodeData, ProviderType, SelectedModel, ModelInputDef } from "@/types";
import { Generate3DNodeData, ProviderType, SelectedModel } from "@/types";
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
@ -65,21 +65,12 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
[id, updateNodeData]
);
// Handle inputs loaded from schema
const handleInputsLoaded = useCallback(
(inputs: ModelInputDef[]) => {
updateNodeData(id, { inputSchema: inputs });
},
[id, updateNodeData]
);
const modelSchema = useSelectedModelSchema({
selectedModel: nodeData.selectedModel,
enabled: Boolean(nodeData.selectedModel?.modelId),
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters }) => {
onLoaded: ({ parameters }) => {
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
modelSchemaError: null,
});
@ -240,8 +231,8 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
{/* Dynamic input handles based on model schema */}
{false ? (
(() => {
const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image");
const textInputs = nodeData.inputSchema!.filter(i => i.type === "text");
const imageInputs: Array<{ name: string; label: string; description?: string }> = [];
const textInputs: Array<{ name: string; label: string; description?: string }> = [];
const hasImageInput = imageInputs.length > 0;
const hasTextInput = textInputs.length > 0;

87
src/components/nodes/GenerateAudioNode.tsx

@ -6,13 +6,12 @@ import { BaseNode } from "./BaseNode";
import { ProviderBadge } from "./ProviderBadge";
import { ModelParameters } from "./ModelParameters";
import { useWorkflowStore } from "@/store/workflowStore";
import { GenerateAudioNodeData, ProviderType, ModelInputDef, SelectedModel } from "@/types";
import { GenerateAudioNodeData, ProviderType, SelectedModel } from "@/types";
import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useAudioVisualization } from "@/hooks/useAudioVisualization";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { useDynamicModelInputEdges } from "@/hooks/useDynamicModelInputEdges";
import { useSelectedModelSchema } from "@/hooks/useSelectedModelSchema";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar";
@ -83,8 +82,6 @@ export function GenerateAudioNodeView({
const { inlineParametersEnabled } = useInlineParameters();
const showInlineNodeSettings = false;
const showLabels = useShowHandleLabels(selected);
useDynamicModelInputEdges(id, nodeData.inputSchema, nodeConfig.selectedModel, nodeConfig.parameters);
// Register browse callback for floating header button
useEffect(() => {
browseRegistry.register(id, () => setIsBrowseDialogOpen(true));
@ -109,7 +106,7 @@ export function GenerateAudioNodeView({
}
updateNodeData(id, {
...buildGenerationModelSwitchPatch(nodeData, selectedModel),
inputSchema: undefined, parameterSchema: undefined, modelSchemaRequestId: Date.now(),
parameterSchema: undefined, modelSchemaRequestId: Date.now(),
});
}, [id, nodeConfig.selectedModel, nodeData, popiProviderMode, popiserverAudioModels, updateNodeData]);
@ -156,7 +153,7 @@ export function GenerateAudioNodeView({
!popiProviderMode || popiserverAudioModels.some((model) => model.id === nodeConfig.selectedModel?.modelId)
),
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters, model }) => {
onLoaded: ({ parameters, model }) => {
const selectedModelPatch = model && shouldUpdateSelectedModelFromProviderModel(nodeConfig.selectedModel, model)
? buildGenerationSelectedModelPatch(nodeConfig, toSelectedModel(model))
: {};
@ -168,7 +165,6 @@ export function GenerateAudioNodeView({
String(model?.metadata?.aiModelCodeAlias ?? nodeConfig.selectedModel?.metadata?.aiModelCodeAlias ?? "")
);
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: nextParameterSchema,
...selectedModelPatch,
modelSchemaError: null,
@ -336,7 +332,6 @@ export function GenerateAudioNodeView({
} else {
updateNodeData(id, {
...buildGenerationModelSwitchPatch(nodeData, newSelectedModel),
inputSchema: undefined,
parameterSchema: undefined,
modelSchemaRequestId: Date.now(),
});
@ -359,81 +354,6 @@ export function GenerateAudioNodeView({
updateNodeData(id, { parametersExpanded: !isParamsExpanded });
}, [id, isParamsExpanded, updateNodeData]);
// Dynamic handles based on inputSchema
const dynamicHandles = useMemo(() => {
if (!nodeData.inputSchema || nodeData.inputSchema.length === 0) return null;
const typeOrder = ["image", "video", "audio", "text"] as const;
const handles = typeOrder
.map((handleType) => {
const inputs = nodeData.inputSchema!.filter((input) => input.type === handleType);
if (inputs.length === 0) return null;
const firstInput = inputs[0];
return {
id: handleType,
type: handleType,
label: handleType === "image"
? t("node.image")
: handleType === "video"
? t("toolbar.video")
: handleType === "audio"
? t("modelSearch.audio")
: t("node.prompt"),
schemaName: firstInput.name,
description: firstInput.description || firstInput.label,
count: inputs.length,
};
})
.filter((handle): handle is NonNullable<typeof handle> => handle !== null);
const getHandleColor = (type: string) => {
if (type === "image") return "var(--handle-color-image)";
if (type === "video") return "var(--handle-color-video)";
if (type === "audio") return "var(--handle-color-audio)";
return "var(--handle-color-text)";
};
const indexedCompatHandles = handles.flatMap((handle, index) =>
Array.from({ length: handle.count }, (_, inputIndex) => ({
id: `${handle.type}-${inputIndex}`,
topPercent: ((index + 1) / (handles.length + 1)) * 100,
}))
);
return (
<>
{handles.map((handle, index) => {
const topPercent = ((index + 1) / (handles.length + 1)) * 100;
return (
<React.Fragment key={handle.id}>
<Handle
type="target"
position={Position.Left}
id={handle.id}
data-handletype={handle.type}
data-schema-name={handle.schemaName}
style={{ top: `${topPercent}%`, zIndex: 10 }}
isConnectable={true}
title={handle.description}
/>
<HandleLabel label={handle.label} side="target" color={getHandleColor(handle.type)} top={`calc(${topPercent}% - 18px)`} visible={showLabels} />
</React.Fragment>
);
})}
{indexedCompatHandles.map((handle) => (
<Handle
key={`compat-${handle.id}`}
type="target"
position={Position.Left}
id={handle.id}
style={{ top: `${handle.topPercent}%`, opacity: 0, pointerEvents: "none" }}
isConnectable={false}
/>
))}
</>
);
}, [nodeData.inputSchema, showLabels, t]);
return (
<>
<BaseNode
@ -670,7 +590,6 @@ export function GenerateAudioNodeView({
</div>
)}
{false && dynamicHandles}
<Handle
type="target"
position={Position.Left}

11
src/components/nodes/GenerateImageNode.tsx

@ -7,14 +7,13 @@ import { BaseNode } from "./BaseNode";
import { ModelParameters } from "./ModelParameters";
import { useWorkflowStore, saveNanoBananaDefaults, useProviderApiKeys } from "@/store/workflowStore";
import { deduplicatedFetch } from "@/utils/deduplicatedFetch";
import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, SelectedModel, ModelInputDef } from "@/types";
import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, SelectedModel } from "@/types";
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
import { getImageDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { useDynamicModelInputEdges } from "@/hooks/useDynamicModelInputEdges";
import { useSelectedModelSchema } from "@/hooks/useSelectedModelSchema";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar";
@ -178,8 +177,6 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
() => readImageGenerationConfig(nodeData, defaultSelectedModel),
[defaultSelectedModel, nodeData]
);
useDynamicModelInputEdges(id, nodeData.inputSchema, nodeConfig.selectedModel, nodeConfig.parameters);
// Get the current selected provider
const currentProvider: ProviderType = nodeConfig.selectedModel?.provider || (popiProviderMode ? POPI_PROVIDER_ID : "gemini");
const modelOptions = currentProvider === POPI_PROVIDER_ID ? popiserverImageModels : externalModels;
@ -285,7 +282,7 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
if (!selectedModel || selectedModel.modelId === nodeConfig.selectedModel?.modelId) return;
updateNodeData(id, {
...buildGenerationModelSwitchPatch(nodeData, selectedModel),
inputSchema: undefined, parameterSchema: undefined, modelSchemaRequestId: Date.now(),
parameterSchema: undefined, modelSchemaRequestId: Date.now(),
});
}, [id, currentProvider, modelOptions, nodeConfig.selectedModel, nodeData, updateNodeData]);
@ -525,7 +522,6 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
} else {
updateNodeData(id, {
...buildGenerationModelSwitchPatch(nodeData, newSelectedModel),
inputSchema: undefined,
parameterSchema: undefined,
modelSchemaRequestId: Date.now(),
});
@ -575,12 +571,11 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
selectedModel: nodeConfig.selectedModel,
enabled: shouldFetchPrimaryModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters, model }) => {
onLoaded: ({ parameters, model }) => {
const selectedModelPatch = model && shouldUpdateSelectedModelFromProviderModel(nodeConfig.selectedModel, model)
? buildGenerationSelectedModelPatch(nodeConfig, toSelectedModel(model))
: {};
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...selectedModelPatch,
modelSchemaError: null,

43
src/components/nodes/GenerateVideoNode.tsx

@ -7,7 +7,7 @@ import { BaseNode } from "./BaseNode";
import { ModelParameters } from "./ModelParameters";
import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore";
import { deduplicatedFetch } from "@/utils/deduplicatedFetch";
import { GenerateVideoNodeData, ProviderType, SelectedModel, ModelInputDef } from "@/types";
import { GenerateVideoNodeData, ProviderType, SelectedModel } from "@/types";
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
@ -16,7 +16,6 @@ import { ProviderBadge } from "./ProviderBadge";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { useDynamicModelInputEdges } from "@/hooks/useDynamicModelInputEdges";
import { useSelectedModelSchema } from "@/hooks/useSelectedModelSchema";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar";
@ -62,26 +61,6 @@ function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | un
return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId);
}
/** Returns true for Gemini-native Veo video models */
function isVeoModel(modelId: string | undefined): boolean {
if (!modelId) return false;
return modelId.startsWith("veo-");
}
/** Build the hardcoded inputSchema for a Veo model, or undefined for non-Veo */
function buildVeoInputSchema(modelId: string): ModelInputDef[] | undefined {
if (!isVeoModel(modelId)) return undefined;
const isI2V = modelId.includes("image-to-video");
const inputs: ModelInputDef[] = [
{ name: "prompt", type: "text", required: true, label: "Prompt" },
{ name: "negative_prompt", type: "text", required: false, label: "Neg. Prompt" },
];
if (isI2V) {
inputs.unshift({ name: "image", type: "image", required: true, label: "Image" });
}
return inputs;
}
type GenerateVideoNodeType = Node<GenerateVideoNodeData, "generateVideo">;
export interface GenerateVideoNodeViewProps {
@ -203,8 +182,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
() => readVideoGenerationConfig(nodeData, defaultSelectedModel),
[defaultSelectedModel, nodeData]
);
useDynamicModelInputEdges(id, nodeData.inputSchema, nodeConfig.selectedModel, nodeConfig.parameters);
const currentProvider: ProviderType = nodeConfig.selectedModel?.provider || (popiProviderMode ? POPI_PROVIDER_ID : "fal");
const modelOptions = currentProvider === POPI_PROVIDER_ID ? popiserverVideoModels : externalModels;
@ -301,7 +278,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
resolution: undefined,
durationSeconds: undefined,
soundEnabled: getDefaultVideoSoundEnabled(selectedModel),
inputSchema: undefined, parameterSchema: undefined, modelSchemaRequestId: Date.now(),
parameterSchema: undefined, modelSchemaRequestId: Date.now(),
});
}, [
id,
@ -336,7 +313,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
durationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
soundEnabled: undefined,
}),
inputSchema: undefined,
parameterSchema: undefined,
modelSchemaRequestId: Date.now(),
soundEnabled: undefined,
@ -353,7 +329,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
if (model) {
const newSelectedModel = toSelectedModel(model);
// Clear parameters when changing models (different models have different schemas)
// Set inputSchema immediately for Veo models so handles render in the same update
updateNodeData(id, {
...buildGenerationModelSwitchPatch(nodeData, newSelectedModel, {
resolution: DEFAULT_VIDEO_RESOLUTION,
@ -363,7 +338,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
resolution: undefined,
durationSeconds: undefined,
soundEnabled: getDefaultVideoSoundEnabled(newSelectedModel),
inputSchema: buildVeoInputSchema(model.id),
parameterSchema: undefined,
modelSchemaRequestId: Date.now(),
});
@ -388,12 +362,11 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
selectedModel: nodeConfig.selectedModel,
enabled: Boolean(nodeConfig.selectedModel?.modelId) && canFetchPopiModelSchema,
refreshKey: nodeData.modelSchemaRequestId,
onLoaded: ({ inputs, parameters, model }) => {
onLoaded: ({ parameters, model }) => {
const selectedModelPatch = model && shouldUpdateSelectedModelFromProviderModel(nodeConfig.selectedModel, model)
? buildGenerationSelectedModelPatch(nodeConfig, toSelectedModel(model))
: {};
updateNodeData(id, {
inputSchema: inputs,
parameterSchema: parameters,
...selectedModelPatch,
modelSchemaError: null,
@ -520,7 +493,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
setIsBrowseDialogOpen(false);
return;
}
// Set inputSchema immediately for Veo models so handles render in the same update
updateNodeData(id, {
...buildGenerationModelSwitchPatch(nodeData, newSelectedModel, {
resolution: DEFAULT_VIDEO_RESOLUTION,
@ -530,7 +502,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
resolution: undefined,
durationSeconds: undefined,
soundEnabled: getDefaultVideoSoundEnabled(newSelectedModel),
inputSchema: buildVeoInputSchema(model.id),
parameterSchema: undefined,
modelSchemaRequestId: Date.now(),
});
@ -720,10 +691,10 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
// compatibility. Schema may only have text inputs (text-to-video models) but
// we still need the image handle to preserve connections made before model selection.
(() => {
const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image");
const videoInputs = nodeData.inputSchema!.filter(i => i.type === "video");
const audioInputs = nodeData.inputSchema!.filter(i => i.type === "audio");
const textInputs = nodeData.inputSchema!.filter(i => i.type === "text");
const imageInputs: Array<{ name: string; type: string; label: string; description?: string }> = [];
const videoInputs: Array<{ name: string; type: string; label: string; description?: string }> = [];
const audioInputs: Array<{ name: string; type: string; label: string; description?: string }> = [];
const textInputs: Array<{ name: string; type: string; label: string; description?: string }> = [];
const hasImageInput = imageInputs.length > 0;
const hasVideoInput = videoInputs.length > 0;

3
src/components/nodes/ModelParameters.tsx

@ -2,7 +2,7 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Select } from "antd";
import { ModelInputDef, ProviderType } from "@/types";
import { ProviderType } from "@/types";
import { ModelParameter } from "@/lib/providers/types";
import { TranslationKey, useI18n } from "@/i18n";
@ -29,7 +29,6 @@ interface ModelParametersProps {
parameters: Record<string, unknown>;
onParametersChange: (parameters: Record<string, unknown>) => void;
onExpandChange?: (expanded: boolean, parameterCount: number) => void;
onInputsLoaded?: (inputs: ModelInputDef[]) => void;
hiddenParameterNames?: string[];
pruneHiddenParameters?: boolean;
}

79
src/hooks/useDynamicModelInputEdges.ts

@ -1,79 +0,0 @@
"use client";
import { useEffect, useMemo } from "react";
import { useUpdateNodeInternals } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import type { ModelInputDef, SelectedModel } from "@/types";
import { modelSupportsReferenceMedia, type ReferenceMediaType } from "@/utils/generationInputSupport";
const DYNAMIC_INPUT_TYPES: readonly ModelInputDef["type"][] = ["image", "video", "audio", "text"];
function metadataSupportFlag(model: SelectedModel | undefined, inputType: ReferenceMediaType): boolean | null {
const metadata = model?.metadata;
if (!metadata) return null;
if (inputType === "image" && typeof metadata.isSupportImages === "boolean") {
return metadata.isSupportImages;
}
if (inputType === "video" && typeof metadata.isSupportVideos === "boolean") {
return metadata.isSupportVideos;
}
if (inputType === "audio") {
if (typeof metadata.isSupportAudios === "boolean") return metadata.isSupportAudios;
if (typeof metadata.isSupportAudio === "boolean") return metadata.isSupportAudio;
}
return null;
}
export function useDynamicModelInputEdges(
nodeId: string,
inputSchema: ModelInputDef[] | undefined,
selectedModel?: SelectedModel,
parameters?: Record<string, unknown>
): void {
const updateNodeInternals = useUpdateNodeInternals();
const reconcileDynamicInputEdges = useWorkflowStore((state) => state.reconcileDynamicInputEdges);
const schemaSignature = useMemo(() => {
if (!inputSchema || inputSchema.length === 0) return "";
return inputSchema.map((input) => `${input.type}:${input.name}`).join("|");
}, [inputSchema]);
const supportSignature = useMemo(() => {
const metadata = selectedModel?.metadata;
const capabilitySignature = selectedModel?.capabilities?.join("|") ?? "";
const parameterSignature = parameters ? Object.keys(parameters).sort().join("|") : "";
return [
selectedModel?.provider ?? "",
selectedModel?.modelId ?? "",
metadata?.isSupportImages,
metadata?.isSupportVideos,
metadata?.isSupportAudios,
metadata?.isSupportAudio,
capabilitySignature,
parameterSignature,
].join("|");
}, [parameters, selectedModel]);
const validInputTypes = useMemo(() => {
const hasSchema = Boolean(inputSchema?.length);
return DYNAMIC_INPUT_TYPES.filter((inputType) => {
if (inputType === "text") return true;
if (hasSchema && !inputSchema?.some((input) => input.type === inputType)) return false;
if (!selectedModel) return hasSchema;
const mediaType = inputType as ReferenceMediaType;
const supportFlag = metadataSupportFlag(selectedModel, mediaType);
if (supportFlag === false) return false;
if (supportFlag === true) return true;
return modelSupportsReferenceMedia(selectedModel, mediaType, inputSchema, parameters);
});
}, [inputSchema, parameters, selectedModel]);
useEffect(() => {
updateNodeInternals(nodeId);
if (!schemaSignature && !selectedModel) return;
reconcileDynamicInputEdges?.(nodeId, validInputTypes);
}, [nodeId, reconcileDynamicInputEdges, schemaSignature, selectedModel, supportSignature, updateNodeInternals, validInputTypes]);
}

6
src/store/execution/generate3dExecutor.ts

@ -64,7 +64,6 @@ export async function executeGenerate3D(
images: connectedImages,
text: connectedText,
textItems: connectedTextItems,
dynamicInputs,
} = getConnectedInputs(node.id);
// Get fresh node data from store
@ -80,11 +79,7 @@ export async function executeGenerate3D(
promptText = buildPromptFromConnectedTextItems(connectedTextItems, connectedText, nodeData.inputPrompt);
} else {
images = connectedImages;
const promptFromDynamic = Array.isArray(dynamicInputs.prompt)
? dynamicInputs.prompt[0]
: dynamicInputs.prompt;
promptText = buildPromptFromConnectedTextItems(connectedTextItems, connectedText, null)
|| promptFromDynamic
|| null;
}
@ -116,7 +111,6 @@ export async function executeGenerate3D(
prompt: promptText || "",
selectedModel: modelToUse,
parameters: parametersOverride ?? nodeData.parameters,
dynamicInputs,
mediaType: "3d" as const,
};

13
src/store/execution/generateAudioExecutor.ts

@ -11,7 +11,6 @@ import { runWithFallback } from "./runWithFallback";
import { executePopiartTask } from "./popiartTaskClient";
import type { NodeExecutionContext } from "./types";
import { buildPromptFromConnectedTextItems } from "./textInputPrompt";
import { uploadPopiserverDynamicInputsForGeneration } from "@/utils/temporaryImageUpload";
import { buildDefaultParametersFromSchema, mergeSchemaDefaults } from "@/utils/modelSchemaDefaults";
import { readAudioGenerationConfig } from "@/utils/generationConfig";
import { createDefaultAudioModel } from "@/store/utils/defaultImageModel";
@ -21,11 +20,6 @@ export interface GenerateAudioOptions {
useStoredFallback?: boolean;
}
function firstTextValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) return value.find((item) => item.trim().length > 0) ?? null;
return value && value.trim().length > 0 ? value : null;
}
export async function executeGenerateAudio(
ctx: NodeExecutionContext,
_options: GenerateAudioOptions = {}
@ -42,7 +36,7 @@ export async function executeGenerateAudio(
void _options;
const { text: connectedText, textItems: connectedTextItems = [], dynamicInputs } = getConnectedInputs(node.id);
const { text: connectedText, textItems: connectedTextItems = [] } = getConnectedInputs(node.id);
// Get fresh node data from store
const freshNode = getFreshNode(node.id);
@ -50,10 +44,9 @@ export async function executeGenerateAudio(
const nodeConfig = readAudioGenerationConfig(nodeData, createDefaultAudioModel());
const hasConnectedText = connectedTextItems.length > 0 || Boolean(connectedText?.trim());
const schemaPrompt = firstTextValue(dynamicInputs.prompt) ?? firstTextValue(dynamicInputs.text);
const text = hasConnectedText
? buildPromptFromConnectedTextItems(connectedTextItems, connectedText, nodeConfig.prompt)
: schemaPrompt ?? nodeConfig.prompt ?? null;
: nodeConfig.prompt ?? null;
if (!text) {
updateNodeData(node.id, {
@ -80,7 +73,6 @@ export async function executeGenerateAudio(
const runOnce = async (modelToUse: SelectedModel, parametersOverride?: Record<string, unknown>): Promise<void> => {
const provider = modelToUse.provider;
const headers = buildGenerateHeaders(provider, providerSettings);
const preparedDynamicInputs = await uploadPopiserverDynamicInputsForGeneration(dynamicInputs);
const requestPayload = {
images: [],
@ -90,7 +82,6 @@ export async function executeGenerateAudio(
buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []),
parametersOverride ?? nodeConfig.parameters
),
dynamicInputs: preparedDynamicInputs.dynamicInputs,
mediaType: "audio" as const,
};

87
src/store/execution/generateVideoExecutor.ts

@ -140,9 +140,9 @@ function sanitizeReferenceSubjectListParameter(
modelToUse: SelectedModel
): Record<string, unknown> | undefined {
if (!parameters || !Array.isArray(parameters.referenceSubjectList)) return parameters;
const supportsImage = modelSupportsReferenceMedia(modelToUse, "image", nodeData.inputSchema, parameters);
const supportsVideo = modelSupportsReferenceMedia(modelToUse, "video", nodeData.inputSchema, parameters);
const supportsAudio = modelSupportsReferenceMedia(modelToUse, "audio", nodeData.inputSchema, parameters);
const supportsImage = modelSupportsReferenceMedia(modelToUse, "image", parameters);
const supportsVideo = modelSupportsReferenceMedia(modelToUse, "video", parameters);
const supportsAudio = modelSupportsReferenceMedia(modelToUse, "audio", parameters);
const referenceSubjectList = parameters.referenceSubjectList.filter((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const type = (item as Record<string, unknown>).type;
@ -156,65 +156,6 @@ function sanitizeReferenceSubjectListParameter(
: Object.fromEntries(Object.entries(parameters).filter(([key]) => key !== "referenceSubjectList"));
}
const hasDynamicVideoInput = (dynamicInputs: Record<string, string | string[]>): boolean =>
Object.entries(dynamicInputs).some(([key, value]) => {
if (!key.toLowerCase().includes("video")) return false;
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
const LEGACY_VIDEO_AUDIO_DYNAMIC_KEYS = new Set([
"audio",
"audios",
"audio_url",
"audio_urls",
"reference_audio",
"reference_audio_url",
"reference_audio_urls",
]);
function removeLegacyVideoAudioDynamicInputs(
dynamicInputs: Record<string, string | string[]>
): Record<string, string | string[]> {
return Object.fromEntries(
Object.entries(dynamicInputs).filter(([key]) => !LEGACY_VIDEO_AUDIO_DYNAMIC_KEYS.has(key))
);
}
function mergeConnectedVideosIntoDynamicInputs(
dynamicInputs: Record<string, string | string[]>,
videos: string[]
): Record<string, string | string[]> {
if (videos.length === 0 || hasDynamicVideoInput(dynamicInputs)) {
return dynamicInputs;
}
return {
...dynamicInputs,
video: videos[0],
videos,
};
}
const hasDynamicVoiceInput = (dynamicInputs: Record<string, string | string[]>): boolean =>
Object.entries(dynamicInputs).some(([key, value]) => {
if (key !== "voices") return false;
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
function mergeConnectedVoicesIntoDynamicInputs(
dynamicInputs: Record<string, string | string[]>,
voices: string[]
): Record<string, string | string[]> {
if (voices.length === 0 || hasDynamicVoiceInput(dynamicInputs)) {
return dynamicInputs;
}
return {
...dynamicInputs,
voices,
};
}
export async function executeGenerateVideo(
ctx: NodeExecutionContext,
options: GenerateVideoOptions = {}
@ -238,18 +179,12 @@ export async function executeGenerateVideo(
text: connectedText,
textItems: connectedTextItems,
audio: connectedAudio,
dynamicInputs,
} = getConnectedInputs(node.id);
const baseDynamicInputs = removeLegacyVideoAudioDynamicInputs(dynamicInputs);
// Get fresh node data from store
const freshNode = getFreshNode(node.id);
const nodeData = (freshNode?.data || node.data) as GenerateVideoNodeData;
const nodeConfig = readVideoGenerationConfig(nodeData, createNewApiWGDefaultVideoModel());
const requestDynamicInputs = mergeConnectedVoicesIntoDynamicInputs(
mergeConnectedVideosIntoDynamicInputs(baseDynamicInputs, connectedVideos),
connectedAudio
);
if (!isValidVideoDurationSeconds(nodeConfig.durationSeconds)) {
const error = "Video duration must be at least 4 seconds";
@ -264,9 +199,9 @@ export async function executeGenerateVideo(
if (useStoredFallback) {
images = connectedImages.length > 0 ? connectedImages : nodeConfig.inputImages;
text = buildPromptFromConnectedTextItems(connectedTextItems, connectedText, nodeConfig.prompt);
const hasPrompt = text || requestDynamicInputs.prompt || requestDynamicInputs.negative_prompt;
const hasPrompt = Boolean(text);
const hasAudio = connectedAudio.length > 0;
const hasVideo = connectedVideos.length > 0 || hasDynamicVideoInput(requestDynamicInputs);
const hasVideo = connectedVideos.length > 0;
if (!hasPrompt && images.length === 0 && !hasVideo && !hasAudio) {
updateNodeData(node.id, {
status: "error",
@ -277,9 +212,9 @@ export async function executeGenerateVideo(
} else {
images = connectedImages;
text = buildPromptFromConnectedTextItems(connectedTextItems, connectedText, nodeConfig.prompt);
const hasPrompt = text || requestDynamicInputs.prompt || requestDynamicInputs.negative_prompt;
const hasPrompt = Boolean(text);
const hasAudio = connectedAudio.length > 0;
const hasVideo = connectedVideos.length > 0 || hasDynamicVideoInput(requestDynamicInputs);
const hasVideo = connectedVideos.length > 0;
if (!hasPrompt && images.length === 0 && !hasVideo && !hasAudio) {
updateNodeData(node.id, {
status: "error",
@ -321,7 +256,6 @@ export async function executeGenerateVideo(
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
inputSchema: undefined,
});
}
@ -344,11 +278,7 @@ export async function executeGenerateVideo(
const provider = modelToUse.provider;
const headers = buildGenerateHeaders(provider, providerSettings);
try {
const runDynamicInputs = mergeConnectedVoicesIntoDynamicInputs(
mergeConnectedVideosIntoDynamicInputs(baseDynamicInputs, connectedVideos),
connectedAudio
);
const preparedInputs = await uploadPopiserverGenerationInputsForGeneration(images, runDynamicInputs);
const preparedInputs = await uploadPopiserverGenerationInputsForGeneration(images, {});
const supportedInputs = provider === "popiserver"
? getPopiSupportedMediaInputs(modelToUse, {
images: preparedInputs.images,
@ -377,7 +307,6 @@ export async function executeGenerateVideo(
prompt: text || "",
selectedModel: modelToUse,
parameters,
dynamicInputs: preparedInputs.dynamicInputs,
mediaType: "video" as const,
};

17
src/store/execution/nanoBananaExecutor.ts

@ -96,7 +96,6 @@ export async function executeNanoBanana(
images: connectedImages,
text: connectedText,
textItems: connectedTextItems = [],
dynamicInputs,
} = getConnectedInputs(node.id);
// Get fresh node data from store
@ -127,13 +126,9 @@ export async function executeNanoBanana(
} else {
const localImage = (nodeData as NanoBananaNodeData & { image?: string | null }).image;
images = connectedImages.length > 0 ? connectedImages : localImage ? [localImage] : [];
// For dynamic inputs, check if we have at least a prompt
const promptFromDynamic = Array.isArray(dynamicInputs.prompt)
? dynamicInputs.prompt[0]
: dynamicInputs.prompt;
promptText = hasConnectedText
? buildPromptFromConnectedTextItems(connectedTextItems, connectedText, nodeConfig.prompt)
: promptFromDynamic || nodeConfig.prompt || null;
: nodeConfig.prompt || null;
}
// Defensive: ensure promptText is actually a string at runtime
@ -168,14 +163,8 @@ export async function executeNanoBanana(
const provider = modelToUse.provider;
const headers = buildGenerateHeaders(provider, providerSettings);
// Sanitize dynamicInputs: remove prompt since it's already sent as the top-level
// `prompt` field in requestPayload. Keeping both can cause providers like Replicate
// to prefer dynamicInputs.prompt over the authoritative top-level value.
const sanitizedDynamicInputs = { ...dynamicInputs };
delete sanitizedDynamicInputs.prompt;
try {
const preparedInputs = await uploadPopiserverGenerationInputsForGeneration(images, sanitizedDynamicInputs);
const preparedInputs = await uploadPopiserverGenerationInputsForGeneration(images, {});
const supportedInputs = provider === "popiserver"
? getPopiSupportedMediaInputs(modelToUse, { images: preparedInputs.images })
: { images: preparedInputs.images, videos: [], voices: [] };
@ -213,7 +202,6 @@ export async function executeNanoBanana(
useImageSearch: (parametersOverride?.useImageSearch as boolean) ?? nodeData.useImageSearch,
selectedModel: modelToUse,
parameters: effectiveParameters,
dynamicInputs: preparedInputs.dynamicInputs,
};
// Final guard: assert that prompt is a string before sending to API
@ -391,7 +379,6 @@ export async function executeNanoBanana(
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
inputSchema: undefined,
});
}

9
src/store/utils/__tests__/connectedInputs.test.ts

@ -277,7 +277,7 @@ describe("getConnectedInputsPure", () => {
expect(result.images).toEqual(["data:image/png;base64,a", "data:image/png;base64,b"]);
});
it("should populate dynamicInputs with schema mapping", () => {
it("should ignore legacy inputSchema when collecting image inputs", () => {
const nodes = [
makeNode("img", "imageInput", { image: "data:image/png;base64,a" }),
makeNode("gen", "nanoBanana", {
@ -286,10 +286,11 @@ describe("getConnectedInputsPure", () => {
];
const edges = [makeEdge("img", "gen", "image-0")];
const result = getConnectedInputsPure("gen", nodes, edges);
expect(result.dynamicInputs).toEqual({ image_url: "data:image/png;base64,a" });
expect(result.images).toEqual(["data:image/png;base64,a"]);
expect(result.dynamicInputs).toEqual({});
});
it("should populate dynamicInputs with video schema mapping", () => {
it("should ignore legacy inputSchema when collecting video inputs", () => {
const nodes = [
makeNode("vid", "videoInput", { video: "data:video/mp4;base64,v" }),
makeNode("gen", "generateVideo", {
@ -306,7 +307,7 @@ describe("getConnectedInputsPure", () => {
const result = getConnectedInputsPure("gen", nodes, edges);
expect(result.videos).toEqual(["data:video/mp4;base64,v"]);
expect(result.videoItems).toEqual([{ url: "data:video/mp4;base64,v" }]);
expect(result.dynamicInputs).toEqual({ video_urls: "data:video/mp4;base64,v" });
expect(result.dynamicInputs).toEqual({});
});
it("should extract easeCurve data", () => {

54
src/store/utils/connectedInputs.ts

@ -279,47 +279,6 @@ export function getConnectedInputsPure(
}
};
// Get the target node to check for inputSchema
const targetNode = nodes.find((n) => n.id === nodeId);
const inputSchema = (targetNode?.data as { inputSchema?: Array<{ name: string; type: string }> })?.inputSchema;
// Build mapping from normalized handle IDs to schema names if schema exists
const handleToSchemaName: Record<string, string> = {};
if (inputSchema && inputSchema.length > 0) {
const imageInputs = inputSchema.filter(i => i.type === "image");
const videoInputs = inputSchema.filter(i => i.type === "video");
const textInputs = inputSchema.filter(i => i.type === "text");
const audioInputs = inputSchema.filter(i => i.type === "audio");
imageInputs.forEach((input, index) => {
handleToSchemaName[`image-${index}`] = input.name;
if (index === 0) {
handleToSchemaName["image"] = input.name;
}
});
videoInputs.forEach((input, index) => {
handleToSchemaName[`video-${index}`] = input.name;
if (index === 0) {
handleToSchemaName["video"] = input.name;
}
});
textInputs.forEach((input, index) => {
handleToSchemaName[`text-${index}`] = input.name;
if (index === 0) {
handleToSchemaName["text"] = input.name;
}
});
audioInputs.forEach((input, index) => {
handleToSchemaName[`audio-${index}`] = input.name;
if (index === 0) {
handleToSchemaName["audio"] = input.name;
}
});
}
// Cache passthrough node results so multiple edges from the same router/switch
// all receive correct data (the _visited set prevents re-traversal, so we cache
// the result from the first traversal and reuse it for subsequent edges).
@ -421,19 +380,6 @@ export function getConnectedInputsPure(
return;
}
// Map normalized handle ID to schema name for dynamicInputs
if (handleId && handleToSchemaName[handleId]) {
const schemaName = handleToSchemaName[handleId];
const existing = dynamicInputs[schemaName];
if (existing !== undefined) {
dynamicInputs[schemaName] = Array.isArray(existing)
? [...existing, value]
: [existing, value];
} else {
dynamicInputs[schemaName] = value;
}
}
addTypedValue(type, value, sourceNode);
if (sourceNode.type === "easeCurve") {
const sourceData = sourceNode.data as EaseCurveNodeData;

56
src/store/workflowStore.ts

@ -26,7 +26,6 @@ import {
RecentModel,
CanvasNavigationSettings,
MatchMode,
ModelInputDef,
} from "@/types";
import { UndoManager, UndoSnapshot, clonePreservingStrings } from "./undoHistory";
import { useToast } from "@/components/Toast";
@ -67,7 +66,6 @@ import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode";
import { isSmartTextLlmMode } from "@/utils/smartTextMode";
import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode";
import {
getNodeOutputMediaType,
normalizeConnectionHandles,
normalizeWorkflowEdgeHandles,
SINGLE_INPUT_HANDLE_ID,
@ -211,21 +209,6 @@ function buildConnectionEdgeData(
return baseData;
}
const DYNAMIC_MODEL_INPUT_TYPES: readonly ModelInputDef["type"][] = ["image", "video", "audio", "text"];
function getDynamicModelInputType(handleId: string | null | undefined): ModelInputDef["type"] | null {
if (!handleId) return null;
if (handleId === "prompt") return "text";
for (const inputType of DYNAMIC_MODEL_INPUT_TYPES) {
if (handleId === inputType || handleId.startsWith(`${inputType}-`)) {
return inputType;
}
}
return null;
}
// Workflow file format
export interface WorkflowFile {
version: 1;
@ -280,7 +263,6 @@ interface WorkflowStore {
onConnect: (connection: Connection, edgeDataOverrides?: Record<string, unknown>) => void;
addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => void;
removeEdge: (edgeId: string) => void;
reconcileDynamicInputEdges: (nodeId: string, validInputTypes: readonly ModelInputDef["type"][]) => void;
toggleEdgePause: (edgeId: string) => void;
setLoopCount: (edgeId: string, count: number) => void;
@ -728,7 +710,6 @@ function promoteUnavailableGeminiFallbacks(
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
inputSchema: undefined,
} as WorkflowNodeData,
} as WorkflowNode;
}) as WorkflowNode[];
@ -1499,43 +1480,6 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
get().incrementManualChangeCount();
},
reconcileDynamicInputEdges: (nodeId: string, validInputTypes: readonly ModelInputDef["type"][]) => {
const validInputTypeSet = new Set<ModelInputDef["type"]>(validInputTypes);
const nodes = get().nodes;
const edges = get().edges;
const removedEdges = edges.filter((edge) => {
if (edge.target !== nodeId) return false;
const sourceNode = nodes.find((node) => node.id === edge.source);
const inputType = getDynamicModelInputType(edge.targetHandle)
?? (sourceNode ? getNodeOutputMediaType(sourceNode) : null);
if (inputType !== "image" && inputType !== "video" && inputType !== "audio" && inputType !== "text") {
return false;
}
return !validInputTypeSet.has(inputType);
});
if (removedEdges.length === 0) return;
const removedEdgeIds = new Set(removedEdges.map((edge) => edge.id));
pushUndoCheckpoint(get, set);
set((state) => ({
edges: state.edges.filter((edge) => !removedEdgeIds.has(edge.id)),
hasUnsavedChanges: true,
}));
deleteCheckpointActive = true;
try {
clearStaleInputImages(removedEdges, get);
} finally {
deleteCheckpointActive = false;
}
get().incrementManualChangeCount();
get().recomputeDimmedNodes();
},
toggleEdgePause: (edgeId: string) => {
pushUndoCheckpoint(get, set);
set((state) => ({

8
src/types/nodes.ts

@ -211,9 +211,7 @@ export interface CarouselVideoItem {
modelProvider?: ProviderType;
}
/**
* Model input definition for dynamic handles
*/
/** Model input definition returned by model detail APIs. */
export interface ModelInputDef {
name: string;
type: "image" | "text" | "video" | "audio";
@ -246,7 +244,6 @@ export interface NanoBananaNodeData extends BaseNodeData {
useImageSearch: boolean; // Only available for Nano Banana 2
parameters?: Record<string, unknown>; // Legacy mirror of config.parameters; model-specific dynamic parameters only
fallbackParameters?: Record<string, unknown>; // Parameters for fallback model
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
parameterSchema?: ModelParameter[]; // Model's parameter schema for per-node controls
modelSchemaLoading?: boolean;
modelSchemaError?: string | null;
@ -285,7 +282,6 @@ export interface GenerateVideoNodeData extends BaseNodeData {
lastUsedModel?: SelectedModel; // The model that produced the current output
parameters?: Record<string, unknown>; // Legacy mirror of config.parameters; model-specific dynamic parameters only
fallbackParameters?: Record<string, unknown>; // Parameters for fallback model
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
parameterSchema?: ModelParameter[]; // Model's parameter schema for per-node controls
modelSchemaLoading?: boolean;
modelSchemaError?: string | null;
@ -316,7 +312,6 @@ export interface Generate3DNodeData extends BaseNodeData {
config?: PersistedGenerationNodeConfig; // Canonical generation config; legacy fields are kept for compatibility
parameters?: Record<string, unknown>; // Legacy mirror of config.parameters; model-specific dynamic parameters only
fallbackParameters?: Record<string, unknown>; // Parameters for fallback model
inputSchema?: ModelInputDef[];
parameterSchema?: ModelParameter[];
modelSchemaLoading?: boolean;
modelSchemaError?: string | null;
@ -353,7 +348,6 @@ export interface GenerateAudioNodeData extends BaseNodeData {
config?: PersistedGenerationNodeConfig; // Canonical generation config; legacy fields are kept for compatibility
parameters?: Record<string, unknown>; // Legacy mirror of config.parameters; model-specific dynamic parameters only
fallbackParameters?: Record<string, unknown>; // Parameters for fallback model
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
parameterSchema?: ModelParameter[]; // Model's parameter schema for per-node controls
modelSchemaLoading?: boolean;
modelSchemaError?: string | null;

5
src/utils/generationConfig.ts

@ -251,7 +251,6 @@ export function readAudioGenerationConfig(
function resetSchemaCachePatch() {
return {
fallbackParameters: {},
inputSchema: undefined,
parameterSchema: undefined,
modelSchemaRequestId: Date.now(),
};
@ -336,7 +335,6 @@ export function buildInitialGenerationNodeData(
selectedModel: config.selectedModel,
imageCount: config.imageCount,
parameters: config.parameters ?? {},
inputSchema: undefined,
useGoogleSearch: false,
useImageSearch: false,
status: "idle",
@ -353,7 +351,6 @@ export function buildInitialGenerationNodeData(
durationSeconds: normalizeVideoDurationSeconds(config.durationSeconds),
soundEnabled: modelSupportsVideoSound(config.selectedModel) ? config.soundEnabled ?? true : undefined,
parameters: config.parameters ?? {},
inputSchema: undefined,
status: "idle",
error: null,
config: persistedGenerationConfig(config),
@ -365,7 +362,6 @@ export function buildInitialGenerationNodeData(
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
parameters: config.parameters ?? {},
inputSchema: undefined,
status: "idle",
error: null,
config: persistedGenerationConfig(config),
@ -375,7 +371,6 @@ export function buildInitialGenerationNodeData(
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
parameters: config.parameters ?? {},
inputSchema: undefined,
status: "idle",
error: null,
config: persistedGenerationConfig(config),

12
src/utils/generationInputSupport.ts

@ -1,4 +1,4 @@
import type { ModelInputDef, SelectedModel } from "@/types";
import type { SelectedModel } from "@/types";
export type ReferenceMediaType = "image" | "video" | "audio";
@ -39,10 +39,6 @@ const IMAGE_INPUT_PARAMETER_NAMES = new Set([
"reference_image_urls",
]);
function hasSchemaInput(inputSchema: ModelInputDef[] | undefined, mediaType: ReferenceMediaType): boolean {
return Boolean(inputSchema?.some((input) => input.type === mediaType));
}
function hasNamedParameterInput(parameters: Record<string, unknown> | undefined, names: Set<string>): boolean {
if (!parameters) return false;
return Object.keys(parameters).some((key) => names.has(key));
@ -56,11 +52,8 @@ function metadataBoolean(model: SelectedModel, key: string): boolean | null {
export function modelSupportsReferenceMedia(
model: SelectedModel,
mediaType: ReferenceMediaType,
inputSchema?: ModelInputDef[],
parameters?: Record<string, unknown>
): boolean {
if (hasSchemaInput(inputSchema, mediaType)) return true;
if (mediaType === "image") {
const supportImages = metadataBoolean(model, "isSupportImages");
if (supportImages !== null) return supportImages;
@ -88,7 +81,6 @@ export function modelSupportsReferenceMedia(
export function filterReferenceSubjectList(
referenceSubjectList: unknown,
model: SelectedModel,
inputSchema?: ModelInputDef[],
parameters?: Record<string, unknown>
): unknown {
if (!Array.isArray(referenceSubjectList)) return referenceSubjectList;
@ -96,6 +88,6 @@ export function filterReferenceSubjectList(
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const type = (item as Record<string, unknown>).type;
if (type !== "image" && type !== "video" && type !== "audio") return true;
return modelSupportsReferenceMedia(model, type, inputSchema, parameters);
return modelSupportsReferenceMedia(model, type, parameters);
});
}

36
src/utils/nodeHandles.ts

@ -268,35 +268,11 @@ function getSmartMediaInputHandles(
const capability = getNodeHandleCapability(node, edges);
if (capability.inputs.length === 0) return [];
const data = node.data as {
inputSchema?: Array<{ name: string; type: string }>;
};
if (!data.inputSchema || data.inputSchema.length === 0) {
return defaultTypes
.filter((type) => capability.inputs.includes(type))
.map((type, index) => ({
id: type,
type,
topPercent: defaultTopPercents[index] ?? ((index + 1) / (defaultTypes.length + 1)) * 100,
}));
}
const supportedTypes = ["image", "video", "audio", "text"] as const;
const grouped = supportedTypes
.map((type) => {
const inputs = data.inputSchema?.filter((input) => input.type === type && capability.inputs.includes(type)) ?? [];
return inputs.length > 0 ? { type, inputs } : null;
})
.filter((item): item is NonNullable<typeof item> => item !== null);
return grouped.flatMap((group, groupIndex) => {
const topPercent = ((groupIndex + 1) / (grouped.length + 1)) * 100;
return group.inputs.map((input, inputIndex) => ({
id: inputIndex === 0 ? group.type : `${group.type}-${inputIndex}`,
type: group.type,
schemaName: input.name,
topPercent,
return defaultTypes
.filter((type) => capability.inputs.includes(type))
.map((type, index) => ({
id: type,
type,
topPercent: defaultTopPercents[index] ?? ((index + 1) / (defaultTypes.length + 1)) * 100,
}));
});
}

Loading…
Cancel
Save