@scenario-labs/sdk 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/index.d.mts +1 -1
  3. package/index.d.mts.map +1 -1
  4. package/index.d.ts +1 -1
  5. package/index.d.ts.map +1 -1
  6. package/index.js +6 -1
  7. package/index.js.map +1 -1
  8. package/index.mjs +1 -1
  9. package/index.mjs.map +1 -1
  10. package/lib/workflow_converter.d.mts +47 -2
  11. package/lib/workflow_converter.d.mts.map +1 -1
  12. package/lib/workflow_converter.d.ts +47 -2
  13. package/lib/workflow_converter.d.ts.map +1 -1
  14. package/lib/workflow_converter.js +253 -28
  15. package/lib/workflow_converter.js.map +1 -1
  16. package/lib/workflow_converter.mjs +248 -28
  17. package/lib/workflow_converter.mjs.map +1 -1
  18. package/lib/workflow_import_validator.d.mts +1 -1
  19. package/lib/workflow_import_validator.d.mts.map +1 -1
  20. package/lib/workflow_import_validator.d.ts +1 -1
  21. package/lib/workflow_import_validator.d.ts.map +1 -1
  22. package/lib/workflow_import_validator.js +2 -0
  23. package/lib/workflow_import_validator.js.map +1 -1
  24. package/lib/workflow_import_validator.mjs +2 -0
  25. package/lib/workflow_import_validator.mjs.map +1 -1
  26. package/package.json +1 -1
  27. package/resources/assets/assets.d.mts +289 -135
  28. package/resources/assets/assets.d.mts.map +1 -1
  29. package/resources/assets/assets.d.ts +289 -135
  30. package/resources/assets/assets.d.ts.map +1 -1
  31. package/resources/assets/assets.js.map +1 -1
  32. package/resources/assets/assets.mjs.map +1 -1
  33. package/resources/generate.d.mts +86 -48
  34. package/resources/generate.d.mts.map +1 -1
  35. package/resources/generate.d.ts +86 -48
  36. package/resources/generate.d.ts.map +1 -1
  37. package/resources/jobs.d.mts +6 -6
  38. package/resources/jobs.d.mts.map +1 -1
  39. package/resources/jobs.d.ts +6 -6
  40. package/resources/jobs.d.ts.map +1 -1
  41. package/resources/models/description.d.mts +54 -4
  42. package/resources/models/description.d.mts.map +1 -1
  43. package/resources/models/description.d.ts +54 -4
  44. package/resources/models/description.d.ts.map +1 -1
  45. package/resources/models/examples.d.mts +74 -36
  46. package/resources/models/examples.d.mts.map +1 -1
  47. package/resources/models/examples.d.ts +74 -36
  48. package/resources/models/examples.d.ts.map +1 -1
  49. package/resources/models/models.d.mts +21 -21
  50. package/resources/models/models.d.mts.map +1 -1
  51. package/resources/models/models.d.ts +21 -21
  52. package/resources/models/models.d.ts.map +1 -1
  53. package/resources/models/models.js.map +1 -1
  54. package/resources/models/models.mjs.map +1 -1
  55. package/resources/models/train.d.mts +8 -8
  56. package/resources/models/train.d.mts.map +1 -1
  57. package/resources/models/train.d.ts +8 -8
  58. package/resources/models/train.d.ts.map +1 -1
  59. package/resources/search.d.mts +6 -0
  60. package/resources/search.d.mts.map +1 -1
  61. package/resources/search.d.ts +6 -0
  62. package/resources/search.d.ts.map +1 -1
  63. package/resources/uploads.d.mts +4 -4
  64. package/resources/uploads.d.mts.map +1 -1
  65. package/resources/uploads.d.ts +4 -4
  66. package/resources/uploads.d.ts.map +1 -1
  67. package/resources/usages.d.mts +31 -6
  68. package/resources/usages.d.mts.map +1 -1
  69. package/resources/usages.d.ts +31 -6
  70. package/resources/usages.d.ts.map +1 -1
  71. package/resources/workflows.d.mts +36 -36
  72. package/resources/workflows.d.mts.map +1 -1
  73. package/resources/workflows.d.ts +36 -36
  74. package/resources/workflows.d.ts.map +1 -1
  75. package/src/index.ts +9 -0
  76. package/src/lib/workflow_converter.ts +347 -21
  77. package/src/lib/workflow_import_validator.ts +5 -1
  78. package/src/resources/assets/assets.ts +376 -118
  79. package/src/resources/generate.ts +106 -44
  80. package/src/resources/jobs.ts +6 -6
  81. package/src/resources/models/description.ts +68 -2
  82. package/src/resources/models/examples.ts +94 -32
  83. package/src/resources/models/models.ts +21 -14
  84. package/src/resources/models/train.ts +8 -6
  85. package/src/resources/search.ts +7 -0
  86. package/src/resources/uploads.ts +4 -4
  87. package/src/resources/usages.ts +41 -4
  88. package/src/resources/workflows.ts +36 -36
  89. package/src/version.ts +1 -1
  90. package/version.d.mts +1 -1
  91. package/version.d.ts +1 -1
  92. package/version.js +1 -1
  93. package/version.mjs +1 -1
