// Converts a workflow editor state (nodes + edges) to a flow array suitable for the Scenario API. // Ported from fawkes useConvertWorkflowToApp — pure function, no React dependencies. // ---- Public types ---- export type WorkflowEditorHandleInput = { id: string; label?: string; name?: string; type?: string | string[]; subHandles?: WorkflowEditorHandleInput[]; }; export type WorkflowEditorHandleOutput = { id: string; name?: string; type?: string; isArray?: boolean; }; type NodeCommonData = { isInput?: boolean; isOutput?: boolean; inputHandles?: WorkflowEditorHandleInput[]; outputHandles?: WorkflowEditorHandleOutput[]; }; export type WorkflowEditorNode = | { id: string; type: 'model'; data: NodeCommonData & { modelId?: string; type?: string; form?: Record }; } | { id: string; type: 'llm'; data: NodeCommonData & { modelId?: string; form?: Record } } | { id: string; type: 'text'; data: NodeCommonData & { value?: string } } | { id: string; type: 'asset'; data: NodeCommonData & { type?: string; value?: string | string[]; isMultiple?: boolean; isRequired?: boolean; valueFirstFrame?: string; valueLastFrame?: string; }; } | { id: string; type: 'transformText'; data: NodeCommonData & { value?: string } } | { id: string; type: 'splitText'; data: NodeCommonData & { splitDelimiter?: string } } | { id: string; type: 'aspectRatio'; data: NodeCommonData & { output?: string; quality?: string } } | { id: string; type: 'groupItems'; data: NodeCommonData & { value?: string } } | { id: string; type: 'sliceAssets'; data: NodeCommonData & { from?: number | string; count?: number | string; isRandomize?: boolean; randomSeed?: string; isSeedLocked?: boolean; value?: string; }; } | { id: string; type: 'forEach'; data: NodeCommonData } | { id: string; type: 'forEachEnd'; data: NodeCommonData & { parentNodeId?: string } } | { id: string; type: 'ifElse'; data: NodeCommonData & { conditionBlocks?: WorkflowEditorConditionBlock[] }; } | { id: string; type: 'approval'; data: NodeCommonData & { message?: string } } | { id: string; type: 'modelInput'; data: NodeCommonData & { inputName: string; form?: Record; parentModelNodeId?: string; modelId?: string; }; }; export type WorkflowEditorEdge = { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null; data?: { isForEachEndEdge?: boolean } | undefined; }; export type WorkflowEditorCondition = { field: string | undefined; operator: string; value?: string | string[]; }; export type WorkflowEditorConditionBlock = { conditions: WorkflowEditorCondition[]; logic: 'and' | 'or'; }; export type WorkflowEditorModelInput = { name: string; type: string; inputs?: Array<{ name: string; type: string }>; min?: number; max?: number; step?: number; allowedValues?: string[]; }; export type WorkflowEditorResolutionPreset = { width?: number; height?: number; }; export type WorkflowEditorModel = { id?: string; inputs?: WorkflowEditorModelInput[]; tags?: string[]; uiConfig?: { resolutionComponent?: { widthInput?: string; heightInput?: string; presets?: WorkflowEditorResolutionPreset[]; }; }; }; type ModelInputNodeData = NodeCommonData & { inputName: string; form?: Record; }; const RUNNABLE_TYPES = new Set(['model', 'llm', 'forEach', 'forEachEnd']); const MODEL_CUSTOM_INPUT_HANDLE_SUFFIX = '-source-custom'; export const ASPECT_RATIO_PRESETS = [ '21:9', '16:9', '3:2', '4:3', '5:4', '1:1', '4:5', '3:4', '2:3', '9:16', '9:21', ] as const; export type AspectRatioBounds = { minWidth: number; maxWidth: number; minHeight: number; maxHeight: number; multipleOf: number; }; export type AspectRatioModelInputNames = { widthInputName?: string | undefined; heightInputName?: string | undefined; aspectRatioInputName?: string | undefined; }; export type WorkflowEditorFlowInputRef = { equal?: string | undefined; node?: string | undefined; conditional?: string[] | undefined; name?: string | undefined; }; export type WorkflowEditorFlowInput = { name: string; type: string; ref?: WorkflowEditorFlowInputRef | undefined; value?: string | number | string[] | boolean | undefined; items?: WorkflowEditorFlowInput[][] | undefined; }; export type WorkflowEditorFlowItem = { id: string; type: string; modelId?: string | undefined; inputs?: WorkflowEditorFlowInput[] | undefined; logic?: | { transform?: string | undefined; cases?: { condition: string; value: string }[] | undefined; default?: string | undefined; } | undefined; logicType?: 'if-else' | undefined; loopBodyNodeIds?: string[] | undefined; includeOutputsInWorkflowJob?: true | undefined; dependsOn?: string[] | undefined; label?: string | undefined; }; // ---- Internal types ---- type ConcatVariable = { name: string; isArray: boolean; isRequired: boolean }; type SourceRefContext = { nodes: WorkflowEditorNode[]; inputNodeIds: string[]; nodeIdToInputNameMap: Map; nodeIdToFlowIdMap: Map; nodeHandleTransformMap?: Map; }; // ---- Utility functions ---- function nodeIdToFlowId(nodeId: string): string { return nodeId.replace(/\s/g, ''); } function escapeCELString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n'); } function formatTextAsCELString(text: string): string { return `'${text.replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`; } function buildConcatArrayTransform(variables: ConcatVariable[]): string { if (variables.length === 0) return '[]'; return variables .map((v) => { const expr = v.isArray ? v.name : `[${v.name}]`; return v.isRequired ? expr : `(exists('${v.name}') ? ${expr} : [])`; }) .join(' + '); } function convertValueToList(value: string): string[] { return value .replace(/\[|\]/g, '') .split('+') .map((v) => v.trim()) .filter(Boolean); } function getConditionalInputHandleId(nodeId: string): string { return `${nodeId}-source-conditional`; } function handleTypeIncludes(handleType: string | string[] | undefined, value: string): boolean { if (Array.isArray(handleType)) return handleType.includes(value); return handleType === value; } type ParsedAspectRatio = { width: number; height: number }; function parseAspectRatio(aspectRatio: string | undefined): ParsedAspectRatio | undefined { if (!aspectRatio) return undefined; const [rawWidth, rawHeight] = aspectRatio.split(':'); const width = Number(rawWidth); const height = Number(rawHeight); if ( !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || !Number.isInteger(width) || !Number.isInteger(height) ) { return undefined; } return { width, height }; } function getGreatestCommonDivisor(a: number, b: number): number { let x = Math.abs(Math.trunc(a)); let y = Math.abs(Math.trunc(b)); while (y) { const temp = y; y = x % y; x = temp; } return x || 1; } function makeNumberAMultipleOfX(value: number, multipleOf: number): number { if (multipleOf <= 0) return Math.round(value); return Math.round(value / multipleOf) * multipleOf; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } export function normalizeAspectRatio(aspectRatio: string | undefined): string | undefined { const parsed = parseAspectRatio(aspectRatio); if (!parsed) return undefined; const divisor = getGreatestCommonDivisor(parsed.width, parsed.height); const normalizedAspectRatio = `${parsed.width / divisor}:${parsed.height / divisor}`; return ( { '3:7': '9:21', '7:3': '21:9', }[normalizedAspectRatio] ?? normalizedAspectRatio ); } export function getAspectRatioModelInputNames( model: WorkflowEditorModel | undefined, ): AspectRatioModelInputNames { if (!model) return {}; const widthInputName = model.uiConfig?.resolutionComponent?.widthInput ?? model.inputs?.find((input) => input.name === 'width')?.name; const heightInputName = model.uiConfig?.resolutionComponent?.heightInput ?? model.inputs?.find((input) => input.name === 'height')?.name; const aspectRatioInputName = model.inputs?.find((input) => input.name === 'aspectRatio')?.name; return { widthInputName, heightInputName, aspectRatioInputName }; } export function getAspectRatioBounds( model: WorkflowEditorModel | undefined, { widthInputName, heightInputName }: Pick, ): AspectRatioBounds | undefined { if (!model || !widthInputName || !heightInputName) return undefined; const widthInput = model.inputs?.find((input) => input.name === widthInputName); const heightInput = model.inputs?.find((input) => input.name === heightInputName); const presets = model.uiConfig?.resolutionComponent?.presets; const widthPresets = (presets ?? []) .map((preset) => preset.width) .filter((value): value is number => typeof value === 'number'); const heightPresets = (presets ?? []) .map((preset) => preset.height) .filter((value): value is number => typeof value === 'number'); const multipleOf = widthInput?.step ?? 8; return { maxWidth: widthInput?.max ?? (widthPresets.length > 0 ? Math.max(...widthPresets) : 2048), minWidth: widthInput?.min ?? (widthPresets.length > 0 ? Math.min(...widthPresets) : 64), maxHeight: heightInput?.max ?? (heightPresets.length > 0 ? Math.max(...heightPresets) : 2048), minHeight: heightInput?.min ?? (heightPresets.length > 0 ? Math.min(...heightPresets) : 64), multipleOf, }; } export function getDimensionsFromAspectRatio(args: { aspectRatio: string; bounds: AspectRatioBounds; }): { width: number; height: number } | undefined { const parsed = parseAspectRatio(args.aspectRatio); if (!parsed) return undefined; const ratio = parsed.width / parsed.height; let width = ratio >= 1 ? args.bounds.maxWidth : Math.round(args.bounds.maxHeight * ratio); let height = ratio >= 1 ? Math.round(args.bounds.maxWidth / ratio) : args.bounds.maxHeight; width = makeNumberAMultipleOfX(width, args.bounds.multipleOf); height = makeNumberAMultipleOfX(height, args.bounds.multipleOf); width = clamp(width, args.bounds.minWidth, args.bounds.maxWidth); height = clamp(height, args.bounds.minHeight, args.bounds.maxHeight); return { width, height }; } function getForEachFlowListInputDescriptor(args: { listProvider: WorkflowEditorNode | undefined; outputHandle: WorkflowEditorHandleOutput | undefined; inputIndex: number; }): { name: string; type: 'file_array' | 'string_array' } { const { listProvider, outputHandle, inputIndex } = args; const isText = listProvider?.type === 'splitText' || listProvider?.type === 'text' || listProvider?.type === 'llm' || listProvider?.type === 'transformText' || (outputHandle?.type === 'text' && !!outputHandle.isArray); return isText ? { name: `text${inputIndex}`, type: 'string_array' } : { name: `image${inputIndex}`, type: 'file_array' }; } function getForEachIterationRefName( outputHandle: WorkflowEditorHandleOutput | undefined, outputIndex: string, ): string { return outputHandle?.type === 'text' ? `text${outputIndex}` : `image${outputIndex}`; } function getHasComposeLayerSourceInput(layerInputs: { name: string }[]): boolean { return layerInputs.some((inp) => inp.name === 'source'); } function conditionToCelExpression(condition: WorkflowEditorCondition): string { if (!condition.field) return 'false'; const { field, operator, value } = condition; function escStr(s: string) { return `'${s.replace(/'/g, "''")}'`; } function escRx(s: string) { return s.replace(/[.+*?[\]()^$|\\]/g, '\\$&'); } function fmtVal(v: string | string[] | undefined): string { if (v === undefined) return "''"; if (Array.isArray(v)) return `[${v.map((x) => escStr(String(x))).join(', ')}]`; const n = Number(v); if (!isNaN(n) && v !== '' && String(n) === String(v).trim()) return String(n); return escStr(String(v)); } switch (operator) { case 'isEmpty': return `${field} == null || size(${field}) == 0 || (type(${field}) != list && trim(${field}) == "")`; case 'isNotEmpty': return `${field} != null && size(${field}) > 0 && (type(${field}) == list || trim(${field}) != "")`; case 'equals': return `trim(${field}) == ${fmtVal(value)}`; case 'notEquals': return `trim(${field}) != ${fmtVal(value)}`; case 'contains': return `${field}.matches('.*${escRx(String(value || '')).replace(/'/g, "''")}.*')`; case 'notContains': return `!${field}.matches('.*${escRx(String(value || '')).replace(/'/g, "''")}.*')`; case 'greaterThan': return `${field} > ${fmtVal(value)}`; case 'greaterThanOrEqual': return `${field} >= ${fmtVal(value)}`; case 'lessThan': return `${field} < ${fmtVal(value)}`; case 'lessThanOrEqual': return `${field} <= ${fmtVal(value)}`; case 'between': if (Array.isArray(value) && value.length === 2) { const n1 = Number(value[0]); const n2 = Number(value[1]); if (!isNaN(n1) && !isNaN(n2)) return `${field} >= ${n1} && ${field} <= ${n2}`; } return 'false'; default: return 'false'; } } function conditionBlockToCEL( block: WorkflowEditorConditionBlock, nodeIdToInputName: Map, ): string { const exprs = block.conditions .map((c) => { const mappedField = c.field ? nodeIdToInputName.get(c.field) ?? c.field : undefined; return conditionToCelExpression({ ...c, field: mappedField }); }) .filter((e) => e !== 'false'); if (exprs.length === 0) return ''; if (exprs.length === 1) return exprs[0]!; return exprs.join(block.logic === 'or' ? ' || ' : ' && '); } function getSourceRef( edge: WorkflowEditorEdge, ctx: SourceRefContext, ): WorkflowEditorFlowInputRef | undefined { const { nodes, inputNodeIds, nodeIdToInputNameMap, nodeIdToFlowIdMap, nodeHandleTransformMap } = ctx; const targetNodeId = edge.target; const targetNode = nodes.find((n) => n.id === targetNodeId); if (!targetNode) return undefined; if (inputNodeIds.includes(targetNodeId) && nodeHandleTransformMap) { const handle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); const handleName = handle?.name ?? 'output'; if (handleName !== 'output') { const transformId = nodeHandleTransformMap.get(`${targetNodeId}_${handleName}`); if (transformId) return { node: transformId, name: 'all' }; } } if (inputNodeIds.includes(targetNodeId)) { const inputName = nodeIdToInputNameMap.get(targetNodeId); if (inputName) return { node: 'workflow', name: inputName }; } if (targetNode.type === 'forEach' && edge.targetHandle) { const m = edge.targetHandle.match(/-output-(\d+)$/); if (m) { const targetFlowId = nodeIdToFlowIdMap.get(targetNodeId); if (targetFlowId) { const outHandle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); return { node: targetFlowId, name: getForEachIterationRefName(outHandle, m[1]!) }; } } } if (targetNode.type === 'forEachEnd') { const parentId = (targetNode.data as { parentNodeId?: string }).parentNodeId; if (parentId) { const forEachFlowId = nodeIdToFlowIdMap.get(parentId); if (forEachFlowId) return { node: forEachFlowId }; } } if (targetNode.type === 'ifElse') { // Approval handles are UI-only and must not shift case/else indices. const handles = (targetNode.data.outputHandles ?? []).filter((h) => h.type !== 'approval'); const handleIndex = handles.findIndex((h) => h.id === edge.targetHandle); if (handleIndex !== -1) { const targetFlowId = nodeIdToFlowIdMap.get(targetNodeId); const blocks = (targetNode.data as { conditionBlocks?: WorkflowEditorConditionBlock[] }).conditionBlocks ?? []; const isElse = handleIndex >= blocks.length; return { node: targetFlowId, equal: isElse ? '1' : String(handleIndex + 2) }; } } if ( (targetNode.type === 'model' || targetNode.type === 'asset') && edge.targetHandle && nodeHandleTransformMap ) { const handle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); const handleName = handle?.name ?? 'output'; if (handleName !== 'output') { const transformId = nodeHandleTransformMap.get(`${targetNodeId}_${handleName}`); if (transformId) return { node: transformId, name: 'all' }; } } if (targetNode.type === 'llm' && edge.targetHandle && nodeHandleTransformMap) { const handle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); if (handle?.name === 'outputs') { const transformId = nodeHandleTransformMap.get(`${targetNodeId}_outputs`); if (transformId) return { node: transformId, name: 'all' }; } } const targetFlowId = nodeIdToFlowIdMap.get(targetNodeId); if (targetFlowId) return { node: targetFlowId, name: 'all' }; return undefined; } function getInputHandle( edge: WorkflowEditorEdge, inputHandles: WorkflowEditorHandleInput[] | undefined, ): WorkflowEditorHandleInput | undefined { return inputHandles?.flatMap((h) => { if (h.id === edge.sourceHandle) return [h]; for (const sub of h.subHandles ?? []) { if (sub.id === edge.sourceHandle) return [sub]; } return []; })[0]; } function hasHandleConnection( node: WorkflowEditorNode, handleName: string, edges: WorkflowEditorEdge[], nodeId: string, ): boolean { return edges .filter((e) => e.target === nodeId) .some( (e) => (node.data.outputHandles?.find((h) => h.id === e.targetHandle)?.name ?? 'output') === handleName, ); } function getOutputHandleVarName(targetNodeId: string, handleName: string): string { return ['output', 'allOutputs'].includes(handleName) ? targetNodeId : `${targetNodeId}_${handleName}`; } function getIsSliceAssetsItemsSourceHandle(nodeId: string, sourceHandle: string | null | undefined): boolean { return sourceHandle === `${nodeId}-source-items` || sourceHandle === `${nodeId}-source-assets`; } function getConcatListSourceDescriptor( targetNode: WorkflowEditorNode, outHandle: WorkflowEditorHandleOutput | undefined, outHandleName: string, ): { isArray: boolean; inputType: string } { const isArray = (targetNode.type === 'asset' && !!(targetNode.data as { isMultiple?: boolean }).isMultiple) || targetNode.type === 'groupItems' || targetNode.type === 'sliceAssets' || targetNode.type === 'splitText' || (targetNode.type === 'llm' && outHandleName === 'outputs') || !!outHandle?.isArray || outHandleName === 'allOutputs'; if (!isArray) { return { isArray: false, inputType: outHandle?.type === 'text' ? 'string' : 'file', }; } const isTextList = targetNode.type === 'splitText' || (targetNode.type === 'llm' && outHandleName === 'outputs') || outHandle?.type === 'text'; return { isArray: true, inputType: isTextList ? 'string_array' : 'file_array', }; } /** * Builds the concat variables and flow inputs for a list-concatenating node * (groupItems, sliceAssets in randomize mode) from its incoming edges. * `getVarName` maps a target node id + output handle name to the variable name, * which differs between callers. */ function collectConcatVarsAndInputs( edges: WorkflowEditorEdge[], ctx: SourceRefContext, getVarName: (targetNodeId: string, handleName: string) => string, ): { variables: ConcatVariable[]; flowInputs: WorkflowEditorFlowInput[] } { const { nodes, inputNodeIds } = ctx; const variables: ConcatVariable[] = []; const flowInputs: WorkflowEditorFlowInput[] = []; for (const edge of edges) { const targetNode = nodes.find((n) => n.id === edge.target); if (!targetNode) continue; const ref = getSourceRef(edge, ctx); if (!ref) continue; const outHandle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); const outHandleName = outHandle?.name ?? 'output'; const varName = getVarName(targetNode.id, outHandleName); const { isArray, inputType } = getConcatListSourceDescriptor(targetNode, outHandle, outHandleName); const isRequired = inputNodeIds.includes(targetNode.id) ? (targetNode.type === 'asset' && !!(targetNode.data as { isRequired?: boolean }).isRequired) || ['firstFrame', 'lastFrame'].includes(outHandleName) : true; variables.push({ name: varName, isArray, isRequired }); flowInputs.push({ name: varName, type: inputType, ref }); } return { variables, flowInputs }; } function setOrPushFlowInput( flowInputs: WorkflowEditorFlowInput[], existingIndex: number, input: WorkflowEditorFlowInput, ): void { if (existingIndex !== -1) { flowInputs[existingIndex] = input; } else { flowInputs.push(input); } } function addVideoFirstLastFrameTransforms( nodeId: string, flowId: string, node: WorkflowEditorNode, edges: WorkflowEditorEdge[], flow: WorkflowEditorFlowItem[], nodeHandleTransformMap: Map, options?: { inputName?: string; onlyHandle?: 'firstFrame' | 'lastFrame' }, ): void { const handles: ('firstFrame' | 'lastFrame')[] = options?.onlyHandle ? [options.onlyHandle] : ['firstFrame', 'lastFrame']; for (const handleName of handles) { if (!hasHandleConnection(node, handleName, edges, nodeId)) continue; const transformId = options?.inputName ? flowId : `${flowId}_${handleName}`; const ref = options?.inputName ? { node: 'workflow' as const, name: options.inputName } : { node: flowId, name: 'all' as const }; flow.push({ id: transformId, type: 'transform', logic: { transform: `exists('videoOutput') ? assetJson(videoOutput).${handleName}.assetId : []` }, inputs: [{ name: 'videoOutput', type: 'file', ref }], }); nodeHandleTransformMap.set(`${nodeId}_${handleName}`, transformId); } } function collectConditionalConnectionInputs( incomingEdges: WorkflowEditorEdge[], nodeId: string, ctx: SourceRefContext, ): WorkflowEditorFlowInput[] { const conditionalHandleId = getConditionalInputHandleId(nodeId); return incomingEdges .filter((e) => e.sourceHandle === conditionalHandleId) .flatMap((edge) => { const targetNode = ctx.nodes.find((n) => n.id === edge.target); if (targetNode?.type !== 'ifElse') return []; const ref = getSourceRef(edge, ctx); if (!ref) return []; return [{ name: 'connection', type: 'connection', ref }]; }); } function toStaticInput(name: string, type: string, value: unknown): WorkflowEditorFlowInput | undefined { if (value === undefined || value === '') return undefined; const isNum = typeof value === 'number'; const isStr = typeof value === 'string'; const isBool = typeof value === 'boolean'; const isStrArr = Array.isArray(value) && (value as unknown[]).every((v) => typeof v === 'string'); if (!isNum && !isStr && !isBool && !isStrArr) return undefined; if (isStrArr && (value as string[]).length === 0) return undefined; return { name, type, value: value as string | number | string[] | boolean }; } function getIsFormArray(value: unknown): value is Record[] { return ( Array.isArray(value) && (value as unknown[]).every((item) => typeof item === 'object' && item !== null && !Array.isArray(item)) ); } function getIsModelCompose(model: WorkflowEditorModel | undefined): boolean { return !!model?.id && (model.tags ?? []).includes('compose'); } function getIsConditionallyExecuted( targetNodeId: string, nodes: WorkflowEditorNode[], edges: WorkflowEditorEdge[], ): boolean { const conditionalHandleId = getConditionalInputHandleId(targetNodeId); return edges.some((e) => { const sourceNode = nodes.find((n) => n.id === e.target); return ( e.source === targetNodeId && e.sourceHandle === conditionalHandleId && sourceNode?.type === 'ifElse' ); }); } function buildDimensionLookupCEL(args: { variable: string; presets: Array<{ aspectRatio: string; width: number; height: number }>; dimension: 'width' | 'height'; fallback: number; }): string { let expression = String(args.fallback); for (let i = args.presets.length - 1; i >= 0; i--) { const preset = args.presets[i]!; expression = `trim(${args.variable}) == '${preset.aspectRatio}' ? ${ preset[args.dimension] } : ${expression}`; } return expression; } function applyAspectRatioInputs(args: { flow: WorkflowEditorFlowItem[]; flowInputs: WorkflowEditorFlowInput[]; flowId: string; aspectRatioRef: WorkflowEditorFlowInputRef; model: WorkflowEditorModel; aspectRatioQuality: string | undefined; }): void { const modelInputNames = getAspectRatioModelInputNames(args.model); const rawModelBounds = getAspectRatioBounds(args.model, modelInputNames); const quality = args.aspectRatioQuality; const maxDimensionCap = quality === 'high' ? 2048 : quality === 'medium' ? 1024 : undefined; const isCapApplied = rawModelBounds != null && maxDimensionCap != null && (rawModelBounds.maxWidth > maxDimensionCap || rawModelBounds.maxHeight > maxDimensionCap); const modelBounds = rawModelBounds && maxDimensionCap != null ? { ...rawModelBounds, maxWidth: Math.min(rawModelBounds.maxWidth, maxDimensionCap), maxHeight: Math.min(rawModelBounds.maxHeight, maxDimensionCap), multipleOf: isCapApplied ? Math.min(rawModelBounds.multipleOf, 8) : rawModelBounds.multipleOf, } : rawModelBounds; const { widthInputName, heightInputName, aspectRatioInputName } = modelInputNames; if (aspectRatioInputName) { const transformId = `${args.flowId}_aspectRatio_value`; args.flow.push({ id: transformId, type: 'transform', logic: { transform: 'trim(aspectRatio)' }, inputs: [{ name: 'aspectRatio', type: 'string', ref: args.aspectRatioRef }], }); setOrPushFlowInput( args.flowInputs, args.flowInputs.findIndex((inp) => inp.name === aspectRatioInputName), { name: aspectRatioInputName, type: 'string', ref: { node: transformId, name: 'all' }, }, ); } if (widthInputName && heightInputName && modelBounds) { const presetDimensions = ASPECT_RATIO_PRESETS.flatMap((preset) => { const dims = getDimensionsFromAspectRatio({ aspectRatio: preset, bounds: modelBounds }); if (!dims) return []; const normalized = normalizeAspectRatio(preset); if (!normalized) return []; return [{ aspectRatio: normalized, ...dims }]; }); const fallbackDimensions = presetDimensions.find((preset) => preset.aspectRatio === '1:1') ?? presetDimensions[0] ?? { width: modelBounds.maxWidth, height: modelBounds.maxHeight, }; const widthTransformId = `${args.flowId}_aspectRatio_width`; const heightTransformId = `${args.flowId}_aspectRatio_height`; args.flow.push({ id: widthTransformId, type: 'transform', logic: { transform: buildDimensionLookupCEL({ variable: 'aspectRatio', presets: presetDimensions, dimension: 'width', fallback: fallbackDimensions.width, }), }, inputs: [{ name: 'aspectRatio', type: 'string', ref: args.aspectRatioRef }], }); args.flow.push({ id: heightTransformId, type: 'transform', logic: { transform: buildDimensionLookupCEL({ variable: 'aspectRatio', presets: presetDimensions, dimension: 'height', fallback: fallbackDimensions.height, }), }, inputs: [{ name: 'aspectRatio', type: 'string', ref: args.aspectRatioRef }], }); setOrPushFlowInput( args.flowInputs, args.flowInputs.findIndex((inp) => inp.name === widthInputName), { name: widthInputName, type: 'number', ref: { node: widthTransformId, name: 'all' }, }, ); setOrPushFlowInput( args.flowInputs, args.flowInputs.findIndex((inp) => inp.name === heightInputName), { name: heightInputName, type: 'number', ref: { node: heightTransformId, name: 'all' }, }, ); } } function getForEachBodyEditorNodeIds( forEachId: string, forEachEndId: string, edges: WorkflowEditorEdge[], nodes: WorkflowEditorNode[], ): Set { const bodyNodeIds = new Set(); const queue: string[] = []; const escaped = forEachId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); for (const edge of edges) { if (edge.target === forEachId && edge.targetHandle?.match(new RegExp(`^${escaped}-output-\\d+$`))) { queue.push(edge.source); } } while (queue.length > 0) { const nodeId = queue.pop()!; if (nodeId === forEachEndId || bodyNodeIds.has(nodeId)) continue; bodyNodeIds.add(nodeId); for (const edge of edges) { if (edge.target === nodeId && !bodyNodeIds.has(edge.source) && edge.source !== forEachEndId) { queue.push(edge.source); } } } addRandomizeClosureToBody(bodyNodeIds, forEachEndId, edges, nodes); return bodyNodeIds; } /** * The body traversal above only walks *downstream* from the loop's per-item * outputs, so an unlocked randomize node sitting on a side branch that merely * *feeds* the body (e.g. `randomSample(...) -> promptBuilder -> generator`) is * left out — computed once and shared, so every iteration reuses the same draw * (scenario-labs/phoenix#8498). * * A locked seed means the user wants reproducibility, so those stay shared. But * for an UNLOCKED randomize node we mutate `bodyNodeIds` in place to also include * that node plus the deterministic chain down to the body, so the runtime clones * it per iteration and each iteration re-draws. We only pull it in when *every* * path out of it lands in the body — if it also feeds a node outside the loop, * cloning it would break that external consumer, so we leave it shared. */ function addRandomizeClosureToBody( bodyNodeIds: Set, forEachEndId: string, edges: WorkflowEditorEdge[], nodes: WorkflowEditorNode[], ): void { const nodeById = new Map(nodes.map((n) => [n.id, n])); const isUnlockedRandomize = (id: string): boolean => { const data = nodeById.get(id)?.data as { isRandomize?: boolean; isSeedLocked?: boolean } | undefined; return data?.isRandomize === true && data.isSeedLocked !== true; }; // edge convention: `{ source: consumer, target: provider }` — an input handle // on `source` reads an output handle on `target`. const providersOf = (id: string): string[] => edges.filter((e) => e.source === id).map((e) => e.target); const consumersOf = (id: string): string[] => edges.filter((e) => e.target === id).map((e) => e.source); // Every node strictly upstream of the body (its transitive input providers). const upstream = new Set(); const upstreamQueue = [...bodyNodeIds]; while (upstreamQueue.length > 0) { for (const provider of providersOf(upstreamQueue.pop()!)) { if (provider !== forEachEndId && !bodyNodeIds.has(provider) && !upstream.has(provider)) { upstream.add(provider); upstreamQueue.push(provider); } } } for (const randomId of upstream) { if (!isUnlockedRandomize(randomId)) continue; // Walk downstream from the randomize node, stopping at the body frontier. const chain = new Set(); const chainQueue = [randomId]; let escapesLoop = false; while (chainQueue.length > 0) { const id = chainQueue.pop()!; if (bodyNodeIds.has(id)) continue; // reached the loop body — a valid sink chain.add(id); const consumers = consumersOf(id); if (consumers.length === 0) { escapesLoop = true; // dead-end outside the loop → do not internalize break; } for (const consumer of consumers) { if (!bodyNodeIds.has(consumer)) chainQueue.push(consumer); } } if (!escapesLoop) for (const id of chain) bodyNodeIds.add(id); } } function sortNodesByInputKeys(nodes: WorkflowEditorNode[], inputKeys: string[]): WorkflowEditorNode[] { return [...nodes].sort((a, b) => { const ai = inputKeys.indexOf(a.id); const bi = inputKeys.indexOf(b.id); if (ai === -1 && bi === -1) return 0; if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }); } function getNodeOutputValue( node: WorkflowEditorNode, targetHandleId?: string, ): string | string[] | undefined { if (node.type === 'text') return (node.data as { value?: string }).value || ''; if (node.type === 'asset') { const d = node.data as { value?: string | string[]; valueFirstFrame?: string; valueLastFrame?: string; outputHandles?: WorkflowEditorHandleOutput[]; }; if (targetHandleId) { const handleName = d.outputHandles?.find((h) => h.id === targetHandleId)?.name ?? 'output'; if (handleName === 'firstFrame') return d.valueFirstFrame; if (handleName === 'lastFrame') return d.valueLastFrame; } return (Array.isArray(d.value) ? d.value : d.value) || undefined; } if (node.type === 'modelInput') { const value = getModelInputNodeStaticValue(node); if (value === undefined) return undefined; if (typeof value === 'boolean') return value ? 'true' : 'false'; if (typeof value === 'number') return String(value); return value; } return undefined; } function internalGetConnectedNodes( nodes: WorkflowEditorNode[], edges: WorkflowEditorEdge[], startNodeId: string, direction: 'input' | 'output', isStoppingAtRunnableNodes: boolean, ): WorkflowEditorNode[] { type NodeMeta = { id: string; type: string; parentNodeId?: string }; const nodesById = new Map( nodes.map((n) => { const meta: NodeMeta = { id: n.id, type: n.type }; if (n.type === 'forEachEnd') { const parentId = (n.data as { parentNodeId?: string }).parentNodeId; if (parentId !== undefined) { meta.parentNodeId = parentId; } } return [n.id, meta]; }), ); const edgesSimple = edges.map((e) => ({ source: e.source, target: e.target })); function isInternalForEachEdge(e: { source: string; target: string }): boolean { const src = nodesById.get(e.source); const tgt = nodesById.get(e.target); return src?.type === 'forEach' && tgt?.type === 'forEachEnd' && tgt.parentNodeId === src.id; } function findIds(nodeId: string, visited: Set): string[] { if (visited.has(nodeId)) return []; visited.add(nodeId); const result: string[] = []; const relevant = edgesSimple.filter( (e) => (direction === 'output' ? e.target === nodeId : e.source === nodeId) && !isInternalForEachEdge(e), ); for (const edge of relevant) { const connectedId = direction === 'output' ? edge.source : edge.target; const connectedNode = nodesById.get(connectedId); if (!connectedNode) continue; result.push(connectedId); if (isStoppingAtRunnableNodes && RUNNABLE_TYPES.has(connectedNode.type)) continue; result.push(...findIds(connectedId, visited)); } return result; } const ids = findIds(startNodeId, new Set()); const idSet = new Set(ids); return nodes.filter((n) => idSet.has(n.id)); } function resolveIfElseConditionField( field: string, candidateNodes: WorkflowEditorNode[], ): { nodeId: string; targetHandleId?: string } | undefined { const byExactId = candidateNodes.find((n) => n.id === field); if (byExactId) return { nodeId: field }; for (const node of candidateNodes) { for (const handle of node.data.outputHandles ?? []) { if (`${node.id}_${handle.name}` === field) { return { nodeId: node.id, targetHandleId: handle.id }; } } } return undefined; } function normalizeNodes(nodes: WorkflowEditorNode[]): WorkflowEditorNode[] { return nodes.map((node) => { if (node.type !== 'model' && node.type !== 'llm') return node; const inputHandles = (node.data.inputHandles ?? []) .flatMap((h) => (h.subHandles ? h.subHandles : [h])) .filter( (h) => handleTypeIncludes(h.type, 'conditional') || handleTypeIncludes(h.type, 'aspectRatio') || handleTypeIncludes(h.type, 'prompt') || handleTypeIncludes(h.type, 'image') || handleTypeIncludes(h.type, 'video') || handleTypeIncludes(h.type, '3d') || handleTypeIncludes(h.type, 'audio') || (node.type === 'llm' && h.name === 'textInputs'), ); return { ...node, data: { ...node.data, inputHandles } } as WorkflowEditorNode; }); } function getIsModelCustomInputHandleId(handleId: string | null | undefined): boolean { return !!handleId && handleId.endsWith(MODEL_CUSTOM_INPUT_HANDLE_SUFFIX); } function getModelInputEdgeInputName( edge: WorkflowEditorEdge, handle: WorkflowEditorHandleInput | undefined, nodes: WorkflowEditorNode[], ): string | undefined { if (handle?.name) return handle.name; const targetNode = nodes.find((n) => n.id === edge.target); if (targetNode?.type !== 'modelInput') return undefined; if (handle?.type === 'custom' || getIsModelCustomInputHandleId(edge.sourceHandle)) { return (targetNode.data as ModelInputNodeData).inputName; } return undefined; } function getModelInputNodeStaticValue(node: WorkflowEditorNode): string | number | boolean | undefined { if (node.type !== 'modelInput') return undefined; const data = node.data as ModelInputNodeData; const value = data.form?.[data.inputName]; if (value === undefined) return undefined; if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return value; return undefined; } function pushModelFlowInputFromEdge( flowInputs: WorkflowEditorFlowInput[], existingIndex: number, inputName: string, inputType: string, edge: WorkflowEditorEdge, ctx: SourceRefContext, filteredNodes: WorkflowEditorNode[], ): void { const ref = getSourceRef(edge, ctx); if (ref) { setOrPushFlowInput(flowInputs, existingIndex, { name: inputName, type: inputType, ref }); return; } const targetNode = filteredNodes.find((n) => n.id === edge.target); if (targetNode?.type === 'modelInput') { const staticInput = toStaticInput(inputName, inputType, getModelInputNodeStaticValue(targetNode)); if (staticInput) setOrPushFlowInput(flowInputs, existingIndex, staticInput); } } // ---- Main function ---- /** * Converts a workflow editor state (nodes, edges, inputKeys) into a flow array * ready to be passed to `client.workflows.update({ flow: ... })`. * * @example * ```ts * const flow = convertWorkflowEditorToFlow({ nodes, edges, inputKeys, getModel }); * await client.workflows.update(workflowId, { flow: flow as WorkflowUpdateParams.Flow[] }); * ``` */ export type ConvertWorkflowEditorToFlowParams = { nodes: WorkflowEditorNode[]; edges: WorkflowEditorEdge[]; inputKeys: string[]; /** Optional model resolver — return the model object, 'unknown', or undefined. */ getModel?: (modelId: string) => WorkflowEditorModel | 'unknown' | undefined; }; export function convertWorkflowEditorToFlow( params: ConvertWorkflowEditorToFlowParams, ): WorkflowEditorFlowItem[] { const { edges, inputKeys } = params; const nodes = normalizeNodes(params.nodes); const inputNodeIds = nodes.filter((n) => !!n.data.isInput).map((n) => n.id); const selectedOutputNodes = nodes.filter( (n) => (n.type === 'model' || n.type === 'forEachEnd' || n.type === 'llm') && !!n.data.isOutput, ); const selectedOutputNodeIds = selectedOutputNodes.map((n) => n.id); const selectedOutputModelNodeIds = new Set( selectedOutputNodes .filter( (n): n is WorkflowEditorNode & { type: 'model' | 'llm' } => n.type === 'model' || n.type === 'llm', ) .map((n) => n.id), ); // Collect all nodes in branches that lead to selected outputs const nodesInSelectedBranches = new Set(); for (const outputNode of selectedOutputNodes) { const branchNodes = internalGetConnectedNodes(nodes, edges, outputNode.id, 'input', false); for (const n of branchNodes) { nodesInSelectedBranches.add(n.id); if (outputNode.type === 'forEachEnd' && n.type === 'model' && !!n.data.isOutput) { selectedOutputModelNodeIds.add(n.id); } } } // Models fed from a forEachEnd "results" handle also inherit output inclusion for (const outputNode of selectedOutputNodes) { if (outputNode.type !== 'forEachEnd') continue; const inputHandleIds = new Set((outputNode.data.inputHandles ?? []).map((h) => h.id)); for (const edge of edges) { if (!edge.sourceHandle || edge.source !== outputNode.id || !inputHandleIds.has(edge.sourceHandle)) continue; const tgt = nodes.find((n) => n.id === edge.target); if (tgt?.type === 'model') selectedOutputModelNodeIds.add(tgt.id); } } const filteredNodes = nodes.filter( (n) => nodesInSelectedBranches.has(n.id) || inputNodeIds.includes(n.id) || selectedOutputNodeIds.includes(n.id), ); const sortedNodes = sortNodesByInputKeys(filteredNodes, inputKeys); const nodeIdToFlowIdMap = new Map( sortedNodes .filter((n) => !inputNodeIds.includes(n.id) && n.type !== 'forEachEnd' && n.type !== 'modelInput') .map((n) => [n.id, nodeIdToFlowId(n.id)]), ); const nodeIdToInputNameMap = new Map(inputNodeIds.map((id) => [id, nodeIdToFlowId(id)])); const approvalByApprovedNodeId = buildApprovalByApprovedNodeIdMap({ nodes, edges, nodesInSelectedBranches, selectedOutputNodeIds, }); const nodeHandleTransformMap = new Map(); const ctx: SourceRefContext = { nodes, inputNodeIds, nodeIdToInputNameMap, nodeIdToFlowIdMap, nodeHandleTransformMap, }; const flow: WorkflowEditorFlowItem[] = []; const forEachBodyEditorNodeIds = new Map>(); const forEachLoopFlowIds = new Map(); const forEachFlowItemRefs = new Map(); for (const node of sortedNodes) { if (node.type !== 'forEach') continue; const endNode = nodes.find( (n) => n.type === 'forEachEnd' && (n.data as { parentNodeId?: string }).parentNodeId === node.id, ); if (!endNode) continue; forEachBodyEditorNodeIds.set(node.id, getForEachBodyEditorNodeIds(node.id, endNode.id, edges, nodes)); forEachLoopFlowIds.set(node.id, []); } // Pre-populate video firstFrame/lastFrame transform IDs for input nodes const addedInputVideoTransforms = new Set(); for (const inputNodeId of inputNodeIds) { const inputNode = nodes.find((n) => n.id === inputNodeId); if (!inputNode || inputNode.type !== 'asset' || (inputNode.data as { type?: string }).type !== 'video') continue; for (const handleName of ['firstFrame', 'lastFrame'] as const) { if (hasHandleConnection(inputNode, handleName, edges, inputNodeId)) { nodeHandleTransformMap.set(`${inputNodeId}_${handleName}`, `${inputNodeId}_${handleName}`); } } } function ensureInputVideoTransformsAdded() { for (const inputNodeId of inputNodeIds) { const inputNode = nodes.find((n) => n.id === inputNodeId); if (!inputNode || inputNode.type !== 'asset' || (inputNode.data as { type?: string }).type !== 'video') continue; const inputName = nodeIdToInputNameMap.get(inputNodeId); if (!inputName) continue; for (const handleName of ['firstFrame', 'lastFrame'] as const) { const key = `${inputNodeId}_${handleName}`; if (addedInputVideoTransforms.has(key)) continue; if (!hasHandleConnection(inputNode, handleName, edges, inputNodeId)) continue; addVideoFirstLastFrameTransforms(inputNodeId, key, inputNode, edges, flow, nodeHandleTransformMap, { inputName, onlyHandle: handleName, }); addedInputVideoTransforms.add(key); } } } for (const node of sortedNodes) { if (inputNodeIds.includes(node.id)) continue; const flowId = nodeIdToFlowIdMap.get(node.id); if (!flowId) continue; const flowLengthBefore = flow.length; const incomingEdges = edges.filter((e) => e.source === node.id); // ------------------------------------ // Model / LLM -> custom-model // ------------------------------------ if (node.type === 'model' || node.type === 'llm') { const nodeData = node.data as NodeCommonData & { modelId?: string; form?: Record }; const flowInputs: WorkflowEditorFlowInput[] = []; const remoteModel = nodeData.modelId ? params.getModel?.(nodeData.modelId) : undefined; const model = remoteModel === 'unknown' ? undefined : remoteModel; const modelInputs = model?.inputs; const conditionalHandleId = getConditionalInputHandleId(node.id); const aspectRatioEdges: WorkflowEditorEdge[] = []; const edgesByInput = new Map(); for (const edge of incomingEdges) { if (edge.sourceHandle === conditionalHandleId) continue; const handle = getInputHandle(edge, node.data.inputHandles); const inputName = getModelInputEdgeInputName(edge, handle, nodes); if (!inputName) continue; if (handleTypeIncludes(handle?.type, 'aspectRatio')) { aspectRatioEdges.push(edge); continue; } const existing = edgesByInput.get(inputName); if (existing) existing.push(edge); else edgesByInput.set(inputName, [edge]); } if (nodeData.form) { for (const [key, value] of Object.entries(nodeData.form)) { if (value === undefined || value === '') continue; const modelFormInput = modelInputs?.find((inp) => inp.name === key); if (modelFormInput?.type === 'inputs_array') { if (!getIsFormArray(value)) continue; const nestedInputs = modelFormInput.inputs ?? []; const items: WorkflowEditorFlowInput[][] = []; for (const [index, form] of (value as Record[]).entries()) { const itemInputs: WorkflowEditorFlowInput[] = []; for (const nestedInput of nestedInputs) { const edgeKey = `${key}[${index}].${nestedInput.name}`; const edgeGroup = edgesByInput.get(edgeKey); if (edgeGroup && edgeGroup.length > 0) { const ref = getSourceRef(edgeGroup[0]!, ctx); if (ref) itemInputs.push({ name: nestedInput.name, type: nestedInput.type, ref }); edgesByInput.delete(edgeKey); } else { const inp = toStaticInput(nestedInput.name, nestedInput.type, form[nestedInput.name]); if (inp) itemInputs.push(inp); } } const isValid = getIsModelCompose(model) ? getHasComposeLayerSourceInput(itemInputs) : itemInputs.length > 0; if (isValid) items.push(itemInputs); } if (items.length > 0) flowInputs.push({ name: key, type: 'inputs_array', items }); } else { const type = modelFormInput?.type ?? (typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string'); const inp = toStaticInput(key, type, value); if (inp) flowInputs.push(inp); } } } if (aspectRatioEdges.length > 0 && model) { const aspectRatioRef = getSourceRef(aspectRatioEdges[0]!, ctx); const aspectRatioNode = nodes.find((candidate) => candidate.id === aspectRatioEdges[0]!.target); const aspectRatioQuality = aspectRatioNode?.type === 'aspectRatio' ? aspectRatioNode.data.quality : undefined; if (aspectRatioRef) { applyAspectRatioInputs({ flow, flowInputs, flowId, aspectRatioRef, model, aspectRatioQuality, }); } } for (const [inputName, inputEdges] of edgesByInput) { const existingIndex = flowInputs.findIndex((inp) => inp.name === inputName); const modelInput = modelInputs?.find((inp) => inp.name === inputName); if (!modelInput) continue; const inputType = modelInput.type; if (inputEdges.length === 1) { pushModelFlowInputFromEdge( flowInputs, existingIndex, inputName, inputType, inputEdges[0]!, ctx, filteredNodes, ); } else { const allConditional = inputEdges.every((e) => getIsConditionallyExecuted(e.target, nodes, edges)); if (allConditional) { const conditionalNodeIds = inputEdges .map((e) => inputNodeIds.includes(e.target) ? nodeIdToInputNameMap.get(e.target) : nodeIdToFlowIdMap.get(e.target), ) .filter((id): id is string => id != null); if (conditionalNodeIds.length > 0) { setOrPushFlowInput(flowInputs, existingIndex, { name: inputName, type: inputType, ref: { conditional: conditionalNodeIds }, }); } } else if (inputType === 'file_array' || inputType === 'string_array') { const transformVariables: ConcatVariable[] = []; const transformInputs: WorkflowEditorFlowInput[] = []; for (const edge of inputEdges) { const targetNode = filteredNodes.find((n) => n.id === edge.target); const targetOutputHandle = targetNode?.data.outputHandles?.find( (h) => h.id === edge.targetHandle, ); const targetOutputHandleName = targetOutputHandle?.name ?? 'output'; let targetNodeKey: string; let targetInputType: string; if (targetNode?.type === 'forEach' && edge.targetHandle) { const m = edge.targetHandle.match(/-output-(\d+)$/); const outputIdx = m ? m[1] ?? '0' : '0'; const outHandle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); targetNodeKey = getForEachIterationRefName(outHandle, outputIdx); targetInputType = outHandle?.type === 'text' ? 'string' : 'file'; } else { targetNodeKey = getOutputHandleVarName(edge.target, targetOutputHandleName); if (!targetNode) continue; const descriptor = getConcatListSourceDescriptor( targetNode, targetOutputHandle, targetOutputHandleName, ); targetInputType = descriptor.inputType; } const isArr = targetInputType === 'file_array' || targetInputType === 'string_array'; const isRequired = (targetNode?.type === 'asset' && !!(targetNode.data as { isRequired?: boolean }).isRequired) || getIsModelCompose(model); transformVariables.push({ name: targetNodeKey, isArray: isArr, isRequired }); if ( !targetNode?.data.isInput && (targetNode?.type === 'asset' || targetNode?.type === 'text' || targetNode?.type === 'modelInput') ) { transformInputs.push({ name: targetNodeKey, type: targetInputType, value: getNodeOutputValue(targetNode, edge.targetHandle ?? undefined) as | string | string[] | undefined, }); } else { const ref = getSourceRef(edge, ctx); if (!ref) continue; transformInputs.push({ name: targetNodeKey, type: targetInputType, ref }); } } const hasLoopIterationInputs = transformInputs.some((inp) => /^(image|text)\d+$/.test(inp.name)); if (hasLoopIterationInputs) { const sortByIdx = (a: { name: string }, b: { name: string }) => parseInt(a.name.replace(/^(image|text)/, ''), 10) - parseInt(b.name.replace(/^(image|text)/, ''), 10); transformVariables.sort(sortByIdx); transformInputs.sort(sortByIdx); } const concatItem: WorkflowEditorFlowItem = { id: `${flowId}_transform_${inputName}`, type: 'transform', logic: { transform: buildConcatArrayTransform(transformVariables) }, inputs: transformInputs, }; flow.push(concatItem); setOrPushFlowInput(flowInputs, existingIndex, { name: inputName, type: inputType, ref: { node: concatItem.id, name: 'all' }, }); } else { pushModelFlowInputFromEdge( flowInputs, existingIndex, inputName, inputType, inputEdges[0]!, ctx, filteredNodes, ); } } } // Conditional control flow connections const conditionalEdges = incomingEdges.filter((e) => e.sourceHandle === conditionalHandleId); if (conditionalEdges.length > 0) { const conditionalRefs: string[] = []; for (const edge of conditionalEdges) { const targetNode = nodes.find((n) => n.id === edge.target); if (targetNode?.type === 'ifElse') { const ref = getSourceRef(edge, ctx); if (ref?.equal) flowInputs.push({ name: 'connection', type: 'connection', ref }); } else { const targetFlowId = nodeIdToFlowIdMap.get(edge.target); if (targetFlowId) conditionalRefs.push(targetFlowId); } } if (conditionalRefs.length > 0) { flowInputs.push({ name: 'connection', type: 'connection', ref: { conditional: conditionalRefs } }); } } flow.push({ id: flowId, type: 'custom-model', modelId: nodeData.modelId, inputs: flowInputs.length > 0 ? flowInputs : undefined, includeOutputsInWorkflowJob: (node.type === 'model' || node.type === 'llm') && selectedOutputModelNodeIds.has(node.id) ? true : undefined, }); ensureInputVideoTransformsAdded(); if (node.type === 'model' && (node.data as { type?: string }).type === 'video') { addVideoFirstLastFrameTransforms(node.id, flowId, node, edges, flow, nodeHandleTransformMap); } else if (node.type === 'model' && node.data.outputHandles?.some((h) => h.name === 'firstOutput')) { if (hasHandleConnection(node, 'firstOutput', edges, node.id)) { const transformId = `${flowId}_firstOutput`; flow.push({ id: transformId, type: 'transform', logic: { transform: "exists('output') && output.size() > 0 ? output[0] : []" }, inputs: [{ name: 'output', type: 'file_array', ref: { node: flowId, name: 'all' } }], }); nodeHandleTransformMap.set(`${node.id}_firstOutput`, transformId); } } if (node.type === 'llm' && hasHandleConnection(node, 'outputs', edges, node.id)) { const transformId = `${flowId}_outputs`; flow.push({ id: transformId, type: 'transform', logic: { transform: '[output]' }, inputs: [{ name: 'output', type: 'string', ref: { node: flowId, name: 'all' } }], }); nodeHandleTransformMap.set(`${node.id}_outputs`, transformId); } // ------------------------------------ // Text -> transform with string literal // ------------------------------------ } else if (node.type === 'text') { const flowInputs = collectConditionalConnectionInputs(incomingEdges, node.id, ctx); const value = (node.data as { value?: string }).value || ''; flow.push({ id: flowId, type: 'transform', logic: { transform: `'${escapeCELString(value)}'` }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); // ------------------------------------ // Asset -> transform with file reference // ------------------------------------ } else if (node.type === 'asset') { const flowInputs = collectConditionalConnectionInputs(incomingEdges, node.id, ctx); const d = node.data as { value?: string | string[]; type?: string }; const value = d.value; if (!value || (Array.isArray(value) && value.length === 0)) continue; flow.push({ id: flowId, type: 'transform', logic: { transform: Array.isArray(value) ? `['${(value as string[]).join("'] + ['")}']` : `'${value}'`, }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); if (d.type === 'video') { addVideoFirstLastFrameTransforms(node.id, flowId, node, edges, flow, nodeHandleTransformMap); } // ------------------------------------ // AspectRatio -> transform with ratio literal // ------------------------------------ } else if (node.type === 'aspectRatio') { const flowInputs = collectConditionalConnectionInputs(incomingEdges, node.id, ctx); const value = (node.data as { output?: string }).output || '1:1'; flow.push({ id: flowId, type: 'transform', logic: { transform: `'${escapeCELString(value)}'` }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); // ------------------------------------ // TransformText -> transform (CEL) // ------------------------------------ } else if (node.type === 'transformText') { const flowInputs: WorkflowEditorFlowInput[] = []; const conditionalHandleId = getConditionalInputHandleId(node.id); const value = (node.data as { value?: string }).value || "''"; for (const edge of incomingEdges) { if (edge.sourceHandle === conditionalHandleId) continue; const targetNode = nodes.find((n) => n.id === edge.target); if (!targetNode) continue; const ref = getSourceRef(edge, ctx); if (!ref) continue; const outHandle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle); const outHandleName = outHandle?.name ?? 'output'; const targetFlowId = nodeIdToFlowIdMap.get(targetNode.id) ?? targetNode.id; flowInputs.push({ name: `${targetFlowId}_${outHandleName}`, type: 'string', ref }); } flowInputs.push(...collectConditionalConnectionInputs(incomingEdges, node.id, ctx)); flow.push({ id: flowId, type: 'transform', logic: { transform: value }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); // ------------------------------------ // SplitText -> split string into list // ------------------------------------ } else if (node.type === 'splitText') { const flowInputs: WorkflowEditorFlowInput[] = []; const conditionalHandleId = getConditionalInputHandleId(node.id); let textSourceVarName: string | undefined; for (const edge of incomingEdges) { if (edge.sourceHandle === conditionalHandleId) continue; const targetNode = nodes.find((n) => n.id === edge.target); if (!targetNode) continue; const ref = getSourceRef(edge, ctx); if (!ref) continue; flowInputs.push({ name: targetNode.id, type: 'string', ref }); textSourceVarName = targetNode.id; } flowInputs.push(...collectConditionalConnectionInputs(incomingEdges, node.id, ctx)); const delimRaw = (node.data as { splitDelimiter?: string }).splitDelimiter ?? ','; const delim = delimRaw.length > 0 ? delimRaw : ','; flow.push({ id: flowId, type: 'transform', logic: { transform: textSourceVarName ? `trim(${textSourceVarName}).split(${formatTextAsCELString(delim)})` : '[]', }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); // ------------------------------------ // GroupItems -> array concatenation // ------------------------------------ } else if (node.type === 'groupItems') { const conditionalHandleId = getConditionalInputHandleId(node.id); const { variables, flowInputs } = collectConcatVarsAndInputs( incomingEdges.filter((e) => e.sourceHandle !== conditionalHandleId), ctx, (targetNodeId, handleName) => `${targetNodeId}_${handleName}`, ); const orderedValues = convertValueToList((node.data as { value?: string }).value ?? ''); variables.sort((a, b) => orderedValues.indexOf(a.name) - orderedValues.indexOf(b.name)); flowInputs.sort((a, b) => orderedValues.indexOf(a.name) - orderedValues.indexOf(b.name)); flowInputs.push(...collectConditionalConnectionInputs(incomingEdges, node.id, ctx)); flow.push({ id: flowId, type: 'transform', logic: { transform: buildConcatArrayTransform(variables) }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); // ------------------------------------ // SliceAssets -> slice(list, from, from+count) or randomSample(list, count, seed) // ------------------------------------ } else if (node.type === 'sliceAssets') { const sliceData = node.data as { from?: number | string; count?: number | string; isRandomize?: boolean; randomSeed?: string; isSeedLocked?: boolean; }; const conditionalHandleId = getConditionalInputHandleId(node.id); const itemsEdges = incomingEdges.filter( (e) => e.sourceHandle !== conditionalHandleId && getIsSliceAssetsItemsSourceHandle(node.id, e.sourceHandle), ); if (sliceData.isRandomize) { const { variables, flowInputs } = collectConcatVarsAndInputs(itemsEdges, ctx, getOutputHandleVarName); flowInputs.push(...collectConditionalConnectionInputs(incomingEdges, node.id, ctx)); const count = Math.max(1, parseInt(String(sliceData.count ?? ''), 10) || 1); const listExpr = buildConcatArrayTransform(variables); const transform = sliceData.isSeedLocked === true ? `randomSample(${listExpr}, ${count}, ${parseInt(String(sliceData.randomSeed ?? ''), 10) || 0})` : `randomSample(${listExpr}, ${count})`; flow.push({ id: flowId, type: 'transform', logic: { transform }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); } else { const assetsEdge = itemsEdges[0]; if (!assetsEdge) { flow.push({ id: flowId, type: 'transform', logic: { transform: '[]' } }); } else { const targetNode = nodes.find((n) => n.id === assetsEdge.target); const ref = targetNode ? getSourceRef(assetsEdge, ctx) : undefined; if (targetNode && ref) { const outHandle = targetNode.data.outputHandles?.find((h) => h.id === assetsEdge.targetHandle); const outHandleName = outHandle?.name ?? 'output'; const varName = getOutputHandleVarName(targetNode.id, outHandleName); const { inputType } = getConcatListSourceDescriptor(targetNode, outHandle, outHandleName); const from = parseInt(String(sliceData.from ?? ''), 10) || 0; const count = parseInt(String(sliceData.count ?? ''), 10) || 0; const transform = from < 0 ? `slice(${varName}, (${varName}).size() + ${from}, (${varName}).size() + ${from + count})` : `slice(${varName}, ${from}, ${from + count})`; const flowInputs: WorkflowEditorFlowInput[] = [ { name: varName, type: inputType, ref }, ...collectConditionalConnectionInputs(incomingEdges, node.id, ctx), ]; flow.push({ id: flowId, type: 'transform', logic: { transform }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); } } } // ------------------------------------ // ForEach -> for-each loop // ------------------------------------ } else if (node.type === 'forEach') { const flowInputs: WorkflowEditorFlowInput[] = []; const conditionalHandleId = getConditionalInputHandleId(node.id); for (const edge of incomingEdges) { if (edge.sourceHandle === conditionalHandleId) continue; const inputMatch = edge.sourceHandle?.match(/-input-(\d+)$/); if (!inputMatch) continue; const inputIndex = parseInt(inputMatch[1]!, 10); const listProvider = nodes.find((n) => n.id === edge.target); const listOutputHandle = listProvider?.data.outputHandles?.find((h) => h.id === edge.targetHandle); const { name: inputName, type: inputApiType } = getForEachFlowListInputDescriptor({ listProvider, outputHandle: listOutputHandle, inputIndex, }); if (flowInputs.find((inp) => inp.name === inputName)) continue; const ref = getSourceRef(edge, ctx); if (!ref) continue; flowInputs.push({ name: inputName, type: inputApiType, ref }); } flowInputs.sort( (a, b) => parseInt(a.name.replace(/^(image|text)/, ''), 10) - parseInt(b.name.replace(/^(image|text)/, ''), 10), ); const forEachFlowItem: WorkflowEditorFlowItem = { id: flowId, type: 'for-each', loopBodyNodeIds: [], inputs: flowInputs.length > 0 ? flowInputs : undefined, }; flow.push(forEachFlowItem); forEachFlowItemRefs.set(node.id, forEachFlowItem); // ------------------------------------ // IfElse -> logic if-else // ------------------------------------ } else if (node.type === 'ifElse') { const flowInputs: WorkflowEditorFlowInput[] = []; const conditionBlocks = (node.data as { conditionBlocks?: WorkflowEditorConditionBlock[] }).conditionBlocks ?? []; const fieldsUsed = new Set(); for (const block of conditionBlocks) { for (const condition of block.conditions) { if (condition.field) fieldsUsed.add(condition.field); } } const edgesIntoIfElse = edges.filter((e) => e.target === node.id); for (const fieldKey of fieldsUsed) { const resolved = resolveIfElseConditionField(fieldKey, nodes); const fieldNodeId = resolved?.nodeId; if (!fieldNodeId) continue; const outputHandleName = resolved?.targetHandleId !== undefined ? nodes .find((n) => n.id === fieldNodeId) ?.data.outputHandles?.find((h) => h.id === resolved.targetHandleId)?.name ?? 'output' : 'output'; const celVarName = getOutputHandleVarName(fieldNodeId, outputHandleName); nodeIdToInputNameMap.set(fieldKey, celVarName); const edgeToIfElse = edgesIntoIfElse .filter((e) => e.source === fieldNodeId) .find((e) => { if (resolved.targetHandleId === undefined) return true; return e.sourceHandle === resolved.targetHandleId; }) ?? edgesIntoIfElse.find((e) => e.source === fieldNodeId); if (inputNodeIds.includes(fieldNodeId)) { const workflowInputName = nodeIdToInputNameMap.get(fieldNodeId); if (workflowInputName) { const ref = edgeToIfElse ? getSourceRef(edgeToIfElse, ctx) : { node: 'workflow' as const, name: workflowInputName }; flowInputs.push({ name: celVarName, type: 'string', ref: ref ?? { node: 'workflow', name: workflowInputName }, }); } } else { const refFromEdge = edgeToIfElse ? getSourceRef(edgeToIfElse, ctx) : undefined; const targetFlowId = nodeIdToFlowIdMap.get(fieldNodeId); if (refFromEdge) { flowInputs.push({ name: celVarName, type: 'string', ref: refFromEdge }); } else if (targetFlowId) { flowInputs.push({ name: celVarName, type: 'string', ref: { node: targetFlowId, name: 'all' } }); } } } const cases: { condition: string; value: string }[] = []; for (let i = 0; i < conditionBlocks.length; i++) { const condition = conditionBlockToCEL(conditionBlocks[i]!, nodeIdToInputNameMap); if (condition) cases.push({ condition, value: String(i + 2) }); } flow.push({ id: flowId, type: 'logic', logicType: 'if-else', logic: { cases: cases.length > 0 ? cases : undefined, default: '1' }, inputs: flowInputs.length > 0 ? flowInputs : undefined, }); } // Track which flow items belong to each forEach body for (const [forEachId, bodyNodeIds] of forEachBodyEditorNodeIds) { if (bodyNodeIds.has(node.id)) { const loopFlowIds = forEachLoopFlowIds.get(forEachId); if (loopFlowIds) { for (let i = flowLengthBefore; i < flow.length; i++) { loopFlowIds.push(flow[i]!.id); } } } } } // Populate loopBodyNodeIds on each forEach flow item for (const [forEachId, forEachFlowItem] of forEachFlowItemRefs) { const loopFlowIds = forEachLoopFlowIds.get(forEachId) ?? []; // Approval nodes attached to a node inside the loop body belong to the loop too, // but they are appended to the flow separately and never tracked above. const bodyEditorNodeIds = forEachBodyEditorNodeIds.get(forEachId); if (bodyEditorNodeIds) { for (const [approvedNodeId, approvalInfo] of approvalByApprovedNodeId) { if (bodyEditorNodeIds.has(approvedNodeId) && !loopFlowIds.includes(approvalInfo.approvalFlowId)) { loopFlowIds.push(approvalInfo.approvalFlowId); } } } forEachFlowItem.loopBodyNodeIds = loopFlowIds; } applyApprovalsToFlow({ flow, edges, approvalByApprovedNodeId, nodeIdToFlowIdMap }); return flow; } // ---- Approval helpers ---- interface ApprovalInfo { approvalFlowId: string; approvedFlowId: string; message: string; } function buildApprovalByApprovedNodeIdMap({ nodes, edges, nodesInSelectedBranches, selectedOutputNodeIds, }: { nodes: WorkflowEditorNode[]; edges: WorkflowEditorEdge[]; nodesInSelectedBranches: Set; selectedOutputNodeIds: string[]; }): Map { const result = new Map(); for (const approvalNode of nodes) { if (approvalNode.type !== 'approval') continue; const approvalEdge = edges.find( (e) => e.source === approvalNode.id && e.sourceHandle === `${approvalNode.id}-source-approval`, ); if (!approvalEdge) continue; const approvedNodeId = approvalEdge.target; if (!nodesInSelectedBranches.has(approvedNodeId) && !selectedOutputNodeIds.includes(approvedNodeId)) continue; const approvedNode = nodes.find((n) => n.id === approvedNodeId); if (!approvedNode) continue; const approvedFlowId = approvedNode.type === 'forEachEnd' ? (approvedNode.data as { parentNodeId?: string }).parentNodeId ? nodeIdToFlowId((approvedNode.data as { parentNodeId?: string }).parentNodeId!) : undefined : nodeIdToFlowId(approvedNodeId); if (!approvedFlowId) continue; result.set(approvedNodeId, { approvalFlowId: nodeIdToFlowId(approvalNode.id), approvedFlowId, message: (approvalNode.data as { message?: string }).message ?? '', }); } return result; } function applyApprovalsToFlow({ flow, edges, approvalByApprovedNodeId, nodeIdToFlowIdMap, }: { flow: WorkflowEditorFlowItem[]; edges: WorkflowEditorEdge[]; approvalByApprovedNodeId: Map; nodeIdToFlowIdMap: Map; }): void { if (approvalByApprovedNodeId.size === 0) return; const flowIdToWorkflowNodeId = new Map( [...nodeIdToFlowIdMap.entries()].map(([nodeId, flowId]) => [flowId, nodeId]), ); for (const flowItem of flow) { const workflowNodeId = flowIdToWorkflowNodeId.get(flowItem.id); if (!workflowNodeId) continue; const dependsOn = new Set(flowItem.dependsOn ?? []); const incomingEdges = edges.filter( (e) => e.source === workflowNodeId && !(e.data as { isForEachEndEdge?: boolean } | undefined)?.isForEachEndEdge, ); for (const edge of incomingEdges) { const approval = approvalByApprovedNodeId.get(edge.target); if (approval) dependsOn.add(approval.approvalFlowId); } if (dependsOn.size > 0) flowItem.dependsOn = [...dependsOn].sort(); } for (const approvalInfo of approvalByApprovedNodeId.values()) { flow.push({ id: approvalInfo.approvalFlowId, type: 'user-approval', dependsOn: [approvalInfo.approvedFlowId], label: approvalInfo.message, }); } }