@koda-sl/baker-cli 0.128.0-dev.70bf43ce4 → 0.129.1-dev.972f3c9aa

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.
@@ -600,7 +600,7 @@ ${originalIndentation}`;
600
600
  });
601
601
 
602
602
  // src/engine/index.ts
603
- import path15 from "path";
603
+ import path16 from "path";
604
604
 
605
605
  // src/engine/client/http.ts
606
606
  var CONTENT_POLICY_CODE = "content_policy_blocked";
@@ -649,17 +649,17 @@ var HttpClient = class {
649
649
  this.fetchFn = opts.fetchFn ?? fetch;
650
650
  this.sleepFn = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
651
651
  }
652
- async postJson(path16, body, signal) {
653
- return await this.requestJson("POST", path16, body, signal);
652
+ async postJson(path17, body, signal) {
653
+ return await this.requestJson("POST", path17, body, signal);
654
654
  }
655
- async putJson(path16, body, signal) {
656
- return await this.requestJson("PUT", path16, body, signal);
655
+ async putJson(path17, body, signal) {
656
+ return await this.requestJson("PUT", path17, body, signal);
657
657
  }
658
- async getJson(path16, signal) {
659
- return await this.requestJson("GET", path16, void 0, signal);
658
+ async getJson(path17, signal) {
659
+ return await this.requestJson("GET", path17, void 0, signal);
660
660
  }
661
- async requestJson(method, path16, body, signal) {
662
- const url = `${this.baseUrl}${path16.startsWith("/") ? path16 : `/${path16}`}`;
661
+ async requestJson(method, path17, body, signal) {
662
+ const url = `${this.baseUrl}${path17.startsWith("/") ? path17 : `/${path17}`}`;
663
663
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
664
664
  const outcome = await this.attempt(method, url, body, attempt, signal);
665
665
  if (outcome.kind === "value") return outcome.value;
@@ -798,12 +798,12 @@ var BackendClient = class {
798
798
  }
799
799
  async pollJob(jobId, signal) {
800
800
  const deadline = Date.now() + JOB_POLL_MAX_MS;
801
- const path16 = `/api/canvas/jobs/${encodeURIComponent(jobId)}`;
801
+ const path17 = `/api/canvas/jobs/${encodeURIComponent(jobId)}`;
802
802
  for (let attempt = 0; ; attempt++) {
803
803
  if (signal?.aborted) {
804
804
  throw new BackendHttpError({ kind: "network", cause: signal.reason ?? new Error("aborted") });
805
805
  }
806
- const job = await this.http.getJson(path16, signal);
806
+ const job = await this.http.getJson(path17, signal);
807
807
  if (job.status === "completed") return job.result;
808
808
  if (job.status === "failed") throw failedJobError(job.error);
809
809
  if (Date.now() > deadline) {
@@ -812,10 +812,10 @@ var BackendClient = class {
812
812
  await sleep(pollInterval(attempt));
813
813
  }
814
814
  }
815
- presignAssetUpload(sha256, mime, signal) {
815
+ presignAssetUpload(sha256, mime, signal, purpose) {
816
816
  return this.http.postJson(
817
817
  "/api/canvas/assets/presign",
818
- { sha256, mime },
818
+ { sha256, mime, purpose },
819
819
  signal
820
820
  );
821
821
  }
@@ -840,6 +840,39 @@ var BackendClient = class {
840
840
  async recordRun(payload, signal) {
841
841
  await this.http.postJson("/api/canvas/runs", payload, signal);
842
842
  }
843
+ /**
844
+ * Resumable-run lookup — GET /api/canvas/runs/active. Returns the newest
845
+ * still-running/interrupted run for the creative (pinned to the exact canvas
846
+ * sha), so a fresh sandbox can adopt its run id and re-attach billed
847
+ * in-flight jobs. Null on no active run — or an older backend without the
848
+ * route (both 404).
849
+ */
850
+ async getActiveRun(creativeSlug, canvasSha, signal) {
851
+ const params = new URLSearchParams({ slug: creativeSlug });
852
+ if (canvasSha) params.set("sha", canvasSha);
853
+ try {
854
+ const res = await this.http.getJson(`/api/canvas/runs/active?${params}`, signal);
855
+ return res.run;
856
+ } catch (e) {
857
+ if (e instanceof BackendHttpError && "status" in e.detail && e.detail.status === 404) return null;
858
+ throw e;
859
+ }
860
+ }
861
+ /**
862
+ * Portable-rerun lookup — GET /api/canvas/runs/latest. The newest run for
863
+ * the creative that recorded a canvas snapshot manifest; null when none
864
+ * exists (or the backend predates the route — both 404).
865
+ */
866
+ async getLatestSnapshotRun(creativeSlug, signal) {
867
+ const params = new URLSearchParams({ slug: creativeSlug });
868
+ try {
869
+ const res = await this.http.getJson(`/api/canvas/runs/latest?${params}`, signal);
870
+ return res.run;
871
+ } catch (e) {
872
+ if (e instanceof BackendHttpError && "status" in e.detail && e.detail.status === 404) return null;
873
+ throw e;
874
+ }
875
+ }
843
876
  /**
844
877
  * Chat-scoped blueprint sync — POST /api/creatives/definition. Lets the
845
878
  * dashboard draw a scaffolded creative's workflow graph BEFORE the first run.
@@ -849,8 +882,8 @@ var BackendClient = class {
849
882
  await this.http.postJson("/api/creatives/definition", payload, signal);
850
883
  }
851
884
  getArtifact(kind, name, version, signal) {
852
- const path16 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
853
- return this.http.getJson(path16, signal);
885
+ const path17 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
886
+ return this.http.getJson(path17, signal);
854
887
  }
855
888
  };
856
889
 
@@ -907,6 +940,14 @@ var NodeExecutionError = class extends CanvasError {
907
940
  this.cause = cause;
908
941
  }
909
942
  };
943
+ var RunAbortedError = class extends CanvasError {
944
+ reason;
945
+ constructor(reason, message2) {
946
+ super(message2);
947
+ this.name = "RunAbortedError";
948
+ this.reason = reason;
949
+ }
950
+ };
910
951
  var LayerExecutionError = class extends CanvasError {
911
952
  failures;
912
953
  constructor(failures) {
@@ -984,6 +1025,7 @@ var OPENROUTER_IMAGE_AR = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:1
984
1025
  var OPENROUTER_IMAGE_AR_EXTREME = [...OPENROUTER_IMAGE_AR, "1:4", "4:1", "1:8", "8:1"];
985
1026
  var OPENROUTER_IMAGE_SIZE = ["1K", "2K", "4K"];
986
1027
  var OPENROUTER_IMAGE_SIZE_EXTENDED = ["0.5K", ...OPENROUTER_IMAGE_SIZE];
1028
+ var OPENROUTER_IMAGE_QUALITY = ["auto", "low", "medium", "high"];
987
1029
  var SEEDANCE_DURATIONS = [4, 5, 6, 8, 10, 12, 15];
988
1030
  var ELEVENLABS_OUTPUT_FORMATS = [
989
1031
  "mp3_22050_32",
@@ -1007,6 +1049,13 @@ var IMAGE_GENERATE_MODELS = [
1007
1049
  "google/gemini-3-pro-image-preview",
1008
1050
  "recraft/recraft-v4.1-pro-vector"
1009
1051
  ];
1052
+ var VIDEO_GENERATE_MODELS = [
1053
+ "bytedance/seedance-2.0",
1054
+ "google/veo-3.1",
1055
+ "google/veo-3.1-fast",
1056
+ "kwaivgi/kling-v3.0-pro"
1057
+ ];
1058
+ var DEFAULT_VIDEO_GENERATE_MODEL = "bytedance/seedance-2.0";
1010
1059
  var MODEL_REGISTRY = {
1011
1060
  text_generate: {
1012
1061
  "~google/gemini-flash-latest": {
@@ -1102,7 +1151,8 @@ var MODEL_REGISTRY = {
1102
1151
  params: {
1103
1152
  prompt: { kind: "string" },
1104
1153
  aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR },
1105
- image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE }
1154
+ image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE },
1155
+ quality: { kind: "string", enum: OPENROUTER_IMAGE_QUALITY }
1106
1156
  }
1107
1157
  },
1108
1158
  "google/gemini-3.5-flash": {
@@ -1114,7 +1164,8 @@ var MODEL_REGISTRY = {
1114
1164
  params: {
1115
1165
  prompt: { kind: "string" },
1116
1166
  aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR_EXTREME },
1117
- image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE_EXTENDED }
1167
+ image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE_EXTENDED },
1168
+ quality: { kind: "string", enum: OPENROUTER_IMAGE_QUALITY }
1118
1169
  }
1119
1170
  },
1120
1171
  "google/gemini-3.1-flash-image-preview": {
@@ -1136,7 +1187,8 @@ var MODEL_REGISTRY = {
1136
1187
  params: {
1137
1188
  prompt: { kind: "string" },
1138
1189
  aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR },
1139
- image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE }
1190
+ image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE },
1191
+ quality: { kind: "string", enum: OPENROUTER_IMAGE_QUALITY }
1140
1192
  }
1141
1193
  },
1142
1194
  "recraft/recraft-v4.1-pro-vector": {
@@ -1239,6 +1291,53 @@ var MODEL_REGISTRY = {
1239
1291
  generate_audio: { kind: "boolean" }
1240
1292
  }
1241
1293
  },
1294
+ "google/veo-3.1": {
1295
+ // Photoreal CINE CEILING + the real-face fallback (Veo generates adult
1296
+ // humans from a keyframe, dodging ByteDance's real-person filter). Same
1297
+ // OpenRouter google-vertex routing as the fast tier; the quality dial is
1298
+ // `resolution: 1080p` + `generate_audio` + a real `negative_prompt`, not a
1299
+ // separate provider knob. Reach for this for hero beats and any clip that
1300
+ // must carry a real human likeness.
1301
+ label: "Google Veo 3.1",
1302
+ inputs: [],
1303
+ optional_inputs: [{ kind: "image", mimes: OPENROUTER_IMAGE_MIMES }],
1304
+ required: ["prompt"],
1305
+ params: {
1306
+ prompt: { kind: "string" },
1307
+ negative_prompt: { kind: "string" },
1308
+ aspect_ratio: { kind: "string", enum: ["16:9", "9:16"] },
1309
+ resolution: { kind: "string", enum: ["720p", "1080p"] },
1310
+ duration: { kind: "number", enum: [4, 6, 8] },
1311
+ seed: { kind: "number" },
1312
+ generate_audio: { kind: "boolean" },
1313
+ person_generation: { kind: "string", enum: ["allow_all", "allow_adult"] },
1314
+ enhance_prompt: { kind: "boolean" },
1315
+ conditioning_scale: { kind: "number" }
1316
+ }
1317
+ },
1318
+ "kwaivgi/kling-v3.0-pro": {
1319
+ // Motion-transfer / dynamic multi-shot beats. Reachable through the default
1320
+ // OpenRouter gateway (generic video body — no google-vertex block), so it
1321
+ // needs no direct-provider exception. Cost is usage-based (known from the
1322
+ // provider response), so it has no pre-flight cost estimate. `cfg_scale`
1323
+ // trades prompt adherence vs motion freedom; higher = closer to prompt.
1324
+ label: "Kling 3.0",
1325
+ inputs: [],
1326
+ optional_inputs: [{ kind: "image", mimes: OPENROUTER_IMAGE_MIMES }],
1327
+ required: ["prompt"],
1328
+ params: {
1329
+ // Kling caps the prompt shorter than Seedance; gate it here so an
1330
+ // over-length prompt fails validate (free) not the billed call.
1331
+ prompt: { kind: "string", maxLength: 2500 },
1332
+ negative_prompt: { kind: "string" },
1333
+ aspect_ratio: { kind: "string", enum: ["1:1", "16:9", "9:16"] },
1334
+ resolution: { kind: "string", enum: ["720p", "1080p"] },
1335
+ duration: { kind: "number", enum: [5, 10] },
1336
+ seed: { kind: "number" },
1337
+ generate_audio: { kind: "boolean" },
1338
+ cfg_scale: { kind: "number", min: 0, max: 1 }
1339
+ }
1340
+ },
1242
1341
  "google/veo-3.1-fast": {
1243
1342
  // Cheap test/iteration model. Forwarded by the backend via
1244
1343
  // `provider.options.google-vertex.parameters` (camelCased on the wire).
@@ -1732,18 +1831,43 @@ function message(e) {
1732
1831
  }
1733
1832
 
1734
1833
  // src/engine/nodes/remote/upload.ts
1834
+ var PUT_MAX_ATTEMPTS = 4;
1835
+ var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1735
1836
  async function presignAndPut(args) {
1736
- const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(args.sha256, args.mime, args.ctx.signal);
1737
- const putRes = await fetch(putUrl, {
1738
- method: "PUT",
1739
- body: new Uint8Array(args.bytes),
1740
- headers: { "Content-Type": args.mime },
1741
- signal: args.ctx.signal
1742
- });
1743
- if (!putRes.ok) {
1744
- throw new Error(`upload: presigned PUT failed ${putRes.status} ${putRes.statusText}`);
1837
+ let lastFailure = null;
1838
+ for (let attempt = 0; attempt < PUT_MAX_ATTEMPTS; attempt++) {
1839
+ if (args.ctx.signal?.aborted) break;
1840
+ if (attempt > 0) await sleep2(500 * 2 ** (attempt - 1) * (1 + Math.random() * 0.25));
1841
+ const result = await attemptPresignedPut(args);
1842
+ if (result.ok) return result.url;
1843
+ lastFailure = result.failure;
1844
+ if (!result.retryable) break;
1845
+ }
1846
+ throw lastFailure ?? new Error("upload: aborted before the PUT could start");
1847
+ }
1848
+ async function attemptPresignedPut(args) {
1849
+ try {
1850
+ const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(args.sha256, args.mime, args.ctx.signal);
1851
+ const putRes = await fetch(putUrl, {
1852
+ method: "PUT",
1853
+ body: new Uint8Array(args.bytes),
1854
+ headers: { "Content-Type": args.mime },
1855
+ signal: args.ctx.signal
1856
+ });
1857
+ if (putRes.ok) return { ok: true, url: publicUrl };
1858
+ return {
1859
+ ok: false,
1860
+ failure: new Error(`upload: presigned PUT failed ${putRes.status} ${putRes.statusText}`),
1861
+ // Only transient statuses warrant a replay — a 400/403 fails identically every attempt.
1862
+ retryable: putRes.status >= 500 || putRes.status === 429 || putRes.status === 408
1863
+ };
1864
+ } catch (e) {
1865
+ return {
1866
+ ok: false,
1867
+ failure: e instanceof Error ? e : new Error(String(e)),
1868
+ retryable: args.ctx.signal?.aborted !== true
1869
+ };
1745
1870
  }
1746
- return publicUrl;
1747
1871
  }
1748
1872
  async function ensureUploaded(ref, ctx) {
1749
1873
  if (ref.url) return ref;
@@ -1803,6 +1927,16 @@ var OutputRef = z.object({
1803
1927
  }).strict();
1804
1928
  var VideoMeta = z.object({
1805
1929
  duration_s: z.number(),
1930
+ // The one-clock contract: the spine's summed clip lengths and every audio
1931
+ // timeline reachable from the final mux must equal `expected_total_s` — the
1932
+ // validator recomputes both from the LIVE graph (`-shortest` at the final mux
1933
+ // silently truncates the longer stream on any mismatch). Emitted by
1934
+ // scaffold-video; optional so hand-authored canvases still parse.
1935
+ timeline: z.object({
1936
+ spine_node: z.string(),
1937
+ expected_total_s: z.number(),
1938
+ audio_mix_node: z.string().optional()
1939
+ }).strict().optional(),
1806
1940
  // Each sequenced voiceover turn on the absolute timeline.
1807
1941
  vo_segments: z.array(
1808
1942
  z.object({
@@ -2280,8 +2414,111 @@ function dfsCycle(u, color, stack, reverseAdj) {
2280
2414
  }
2281
2415
 
2282
2416
  // src/engine/engine/validator.ts
2417
+ import { readFile as readFile2 } from "fs/promises";
2418
+ import path3 from "path";
2283
2419
  import { z as z2 } from "zod";
2284
2420
 
2421
+ // src/engine/scaffold/lib/prompt-profiles.ts
2422
+ var VEO_PERSON_GENERATION = "allow_adult";
2423
+ var VEO_NEGATIVE_PROMPT = "subtitles, captions, on-screen text, watermark, logo, warped face, distorted hands, extra fingers, low quality";
2424
+ var VEO_DURATIONS = [4, 6, 8];
2425
+ var KLING_DURATIONS = [5, 10];
2426
+ var KLING_NEGATIVE_PROMPT = "warped face, distorted hands, extra fingers, morphing, flicker, on-screen text, watermark, low quality";
2427
+ var KLING_CFG_SCALE = 0.7;
2428
+ var SPEAKS_PROSE = (line) => `The person speaks to camera; lip-sync follows the dialogue verbatim, with delivery and emotion carried in the wording itself (no bracketed cues). Dialogue: "${line}"`;
2429
+ var SEEDANCE_PROFILE = {
2430
+ id: "seedance",
2431
+ dialogueDirective: SPEAKS_PROSE,
2432
+ extraDirectives: [],
2433
+ // Seedance has no negative_prompt field. Phrase the anti-artifact intent as the POSITIVE
2434
+ // state we want (crisp hands, one stable face, smooth coherent motion, locked identity) —
2435
+ // an "Avoid: warped fingers, morphing faces" list tends to plant those very artifacts.
2436
+ stabilityDirectives: [
2437
+ "hands and fingers crisp and anatomically correct",
2438
+ "one consistent face and identity across every frame",
2439
+ "smooth, temporally coherent motion that holds steady frame to frame"
2440
+ ],
2441
+ keyframeInstruction: "Preserve the composition and colors of the first frame; change only the motion described.",
2442
+ // ~120 words is the Seedance quality sweet spot: past it the model starts dropping beats and
2443
+ // the attention budget dilutes. The validator warns above this; composite scenes that stack
2444
+ // many brief `parts` are the usual offenders.
2445
+ wordBudget: 120,
2446
+ durationSet: SEEDANCE_DURATIONS,
2447
+ paramDefaults: {}
2448
+ };
2449
+ var VEO_PROFILE = {
2450
+ id: "veo",
2451
+ dialogueDirective: SPEAKS_PROSE,
2452
+ // Veo's known failure mode: quoted dialogue triggers burned-in subtitles. Belt
2453
+ // (inline) and braces (the negative_prompt param, below) — the param is the real lever.
2454
+ extraDirectives: ["No subtitles, no captions, no on-screen text of any kind."],
2455
+ stabilityDirectives: [],
2456
+ // Veo takes a negative_prompt PARAM instead (paramDefaults).
2457
+ keyframeInstruction: "Describe the transition and camera move between the frames; the frames fix the content.",
2458
+ wordBudget: 200,
2459
+ durationSet: VEO_DURATIONS,
2460
+ paramDefaults: { person_generation: VEO_PERSON_GENERATION, negative_prompt: VEO_NEGATIVE_PROMPT }
2461
+ };
2462
+ var KLING_PROFILE = {
2463
+ id: "kling",
2464
+ dialogueDirective: SPEAKS_PROSE,
2465
+ // Belt (inline) and braces (the negative_prompt param) — the param is the real lever.
2466
+ extraDirectives: ["Single, deliberate camera move \u2014 no whip pans or rapid cuts within the clip."],
2467
+ stabilityDirectives: [],
2468
+ // Kling takes a negative_prompt PARAM instead (paramDefaults).
2469
+ keyframeInstruction: "Preserve the composition and colors of the first frame; animate the motion described.",
2470
+ wordBudget: 200,
2471
+ durationSet: KLING_DURATIONS,
2472
+ paramDefaults: { negative_prompt: KLING_NEGATIVE_PROMPT, cfg_scale: KLING_CFG_SCALE }
2473
+ };
2474
+ function clipProfileFor(modelId) {
2475
+ if (/^bytedance\/seedance/.test(modelId)) return SEEDANCE_PROFILE;
2476
+ if (/^google\/veo/.test(modelId)) return VEO_PROFILE;
2477
+ if (/^kwaivgi\/kling|^kling\//.test(modelId)) return KLING_PROFILE;
2478
+ return void 0;
2479
+ }
2480
+ function clipParamRecipe(profile, intent) {
2481
+ const out = {};
2482
+ if (intent === "hero") {
2483
+ out.resolution = "1080p";
2484
+ }
2485
+ if (intent === "hook" && profile.id === "kling") {
2486
+ out.cfg_scale = 0.85;
2487
+ }
2488
+ return out;
2489
+ }
2490
+ function nativeDialogueOf(prompt) {
2491
+ if (typeof prompt !== "string") return void 0;
2492
+ const m = prompt.match(/Dialogue: "(.*)"/);
2493
+ return m?.[1]?.trim() || void 0;
2494
+ }
2495
+ var GPT_IMAGE_PROFILE = {
2496
+ id: "gpt-image",
2497
+ constraintPlacement: "last",
2498
+ photorealCue: true,
2499
+ // OpenRouter forwards `quality`; gpt-image-2 already processes inputs at high
2500
+ // fidelity automatically, so we deliberately do NOT send `input_fidelity`.
2501
+ paramDefaults: { quality: "high" }
2502
+ };
2503
+ var GEMINI_IMAGE_PROFILE = {
2504
+ id: "gemini",
2505
+ constraintPlacement: "inline",
2506
+ photorealCue: true,
2507
+ paramDefaults: { quality: "high" }
2508
+ };
2509
+ var RECRAFT_IMAGE_PROFILE = {
2510
+ id: "recraft",
2511
+ constraintPlacement: "inline",
2512
+ photorealCue: false,
2513
+ paramDefaults: {}
2514
+ };
2515
+ function imageProfileFor(modelId) {
2516
+ if (/^openai\/gpt-.*image/.test(modelId)) return GPT_IMAGE_PROFILE;
2517
+ if (/^google\/gemini/.test(modelId)) return GEMINI_IMAGE_PROFILE;
2518
+ if (/^recraft\//.test(modelId)) return RECRAFT_IMAGE_PROFILE;
2519
+ return void 0;
2520
+ }
2521
+
2285
2522
  // src/engine/engine/define.ts
2286
2523
  function resolveOutputKinds(spec, params) {
2287
2524
  if (!spec) return {};
@@ -2363,16 +2600,62 @@ var STAGE_CODES = {
2363
2600
  REFERENCE_MISSING: "VIDEO_REFERENCE_MISSING",
2364
2601
  SPAN_EXCEEDS_MODEL: "VIDEO_SPAN_EXCEEDS_MODEL",
2365
2602
  UI_IN_PROMPT: "VIDEO_UI_IN_PROMPT",
2366
- BRANDMARK_IN_PROMPT: "VIDEO_BRANDMARK_IN_PROMPT"
2603
+ BRANDMARK_IN_PROMPT: "VIDEO_BRANDMARK_IN_PROMPT",
2604
+ SPEECH_EXCEEDS_EXTRACT: "VIDEO_SPEECH_EXCEEDS_EXTRACT",
2605
+ PROMPT_PROFILE_MISSING: "VIDEO_PROMPT_PROFILE_MISSING",
2606
+ PROMPT_DECISION_MISSING: "VIDEO_PROMPT_DECISION_MISSING",
2607
+ HOOK_LAYER_MISSING: "VIDEO_HOOK_LAYER_MISSING",
2608
+ IMAGE_PROFILE_MISSING: "IMAGE_PROMPT_PROFILE_MISSING",
2609
+ PROMPT_OVER_BUDGET: "VIDEO_PROMPT_OVER_BUDGET",
2610
+ PERSON_GENERATION_MISSING: "VIDEO_PERSON_GENERATION_MISSING",
2611
+ RAW_FACE_KEYFRAME: "VIDEO_RAW_FACE_KEYFRAME",
2612
+ TIMELINE_TOTAL: "VIDEO_TIMELINE_TOTAL_MISMATCH",
2613
+ NATIVE_SEG_OVERLAP: "VIDEO_NATIVE_SEG_OVERLAP",
2614
+ SPINE_UNNORMALIZED: "VIDEO_SPINE_UNNORMALIZED",
2615
+ ODD_DIMENSIONS: "VIDEO_ODD_DIMENSIONS",
2616
+ REGION_DROPPED: "VIDEO_REGION_DROPPED",
2617
+ OVERLAY_OUT_OF_BOUNDS: "VIDEO_OVERLAY_OUT_OF_BOUNDS"
2367
2618
  };
2368
2619
  var SPAN_MODEL_SLACK_S = 0.25;
2369
2620
  var VIDEO_TIME_SLACK_S = 0.75;
2370
2621
  var SPEECH_WORDS_PER_SECOND = 2.5;
2371
2622
  var SPEECH_OVERRUN_RATIO = 1.6;
2372
- function nativeDialogueOf(prompt) {
2373
- if (typeof prompt !== "string") return void 0;
2374
- const m = prompt.match(/Dialogue: "(.*)"/);
2375
- return m?.[1]?.trim() || void 0;
2623
+ function ffmpegTrimSeconds(node) {
2624
+ const args = node.params?.args;
2625
+ if (!Array.isArray(args)) return null;
2626
+ let t = null;
2627
+ for (let i = 0; i < args.length - 1; i++) {
2628
+ if (args[i] === "-t") {
2629
+ const v = Number(args[i + 1]);
2630
+ if (Number.isFinite(v)) t = v;
2631
+ }
2632
+ }
2633
+ return t;
2634
+ }
2635
+ function refNodeOf(ctx, value) {
2636
+ if (typeof value !== "string" || !value.startsWith(REF_PREFIX)) return null;
2637
+ const parsed = parseRefExpr(value);
2638
+ if (!parsed) return null;
2639
+ const idx = ctx.idToIndex.get(parsed.nodeId);
2640
+ return idx === void 0 ? null : ctx.canvas.nodes[idx];
2641
+ }
2642
+ function liveNodeDurationS(ctx, node, depth = 0) {
2643
+ if (!node || depth > 3) return null;
2644
+ if (node.type === "video_generate") {
2645
+ const d = node.params?.duration;
2646
+ return typeof d === "number" ? d : null;
2647
+ }
2648
+ if (node.type === "ffmpeg") {
2649
+ const t = ffmpegTrimSeconds(node);
2650
+ if (t !== null) return t;
2651
+ for (const v of Object.values(node.inputs ?? {})) {
2652
+ const upstream = refNodeOf(ctx, v);
2653
+ const d = liveNodeDurationS(ctx, upstream, depth + 1);
2654
+ if (d !== null) return d;
2655
+ }
2656
+ return null;
2657
+ }
2658
+ return null;
2376
2659
  }
2377
2660
  function validateCanvas(input, registry) {
2378
2661
  const issues = [];
@@ -2434,6 +2717,7 @@ async function validateCanvasDeep(input, registry) {
2434
2717
  });
2435
2718
  }
2436
2719
  }
2720
+ await checkOverlayTimingBounds(canvas, issues);
2437
2721
  const hasBlocking = issues.some(isBlocking);
2438
2722
  if (hasBlocking) return { ok: false, issues };
2439
2723
  const warnings = [...shallow.warnings ?? [], ...issues.filter((i) => !isBlocking(i))];
@@ -2675,6 +2959,38 @@ function estimateCredits(ctx) {
2675
2959
  }
2676
2960
  return total;
2677
2961
  }
2962
+ function overlayTimingIssues(html, durationS) {
2963
+ const out = [];
2964
+ for (const m of html.matchAll(/<[^>]*\bdata-start="([\d.]+)"[^>]*\bdata-dur="([\d.]+)"[^>]*>/g)) {
2965
+ const start = Number(m[1]);
2966
+ const dur = Number(m[2]);
2967
+ if (!Number.isFinite(start) || !Number.isFinite(dur)) continue;
2968
+ if (start >= durationS || start + dur > durationS + 0.25) out.push({ start, dur });
2969
+ }
2970
+ return out;
2971
+ }
2972
+ async function checkOverlayTimingBounds(canvas, issues) {
2973
+ const durationS = canvas.metadata?.video?.duration_s;
2974
+ if (typeof durationS !== "number") return;
2975
+ for (let i = 0; i < canvas.nodes.length; i++) {
2976
+ const n = canvas.nodes[i];
2977
+ if (n?.type !== "hyperframe_render") continue;
2978
+ const composition = n.params?.composition;
2979
+ if (typeof composition !== "string" || !path3.isAbsolute(composition)) continue;
2980
+ const html = await readFile2(path3.join(composition, "index.html"), "utf8").catch(() => null);
2981
+ if (!html) continue;
2982
+ for (const w of overlayTimingIssues(html, durationS)) {
2983
+ issues.push({
2984
+ path: `nodes[${i}].params.composition`,
2985
+ code: STAGE_CODES.OVERLAY_OUT_OF_BOUNDS,
2986
+ severity: "warning",
2987
+ node_id: n.id,
2988
+ node_type: n.type,
2989
+ message: `an overlay in ${path3.basename(composition)}/index.html runs [${w.start}s, ${w.start + w.dur}s] but the video ends at ${durationS}s \u2014 it never hides on screen. Re-time its data-start/data-dur to fit the video`
2990
+ });
2991
+ }
2992
+ }
2993
+ }
2678
2994
  function nativeAudioReachesMix(ctx, scene) {
2679
2995
  const wanted = [
2680
2996
  `$ref:s${scene}_voextract.audio`,
@@ -2707,6 +3023,13 @@ function talkingSceneSatisfied(ctx, entry, scene) {
2707
3023
  });
2708
3024
  }
2709
3025
  function checkVideoInvariants(ctx) {
3026
+ checkPromptProfile(ctx);
3027
+ checkImageProfile(ctx);
3028
+ checkPersonGeneration(ctx);
3029
+ checkPromptBudget(ctx);
3030
+ checkRawFaceKeyframe(ctx);
3031
+ checkPromptDecisions(ctx);
3032
+ checkHookLayers(ctx);
2710
3033
  const meta = ctx.canvas.metadata?.video;
2711
3034
  if (!meta) return;
2712
3035
  const segments = [...meta.vo_segments].sort((a, b) => a.start_s - b.start_s);
@@ -2746,6 +3069,286 @@ function checkVideoInvariants(ctx) {
2746
3069
  checkBrandmarkInPrompt(ctx);
2747
3070
  checkReferenceCompleteness(ctx, meta);
2748
3071
  checkClipSpanFitsModel(ctx, meta);
3072
+ checkTimelineContract(ctx, meta);
3073
+ checkNativeSegOverlap(ctx);
3074
+ checkSpineNormalized(ctx, meta);
3075
+ checkOddDimensions(ctx);
3076
+ checkRegionDropped(ctx);
3077
+ }
3078
+ function checkPromptProfile(ctx) {
3079
+ for (const n of ctx.canvas.nodes) {
3080
+ if (n.type !== "video_generate") continue;
3081
+ const model = n.params?.model;
3082
+ if (typeof model !== "string" || clipProfileFor(model)) continue;
3083
+ ctx.issues.push({
3084
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.model`,
3085
+ code: STAGE_CODES.PROMPT_PROFILE_MISSING,
3086
+ severity: "warning",
3087
+ node_id: n.id,
3088
+ message: `"${n.id}" runs on "${model}", a model with no clip-prompt profile \u2014 its prompt was authored with the default Seedance syntax. Review the dialogue/emotion markup for this model before billing`
3089
+ });
3090
+ }
3091
+ }
3092
+ var TECHNIQUE_CUE = /\b(camera|push[- ]?in|pull[- ]?out|pan|dolly|zoom|tilt|track|handheld|locked|orbit|crane|whip|motion|move)\b/i;
3093
+ var NEGATIVES_CUE = /\bavoid:|\bno subtitles|\bnegative\b|keep it clean and stable/i;
3094
+ var VIBE_CUE = /\b(light|lighting|golden|moody|warm|cool|tone|grade|contrast|shadow|glow|rim|backlit|neon|soft|harsh)\b/i;
3095
+ function checkPromptDecisions(ctx) {
3096
+ for (const n of ctx.canvas.nodes) {
3097
+ if (n.type !== "video_generate") continue;
3098
+ const params = n.params;
3099
+ const prompt = typeof params?.prompt === "string" ? params.prompt : "";
3100
+ if (!prompt) continue;
3101
+ const hasTechnique = TECHNIQUE_CUE.test(prompt);
3102
+ const hasNegatives = NEGATIVES_CUE.test(prompt) || typeof params?.negative_prompt === "string";
3103
+ if (hasTechnique || hasNegatives) continue;
3104
+ ctx.issues.push({
3105
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3106
+ code: STAGE_CODES.PROMPT_DECISION_MISSING,
3107
+ severity: "warning",
3108
+ node_id: n.id,
3109
+ message: `"${n.id}" is a thin clip prompt \u2014 it names no camera/motion move (TECHNIQUE) and no constraints (NEGATIVES: an affirmative "Keep it clean and stable" line, a negative_prompt param, or an "Avoid:" tail). Make the six decisions (route / spec / beats / copy / technique / negatives) so the model doesn't fill the gaps with drift. See prompt-anatomy.md`
3110
+ });
3111
+ }
3112
+ }
3113
+ function checkHookLayers(ctx) {
3114
+ const scene0 = ctx.canvas.nodes.filter((n) => /^s0(?:[_a-z0-9])*_/.test(n.id) || n.id === "s0");
3115
+ const hookClip = scene0.find((n) => n.type === "video_generate");
3116
+ if (!hookClip) return;
3117
+ const scene0Text = scene0.map((n) => {
3118
+ const p = n.params?.prompt;
3119
+ return typeof p === "string" ? p : "";
3120
+ }).join("\n");
3121
+ const hookParams = hookClip.params;
3122
+ const hasOverlayLayer = ctx.canvas.nodes.some((n) => /hyperframe/.test(n.type));
3123
+ const hasSound = hookParams?.generate_audio === true || /Dialogue:|Audio:/.test(scene0Text) || ctx.canvas.nodes.some((n) => n.type === "tts" || n.type === "dialogue" || n.type === "music");
3124
+ const hasVisual = scene0Text.trim().length > 40;
3125
+ const hasVibe = VIBE_CUE.test(scene0Text);
3126
+ const missing = [];
3127
+ if (!hasOverlayLayer) missing.push("text (overlay)");
3128
+ if (!hasSound) missing.push("sound (line/SFX)");
3129
+ if (!hasVisual) missing.push("visual (the frame)");
3130
+ if (!hasVibe) missing.push("vibe (lighting/tone)");
3131
+ if (missing.length < 2) return;
3132
+ ctx.issues.push({
3133
+ path: `nodes[${ctx.idToIndex.get(hookClip.id) ?? -1}]`,
3134
+ code: STAGE_CODES.HOOK_LAYER_MISSING,
3135
+ severity: "warning",
3136
+ node_id: hookClip.id,
3137
+ message: `the hook (scene 0) is thin across layers \u2014 missing ${missing.join(", ")}. A scroll-stopping hook works on all four layers (text / sound / visual / vibe); strengthen the missing ones. See hook-craft.md`
3138
+ });
3139
+ }
3140
+ function checkImageProfile(ctx) {
3141
+ for (const n of ctx.canvas.nodes) {
3142
+ if (n.type !== "image_generate") continue;
3143
+ const model = n.params?.model;
3144
+ if (typeof model !== "string" || imageProfileFor(model)) continue;
3145
+ ctx.issues.push({
3146
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.model`,
3147
+ code: STAGE_CODES.IMAGE_PROFILE_MISSING,
3148
+ severity: "warning",
3149
+ node_id: n.id,
3150
+ message: `"${n.id}" renders on "${model}", an image model with no frame-prompt profile \u2014 it got the generic template (constraint placement / photoreal cue not tuned). Add an ImageModelProfile for this model`
3151
+ });
3152
+ }
3153
+ }
3154
+ function checkPersonGeneration(ctx) {
3155
+ for (const n of ctx.canvas.nodes) {
3156
+ if (n.type !== "video_generate") continue;
3157
+ const params = n.params;
3158
+ const model = params?.model;
3159
+ if (clipProfileFor(typeof model === "string" ? model : "")?.id !== "veo") continue;
3160
+ const hasKeyframe = Boolean(n.inputs?.first_frame ?? n.inputs?.reference);
3161
+ if (!hasKeyframe || params?.person_generation) continue;
3162
+ ctx.issues.push({
3163
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params`,
3164
+ code: STAGE_CODES.PERSON_GENERATION_MISSING,
3165
+ severity: "warning",
3166
+ node_id: n.id,
3167
+ message: `Veo clip "${n.id}" drives from a keyframe but sets no person_generation \u2014 set "allow_adult" (the only legal value for image-to-video, and the only one allowed in the EU/UK)`
3168
+ });
3169
+ }
3170
+ }
3171
+ function checkPromptBudget(ctx) {
3172
+ for (const n of ctx.canvas.nodes) {
3173
+ if (n.type !== "video_generate") continue;
3174
+ const params = n.params;
3175
+ const model = params?.model;
3176
+ const prompt = params?.prompt;
3177
+ const profile = typeof model === "string" ? clipProfileFor(model) : void 0;
3178
+ if (!profile || typeof prompt !== "string") continue;
3179
+ const words = prompt.trim().split(/\s+/).filter(Boolean).length;
3180
+ if (words <= profile.wordBudget) continue;
3181
+ ctx.issues.push({
3182
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3183
+ code: STAGE_CODES.PROMPT_OVER_BUDGET,
3184
+ severity: "warning",
3185
+ node_id: n.id,
3186
+ message: `"${n.id}" prompt is ${words} words \u2014 over ${model}'s ~${profile.wordBudget}-word budget; trim it (the frames carry the content, so keep the clip prompt to motion + audio)`
3187
+ });
3188
+ }
3189
+ }
3190
+ function checkRawFaceKeyframe(ctx) {
3191
+ for (const n of ctx.canvas.nodes) {
3192
+ if (n.type !== "video_generate") continue;
3193
+ const model = n.params?.model;
3194
+ if (clipProfileFor(typeof model === "string" ? model : "")?.id !== "seedance") continue;
3195
+ const src = refNodeOf(ctx, n.inputs?.first_frame ?? n.inputs?.reference);
3196
+ if (!src || src.type !== "ingest") continue;
3197
+ ctx.issues.push({
3198
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].inputs.first_frame`,
3199
+ code: STAGE_CODES.RAW_FACE_KEYFRAME,
3200
+ severity: "warning",
3201
+ node_id: n.id,
3202
+ message: `Seedance clip "${n.id}" animates a RAW ingested image ("${src.id}"), not a generated frame \u2014 if it shows a real human face, ByteDance's real-person filter rejects it (422). Anchor on an AI-generated portrait (recast), or route this clip to Veo`
3203
+ });
3204
+ }
3205
+ }
3206
+ function spineTotalS(ctx, spine) {
3207
+ let total = 0;
3208
+ const inputs = Object.entries(spine.inputs ?? {}).filter(([k]) => /^c\d+$/.test(k));
3209
+ if (inputs.length === 0) return null;
3210
+ for (const [, v] of inputs) {
3211
+ const d = liveNodeDurationS(ctx, refNodeOf(ctx, v));
3212
+ if (d === null) return null;
3213
+ total += d;
3214
+ }
3215
+ const graph = spine.params.args?.join?.(" ") ?? "";
3216
+ for (const m of String(graph).matchAll(/xfade=transition=[^:]+:duration=([\d.]+)/g)) {
3217
+ total -= Number(m[1]);
3218
+ }
3219
+ return total;
3220
+ }
3221
+ function checkTimelineContract(ctx, meta) {
3222
+ const stamp = meta.timeline;
3223
+ const spineId = stamp?.spine_node ?? (ctx.idToIndex.has("spine") ? "spine" : null);
3224
+ if (!spineId) return;
3225
+ const expected = stamp?.expected_total_s ?? meta.duration_s;
3226
+ checkSpineTotal(ctx, spineId, expected);
3227
+ checkAudioTimelineTotals(ctx, stamp?.audio_mix_node, expected);
3228
+ }
3229
+ function checkSpineTotal(ctx, spineId, expected) {
3230
+ const spineIdx = ctx.idToIndex.get(spineId);
3231
+ const spine = spineIdx === void 0 ? null : ctx.canvas.nodes[spineIdx];
3232
+ const spineLen = spine ? spineTotalS(ctx, spine) : null;
3233
+ if (spineLen === null || Math.abs(spineLen - expected) <= VIDEO_TIME_SLACK_S) return;
3234
+ ctx.issues.push({
3235
+ path: `nodes[${spineIdx}].params.args`,
3236
+ code: STAGE_CODES.TIMELINE_TOTAL,
3237
+ node_id: spineId,
3238
+ message: `the picture sums to ${round2(spineLen)}s but the timeline is pinned to ${expected}s \u2014 \`-shortest\` at the final mux will silently cut the longer stream. Re-time the seg trims (or the audio total_ms) so both match`
3239
+ });
3240
+ }
3241
+ function checkAudioTimelineTotals(ctx, audioMixNode, expected) {
3242
+ const mustBeFullLength = /* @__PURE__ */ new Set();
3243
+ if (audioMixNode) mustBeFullLength.add(audioMixNode);
3244
+ ctx.canvas.nodes.forEach((n) => {
3245
+ if (n.type !== "audio_voice_convert") return;
3246
+ const track = refNodeOf(ctx, (n.inputs ?? {}).audio);
3247
+ if (track?.type === "audio_timeline") mustBeFullLength.add(track.id);
3248
+ });
3249
+ for (const id of mustBeFullLength) {
3250
+ const idx = ctx.idToIndex.get(id);
3251
+ const node = idx === void 0 ? null : ctx.canvas.nodes[idx];
3252
+ const totalMs = node?.params?.total_ms;
3253
+ if (typeof totalMs !== "number") continue;
3254
+ if (Math.abs(totalMs / 1e3 - expected) > VIDEO_TIME_SLACK_S) {
3255
+ ctx.issues.push({
3256
+ path: `nodes[${idx}].params.total_ms`,
3257
+ code: STAGE_CODES.TIMELINE_TOTAL,
3258
+ node_id: id,
3259
+ message: `audio timeline "${id}" is pinned to ${totalMs}ms but the picture timeline is ${expected}s \u2014 \`-shortest\` at the final mux will silently cut the longer stream. Pin total_ms to ${Math.round(expected * 1e3)}`
3260
+ });
3261
+ }
3262
+ }
3263
+ }
3264
+ function checkNativeSegOverlap(ctx) {
3265
+ for (const conv of ctx.canvas.nodes) {
3266
+ if (conv.type !== "audio_voice_convert") continue;
3267
+ const track = refNodeOf(ctx, (conv.inputs ?? {}).audio);
3268
+ if (track?.type !== "audio_timeline") continue;
3269
+ const params = track.params;
3270
+ const windows = (params.tracks ?? []).map((t) => {
3271
+ const extract = refNodeOf(ctx, (track.inputs ?? {})[t.slot]);
3272
+ const len = t.duration_s ?? (extract ? ffmpegTrimSeconds(extract) : null);
3273
+ return len === null ? null : { slot: t.slot, start: t.start_s, end: t.start_s + len };
3274
+ }).filter((w) => w !== null).sort((a, b) => a.start - b.start);
3275
+ for (let i = 1; i < windows.length; i++) {
3276
+ const prev = windows[i - 1];
3277
+ const cur = windows[i];
3278
+ if (!prev || !cur || cur.start >= prev.end - 0.01) continue;
3279
+ const trackIdx = ctx.idToIndex.get(track.id);
3280
+ ctx.issues.push({
3281
+ path: `nodes[${trackIdx}].params.tracks`,
3282
+ code: STAGE_CODES.NATIVE_SEG_OVERLAP,
3283
+ node_id: track.id,
3284
+ message: `voice track "${track.id}": "${prev.slot}" runs to ${round2(prev.end)}s but "${cur.slot}" starts at ${cur.start}s \u2014 both play at once (echo). Cap the first with duration_s: ${round2(cur.start - prev.start)} or re-time the windows`
3285
+ });
3286
+ }
3287
+ }
3288
+ }
3289
+ function checkSpineNormalized(ctx, meta) {
3290
+ const spineId = meta.timeline?.spine_node ?? "spine";
3291
+ const idx = ctx.idToIndex.get(spineId);
3292
+ const spine = idx === void 0 ? null : ctx.canvas.nodes[idx];
3293
+ if (!spine || spine.type !== "ffmpeg") return;
3294
+ const args = spine.params.args;
3295
+ const graph = Array.isArray(args) ? args.join(" ") : "";
3296
+ if (!graph.includes("concat=n=")) return;
3297
+ if (/\[\d+:v\](?:\[|concat)/.test(graph)) {
3298
+ ctx.issues.push({
3299
+ path: `nodes[${idx}].params.args`,
3300
+ code: STAGE_CODES.SPINE_UNNORMALIZED,
3301
+ severity: "warning",
3302
+ node_id: spineId,
3303
+ message: `the spine concat feeds raw input labels \u2014 generated clips carry no fps/SAR guarantee, and one 24fps clip silently stretches the picture off the audio/overlay clock. Chain \`format=yuv420p,fps=30,setsar=1,settb=AVTB\` on every input before the concat`
3304
+ });
3305
+ }
3306
+ }
3307
+ function checkOddDimensions(ctx) {
3308
+ ctx.canvas.nodes.forEach((node, idx) => {
3309
+ if (node.type !== "ffmpeg") return;
3310
+ const args = node.params.args;
3311
+ const blob = Array.isArray(args) ? args.join(" ") : "";
3312
+ for (const m of blob.matchAll(/(?:scale|crop|pad)=(\d+):(\d+)/g)) {
3313
+ const w = Number(m[1]);
3314
+ const h = Number(m[2]);
3315
+ if (w % 2 === 0 && h % 2 === 0) continue;
3316
+ ctx.issues.push({
3317
+ path: `nodes[${idx}].params.args`,
3318
+ code: STAGE_CODES.ODD_DIMENSIONS,
3319
+ severity: "warning",
3320
+ node_id: node.id,
3321
+ message: `"${m[0]}" produces an odd dimension \u2014 libx264 yuv420p rejects odd sizes, so this node fails at render time. Round both to even numbers`
3322
+ });
3323
+ return;
3324
+ }
3325
+ });
3326
+ }
3327
+ function checkRegionDropped(ctx) {
3328
+ const regionClips = /* @__PURE__ */ new Map();
3329
+ for (const node of ctx.canvas.nodes) {
3330
+ const m = /^s(\d+)_r\d+_clip$/.exec(node.id);
3331
+ if (m && node.type === "video_generate")
3332
+ regionClips.set(m[1], (regionClips.get(m[1]) ?? 0) + 1);
3333
+ }
3334
+ for (const [scene, billed] of regionClips) {
3335
+ const idx = ctx.idToIndex.get(`s${scene}_composite`);
3336
+ if (idx === void 0) continue;
3337
+ const composite = ctx.canvas.nodes[idx];
3338
+ const consumed = Object.keys(composite.inputs ?? {}).filter((k) => /^c\d+$/.test(k)).length;
3339
+ if (billed > consumed) {
3340
+ ctx.issues.push({
3341
+ path: `nodes[${idx}].inputs`,
3342
+ code: STAGE_CODES.REGION_DROPPED,
3343
+ severity: "warning",
3344
+ node_id: composite.id,
3345
+ message: `scene s${scene} bills ${billed} region clips but its composite consumes ${consumed} \u2014 the extra generation is paid for and never reaches the frame. Wire every region into the composite or delete the unused clip node`
3346
+ });
3347
+ }
3348
+ }
3349
+ }
3350
+ function round2(n) {
3351
+ return Math.round(n * 100) / 100;
2749
3352
  }
2750
3353
  var ELEMENT_TYPE_KEYWORDS = {
2751
3354
  animal: ["dog", "puppy", "pup", "cat", "kitten", "kitty", "pet", "canine", "feline"],
@@ -2873,15 +3476,37 @@ function checkSpeechOverrun(ctx, talkingScenes) {
2873
3476
  for (const n of ctx.canvas.nodes) {
2874
3477
  if (!nativeClipRe.test(n.id) || n.type !== "video_generate") continue;
2875
3478
  const overrun = speechOverrunOf(n, secondsPerWord(entry));
2876
- if (!overrun) continue;
2877
- ctx.issues.push({
2878
- path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
2879
- code: STAGE_CODES.SPEECH_OVERRUN,
2880
- message: `"${n.id}" asks Seedance to speak ~${Math.round(overrun.estSpeechS * 10) / 10}s of dialogue inside a ${overrun.duration}s clip \u2014 the line cannot fit (>${SPEECH_OVERRUN_RATIO}\xD7 the clip). Shorten the line, split the scene, or lengthen the clip duration`
2881
- });
3479
+ if (overrun) {
3480
+ ctx.issues.push({
3481
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3482
+ code: STAGE_CODES.SPEECH_OVERRUN,
3483
+ message: `"${n.id}" asks Seedance to speak ~${Math.round(overrun.estSpeechS * 10) / 10}s of dialogue inside a ${overrun.duration}s clip \u2014 the line cannot fit (>${SPEECH_OVERRUN_RATIO}\xD7 the clip). Shorten the line, split the scene, or lengthen the clip duration`
3484
+ });
3485
+ continue;
3486
+ }
3487
+ checkSpeechExceedsExtract(ctx, n, entry);
2882
3488
  }
2883
3489
  }
2884
3490
  }
3491
+ function checkSpeechExceedsExtract(ctx, clip, entry) {
3492
+ const params = clip.params;
3493
+ if (params?.generate_audio !== true) return;
3494
+ const line = nativeDialogueOf(params.prompt);
3495
+ if (!line) return;
3496
+ const regionTag = /^s\d+(_r\d+)?_clip$/.exec(clip.id)?.[1] ?? "";
3497
+ const extractIdx = ctx.idToIndex.get(`s${entry.scene}${regionTag}_voextract`) ?? ctx.idToIndex.get(`s${entry.scene}_voextract`);
3498
+ const extract = extractIdx === void 0 ? null : ctx.canvas.nodes[extractIdx];
3499
+ const windowS = extract ? ffmpegTrimSeconds(extract) : null;
3500
+ if (windowS === null) return;
3501
+ const estSpeechS = line.split(/\s+/).filter(Boolean).length * secondsPerWord(entry);
3502
+ if (estSpeechS <= windowS * SPEECH_OVERRUN_RATIO) return;
3503
+ ctx.issues.push({
3504
+ path: `nodes[${ctx.idToIndex.get(clip.id) ?? -1}].params.prompt`,
3505
+ code: STAGE_CODES.SPEECH_EXCEEDS_EXTRACT,
3506
+ node_id: clip.id,
3507
+ message: `"${clip.id}"'s line is ~${Math.round(estSpeechS * 10) / 10}s of speech but its extract window (s${entry.scene}_voextract) is ${windowS}s \u2014 the read gets cut mid-word on the spine. Shorten the Dialogue line, lengthen the scene in prompt.json and re-scaffold, or raise the voextract \`-t\` when the read should carry over the next cutaway`
3508
+ });
3509
+ }
2885
3510
  var UI_IN_PROMPT_RE = /\bscreen[- ]?(?:recording|capture|grab|share)\b|\bapp (?:interface|screen)\b|\bphone screen overlay\b/i;
2886
3511
  function checkUiInPrompt(ctx) {
2887
3512
  for (const n of ctx.canvas.nodes) {
@@ -2954,9 +3579,9 @@ function checkOutputRef(ctx) {
2954
3579
  function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2955
3580
  for (const issue of err.issues) {
2956
3581
  const tail2 = pathToString(issue.path);
2957
- const path16 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
3582
+ const path17 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
2958
3583
  issues.push({
2959
- path: path16,
3584
+ path: path17,
2960
3585
  code,
2961
3586
  message: issue.message,
2962
3587
  received: issue.code === "invalid_type" ? issue.received : void 0,
@@ -2965,8 +3590,8 @@ function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2965
3590
  });
2966
3591
  }
2967
3592
  }
2968
- function pathToString(path16) {
2969
- return path16.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
3593
+ function pathToString(path17) {
3594
+ return path17.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
2970
3595
  }
2971
3596
  function buildDepGraph(canvas) {
2972
3597
  const graph = /* @__PURE__ */ new Map();
@@ -3065,6 +3690,12 @@ var Engine = class {
3065
3690
  async run(input, opts = {}) {
3066
3691
  const validation = await this.validateDeep(input);
3067
3692
  if (!validation.ok) throw new ValidationError(validation.issues);
3693
+ if (opts.max_credits !== void 0 && validation.estimatedCredits > opts.max_credits) {
3694
+ throw new RunAbortedError(
3695
+ "cost_cap",
3696
+ `estimated ${validation.estimatedCredits} credits exceeds the ${opts.max_credits}-credit cap \u2014 nothing was billed; raise --max-credits or shrink the canvas`
3697
+ );
3698
+ }
3068
3699
  const canvas = validation.canvas;
3069
3700
  const runId = opts.run_id ?? `r_${ulid()}`;
3070
3701
  const writer = new OutputWriter({ outputsDir: this.outputsDir, runId });
@@ -3115,7 +3746,17 @@ var Engine = class {
3115
3746
  const layers = topologicalLayers(graph);
3116
3747
  const limit = resolveConcurrency(opts.concurrency);
3117
3748
  for (const layer of layers) {
3749
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted before layer dispatch");
3750
+ if (opts.max_credits !== void 0 && counters.totalCredits > opts.max_credits) {
3751
+ throw new RunAbortedError(
3752
+ "cost_cap",
3753
+ `spent ${counters.totalCredits} credits, over the ${opts.max_credits}-credit cap \u2014 completed nodes are cached; raise --max-credits to continue where this stopped`
3754
+ );
3755
+ }
3118
3756
  const settled = await mapWithConcurrency(layer, limit, (nodeId) => {
3757
+ if (opts.signal?.aborted) {
3758
+ return Promise.reject(new RunAbortedError("signal", "run aborted before node dispatch"));
3759
+ }
3119
3760
  this.emitProgress(opts, { kind: "node_start", node_id: nodeId });
3120
3761
  return this.executeOne(canvas, nodeId, outputs, runId, writer, opts, needsBytes.has(nodeId)).then((r) => {
3121
3762
  if (r.cached) counters.cachedNodes++;
@@ -3138,10 +3779,12 @@ var Engine = class {
3138
3779
  settled.forEach((result, i) => {
3139
3780
  const nodeId = layer[i];
3140
3781
  if (result.status === "rejected" && nodeId) {
3782
+ if (result.reason instanceof RunAbortedError) return;
3141
3783
  failures.push({ nodeId, reason: result.reason });
3142
3784
  this.emitProgress(opts, { kind: "node_failed", node_id: nodeId });
3143
3785
  }
3144
3786
  });
3787
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted by signal");
3145
3788
  if (failures.length === 1 && failures[0]) throw failures[0].reason;
3146
3789
  if (failures.length > 1) throw new LayerExecutionError(failures);
3147
3790
  }
@@ -3214,7 +3857,7 @@ var Engine = class {
3214
3857
  log: this.log,
3215
3858
  signal: opts.signal
3216
3859
  };
3217
- const preparedForExec = def.location === "local" ? { ...prepared, resolvedInputs: await this.materializeLocalInputs(prepared.resolvedInputs) } : prepared;
3860
+ const preparedForExec = needsLocalMaterialization(def) ? { ...prepared, resolvedInputs: await this.materializeLocalInputs(prepared.resolvedInputs) } : prepared;
3218
3861
  const { parsedInputs, parsedParams } = parseNodeArgs(def, preparedForExec, node.id, node.type);
3219
3862
  const result = await invokeExecute(def, parsedInputs, parsedParams, ctx, node.id, node.type);
3220
3863
  const elapsed = Date.now() - t0;
@@ -3336,6 +3979,9 @@ async function invokeExecute(def, parsedInputs, parsedParams, ctx, nodeId, nodeT
3336
3979
  throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
3337
3980
  }
3338
3981
  }
3982
+ function needsLocalMaterialization(def) {
3983
+ return def.location === "local" && !def.passthroughRefs;
3984
+ }
3339
3985
  function pickFinalOutput(canvas, outputs) {
3340
3986
  if (canvas.output) {
3341
3987
  const node = outputs[canvas.output.node];
@@ -3351,7 +3997,7 @@ function computeNeedsLocalBytes(canvas, graph, registry) {
3351
3997
  const needs = /* @__PURE__ */ new Set();
3352
3998
  for (const [consumerId, deps] of graph) {
3353
3999
  const def = registry.get(typeById.get(consumerId) ?? "");
3354
- if (def?.location !== "local") continue;
4000
+ if (def?.location !== "local" || def.passthroughRefs) continue;
3355
4001
  for (const dep of deps) needs.add(dep);
3356
4002
  }
3357
4003
  return needs;
@@ -3390,11 +4036,11 @@ function hashInputs(inputs) {
3390
4036
  const out = {};
3391
4037
  for (const [k, v] of Object.entries(inputs)) {
3392
4038
  if (Array.isArray(v)) {
3393
- out[k] = v.map((el) => extractSha(el) ?? canonicalLiteral(el));
4039
+ out[k] = v.map((el) => extractSha(el) ?? canonicalLiteral(normalizeParamsForCacheKey(el)));
3394
4040
  } else {
3395
4041
  const s = extractSha(v);
3396
4042
  if (s !== null) out[k] = s;
3397
- else out[k] = canonicalLiteral(v);
4043
+ else out[k] = canonicalLiteral(normalizeParamsForCacheKey(v));
3398
4044
  }
3399
4045
  }
3400
4046
  return out;
@@ -3555,9 +4201,9 @@ var NodeRegistry = class {
3555
4201
 
3556
4202
  // src/engine/nodes/ingest.ts
3557
4203
  import { execFile as execFileCb, spawn } from "child_process";
3558
- import { mkdtemp, readdir, readFile as readFile2, rm, stat as stat2 } from "fs/promises";
4204
+ import { mkdtemp, readdir, readFile as readFile3, rm, stat as stat2 } from "fs/promises";
3559
4205
  import { tmpdir } from "os";
3560
- import path3 from "path";
4206
+ import path4 from "path";
3561
4207
  import { promisify } from "util";
3562
4208
  import { z as z5 } from "zod";
3563
4209
 
@@ -3915,8 +4561,8 @@ function resolveLocalPath(input) {
3915
4561
  `ingest: ~ path expansion is not supported (got "${input}"). Use an absolute path or a cwd-relative path.`
3916
4562
  );
3917
4563
  }
3918
- if (path3.isAbsolute(input)) return input;
3919
- return path3.resolve(process.cwd(), input);
4564
+ if (path4.isAbsolute(input)) return input;
4565
+ return path4.resolve(process.cwd(), input);
3920
4566
  }
3921
4567
  var EXT_TO_MIME = {
3922
4568
  png: "image/png",
@@ -3964,7 +4610,7 @@ function sniffSvg(buf) {
3964
4610
  return head.startsWith("<?xml") ? head.includes("<svg") : head.startsWith("<svg");
3965
4611
  }
3966
4612
  function inferMimeFromPath(absPath, sniffBytes) {
3967
- const ext = path3.extname(absPath).slice(1).toLowerCase();
4613
+ const ext = path4.extname(absPath).slice(1).toLowerCase();
3968
4614
  const fromExt = EXT_TO_MIME[ext];
3969
4615
  if (fromExt) return fromExt;
3970
4616
  const fromBytes = sniffImageMime(sniffBytes);
@@ -4103,7 +4749,7 @@ async function execLocalFile(params, ctx) {
4103
4749
  }
4104
4750
  let bytes;
4105
4751
  try {
4106
- bytes = await readFile2(absPath);
4752
+ bytes = await readFile3(absPath);
4107
4753
  } catch (e) {
4108
4754
  if (e.code === "EACCES") {
4109
4755
  throw localExecError(ctx, `permission_denied: ${absPath}`);
@@ -4154,7 +4800,7 @@ function localFileMetadata(args) {
4154
4800
  strategy: "local_file",
4155
4801
  ingested_at: (/* @__PURE__ */ new Date()).toISOString(),
4156
4802
  file_size: args.fileSize,
4157
- original_filename: path3.basename(args.absPath),
4803
+ original_filename: path4.basename(args.absPath),
4158
4804
  ...args.rasterizedFrom ? { rasterized_from: args.rasterizedFrom } : {},
4159
4805
  ...args.durationMs !== void 0 ? { duration_ms: args.durationMs } : {}
4160
4806
  };
@@ -4178,7 +4824,7 @@ async function execYtDlp(params, ctx) {
4178
4824
  if (params.expect !== "video" && params.expect !== "audio") {
4179
4825
  throw new Error(`ingest: yt_dlp only handles video/audio, got expect=${params.expect}`);
4180
4826
  }
4181
- const workDir = await mkdtemp(path3.join(tmpdir(), "ingest-yt-"));
4827
+ const workDir = await mkdtemp(path4.join(tmpdir(), "ingest-yt-"));
4182
4828
  try {
4183
4829
  const { filePath, info } = await runYtDlp({
4184
4830
  url: params.url,
@@ -4193,7 +4839,7 @@ async function execYtDlp(params, ctx) {
4193
4839
  `file_too_large: yt-dlp output for ${params.url} is ${downloadedStats.size} bytes (limit ${MAX_ASSET_BYTES})`
4194
4840
  );
4195
4841
  }
4196
- const bytes = await readFile2(filePath);
4842
+ const bytes = await readFile3(filePath);
4197
4843
  const kind = params.expect;
4198
4844
  const mime = YT_DLP_MIME[kind];
4199
4845
  const metadata = buildYtDlpMetadata(params.url, info);
@@ -4205,8 +4851,8 @@ async function execYtDlp(params, ctx) {
4205
4851
  }
4206
4852
  }
4207
4853
  async function runYtDlp(args) {
4208
- const outTemplate = path3.join(args.workDir, "out.%(ext)s");
4209
- const infoPath = path3.join(args.workDir, "out.info.json");
4854
+ const outTemplate = path4.join(args.workDir, "out.%(ext)s");
4855
+ const infoPath = path4.join(args.workDir, "out.info.json");
4210
4856
  const argv = [
4211
4857
  args.url,
4212
4858
  "--no-playlist",
@@ -4239,11 +4885,11 @@ ${tail(stderr, 40)}`)
4239
4885
  }
4240
4886
  let info = {};
4241
4887
  try {
4242
- const raw = await readFile2(infoPath, "utf-8");
4888
+ const raw = await readFile3(infoPath, "utf-8");
4243
4889
  info = JSON.parse(raw);
4244
4890
  } catch {
4245
4891
  }
4246
- return { filePath: path3.join(args.workDir, downloaded), info };
4892
+ return { filePath: path4.join(args.workDir, downloaded), info };
4247
4893
  }
4248
4894
  function buildYtDlpMetadata(sourceUrl, info) {
4249
4895
  const out = {
@@ -4332,9 +4978,9 @@ import { z as z6 } from "zod";
4332
4978
 
4333
4979
  // src/engine/nodes/local/lib/cli-runner.ts
4334
4980
  import { execFile as execFileCb2, spawn as spawn2 } from "child_process";
4335
- import { copyFile as copyFile2, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm2, stat as stat3 } from "fs/promises";
4981
+ import { copyFile as copyFile2, mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2, stat as stat3 } from "fs/promises";
4336
4982
  import { tmpdir as tmpdir2 } from "os";
4337
- import path4 from "path";
4983
+ import path5 from "path";
4338
4984
  import { promisify as promisify2 } from "util";
4339
4985
  var execFile2 = promisify2(execFileCb2);
4340
4986
  var DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
@@ -4361,7 +5007,7 @@ function mimeForExt(ext) {
4361
5007
  }
4362
5008
  function extForAssetRef(ref) {
4363
5009
  if (ref.path) {
4364
- const e = path4.extname(ref.path);
5010
+ const e = path5.extname(ref.path);
4365
5011
  if (e) return e;
4366
5012
  }
4367
5013
  const reverse = {
@@ -4383,14 +5029,14 @@ function planArrayInputSlot(tmpDir, slot, values, lookup, stagedInputs) {
4383
5029
  const ref = values[i];
4384
5030
  if (!ref) continue;
4385
5031
  if (!ref.path) throw new Error(`cli-runner: inputs.${slot}[${i}] has no local path`);
4386
- const dest = path4.join(tmpDir, `in_${slot}_${i}${extForAssetRef(ref)}`);
5032
+ const dest = path5.join(tmpDir, `in_${slot}_${i}${extForAssetRef(ref)}`);
4387
5033
  stagedInputs.push({ srcPath: ref.path, destPath: dest });
4388
5034
  lookup.set(`in.${slot}.${i}`, dest);
4389
5035
  }
4390
5036
  }
4391
5037
  function planSingleInputSlot(tmpDir, slot, ref, lookup, stagedInputs) {
4392
5038
  if (!ref.path) throw new Error(`cli-runner: inputs.${slot} has no local path`);
4393
- const dest = path4.join(tmpDir, `in_${slot}${extForAssetRef(ref)}`);
5039
+ const dest = path5.join(tmpDir, `in_${slot}${extForAssetRef(ref)}`);
4394
5040
  stagedInputs.push({ srcPath: ref.path, destPath: dest });
4395
5041
  lookup.set(`in.${slot}`, dest);
4396
5042
  }
@@ -4398,7 +5044,7 @@ function planOutputs(tmpDir, outputs, lookup) {
4398
5044
  const outputPaths = [];
4399
5045
  for (const [name, spec] of Object.entries(outputs)) {
4400
5046
  const ext = spec.ext.startsWith(".") ? spec.ext : `.${spec.ext}`;
4401
- const absPath = path4.join(tmpDir, `out_${name}${ext}`);
5047
+ const absPath = path5.join(tmpDir, `out_${name}${ext}`);
4402
5048
  outputPaths.push({ name, absPath, spec });
4403
5049
  lookup.set(`out.${name}`, absPath);
4404
5050
  }
@@ -4439,8 +5085,8 @@ function rejectRawPaths(substituted, original, stagingDir) {
4439
5085
  throw new Error(`cli-runner: home-relative path "${original}" not allowed in args.`);
4440
5086
  }
4441
5087
  if (substituted.startsWith("/")) {
4442
- const resolved = path4.resolve(substituted);
4443
- if (!resolved.startsWith(`${stagingDir}${path4.sep}`) && resolved !== stagingDir) {
5088
+ const resolved = path5.resolve(substituted);
5089
+ if (!resolved.startsWith(`${stagingDir}${path5.sep}`) && resolved !== stagingDir) {
4444
5090
  throw new Error(
4445
5091
  `cli-runner: raw filesystem path "${original}" not allowed \u2014 declare an input slot and use {{in.<slot>}} instead.`
4446
5092
  );
@@ -4490,7 +5136,7 @@ function tailLines(text, maxLines) {
4490
5136
  }
4491
5137
  async function runCli(opts) {
4492
5138
  const { bin, args, inputs, outputs, ctx, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
4493
- const tmpDir = await mkdtemp2(path4.join(tmpdir2(), `cli-${bin.replace(/[^a-z0-9]/gi, "")}-`));
5139
+ const tmpDir = await mkdtemp2(path5.join(tmpdir2(), `cli-${bin.replace(/[^a-z0-9]/gi, "")}-`));
4494
5140
  try {
4495
5141
  const { lookup, stagedInputs, outputPaths } = planPlaceholders(tmpDir, inputs, outputs);
4496
5142
  await stageInputs(stagedInputs);
@@ -4512,7 +5158,7 @@ ${tailLines(stderr, 40)}`);
4512
5158
  if (!s?.isFile() || s.size === 0) {
4513
5159
  throw new Error(`cli-runner: declared output "${name}" missing or empty at ${absPath}`);
4514
5160
  }
4515
- const bytes = await readFile3(absPath);
5161
+ const bytes = await readFile4(absPath);
4516
5162
  const ref = await ctx.assets.ingestBytes({
4517
5163
  bytes: Buffer.from(bytes),
4518
5164
  kind: spec.kind,
@@ -4552,6 +5198,12 @@ var Track = z6.object({
4552
5198
  slot: z6.string().min(1),
4553
5199
  /** When this track starts on the timeline, seconds from 0. */
4554
5200
  start_s: z6.number().min(0),
5201
+ /**
5202
+ * Optional hard cap on this track's length, seconds. The clip is trimmed BEFORE
5203
+ * placement, so a source that runs long (an over-long voice extract, a converted
5204
+ * track that came back oversized) cannot bleed into the next track's window.
5205
+ */
5206
+ duration_s: z6.number().positive().optional(),
4555
5207
  /** Optional level adjustment in dB (negative ducks, e.g. a music bed at -12). */
4556
5208
  gain_db: z6.number().optional()
4557
5209
  }).strict();
@@ -4608,7 +5260,10 @@ function buildAudioTimelineArgs(params) {
4608
5260
  params.tracks.forEach((track, i) => {
4609
5261
  inputArgs.push("-i", `{{in.${track.slot}}}`);
4610
5262
  const delayMs = Math.round(track.start_s * 1e3);
4611
- const steps = [`adelay=${delayMs}:all=1`];
5263
+ const steps = [
5264
+ ...track.duration_s !== void 0 ? [`atrim=0:${track.duration_s}`] : [],
5265
+ `adelay=${delayMs}:all=1`
5266
+ ];
4612
5267
  if (track.gain_db !== void 0) steps.push(`volume=${track.gain_db}dB`);
4613
5268
  const label = `a${i}`;
4614
5269
  filterChains.push(`[${i}:a]${steps.join(",")}[${label}]`);
@@ -4626,11 +5281,11 @@ function buildAudioTimelineArgs(params) {
4626
5281
  }
4627
5282
  var audioTimelineNode = defineNode({
4628
5283
  id: "audio_timeline",
4629
- version: "1.1.0",
5284
+ version: "1.2.0",
4630
5285
  category: "audio",
4631
5286
  location: "local",
4632
5287
  summary: "Place and mix several audio clips onto one timeline: each track starts at a given second (optionally level-adjusted in dB), then they're combined into a single track. Built for laying a music bed plus timed voiceover lines and sound effects under a video.",
4633
- when_to_use: "Use to assemble a full audio bed from separately-generated clips \u2014 e.g. a `music` bed at 0 (ducked via `gain_db: -12`), each scene's `tts` voiceover at its scene start, and `sound_effect` hits at their timestamps. Wire each clip as `inputs.<slot>` (audio AssetRef) and list it in `params.tracks` with `{slot, start_s, gain_db?}`. Set `total_ms` to pin the final length to the video. Requires `ffmpeg` on PATH.",
5288
+ when_to_use: "Use to assemble a full audio bed from separately-generated clips \u2014 e.g. a `music` bed at 0 (ducked via `gain_db: -12`), each scene's `tts` voiceover at its scene start, and `sound_effect` hits at their timestamps. Wire each clip as `inputs.<slot>` (audio AssetRef) and list it in `params.tracks` with `{slot, start_s, duration_s?, gain_db?}` (`duration_s` hard-caps a clip so it can't bleed into the next track's window). Set `total_ms` to pin the final length to the video. Requires `ffmpeg` on PATH.",
4634
5289
  inputs: AudioTimelineInputs,
4635
5290
  params: AudioTimelineParams,
4636
5291
  outputs: AudioTimelineOutputs,
@@ -4682,19 +5337,56 @@ var audioTimelineNode = defineNode({
4682
5337
  }
4683
5338
  });
4684
5339
 
4685
- // src/engine/nodes/local/ffmpeg.ts
5340
+ // src/engine/nodes/local/collect.ts
4686
5341
  import { z as z7 } from "zod";
5342
+ var collectNode = defineNode({
5343
+ id: "collect",
5344
+ version: "1.0.0",
5345
+ category: "data",
5346
+ location: "local",
5347
+ passthroughRefs: true,
5348
+ summary: "Gather images from multiple upstream nodes into one ordered array \u2014 the standard terminal for multi-variant canvases whose final output is several images.",
5349
+ when_to_use: "Point the canvas `output` at this node when several independent branches (e.g. one image_generate per scene/variant) must ALL be finals. Wire `inputs.images` as an array of refs like `$ref:gen_billboard_03.images#0` \u2014 each final inherits its producer node id as its label (or set `params.labels` to override), so variants stay identifiable in the dashboard and selection.",
5350
+ inputs: z7.object({ images: z7.array(ImageRef).min(1) }).strict(),
5351
+ params: z7.object({ labels: z7.array(z7.string().min(1)).min(1).optional() }).strict(),
5352
+ outputs: z7.object({ images: z7.array(ImageRef).min(1) }).strict(),
5353
+ outputKinds: { images: "image" },
5354
+ cost: () => ({ credits: 0, seconds_estimate: 0 }),
5355
+ // Arity is only knowable at validate time when `images` is a literal array;
5356
+ // a single `$ref:` string to an upstream array output defers to runtime.
5357
+ validateExtra: ({ rawParams, rawInputs }) => {
5358
+ const labels = rawParams?.labels;
5359
+ if (!Array.isArray(labels)) return [];
5360
+ if (new Set(labels).size !== labels.length) {
5361
+ return [{ path: "params.labels", message: "labels must be unique \u2014 each names one output variant" }];
5362
+ }
5363
+ const images = rawInputs?.images;
5364
+ if (Array.isArray(images) && labels.length !== images.length) {
5365
+ return [
5366
+ {
5367
+ path: "params.labels",
5368
+ message: `labels has ${labels.length} entries but ${images.length} images are wired \u2014 provide one label per image`
5369
+ }
5370
+ ];
5371
+ }
5372
+ return [];
5373
+ },
5374
+ execute: ({ inputs }) => Promise.resolve({ images: inputs.images })
5375
+ });
5376
+
5377
+ // src/engine/nodes/local/ffmpeg.ts
5378
+ import { z as z8 } from "zod";
4687
5379
  var FFMPEG_BIN2 = "ffmpeg";
4688
- var OutputDecl = z7.object({
4689
- kind: z7.enum(["image", "video", "audio"]),
4690
- ext: z7.string().min(1).max(8)
5380
+ var OutputDecl = z8.object({
5381
+ kind: z8.enum(["image", "video", "audio"]),
5382
+ ext: z8.string().min(1).max(8)
4691
5383
  }).strict();
4692
- var FfmpegParams = z7.object({
4693
- args: z7.array(z7.string()).min(1),
4694
- outputs: z7.record(z7.string(), OutputDecl).default({})
5384
+ var FfmpegParams = z8.object({
5385
+ args: z8.array(z8.string()).min(1),
5386
+ outputs: z8.record(z8.string(), OutputDecl).default({})
4695
5387
  }).strict();
4696
- var FfmpegInputs = z7.record(z7.string(), z7.unknown());
4697
- var FfmpegOutputs = z7.record(z7.string(), z7.custom());
5388
+ var FfmpegInputs = z8.record(z8.string(), z8.unknown());
5389
+ var FfmpegOutputs = z8.record(z8.string(), z8.custom());
4698
5390
  var ffmpegNode = defineNode({
4699
5391
  id: "ffmpeg",
4700
5392
  version: "2.0.0",
@@ -4724,17 +5416,17 @@ var ffmpegNode = defineNode({
4724
5416
  import { mkdtemp as mkdtemp3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
4725
5417
  import { createRequire } from "module";
4726
5418
  import { tmpdir as tmpdir3 } from "os";
4727
- import path6 from "path";
4728
- import { z as z8 } from "zod";
5419
+ import path7 from "path";
5420
+ import { z as z9 } from "zod";
4729
5421
 
4730
5422
  // src/engine/nodes/local/lib/assets.ts
4731
- import { copyFile as copyFile3, readFile as readFile4 } from "fs/promises";
4732
- import path5 from "path";
5423
+ import { copyFile as copyFile3, readFile as readFile5 } from "fs/promises";
5424
+ import path6 from "path";
4733
5425
  async function stageAsset(ref, destDir, filename) {
4734
5426
  if (!ref.path) {
4735
5427
  throw new Error(`stageAsset: ref (${ref.kind}/${ref.mime}) has no local path`);
4736
5428
  }
4737
- const dest = path5.join(destDir, filename);
5429
+ const dest = path6.join(destDir, filename);
4738
5430
  await copyFile3(ref.path, dest);
4739
5431
  return dest;
4740
5432
  }
@@ -4743,7 +5435,7 @@ async function refToUrl(ref) {
4743
5435
  if (!ref.path) {
4744
5436
  throw new Error("refToUrl: AssetRef has neither url nor path");
4745
5437
  }
4746
- const bytes = await readFile4(ref.path);
5438
+ const bytes = await readFile5(ref.path);
4747
5439
  return `data:${ref.mime};base64,${bytes.toString("base64")}`;
4748
5440
  }
4749
5441
  var ASSET_KINDS = /* @__PURE__ */ new Set(["image", "video", "audio", "json", "text", "font"]);
@@ -4761,15 +5453,15 @@ var DEFAULT_SPECIMEN = [
4761
5453
  "abcdefghijklmnopqrstuvwxyz",
4762
5454
  `0123456789 !?&@#$%().,:;'"-`
4763
5455
  ].join("\n");
4764
- var FontSpecimenParams = z8.object({
4765
- text: z8.string().min(1).max(2e3).optional().default(DEFAULT_SPECIMEN),
4766
- font_size: z8.number().int().min(8).max(512).optional().default(72),
4767
- padding: z8.number().int().min(0).max(512).optional().default(64),
4768
- line_height: z8.number().min(0.8).max(3).optional().default(1.35),
4769
- max_width: z8.number().int().min(256).max(4096).optional()
5456
+ var FontSpecimenParams = z9.object({
5457
+ text: z9.string().min(1).max(2e3).optional().default(DEFAULT_SPECIMEN),
5458
+ font_size: z9.number().int().min(8).max(512).optional().default(72),
5459
+ padding: z9.number().int().min(0).max(512).optional().default(64),
5460
+ line_height: z9.number().min(0.8).max(3).optional().default(1.35),
5461
+ max_width: z9.number().int().min(256).max(4096).optional()
4770
5462
  }).strict();
4771
- var FontSpecimenInputs = z8.object({ font: FontRef }).loose();
4772
- var FontSpecimenOutputs = z8.object({ image: ImageRef }).strict();
5463
+ var FontSpecimenInputs = z9.object({ font: FontRef }).loose();
5464
+ var FontSpecimenOutputs = z9.object({ image: ImageRef }).strict();
4773
5465
  var DEVICE_SCALE_FACTOR = 2;
4774
5466
  var PAGE_TIMEOUT_MS = 3e4;
4775
5467
  function escapeHtml(text) {
@@ -4817,11 +5509,11 @@ var fontSpecimenNode = defineNode({
4817
5509
  outputKinds: { image: "image" },
4818
5510
  cost: () => ({ credits: 0, seconds_estimate: 5 }),
4819
5511
  async execute({ inputs, params, ctx }) {
4820
- const tmp = await mkdtemp3(path6.join(tmpdir3(), "font-specimen-"));
5512
+ const tmp = await mkdtemp3(path7.join(tmpdir3(), "font-specimen-"));
4821
5513
  try {
4822
5514
  const fontFilename = `font.${extForMime(inputs.font.mime)}`;
4823
5515
  await stageAsset(inputs.font, tmp, fontFilename);
4824
- const entryPath = path6.join(tmp, "index.html");
5516
+ const entryPath = path7.join(tmp, "index.html");
4825
5517
  await writeFile3(entryPath, buildSpecimenHtml(params, fontFilename), "utf-8");
4826
5518
  ctx.log(`rendering specimen (${params.font_size}px, ${params.text.split("\n").length} lines)`);
4827
5519
  const pwSpecifier = ["play", "wright"].join("");
@@ -4905,16 +5597,16 @@ var fontSpecimenNode = defineNode({
4905
5597
 
4906
5598
  // src/engine/nodes/local/hyperframe.ts
4907
5599
  import { execFile as execFile4 } from "child_process";
4908
- import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile8, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
5600
+ import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile9, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
4909
5601
  import { createRequire as createRequire2 } from "module";
4910
5602
  import { cpus, tmpdir as tmpdir4 } from "os";
4911
- import path11 from "path";
5603
+ import path12 from "path";
4912
5604
  import { promisify as promisify4 } from "util";
4913
- import { z as z10 } from "zod";
5605
+ import { z as z11 } from "zod";
4914
5606
 
4915
5607
  // src/engine/engine/composition-hash.ts
4916
- import { readdir as readdir2, readFile as readFile5, stat as stat4 } from "fs/promises";
4917
- import path7 from "path";
5608
+ import { readdir as readdir2, readFile as readFile6, stat as stat4 } from "fs/promises";
5609
+ import path8 from "path";
4918
5610
  var SKIP_DIRS = /* @__PURE__ */ new Set([".cache", ".git", "node_modules", "dist", "build", ".next", ".turbo"]);
4919
5611
  function isSkippedName(name) {
4920
5612
  if (name.startsWith(".")) return true;
@@ -4931,79 +5623,79 @@ async function collectFiles(root, current) {
4931
5623
  const names = await readdir2(current);
4932
5624
  for (const name of names) {
4933
5625
  if (isSkippedName(name)) continue;
4934
- const abs = path7.join(current, name);
5626
+ const abs = path8.join(current, name);
4935
5627
  const s = await stat4(abs);
4936
5628
  if (s.isDirectory()) {
4937
5629
  out.push(...await collectFiles(root, abs));
4938
5630
  continue;
4939
5631
  }
4940
5632
  if (!s.isFile()) continue;
4941
- const bytes = await readFile5(abs);
4942
- const relPath = path7.relative(root, abs).split(path7.sep).join("/");
5633
+ const bytes = await readFile6(abs);
5634
+ const relPath = path8.relative(root, abs).split(path8.sep).join("/");
4943
5635
  out.push({ relPath, contentSha: sha256Hex(bytes) });
4944
5636
  }
4945
5637
  return out;
4946
5638
  }
4947
5639
 
4948
5640
  // src/engine/engine/composition-meta.ts
4949
- import { readFile as readFile6 } from "fs/promises";
4950
- import path8 from "path";
4951
- import { z as z9 } from "zod";
4952
- var InputKind = z9.enum(["video", "image", "audio", "json"]);
4953
- var InputSpec = z9.object({
5641
+ import { readFile as readFile7 } from "fs/promises";
5642
+ import path9 from "path";
5643
+ import { z as z10 } from "zod";
5644
+ var InputKind = z10.enum(["video", "image", "audio", "json"]);
5645
+ var InputSpec = z10.object({
4954
5646
  kind: InputKind,
4955
- required: z9.boolean().optional().default(false),
5647
+ required: z10.boolean().optional().default(false),
4956
5648
  // Filename the composition's HTML references (e.g. `input.mp4`, `logo.png`).
4957
5649
  // Defaults to `<key><ext>` derived from the kind.
4958
- staged_as: z9.string().min(1).optional(),
4959
- description: z9.string().optional()
5650
+ staged_as: z10.string().min(1).optional(),
5651
+ description: z10.string().optional()
4960
5652
  }).strict();
4961
5653
  var ParamSpecBase = {
4962
- required: z9.boolean().optional().default(false),
4963
- description: z9.string().optional()
5654
+ required: z10.boolean().optional().default(false),
5655
+ description: z10.string().optional()
4964
5656
  };
4965
- var StringParam = z9.object({
5657
+ var StringParam = z10.object({
4966
5658
  ...ParamSpecBase,
4967
- kind: z9.literal("string"),
4968
- default: z9.string().optional(),
4969
- enum: z9.array(z9.string()).optional()
5659
+ kind: z10.literal("string"),
5660
+ default: z10.string().optional(),
5661
+ enum: z10.array(z10.string()).optional()
4970
5662
  }).strict();
4971
- var IntegerParam = z9.object({
5663
+ var IntegerParam = z10.object({
4972
5664
  ...ParamSpecBase,
4973
- kind: z9.literal("integer"),
4974
- default: z9.number().int().optional(),
4975
- min: z9.number().int().optional(),
4976
- max: z9.number().int().optional()
5665
+ kind: z10.literal("integer"),
5666
+ default: z10.number().int().optional(),
5667
+ min: z10.number().int().optional(),
5668
+ max: z10.number().int().optional()
4977
5669
  }).strict();
4978
- var NumberParam = z9.object({
5670
+ var NumberParam = z10.object({
4979
5671
  ...ParamSpecBase,
4980
- kind: z9.literal("number"),
4981
- default: z9.number().optional(),
4982
- min: z9.number().optional(),
4983
- max: z9.number().optional()
5672
+ kind: z10.literal("number"),
5673
+ default: z10.number().optional(),
5674
+ min: z10.number().optional(),
5675
+ max: z10.number().optional()
4984
5676
  }).strict();
4985
- var BooleanParam = z9.object({
5677
+ var BooleanParam = z10.object({
4986
5678
  ...ParamSpecBase,
4987
- kind: z9.literal("boolean"),
4988
- default: z9.boolean().optional()
5679
+ kind: z10.literal("boolean"),
5680
+ default: z10.boolean().optional()
4989
5681
  }).strict();
4990
- var ColorParam = z9.object({
5682
+ var ColorParam = z10.object({
4991
5683
  ...ParamSpecBase,
4992
- kind: z9.literal("color"),
4993
- default: z9.string().optional()
5684
+ kind: z10.literal("color"),
5685
+ default: z10.string().optional()
4994
5686
  }).strict();
4995
- var ImageParam = z9.object({
5687
+ var ImageParam = z10.object({
4996
5688
  ...ParamSpecBase,
4997
- kind: z9.literal("image"),
4998
- default: z9.string().optional()
5689
+ kind: z10.literal("image"),
5690
+ default: z10.string().optional()
4999
5691
  }).strict();
5000
- var JsonParam = z9.object({
5692
+ var JsonParam = z10.object({
5001
5693
  ...ParamSpecBase,
5002
- kind: z9.literal("json"),
5003
- schema: z9.unknown().optional(),
5004
- default: z9.unknown().optional()
5694
+ kind: z10.literal("json"),
5695
+ schema: z10.unknown().optional(),
5696
+ default: z10.unknown().optional()
5005
5697
  }).strict();
5006
- var ParamSpec = z9.discriminatedUnion("kind", [
5698
+ var ParamSpec = z10.discriminatedUnion("kind", [
5007
5699
  StringParam,
5008
5700
  IntegerParam,
5009
5701
  NumberParam,
@@ -5012,22 +5704,22 @@ var ParamSpec = z9.discriminatedUnion("kind", [
5012
5704
  ImageParam,
5013
5705
  JsonParam
5014
5706
  ]);
5015
- var CompositionMetaSchema = z9.object({
5016
- id: z9.string().min(1),
5017
- title: z9.string().min(1),
5018
- description: z9.string().optional(),
5019
- width: z9.number().int().positive(),
5020
- height: z9.number().int().positive(),
5021
- fps: z9.number().int().positive().default(30),
5022
- default_duration: z9.number().positive().default(10),
5023
- inputs: z9.record(z9.string(), InputSpec).default({}),
5024
- params: z9.record(z9.string(), ParamSpec).default({})
5707
+ var CompositionMetaSchema = z10.object({
5708
+ id: z10.string().min(1),
5709
+ title: z10.string().min(1),
5710
+ description: z10.string().optional(),
5711
+ width: z10.number().int().positive(),
5712
+ height: z10.number().int().positive(),
5713
+ fps: z10.number().int().positive().default(30),
5714
+ default_duration: z10.number().positive().default(10),
5715
+ inputs: z10.record(z10.string(), InputSpec).default({}),
5716
+ params: z10.record(z10.string(), ParamSpec).default({})
5025
5717
  }).strict();
5026
5718
  async function loadCompositionMeta(compositionDir) {
5027
- const metaPath = path8.join(compositionDir, "meta.json");
5719
+ const metaPath = path9.join(compositionDir, "meta.json");
5028
5720
  let raw;
5029
5721
  try {
5030
- raw = await readFile6(metaPath, "utf-8");
5722
+ raw = await readFile7(metaPath, "utf-8");
5031
5723
  } catch (e) {
5032
5724
  throw new Error(`composition meta: cannot read ${metaPath} (${e.message})`);
5033
5725
  }
@@ -5049,39 +5741,39 @@ function buildParamsSchema(meta) {
5049
5741
  for (const [name, spec] of Object.entries(meta.params)) {
5050
5742
  shape[name] = buildParamFieldSchema(name, spec);
5051
5743
  }
5052
- return z9.object(shape).strict();
5744
+ return z10.object(shape).strict();
5053
5745
  }
5054
5746
  function buildParamFieldSchema(name, spec) {
5055
5747
  switch (spec.kind) {
5056
5748
  case "string": {
5057
- const s = spec.enum && spec.enum.length > 0 ? z9.enum(spec.enum) : z9.string();
5749
+ const s = spec.enum && spec.enum.length > 0 ? z10.enum(spec.enum) : z10.string();
5058
5750
  return finalize(s, spec.default, spec.required);
5059
5751
  }
5060
5752
  case "integer": {
5061
- let s = z9.number().int();
5753
+ let s = z10.number().int();
5062
5754
  if (spec.min !== void 0) s = s.min(spec.min);
5063
5755
  if (spec.max !== void 0) s = s.max(spec.max);
5064
5756
  return finalize(s, spec.default, spec.required);
5065
5757
  }
5066
5758
  case "number": {
5067
- let s = z9.number();
5759
+ let s = z10.number();
5068
5760
  if (spec.min !== void 0) s = s.min(spec.min);
5069
5761
  if (spec.max !== void 0) s = s.max(spec.max);
5070
5762
  return finalize(s, spec.default, spec.required);
5071
5763
  }
5072
5764
  case "boolean":
5073
- return finalize(z9.boolean(), spec.default, spec.required);
5765
+ return finalize(z10.boolean(), spec.default, spec.required);
5074
5766
  case "color": {
5075
- const s = z9.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
5767
+ const s = z10.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
5076
5768
  message: `param "${name}": must be a 3/6/8-digit hex color (e.g. "#ff0066")`
5077
5769
  });
5078
5770
  return finalize(s, spec.default, spec.required);
5079
5771
  }
5080
5772
  case "image":
5081
- return finalize(z9.union([z9.string().min(1), z9.record(z9.string(), z9.unknown())]), spec.default, spec.required);
5773
+ return finalize(z10.union([z10.string().min(1), z10.record(z10.string(), z10.unknown())]), spec.default, spec.required);
5082
5774
  case "json":
5083
5775
  return finalize(
5084
- z9.unknown().refine((v) => v !== void 0, { message: `param "${name}" is required` }),
5776
+ z10.unknown().refine((v) => v !== void 0, { message: `param "${name}" is required` }),
5085
5777
  spec.default,
5086
5778
  spec.required
5087
5779
  );
@@ -5105,8 +5797,8 @@ function defaultFilenameForInput(key, kind) {
5105
5797
 
5106
5798
  // src/engine/nodes/local/lib/hyperframe-check.ts
5107
5799
  import { execFile as execFile3 } from "child_process";
5108
- import { readFile as readFile7 } from "fs/promises";
5109
- import path9 from "path";
5800
+ import { readFile as readFile8 } from "fs/promises";
5801
+ import path10 from "path";
5110
5802
  import { promisify as promisify3 } from "util";
5111
5803
  var execFileAsync = promisify3(execFile3);
5112
5804
  var NEVER_BLOCK = [
@@ -5258,7 +5950,7 @@ ${detail}`);
5258
5950
  }
5259
5951
  let indexHtml = "";
5260
5952
  try {
5261
- indexHtml = await readFile7(path9.join(dir, "index.html"), "utf-8");
5953
+ indexHtml = await readFile8(path10.join(dir, "index.html"), "utf-8");
5262
5954
  } catch {
5263
5955
  indexHtml = "";
5264
5956
  }
@@ -5323,9 +6015,9 @@ ${stderr.slice(0, 1500)}`;
5323
6015
 
5324
6016
  // src/engine/nodes/local/lib/hyperframe-meta.ts
5325
6017
  import { writeFile as writeFile4 } from "fs/promises";
5326
- import path10 from "path";
6018
+ import path11 from "path";
5327
6019
  async function ensureHyperframesMetaJson(tmp, nodeId, meta, duration) {
5328
- const metaPath = path10.join(tmp, "meta.json");
6020
+ const metaPath = path11.join(tmp, "meta.json");
5329
6021
  await writeFile4(
5330
6022
  metaPath,
5331
6023
  JSON.stringify(
@@ -5381,17 +6073,17 @@ function literalize(value) {
5381
6073
  // src/engine/nodes/local/hyperframe.ts
5382
6074
  var execFileAsync2 = promisify4(execFile4);
5383
6075
  var require_2 = createRequire2(import.meta.url);
5384
- var HyperframeParams = z10.object({
5385
- composition: z10.string().min(1),
6076
+ var HyperframeParams = z11.object({
6077
+ composition: z11.string().min(1),
5386
6078
  // Output container. mp4 (default) for delivery; webm/mov render WITH
5387
6079
  // transparency (alpha) when the composition background is transparent —
5388
6080
  // use for motion-graphic overlays dropped into Premiere/AE/Nuke.
5389
- format: z10.enum(["mp4", "webm", "mov"]).optional().default("mp4"),
5390
- timeout_ms: z10.number().int().positive().optional().default(10 * 60 * 1e3)
5391
- }).catchall(z10.unknown());
5392
- var HyperframeInputs = z10.record(z10.string(), z10.custom()).optional().default({});
5393
- var HyperframeOutputs = z10.object({
5394
- video: z10.custom()
6081
+ format: z11.enum(["mp4", "webm", "mov"]).optional().default("mp4"),
6082
+ timeout_ms: z11.number().int().positive().optional().default(10 * 60 * 1e3)
6083
+ }).catchall(z11.unknown());
6084
+ var HyperframeInputs = z11.record(z11.string(), z11.custom()).optional().default({});
6085
+ var HyperframeOutputs = z11.object({
6086
+ video: z11.custom()
5395
6087
  }).strict();
5396
6088
  var NODE_OWNED_PARAM_KEYS = /* @__PURE__ */ new Set(["composition", "format", "timeout_ms"]);
5397
6089
  var MIME_BY_FORMAT = {
@@ -5425,7 +6117,7 @@ var hyperframeRenderNode = defineNode({
5425
6117
  const compositionDir = await resolveCompositionDir(params.composition);
5426
6118
  const meta = await loadCompositionMeta(compositionDir);
5427
6119
  const compositionParams = validateAndParseDynamicParams(meta, params);
5428
- const tmp = await mkdtemp4(path11.join(tmpdir4(), "hf-render-"));
6120
+ const tmp = await mkdtemp4(path12.join(tmpdir4(), "hf-render-"));
5429
6121
  try {
5430
6122
  await copyComposition(compositionDir, tmp);
5431
6123
  await vendorGsap(tmp, ctx);
@@ -5435,9 +6127,9 @@ var hyperframeRenderNode = defineNode({
5435
6127
  await substituteCompositionFiles(tmp, substitutionValues);
5436
6128
  await ensureHyperframesMetaJson(tmp, ctx.nodeId, meta, duration);
5437
6129
  await runHyperframesCheck({ dir: tmp, nodeId: "hyperframe_render", ctx, timeoutMs: params.timeout_ms });
5438
- const outputPath = path11.join(tmp, `output.${params.format}`);
6130
+ const outputPath = path12.join(tmp, `output.${params.format}`);
5439
6131
  await runRender({ tmp, outputPath, params, meta, ctx });
5440
- const bytes = await readFile8(outputPath);
6132
+ const bytes = await readFile9(outputPath);
5441
6133
  ctx.log(`rendered ${bytes.length} bytes`);
5442
6134
  const ref = await ctx.assets.ingestBytes({
5443
6135
  bytes: Buffer.from(bytes),
@@ -5459,10 +6151,10 @@ var hyperframeRenderNode = defineNode({
5459
6151
  }
5460
6152
  });
5461
6153
  async function resolveCompositionDir(composition) {
5462
- const compositionPath = path11.isAbsolute(composition) ? composition : path11.resolve(process.cwd(), composition);
6154
+ const compositionPath = path12.isAbsolute(composition) ? composition : path12.resolve(process.cwd(), composition);
5463
6155
  const s = await stat5(compositionPath);
5464
6156
  if (s.isDirectory()) return compositionPath;
5465
- return path11.dirname(compositionPath);
6157
+ return path12.dirname(compositionPath);
5466
6158
  }
5467
6159
  async function validateComposition(rawParams) {
5468
6160
  const issues = await validateCompositionParams(rawParams);
@@ -5544,7 +6236,7 @@ async function copyComposition(srcDir, destDir) {
5544
6236
  await cp(srcDir, destDir, {
5545
6237
  recursive: true,
5546
6238
  filter: (src) => {
5547
- const name = path11.basename(src);
6239
+ const name = path12.basename(src);
5548
6240
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
5549
6241
  return true;
5550
6242
  }
@@ -5553,7 +6245,7 @@ async function copyComposition(srcDir, destDir) {
5553
6245
  async function vendorGsap(tmp, ctx) {
5554
6246
  try {
5555
6247
  const gsapMin = require_2.resolve("gsap/dist/gsap.min.js");
5556
- await copyFile4(gsapMin, path11.join(tmp, "gsap.min.js"));
6248
+ await copyFile4(gsapMin, path12.join(tmp, "gsap.min.js"));
5557
6249
  } catch (e) {
5558
6250
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
5559
6251
  }
@@ -5568,7 +6260,7 @@ async function stageInputs2(tmp, inputs, meta, ctx) {
5568
6260
  await stageAsset(ref, tmp, filename);
5569
6261
  ctx.log(`staged ${spec.kind} \u2192 ${filename}`);
5570
6262
  if (spec.kind === "video" && primaryDuration === null) {
5571
- primaryDuration = await probeDurationSeconds(path11.join(tmp, filename));
6263
+ primaryDuration = await probeDurationSeconds(path12.join(tmp, filename));
5572
6264
  }
5573
6265
  }
5574
6266
  return primaryDuration;
@@ -5614,8 +6306,8 @@ function coerceImageParam(value) {
5614
6306
  throw new Error("hyperframe_render: image param must be a URL string or AssetRef");
5615
6307
  }
5616
6308
  async function substituteCompositionFiles(tmp, values) {
5617
- const entryPath = path11.join(tmp, "index.html");
5618
- const original = await readFile8(entryPath, "utf-8");
6309
+ const entryPath = path12.join(tmp, "index.html");
6310
+ const original = await readFile9(entryPath, "utf-8");
5619
6311
  const { output, missing } = substituteVariables(original, values);
5620
6312
  if (missing.length > 0) {
5621
6313
  throw new Error(
@@ -5631,7 +6323,7 @@ function workerCount() {
5631
6323
  async function runRender(opts) {
5632
6324
  const { tmp, outputPath, params, meta, ctx } = opts;
5633
6325
  const args = buildRenderArgs(tmp, outputPath, meta, params.format);
5634
- ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path11.basename(tmp)}`);
6326
+ ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path12.basename(tmp)}`);
5635
6327
  try {
5636
6328
  await execFileAsync2("npx", args, { timeout: params.timeout_ms, maxBuffer: 64 * 1024 * 1024 });
5637
6329
  } catch (e) {
@@ -5675,28 +6367,28 @@ async function probeDurationSeconds(filePath) {
5675
6367
 
5676
6368
  // src/engine/nodes/local/hyperframe-snapshot.ts
5677
6369
  import { execFile as execFile5 } from "child_process";
5678
- import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile9, rm as rm5, writeFile as writeFile6 } from "fs/promises";
6370
+ import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile10, rm as rm5, writeFile as writeFile6 } from "fs/promises";
5679
6371
  import { createRequire as createRequire3 } from "module";
5680
6372
  import { tmpdir as tmpdir5 } from "os";
5681
- import path12 from "path";
6373
+ import path13 from "path";
5682
6374
  import { promisify as promisify5 } from "util";
5683
- import { z as z11 } from "zod";
6375
+ import { z as z12 } from "zod";
5684
6376
  var _execFileAsync = promisify5(execFile5);
5685
6377
  var require_3 = createRequire3(import.meta.url);
5686
- var WaitForSpec = z11.discriminatedUnion("kind", [
5687
- z11.object({ kind: z11.literal("auto") }),
5688
- z11.object({ kind: z11.literal("selector"), value: z11.string().min(1) }),
5689
- z11.object({ kind: z11.literal("function"), value: z11.string().min(1) }),
5690
- z11.object({ kind: z11.literal("timeout"), ms: z11.number().int().min(0).max(6e4) })
6378
+ var WaitForSpec = z12.discriminatedUnion("kind", [
6379
+ z12.object({ kind: z12.literal("auto") }),
6380
+ z12.object({ kind: z12.literal("selector"), value: z12.string().min(1) }),
6381
+ z12.object({ kind: z12.literal("function"), value: z12.string().min(1) }),
6382
+ z12.object({ kind: z12.literal("timeout"), ms: z12.number().int().min(0).max(6e4) })
5691
6383
  ]);
5692
- var HyperframeSnapshotParams = z11.object({
5693
- composition: z11.string().min(1),
6384
+ var HyperframeSnapshotParams = z12.object({
6385
+ composition: z12.string().min(1),
5694
6386
  wait_for: WaitForSpec.optional().default({ kind: "auto" }),
5695
- timeout_ms: z11.number().int().positive().optional().default(6e4)
5696
- }).catchall(z11.unknown());
5697
- var HyperframeSnapshotInputs = z11.record(z11.string(), z11.custom()).optional().default({});
5698
- var HyperframeSnapshotOutputs = z11.object({
5699
- image: z11.custom()
6387
+ timeout_ms: z12.number().int().positive().optional().default(6e4)
6388
+ }).catchall(z12.unknown());
6389
+ var HyperframeSnapshotInputs = z12.record(z12.string(), z12.custom()).optional().default({});
6390
+ var HyperframeSnapshotOutputs = z12.object({
6391
+ image: z12.custom()
5700
6392
  }).strict();
5701
6393
  var NODE_OWNED_PARAM_KEYS2 = /* @__PURE__ */ new Set(["composition", "wait_for", "timeout_ms"]);
5702
6394
  var DEVICE_SCALE_FACTOR2 = 2;
@@ -5725,7 +6417,7 @@ var hyperframeSnapshotNode = defineNode({
5725
6417
  const compositionDir = await resolveCompositionDir(params.composition);
5726
6418
  const meta = await loadCompositionMeta(compositionDir);
5727
6419
  const compositionParams = validateAndParseDynamicParams2(meta, params);
5728
- const tmp = await mkdtemp5(path12.join(tmpdir5(), "hf-snap-"));
6420
+ const tmp = await mkdtemp5(path13.join(tmpdir5(), "hf-snap-"));
5729
6421
  try {
5730
6422
  await copyComposition2(compositionDir, tmp);
5731
6423
  await vendorGsap2(tmp, ctx);
@@ -5740,7 +6432,7 @@ var hyperframeSnapshotNode = defineNode({
5740
6432
  timeoutMs: params.timeout_ms,
5741
6433
  samples: 1
5742
6434
  });
5743
- const entryPath = path12.join(tmp, "index.html");
6435
+ const entryPath = path13.join(tmp, "index.html");
5744
6436
  const entryUrl = `file://${entryPath}`;
5745
6437
  ctx.log(`snapshotting ${meta.width}x${meta.height}@${DEVICE_SCALE_FACTOR2}x wait=${params.wait_for.kind}`);
5746
6438
  const pwSpecifier = ["play", "wright"].join("");
@@ -5801,7 +6493,7 @@ async function copyComposition2(srcDir, destDir) {
5801
6493
  await cp(srcDir, destDir, {
5802
6494
  recursive: true,
5803
6495
  filter: (src) => {
5804
- const name = path12.basename(src);
6496
+ const name = path13.basename(src);
5805
6497
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
5806
6498
  return true;
5807
6499
  }
@@ -5810,7 +6502,7 @@ async function copyComposition2(srcDir, destDir) {
5810
6502
  async function vendorGsap2(tmp, ctx) {
5811
6503
  try {
5812
6504
  const gsapMin = require_3.resolve("gsap/dist/gsap.min.js");
5813
- await copyFile5(gsapMin, path12.join(tmp, "gsap.min.js"));
6505
+ await copyFile5(gsapMin, path13.join(tmp, "gsap.min.js"));
5814
6506
  } catch (e) {
5815
6507
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
5816
6508
  }
@@ -5844,8 +6536,8 @@ function coerceImageParam2(value) {
5844
6536
  throw new Error("hyperframe_snapshot: image param must be a URL string or AssetRef");
5845
6537
  }
5846
6538
  async function substituteCompositionFiles2(tmp, values) {
5847
- const entryPath = path12.join(tmp, "index.html");
5848
- const original = await readFile9(entryPath, "utf-8");
6539
+ const entryPath = path13.join(tmp, "index.html");
6540
+ const original = await readFile10(entryPath, "utf-8");
5849
6541
  const { output, missing } = substituteVariables(original, values);
5850
6542
  if (missing.length > 0) {
5851
6543
  throw new Error(
@@ -5888,18 +6580,18 @@ async function waitForReady(page, waitFor, timeoutMs) {
5888
6580
  // src/engine/nodes/local/imagemagick.ts
5889
6581
  import { execFile as execFile6 } from "child_process";
5890
6582
  import { promisify as promisify6 } from "util";
5891
- import { z as z12 } from "zod";
6583
+ import { z as z13 } from "zod";
5892
6584
  var execFileAsync3 = promisify6(execFile6);
5893
- var OutputDecl2 = z12.object({
5894
- kind: z12.enum(["image", "video", "audio"]),
5895
- ext: z12.string().min(1).max(8)
6585
+ var OutputDecl2 = z13.object({
6586
+ kind: z13.enum(["image", "video", "audio"]),
6587
+ ext: z13.string().min(1).max(8)
5896
6588
  }).strict();
5897
- var ImageMagickParams = z12.object({
5898
- args: z12.array(z12.string()).min(1),
5899
- outputs: z12.record(z12.string(), OutputDecl2).default({})
6589
+ var ImageMagickParams = z13.object({
6590
+ args: z13.array(z13.string()).min(1),
6591
+ outputs: z13.record(z13.string(), OutputDecl2).default({})
5900
6592
  }).strict();
5901
- var ImageMagickInputs = z12.record(z12.string(), z12.unknown());
5902
- var ImageMagickOutputs = z12.record(z12.string(), z12.custom());
6593
+ var ImageMagickInputs = z13.record(z13.string(), z13.unknown());
6594
+ var ImageMagickOutputs = z13.record(z13.string(), z13.custom());
5903
6595
  var resolvedBin;
5904
6596
  async function resolveBin() {
5905
6597
  if (resolvedBin) return resolvedBin;
@@ -5941,29 +6633,29 @@ var imagemagickNode = defineNode({
5941
6633
  });
5942
6634
 
5943
6635
  // src/engine/nodes/local/text.ts
5944
- import { z as z13 } from "zod";
6636
+ import { z as z14 } from "zod";
5945
6637
  var textNode = defineNode({
5946
6638
  id: "text",
5947
6639
  version: "1.0.0",
5948
6640
  category: "data",
5949
6641
  location: "local",
5950
6642
  summary: "A literal text value. Use for prompts, descriptions, copy.",
5951
- inputs: z13.object({}).strict(),
5952
- params: z13.object({ value: z13.string() }).strict(),
5953
- outputs: z13.object({ text: z13.string() }).strict(),
6643
+ inputs: z14.object({}).strict(),
6644
+ params: z14.object({ value: z14.string() }).strict(),
6645
+ outputs: z14.object({ text: z14.string() }).strict(),
5954
6646
  cost: () => ({ credits: 0, seconds_estimate: 0 }),
5955
6647
  execute: ({ params }) => Promise.resolve({ text: params.value })
5956
6648
  });
5957
6649
 
5958
6650
  // src/engine/nodes/remote/audioVoiceConvert.ts
5959
- import { z as z14 } from "zod";
5960
- var AudioVoiceConvertParams = z14.object({
5961
- model: z14.literal("elevenlabs/eleven_multilingual_sts_v2"),
6651
+ import { z as z15 } from "zod";
6652
+ var AudioVoiceConvertParams = z15.object({
6653
+ model: z15.literal("elevenlabs/eleven_multilingual_sts_v2"),
5962
6654
  /** Target voice id. Splice an upstream `voice_select` via `"{{voice_ref}}"`. */
5963
- voice: z14.string().min(1),
5964
- output_format: z14.string().optional(),
6655
+ voice: z15.string().min(1),
6656
+ output_format: z15.string().optional(),
5965
6657
  /** Strip the source clip's background noise before re-voicing. */
5966
- remove_background_noise: z14.boolean().optional()
6658
+ remove_background_noise: z15.boolean().optional()
5967
6659
  }).strict();
5968
6660
  var audioVoiceConvertNode = delegated({
5969
6661
  id: "audio_voice_convert",
@@ -5971,44 +6663,44 @@ var audioVoiceConvertNode = delegated({
5971
6663
  category: "audio",
5972
6664
  summary: "Voice Changer / speech-to-speech via ElevenLabs (eleven_multilingual_sts_v2). Re-voices an existing audio clip in a TARGET voice while preserving timing/prosody.",
5973
6665
  when_to_use: 'Use to normalize a generator-chosen voice (e.g. a Seedance talking-head clip\'s native audio) into ONE consistent brand voice across every scene \u2014 the cadence is preserved so any lip-sync stays valid. Wire `inputs.voice_ref: $ref:<voice_select>.voice_id` and set `params.voice: "{{voice_ref}}"`.',
5974
- inputs: z14.object({
6666
+ inputs: z15.object({
5975
6667
  audio: AudioRef,
5976
6668
  voice_ref: TextRef.optional()
5977
6669
  }).strict(),
5978
6670
  params: AudioVoiceConvertParams,
5979
- outputs: z14.object({ audio: AudioRef }).strict(),
6671
+ outputs: z15.object({ audio: AudioRef }).strict(),
5980
6672
  outputKinds: { audio: "audio" },
5981
6673
  cost: () => ({ credits: 1, seconds_estimate: 20 })
5982
6674
  });
5983
6675
 
5984
6676
  // src/engine/nodes/remote/dialogue.ts
5985
- import { z as z15 } from "zod";
5986
- var DialogueInput = z15.object({
5987
- text: z15.string().min(1),
5988
- voice_id: z15.string().min(1)
6677
+ import { z as z16 } from "zod";
6678
+ var DialogueInput = z16.object({
6679
+ text: z16.string().min(1),
6680
+ voice_id: z16.string().min(1)
5989
6681
  });
5990
6682
  var DIALOGUE_MODELS = ["elevenlabs/eleven_v3"];
5991
- var DialogueParams = z15.object({
5992
- model: z15.enum(DIALOGUE_MODELS),
6683
+ var DialogueParams = z16.object({
6684
+ model: z16.enum(DIALOGUE_MODELS),
5993
6685
  /**
5994
6686
  * Ordered list of lines, each tagged with the voice that should speak it.
5995
6687
  * Up to 10 unique voice_ids; total text across all lines should stay under
5996
6688
  * ~2000 characters for best quality (ElevenLabs guidance).
5997
6689
  */
5998
- inputs: z15.array(DialogueInput).min(1).max(50),
5999
- language_code: z15.string().optional(),
6690
+ inputs: z16.array(DialogueInput).min(1).max(50),
6691
+ language_code: z16.string().optional(),
6000
6692
  /** ElevenLabs voice/model settings passthrough (e.g. `{ stability: 0.5 }`). */
6001
- settings: z15.record(z15.string(), z15.unknown()).optional(),
6002
- seed: z15.number().int().min(0).max(4294967295).optional(),
6003
- apply_text_normalization: z15.enum(["auto", "on", "off"]).optional(),
6693
+ settings: z16.record(z16.string(), z16.unknown()).optional(),
6694
+ seed: z16.number().int().min(0).max(4294967295).optional(),
6695
+ apply_text_normalization: z16.enum(["auto", "on", "off"]).optional(),
6004
6696
  /**
6005
6697
  * When true, hits `/v1/text-to-dialogue/with-timestamps` and emits a
6006
6698
  * separate `timestamps` output — character-level alignment plus
6007
6699
  * per-voice segment markers usable for captions, lipsync, or
6008
6700
  * beat-matched cuts in ad creatives.
6009
6701
  */
6010
- with_timestamps: z15.boolean().optional(),
6011
- output_format: z15.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6702
+ with_timestamps: z16.boolean().optional(),
6703
+ output_format: z16.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6012
6704
  }).strict().refine((p) => p.inputs.reduce((sum, line) => sum + line.text.length, 0) <= ELEVENLABS_MAX_TEXT_CHARS, {
6013
6705
  message: `total dialogue text exceeds ${ELEVENLABS_MAX_TEXT_CHARS} characters`,
6014
6706
  path: ["inputs"]
@@ -6019,9 +6711,9 @@ var dialogueNode = delegated({
6019
6711
  category: "audio",
6020
6712
  summary: "Multi-voice dialogue / VO with ElevenLabs Eleven v3. Each line is tagged with a `voice_id`, so you can render two-character scripts (e.g. ad VO + customer testimonial reaction) in a single call. Setting `with_timestamps: true` adds character-level alignment for caption rendering and lipsync-friendly cuts.",
6021
6713
  when_to_use: "Use for any ad creative or website video VO that needs more than narration \u2014 interviews, two-actor scripts, character ads, testimonial reads. For single-voice flat reads the existing `tts` node is cheaper and simpler; reach for `dialogue` when you need multiple speakers in one stitched track or word-level timing for downstream lipsync / captions.",
6022
- inputs: z15.object({}).loose(),
6714
+ inputs: z16.object({}).loose(),
6023
6715
  params: DialogueParams,
6024
- outputs: z15.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6716
+ outputs: z16.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6025
6717
  outputKinds: { audio: "audio", timestamps: "json" },
6026
6718
  cost: ({ params }) => {
6027
6719
  const chars = params.inputs.reduce((sum, line) => sum + line.text.length, 0);
@@ -6030,7 +6722,7 @@ var dialogueNode = delegated({
6030
6722
  });
6031
6723
 
6032
6724
  // src/engine/nodes/remote/image.ts
6033
- import { z as z16 } from "zod";
6725
+ import { z as z17 } from "zod";
6034
6726
  var IMAGE_GENERATE_MODELS2 = [
6035
6727
  "openai/gpt-5.4-image-2",
6036
6728
  "google/gemini-3.5-flash",
@@ -6038,41 +6730,44 @@ var IMAGE_GENERATE_MODELS2 = [
6038
6730
  "google/gemini-3-pro-image-preview",
6039
6731
  "recraft/recraft-v4.1-pro-vector"
6040
6732
  ];
6041
- var ImageGenerateParams = z16.object({
6042
- model: z16.enum(IMAGE_GENERATE_MODELS2),
6043
- prompt: z16.string().min(1),
6044
- aspect_ratio: z16.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
6045
- image_size: z16.enum(["0.5K", "1K", "2K", "4K"]).optional(),
6733
+ var ImageGenerateParams = z17.object({
6734
+ model: z17.enum(IMAGE_GENERATE_MODELS2),
6735
+ prompt: z17.string().min(1),
6736
+ aspect_ratio: z17.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
6737
+ image_size: z17.enum(["0.5K", "1K", "2K", "4K"]).optional(),
6738
+ // Rendering quality — forwarded into `image_config`. OpenRouter models without a
6739
+ // quality knob ignore it; the registry gates which models accept it (gpt-image, Gemini).
6740
+ quality: z17.enum(["auto", "low", "medium", "high"]).optional(),
6046
6741
  // Recraft v4 vector controls — forwarded into `image_config`. Registry
6047
6742
  // rejects them on non-Recraft models.
6048
- strength: z16.number().min(0).max(1).optional(),
6049
- rgb_colors: z16.array(z16.array(z16.number().int().min(0).max(255))).optional(),
6050
- background_rgb_color: z16.array(z16.number().int().min(0).max(255)).optional()
6743
+ strength: z17.number().min(0).max(1).optional(),
6744
+ rgb_colors: z17.array(z17.array(z17.number().int().min(0).max(255))).optional(),
6745
+ background_rgb_color: z17.array(z17.number().int().min(0).max(255)).optional()
6051
6746
  }).strict();
6052
6747
  var imageGenerateNode = delegated({
6053
6748
  id: "image_generate",
6054
- version: "2.1.0",
6749
+ version: "2.2.0",
6055
6750
  category: "image",
6056
6751
  summary: "Generate images for ad creatives. Curated model set: GPT-5.4 Image, Gemini 3.5 Flash, Gemini 3.1 Flash Image Preview, Gemini 3 Pro Image, Recraft v4.1 Pro Vector. Per-model param support comes from the canvas-engine model registry.",
6057
6752
  when_to_use: "Use for hero shots, product photography, illustrations, and vector logos. `recraft/recraft-v4.1-pro-vector` for crisp vector / logo work; `openai/gpt-5.4-image-2` for photorealistic; Gemini variants for fast iteration and editing via the `reference` input. `reference` accepts ONE image or an ARRAY of images \u2014 wire several to combine references in a single generation (e.g. a subject sheet + a font specimen + the original ad). Every reference is forwarded to the model in array order.",
6058
6753
  // `reference` is one image or an ordered array of images. The backend forwards
6059
6754
  // each as a separate `image_url` to the provider (OpenRouter accepts many).
6060
- inputs: z16.object({ reference: z16.union([ImageRef, z16.array(ImageRef).min(1)]).optional() }).loose(),
6755
+ inputs: z17.object({ reference: z17.union([ImageRef, z17.array(ImageRef).min(1)]).optional() }).loose(),
6061
6756
  params: ImageGenerateParams,
6062
- outputs: z16.object({ images: z16.array(ImageRef).min(1) }).strict(),
6757
+ outputs: z17.object({ images: z17.array(ImageRef).min(1) }).strict(),
6063
6758
  outputKinds: { images: "image" },
6064
6759
  cost: () => ({ credits: 5, seconds_estimate: 10 })
6065
6760
  });
6066
6761
 
6067
6762
  // src/engine/nodes/remote/imageAspectAdapt.ts
6068
- import { z as z17 } from "zod";
6763
+ import { z as z18 } from "zod";
6069
6764
  var ASPECT_ADAPT_MODELS = ["google/gemini-3-pro-image-preview", "google/gemini-3.1-flash-image-preview"];
6070
6765
  var ASPECT_ADAPT_FORMATS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
6071
- var ImageAspectAdaptParams = z17.object({
6072
- model: z17.enum(ASPECT_ADAPT_MODELS),
6073
- formats: z17.array(z17.enum(ASPECT_ADAPT_FORMATS)).min(1).max(6).refine((formats) => new Set(formats).size === formats.length, { message: "formats must be unique" }),
6074
- guidance: z17.string().min(1).optional(),
6075
- image_size: z17.enum(["0.5K", "1K", "2K", "4K"]).optional()
6766
+ var ImageAspectAdaptParams = z18.object({
6767
+ model: z18.enum(ASPECT_ADAPT_MODELS),
6768
+ formats: z18.array(z18.enum(ASPECT_ADAPT_FORMATS)).min(1).max(6).refine((formats) => new Set(formats).size === formats.length, { message: "formats must be unique" }),
6769
+ guidance: z18.string().min(1).optional(),
6770
+ image_size: z18.enum(["0.5K", "1K", "2K", "4K"]).optional()
6076
6771
  }).strict();
6077
6772
  var imageAspectAdaptNode = delegated({
6078
6773
  id: "image_aspect_adapt",
@@ -6080,9 +6775,9 @@ var imageAspectAdaptNode = delegated({
6080
6775
  category: "image",
6081
6776
  summary: "Adapt ONE creative into multiple aspect ratios (Meta: 9:16 stories, 1:1 feed, 4:5, 16:9\u2026) in a single step. AI recomposes the layout per format \u2014 identical subject, text, logos, colors, and style; the scene is extended/restructured, never stretched or cropped. Formats that already match the source ratio pass through unchanged at zero cost. Outputs are ordered exactly as `formats`.",
6082
6777
  when_to_use: "Use after a hero creative exists (image_generate, ingest, image_search) to fan it out to every placement format \u2014 wire the creative into `source` and list the target ratios in `formats`. Cost is estimated per format; formats matching the source ratio are free pass-throughs. Pick `google/gemini-3.1-flash-image-preview` (Nano Banana flash) while iterating, `google/gemini-3-pro-image-preview` (Nano Banana Pro) for final-quality adaptation.",
6083
- inputs: z17.object({ source: ImageRef }).loose(),
6778
+ inputs: z18.object({ source: ImageRef }).loose(),
6084
6779
  params: ImageAspectAdaptParams,
6085
- outputs: z17.object({ images: z17.array(ImageRef).min(1) }).strict(),
6780
+ outputs: z18.object({ images: z18.array(ImageRef).min(1) }).strict(),
6086
6781
  outputKinds: { images: "image" },
6087
6782
  cost: ({ params }) => {
6088
6783
  const p = params;
@@ -6095,12 +6790,12 @@ var imageAspectAdaptNode = delegated({
6095
6790
  });
6096
6791
 
6097
6792
  // src/engine/nodes/remote/imageBackgroundRemove.ts
6098
- import { z as z18 } from "zod";
6099
- var ImageBackgroundRemoveParams = z18.object({
6100
- model: z18.literal("fal/birefnet-v2").optional().default("fal/birefnet-v2"),
6101
- model_variant: z18.enum(["General Use (Light)", "General Use (Heavy)", "Matting", "Portrait", "DIS", "HRSOD", "COD"]).optional().default("General Use (Light)"),
6102
- operating_resolution: z18.enum(["1024x1024", "2048x2048", "2304x2304"]).optional(),
6103
- mask_only: z18.boolean().optional().default(false)
6793
+ import { z as z19 } from "zod";
6794
+ var ImageBackgroundRemoveParams = z19.object({
6795
+ model: z19.literal("fal/birefnet-v2").optional().default("fal/birefnet-v2"),
6796
+ model_variant: z19.enum(["General Use (Light)", "General Use (Heavy)", "Matting", "Portrait", "DIS", "HRSOD", "COD"]).optional().default("General Use (Light)"),
6797
+ operating_resolution: z19.enum(["1024x1024", "2048x2048", "2304x2304"]).optional(),
6798
+ mask_only: z19.boolean().optional().default(false)
6104
6799
  }).strict();
6105
6800
  var imageBackgroundRemoveNode = delegated({
6106
6801
  id: "image_background_remove",
@@ -6108,11 +6803,11 @@ var imageBackgroundRemoveNode = delegated({
6108
6803
  category: "image",
6109
6804
  summary: "Remove the background from an image and return a transparent PNG (or the segmentation mask). Powered by fal.ai `fal-ai/birefnet/v2`.",
6110
6805
  when_to_use: "Use to extract subjects from photos for use as overlays in hyperframe compositions, product shots, or compositing pipelines. Set `mask_only:true` to return the binary mask instead of the alpha-cut image.",
6111
- inputs: z18.object({
6806
+ inputs: z19.object({
6112
6807
  image: ImageRef
6113
6808
  }).strict(),
6114
6809
  params: ImageBackgroundRemoveParams,
6115
- outputs: z18.object({
6810
+ outputs: z19.object({
6116
6811
  image: ImageRef,
6117
6812
  mask: ImageRef.optional()
6118
6813
  }).strict(),
@@ -6121,7 +6816,7 @@ var imageBackgroundRemoveNode = delegated({
6121
6816
  });
6122
6817
 
6123
6818
  // src/engine/nodes/remote/imageDescribe.ts
6124
- import { z as z19 } from "zod";
6819
+ import { z as z20 } from "zod";
6125
6820
  var IMAGE_DESCRIBE_MODELS = ["~google/gemini-pro-latest", "~google/gemini-flash-latest"];
6126
6821
  var imageDescribeNode = delegated({
6127
6822
  id: "image_describe",
@@ -6129,33 +6824,33 @@ var imageDescribeNode = delegated({
6129
6824
  category: "vision",
6130
6825
  summary: "Reverse-engineer an image into an exhaustive, replication-grade JSON description: who the advertiser is and what they sell (source_context), composition, non-person subjects with expression/treatment, deeply detailed people, brand-identified logos (named by brand, not appearance), camera optics, lighting, color palette WITH per-color brand-ownership (brand vs borrowed-functional) and purpose, materials, visible text, ad signals (proof badges/CTA/price), the persuasion engine (ad_intent), style, post-processing.",
6131
6826
  when_to_use: 'Use to turn a reference image into a structured blueprint you can inject into downstream prompts via `{{slot}}` \u2014 e.g. restyle a competitor ad onto your own product, lock a look across a series, or feed exact palette/lighting into image_generate. Purpose-built for market adaptation: logos are identified by brand ("Trustpilot", never "green star"), people and animals carry expression/emotion/intent detail, and each color is tagged brand vs borrowed-functional so a recolor can keep the reds/yellows that do a job. The extraction prompt is baked in; use `focus` to emphasise aspects and `context` to pass known provenance (advertiser, category, market) so source_context and color ownership are grounded. Pick `~google/gemini-pro-latest` for the densest extraction (recommended for ad / market-adaptation passes), `~google/gemini-flash-latest` for cheap/fast passes. The output is rich \u2014 raise `max_tokens` (e.g. 8000+) for dense ads so the JSON isn\'t truncated.',
6132
- inputs: z19.object({ image: ImageRef }).loose(),
6133
- params: z19.object({
6134
- model: z19.enum(IMAGE_DESCRIBE_MODELS),
6135
- focus: z19.string().optional(),
6136
- context: z19.string().optional(),
6137
- temperature: z19.number().min(0).max(2).optional(),
6138
- max_tokens: z19.number().int().positive().optional()
6827
+ inputs: z20.object({ image: ImageRef }).loose(),
6828
+ params: z20.object({
6829
+ model: z20.enum(IMAGE_DESCRIBE_MODELS),
6830
+ focus: z20.string().optional(),
6831
+ context: z20.string().optional(),
6832
+ temperature: z20.number().min(0).max(2).optional(),
6833
+ max_tokens: z20.number().int().positive().optional()
6139
6834
  }).strict(),
6140
- outputs: z19.object({ description: JsonRef }).strict(),
6835
+ outputs: z20.object({ description: JsonRef }).strict(),
6141
6836
  outputKinds: { description: "json" },
6142
6837
  cost: () => ({ credits: 2, seconds_estimate: 10 })
6143
6838
  });
6144
6839
 
6145
6840
  // src/engine/nodes/remote/imageReferenceSheet.ts
6146
- import { z as z20 } from "zod";
6841
+ import { z as z21 } from "zod";
6147
6842
  var REFERENCE_SHEET_MODELS = ["google/gemini-3-pro-image-preview", "google/gemini-3.1-flash-image-preview"];
6148
- var ImageReferenceSheetParams = z20.object({
6149
- model: z20.enum(REFERENCE_SHEET_MODELS),
6150
- subject_description: z20.string().min(1),
6843
+ var ImageReferenceSheetParams = z21.object({
6844
+ model: z21.enum(REFERENCE_SHEET_MODELS),
6845
+ subject_description: z21.string().min(1),
6151
6846
  // `location` = a set/room shown from several camera ANGLES (not a rotated subject),
6152
6847
  // so a multi-scene shoot keeps one consistent set.
6153
- subject_type: z20.enum(["character", "person", "product", "location"]),
6154
- views: z20.array(z20.string().min(1)).min(2).max(8).optional(),
6155
- style: z20.string().optional(),
6156
- prompt_override: z20.string().min(1).optional(),
6157
- aspect_ratio: z20.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
6158
- image_size: z20.enum(["0.5K", "1K", "2K", "4K"]).optional()
6848
+ subject_type: z21.enum(["character", "person", "product", "location"]),
6849
+ views: z21.array(z21.string().min(1)).min(2).max(8).optional(),
6850
+ style: z21.string().optional(),
6851
+ prompt_override: z21.string().min(1).optional(),
6852
+ aspect_ratio: z21.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
6853
+ image_size: z21.enum(["0.5K", "1K", "2K", "4K"]).optional()
6159
6854
  }).strict();
6160
6855
  var imageReferenceSheetNode = delegated({
6161
6856
  id: "image_reference_sheet",
@@ -6163,9 +6858,9 @@ var imageReferenceSheetNode = delegated({
6163
6858
  category: "image",
6164
6859
  summary: "Fuse 1\u20136 images of a single subject (person, character, product, or location/set) into ONE multi-view reference sheet \u2014 a labeled grid in consistent style and lighting: a turnaround (FRONT / SIDE / BACK\u2026) for a person/character/product, or several camera angles of the same room (WIDE / REVERSE / DETAIL\u2026) for a location. Curated models: Gemini 3 Pro Image (best fusion + labels), Gemini 3.1 Flash Image (cheap iteration).",
6165
6860
  when_to_use: "Use before image_generate / video_generate when a subject must stay consistent across many creatives \u2014 wire the `sheet` output into their `reference` input instead of re-describing the subject per prompt. `subject_description` should be the exact wording you reuse downstream. Pick `google/gemini-3-pro-image-preview` for final 6-view sheets at 2K+, `google/gemini-3.1-flash-image-preview` while iterating.",
6166
- inputs: z20.object({ references: z20.array(ImageRef).min(1).max(6) }).loose(),
6861
+ inputs: z21.object({ references: z21.array(ImageRef).min(1).max(6) }).loose(),
6167
6862
  params: ImageReferenceSheetParams,
6168
- outputs: z20.object({ sheet: ImageRef }).strict(),
6863
+ outputs: z21.object({ sheet: ImageRef }).strict(),
6169
6864
  outputKinds: { sheet: "image" },
6170
6865
  cost: ({ params }) => ({
6171
6866
  credits: params?.model === "google/gemini-3-pro-image-preview" ? 20 : 5,
@@ -6174,10 +6869,10 @@ var imageReferenceSheetNode = delegated({
6174
6869
  });
6175
6870
 
6176
6871
  // src/engine/nodes/remote/imageSearch.ts
6177
- import { z as z21 } from "zod";
6178
- var ImageSearchParams = z21.object({
6179
- prompt: z21.string().min(1),
6180
- count: z21.number().int().min(1).max(20).default(5)
6872
+ import { z as z22 } from "zod";
6873
+ var ImageSearchParams = z22.object({
6874
+ prompt: z22.string().min(1),
6875
+ count: z22.number().int().min(1).max(20).default(5)
6181
6876
  }).strict();
6182
6877
  var imageSearchNode = delegated({
6183
6878
  id: "image_search",
@@ -6185,15 +6880,15 @@ var imageSearchNode = delegated({
6185
6880
  category: "image",
6186
6881
  summary: "Agentic image search across Google Images, stock photography (Freepik), and Pinterest. An LLM agent picks the search tools and queries, selects the best matches, and the results are downloaded into canvas assets.",
6187
6882
  when_to_use: "Use to gather real-world reference or inspiration images for a prompt (e.g. several photos of an australian shepherd) so a later step or the user can pick the best one. Not for creating new imagery \u2014 use image_generate for that.",
6188
- inputs: z21.object({}).loose(),
6883
+ inputs: z22.object({}).loose(),
6189
6884
  params: ImageSearchParams,
6190
- outputs: z21.object({ images: z21.array(ImageRef).min(1) }).strict(),
6885
+ outputs: z22.object({ images: z22.array(ImageRef).min(1) }).strict(),
6191
6886
  outputKinds: { images: "image" },
6192
6887
  cost: ({ params }) => ({ credits: Math.ceil(2 + params.count / 2), seconds_estimate: 30 })
6193
6888
  });
6194
6889
 
6195
6890
  // src/engine/nodes/remote/imageSelect.ts
6196
- import { z as z22 } from "zod";
6891
+ import { z as z23 } from "zod";
6197
6892
  var IMAGE_SELECT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6198
6893
  var imageSelectNode = delegated({
6199
6894
  id: "image_select",
@@ -6201,15 +6896,15 @@ var imageSelectNode = delegated({
6201
6896
  category: "vision",
6202
6897
  summary: "Pick the best `count` images out of 2+ candidates with a vision LLM, judged against a prompt. Outputs a passthrough subset of the input refs (no new pixels) plus the model's comparative reasoning.",
6203
6898
  when_to_use: "Use after fanning out several image_generate variants (or any pool of 2+ images) to keep only the strongest before expensive downstream steps \u2014 video generation, reference sheets, final delivery. `count` fixes the output size, so `images#0`\u2026`images#count-1` are always safe to wire. Pick `~google/gemini-flash-latest` for cheap/fast picks and `~google/gemini-pro-latest` for harder aesthetic judgement.",
6204
- inputs: z22.object({ images: z22.array(ImageRef).min(2) }).loose(),
6205
- params: z22.object({
6206
- model: z22.enum(IMAGE_SELECT_MODELS),
6207
- prompt: z22.string().min(1),
6208
- count: z22.number().int().min(1).default(1),
6209
- temperature: z22.number().min(0).max(2).optional(),
6210
- max_tokens: z22.number().int().positive().optional()
6899
+ inputs: z23.object({ images: z23.array(ImageRef).min(2) }).loose(),
6900
+ params: z23.object({
6901
+ model: z23.enum(IMAGE_SELECT_MODELS),
6902
+ prompt: z23.string().min(1),
6903
+ count: z23.number().int().min(1).default(1),
6904
+ temperature: z23.number().min(0).max(2).optional(),
6905
+ max_tokens: z23.number().int().positive().optional()
6211
6906
  }).strict(),
6212
- outputs: z22.object({ images: z22.array(ImageRef).min(1), reasoning: TextRef }).strict(),
6907
+ outputs: z23.object({ images: z23.array(ImageRef).min(1), reasoning: TextRef }).strict(),
6213
6908
  outputKinds: { images: "image", reasoning: "text" },
6214
6909
  cost: () => ({ credits: 1, seconds_estimate: 5 }),
6215
6910
  // Arity is only knowable at validate time when `images` is a literal array
@@ -6234,34 +6929,34 @@ var imageSelectNode = delegated({
6234
6929
  });
6235
6930
 
6236
6931
  // src/engine/nodes/remote/music.ts
6237
- import { z as z23 } from "zod";
6932
+ import { z as z24 } from "zod";
6238
6933
  var MUSIC_MODELS = ["elevenlabs/music-v1", "elevenlabs/video-background-music-v1"];
6239
- var MusicParams = z23.object({
6240
- model: z23.enum(MUSIC_MODELS),
6934
+ var MusicParams = z24.object({
6935
+ model: z24.enum(MUSIC_MODELS),
6241
6936
  /** Free-form prompt. Used by `elevenlabs/music-v1` (compose-detailed). */
6242
- prompt: z23.string().optional(),
6937
+ prompt: z24.string().optional(),
6243
6938
  /**
6244
6939
  * Structured composition plan (intro / hook / verse / outro sections with
6245
6940
  * per-section styles + durations). Mutually exclusive with `prompt`.
6246
6941
  */
6247
- composition_plan: z23.record(z23.string(), z23.unknown()).optional(),
6942
+ composition_plan: z24.record(z24.string(), z24.unknown()).optional(),
6248
6943
  /** Target length when using `prompt`. 3000–454545ms (capped by the $10 per-node cost limit). */
6249
- music_length_ms: z23.number().int().min(3e3).max(ELEVENLABS_MAX_MUSIC_LENGTH_MS).optional(),
6250
- seed: z23.number().int().optional(),
6944
+ music_length_ms: z24.number().int().min(3e3).max(ELEVENLABS_MAX_MUSIC_LENGTH_MS).optional(),
6945
+ seed: z24.number().int().optional(),
6251
6946
  /** Prompt mode only — forces an instrumental (no vocals) track. */
6252
- force_instrumental: z23.boolean().optional(),
6947
+ force_instrumental: z24.boolean().optional(),
6253
6948
  /** composition_plan only — honor exact section durations. */
6254
- respect_sections_durations: z23.boolean().optional(),
6949
+ respect_sections_durations: z24.boolean().optional(),
6255
6950
  /** Emit word-level timestamps alongside the audio. */
6256
- with_timestamps: z23.boolean().optional(),
6951
+ with_timestamps: z24.boolean().optional(),
6257
6952
  /**
6258
6953
  * video-to-music only — short description of the desired score
6259
6954
  * ("upbeat synth, fast cuts, 80s") used to bias the model.
6260
6955
  */
6261
- description: z23.string().max(1e3).optional(),
6956
+ description: z24.string().max(1e3).optional(),
6262
6957
  /** video-to-music only — up to 10 style tags. */
6263
- tags: z23.array(z23.string()).max(10).optional(),
6264
- output_format: z23.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6958
+ tags: z24.array(z24.string()).max(10).optional(),
6959
+ output_format: z24.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6265
6960
  }).strict();
6266
6961
  var musicNode = delegated({
6267
6962
  id: "music",
@@ -6269,9 +6964,9 @@ var musicNode = delegated({
6269
6964
  category: "audio",
6270
6965
  summary: "Generate music for ad creatives and website video content. `elevenlabs/music-v1` composes from a text prompt or structured composition plan; `elevenlabs/video-background-music-v1` scores an existing video clip provided via `inputs.video`.",
6271
6966
  when_to_use: "Use to produce background music or a full score for video ads, hero-section reels, or any motion content. Prefer the video-to-music model when you already have a cut and want music timed to it; use compose-detailed when you have only a brief or want section-level control (intro / hook / outro). Pair the resulting audio with `video_generate` or `video_lipsync` at compose time.",
6272
- inputs: z23.object({ video: VideoRef.optional() }).loose(),
6967
+ inputs: z24.object({ video: VideoRef.optional() }).loose(),
6273
6968
  params: MusicParams,
6274
- outputs: z23.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6969
+ outputs: z24.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6275
6970
  outputKinds: { audio: "audio", timestamps: "json" },
6276
6971
  cost: ({ params }) => {
6277
6972
  const seconds = params.music_length_ms ? Math.ceil(params.music_length_ms / 1e3) : 30;
@@ -6302,25 +6997,25 @@ var musicNode = delegated({
6302
6997
  });
6303
6998
 
6304
6999
  // src/engine/nodes/remote/soundEffect.ts
6305
- import { z as z24 } from "zod";
7000
+ import { z as z25 } from "zod";
6306
7001
  var SOUND_EFFECT_MODELS = ["elevenlabs/eleven_text_to_sound_v2"];
6307
- var SoundEffectParams = z24.object({
6308
- model: z24.enum(SOUND_EFFECT_MODELS),
7002
+ var SoundEffectParams = z25.object({
7003
+ model: z25.enum(SOUND_EFFECT_MODELS),
6309
7004
  /** Prompt describing the SFX ("metal door slam", "soft UI tap", "ocean waves"). */
6310
- text: z24.string().min(1),
7005
+ text: z25.string().min(1),
6311
7006
  /**
6312
7007
  * Target length in seconds. 0.5–30. Leave unset to let the model pick the
6313
7008
  * natural length for the described effect.
6314
7009
  */
6315
- duration_seconds: z24.number().min(0.5).max(30).optional(),
7010
+ duration_seconds: z25.number().min(0.5).max(30).optional(),
6316
7011
  /**
6317
7012
  * 0–1. Higher = stick closer to the prompt at the cost of variety; lower
6318
7013
  * = let the model interpret more freely. Defaults to 0.3 on the provider.
6319
7014
  */
6320
- prompt_influence: z24.number().min(0).max(1).optional(),
7015
+ prompt_influence: z25.number().min(0).max(1).optional(),
6321
7016
  /** Only valid on `eleven_text_to_sound_v2` — produce a seamless loop. */
6322
- loop: z24.boolean().optional(),
6323
- output_format: z24.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
7017
+ loop: z25.boolean().optional(),
7018
+ output_format: z25.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6324
7019
  }).strict();
6325
7020
  var soundEffectNode = delegated({
6326
7021
  id: "sound_effect",
@@ -6328,9 +7023,9 @@ var soundEffectNode = delegated({
6328
7023
  category: "audio",
6329
7024
  summary: "Generate short sound effects from a text prompt via ElevenLabs Text-to-Sound. Use for whooshes, impacts, UI clicks, ambient beds, or signature stingers in ad creatives and product videos.",
6330
7025
  when_to_use: "Reach for this when you need a punch-in SFX layered against `video_generate` or `hyperframe_render` output \u2014 e.g. a logo whoosh on a hero shot, a click on a CTA cut, a swelling ambient bed under VO. Set `loop: true` for atmospheric beds that need to tile under longer footage; leave `duration_seconds` unset and the model picks a natural length.",
6331
- inputs: z24.object({}).loose(),
7026
+ inputs: z25.object({}).loose(),
6332
7027
  params: SoundEffectParams,
6333
- outputs: z24.object({ audio: AudioRef }).strict(),
7028
+ outputs: z25.object({ audio: AudioRef }).strict(),
6334
7029
  outputKinds: { audio: "audio" },
6335
7030
  cost: ({ params }) => {
6336
7031
  const seconds = params.duration_seconds ?? 5;
@@ -6339,7 +7034,7 @@ var soundEffectNode = delegated({
6339
7034
  });
6340
7035
 
6341
7036
  // src/engine/nodes/remote/textGenerate.ts
6342
- import { z as z25 } from "zod";
7037
+ import { z as z26 } from "zod";
6343
7038
  var TEXT_GENERATE_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6344
7039
  var textGenerateNode = delegated({
6345
7040
  id: "text_generate",
@@ -6347,58 +7042,58 @@ var textGenerateNode = delegated({
6347
7042
  category: "language",
6348
7043
  summary: "Single-turn LLM text generation via OpenRouter. Returns a text response.",
6349
7044
  when_to_use: 'Use for any short text generation step in a canvas \u2014 ad copy, hooks, headlines, JSON outputs for downstream nodes. Pick `~google/gemini-flash-latest` for cheap/fast work and `~google/gemini-pro-latest` for harder reasoning. When the output must be JSON for a downstream `{{slot}}` (e.g. the ad-blueprint transform), set `response_format: "json_object"` so the model returns clean JSON with no markdown fences or prose. Set `web_search: true` to let the model search the live web first (OpenRouter `:online`) \u2014 useful when the transform must adapt copy to the target brand\'s real facts (current pricing, the trust signals it actually has) rather than guess.',
6350
- inputs: z25.object({}).loose(),
6351
- params: z25.object({
6352
- model: z25.enum(TEXT_GENERATE_MODELS),
6353
- prompt: z25.string().min(1),
6354
- system: z25.string().optional(),
6355
- response_format: z25.enum(["text", "json_object"]).optional(),
6356
- web_search: z25.boolean().optional(),
6357
- temperature: z25.number().min(0).max(2).optional(),
6358
- max_tokens: z25.number().int().positive().optional()
7045
+ inputs: z26.object({}).loose(),
7046
+ params: z26.object({
7047
+ model: z26.enum(TEXT_GENERATE_MODELS),
7048
+ prompt: z26.string().min(1),
7049
+ system: z26.string().optional(),
7050
+ response_format: z26.enum(["text", "json_object"]).optional(),
7051
+ web_search: z26.boolean().optional(),
7052
+ temperature: z26.number().min(0).max(2).optional(),
7053
+ max_tokens: z26.number().int().positive().optional()
6359
7054
  }).strict(),
6360
- outputs: z25.object({ text: TextRef }).strict(),
7055
+ outputs: z26.object({ text: TextRef }).strict(),
6361
7056
  outputKinds: { text: "text" },
6362
7057
  cost: () => ({ credits: 1, seconds_estimate: 3 })
6363
7058
  });
6364
7059
 
6365
7060
  // src/engine/nodes/remote/tts.ts
6366
- import { z as z26 } from "zod";
7061
+ import { z as z27 } from "zod";
6367
7062
  var TTS_MODELS = ["elevenlabs/eleven_v3"];
6368
- var TtsVoiceSettings = z26.object({
6369
- stability: z26.number().min(0).max(1).optional(),
6370
- similarity_boost: z26.number().min(0).max(1).optional(),
6371
- style: z26.number().min(0).max(1).optional(),
6372
- use_speaker_boost: z26.boolean().optional(),
6373
- speed: z26.number().min(0.25).max(4).optional()
7063
+ var TtsVoiceSettings = z27.object({
7064
+ stability: z27.number().min(0).max(1).optional(),
7065
+ similarity_boost: z27.number().min(0).max(1).optional(),
7066
+ style: z27.number().min(0).max(1).optional(),
7067
+ use_speaker_boost: z27.boolean().optional(),
7068
+ speed: z27.number().min(0.25).max(4).optional()
6374
7069
  }).strict();
6375
- var TtsPronunciationLocator = z26.object({
6376
- pronunciation_dictionary_id: z26.string().min(1),
6377
- version_id: z26.string().nullable().optional()
7070
+ var TtsPronunciationLocator = z27.object({
7071
+ pronunciation_dictionary_id: z27.string().min(1),
7072
+ version_id: z27.string().nullable().optional()
6378
7073
  }).strict();
6379
- var TtsParams = z26.object({
6380
- model: z26.enum(TTS_MODELS),
6381
- text: z26.string().min(1).max(ELEVENLABS_MAX_TEXT_CHARS),
6382
- voice: z26.string().min(1),
7074
+ var TtsParams = z27.object({
7075
+ model: z27.enum(TTS_MODELS),
7076
+ text: z27.string().min(1).max(ELEVENLABS_MAX_TEXT_CHARS),
7077
+ voice: z27.string().min(1),
6383
7078
  /** Provider output_format (mp3 family only — assets are stored as audio/mpeg). */
6384
- output_format: z26.enum(ELEVENLABS_OUTPUT_FORMATS).optional(),
6385
- seed: z26.number().int().min(0).max(4294967295).optional(),
7079
+ output_format: z27.enum(ELEVENLABS_OUTPUT_FORMATS).optional(),
7080
+ seed: z27.number().int().min(0).max(4294967295).optional(),
6386
7081
  // Top-level shortcuts; structured form is `voice_settings`.
6387
- stability: z26.number().min(0).max(1).optional(),
6388
- similarity_boost: z26.number().min(0).max(1).optional(),
7082
+ stability: z27.number().min(0).max(1).optional(),
7083
+ similarity_boost: z27.number().min(0).max(1).optional(),
6389
7084
  voice_settings: TtsVoiceSettings.optional(),
6390
7085
  /** ISO 639-1 language code. eleven_v3 supports language hints. */
6391
- language_code: z26.string().optional(),
6392
- pronunciation_dictionary_locators: z26.array(TtsPronunciationLocator).max(3).optional(),
6393
- apply_text_normalization: z26.enum(["auto", "on", "off"]).optional(),
7086
+ language_code: z27.string().optional(),
7087
+ pronunciation_dictionary_locators: z27.array(TtsPronunciationLocator).max(3).optional(),
7088
+ apply_text_normalization: z27.enum(["auto", "on", "off"]).optional(),
6394
7089
  /** Currently Japanese-only. Adds latency. */
6395
- apply_language_text_normalization: z26.boolean().optional(),
7090
+ apply_language_text_normalization: z27.boolean().optional(),
6396
7091
  /**
6397
7092
  * When true, hits `/v1/text-to-speech/{voice_id}/with-timestamps` and
6398
7093
  * adds a `timestamps` output (character-level alignment) for caption
6399
7094
  * rendering, lipsync, and beat-matched cuts.
6400
7095
  */
6401
- with_timestamps: z26.boolean().optional()
7096
+ with_timestamps: z27.boolean().optional()
6402
7097
  }).strict();
6403
7098
  var ttsNode = delegated({
6404
7099
  id: "tts",
@@ -6406,9 +7101,9 @@ var ttsNode = delegated({
6406
7101
  category: "audio",
6407
7102
  summary: "Single-voice text-to-speech via ElevenLabs Eleven v3. Optional character-level timestamps for caption rendering and beat-matched cuts.",
6408
7103
  when_to_use: "Use for single-speaker VO \u2014 ad reads, hero-section narration, product walkthroughs. Reach for `dialogue` when you need multiple voices in one stitched track. Set `with_timestamps: true` when downstream needs character-level alignment (captions, lipsync).",
6409
- inputs: z26.object({}).loose(),
7104
+ inputs: z27.object({}).loose(),
6410
7105
  params: TtsParams,
6411
- outputs: z26.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
7106
+ outputs: z27.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6412
7107
  outputKinds: { audio: "audio", timestamps: "json" },
6413
7108
  cost: ({ params }) => ({
6414
7109
  credits: Math.max(1, Math.ceil(params.text.length * 15e-4)),
@@ -6417,47 +7112,49 @@ var ttsNode = delegated({
6417
7112
  });
6418
7113
 
6419
7114
  // src/engine/nodes/remote/video.ts
6420
- import { z as z27 } from "zod";
6421
- var VIDEO_GENERATE_MODELS = ["bytedance/seedance-2.0", "google/veo-3.1-fast"];
6422
- var VideoGenerateParams = z27.object({
6423
- model: z27.enum(VIDEO_GENERATE_MODELS),
6424
- prompt: z27.string().min(1),
6425
- duration: z27.number().int().positive().optional(),
6426
- resolution: z27.string().optional(),
7115
+ import { z as z28 } from "zod";
7116
+ var videoModelEnum = z28.enum(VIDEO_GENERATE_MODELS);
7117
+ var VideoGenerateParams = z28.object({
7118
+ model: videoModelEnum,
7119
+ prompt: z28.string().min(1),
7120
+ duration: z28.number().int().positive().optional(),
7121
+ resolution: z28.string().optional(),
6427
7122
  // Union of ratios accepted by at least one curated model (registry gates
6428
7123
  // per-model). 3:2/2:3 are deliberately absent: no registered model takes them.
6429
- aspect_ratio: z27.enum(["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"]).optional(),
6430
- generate_audio: z27.boolean().optional(),
6431
- seed: z27.number().int().nonnegative().optional(),
7124
+ aspect_ratio: z28.enum(["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"]).optional(),
7125
+ generate_audio: z28.boolean().optional(),
7126
+ seed: z28.number().int().nonnegative().optional(),
6432
7127
  // Veo-only passthroughs (routed via `provider.options.google-vertex.parameters`).
6433
- negative_prompt: z27.string().optional(),
6434
- person_generation: z27.string().optional(),
6435
- enhance_prompt: z27.boolean().optional(),
6436
- conditioning_scale: z27.number().optional()
7128
+ negative_prompt: z28.string().optional(),
7129
+ person_generation: z28.string().optional(),
7130
+ enhance_prompt: z28.boolean().optional(),
7131
+ conditioning_scale: z28.number().optional(),
7132
+ // Kling-only passthrough (prompt-adherence dial, sent top-level).
7133
+ cfg_scale: z28.number().optional()
6437
7134
  }).strict();
6438
7135
  var videoGenerateNode = delegated({
6439
7136
  id: "video_generate",
6440
7137
  version: "2.0.0",
6441
7138
  category: "video",
6442
- summary: "Generate video for ad creatives. Two curated models: `bytedance/seedance-2.0` (production quality, photorealistic humans via fal.ai) and `google/veo-3.1-fast` (cheap/fast for iteration and tests). Async with polling.",
6443
- when_to_use: "Use `bytedance/seedance-2.0` for final ad output (photoreal subjects, image-to-video with first/last frames). Use `google/veo-3.1-fast` while iterating to keep cost low. Each model has different supported durations, resolutions, and aspect ratios \u2014 see the README per-model section.",
6444
- inputs: z27.object({
7139
+ summary: "Generate video for ad creatives. Curated roster: `bytedance/seedance-2.0` (identity/product workhorse), `google/veo-3.1` (photoreal cine ceiling + real-face fallback), `google/veo-3.1-fast` (cheap Veo iteration), `kwaivgi/kling-3.0` (motion-transfer/dynamic). Async with polling.",
7140
+ when_to_use: "Use `bytedance/seedance-2.0` for identity/product output. Route real human likenesses to `google/veo-3.1` (dodges the ByteDance real-person filter); use `google/veo-3.1-fast` while iterating to keep cost low; `kwaivgi/kling-3.0` for motion-transfer/hyper-dynamic beats. The scaffolder's scored router picks for you. Each model gates its own durations/resolutions/aspect ratios in the registry \u2014 see the README per-model section.",
7141
+ inputs: z28.object({
6445
7142
  first_frame: ImageRef.optional(),
6446
7143
  last_frame: ImageRef.optional(),
6447
7144
  reference: ImageRef.optional()
6448
7145
  }).loose(),
6449
7146
  params: VideoGenerateParams,
6450
- outputs: z27.object({ video: VideoRef }).strict(),
7147
+ outputs: z28.object({ video: VideoRef }).strict(),
6451
7148
  outputKinds: { video: "video" },
6452
7149
  cost: () => ({ credits: 50, seconds_estimate: 120 })
6453
7150
  });
6454
7151
 
6455
7152
  // src/engine/nodes/remote/videoBackgroundRemove.ts
6456
- import { z as z28 } from "zod";
6457
- var VideoBackgroundRemoveParams = z28.object({
6458
- model: z28.literal("fal/veed-video-background-removal").optional().default("fal/veed-video-background-removal"),
6459
- edge_refinement: z28.boolean().optional().default(true),
6460
- output_codec: z28.enum(["vp9", "h264"]).optional().default("vp9")
7153
+ import { z as z29 } from "zod";
7154
+ var VideoBackgroundRemoveParams = z29.object({
7155
+ model: z29.literal("fal/veed-video-background-removal").optional().default("fal/veed-video-background-removal"),
7156
+ edge_refinement: z29.boolean().optional().default(true),
7157
+ output_codec: z29.enum(["vp9", "h264"]).optional().default("vp9")
6461
7158
  }).strict();
6462
7159
  var videoBackgroundRemoveNode = delegated({
6463
7160
  id: "video_background_remove",
@@ -6465,18 +7162,18 @@ var videoBackgroundRemoveNode = delegated({
6465
7162
  category: "video",
6466
7163
  summary: "Remove the background from a video and return a transparent VP9-with-alpha WebM (or H264 RGB+alpha pair). Drops directly into a hyperframe composition as `<video src='...'>` for chroma-keyed picture-in-picture overlays. Powered by fal.ai `veed/video-background-removal/fast`.",
6467
7164
  when_to_use: "Use when you need a talking-head or subject to float over a custom background in a hyperframe composition. Pair with hyperframe_render(composition: screencast-with-talker) for screencast-with-narrator videos. Output is `video/webm` with alpha \u2014 feed straight into `<video src>` in a composition.",
6468
- inputs: z28.object({
7165
+ inputs: z29.object({
6469
7166
  video: VideoRef
6470
7167
  }).strict(),
6471
7168
  params: VideoBackgroundRemoveParams,
6472
- outputs: z28.object({ video: VideoRef }).strict(),
7169
+ outputs: z29.object({ video: VideoRef }).strict(),
6473
7170
  outputKinds: { video: "video" },
6474
7171
  // $0.012 per 30 frames (edge refinement on) — assume ~30fps; refine via fal dashboard.
6475
7172
  cost: () => ({ credits: 50, seconds_estimate: 60 })
6476
7173
  });
6477
7174
 
6478
7175
  // src/engine/nodes/remote/videoDeconstruct.ts
6479
- import { z as z29 } from "zod";
7176
+ import { z as z30 } from "zod";
6480
7177
  var VIDEO_DECONSTRUCT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6481
7178
  var videoDeconstructNode = delegated({
6482
7179
  id: "video_deconstruct",
@@ -6484,34 +7181,34 @@ var videoDeconstructNode = delegated({
6484
7181
  category: "video",
6485
7182
  summary: 'Deconstruct a video into a replication-grade blueprint: scene boundaries, the real start/end frame of every scene (extracted from the video as images), and an exhaustive JSON analysis \u2014 per-scene action detail, camera motion, generation-ready frame/motion prompts, overlay text with full typographic style, floating elements, deeply detailed cast (perceived demographics, ethnicity/skin-tone, styling, market-recasting notes), brand-identified logos (named by brand and what they signal, not by appearance, with on-screen timestamps), dialogue with voice descriptions, music spec, SFX list, plus a word-level transcript. `mode:"index"` is the cheap structure-first pass: scene boundaries + global blueprint only (one LLM call, no frames).',
6486
7183
  when_to_use: 'Use to reverse-engineer a reference video (e.g. a competitor ad) so a new canvas can reproduce or remix it scene by scene. Agent loop: (1) optionally run `mode:"index"` to see the structure cheaply (scene count, boundaries, transcript) before planning; (2) run the full deconstruct; (3) read `analysis` and author the reproduction canvas. The blueprint maps 1:1 onto generation nodes: `analysis.scenes[i]` aligns positionally with `start_frames#i`/`end_frames#i`; per scene, `start_frame_prompt`/`end_frame_prompt` feed image_generate (overlay text is excluded from them by contract \u2014 recomposite it from `overlays`), `motion_prompt` + the two frames feed video_generate (first_frame/last_frame), `dialogue[].voice_description` casts tts/dialogue voices, `global.music.music_prompt` feeds music, `sfx[].sound_effect_prompt` feeds sound_effect, and `overlays`/`floating_elements` drive an ffmpeg/hyperframe overlay pass. Long videos (over ~8 min single-shot): run `mode:"index"` first, then several full nodes IN PARALLEL each with a `start_s`/`end_s` window (\u2264480s, snap edges to index scene boundaries), and merge by concatenating `analysis.scenes`; over-length errors include suggested windows. Inject fields into downstream prompts via `{{slot}}`. Pick `~google/gemini-pro-latest` for the densest extraction, `~google/gemini-flash-latest` for cheap/fast passes.',
6487
- inputs: z29.object({ video: VideoRef }).loose(),
6488
- params: z29.object({
6489
- model: z29.enum(VIDEO_DECONSTRUCT_MODELS),
6490
- mode: z29.enum(["full", "index"]).optional(),
6491
- language: z29.string().min(2).max(8).optional(),
6492
- max_scenes: z29.number().int().min(1).max(60).optional(),
6493
- focus: z29.string().optional(),
6494
- start_s: z29.number().min(0).optional(),
6495
- end_s: z29.number().positive().optional(),
7184
+ inputs: z30.object({ video: VideoRef }).loose(),
7185
+ params: z30.object({
7186
+ model: z30.enum(VIDEO_DECONSTRUCT_MODELS),
7187
+ mode: z30.enum(["full", "index"]).optional(),
7188
+ language: z30.string().min(2).max(8).optional(),
7189
+ max_scenes: z30.number().int().min(1).max(60).optional(),
7190
+ focus: z30.string().optional(),
7191
+ start_s: z30.number().min(0).optional(),
7192
+ end_s: z30.number().positive().optional(),
6496
7193
  // Real visual shot-cut timestamps (absolute seconds), detected locally with
6497
7194
  // ffmpeg before the deconstruct. The backend SNAPS its LLM scene boundaries
6498
7195
  // onto these and SPLITS any scene that spans one, so a scene's frames never
6499
7196
  // straddle a hard cut. `scaffold-video` populates this; omit for LLM-only cuts.
6500
- shot_cuts: z29.array(z29.number().min(0)).max(200).optional(),
7197
+ shot_cuts: z30.array(z30.number().min(0)).max(200).optional(),
6501
7198
  // The video model's per-clip ceiling (seconds). A shot longer than this is
6502
7199
  // split into seamless continuation sub-scenes (shared splice frame), so long
6503
7200
  // shots reproduce in full instead of being truncated. `scaffold-video` sets
6504
7201
  // the Seedance ceiling (15); omit to disable length splitting.
6505
- max_clip_s: z29.number().positive().max(60).optional(),
7202
+ max_clip_s: z30.number().positive().max(60).optional(),
6506
7203
  // Transcript provider for the blueprint's dialogue/transcript. Default
6507
7204
  // Groq Whisper; "deepgram" routes to Nova-3 so words carry punctuation.
6508
- transcriber: z29.enum(["groq", "deepgram"]).optional()
7205
+ transcriber: z30.enum(["groq", "deepgram"]).optional()
6509
7206
  }).strict(),
6510
- outputs: z29.object({
7207
+ outputs: z30.object({
6511
7208
  analysis: JsonRef,
6512
7209
  // Absent in mode:"index" (structure only, no Mux frame extraction).
6513
- start_frames: z29.array(ImageRef).min(1).optional(),
6514
- end_frames: z29.array(ImageRef).min(1).optional(),
7210
+ start_frames: z30.array(ImageRef).min(1).optional(),
7211
+ end_frames: z30.array(ImageRef).min(1).optional(),
6515
7212
  transcript: JsonRef
6516
7213
  }).strict(),
6517
7214
  outputKinds: { analysis: "json", start_frames: "image", end_frames: "image", transcript: "json" },
@@ -6519,31 +7216,31 @@ var videoDeconstructNode = delegated({
6519
7216
  });
6520
7217
 
6521
7218
  // src/engine/nodes/remote/videoLipsync.ts
6522
- import { z as z30 } from "zod";
6523
- var FalLipsyncParams = z30.object({
6524
- model: z30.literal("fal/veed-lipsync")
7219
+ import { z as z31 } from "zod";
7220
+ var FalLipsyncParams = z31.object({
7221
+ model: z31.literal("fal/veed-lipsync")
6525
7222
  }).strict();
6526
- var VideoLipsyncParams = z30.discriminatedUnion("model", [FalLipsyncParams]);
7223
+ var VideoLipsyncParams = z31.discriminatedUnion("model", [FalLipsyncParams]);
6527
7224
  var videoLipsyncNode = delegated({
6528
7225
  id: "video_lipsync",
6529
7226
  version: "1.0.0",
6530
7227
  category: "video",
6531
7228
  summary: "Lip-sync a video to an audio track. Currently backed by VEED via fal.ai (`fal/veed-lipsync`). $0.40/min of output.",
6532
- inputs: z30.object({
7229
+ inputs: z31.object({
6533
7230
  video: VideoRef,
6534
7231
  audio: AudioRef
6535
7232
  }).strict(),
6536
7233
  params: VideoLipsyncParams,
6537
- outputs: z30.object({ video: VideoRef }).strict(),
7234
+ outputs: z31.object({ video: VideoRef }).strict(),
6538
7235
  outputKinds: { video: "video" },
6539
7236
  cost: () => ({ credits: 20, seconds_estimate: 120 })
6540
7237
  });
6541
7238
 
6542
7239
  // src/engine/nodes/remote/videoTranscribe.ts
6543
- import { mkdtemp as mkdtemp6, readFile as readFile10, rm as rm6 } from "fs/promises";
7240
+ import { mkdtemp as mkdtemp6, readFile as readFile11, rm as rm6 } from "fs/promises";
6544
7241
  import { tmpdir as tmpdir6 } from "os";
6545
- import path13 from "path";
6546
- import { z as z31 } from "zod";
7242
+ import path14 from "path";
7243
+ import { z as z32 } from "zod";
6547
7244
 
6548
7245
  // src/engine/nodes/local/lib/ffmpeg.ts
6549
7246
  import { execFile as execFile7 } from "child_process";
@@ -6622,29 +7319,32 @@ ${detail.slice(-4e3)}`);
6622
7319
  }
6623
7320
 
6624
7321
  // src/engine/nodes/remote/videoTranscribe.ts
6625
- var VideoTranscribeParams = z31.object({
6626
- language: z31.string().min(2).max(8).optional(),
7322
+ var VideoTranscribeParams = z32.object({
7323
+ language: z32.string().min(2).max(8).optional(),
6627
7324
  // Provider choice is explicit (no env-based silent branching). Default Groq
6628
7325
  // Whisper; "deepgram" routes to Deepgram Nova-3, which additionally emits a
6629
7326
  // `rich` JSON output with punctuated words + paragraph/sentence grouping.
6630
- transcriber: z31.enum(["groq", "deepgram"]).optional()
7327
+ transcriber: z32.enum(["groq", "deepgram"]).optional()
6631
7328
  }).strict();
6632
- var VideoTranscribeInputs = z31.object({
6633
- video: VideoRef
7329
+ var VideoTranscribeInputs = z32.object({
7330
+ // A video (audio auto-extracted locally) OR a bare audio track. The key stays
7331
+ // `video` for back-compat; the backend already accepts audio-kind refs on it —
7332
+ // the local extraction path has been shipping one for every video input.
7333
+ video: z32.union([VideoRef, AudioRef])
6634
7334
  }).strict();
6635
- var VideoTranscribeOutputs = z31.object({
6636
- transcript: z31.custom(),
7335
+ var VideoTranscribeOutputs = z32.object({
7336
+ transcript: z32.custom(),
6637
7337
  // Only emitted by the Deepgram path: full punctuated words + paragraph /
6638
7338
  // sentence grouping with speaker indices. Absent for the default Groq path.
6639
- rich: z31.custom().optional()
7339
+ rich: z32.custom().optional()
6640
7340
  }).strict();
6641
7341
  var AUDIO_EXTRACT_TIMEOUT_MS = 6e4;
6642
7342
  var videoTranscribeNode = defineNode({
6643
7343
  id: "video_transcribe",
6644
- version: "2.2.0",
7344
+ version: "2.3.0",
6645
7345
  category: "language",
6646
7346
  location: "local",
6647
- summary: 'Transcribe a video\'s audio to a word-level JSON transcript. Default `transcriber:"groq"` uses Groq Whisper Large v3 Turbo ($0.04/hr, 10s min); `transcriber:"deepgram"` uses Deepgram Nova-3 ($0.0043/min) and additionally emits a `rich` JSON output with punctuated words + paragraph/sentence grouping (and speaker indices). Automatically extracts audio locally (mono 16 kHz MP3) before uploading \u2014 reduces payload ~100\xD7 and lifts the effective duration limit well beyond Groq\'s 100 MB file cap. The `transcript` output is always an array of {text, start, end} entries ready to feed Hyperframes caption compositions (Deepgram prefers the punctuated word form).',
7347
+ summary: 'Transcribe a video or audio track to a word-level JSON transcript. Default `transcriber:"groq"` uses Groq Whisper Large v3 Turbo ($0.04/hr, 10s min); `transcriber:"deepgram"` uses Deepgram Nova-3 ($0.0043/min) and additionally emits a `rich` JSON output with punctuated words + paragraph/sentence grouping (and speaker indices). Automatically extracts audio locally (mono 16 kHz MP3) before uploading \u2014 reduces payload ~100\xD7 and lifts the effective duration limit well beyond Groq\'s 100 MB file cap. The `transcript` output is always an array of {text, start, end} entries ready to feed Hyperframes caption compositions (Deepgram prefers the punctuated word form).',
6648
7348
  when_to_use: 'Use to generate burned-in captions for a stitched video. Pair with `hyperframe_render` and a captions composition (e.g. `tiktok-captions`) by passing the transcript JSON through `params.variables.transcript`. Pick `params.language` to filter out non-target speech (e.g. "es" for Spanish). Pick `transcriber:"deepgram"` when you want punctuation/paragraphs (read them from the `rich` output) or speaker grouping. Requires ffmpeg on PATH for audio extraction (falls back to full video upload if unavailable).',
6649
7349
  inputs: VideoTranscribeInputs,
6650
7350
  params: VideoTranscribeParams,
@@ -6656,7 +7356,7 @@ var videoTranscribeNode = defineNode({
6656
7356
  const effectiveInputs = audioInput ?? inputs;
6657
7357
  return await callBackendExec({
6658
7358
  nodeType: "video_transcribe",
6659
- nodeVersion: "2.2.0",
7359
+ nodeVersion: "2.3.0",
6660
7360
  params,
6661
7361
  inputs: effectiveInputs,
6662
7362
  outputKinds: { transcript: "json", rich: "json" },
@@ -6674,14 +7374,14 @@ async function tryExtractAudio(inputs, ctx) {
6674
7374
  ctx.log("video_transcribe: no audio track detected, sending full video");
6675
7375
  return null;
6676
7376
  }
6677
- tmpDir = await mkdtemp6(path13.join(tmpdir6(), "vtx-"));
6678
- const audioPath = path13.join(tmpDir, "audio.mp3");
7377
+ tmpDir = await mkdtemp6(path14.join(tmpdir6(), "vtx-"));
7378
+ const audioPath = path14.join(tmpDir, "audio.mp3");
6679
7379
  ctx.log("video_transcribe: extracting audio (mono 16kHz mp3)");
6680
7380
  await runFfmpeg(
6681
7381
  ["-i", video.path, "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", "-f", "mp3", "-y", audioPath],
6682
7382
  { timeout_ms: AUDIO_EXTRACT_TIMEOUT_MS }
6683
7383
  );
6684
- const bytes = await readFile10(audioPath);
7384
+ const bytes = await readFile11(audioPath);
6685
7385
  if (bytes.byteLength === 0) {
6686
7386
  ctx.log("video_transcribe: extracted audio is empty, sending full video");
6687
7387
  return null;
@@ -6721,29 +7421,29 @@ async function tryExtractAudio(inputs, ctx) {
6721
7421
  }
6722
7422
 
6723
7423
  // src/engine/nodes/remote/voiceSelect.ts
6724
- import { z as z32 } from "zod";
7424
+ import { z as z33 } from "zod";
6725
7425
  var voiceSelectNode = delegated({
6726
7426
  id: "voice_select",
6727
7427
  version: "1.0.0",
6728
7428
  category: "audio",
6729
7429
  summary: 'Cast an ElevenLabs voice from a natural-language description (e.g. "warm, authoritative female narrator, American accent"). Lists the account\'s voices and ranks them against the brief, emitting the best `voice_id` as a bare-string text asset plus a ranked `candidates` JSON.',
6730
7430
  when_to_use: 'Use to turn a voice description (e.g. from a `video_deconstruct` blueprint\'s `voice_description`) into a usable ElevenLabs voice id, then feed it into a `tts` node by wiring `inputs.voice_ref: $ref:<this>.voice_id` and setting `params.voice: "{{voice_ref}}"` \u2014 the engine splices the id in at run time. Review `candidates` (json) to pick a different voice. Optional `gender`/`age`/`accent`/`language` hints sharpen the ranking.',
6731
- inputs: z32.object({}).loose(),
6732
- params: z32.object({
6733
- description: z32.string().min(1),
6734
- gender: z32.string().optional(),
6735
- age: z32.string().optional(),
6736
- accent: z32.string().optional(),
6737
- language: z32.string().optional(),
6738
- limit: z32.number().int().min(1).max(20).optional()
7431
+ inputs: z33.object({}).loose(),
7432
+ params: z33.object({
7433
+ description: z33.string().min(1),
7434
+ gender: z33.string().optional(),
7435
+ age: z33.string().optional(),
7436
+ accent: z33.string().optional(),
7437
+ language: z33.string().optional(),
7438
+ limit: z33.number().int().min(1).max(20).optional()
6739
7439
  }).strict(),
6740
- outputs: z32.object({ voice_id: TextRef, candidates: JsonRef }).strict(),
7440
+ outputs: z33.object({ voice_id: TextRef, candidates: JsonRef }).strict(),
6741
7441
  outputKinds: { voice_id: "text", candidates: "json" },
6742
7442
  cost: () => ({ credits: 0, seconds_estimate: 5 })
6743
7443
  });
6744
7444
 
6745
7445
  // src/engine/schema/catalog.ts
6746
- import { z as z33 } from "zod";
7446
+ import { z as z34 } from "zod";
6747
7447
  function generateCatalog(registry, opts = {}) {
6748
7448
  const entries = registry.all().map((def) => {
6749
7449
  const cost = def.cost ? safeCost(def) : void 0;
@@ -6754,9 +7454,9 @@ function generateCatalog(registry, opts = {}) {
6754
7454
  summary: def.summary,
6755
7455
  when_to_use: def.when_to_use,
6756
7456
  location: def.location,
6757
- inputs: z33.toJSONSchema(def.inputs, { unrepresentable: "any" }),
6758
- params: z33.toJSONSchema(def.params, { unrepresentable: "any" }),
6759
- outputs: z33.toJSONSchema(def.outputs, { unrepresentable: "any" }),
7457
+ inputs: z34.toJSONSchema(def.inputs, { unrepresentable: "any" }),
7458
+ params: z34.toJSONSchema(def.params, { unrepresentable: "any" }),
7459
+ outputs: z34.toJSONSchema(def.outputs, { unrepresentable: "any" }),
6760
7460
  cost_estimate_credits: cost?.credits,
6761
7461
  runtime_estimate_seconds: cost?.seconds_estimate
6762
7462
  };
@@ -6788,19 +7488,19 @@ function safeCost(def) {
6788
7488
 
6789
7489
  // src/engine/storage/cache-store.ts
6790
7490
  import { randomUUID as randomUUID2 } from "crypto";
6791
- import { mkdir as mkdir3, readFile as readFile11, rename as rename2, writeFile as writeFile7 } from "fs/promises";
6792
- import path14 from "path";
7491
+ import { mkdir as mkdir3, readFile as readFile12, rename as rename2, writeFile as writeFile7 } from "fs/promises";
7492
+ import path15 from "path";
6793
7493
  var LocalCacheStore = class {
6794
7494
  rootDir;
6795
7495
  constructor(rootDir) {
6796
7496
  this.rootDir = rootDir;
6797
7497
  }
6798
7498
  filePath(cacheKey) {
6799
- return path14.join(this.rootDir, `${cacheKey}.json`);
7499
+ return path15.join(this.rootDir, `${cacheKey}.json`);
6800
7500
  }
6801
7501
  async get(cacheKey) {
6802
7502
  try {
6803
- const buf = await readFile11(this.filePath(cacheKey), "utf8");
7503
+ const buf = await readFile12(this.filePath(cacheKey), "utf8");
6804
7504
  return JSON.parse(buf);
6805
7505
  } catch (e) {
6806
7506
  if (e.code === "ENOENT") return null;
@@ -6809,7 +7509,7 @@ var LocalCacheStore = class {
6809
7509
  }
6810
7510
  async put(entry) {
6811
7511
  const dest = this.filePath(entry.cacheKey);
6812
- await mkdir3(path14.dirname(dest), { recursive: true });
7512
+ await mkdir3(path15.dirname(dest), { recursive: true });
6813
7513
  const tmp = `${dest}.tmp-${process.pid}-${randomUUID2()}`;
6814
7514
  await writeFile7(tmp, JSON.stringify(entry, null, 0));
6815
7515
  await rename2(tmp, dest);
@@ -6833,7 +7533,8 @@ var LOCAL_NODES = [
6833
7533
  imagemagickNode,
6834
7534
  videoTranscribeNode,
6835
7535
  fontSpecimenNode,
6836
- audioTimelineNode
7536
+ audioTimelineNode,
7537
+ collectNode
6837
7538
  ];
6838
7539
  var REMOTE_NODES = [
6839
7540
  textGenerateNode,
@@ -6863,12 +7564,12 @@ function defaultRegistry() {
6863
7564
  }
6864
7565
  function createEngineFromEnv(opts = {}) {
6865
7566
  const cwd = opts.cwd ?? process.cwd();
6866
- const cacheDir = opts.cacheDir ?? path15.join(cwd, "canvas", ".cache");
6867
- const outputsDir = opts.outputsDir ?? path15.join(cwd, "canvas");
7567
+ const cacheDir = opts.cacheDir ?? path16.join(cwd, "canvas", ".cache");
7568
+ const outputsDir = opts.outputsDir ?? path16.join(cwd, "canvas");
6868
7569
  const creds = requireCredentialsFromEnv();
6869
7570
  const client = new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey });
6870
- const assets = new LocalAssetStore(path15.join(cacheDir, "assets"));
6871
- const localCache = new LocalCacheStore(path15.join(cacheDir, "index"));
7571
+ const assets = new LocalAssetStore(path16.join(cacheDir, "assets"));
7572
+ const localCache = new LocalCacheStore(path16.join(cacheDir, "index"));
6872
7573
  const remoteCacheEnabled = opts.remoteCache ?? remoteCacheEnabledFromEnv();
6873
7574
  const cache = remoteCacheEnabled ? new LayeredCacheStore({
6874
7575
  local: localCache,
@@ -6888,12 +7589,15 @@ function createEngineFromEnv(opts = {}) {
6888
7589
  }
6889
7590
 
6890
7591
  export {
7592
+ BackendClient,
6891
7593
  requireCredentialsFromEnv,
7594
+ RunAbortedError,
6892
7595
  LayerExecutionError,
6893
7596
  describeFailureReason,
6894
7597
  SEEDANCE_DURATIONS,
6895
7598
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
6896
7599
  IMAGE_GENERATE_MODELS,
7600
+ DEFAULT_VIDEO_GENERATE_MODEL,
6897
7601
  MODEL_REGISTRY,
6898
7602
  resolveConcurrency,
6899
7603
  ulid,
@@ -6902,9 +7606,13 @@ export {
6902
7606
  REF_PREFIX,
6903
7607
  parseRefExpr,
6904
7608
  sha256Hex,
7609
+ SEEDANCE_PROFILE,
7610
+ clipProfileFor,
7611
+ clipParamRecipe,
7612
+ imageProfileFor,
6905
7613
  elementMentionKeywords,
6906
7614
  toModelSafeImage,
6907
- BackendClient2 as BackendClient,
7615
+ BackendClient2,
6908
7616
  Engine2 as Engine,
6909
7617
  LocalAssetStore2 as LocalAssetStore,
6910
7618
  LocalCacheStore2 as LocalCacheStore,
@@ -6914,4 +7622,4 @@ export {
6914
7622
  defaultRegistry,
6915
7623
  createEngineFromEnv
6916
7624
  };
6917
- //# sourceMappingURL=chunk-43KBQLP5.js.map
7625
+ //# sourceMappingURL=chunk-J2LYFDVC.js.map