@@ -47,6 +47,7 @@ export type WorkflowEditorNode =
47
47
  }
48
48
  | { id: string; type: 'transformText'; data: NodeCommonData & { value?: string } }
49
49
  | { id: string; type: 'splitText'; data: NodeCommonData & { splitDelimiter?: string } }
50
+ | { id: string; type: 'aspectRatio'; data: NodeCommonData & { output?: string } }
50
51
  | { id: string; type: 'groupItems'; data: NodeCommonData & { value?: string } }
51
52
  | {
52
53
  id: string;
@@ -86,12 +87,57 @@ export type WorkflowEditorModelInput = {
86
87
  name: string;
87
88
  type: string;
88
89
  inputs?: Array<{ name: string; type: string }>;
90
+ min?: number;
91
+ max?: number;
92
+ step?: number;
93
+ allowedValues?: string[];
94
+ };
95
+
96
+ export type WorkflowEditorResolutionPreset = {
97
+ width?: number;
98
+ height?: number;
89
99
  };
90
100
 
91
101
  export type WorkflowEditorModel = {
92
102
  id?: string;
93
103
  inputs?: WorkflowEditorModelInput[];
94
104
  tags?: string[];
105
+ uiConfig?: {
106
+ resolutionComponent?: {
107
+ widthInput?: string;
108
+ heightInput?: string;
109
+ presets?: WorkflowEditorResolutionPreset[];
110
+ };
111
+ };
112
+ };
113
+
114
+ const RUNNABLE_TYPES = new Set(['model', 'llm', 'forEach', 'forEachEnd']);
115
+ export const ASPECT_RATIO_PRESETS = [
116
+ '21:9',
117
+ '16:9',
118
+ '3:2',
119
+ '4:3',
120
+ '5:4',
121
+ '1:1',
122
+ '4:5',
123
+ '3:4',
124
+ '2:3',
125
+ '9:16',
126
+ '9:21',
127
+ ] as const;
128
+
129
+ export type AspectRatioBounds = {
130
+ minWidth: number;
131
+ maxWidth: number;
132
+ minHeight: number;
133
+ maxHeight: number;
134
+ multipleOf: number;
135
+ };
136
+
137
+ export type AspectRatioModelInputNames = {
138
+ widthInputName?: string | undefined;
139
+ heightInputName?: string | undefined;
140
+ aspectRatioInputName?: string | undefined;
95
141
  };
96
142
 
97
143
  export type WorkflowEditorFlowInputRef = {
@@ -176,6 +222,125 @@ function getConditionalInputHandleId(nodeId: string): string {
176
222
  return `${nodeId}-source-conditional`;
177
223
  }
178
224
 
225
+ function handleTypeIncludes(handleType: string | string[] | undefined, value: string): boolean {
226
+ if (Array.isArray(handleType)) return handleType.includes(value);
227
+ return handleType === value;
228
+ }
229
+
230
+ type ParsedAspectRatio = { width: number; height: number };
231
+
232
+ function parseAspectRatio(aspectRatio: string | undefined): ParsedAspectRatio | undefined {
233
+ if (!aspectRatio) return undefined;
234
+ const [rawWidth, rawHeight] = aspectRatio.split(':');
235
+ const width = Number(rawWidth);
236
+ const height = Number(rawHeight);
237
+ if (
238
+ !Number.isFinite(width) ||
239
+ !Number.isFinite(height) ||
240
+ width <= 0 ||
241
+ height <= 0 ||
242
+ !Number.isInteger(width) ||
243
+ !Number.isInteger(height)
244
+ ) {
245
+ return undefined;
246
+ }
247
+ return { width, height };
248
+ }
249
+
250
+ function getGreatestCommonDivisor(a: number, b: number): number {
251
+ let x = Math.abs(Math.trunc(a));
252
+ let y = Math.abs(Math.trunc(b));
253
+ while (y) {
254
+ const temp = y;
255
+ y = x % y;
256
+ x = temp;
257
+ }
258
+ return x || 1;
259
+ }
260
+
261
+ function makeNumberAMultipleOfX(value: number, multipleOf: number): number {
262
+ if (multipleOf <= 0) return Math.round(value);
263
+ return Math.round(value / multipleOf) * multipleOf;
264
+ }
265
+
266
+ function clamp(value: number, min: number, max: number): number {
267
+ return Math.min(Math.max(value, min), max);
268
+ }
269
+
270
+ export function normalizeAspectRatio(aspectRatio: string | undefined): string | undefined {
271
+ const parsed = parseAspectRatio(aspectRatio);
272
+ if (!parsed) return undefined;
273
+ const divisor = getGreatestCommonDivisor(parsed.width, parsed.height);
274
+ const normalizedAspectRatio = `${parsed.width / divisor}:${parsed.height / divisor}`;
275
+ return (
276
+ {
277
+ '3:7': '9:21',
278
+ '7:3': '21:9',
279
+ }[normalizedAspectRatio] ?? normalizedAspectRatio
280
+ );
281
+ }
282
+
283
+ export function getAspectRatioModelInputNames(
284
+ model: WorkflowEditorModel | undefined,
285
+ ): AspectRatioModelInputNames {
286
+ if (!model) return {};
287
+
288
+ const widthInputName =
289
+ model.uiConfig?.resolutionComponent?.widthInput ??
290
+ model.inputs?.find((input) => input.name === 'width')?.name;
291
+ const heightInputName =
292
+ model.uiConfig?.resolutionComponent?.heightInput ??
293
+ model.inputs?.find((input) => input.name === 'height')?.name;
294
+ const aspectRatioInputName = model.inputs?.find((input) => input.name === 'aspectRatio')?.name;
295
+
296
+ return { widthInputName, heightInputName, aspectRatioInputName };
297
+ }
298
+
299
+ export function getAspectRatioBounds(
300
+ model: WorkflowEditorModel | undefined,
301
+ { widthInputName, heightInputName }: Pick<AspectRatioModelInputNames, 'widthInputName' | 'heightInputName'>,
302
+ ): AspectRatioBounds | undefined {
303
+ if (!model || !widthInputName || !heightInputName) return undefined;
304
+
305
+ const widthInput = model.inputs?.find((input) => input.name === widthInputName);
306
+ const heightInput = model.inputs?.find((input) => input.name === heightInputName);
307
+ const presets = model.uiConfig?.resolutionComponent?.presets;
308
+ const widthPresets = (presets ?? [])
309
+ .map((preset) => preset.width)
310
+ .filter((value): value is number => typeof value === 'number');
311
+ const heightPresets = (presets ?? [])
312
+ .map((preset) => preset.height)
313
+ .filter((value): value is number => typeof value === 'number');
314
+ const multipleOf = widthInput?.step ?? 8;
315
+
316
+ return {
317
+ maxWidth: widthInput?.max ?? (widthPresets.length > 0 ? Math.max(...widthPresets) : 2048),
318
+ minWidth: widthInput?.min ?? (widthPresets.length > 0 ? Math.min(...widthPresets) : 64),
319
+ maxHeight: heightInput?.max ?? (heightPresets.length > 0 ? Math.max(...heightPresets) : 2048),
320
+ minHeight: heightInput?.min ?? (heightPresets.length > 0 ? Math.min(...heightPresets) : 64),
321
+ multipleOf,
322
+ };
323
+ }
324
+
325
+ export function getDimensionsFromAspectRatio(args: {
326
+ aspectRatio: string;
327
+ bounds: AspectRatioBounds;
328
+ }): { width: number; height: number } | undefined {
329
+ const parsed = parseAspectRatio(args.aspectRatio);
330
+ if (!parsed) return undefined;
331
+
332
+ const ratio = parsed.width / parsed.height;
333
+ let width = ratio >= 1 ? args.bounds.maxWidth : Math.round(args.bounds.maxHeight * ratio);
334
+ let height = ratio >= 1 ? Math.round(args.bounds.maxWidth / ratio) : args.bounds.maxHeight;
335
+
336
+ width = makeNumberAMultipleOfX(width, args.bounds.multipleOf);
337
+ height = makeNumberAMultipleOfX(height, args.bounds.multipleOf);
338
+ width = clamp(width, args.bounds.minWidth, args.bounds.maxWidth);
339
+ height = clamp(height, args.bounds.minHeight, args.bounds.maxHeight);
340
+
341
+ return { width, height };
342
+ }
343
+
179
344
  function getForEachFlowListInputDescriptor(args: {
180
345
  listProvider: WorkflowEditorNode | undefined;
181
346
  outputHandle: WorkflowEditorHandleOutput | undefined;
@@ -448,14 +613,14 @@ function toStaticInput(name: string, type: string, value: unknown): WorkflowEdit
448
613
  return { name, type, value: value as string | number | string[] | boolean };
449
614
  }
450
615
 
451
- function isFormArray(value: unknown): value is Record<string, unknown>[] {
616
+ function getIsFormArray(value: unknown): value is Record<string, unknown>[] {
452
617
  return (
453
618
  Array.isArray(value) &&
454
619
  (value as unknown[]).every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))
455
620
  );
456
621
  }
457
622
 
458
- function isModelCompose(model: WorkflowEditorModel | undefined): boolean {
623
+ function getIsModelCompose(model: WorkflowEditorModel | undefined): boolean {
459
624
  return !!model?.id && (model.tags ?? []).includes('compose');
460
625
  }
461
626
 
@@ -473,6 +638,120 @@ function getIsConditionallyExecuted(
473
638
  });
474
639
  }
475
640
 
641
+ function buildDimensionLookupCEL(args: {
642
+ variable: string;
643
+ presets: Array<{ aspectRatio: string; width: number; height: number }>;
644
+ dimension: 'width' | 'height';
645
+ fallback: number;
646
+ }): string {
647
+ let expression = String(args.fallback);
648
+ for (let i = args.presets.length - 1; i >= 0; i--) {
649
+ const preset = args.presets[i]!;
650
+ expression = `trim(${args.variable}) == '${preset.aspectRatio}' ? ${
651
+ preset[args.dimension]
652
+ } : ${expression}`;
653
+ }
654
+ return expression;
655
+ }
656
+
657
+ function applyAspectRatioInputs(args: {
658
+ flow: WorkflowEditorFlowItem[];
659
+ flowInputs: WorkflowEditorFlowInput[];
660
+ flowId: string;
661
+ aspectRatioRef: WorkflowEditorFlowInputRef;
662
+ model: WorkflowEditorModel;
663
+ }): void {
664
+ const modelInputNames = getAspectRatioModelInputNames(args.model);
665
+ const modelBounds = getAspectRatioBounds(args.model, modelInputNames);
666
+ const { widthInputName, heightInputName, aspectRatioInputName } = modelInputNames;
667
+
668
+ if (aspectRatioInputName) {
669
+ const transformId = `${args.flowId}_aspectRatio_value`;
670
+ args.flow.push({
671
+ id: transformId,
672
+ type: 'transform',
673
+ logic: { transform: 'trim(aspectRatio)' },
674
+ inputs: [{ name: 'aspectRatio', type: 'string', ref: args.aspectRatioRef }],
675
+ });
676
+
677
+ setOrPushFlowInput(
678
+ args.flowInputs,
679
+ args.flowInputs.findIndex((inp) => inp.name === aspectRatioInputName),
680
+ {
681
+ name: aspectRatioInputName,
682
+ type: 'string',
683
+ ref: { node: transformId, name: 'all' },
684
+ },
685
+ );
686
+ }
687
+
688
+ if (widthInputName && heightInputName && modelBounds) {
689
+ const presetDimensions = ASPECT_RATIO_PRESETS.flatMap((preset) => {
690
+ const dims = getDimensionsFromAspectRatio({ aspectRatio: preset, bounds: modelBounds });
691
+ if (!dims) return [];
692
+ const normalized = normalizeAspectRatio(preset);
693
+ if (!normalized) return [];
694
+ return [{ aspectRatio: normalized, ...dims }];
695
+ });
696
+
697
+ const fallbackDimensions = presetDimensions.find((preset) => preset.aspectRatio === '1:1') ??
698
+ presetDimensions[0] ?? {
699
+ width: modelBounds.maxWidth,
700
+ height: modelBounds.maxHeight,
701
+ };
702
+
703
+ const widthTransformId = `${args.flowId}_aspectRatio_width`;
704
+ const heightTransformId = `${args.flowId}_aspectRatio_height`;
705
+
706
+ args.flow.push({
707
+ id: widthTransformId,
708
+ type: 'transform',
709
+ logic: {
710
+ transform: buildDimensionLookupCEL({
711
+ variable: 'aspectRatio',
712
+ presets: presetDimensions,
713
+ dimension: 'width',
714
+ fallback: fallbackDimensions.width,
715
+ }),
716
+ },
717
+ inputs: [{ name: 'aspectRatio', type: 'string', ref: args.aspectRatioRef }],
718
+ });
719
+
720
+ args.flow.push({
721
+ id: heightTransformId,
722
+ type: 'transform',
723
+ logic: {
724
+ transform: buildDimensionLookupCEL({
725
+ variable: 'aspectRatio',
726
+ presets: presetDimensions,
727
+ dimension: 'height',
728
+ fallback: fallbackDimensions.height,
729
+ }),
730
+ },
731
+ inputs: [{ name: 'aspectRatio', type: 'string', ref: args.aspectRatioRef }],
732
+ });
733
+
734
+ setOrPushFlowInput(
735
+ args.flowInputs,
736
+ args.flowInputs.findIndex((inp) => inp.name === widthInputName),
737
+ {
738
+ name: widthInputName,
739
+ type: 'number',
740
+ ref: { node: widthTransformId, name: 'all' },
741
+ },
742
+ );
743
+ setOrPushFlowInput(
744
+ args.flowInputs,
745
+ args.flowInputs.findIndex((inp) => inp.name === heightInputName),
746
+ {
747
+ name: heightInputName,
748
+ type: 'number',
749
+ ref: { node: heightTransformId, name: 'all' },
750
+ },
751
+ );
752
+ }
753
+ }
754
+
476
755
  function getForEachBodyEditorNodeIds(
477
756
  forEachId: string,
478
757
  forEachEndId: string,
@@ -532,8 +811,6 @@ function getNodeOutputValue(
532
811
  return undefined;
533
812
  }
534
813
 
535
- const RUNNABLE_TYPES = new Set(['model', 'llm', 'forEach', 'forEachEnd']);
536
-
537
814
  function internalGetConnectedNodes(
538
815
  nodes: WorkflowEditorNode[],
539
816
  edges: WorkflowEditorEdge[],
@@ -605,18 +882,17 @@ function resolveIfElseConditionField(
605
882
  function normalizeNodes(nodes: WorkflowEditorNode[]): WorkflowEditorNode[] {
606
883
  return nodes.map((node) => {
607
884
  if (node.type !== 'model' && node.type !== 'llm') return node;
608
- const hasType = (t: string | string[] | undefined, v: string) =>
609
- Array.isArray(t) ? t.includes(v) : t === v;
610
885
  const inputHandles = (node.data.inputHandles ?? [])
611
886
  .flatMap((h) => (h.subHandles ? h.subHandles : [h]))
612
887
  .filter(
613
888
  (h) =>
614
- hasType(h.type, 'conditional') ||
615
- hasType(h.type, 'prompt') ||
616
- hasType(h.type, 'image') ||
617
- hasType(h.type, 'video') ||
618
- hasType(h.type, '3d') ||
619
- hasType(h.type, 'audio') ||
889
+ handleTypeIncludes(h.type, 'conditional') ||
890
+ handleTypeIncludes(h.type, 'aspectRatio') ||
891
+ handleTypeIncludes(h.type, 'prompt') ||
892
+ handleTypeIncludes(h.type, 'image') ||
893
+ handleTypeIncludes(h.type, 'video') ||
894
+ handleTypeIncludes(h.type, '3d') ||
895
+ handleTypeIncludes(h.type, 'audio') ||
620
896
  (node.type === 'llm' && h.name === 'textInputs'),
621
897
  );
622
898
  return { ...node, data: { ...node.data, inputHandles } } as WorkflowEditorNode;
@@ -635,25 +911,31 @@ function normalizeNodes(nodes: WorkflowEditorNode[]): WorkflowEditorNode[] {
635
911
  * await client.workflows.update(workflowId, { flow: flow as WorkflowUpdateParams.Flow[] });
636
912
  * ```
637
913
  */
638
- export function convertWorkflowEditorToFlow(params: {
914
+ export type ConvertWorkflowEditorToFlowParams = {
639
915
  nodes: WorkflowEditorNode[];
640
916
  edges: WorkflowEditorEdge[];
641
917
  inputKeys: string[];
642
918
  /** Optional model resolver — return the model object, 'unknown', or undefined. */
643
919
  getModel?: (modelId: string) => WorkflowEditorModel | 'unknown' | undefined;
644
- }): WorkflowEditorFlowItem[] {
920
+ };
921
+
922
+ export function convertWorkflowEditorToFlow(
923
+ params: ConvertWorkflowEditorToFlowParams,
924
+ ): WorkflowEditorFlowItem[] {
645
925
  const { edges, inputKeys } = params;
646
926
  const nodes = normalizeNodes(params.nodes);
647
927
 
648
928
  const inputNodeIds = nodes.filter((n) => !!n.data.isInput).map((n) => n.id);
649
929
 
650
930
  const selectedOutputNodes = nodes.filter(
651
- (n) => (n.type === 'model' || n.type === 'forEachEnd') && !!n.data.isOutput,
931
+ (n) => (n.type === 'model' || n.type === 'forEachEnd' || n.type === 'llm') && !!n.data.isOutput,
652
932
  );
653
933
  const selectedOutputNodeIds = selectedOutputNodes.map((n) => n.id);
654
934
  const selectedOutputModelNodeIds = new Set(
655
935
  selectedOutputNodes
656
- .filter((n): n is WorkflowEditorNode & { type: 'model' } => n.type === 'model')
936
+ .filter(
937
+ (n): n is WorkflowEditorNode & { type: 'model' | 'llm' } => n.type === 'model' || n.type === 'llm',
938
+ )
657
939
  .map((n) => n.id),
658
940
  );
659
941
 
@@ -777,12 +1059,17 @@ export function convertWorkflowEditorToFlow(params: {
777
1059
  const model = remoteModel === 'unknown' ? undefined : remoteModel;
778
1060
  const modelInputs = model?.inputs;
779
1061
  const conditionalHandleId = getConditionalInputHandleId(node.id);
1062
+ const aspectRatioEdges: WorkflowEditorEdge[] = [];
780
1063
 
781
1064
  const edgesByInput = new Map<string, WorkflowEditorEdge[]>();
782
1065
  for (const edge of incomingEdges) {
783
1066
  if (edge.sourceHandle === conditionalHandleId) continue;
784
1067
  const handle = getInputHandle(edge, node.data.inputHandles);
785
1068
  if (!handle?.name) continue;
1069
+ if (handleTypeIncludes(handle.type, 'aspectRatio')) {
1070
+ aspectRatioEdges.push(edge);
1071
+ continue;
1072
+ }
786
1073
  const existing = edgesByInput.get(handle.name);
787
1074
  if (existing) existing.push(edge);
788
1075
  else edgesByInput.set(handle.name, [edge]);
@@ -794,7 +1081,7 @@ export function convertWorkflowEditorToFlow(params: {
794
1081
  const modelInput = modelInputs?.find((inp) => inp.name === key);
795
1082
 
796
1083
  if (modelInput?.type === 'inputs_array') {
797
- if (!isFormArray(value)) continue;
1084
+ if (!getIsFormArray(value)) continue;
798
1085
  const nestedInputs = modelInput.inputs ?? [];
799
1086
  const items: WorkflowEditorFlowInput[][] = [];
800
1087
 
@@ -813,7 +1100,7 @@ export function convertWorkflowEditorToFlow(params: {
813
1100
  }
814
1101
  }
815
1102
  const isValid =
816
- isModelCompose(model) ? getHasComposeLayerSourceInput(itemInputs) : itemInputs.length > 0;
1103
+ getIsModelCompose(model) ? getHasComposeLayerSourceInput(itemInputs) : itemInputs.length > 0;
817
1104
  if (isValid) items.push(itemInputs);
818
1105
  }
819
1106
  if (items.length > 0) flowInputs.push({ name: key, type: 'inputs_array', items });
@@ -829,6 +1116,19 @@ export function convertWorkflowEditorToFlow(params: {
829
1116
  }
830
1117
  }
831
1118
 
1119
+ if (aspectRatioEdges.length > 0 && model) {
1120
+ const aspectRatioRef = getSourceRef(aspectRatioEdges[0]!, ctx);
1121
+ if (aspectRatioRef) {
1122
+ applyAspectRatioInputs({
1123
+ flow,
1124
+ flowInputs,
1125
+ flowId,
1126
+ aspectRatioRef,
1127
+ model,
1128
+ });
1129
+ }
1130
+ }
1131
+
832
1132
  for (const [inputName, inputEdges] of edgesByInput) {
833
1133
  const existingIndex = flowInputs.findIndex((inp) => inp.name === inputName);
834
1134
  const modelInput = modelInputs?.find((inp) => inp.name === inputName);
@@ -897,7 +1197,7 @@ export function convertWorkflowEditorToFlow(params: {
897
1197
  const isRequired =
898
1198
  (targetNode?.type === 'asset' &&
899
1199
  !!(targetNode.data as { isRequired?: boolean }).isRequired) ||
900
- isModelCompose(model);
1200
+ getIsModelCompose(model);
901
1201
  transformVariables.push({ name: targetNodeKey, isArray: isArr, isRequired });
902
1202
 
903
1203
  if (
@@ -973,7 +1273,9 @@ export function convertWorkflowEditorToFlow(params: {
973
1273
  modelId: nodeData.modelId,
974
1274
  inputs: flowInputs.length > 0 ? flowInputs : undefined,
975
1275
  includeOutputsInWorkflowJob:
976
- node.type === 'model' && selectedOutputModelNodeIds.has(node.id) ? true : undefined,
1276
+ (node.type === 'model' || node.type === 'llm') && selectedOutputModelNodeIds.has(node.id) ?
1277
+ true
1278
+ : undefined,
977
1279
  });
978
1280
 
979
1281
  ensureInputVideoTransformsAdded();
@@ -1037,6 +1339,19 @@ export function convertWorkflowEditorToFlow(params: {
1037
1339
  addVideoFirstLastFrameTransforms(node.id, flowId, node, edges, flow, nodeHandleTransformMap);
1038
1340
  }
1039
1341
 
1342
+ // ------------------------------------
1343
+ // AspectRatio -> transform with ratio literal
1344
+ // ------------------------------------
1345
+ } else if (node.type === 'aspectRatio') {
1346
+ const flowInputs = collectConditionalConnectionInputs(incomingEdges, node.id, ctx);
1347
+ const value = (node.data as { output?: string }).output || '1:1';
1348
+ flow.push({
1349
+ id: flowId,
1350
+ type: 'transform',
1351
+ logic: { transform: `'${escapeCELString(value)}'` },
1352
+ inputs: flowInputs.length > 0 ? flowInputs : undefined,
1353
+ });
1354
+
1040
1355
  // ------------------------------------
1041
1356
  // TransformText -> transform (CEL)
1042
1357
  // ------------------------------------
@@ -1303,7 +1618,18 @@ export function convertWorkflowEditorToFlow(params: {
1303
1618
 
1304
1619
  // Populate loopBodyNodeIds on each forEach flow item
1305
1620
  for (const [forEachId, forEachFlowItem] of forEachFlowItemRefs) {
1306
- forEachFlowItem.loopBodyNodeIds = forEachLoopFlowIds.get(forEachId) ?? [];
1621
+ const loopFlowIds = forEachLoopFlowIds.get(forEachId) ?? [];
1622
+ // Approval nodes attached to a node inside the loop body belong to the loop too,
1623
+ // but they are appended to the flow separately and never tracked above.
1624
+ const bodyEditorNodeIds = forEachBodyEditorNodeIds.get(forEachId);
1625
+ if (bodyEditorNodeIds) {
1626
+ for (const [approvedNodeId, approvalInfo] of approvalByApprovedNodeId) {
1627
+ if (bodyEditorNodeIds.has(approvedNodeId) && !loopFlowIds.includes(approvalInfo.approvalFlowId)) {
1628
+ loopFlowIds.push(approvalInfo.approvalFlowId);
1629
+ }
1630
+ }
1631
+ }
1632
+ forEachFlowItem.loopBodyNodeIds = loopFlowIds;
1307
1633
  }
1308
1634
 
1309
1635
  applyApprovalsToFlow({ flow, edges, approvalByApprovedNodeId, nodeIdToFlowIdMap });
@@ -8,6 +8,7 @@ export class WorkflowImportError extends Error {
8
8
  export type WorkflowEditorNodeType =
9
9
  | 'text'
10
10
  | 'asset'
11
+ | 'aspectRatio'
11
12
  | 'model'
12
13
  | 'llm'
13
14
  | 'transformText'
@@ -17,11 +18,13 @@ export type WorkflowEditorNodeType =
17
18
  | 'sliceAssets'
18
19
  | 'forEach'
19
20
  | 'forEachEnd'
20
- | 'stickyNote';
21
+ | 'stickyNote'
22
+ | 'approval';
21
23
 
22
24
  export const VALID_EDITOR_NODE_TYPES: readonly WorkflowEditorNodeType[] = [
23
25
  'text',
24
26
  'asset',
27
+ 'aspectRatio',
25
28
  'model',
26
29
  'llm',
27
30
  'transformText',
@@ -32,6 +35,7 @@ export const VALID_EDITOR_NODE_TYPES: readonly WorkflowEditorNodeType[] = [
32
35
  'forEach',
33
36
  'forEachEnd',
34
37
  'stickyNote',
38
+ 'approval',
35
39
  ];
36
40
 
37
41
  export interface WorkflowExportData {