@koda-sl/baker-cli 0.128.0-dev.70bf43ce4 → 0.131.0-dev.597b50181

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({
@@ -1861,7 +1995,17 @@ var VideoMeta = z.object({
1861
1995
  // Per video_generate node: the scene's natural visual span. The validator warns
1862
1996
  // when a clip's span exceeds the assigned model's max clip duration (e.g. a 9.2s
1863
1997
  // scene on Veo, which caps at 8s) — the clip would truncate.
1864
- clip_spans: z.array(z.object({ node: z.string(), span_s: z.number() })).optional()
1998
+ clip_spans: z.array(z.object({ node: z.string(), span_s: z.number() })).optional(),
1999
+ // WHY the graph has the nodes it has: one route per scene (clip / still_hold /
2000
+ // screen_still / graphic_plate / brand_card / phrase_slice / phrase_run /
2001
+ // composite) + a node-type histogram, stamped by scaffold-video. Read this first
2002
+ // when a canvas looks big.
2003
+ graph_stats: z.object({
2004
+ nodes_total: z.number(),
2005
+ nodes_billable: z.number(),
2006
+ node_types: z.record(z.string(), z.number()),
2007
+ scene_routes: z.array(z.object({ scene: z.number(), route: z.string(), window_s: z.number() }))
2008
+ }).strict().optional()
1865
2009
  }).strict().optional();
1866
2010
  var CanvasMetadata = z.object({
1867
2011
  name: z.string().optional(),
@@ -2280,8 +2424,146 @@ function dfsCycle(u, color, stack, reverseAdj) {
2280
2424
  }
2281
2425
 
2282
2426
  // src/engine/engine/validator.ts
2427
+ import { readFile as readFile2 } from "fs/promises";
2428
+ import path3 from "path";
2283
2429
  import { z as z2 } from "zod";
2284
2430
 
2431
+ // src/engine/scaffold/lib/prompt-profiles.ts
2432
+ var VEO_PERSON_GENERATION = "allow_adult";
2433
+ var VEO_NEGATIVE_PROMPT = "subtitles, captions, on-screen text, watermark, logo, warped face, distorted hands, extra fingers, low quality";
2434
+ var VEO_DURATIONS = [4, 6, 8];
2435
+ var KLING_DURATIONS = [5, 10];
2436
+ var KLING_NEGATIVE_PROMPT = "warped face, distorted hands, extra fingers, morphing, flicker, on-screen text, watermark, low quality";
2437
+ var KLING_CFG_SCALE = 0.7;
2438
+ 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}"`;
2439
+ var SEEDANCE_PROFILE = {
2440
+ id: "seedance",
2441
+ dialogueDirective: SPEAKS_PROSE,
2442
+ extraDirectives: [],
2443
+ // Seedance has no negative_prompt field. Phrase the anti-artifact intent as the POSITIVE
2444
+ // state we want (crisp hands, one stable face, smooth coherent motion, locked identity) —
2445
+ // an "Avoid: warped fingers, morphing faces" list tends to plant those very artifacts.
2446
+ stabilityDirectives: [
2447
+ "hands and fingers crisp and anatomically correct",
2448
+ "one consistent face and identity across every frame",
2449
+ "smooth, temporally coherent motion that holds steady frame to frame"
2450
+ ],
2451
+ keyframeInstruction: "Preserve the composition and colors of the first frame; change only the motion described.",
2452
+ // ~120 words is the Seedance quality sweet spot: past it the model starts dropping beats and
2453
+ // the attention budget dilutes. The validator warns above this; composite scenes that stack
2454
+ // many brief `parts` are the usual offenders.
2455
+ wordBudget: 120,
2456
+ durationSet: SEEDANCE_DURATIONS,
2457
+ paramDefaults: {}
2458
+ };
2459
+ var VEO_PROFILE = {
2460
+ id: "veo",
2461
+ dialogueDirective: SPEAKS_PROSE,
2462
+ // Veo's known failure mode: quoted dialogue triggers burned-in subtitles. Belt
2463
+ // (inline) and braces (the negative_prompt param, below) — the param is the real lever.
2464
+ extraDirectives: ["No subtitles, no captions, no on-screen text of any kind."],
2465
+ stabilityDirectives: [],
2466
+ // Veo takes a negative_prompt PARAM instead (paramDefaults).
2467
+ keyframeInstruction: "Describe the transition and camera move between the frames; the frames fix the content.",
2468
+ wordBudget: 200,
2469
+ durationSet: VEO_DURATIONS,
2470
+ paramDefaults: { person_generation: VEO_PERSON_GENERATION, negative_prompt: VEO_NEGATIVE_PROMPT }
2471
+ };
2472
+ var KLING_PROFILE = {
2473
+ id: "kling",
2474
+ dialogueDirective: SPEAKS_PROSE,
2475
+ // Belt (inline) and braces (the negative_prompt param) — the param is the real lever.
2476
+ extraDirectives: ["Single, deliberate camera move \u2014 no whip pans or rapid cuts within the clip."],
2477
+ stabilityDirectives: [],
2478
+ // Kling takes a negative_prompt PARAM instead (paramDefaults).
2479
+ keyframeInstruction: "Preserve the composition and colors of the first frame; animate the motion described.",
2480
+ wordBudget: 200,
2481
+ durationSet: KLING_DURATIONS,
2482
+ paramDefaults: { negative_prompt: KLING_NEGATIVE_PROMPT, cfg_scale: KLING_CFG_SCALE }
2483
+ };
2484
+ function clipProfileFor(modelId) {
2485
+ if (/^bytedance\/seedance/.test(modelId)) return SEEDANCE_PROFILE;
2486
+ if (/^google\/veo/.test(modelId)) return VEO_PROFILE;
2487
+ if (/^kwaivgi\/kling|^kling\//.test(modelId)) return KLING_PROFILE;
2488
+ return void 0;
2489
+ }
2490
+ function clipParamRecipe(profile, intent) {
2491
+ const out = {};
2492
+ if (intent === "hero") {
2493
+ out.resolution = "1080p";
2494
+ }
2495
+ if (intent === "hook" && profile.id === "kling") {
2496
+ out.cfg_scale = 0.85;
2497
+ }
2498
+ return out;
2499
+ }
2500
+ function nativeDialogueOf(prompt) {
2501
+ if (typeof prompt !== "string") return void 0;
2502
+ const m = prompt.match(/Dialogue: "(.*)"/);
2503
+ return m?.[1]?.trim() || void 0;
2504
+ }
2505
+ var GPT_IMAGE_PROFILE = {
2506
+ id: "gpt-image",
2507
+ constraintPlacement: "last",
2508
+ photorealCue: true,
2509
+ // OpenRouter forwards `quality`; gpt-image-2 already processes inputs at high
2510
+ // fidelity automatically, so we deliberately do NOT send `input_fidelity`.
2511
+ paramDefaults: { quality: "high" }
2512
+ };
2513
+ var GEMINI_IMAGE_PROFILE = {
2514
+ id: "gemini",
2515
+ constraintPlacement: "inline",
2516
+ photorealCue: true,
2517
+ paramDefaults: { quality: "high" }
2518
+ };
2519
+ var RECRAFT_IMAGE_PROFILE = {
2520
+ id: "recraft",
2521
+ constraintPlacement: "inline",
2522
+ photorealCue: false,
2523
+ paramDefaults: {}
2524
+ };
2525
+ function imageProfileFor(modelId) {
2526
+ if (/^openai\/gpt-.*image/.test(modelId)) return GPT_IMAGE_PROFILE;
2527
+ if (/^google\/gemini/.test(modelId)) return GEMINI_IMAGE_PROFILE;
2528
+ if (/^recraft\//.test(modelId)) return RECRAFT_IMAGE_PROFILE;
2529
+ return void 0;
2530
+ }
2531
+
2532
+ // src/engine/scaffold/spine-input.ts
2533
+ function spineInputFlags(work, lenS) {
2534
+ return work.still ? ["-loop", "1", "-t", lenS.toFixed(3)] : [];
2535
+ }
2536
+ function spineInputOps(work, lenS, dims) {
2537
+ const fit = work.still?.fit ?? "fill";
2538
+ const scale = fit === "pad" ? `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=decrease,pad=${dims.w}:${dims.h}:(ow-iw)/2:(oh-ih)/2:color=black` : `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h}`;
2539
+ const normalize = `${scale},format=yuv420p,fps=30,setsar=1,settb=AVTB`;
2540
+ if (work.trim) {
2541
+ const start = work.trim.offset_s > 0 ? `start=${work.trim.offset_s.toFixed(3)}:` : "";
2542
+ return `${normalize},trim=${start}duration=${lenS.toFixed(3)},setpts=PTS-STARTPTS`;
2543
+ }
2544
+ if (work.still) return `${normalize},trim=duration=${lenS.toFixed(3)}`;
2545
+ return normalize;
2546
+ }
2547
+ function parseSpineInputLens(args) {
2548
+ const lens = /* @__PURE__ */ new Map();
2549
+ let inputIndex = 0;
2550
+ for (let i = 0; i < args.length; i++) {
2551
+ if (args[i] !== "-i") continue;
2552
+ if (args[i - 2] === "-t") {
2553
+ const v = Number(args[i - 1]);
2554
+ if (Number.isFinite(v)) lens.set(inputIndex, v);
2555
+ }
2556
+ inputIndex++;
2557
+ }
2558
+ const graph = args.join(" ");
2559
+ for (const m of graph.matchAll(/\[(\d+):v\][^;[]*?trim=(?:start=[\d.]+:)?duration=([\d.]+)/g)) {
2560
+ const idx = Number(m[1]);
2561
+ const v = Number(m[2]);
2562
+ if (Number.isFinite(idx) && Number.isFinite(v)) lens.set(idx, v);
2563
+ }
2564
+ return lens;
2565
+ }
2566
+
2285
2567
  // src/engine/engine/define.ts
2286
2568
  function resolveOutputKinds(spec, params) {
2287
2569
  if (!spec) return {};
@@ -2363,16 +2645,63 @@ var STAGE_CODES = {
2363
2645
  REFERENCE_MISSING: "VIDEO_REFERENCE_MISSING",
2364
2646
  SPAN_EXCEEDS_MODEL: "VIDEO_SPAN_EXCEEDS_MODEL",
2365
2647
  UI_IN_PROMPT: "VIDEO_UI_IN_PROMPT",
2366
- BRANDMARK_IN_PROMPT: "VIDEO_BRANDMARK_IN_PROMPT"
2648
+ BRANDMARK_IN_PROMPT: "VIDEO_BRANDMARK_IN_PROMPT",
2649
+ SPEECH_EXCEEDS_EXTRACT: "VIDEO_SPEECH_EXCEEDS_EXTRACT",
2650
+ PROMPT_PROFILE_MISSING: "VIDEO_PROMPT_PROFILE_MISSING",
2651
+ PROMPT_DECISION_MISSING: "VIDEO_PROMPT_DECISION_MISSING",
2652
+ HOOK_LAYER_MISSING: "VIDEO_HOOK_LAYER_MISSING",
2653
+ IMAGE_PROFILE_MISSING: "IMAGE_PROMPT_PROFILE_MISSING",
2654
+ PROMPT_OVER_BUDGET: "VIDEO_PROMPT_OVER_BUDGET",
2655
+ PERSON_GENERATION_MISSING: "VIDEO_PERSON_GENERATION_MISSING",
2656
+ RAW_FACE_KEYFRAME: "VIDEO_RAW_FACE_KEYFRAME",
2657
+ TIMELINE_TOTAL: "VIDEO_TIMELINE_TOTAL_MISMATCH",
2658
+ NATIVE_SEG_OVERLAP: "VIDEO_NATIVE_SEG_OVERLAP",
2659
+ SPINE_UNNORMALIZED: "VIDEO_SPINE_UNNORMALIZED",
2660
+ ODD_DIMENSIONS: "VIDEO_ODD_DIMENSIONS",
2661
+ REGION_DROPPED: "VIDEO_REGION_DROPPED",
2662
+ OVERLAY_OUT_OF_BOUNDS: "VIDEO_OVERLAY_OUT_OF_BOUNDS",
2663
+ ORPHAN_NODE: "ORPHAN_NODE"
2367
2664
  };
2368
2665
  var SPAN_MODEL_SLACK_S = 0.25;
2369
2666
  var VIDEO_TIME_SLACK_S = 0.75;
2370
2667
  var SPEECH_WORDS_PER_SECOND = 2.5;
2371
2668
  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;
2669
+ function ffmpegTrimSeconds(node) {
2670
+ const args = node.params?.args;
2671
+ if (!Array.isArray(args)) return null;
2672
+ let t = null;
2673
+ for (let i = 0; i < args.length - 1; i++) {
2674
+ if (args[i] === "-t") {
2675
+ const v = Number(args[i + 1]);
2676
+ if (Number.isFinite(v)) t = v;
2677
+ }
2678
+ }
2679
+ return t;
2680
+ }
2681
+ function refNodeOf(ctx, value) {
2682
+ if (typeof value !== "string" || !value.startsWith(REF_PREFIX)) return null;
2683
+ const parsed = parseRefExpr(value);
2684
+ if (!parsed) return null;
2685
+ const idx = ctx.idToIndex.get(parsed.nodeId);
2686
+ return idx === void 0 ? null : ctx.canvas.nodes[idx];
2687
+ }
2688
+ function liveNodeDurationS(ctx, node, depth = 0) {
2689
+ if (!node || depth > 3) return null;
2690
+ if (node.type === "video_generate") {
2691
+ const d = node.params?.duration;
2692
+ return typeof d === "number" ? d : null;
2693
+ }
2694
+ if (node.type === "ffmpeg") {
2695
+ const t = ffmpegTrimSeconds(node);
2696
+ if (t !== null) return t;
2697
+ for (const v of Object.values(node.inputs ?? {})) {
2698
+ const upstream = refNodeOf(ctx, v);
2699
+ const d = liveNodeDurationS(ctx, upstream, depth + 1);
2700
+ if (d !== null) return d;
2701
+ }
2702
+ return null;
2703
+ }
2704
+ return null;
2376
2705
  }
2377
2706
  function validateCanvas(input, registry) {
2378
2707
  const issues = [];
@@ -2395,6 +2724,7 @@ function validateCanvas(input, registry) {
2395
2724
  checkAllSlots(ctx);
2396
2725
  const estimatedCredits = estimateCredits(ctx);
2397
2726
  checkOutputRef(ctx);
2727
+ checkOrphanNodes(ctx);
2398
2728
  checkVideoInvariants(ctx);
2399
2729
  const hasBlocking = issues.some(isBlocking);
2400
2730
  if (hasBlocking) return { ok: false, issues };
@@ -2434,6 +2764,7 @@ async function validateCanvasDeep(input, registry) {
2434
2764
  });
2435
2765
  }
2436
2766
  }
2767
+ await checkOverlayTimingBounds(canvas, issues);
2437
2768
  const hasBlocking = issues.some(isBlocking);
2438
2769
  if (hasBlocking) return { ok: false, issues };
2439
2770
  const warnings = [...shallow.warnings ?? [], ...issues.filter((i) => !isBlocking(i))];
@@ -2675,6 +3006,38 @@ function estimateCredits(ctx) {
2675
3006
  }
2676
3007
  return total;
2677
3008
  }
3009
+ function overlayTimingIssues(html, durationS) {
3010
+ const out = [];
3011
+ for (const m of html.matchAll(/<[^>]*\bdata-start="([\d.]+)"[^>]*\bdata-dur="([\d.]+)"[^>]*>/g)) {
3012
+ const start = Number(m[1]);
3013
+ const dur = Number(m[2]);
3014
+ if (!Number.isFinite(start) || !Number.isFinite(dur)) continue;
3015
+ if (start >= durationS || start + dur > durationS + 0.25) out.push({ start, dur });
3016
+ }
3017
+ return out;
3018
+ }
3019
+ async function checkOverlayTimingBounds(canvas, issues) {
3020
+ const durationS = canvas.metadata?.video?.duration_s;
3021
+ if (typeof durationS !== "number") return;
3022
+ for (let i = 0; i < canvas.nodes.length; i++) {
3023
+ const n = canvas.nodes[i];
3024
+ if (n?.type !== "hyperframe_render") continue;
3025
+ const composition = n.params?.composition;
3026
+ if (typeof composition !== "string" || !path3.isAbsolute(composition)) continue;
3027
+ const html = await readFile2(path3.join(composition, "index.html"), "utf8").catch(() => null);
3028
+ if (!html) continue;
3029
+ for (const w of overlayTimingIssues(html, durationS)) {
3030
+ issues.push({
3031
+ path: `nodes[${i}].params.composition`,
3032
+ code: STAGE_CODES.OVERLAY_OUT_OF_BOUNDS,
3033
+ severity: "warning",
3034
+ node_id: n.id,
3035
+ node_type: n.type,
3036
+ 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`
3037
+ });
3038
+ }
3039
+ }
3040
+ }
2678
3041
  function nativeAudioReachesMix(ctx, scene) {
2679
3042
  const wanted = [
2680
3043
  `$ref:s${scene}_voextract.audio`,
@@ -2707,6 +3070,13 @@ function talkingSceneSatisfied(ctx, entry, scene) {
2707
3070
  });
2708
3071
  }
2709
3072
  function checkVideoInvariants(ctx) {
3073
+ checkPromptProfile(ctx);
3074
+ checkImageProfile(ctx);
3075
+ checkPersonGeneration(ctx);
3076
+ checkPromptBudget(ctx);
3077
+ checkRawFaceKeyframe(ctx);
3078
+ checkPromptDecisions(ctx);
3079
+ checkHookLayers(ctx);
2710
3080
  const meta = ctx.canvas.metadata?.video;
2711
3081
  if (!meta) return;
2712
3082
  const segments = [...meta.vo_segments].sort((a, b) => a.start_s - b.start_s);
@@ -2746,6 +3116,289 @@ function checkVideoInvariants(ctx) {
2746
3116
  checkBrandmarkInPrompt(ctx);
2747
3117
  checkReferenceCompleteness(ctx, meta);
2748
3118
  checkClipSpanFitsModel(ctx, meta);
3119
+ checkTimelineContract(ctx, meta);
3120
+ checkNativeSegOverlap(ctx);
3121
+ checkSpineNormalized(ctx, meta);
3122
+ checkOddDimensions(ctx);
3123
+ checkRegionDropped(ctx);
3124
+ }
3125
+ function checkPromptProfile(ctx) {
3126
+ for (const n of ctx.canvas.nodes) {
3127
+ if (n.type !== "video_generate") continue;
3128
+ const model = n.params?.model;
3129
+ if (typeof model !== "string" || clipProfileFor(model)) continue;
3130
+ ctx.issues.push({
3131
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.model`,
3132
+ code: STAGE_CODES.PROMPT_PROFILE_MISSING,
3133
+ severity: "warning",
3134
+ node_id: n.id,
3135
+ 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`
3136
+ });
3137
+ }
3138
+ }
3139
+ var TECHNIQUE_CUE = /\b(camera|push[- ]?in|pull[- ]?out|pan|dolly|zoom|tilt|track|handheld|locked|orbit|crane|whip|motion|move)\b/i;
3140
+ var NEGATIVES_CUE = /\bavoid:|\bno subtitles|\bnegative\b|keep it clean and stable/i;
3141
+ var VIBE_CUE = /\b(light|lighting|golden|moody|warm|cool|tone|grade|contrast|shadow|glow|rim|backlit|neon|soft|harsh)\b/i;
3142
+ function checkPromptDecisions(ctx) {
3143
+ for (const n of ctx.canvas.nodes) {
3144
+ if (n.type !== "video_generate") continue;
3145
+ const params = n.params;
3146
+ const prompt = typeof params?.prompt === "string" ? params.prompt : "";
3147
+ if (!prompt) continue;
3148
+ const hasTechnique = TECHNIQUE_CUE.test(prompt);
3149
+ const hasNegatives = NEGATIVES_CUE.test(prompt) || typeof params?.negative_prompt === "string";
3150
+ if (hasTechnique || hasNegatives) continue;
3151
+ ctx.issues.push({
3152
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3153
+ code: STAGE_CODES.PROMPT_DECISION_MISSING,
3154
+ severity: "warning",
3155
+ node_id: n.id,
3156
+ 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`
3157
+ });
3158
+ }
3159
+ }
3160
+ function checkHookLayers(ctx) {
3161
+ const scene0 = ctx.canvas.nodes.filter((n) => /^s0(?:[_a-z0-9])*_/.test(n.id) || n.id === "s0");
3162
+ const hookClip = scene0.find((n) => n.type === "video_generate");
3163
+ if (!hookClip) return;
3164
+ const scene0Text = scene0.map((n) => {
3165
+ const p = n.params?.prompt;
3166
+ return typeof p === "string" ? p : "";
3167
+ }).join("\n");
3168
+ const hookParams = hookClip.params;
3169
+ const hasOverlayLayer = ctx.canvas.nodes.some((n) => /hyperframe/.test(n.type));
3170
+ const hasSound = hookParams?.generate_audio === true || /Dialogue:|Audio:/.test(scene0Text) || ctx.canvas.nodes.some((n) => n.type === "tts" || n.type === "dialogue" || n.type === "music");
3171
+ const hasVisual = scene0Text.trim().length > 40;
3172
+ const hasVibe = VIBE_CUE.test(scene0Text);
3173
+ const missing = [];
3174
+ if (!hasOverlayLayer) missing.push("text (overlay)");
3175
+ if (!hasSound) missing.push("sound (line/SFX)");
3176
+ if (!hasVisual) missing.push("visual (the frame)");
3177
+ if (!hasVibe) missing.push("vibe (lighting/tone)");
3178
+ if (missing.length < 2) return;
3179
+ ctx.issues.push({
3180
+ path: `nodes[${ctx.idToIndex.get(hookClip.id) ?? -1}]`,
3181
+ code: STAGE_CODES.HOOK_LAYER_MISSING,
3182
+ severity: "warning",
3183
+ node_id: hookClip.id,
3184
+ 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`
3185
+ });
3186
+ }
3187
+ function checkImageProfile(ctx) {
3188
+ for (const n of ctx.canvas.nodes) {
3189
+ if (n.type !== "image_generate") continue;
3190
+ const model = n.params?.model;
3191
+ if (typeof model !== "string" || imageProfileFor(model)) continue;
3192
+ ctx.issues.push({
3193
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.model`,
3194
+ code: STAGE_CODES.IMAGE_PROFILE_MISSING,
3195
+ severity: "warning",
3196
+ node_id: n.id,
3197
+ 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`
3198
+ });
3199
+ }
3200
+ }
3201
+ function checkPersonGeneration(ctx) {
3202
+ for (const n of ctx.canvas.nodes) {
3203
+ if (n.type !== "video_generate") continue;
3204
+ const params = n.params;
3205
+ const model = params?.model;
3206
+ if (clipProfileFor(typeof model === "string" ? model : "")?.id !== "veo") continue;
3207
+ const hasKeyframe = Boolean(n.inputs?.first_frame ?? n.inputs?.reference);
3208
+ if (!hasKeyframe || params?.person_generation) continue;
3209
+ ctx.issues.push({
3210
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params`,
3211
+ code: STAGE_CODES.PERSON_GENERATION_MISSING,
3212
+ severity: "warning",
3213
+ node_id: n.id,
3214
+ 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)`
3215
+ });
3216
+ }
3217
+ }
3218
+ function checkPromptBudget(ctx) {
3219
+ for (const n of ctx.canvas.nodes) {
3220
+ if (n.type !== "video_generate") continue;
3221
+ const params = n.params;
3222
+ const model = params?.model;
3223
+ const prompt = params?.prompt;
3224
+ const profile = typeof model === "string" ? clipProfileFor(model) : void 0;
3225
+ if (!profile || typeof prompt !== "string") continue;
3226
+ const words = prompt.trim().split(/\s+/).filter(Boolean).length;
3227
+ if (words <= profile.wordBudget) continue;
3228
+ ctx.issues.push({
3229
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3230
+ code: STAGE_CODES.PROMPT_OVER_BUDGET,
3231
+ severity: "warning",
3232
+ node_id: n.id,
3233
+ 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)`
3234
+ });
3235
+ }
3236
+ }
3237
+ function checkRawFaceKeyframe(ctx) {
3238
+ for (const n of ctx.canvas.nodes) {
3239
+ if (n.type !== "video_generate") continue;
3240
+ const model = n.params?.model;
3241
+ if (clipProfileFor(typeof model === "string" ? model : "")?.id !== "seedance") continue;
3242
+ const src = refNodeOf(ctx, n.inputs?.first_frame ?? n.inputs?.reference);
3243
+ if (!src || src.type !== "ingest") continue;
3244
+ ctx.issues.push({
3245
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].inputs.first_frame`,
3246
+ code: STAGE_CODES.RAW_FACE_KEYFRAME,
3247
+ severity: "warning",
3248
+ node_id: n.id,
3249
+ 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`
3250
+ });
3251
+ }
3252
+ }
3253
+ function spineTotalS(ctx, spine) {
3254
+ let total = 0;
3255
+ const inputs = Object.entries(spine.inputs ?? {}).filter(([k]) => /^c\d+$/.test(k));
3256
+ if (inputs.length === 0) return null;
3257
+ const rawArgs = spine.params.args;
3258
+ const absorbed = Array.isArray(rawArgs) ? parseSpineInputLens(rawArgs.map(String)) : /* @__PURE__ */ new Map();
3259
+ for (const [k, v] of inputs) {
3260
+ const inputIdx = Number(k.slice(1));
3261
+ const d = absorbed.get(inputIdx) ?? liveNodeDurationS(ctx, refNodeOf(ctx, v));
3262
+ if (d === null) return null;
3263
+ total += d;
3264
+ }
3265
+ const graph = spine.params.args?.join?.(" ") ?? "";
3266
+ for (const m of String(graph).matchAll(/xfade=transition=[^:]+:duration=([\d.]+)/g)) {
3267
+ total -= Number(m[1]);
3268
+ }
3269
+ return total;
3270
+ }
3271
+ function checkTimelineContract(ctx, meta) {
3272
+ const stamp = meta.timeline;
3273
+ const spineId = stamp?.spine_node ?? (ctx.idToIndex.has("spine") ? "spine" : null);
3274
+ if (!spineId) return;
3275
+ const expected = stamp?.expected_total_s ?? meta.duration_s;
3276
+ checkSpineTotal(ctx, spineId, expected);
3277
+ checkAudioTimelineTotals(ctx, stamp?.audio_mix_node, expected);
3278
+ }
3279
+ function checkSpineTotal(ctx, spineId, expected) {
3280
+ const spineIdx = ctx.idToIndex.get(spineId);
3281
+ const spine = spineIdx === void 0 ? null : ctx.canvas.nodes[spineIdx];
3282
+ const spineLen = spine ? spineTotalS(ctx, spine) : null;
3283
+ if (spineLen === null || Math.abs(spineLen - expected) <= VIDEO_TIME_SLACK_S) return;
3284
+ ctx.issues.push({
3285
+ path: `nodes[${spineIdx}].params.args`,
3286
+ code: STAGE_CODES.TIMELINE_TOTAL,
3287
+ node_id: spineId,
3288
+ 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`
3289
+ });
3290
+ }
3291
+ function checkAudioTimelineTotals(ctx, audioMixNode, expected) {
3292
+ const mustBeFullLength = /* @__PURE__ */ new Set();
3293
+ if (audioMixNode) mustBeFullLength.add(audioMixNode);
3294
+ ctx.canvas.nodes.forEach((n) => {
3295
+ if (n.type !== "audio_voice_convert") return;
3296
+ const track = refNodeOf(ctx, (n.inputs ?? {}).audio);
3297
+ if (track?.type === "audio_timeline") mustBeFullLength.add(track.id);
3298
+ });
3299
+ for (const id of mustBeFullLength) {
3300
+ const idx = ctx.idToIndex.get(id);
3301
+ const node = idx === void 0 ? null : ctx.canvas.nodes[idx];
3302
+ const totalMs = node?.params?.total_ms;
3303
+ if (typeof totalMs !== "number") continue;
3304
+ if (Math.abs(totalMs / 1e3 - expected) > VIDEO_TIME_SLACK_S) {
3305
+ ctx.issues.push({
3306
+ path: `nodes[${idx}].params.total_ms`,
3307
+ code: STAGE_CODES.TIMELINE_TOTAL,
3308
+ node_id: id,
3309
+ 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)}`
3310
+ });
3311
+ }
3312
+ }
3313
+ }
3314
+ function checkNativeSegOverlap(ctx) {
3315
+ for (const conv of ctx.canvas.nodes) {
3316
+ if (conv.type !== "audio_voice_convert") continue;
3317
+ const track = refNodeOf(ctx, (conv.inputs ?? {}).audio);
3318
+ if (track?.type !== "audio_timeline") continue;
3319
+ const params = track.params;
3320
+ const windows = (params.tracks ?? []).map((t) => {
3321
+ const extract = refNodeOf(ctx, (track.inputs ?? {})[t.slot]);
3322
+ const len = t.duration_s ?? (extract ? ffmpegTrimSeconds(extract) : null);
3323
+ return len === null ? null : { slot: t.slot, start: t.start_s, end: t.start_s + len };
3324
+ }).filter((w) => w !== null).sort((a, b) => a.start - b.start);
3325
+ for (let i = 1; i < windows.length; i++) {
3326
+ const prev = windows[i - 1];
3327
+ const cur = windows[i];
3328
+ if (!prev || !cur || cur.start >= prev.end - 0.01) continue;
3329
+ const trackIdx = ctx.idToIndex.get(track.id);
3330
+ ctx.issues.push({
3331
+ path: `nodes[${trackIdx}].params.tracks`,
3332
+ code: STAGE_CODES.NATIVE_SEG_OVERLAP,
3333
+ node_id: track.id,
3334
+ 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`
3335
+ });
3336
+ }
3337
+ }
3338
+ }
3339
+ function checkSpineNormalized(ctx, meta) {
3340
+ const spineId = meta.timeline?.spine_node ?? "spine";
3341
+ const idx = ctx.idToIndex.get(spineId);
3342
+ const spine = idx === void 0 ? null : ctx.canvas.nodes[idx];
3343
+ if (!spine || spine.type !== "ffmpeg") return;
3344
+ const args = spine.params.args;
3345
+ const graph = Array.isArray(args) ? args.join(" ") : "";
3346
+ if (!graph.includes("concat=n=")) return;
3347
+ if (/\[\d+:v\](?:\[|concat)/.test(graph)) {
3348
+ ctx.issues.push({
3349
+ path: `nodes[${idx}].params.args`,
3350
+ code: STAGE_CODES.SPINE_UNNORMALIZED,
3351
+ severity: "warning",
3352
+ node_id: spineId,
3353
+ 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`
3354
+ });
3355
+ }
3356
+ }
3357
+ function checkOddDimensions(ctx) {
3358
+ ctx.canvas.nodes.forEach((node, idx) => {
3359
+ if (node.type !== "ffmpeg") return;
3360
+ const args = node.params.args;
3361
+ const blob = Array.isArray(args) ? args.join(" ") : "";
3362
+ for (const m of blob.matchAll(/(?:scale|crop|pad)=(\d+):(\d+)/g)) {
3363
+ const w = Number(m[1]);
3364
+ const h = Number(m[2]);
3365
+ if (w % 2 === 0 && h % 2 === 0) continue;
3366
+ ctx.issues.push({
3367
+ path: `nodes[${idx}].params.args`,
3368
+ code: STAGE_CODES.ODD_DIMENSIONS,
3369
+ severity: "warning",
3370
+ node_id: node.id,
3371
+ 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`
3372
+ });
3373
+ return;
3374
+ }
3375
+ });
3376
+ }
3377
+ function checkRegionDropped(ctx) {
3378
+ const regionClips = /* @__PURE__ */ new Map();
3379
+ for (const node of ctx.canvas.nodes) {
3380
+ const m = /^s(\d+)_r\d+_clip$/.exec(node.id);
3381
+ if (m && node.type === "video_generate")
3382
+ regionClips.set(m[1], (regionClips.get(m[1]) ?? 0) + 1);
3383
+ }
3384
+ for (const [scene, billed] of regionClips) {
3385
+ const idx = ctx.idToIndex.get(`s${scene}_composite`);
3386
+ if (idx === void 0) continue;
3387
+ const composite = ctx.canvas.nodes[idx];
3388
+ const consumed = Object.keys(composite.inputs ?? {}).filter((k) => /^c\d+$/.test(k)).length;
3389
+ if (billed > consumed) {
3390
+ ctx.issues.push({
3391
+ path: `nodes[${idx}].inputs`,
3392
+ code: STAGE_CODES.REGION_DROPPED,
3393
+ severity: "warning",
3394
+ node_id: composite.id,
3395
+ 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`
3396
+ });
3397
+ }
3398
+ }
3399
+ }
3400
+ function round2(n) {
3401
+ return Math.round(n * 100) / 100;
2749
3402
  }
2750
3403
  var ELEMENT_TYPE_KEYWORDS = {
2751
3404
  animal: ["dog", "puppy", "pup", "cat", "kitten", "kitty", "pet", "canine", "feline"],
@@ -2873,15 +3526,37 @@ function checkSpeechOverrun(ctx, talkingScenes) {
2873
3526
  for (const n of ctx.canvas.nodes) {
2874
3527
  if (!nativeClipRe.test(n.id) || n.type !== "video_generate") continue;
2875
3528
  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
- });
3529
+ if (overrun) {
3530
+ ctx.issues.push({
3531
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3532
+ code: STAGE_CODES.SPEECH_OVERRUN,
3533
+ 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`
3534
+ });
3535
+ continue;
3536
+ }
3537
+ checkSpeechExceedsExtract(ctx, n, entry);
2882
3538
  }
2883
3539
  }
2884
3540
  }
3541
+ function checkSpeechExceedsExtract(ctx, clip, entry) {
3542
+ const params = clip.params;
3543
+ if (params?.generate_audio !== true) return;
3544
+ const line = nativeDialogueOf(params.prompt);
3545
+ if (!line) return;
3546
+ const regionTag = /^s\d+(_r\d+)?_clip$/.exec(clip.id)?.[1] ?? "";
3547
+ const extractIdx = ctx.idToIndex.get(`s${entry.scene}${regionTag}_voextract`) ?? ctx.idToIndex.get(`s${entry.scene}_voextract`);
3548
+ const extract = extractIdx === void 0 ? null : ctx.canvas.nodes[extractIdx];
3549
+ const windowS = extract ? ffmpegTrimSeconds(extract) : null;
3550
+ if (windowS === null) return;
3551
+ const estSpeechS = line.split(/\s+/).filter(Boolean).length * secondsPerWord(entry);
3552
+ if (estSpeechS <= windowS * SPEECH_OVERRUN_RATIO) return;
3553
+ ctx.issues.push({
3554
+ path: `nodes[${ctx.idToIndex.get(clip.id) ?? -1}].params.prompt`,
3555
+ code: STAGE_CODES.SPEECH_EXCEEDS_EXTRACT,
3556
+ node_id: clip.id,
3557
+ 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`
3558
+ });
3559
+ }
2885
3560
  var UI_IN_PROMPT_RE = /\bscreen[- ]?(?:recording|capture|grab|share)\b|\bapp (?:interface|screen)\b|\bphone screen overlay\b/i;
2886
3561
  function checkUiInPrompt(ctx) {
2887
3562
  for (const n of ctx.canvas.nodes) {
@@ -2925,6 +3600,28 @@ function checkAspectConsistency(ctx) {
2925
3600
  message: `video_generate nodes disagree on aspect_ratio (${[...ratios].sort().join(", ")}) \u2014 the spine's segments would render at different shapes and the composite silently crops. Pin every clip to the same aspect_ratio`
2926
3601
  });
2927
3602
  }
3603
+ function checkOrphanNodes(ctx) {
3604
+ const out = ctx.canvas.output;
3605
+ if (!out || !ctx.idToIndex.has(out.node)) return;
3606
+ const graph = buildDepGraph(ctx.canvas);
3607
+ const reachable = /* @__PURE__ */ new Set();
3608
+ const stack = [out.node];
3609
+ while (stack.length > 0) {
3610
+ const id = stack.pop();
3611
+ if (id === void 0 || reachable.has(id)) continue;
3612
+ reachable.add(id);
3613
+ for (const dep of graph.get(id) ?? []) stack.push(dep);
3614
+ }
3615
+ for (const n of ctx.canvas.nodes) {
3616
+ if (reachable.has(n.id) || n.type === "ingest") continue;
3617
+ ctx.issues.push({
3618
+ path: `nodes[${ctx.idToIndex.get(n.id)}]`,
3619
+ code: STAGE_CODES.ORPHAN_NODE,
3620
+ severity: "warning",
3621
+ message: `node "${n.id}" (${n.type}) is not reachable from output "${out.node}" \u2014 it will still execute (and bill, if billable) but nothing consumes its result; wire it in or remove it`
3622
+ });
3623
+ }
3624
+ }
2928
3625
  function checkOutputRef(ctx) {
2929
3626
  const out = ctx.canvas.output;
2930
3627
  if (!out) return;
@@ -2954,9 +3651,9 @@ function checkOutputRef(ctx) {
2954
3651
  function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2955
3652
  for (const issue of err.issues) {
2956
3653
  const tail2 = pathToString(issue.path);
2957
- const path16 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
3654
+ const path17 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
2958
3655
  issues.push({
2959
- path: path16,
3656
+ path: path17,
2960
3657
  code,
2961
3658
  message: issue.message,
2962
3659
  received: issue.code === "invalid_type" ? issue.received : void 0,
@@ -2965,8 +3662,8 @@ function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2965
3662
  });
2966
3663
  }
2967
3664
  }
2968
- function pathToString(path16) {
2969
- return path16.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
3665
+ function pathToString(path17) {
3666
+ return path17.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
2970
3667
  }
2971
3668
  function buildDepGraph(canvas) {
2972
3669
  const graph = /* @__PURE__ */ new Map();
@@ -3065,6 +3762,12 @@ var Engine = class {
3065
3762
  async run(input, opts = {}) {
3066
3763
  const validation = await this.validateDeep(input);
3067
3764
  if (!validation.ok) throw new ValidationError(validation.issues);
3765
+ if (opts.max_credits !== void 0 && validation.estimatedCredits > opts.max_credits) {
3766
+ throw new RunAbortedError(
3767
+ "cost_cap",
3768
+ `estimated ${validation.estimatedCredits} credits exceeds the ${opts.max_credits}-credit cap \u2014 nothing was billed; raise --max-credits or shrink the canvas`
3769
+ );
3770
+ }
3068
3771
  const canvas = validation.canvas;
3069
3772
  const runId = opts.run_id ?? `r_${ulid()}`;
3070
3773
  const writer = new OutputWriter({ outputsDir: this.outputsDir, runId });
@@ -3115,7 +3818,17 @@ var Engine = class {
3115
3818
  const layers = topologicalLayers(graph);
3116
3819
  const limit = resolveConcurrency(opts.concurrency);
3117
3820
  for (const layer of layers) {
3821
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted before layer dispatch");
3822
+ if (opts.max_credits !== void 0 && counters.totalCredits > opts.max_credits) {
3823
+ throw new RunAbortedError(
3824
+ "cost_cap",
3825
+ `spent ${counters.totalCredits} credits, over the ${opts.max_credits}-credit cap \u2014 completed nodes are cached; raise --max-credits to continue where this stopped`
3826
+ );
3827
+ }
3118
3828
  const settled = await mapWithConcurrency(layer, limit, (nodeId) => {
3829
+ if (opts.signal?.aborted) {
3830
+ return Promise.reject(new RunAbortedError("signal", "run aborted before node dispatch"));
3831
+ }
3119
3832
  this.emitProgress(opts, { kind: "node_start", node_id: nodeId });
3120
3833
  return this.executeOne(canvas, nodeId, outputs, runId, writer, opts, needsBytes.has(nodeId)).then((r) => {
3121
3834
  if (r.cached) counters.cachedNodes++;
@@ -3138,10 +3851,12 @@ var Engine = class {
3138
3851
  settled.forEach((result, i) => {
3139
3852
  const nodeId = layer[i];
3140
3853
  if (result.status === "rejected" && nodeId) {
3854
+ if (result.reason instanceof RunAbortedError) return;
3141
3855
  failures.push({ nodeId, reason: result.reason });
3142
3856
  this.emitProgress(opts, { kind: "node_failed", node_id: nodeId });
3143
3857
  }
3144
3858
  });
3859
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted by signal");
3145
3860
  if (failures.length === 1 && failures[0]) throw failures[0].reason;
3146
3861
  if (failures.length > 1) throw new LayerExecutionError(failures);
3147
3862
  }
@@ -3214,7 +3929,7 @@ var Engine = class {
3214
3929
  log: this.log,
3215
3930
  signal: opts.signal
3216
3931
  };
3217
- const preparedForExec = def.location === "local" ? { ...prepared, resolvedInputs: await this.materializeLocalInputs(prepared.resolvedInputs) } : prepared;
3932
+ const preparedForExec = needsLocalMaterialization(def) ? { ...prepared, resolvedInputs: await this.materializeLocalInputs(prepared.resolvedInputs) } : prepared;
3218
3933
  const { parsedInputs, parsedParams } = parseNodeArgs(def, preparedForExec, node.id, node.type);
3219
3934
  const result = await invokeExecute(def, parsedInputs, parsedParams, ctx, node.id, node.type);
3220
3935
  const elapsed = Date.now() - t0;
@@ -3336,6 +4051,9 @@ async function invokeExecute(def, parsedInputs, parsedParams, ctx, nodeId, nodeT
3336
4051
  throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
3337
4052
  }
3338
4053
  }
4054
+ function needsLocalMaterialization(def) {
4055
+ return def.location === "local" && !def.passthroughRefs;
4056
+ }
3339
4057
  function pickFinalOutput(canvas, outputs) {
3340
4058
  if (canvas.output) {
3341
4059
  const node = outputs[canvas.output.node];
@@ -3351,7 +4069,7 @@ function computeNeedsLocalBytes(canvas, graph, registry) {
3351
4069
  const needs = /* @__PURE__ */ new Set();
3352
4070
  for (const [consumerId, deps] of graph) {
3353
4071
  const def = registry.get(typeById.get(consumerId) ?? "");
3354
- if (def?.location !== "local") continue;
4072
+ if (def?.location !== "local" || def.passthroughRefs) continue;
3355
4073
  for (const dep of deps) needs.add(dep);
3356
4074
  }
3357
4075
  return needs;
@@ -3390,11 +4108,11 @@ function hashInputs(inputs) {
3390
4108
  const out = {};
3391
4109
  for (const [k, v] of Object.entries(inputs)) {
3392
4110
  if (Array.isArray(v)) {
3393
- out[k] = v.map((el) => extractSha(el) ?? canonicalLiteral(el));
4111
+ out[k] = v.map((el) => extractSha(el) ?? canonicalLiteral(normalizeParamsForCacheKey(el)));
3394
4112
  } else {
3395
4113
  const s = extractSha(v);
3396
4114
  if (s !== null) out[k] = s;
3397
- else out[k] = canonicalLiteral(v);
4115
+ else out[k] = canonicalLiteral(normalizeParamsForCacheKey(v));
3398
4116
  }
3399
4117
  }
3400
4118
  return out;
@@ -3555,9 +4273,9 @@ var NodeRegistry = class {
3555
4273
 
3556
4274
  // src/engine/nodes/ingest.ts
3557
4275
  import { execFile as execFileCb, spawn } from "child_process";
3558
- import { mkdtemp, readdir, readFile as readFile2, rm, stat as stat2 } from "fs/promises";
4276
+ import { mkdtemp, readdir, readFile as readFile3, rm, stat as stat2 } from "fs/promises";
3559
4277
  import { tmpdir } from "os";
3560
- import path3 from "path";
4278
+ import path4 from "path";
3561
4279
  import { promisify } from "util";
3562
4280
  import { z as z5 } from "zod";
3563
4281
 
@@ -3915,8 +4633,8 @@ function resolveLocalPath(input) {
3915
4633
  `ingest: ~ path expansion is not supported (got "${input}"). Use an absolute path or a cwd-relative path.`
3916
4634
  );
3917
4635
  }
3918
- if (path3.isAbsolute(input)) return input;
3919
- return path3.resolve(process.cwd(), input);
4636
+ if (path4.isAbsolute(input)) return input;
4637
+ return path4.resolve(process.cwd(), input);
3920
4638
  }
3921
4639
  var EXT_TO_MIME = {
3922
4640
  png: "image/png",
@@ -3964,7 +4682,7 @@ function sniffSvg(buf) {
3964
4682
  return head.startsWith("<?xml") ? head.includes("<svg") : head.startsWith("<svg");
3965
4683
  }
3966
4684
  function inferMimeFromPath(absPath, sniffBytes) {
3967
- const ext = path3.extname(absPath).slice(1).toLowerCase();
4685
+ const ext = path4.extname(absPath).slice(1).toLowerCase();
3968
4686
  const fromExt = EXT_TO_MIME[ext];
3969
4687
  if (fromExt) return fromExt;
3970
4688
  const fromBytes = sniffImageMime(sniffBytes);
@@ -4103,7 +4821,7 @@ async function execLocalFile(params, ctx) {
4103
4821
  }
4104
4822
  let bytes;
4105
4823
  try {
4106
- bytes = await readFile2(absPath);
4824
+ bytes = await readFile3(absPath);
4107
4825
  } catch (e) {
4108
4826
  if (e.code === "EACCES") {
4109
4827
  throw localExecError(ctx, `permission_denied: ${absPath}`);
@@ -4154,7 +4872,7 @@ function localFileMetadata(args) {
4154
4872
  strategy: "local_file",
4155
4873
  ingested_at: (/* @__PURE__ */ new Date()).toISOString(),
4156
4874
  file_size: args.fileSize,
4157
- original_filename: path3.basename(args.absPath),
4875
+ original_filename: path4.basename(args.absPath),
4158
4876
  ...args.rasterizedFrom ? { rasterized_from: args.rasterizedFrom } : {},
4159
4877
  ...args.durationMs !== void 0 ? { duration_ms: args.durationMs } : {}
4160
4878
  };
@@ -4178,7 +4896,7 @@ async function execYtDlp(params, ctx) {
4178
4896
  if (params.expect !== "video" && params.expect !== "audio") {
4179
4897
  throw new Error(`ingest: yt_dlp only handles video/audio, got expect=${params.expect}`);
4180
4898
  }
4181
- const workDir = await mkdtemp(path3.join(tmpdir(), "ingest-yt-"));
4899
+ const workDir = await mkdtemp(path4.join(tmpdir(), "ingest-yt-"));
4182
4900
  try {
4183
4901
  const { filePath, info } = await runYtDlp({
4184
4902
  url: params.url,
@@ -4193,7 +4911,7 @@ async function execYtDlp(params, ctx) {
4193
4911
  `file_too_large: yt-dlp output for ${params.url} is ${downloadedStats.size} bytes (limit ${MAX_ASSET_BYTES})`
4194
4912
  );
4195
4913
  }
4196
- const bytes = await readFile2(filePath);
4914
+ const bytes = await readFile3(filePath);
4197
4915
  const kind = params.expect;
4198
4916
  const mime = YT_DLP_MIME[kind];
4199
4917
  const metadata = buildYtDlpMetadata(params.url, info);
@@ -4205,8 +4923,8 @@ async function execYtDlp(params, ctx) {
4205
4923
  }
4206
4924
  }
4207
4925
  async function runYtDlp(args) {
4208
- const outTemplate = path3.join(args.workDir, "out.%(ext)s");
4209
- const infoPath = path3.join(args.workDir, "out.info.json");
4926
+ const outTemplate = path4.join(args.workDir, "out.%(ext)s");
4927
+ const infoPath = path4.join(args.workDir, "out.info.json");
4210
4928
  const argv = [
4211
4929
  args.url,
4212
4930
  "--no-playlist",
@@ -4239,11 +4957,11 @@ ${tail(stderr, 40)}`)
4239
4957
  }
4240
4958
  let info = {};
4241
4959
  try {
4242
- const raw = await readFile2(infoPath, "utf-8");
4960
+ const raw = await readFile3(infoPath, "utf-8");
4243
4961
  info = JSON.parse(raw);
4244
4962
  } catch {
4245
4963
  }
4246
- return { filePath: path3.join(args.workDir, downloaded), info };
4964
+ return { filePath: path4.join(args.workDir, downloaded), info };
4247
4965
  }
4248
4966
  function buildYtDlpMetadata(sourceUrl, info) {
4249
4967
  const out = {
@@ -4332,9 +5050,9 @@ import { z as z6 } from "zod";
4332
5050
 
4333
5051
  // src/engine/nodes/local/lib/cli-runner.ts
4334
5052
  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";
5053
+ import { copyFile as copyFile2, mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2, stat as stat3 } from "fs/promises";
4336
5054
  import { tmpdir as tmpdir2 } from "os";
4337
- import path4 from "path";
5055
+ import path5 from "path";
4338
5056
  import { promisify as promisify2 } from "util";
4339
5057
  var execFile2 = promisify2(execFileCb2);
4340
5058
  var DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
@@ -4361,7 +5079,7 @@ function mimeForExt(ext) {
4361
5079
  }
4362
5080
  function extForAssetRef(ref) {
4363
5081
  if (ref.path) {
4364
- const e = path4.extname(ref.path);
5082
+ const e = path5.extname(ref.path);
4365
5083
  if (e) return e;
4366
5084
  }
4367
5085
  const reverse = {
@@ -4383,14 +5101,14 @@ function planArrayInputSlot(tmpDir, slot, values, lookup, stagedInputs) {
4383
5101
  const ref = values[i];
4384
5102
  if (!ref) continue;
4385
5103
  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)}`);
5104
+ const dest = path5.join(tmpDir, `in_${slot}_${i}${extForAssetRef(ref)}`);
4387
5105
  stagedInputs.push({ srcPath: ref.path, destPath: dest });
4388
5106
  lookup.set(`in.${slot}.${i}`, dest);
4389
5107
  }
4390
5108
  }
4391
5109
  function planSingleInputSlot(tmpDir, slot, ref, lookup, stagedInputs) {
4392
5110
  if (!ref.path) throw new Error(`cli-runner: inputs.${slot} has no local path`);
4393
- const dest = path4.join(tmpDir, `in_${slot}${extForAssetRef(ref)}`);
5111
+ const dest = path5.join(tmpDir, `in_${slot}${extForAssetRef(ref)}`);
4394
5112
  stagedInputs.push({ srcPath: ref.path, destPath: dest });
4395
5113
  lookup.set(`in.${slot}`, dest);
4396
5114
  }
@@ -4398,7 +5116,7 @@ function planOutputs(tmpDir, outputs, lookup) {
4398
5116
  const outputPaths = [];
4399
5117
  for (const [name, spec] of Object.entries(outputs)) {
4400
5118
  const ext = spec.ext.startsWith(".") ? spec.ext : `.${spec.ext}`;
4401
- const absPath = path4.join(tmpDir, `out_${name}${ext}`);
5119
+ const absPath = path5.join(tmpDir, `out_${name}${ext}`);
4402
5120
  outputPaths.push({ name, absPath, spec });
4403
5121
  lookup.set(`out.${name}`, absPath);
4404
5122
  }
@@ -4439,8 +5157,8 @@ function rejectRawPaths(substituted, original, stagingDir) {
4439
5157
  throw new Error(`cli-runner: home-relative path "${original}" not allowed in args.`);
4440
5158
  }
4441
5159
  if (substituted.startsWith("/")) {
4442
- const resolved = path4.resolve(substituted);
4443
- if (!resolved.startsWith(`${stagingDir}${path4.sep}`) && resolved !== stagingDir) {
5160
+ const resolved = path5.resolve(substituted);
5161
+ if (!resolved.startsWith(`${stagingDir}${path5.sep}`) && resolved !== stagingDir) {
4444
5162
  throw new Error(
4445
5163
  `cli-runner: raw filesystem path "${original}" not allowed \u2014 declare an input slot and use {{in.<slot>}} instead.`
4446
5164
  );
@@ -4490,7 +5208,7 @@ function tailLines(text, maxLines) {
4490
5208
  }
4491
5209
  async function runCli(opts) {
4492
5210
  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, "")}-`));
5211
+ const tmpDir = await mkdtemp2(path5.join(tmpdir2(), `cli-${bin.replace(/[^a-z0-9]/gi, "")}-`));
4494
5212
  try {
4495
5213
  const { lookup, stagedInputs, outputPaths } = planPlaceholders(tmpDir, inputs, outputs);
4496
5214
  await stageInputs(stagedInputs);
@@ -4512,7 +5230,7 @@ ${tailLines(stderr, 40)}`);
4512
5230
  if (!s?.isFile() || s.size === 0) {
4513
5231
  throw new Error(`cli-runner: declared output "${name}" missing or empty at ${absPath}`);
4514
5232
  }
4515
- const bytes = await readFile3(absPath);
5233
+ const bytes = await readFile4(absPath);
4516
5234
  const ref = await ctx.assets.ingestBytes({
4517
5235
  bytes: Buffer.from(bytes),
4518
5236
  kind: spec.kind,
@@ -4552,6 +5270,12 @@ var Track = z6.object({
4552
5270
  slot: z6.string().min(1),
4553
5271
  /** When this track starts on the timeline, seconds from 0. */
4554
5272
  start_s: z6.number().min(0),
5273
+ /**
5274
+ * Optional hard cap on this track's length, seconds. The clip is trimmed BEFORE
5275
+ * placement, so a source that runs long (an over-long voice extract, a converted
5276
+ * track that came back oversized) cannot bleed into the next track's window.
5277
+ */
5278
+ duration_s: z6.number().positive().optional(),
4555
5279
  /** Optional level adjustment in dB (negative ducks, e.g. a music bed at -12). */
4556
5280
  gain_db: z6.number().optional()
4557
5281
  }).strict();
@@ -4608,7 +5332,10 @@ function buildAudioTimelineArgs(params) {
4608
5332
  params.tracks.forEach((track, i) => {
4609
5333
  inputArgs.push("-i", `{{in.${track.slot}}}`);
4610
5334
  const delayMs = Math.round(track.start_s * 1e3);
4611
- const steps = [`adelay=${delayMs}:all=1`];
5335
+ const steps = [
5336
+ ...track.duration_s !== void 0 ? [`atrim=0:${track.duration_s}`] : [],
5337
+ `adelay=${delayMs}:all=1`
5338
+ ];
4612
5339
  if (track.gain_db !== void 0) steps.push(`volume=${track.gain_db}dB`);
4613
5340
  const label = `a${i}`;
4614
5341
  filterChains.push(`[${i}:a]${steps.join(",")}[${label}]`);
@@ -4626,11 +5353,11 @@ function buildAudioTimelineArgs(params) {
4626
5353
  }
4627
5354
  var audioTimelineNode = defineNode({
4628
5355
  id: "audio_timeline",
4629
- version: "1.1.0",
5356
+ version: "1.2.0",
4630
5357
  category: "audio",
4631
5358
  location: "local",
4632
5359
  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.",
5360
+ 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
5361
  inputs: AudioTimelineInputs,
4635
5362
  params: AudioTimelineParams,
4636
5363
  outputs: AudioTimelineOutputs,
@@ -4682,19 +5409,56 @@ var audioTimelineNode = defineNode({
4682
5409
  }
4683
5410
  });
4684
5411
 
4685
- // src/engine/nodes/local/ffmpeg.ts
5412
+ // src/engine/nodes/local/collect.ts
4686
5413
  import { z as z7 } from "zod";
5414
+ var collectNode = defineNode({
5415
+ id: "collect",
5416
+ version: "1.0.0",
5417
+ category: "data",
5418
+ location: "local",
5419
+ passthroughRefs: true,
5420
+ 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.",
5421
+ 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.",
5422
+ inputs: z7.object({ images: z7.array(ImageRef).min(1) }).strict(),
5423
+ params: z7.object({ labels: z7.array(z7.string().min(1)).min(1).optional() }).strict(),
5424
+ outputs: z7.object({ images: z7.array(ImageRef).min(1) }).strict(),
5425
+ outputKinds: { images: "image" },
5426
+ cost: () => ({ credits: 0, seconds_estimate: 0 }),
5427
+ // Arity is only knowable at validate time when `images` is a literal array;
5428
+ // a single `$ref:` string to an upstream array output defers to runtime.
5429
+ validateExtra: ({ rawParams, rawInputs }) => {
5430
+ const labels = rawParams?.labels;
5431
+ if (!Array.isArray(labels)) return [];
5432
+ if (new Set(labels).size !== labels.length) {
5433
+ return [{ path: "params.labels", message: "labels must be unique \u2014 each names one output variant" }];
5434
+ }
5435
+ const images = rawInputs?.images;
5436
+ if (Array.isArray(images) && labels.length !== images.length) {
5437
+ return [
5438
+ {
5439
+ path: "params.labels",
5440
+ message: `labels has ${labels.length} entries but ${images.length} images are wired \u2014 provide one label per image`
5441
+ }
5442
+ ];
5443
+ }
5444
+ return [];
5445
+ },
5446
+ execute: ({ inputs }) => Promise.resolve({ images: inputs.images })
5447
+ });
5448
+
5449
+ // src/engine/nodes/local/ffmpeg.ts
5450
+ import { z as z8 } from "zod";
4687
5451
  var FFMPEG_BIN2 = "ffmpeg";
4688
- var OutputDecl = z7.object({
4689
- kind: z7.enum(["image", "video", "audio"]),
4690
- ext: z7.string().min(1).max(8)
5452
+ var OutputDecl = z8.object({
5453
+ kind: z8.enum(["image", "video", "audio"]),
5454
+ ext: z8.string().min(1).max(8)
4691
5455
  }).strict();
4692
- var FfmpegParams = z7.object({
4693
- args: z7.array(z7.string()).min(1),
4694
- outputs: z7.record(z7.string(), OutputDecl).default({})
5456
+ var FfmpegParams = z8.object({
5457
+ args: z8.array(z8.string()).min(1),
5458
+ outputs: z8.record(z8.string(), OutputDecl).default({})
4695
5459
  }).strict();
4696
- var FfmpegInputs = z7.record(z7.string(), z7.unknown());
4697
- var FfmpegOutputs = z7.record(z7.string(), z7.custom());
5460
+ var FfmpegInputs = z8.record(z8.string(), z8.unknown());
5461
+ var FfmpegOutputs = z8.record(z8.string(), z8.custom());
4698
5462
  var ffmpegNode = defineNode({
4699
5463
  id: "ffmpeg",
4700
5464
  version: "2.0.0",
@@ -4724,17 +5488,17 @@ var ffmpegNode = defineNode({
4724
5488
  import { mkdtemp as mkdtemp3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
4725
5489
  import { createRequire } from "module";
4726
5490
  import { tmpdir as tmpdir3 } from "os";
4727
- import path6 from "path";
4728
- import { z as z8 } from "zod";
5491
+ import path7 from "path";
5492
+ import { z as z9 } from "zod";
4729
5493
 
4730
5494
  // src/engine/nodes/local/lib/assets.ts
4731
- import { copyFile as copyFile3, readFile as readFile4 } from "fs/promises";
4732
- import path5 from "path";
5495
+ import { copyFile as copyFile3, readFile as readFile5 } from "fs/promises";
5496
+ import path6 from "path";
4733
5497
  async function stageAsset(ref, destDir, filename) {
4734
5498
  if (!ref.path) {
4735
5499
  throw new Error(`stageAsset: ref (${ref.kind}/${ref.mime}) has no local path`);
4736
5500
  }
4737
- const dest = path5.join(destDir, filename);
5501
+ const dest = path6.join(destDir, filename);
4738
5502
  await copyFile3(ref.path, dest);
4739
5503
  return dest;
4740
5504
  }
@@ -4743,7 +5507,7 @@ async function refToUrl(ref) {
4743
5507
  if (!ref.path) {
4744
5508
  throw new Error("refToUrl: AssetRef has neither url nor path");
4745
5509
  }
4746
- const bytes = await readFile4(ref.path);
5510
+ const bytes = await readFile5(ref.path);
4747
5511
  return `data:${ref.mime};base64,${bytes.toString("base64")}`;
4748
5512
  }
4749
5513
  var ASSET_KINDS = /* @__PURE__ */ new Set(["image", "video", "audio", "json", "text", "font"]);
@@ -4761,15 +5525,15 @@ var DEFAULT_SPECIMEN = [
4761
5525
  "abcdefghijklmnopqrstuvwxyz",
4762
5526
  `0123456789 !?&@#$%().,:;'"-`
4763
5527
  ].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()
5528
+ var FontSpecimenParams = z9.object({
5529
+ text: z9.string().min(1).max(2e3).optional().default(DEFAULT_SPECIMEN),
5530
+ font_size: z9.number().int().min(8).max(512).optional().default(72),
5531
+ padding: z9.number().int().min(0).max(512).optional().default(64),
5532
+ line_height: z9.number().min(0.8).max(3).optional().default(1.35),
5533
+ max_width: z9.number().int().min(256).max(4096).optional()
4770
5534
  }).strict();
4771
- var FontSpecimenInputs = z8.object({ font: FontRef }).loose();
4772
- var FontSpecimenOutputs = z8.object({ image: ImageRef }).strict();
5535
+ var FontSpecimenInputs = z9.object({ font: FontRef }).loose();
5536
+ var FontSpecimenOutputs = z9.object({ image: ImageRef }).strict();
4773
5537
  var DEVICE_SCALE_FACTOR = 2;
4774
5538
  var PAGE_TIMEOUT_MS = 3e4;
4775
5539
  function escapeHtml(text) {
@@ -4817,11 +5581,11 @@ var fontSpecimenNode = defineNode({
4817
5581
  outputKinds: { image: "image" },
4818
5582
  cost: () => ({ credits: 0, seconds_estimate: 5 }),
4819
5583
  async execute({ inputs, params, ctx }) {
4820
- const tmp = await mkdtemp3(path6.join(tmpdir3(), "font-specimen-"));
5584
+ const tmp = await mkdtemp3(path7.join(tmpdir3(), "font-specimen-"));
4821
5585
  try {
4822
5586
  const fontFilename = `font.${extForMime(inputs.font.mime)}`;
4823
5587
  await stageAsset(inputs.font, tmp, fontFilename);
4824
- const entryPath = path6.join(tmp, "index.html");
5588
+ const entryPath = path7.join(tmp, "index.html");
4825
5589
  await writeFile3(entryPath, buildSpecimenHtml(params, fontFilename), "utf-8");
4826
5590
  ctx.log(`rendering specimen (${params.font_size}px, ${params.text.split("\n").length} lines)`);
4827
5591
  const pwSpecifier = ["play", "wright"].join("");
@@ -4905,16 +5669,16 @@ var fontSpecimenNode = defineNode({
4905
5669
 
4906
5670
  // src/engine/nodes/local/hyperframe.ts
4907
5671
  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";
5672
+ import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile9, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
4909
5673
  import { createRequire as createRequire2 } from "module";
4910
5674
  import { cpus, tmpdir as tmpdir4 } from "os";
4911
- import path11 from "path";
5675
+ import path12 from "path";
4912
5676
  import { promisify as promisify4 } from "util";
4913
- import { z as z10 } from "zod";
5677
+ import { z as z11 } from "zod";
4914
5678
 
4915
5679
  // 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";
5680
+ import { readdir as readdir2, readFile as readFile6, stat as stat4 } from "fs/promises";
5681
+ import path8 from "path";
4918
5682
  var SKIP_DIRS = /* @__PURE__ */ new Set([".cache", ".git", "node_modules", "dist", "build", ".next", ".turbo"]);
4919
5683
  function isSkippedName(name) {
4920
5684
  if (name.startsWith(".")) return true;
@@ -4931,79 +5695,79 @@ async function collectFiles(root, current) {
4931
5695
  const names = await readdir2(current);
4932
5696
  for (const name of names) {
4933
5697
  if (isSkippedName(name)) continue;
4934
- const abs = path7.join(current, name);
5698
+ const abs = path8.join(current, name);
4935
5699
  const s = await stat4(abs);
4936
5700
  if (s.isDirectory()) {
4937
5701
  out.push(...await collectFiles(root, abs));
4938
5702
  continue;
4939
5703
  }
4940
5704
  if (!s.isFile()) continue;
4941
- const bytes = await readFile5(abs);
4942
- const relPath = path7.relative(root, abs).split(path7.sep).join("/");
5705
+ const bytes = await readFile6(abs);
5706
+ const relPath = path8.relative(root, abs).split(path8.sep).join("/");
4943
5707
  out.push({ relPath, contentSha: sha256Hex(bytes) });
4944
5708
  }
4945
5709
  return out;
4946
5710
  }
4947
5711
 
4948
5712
  // 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({
5713
+ import { readFile as readFile7 } from "fs/promises";
5714
+ import path9 from "path";
5715
+ import { z as z10 } from "zod";
5716
+ var InputKind = z10.enum(["video", "image", "audio", "json"]);
5717
+ var InputSpec = z10.object({
4954
5718
  kind: InputKind,
4955
- required: z9.boolean().optional().default(false),
5719
+ required: z10.boolean().optional().default(false),
4956
5720
  // Filename the composition's HTML references (e.g. `input.mp4`, `logo.png`).
4957
5721
  // Defaults to `<key><ext>` derived from the kind.
4958
- staged_as: z9.string().min(1).optional(),
4959
- description: z9.string().optional()
5722
+ staged_as: z10.string().min(1).optional(),
5723
+ description: z10.string().optional()
4960
5724
  }).strict();
4961
5725
  var ParamSpecBase = {
4962
- required: z9.boolean().optional().default(false),
4963
- description: z9.string().optional()
5726
+ required: z10.boolean().optional().default(false),
5727
+ description: z10.string().optional()
4964
5728
  };
4965
- var StringParam = z9.object({
5729
+ var StringParam = z10.object({
4966
5730
  ...ParamSpecBase,
4967
- kind: z9.literal("string"),
4968
- default: z9.string().optional(),
4969
- enum: z9.array(z9.string()).optional()
5731
+ kind: z10.literal("string"),
5732
+ default: z10.string().optional(),
5733
+ enum: z10.array(z10.string()).optional()
4970
5734
  }).strict();
4971
- var IntegerParam = z9.object({
5735
+ var IntegerParam = z10.object({
4972
5736
  ...ParamSpecBase,
4973
- kind: z9.literal("integer"),
4974
- default: z9.number().int().optional(),
4975
- min: z9.number().int().optional(),
4976
- max: z9.number().int().optional()
5737
+ kind: z10.literal("integer"),
5738
+ default: z10.number().int().optional(),
5739
+ min: z10.number().int().optional(),
5740
+ max: z10.number().int().optional()
4977
5741
  }).strict();
4978
- var NumberParam = z9.object({
5742
+ var NumberParam = z10.object({
4979
5743
  ...ParamSpecBase,
4980
- kind: z9.literal("number"),
4981
- default: z9.number().optional(),
4982
- min: z9.number().optional(),
4983
- max: z9.number().optional()
5744
+ kind: z10.literal("number"),
5745
+ default: z10.number().optional(),
5746
+ min: z10.number().optional(),
5747
+ max: z10.number().optional()
4984
5748
  }).strict();
4985
- var BooleanParam = z9.object({
5749
+ var BooleanParam = z10.object({
4986
5750
  ...ParamSpecBase,
4987
- kind: z9.literal("boolean"),
4988
- default: z9.boolean().optional()
5751
+ kind: z10.literal("boolean"),
5752
+ default: z10.boolean().optional()
4989
5753
  }).strict();
4990
- var ColorParam = z9.object({
5754
+ var ColorParam = z10.object({
4991
5755
  ...ParamSpecBase,
4992
- kind: z9.literal("color"),
4993
- default: z9.string().optional()
5756
+ kind: z10.literal("color"),
5757
+ default: z10.string().optional()
4994
5758
  }).strict();
4995
- var ImageParam = z9.object({
5759
+ var ImageParam = z10.object({
4996
5760
  ...ParamSpecBase,
4997
- kind: z9.literal("image"),
4998
- default: z9.string().optional()
5761
+ kind: z10.literal("image"),
5762
+ default: z10.string().optional()
4999
5763
  }).strict();
5000
- var JsonParam = z9.object({
5764
+ var JsonParam = z10.object({
5001
5765
  ...ParamSpecBase,
5002
- kind: z9.literal("json"),
5003
- schema: z9.unknown().optional(),
5004
- default: z9.unknown().optional()
5766
+ kind: z10.literal("json"),
5767
+ schema: z10.unknown().optional(),
5768
+ default: z10.unknown().optional()
5005
5769
  }).strict();
5006
- var ParamSpec = z9.discriminatedUnion("kind", [
5770
+ var ParamSpec = z10.discriminatedUnion("kind", [
5007
5771
  StringParam,
5008
5772
  IntegerParam,
5009
5773
  NumberParam,
@@ -5012,22 +5776,22 @@ var ParamSpec = z9.discriminatedUnion("kind", [
5012
5776
  ImageParam,
5013
5777
  JsonParam
5014
5778
  ]);
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({})
5779
+ var CompositionMetaSchema = z10.object({
5780
+ id: z10.string().min(1),
5781
+ title: z10.string().min(1),
5782
+ description: z10.string().optional(),
5783
+ width: z10.number().int().positive(),
5784
+ height: z10.number().int().positive(),
5785
+ fps: z10.number().int().positive().default(30),
5786
+ default_duration: z10.number().positive().default(10),
5787
+ inputs: z10.record(z10.string(), InputSpec).default({}),
5788
+ params: z10.record(z10.string(), ParamSpec).default({})
5025
5789
  }).strict();
5026
5790
  async function loadCompositionMeta(compositionDir) {
5027
- const metaPath = path8.join(compositionDir, "meta.json");
5791
+ const metaPath = path9.join(compositionDir, "meta.json");
5028
5792
  let raw;
5029
5793
  try {
5030
- raw = await readFile6(metaPath, "utf-8");
5794
+ raw = await readFile7(metaPath, "utf-8");
5031
5795
  } catch (e) {
5032
5796
  throw new Error(`composition meta: cannot read ${metaPath} (${e.message})`);
5033
5797
  }
@@ -5049,39 +5813,39 @@ function buildParamsSchema(meta) {
5049
5813
  for (const [name, spec] of Object.entries(meta.params)) {
5050
5814
  shape[name] = buildParamFieldSchema(name, spec);
5051
5815
  }
5052
- return z9.object(shape).strict();
5816
+ return z10.object(shape).strict();
5053
5817
  }
5054
5818
  function buildParamFieldSchema(name, spec) {
5055
5819
  switch (spec.kind) {
5056
5820
  case "string": {
5057
- const s = spec.enum && spec.enum.length > 0 ? z9.enum(spec.enum) : z9.string();
5821
+ const s = spec.enum && spec.enum.length > 0 ? z10.enum(spec.enum) : z10.string();
5058
5822
  return finalize(s, spec.default, spec.required);
5059
5823
  }
5060
5824
  case "integer": {
5061
- let s = z9.number().int();
5825
+ let s = z10.number().int();
5062
5826
  if (spec.min !== void 0) s = s.min(spec.min);
5063
5827
  if (spec.max !== void 0) s = s.max(spec.max);
5064
5828
  return finalize(s, spec.default, spec.required);
5065
5829
  }
5066
5830
  case "number": {
5067
- let s = z9.number();
5831
+ let s = z10.number();
5068
5832
  if (spec.min !== void 0) s = s.min(spec.min);
5069
5833
  if (spec.max !== void 0) s = s.max(spec.max);
5070
5834
  return finalize(s, spec.default, spec.required);
5071
5835
  }
5072
5836
  case "boolean":
5073
- return finalize(z9.boolean(), spec.default, spec.required);
5837
+ return finalize(z10.boolean(), spec.default, spec.required);
5074
5838
  case "color": {
5075
- const s = z9.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
5839
+ const s = z10.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
5076
5840
  message: `param "${name}": must be a 3/6/8-digit hex color (e.g. "#ff0066")`
5077
5841
  });
5078
5842
  return finalize(s, spec.default, spec.required);
5079
5843
  }
5080
5844
  case "image":
5081
- return finalize(z9.union([z9.string().min(1), z9.record(z9.string(), z9.unknown())]), spec.default, spec.required);
5845
+ return finalize(z10.union([z10.string().min(1), z10.record(z10.string(), z10.unknown())]), spec.default, spec.required);
5082
5846
  case "json":
5083
5847
  return finalize(
5084
- z9.unknown().refine((v) => v !== void 0, { message: `param "${name}" is required` }),
5848
+ z10.unknown().refine((v) => v !== void 0, { message: `param "${name}" is required` }),
5085
5849
  spec.default,
5086
5850
  spec.required
5087
5851
  );
@@ -5105,8 +5869,8 @@ function defaultFilenameForInput(key, kind) {
5105
5869
 
5106
5870
  // src/engine/nodes/local/lib/hyperframe-check.ts
5107
5871
  import { execFile as execFile3 } from "child_process";
5108
- import { readFile as readFile7 } from "fs/promises";
5109
- import path9 from "path";
5872
+ import { readFile as readFile8 } from "fs/promises";
5873
+ import path10 from "path";
5110
5874
  import { promisify as promisify3 } from "util";
5111
5875
  var execFileAsync = promisify3(execFile3);
5112
5876
  var NEVER_BLOCK = [
@@ -5258,7 +6022,7 @@ ${detail}`);
5258
6022
  }
5259
6023
  let indexHtml = "";
5260
6024
  try {
5261
- indexHtml = await readFile7(path9.join(dir, "index.html"), "utf-8");
6025
+ indexHtml = await readFile8(path10.join(dir, "index.html"), "utf-8");
5262
6026
  } catch {
5263
6027
  indexHtml = "";
5264
6028
  }
@@ -5323,9 +6087,9 @@ ${stderr.slice(0, 1500)}`;
5323
6087
 
5324
6088
  // src/engine/nodes/local/lib/hyperframe-meta.ts
5325
6089
  import { writeFile as writeFile4 } from "fs/promises";
5326
- import path10 from "path";
6090
+ import path11 from "path";
5327
6091
  async function ensureHyperframesMetaJson(tmp, nodeId, meta, duration) {
5328
- const metaPath = path10.join(tmp, "meta.json");
6092
+ const metaPath = path11.join(tmp, "meta.json");
5329
6093
  await writeFile4(
5330
6094
  metaPath,
5331
6095
  JSON.stringify(
@@ -5381,17 +6145,17 @@ function literalize(value) {
5381
6145
  // src/engine/nodes/local/hyperframe.ts
5382
6146
  var execFileAsync2 = promisify4(execFile4);
5383
6147
  var require_2 = createRequire2(import.meta.url);
5384
- var HyperframeParams = z10.object({
5385
- composition: z10.string().min(1),
6148
+ var HyperframeParams = z11.object({
6149
+ composition: z11.string().min(1),
5386
6150
  // Output container. mp4 (default) for delivery; webm/mov render WITH
5387
6151
  // transparency (alpha) when the composition background is transparent —
5388
6152
  // 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()
6153
+ format: z11.enum(["mp4", "webm", "mov"]).optional().default("mp4"),
6154
+ timeout_ms: z11.number().int().positive().optional().default(10 * 60 * 1e3)
6155
+ }).catchall(z11.unknown());
6156
+ var HyperframeInputs = z11.record(z11.string(), z11.custom()).optional().default({});
6157
+ var HyperframeOutputs = z11.object({
6158
+ video: z11.custom()
5395
6159
  }).strict();
5396
6160
  var NODE_OWNED_PARAM_KEYS = /* @__PURE__ */ new Set(["composition", "format", "timeout_ms"]);
5397
6161
  var MIME_BY_FORMAT = {
@@ -5425,7 +6189,7 @@ var hyperframeRenderNode = defineNode({
5425
6189
  const compositionDir = await resolveCompositionDir(params.composition);
5426
6190
  const meta = await loadCompositionMeta(compositionDir);
5427
6191
  const compositionParams = validateAndParseDynamicParams(meta, params);
5428
- const tmp = await mkdtemp4(path11.join(tmpdir4(), "hf-render-"));
6192
+ const tmp = await mkdtemp4(path12.join(tmpdir4(), "hf-render-"));
5429
6193
  try {
5430
6194
  await copyComposition(compositionDir, tmp);
5431
6195
  await vendorGsap(tmp, ctx);
@@ -5435,9 +6199,9 @@ var hyperframeRenderNode = defineNode({
5435
6199
  await substituteCompositionFiles(tmp, substitutionValues);
5436
6200
  await ensureHyperframesMetaJson(tmp, ctx.nodeId, meta, duration);
5437
6201
  await runHyperframesCheck({ dir: tmp, nodeId: "hyperframe_render", ctx, timeoutMs: params.timeout_ms });
5438
- const outputPath = path11.join(tmp, `output.${params.format}`);
6202
+ const outputPath = path12.join(tmp, `output.${params.format}`);
5439
6203
  await runRender({ tmp, outputPath, params, meta, ctx });
5440
- const bytes = await readFile8(outputPath);
6204
+ const bytes = await readFile9(outputPath);
5441
6205
  ctx.log(`rendered ${bytes.length} bytes`);
5442
6206
  const ref = await ctx.assets.ingestBytes({
5443
6207
  bytes: Buffer.from(bytes),
@@ -5459,10 +6223,10 @@ var hyperframeRenderNode = defineNode({
5459
6223
  }
5460
6224
  });
5461
6225
  async function resolveCompositionDir(composition) {
5462
- const compositionPath = path11.isAbsolute(composition) ? composition : path11.resolve(process.cwd(), composition);
6226
+ const compositionPath = path12.isAbsolute(composition) ? composition : path12.resolve(process.cwd(), composition);
5463
6227
  const s = await stat5(compositionPath);
5464
6228
  if (s.isDirectory()) return compositionPath;
5465
- return path11.dirname(compositionPath);
6229
+ return path12.dirname(compositionPath);
5466
6230
  }
5467
6231
  async function validateComposition(rawParams) {
5468
6232
  const issues = await validateCompositionParams(rawParams);
@@ -5544,7 +6308,7 @@ async function copyComposition(srcDir, destDir) {
5544
6308
  await cp(srcDir, destDir, {
5545
6309
  recursive: true,
5546
6310
  filter: (src) => {
5547
- const name = path11.basename(src);
6311
+ const name = path12.basename(src);
5548
6312
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
5549
6313
  return true;
5550
6314
  }
@@ -5553,7 +6317,7 @@ async function copyComposition(srcDir, destDir) {
5553
6317
  async function vendorGsap(tmp, ctx) {
5554
6318
  try {
5555
6319
  const gsapMin = require_2.resolve("gsap/dist/gsap.min.js");
5556
- await copyFile4(gsapMin, path11.join(tmp, "gsap.min.js"));
6320
+ await copyFile4(gsapMin, path12.join(tmp, "gsap.min.js"));
5557
6321
  } catch (e) {
5558
6322
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
5559
6323
  }
@@ -5568,7 +6332,7 @@ async function stageInputs2(tmp, inputs, meta, ctx) {
5568
6332
  await stageAsset(ref, tmp, filename);
5569
6333
  ctx.log(`staged ${spec.kind} \u2192 ${filename}`);
5570
6334
  if (spec.kind === "video" && primaryDuration === null) {
5571
- primaryDuration = await probeDurationSeconds(path11.join(tmp, filename));
6335
+ primaryDuration = await probeDurationSeconds(path12.join(tmp, filename));
5572
6336
  }
5573
6337
  }
5574
6338
  return primaryDuration;
@@ -5614,8 +6378,8 @@ function coerceImageParam(value) {
5614
6378
  throw new Error("hyperframe_render: image param must be a URL string or AssetRef");
5615
6379
  }
5616
6380
  async function substituteCompositionFiles(tmp, values) {
5617
- const entryPath = path11.join(tmp, "index.html");
5618
- const original = await readFile8(entryPath, "utf-8");
6381
+ const entryPath = path12.join(tmp, "index.html");
6382
+ const original = await readFile9(entryPath, "utf-8");
5619
6383
  const { output, missing } = substituteVariables(original, values);
5620
6384
  if (missing.length > 0) {
5621
6385
  throw new Error(
@@ -5631,7 +6395,7 @@ function workerCount() {
5631
6395
  async function runRender(opts) {
5632
6396
  const { tmp, outputPath, params, meta, ctx } = opts;
5633
6397
  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)}`);
6398
+ ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path12.basename(tmp)}`);
5635
6399
  try {
5636
6400
  await execFileAsync2("npx", args, { timeout: params.timeout_ms, maxBuffer: 64 * 1024 * 1024 });
5637
6401
  } catch (e) {
@@ -5675,28 +6439,28 @@ async function probeDurationSeconds(filePath) {
5675
6439
 
5676
6440
  // src/engine/nodes/local/hyperframe-snapshot.ts
5677
6441
  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";
6442
+ import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile10, rm as rm5, writeFile as writeFile6 } from "fs/promises";
5679
6443
  import { createRequire as createRequire3 } from "module";
5680
6444
  import { tmpdir as tmpdir5 } from "os";
5681
- import path12 from "path";
6445
+ import path13 from "path";
5682
6446
  import { promisify as promisify5 } from "util";
5683
- import { z as z11 } from "zod";
6447
+ import { z as z12 } from "zod";
5684
6448
  var _execFileAsync = promisify5(execFile5);
5685
6449
  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) })
6450
+ var WaitForSpec = z12.discriminatedUnion("kind", [
6451
+ z12.object({ kind: z12.literal("auto") }),
6452
+ z12.object({ kind: z12.literal("selector"), value: z12.string().min(1) }),
6453
+ z12.object({ kind: z12.literal("function"), value: z12.string().min(1) }),
6454
+ z12.object({ kind: z12.literal("timeout"), ms: z12.number().int().min(0).max(6e4) })
5691
6455
  ]);
5692
- var HyperframeSnapshotParams = z11.object({
5693
- composition: z11.string().min(1),
6456
+ var HyperframeSnapshotParams = z12.object({
6457
+ composition: z12.string().min(1),
5694
6458
  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()
6459
+ timeout_ms: z12.number().int().positive().optional().default(6e4)
6460
+ }).catchall(z12.unknown());
6461
+ var HyperframeSnapshotInputs = z12.record(z12.string(), z12.custom()).optional().default({});
6462
+ var HyperframeSnapshotOutputs = z12.object({
6463
+ image: z12.custom()
5700
6464
  }).strict();
5701
6465
  var NODE_OWNED_PARAM_KEYS2 = /* @__PURE__ */ new Set(["composition", "wait_for", "timeout_ms"]);
5702
6466
  var DEVICE_SCALE_FACTOR2 = 2;
@@ -5725,7 +6489,7 @@ var hyperframeSnapshotNode = defineNode({
5725
6489
  const compositionDir = await resolveCompositionDir(params.composition);
5726
6490
  const meta = await loadCompositionMeta(compositionDir);
5727
6491
  const compositionParams = validateAndParseDynamicParams2(meta, params);
5728
- const tmp = await mkdtemp5(path12.join(tmpdir5(), "hf-snap-"));
6492
+ const tmp = await mkdtemp5(path13.join(tmpdir5(), "hf-snap-"));
5729
6493
  try {
5730
6494
  await copyComposition2(compositionDir, tmp);
5731
6495
  await vendorGsap2(tmp, ctx);
@@ -5740,7 +6504,7 @@ var hyperframeSnapshotNode = defineNode({
5740
6504
  timeoutMs: params.timeout_ms,
5741
6505
  samples: 1
5742
6506
  });
5743
- const entryPath = path12.join(tmp, "index.html");
6507
+ const entryPath = path13.join(tmp, "index.html");
5744
6508
  const entryUrl = `file://${entryPath}`;
5745
6509
  ctx.log(`snapshotting ${meta.width}x${meta.height}@${DEVICE_SCALE_FACTOR2}x wait=${params.wait_for.kind}`);
5746
6510
  const pwSpecifier = ["play", "wright"].join("");
@@ -5801,7 +6565,7 @@ async function copyComposition2(srcDir, destDir) {
5801
6565
  await cp(srcDir, destDir, {
5802
6566
  recursive: true,
5803
6567
  filter: (src) => {
5804
- const name = path12.basename(src);
6568
+ const name = path13.basename(src);
5805
6569
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
5806
6570
  return true;
5807
6571
  }
@@ -5810,7 +6574,7 @@ async function copyComposition2(srcDir, destDir) {
5810
6574
  async function vendorGsap2(tmp, ctx) {
5811
6575
  try {
5812
6576
  const gsapMin = require_3.resolve("gsap/dist/gsap.min.js");
5813
- await copyFile5(gsapMin, path12.join(tmp, "gsap.min.js"));
6577
+ await copyFile5(gsapMin, path13.join(tmp, "gsap.min.js"));
5814
6578
  } catch (e) {
5815
6579
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
5816
6580
  }
@@ -5844,8 +6608,8 @@ function coerceImageParam2(value) {
5844
6608
  throw new Error("hyperframe_snapshot: image param must be a URL string or AssetRef");
5845
6609
  }
5846
6610
  async function substituteCompositionFiles2(tmp, values) {
5847
- const entryPath = path12.join(tmp, "index.html");
5848
- const original = await readFile9(entryPath, "utf-8");
6611
+ const entryPath = path13.join(tmp, "index.html");
6612
+ const original = await readFile10(entryPath, "utf-8");
5849
6613
  const { output, missing } = substituteVariables(original, values);
5850
6614
  if (missing.length > 0) {
5851
6615
  throw new Error(
@@ -5888,18 +6652,18 @@ async function waitForReady(page, waitFor, timeoutMs) {
5888
6652
  // src/engine/nodes/local/imagemagick.ts
5889
6653
  import { execFile as execFile6 } from "child_process";
5890
6654
  import { promisify as promisify6 } from "util";
5891
- import { z as z12 } from "zod";
6655
+ import { z as z13 } from "zod";
5892
6656
  var execFileAsync3 = promisify6(execFile6);
5893
- var OutputDecl2 = z12.object({
5894
- kind: z12.enum(["image", "video", "audio"]),
5895
- ext: z12.string().min(1).max(8)
6657
+ var OutputDecl2 = z13.object({
6658
+ kind: z13.enum(["image", "video", "audio"]),
6659
+ ext: z13.string().min(1).max(8)
5896
6660
  }).strict();
5897
- var ImageMagickParams = z12.object({
5898
- args: z12.array(z12.string()).min(1),
5899
- outputs: z12.record(z12.string(), OutputDecl2).default({})
6661
+ var ImageMagickParams = z13.object({
6662
+ args: z13.array(z13.string()).min(1),
6663
+ outputs: z13.record(z13.string(), OutputDecl2).default({})
5900
6664
  }).strict();
5901
- var ImageMagickInputs = z12.record(z12.string(), z12.unknown());
5902
- var ImageMagickOutputs = z12.record(z12.string(), z12.custom());
6665
+ var ImageMagickInputs = z13.record(z13.string(), z13.unknown());
6666
+ var ImageMagickOutputs = z13.record(z13.string(), z13.custom());
5903
6667
  var resolvedBin;
5904
6668
  async function resolveBin() {
5905
6669
  if (resolvedBin) return resolvedBin;
@@ -5941,29 +6705,29 @@ var imagemagickNode = defineNode({
5941
6705
  });
5942
6706
 
5943
6707
  // src/engine/nodes/local/text.ts
5944
- import { z as z13 } from "zod";
6708
+ import { z as z14 } from "zod";
5945
6709
  var textNode = defineNode({
5946
6710
  id: "text",
5947
6711
  version: "1.0.0",
5948
6712
  category: "data",
5949
6713
  location: "local",
5950
6714
  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(),
6715
+ inputs: z14.object({}).strict(),
6716
+ params: z14.object({ value: z14.string() }).strict(),
6717
+ outputs: z14.object({ text: z14.string() }).strict(),
5954
6718
  cost: () => ({ credits: 0, seconds_estimate: 0 }),
5955
6719
  execute: ({ params }) => Promise.resolve({ text: params.value })
5956
6720
  });
5957
6721
 
5958
6722
  // 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"),
6723
+ import { z as z15 } from "zod";
6724
+ var AudioVoiceConvertParams = z15.object({
6725
+ model: z15.literal("elevenlabs/eleven_multilingual_sts_v2"),
5962
6726
  /** Target voice id. Splice an upstream `voice_select` via `"{{voice_ref}}"`. */
5963
- voice: z14.string().min(1),
5964
- output_format: z14.string().optional(),
6727
+ voice: z15.string().min(1),
6728
+ output_format: z15.string().optional(),
5965
6729
  /** Strip the source clip's background noise before re-voicing. */
5966
- remove_background_noise: z14.boolean().optional()
6730
+ remove_background_noise: z15.boolean().optional()
5967
6731
  }).strict();
5968
6732
  var audioVoiceConvertNode = delegated({
5969
6733
  id: "audio_voice_convert",
@@ -5971,44 +6735,44 @@ var audioVoiceConvertNode = delegated({
5971
6735
  category: "audio",
5972
6736
  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
6737
  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({
6738
+ inputs: z15.object({
5975
6739
  audio: AudioRef,
5976
6740
  voice_ref: TextRef.optional()
5977
6741
  }).strict(),
5978
6742
  params: AudioVoiceConvertParams,
5979
- outputs: z14.object({ audio: AudioRef }).strict(),
6743
+ outputs: z15.object({ audio: AudioRef }).strict(),
5980
6744
  outputKinds: { audio: "audio" },
5981
6745
  cost: () => ({ credits: 1, seconds_estimate: 20 })
5982
6746
  });
5983
6747
 
5984
6748
  // 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)
6749
+ import { z as z16 } from "zod";
6750
+ var DialogueInput = z16.object({
6751
+ text: z16.string().min(1),
6752
+ voice_id: z16.string().min(1)
5989
6753
  });
5990
6754
  var DIALOGUE_MODELS = ["elevenlabs/eleven_v3"];
5991
- var DialogueParams = z15.object({
5992
- model: z15.enum(DIALOGUE_MODELS),
6755
+ var DialogueParams = z16.object({
6756
+ model: z16.enum(DIALOGUE_MODELS),
5993
6757
  /**
5994
6758
  * Ordered list of lines, each tagged with the voice that should speak it.
5995
6759
  * Up to 10 unique voice_ids; total text across all lines should stay under
5996
6760
  * ~2000 characters for best quality (ElevenLabs guidance).
5997
6761
  */
5998
- inputs: z15.array(DialogueInput).min(1).max(50),
5999
- language_code: z15.string().optional(),
6762
+ inputs: z16.array(DialogueInput).min(1).max(50),
6763
+ language_code: z16.string().optional(),
6000
6764
  /** 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(),
6765
+ settings: z16.record(z16.string(), z16.unknown()).optional(),
6766
+ seed: z16.number().int().min(0).max(4294967295).optional(),
6767
+ apply_text_normalization: z16.enum(["auto", "on", "off"]).optional(),
6004
6768
  /**
6005
6769
  * When true, hits `/v1/text-to-dialogue/with-timestamps` and emits a
6006
6770
  * separate `timestamps` output — character-level alignment plus
6007
6771
  * per-voice segment markers usable for captions, lipsync, or
6008
6772
  * beat-matched cuts in ad creatives.
6009
6773
  */
6010
- with_timestamps: z15.boolean().optional(),
6011
- output_format: z15.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6774
+ with_timestamps: z16.boolean().optional(),
6775
+ output_format: z16.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6012
6776
  }).strict().refine((p) => p.inputs.reduce((sum, line) => sum + line.text.length, 0) <= ELEVENLABS_MAX_TEXT_CHARS, {
6013
6777
  message: `total dialogue text exceeds ${ELEVENLABS_MAX_TEXT_CHARS} characters`,
6014
6778
  path: ["inputs"]
@@ -6019,9 +6783,9 @@ var dialogueNode = delegated({
6019
6783
  category: "audio",
6020
6784
  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
6785
  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(),
6786
+ inputs: z16.object({}).loose(),
6023
6787
  params: DialogueParams,
6024
- outputs: z15.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6788
+ outputs: z16.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6025
6789
  outputKinds: { audio: "audio", timestamps: "json" },
6026
6790
  cost: ({ params }) => {
6027
6791
  const chars = params.inputs.reduce((sum, line) => sum + line.text.length, 0);
@@ -6030,7 +6794,7 @@ var dialogueNode = delegated({
6030
6794
  });
6031
6795
 
6032
6796
  // src/engine/nodes/remote/image.ts
6033
- import { z as z16 } from "zod";
6797
+ import { z as z17 } from "zod";
6034
6798
  var IMAGE_GENERATE_MODELS2 = [
6035
6799
  "openai/gpt-5.4-image-2",
6036
6800
  "google/gemini-3.5-flash",
@@ -6038,41 +6802,44 @@ var IMAGE_GENERATE_MODELS2 = [
6038
6802
  "google/gemini-3-pro-image-preview",
6039
6803
  "recraft/recraft-v4.1-pro-vector"
6040
6804
  ];
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(),
6805
+ var ImageGenerateParams = z17.object({
6806
+ model: z17.enum(IMAGE_GENERATE_MODELS2),
6807
+ prompt: z17.string().min(1),
6808
+ 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(),
6809
+ image_size: z17.enum(["0.5K", "1K", "2K", "4K"]).optional(),
6810
+ // Rendering quality — forwarded into `image_config`. OpenRouter models without a
6811
+ // quality knob ignore it; the registry gates which models accept it (gpt-image, Gemini).
6812
+ quality: z17.enum(["auto", "low", "medium", "high"]).optional(),
6046
6813
  // Recraft v4 vector controls — forwarded into `image_config`. Registry
6047
6814
  // 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()
6815
+ strength: z17.number().min(0).max(1).optional(),
6816
+ rgb_colors: z17.array(z17.array(z17.number().int().min(0).max(255))).optional(),
6817
+ background_rgb_color: z17.array(z17.number().int().min(0).max(255)).optional()
6051
6818
  }).strict();
6052
6819
  var imageGenerateNode = delegated({
6053
6820
  id: "image_generate",
6054
- version: "2.1.0",
6821
+ version: "2.2.0",
6055
6822
  category: "image",
6056
6823
  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
6824
  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
6825
  // `reference` is one image or an ordered array of images. The backend forwards
6059
6826
  // 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(),
6827
+ inputs: z17.object({ reference: z17.union([ImageRef, z17.array(ImageRef).min(1)]).optional() }).loose(),
6061
6828
  params: ImageGenerateParams,
6062
- outputs: z16.object({ images: z16.array(ImageRef).min(1) }).strict(),
6829
+ outputs: z17.object({ images: z17.array(ImageRef).min(1) }).strict(),
6063
6830
  outputKinds: { images: "image" },
6064
6831
  cost: () => ({ credits: 5, seconds_estimate: 10 })
6065
6832
  });
6066
6833
 
6067
6834
  // src/engine/nodes/remote/imageAspectAdapt.ts
6068
- import { z as z17 } from "zod";
6835
+ import { z as z18 } from "zod";
6069
6836
  var ASPECT_ADAPT_MODELS = ["google/gemini-3-pro-image-preview", "google/gemini-3.1-flash-image-preview"];
6070
6837
  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()
6838
+ var ImageAspectAdaptParams = z18.object({
6839
+ model: z18.enum(ASPECT_ADAPT_MODELS),
6840
+ 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" }),
6841
+ guidance: z18.string().min(1).optional(),
6842
+ image_size: z18.enum(["0.5K", "1K", "2K", "4K"]).optional()
6076
6843
  }).strict();
6077
6844
  var imageAspectAdaptNode = delegated({
6078
6845
  id: "image_aspect_adapt",
@@ -6080,9 +6847,9 @@ var imageAspectAdaptNode = delegated({
6080
6847
  category: "image",
6081
6848
  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
6849
  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(),
6850
+ inputs: z18.object({ source: ImageRef }).loose(),
6084
6851
  params: ImageAspectAdaptParams,
6085
- outputs: z17.object({ images: z17.array(ImageRef).min(1) }).strict(),
6852
+ outputs: z18.object({ images: z18.array(ImageRef).min(1) }).strict(),
6086
6853
  outputKinds: { images: "image" },
6087
6854
  cost: ({ params }) => {
6088
6855
  const p = params;
@@ -6095,12 +6862,12 @@ var imageAspectAdaptNode = delegated({
6095
6862
  });
6096
6863
 
6097
6864
  // 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)
6865
+ import { z as z19 } from "zod";
6866
+ var ImageBackgroundRemoveParams = z19.object({
6867
+ model: z19.literal("fal/birefnet-v2").optional().default("fal/birefnet-v2"),
6868
+ model_variant: z19.enum(["General Use (Light)", "General Use (Heavy)", "Matting", "Portrait", "DIS", "HRSOD", "COD"]).optional().default("General Use (Light)"),
6869
+ operating_resolution: z19.enum(["1024x1024", "2048x2048", "2304x2304"]).optional(),
6870
+ mask_only: z19.boolean().optional().default(false)
6104
6871
  }).strict();
6105
6872
  var imageBackgroundRemoveNode = delegated({
6106
6873
  id: "image_background_remove",
@@ -6108,11 +6875,11 @@ var imageBackgroundRemoveNode = delegated({
6108
6875
  category: "image",
6109
6876
  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
6877
  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({
6878
+ inputs: z19.object({
6112
6879
  image: ImageRef
6113
6880
  }).strict(),
6114
6881
  params: ImageBackgroundRemoveParams,
6115
- outputs: z18.object({
6882
+ outputs: z19.object({
6116
6883
  image: ImageRef,
6117
6884
  mask: ImageRef.optional()
6118
6885
  }).strict(),
@@ -6121,7 +6888,7 @@ var imageBackgroundRemoveNode = delegated({
6121
6888
  });
6122
6889
 
6123
6890
  // src/engine/nodes/remote/imageDescribe.ts
6124
- import { z as z19 } from "zod";
6891
+ import { z as z20 } from "zod";
6125
6892
  var IMAGE_DESCRIBE_MODELS = ["~google/gemini-pro-latest", "~google/gemini-flash-latest"];
6126
6893
  var imageDescribeNode = delegated({
6127
6894
  id: "image_describe",
@@ -6129,33 +6896,33 @@ var imageDescribeNode = delegated({
6129
6896
  category: "vision",
6130
6897
  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
6898
  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()
6899
+ inputs: z20.object({ image: ImageRef }).loose(),
6900
+ params: z20.object({
6901
+ model: z20.enum(IMAGE_DESCRIBE_MODELS),
6902
+ focus: z20.string().optional(),
6903
+ context: z20.string().optional(),
6904
+ temperature: z20.number().min(0).max(2).optional(),
6905
+ max_tokens: z20.number().int().positive().optional()
6139
6906
  }).strict(),
6140
- outputs: z19.object({ description: JsonRef }).strict(),
6907
+ outputs: z20.object({ description: JsonRef }).strict(),
6141
6908
  outputKinds: { description: "json" },
6142
6909
  cost: () => ({ credits: 2, seconds_estimate: 10 })
6143
6910
  });
6144
6911
 
6145
6912
  // src/engine/nodes/remote/imageReferenceSheet.ts
6146
- import { z as z20 } from "zod";
6913
+ import { z as z21 } from "zod";
6147
6914
  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),
6915
+ var ImageReferenceSheetParams = z21.object({
6916
+ model: z21.enum(REFERENCE_SHEET_MODELS),
6917
+ subject_description: z21.string().min(1),
6151
6918
  // `location` = a set/room shown from several camera ANGLES (not a rotated subject),
6152
6919
  // 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()
6920
+ subject_type: z21.enum(["character", "person", "product", "location"]),
6921
+ views: z21.array(z21.string().min(1)).min(2).max(8).optional(),
6922
+ style: z21.string().optional(),
6923
+ prompt_override: z21.string().min(1).optional(),
6924
+ 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(),
6925
+ image_size: z21.enum(["0.5K", "1K", "2K", "4K"]).optional()
6159
6926
  }).strict();
6160
6927
  var imageReferenceSheetNode = delegated({
6161
6928
  id: "image_reference_sheet",
@@ -6163,9 +6930,9 @@ var imageReferenceSheetNode = delegated({
6163
6930
  category: "image",
6164
6931
  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
6932
  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(),
6933
+ inputs: z21.object({ references: z21.array(ImageRef).min(1).max(6) }).loose(),
6167
6934
  params: ImageReferenceSheetParams,
6168
- outputs: z20.object({ sheet: ImageRef }).strict(),
6935
+ outputs: z21.object({ sheet: ImageRef }).strict(),
6169
6936
  outputKinds: { sheet: "image" },
6170
6937
  cost: ({ params }) => ({
6171
6938
  credits: params?.model === "google/gemini-3-pro-image-preview" ? 20 : 5,
@@ -6174,10 +6941,10 @@ var imageReferenceSheetNode = delegated({
6174
6941
  });
6175
6942
 
6176
6943
  // 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)
6944
+ import { z as z22 } from "zod";
6945
+ var ImageSearchParams = z22.object({
6946
+ prompt: z22.string().min(1),
6947
+ count: z22.number().int().min(1).max(20).default(5)
6181
6948
  }).strict();
6182
6949
  var imageSearchNode = delegated({
6183
6950
  id: "image_search",
@@ -6185,15 +6952,15 @@ var imageSearchNode = delegated({
6185
6952
  category: "image",
6186
6953
  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
6954
  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(),
6955
+ inputs: z22.object({}).loose(),
6189
6956
  params: ImageSearchParams,
6190
- outputs: z21.object({ images: z21.array(ImageRef).min(1) }).strict(),
6957
+ outputs: z22.object({ images: z22.array(ImageRef).min(1) }).strict(),
6191
6958
  outputKinds: { images: "image" },
6192
6959
  cost: ({ params }) => ({ credits: Math.ceil(2 + params.count / 2), seconds_estimate: 30 })
6193
6960
  });
6194
6961
 
6195
6962
  // src/engine/nodes/remote/imageSelect.ts
6196
- import { z as z22 } from "zod";
6963
+ import { z as z23 } from "zod";
6197
6964
  var IMAGE_SELECT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6198
6965
  var imageSelectNode = delegated({
6199
6966
  id: "image_select",
@@ -6201,15 +6968,15 @@ var imageSelectNode = delegated({
6201
6968
  category: "vision",
6202
6969
  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
6970
  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()
6971
+ inputs: z23.object({ images: z23.array(ImageRef).min(2) }).loose(),
6972
+ params: z23.object({
6973
+ model: z23.enum(IMAGE_SELECT_MODELS),
6974
+ prompt: z23.string().min(1),
6975
+ count: z23.number().int().min(1).default(1),
6976
+ temperature: z23.number().min(0).max(2).optional(),
6977
+ max_tokens: z23.number().int().positive().optional()
6211
6978
  }).strict(),
6212
- outputs: z22.object({ images: z22.array(ImageRef).min(1), reasoning: TextRef }).strict(),
6979
+ outputs: z23.object({ images: z23.array(ImageRef).min(1), reasoning: TextRef }).strict(),
6213
6980
  outputKinds: { images: "image", reasoning: "text" },
6214
6981
  cost: () => ({ credits: 1, seconds_estimate: 5 }),
6215
6982
  // Arity is only knowable at validate time when `images` is a literal array
@@ -6234,34 +7001,34 @@ var imageSelectNode = delegated({
6234
7001
  });
6235
7002
 
6236
7003
  // src/engine/nodes/remote/music.ts
6237
- import { z as z23 } from "zod";
7004
+ import { z as z24 } from "zod";
6238
7005
  var MUSIC_MODELS = ["elevenlabs/music-v1", "elevenlabs/video-background-music-v1"];
6239
- var MusicParams = z23.object({
6240
- model: z23.enum(MUSIC_MODELS),
7006
+ var MusicParams = z24.object({
7007
+ model: z24.enum(MUSIC_MODELS),
6241
7008
  /** Free-form prompt. Used by `elevenlabs/music-v1` (compose-detailed). */
6242
- prompt: z23.string().optional(),
7009
+ prompt: z24.string().optional(),
6243
7010
  /**
6244
7011
  * Structured composition plan (intro / hook / verse / outro sections with
6245
7012
  * per-section styles + durations). Mutually exclusive with `prompt`.
6246
7013
  */
6247
- composition_plan: z23.record(z23.string(), z23.unknown()).optional(),
7014
+ composition_plan: z24.record(z24.string(), z24.unknown()).optional(),
6248
7015
  /** 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(),
7016
+ music_length_ms: z24.number().int().min(3e3).max(ELEVENLABS_MAX_MUSIC_LENGTH_MS).optional(),
7017
+ seed: z24.number().int().optional(),
6251
7018
  /** Prompt mode only — forces an instrumental (no vocals) track. */
6252
- force_instrumental: z23.boolean().optional(),
7019
+ force_instrumental: z24.boolean().optional(),
6253
7020
  /** composition_plan only — honor exact section durations. */
6254
- respect_sections_durations: z23.boolean().optional(),
7021
+ respect_sections_durations: z24.boolean().optional(),
6255
7022
  /** Emit word-level timestamps alongside the audio. */
6256
- with_timestamps: z23.boolean().optional(),
7023
+ with_timestamps: z24.boolean().optional(),
6257
7024
  /**
6258
7025
  * video-to-music only — short description of the desired score
6259
7026
  * ("upbeat synth, fast cuts, 80s") used to bias the model.
6260
7027
  */
6261
- description: z23.string().max(1e3).optional(),
7028
+ description: z24.string().max(1e3).optional(),
6262
7029
  /** 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()
7030
+ tags: z24.array(z24.string()).max(10).optional(),
7031
+ output_format: z24.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6265
7032
  }).strict();
6266
7033
  var musicNode = delegated({
6267
7034
  id: "music",
@@ -6269,9 +7036,9 @@ var musicNode = delegated({
6269
7036
  category: "audio",
6270
7037
  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
7038
  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(),
7039
+ inputs: z24.object({ video: VideoRef.optional() }).loose(),
6273
7040
  params: MusicParams,
6274
- outputs: z23.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
7041
+ outputs: z24.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6275
7042
  outputKinds: { audio: "audio", timestamps: "json" },
6276
7043
  cost: ({ params }) => {
6277
7044
  const seconds = params.music_length_ms ? Math.ceil(params.music_length_ms / 1e3) : 30;
@@ -6302,25 +7069,25 @@ var musicNode = delegated({
6302
7069
  });
6303
7070
 
6304
7071
  // src/engine/nodes/remote/soundEffect.ts
6305
- import { z as z24 } from "zod";
7072
+ import { z as z25 } from "zod";
6306
7073
  var SOUND_EFFECT_MODELS = ["elevenlabs/eleven_text_to_sound_v2"];
6307
- var SoundEffectParams = z24.object({
6308
- model: z24.enum(SOUND_EFFECT_MODELS),
7074
+ var SoundEffectParams = z25.object({
7075
+ model: z25.enum(SOUND_EFFECT_MODELS),
6309
7076
  /** Prompt describing the SFX ("metal door slam", "soft UI tap", "ocean waves"). */
6310
- text: z24.string().min(1),
7077
+ text: z25.string().min(1),
6311
7078
  /**
6312
7079
  * Target length in seconds. 0.5–30. Leave unset to let the model pick the
6313
7080
  * natural length for the described effect.
6314
7081
  */
6315
- duration_seconds: z24.number().min(0.5).max(30).optional(),
7082
+ duration_seconds: z25.number().min(0.5).max(30).optional(),
6316
7083
  /**
6317
7084
  * 0–1. Higher = stick closer to the prompt at the cost of variety; lower
6318
7085
  * = let the model interpret more freely. Defaults to 0.3 on the provider.
6319
7086
  */
6320
- prompt_influence: z24.number().min(0).max(1).optional(),
7087
+ prompt_influence: z25.number().min(0).max(1).optional(),
6321
7088
  /** 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()
7089
+ loop: z25.boolean().optional(),
7090
+ output_format: z25.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6324
7091
  }).strict();
6325
7092
  var soundEffectNode = delegated({
6326
7093
  id: "sound_effect",
@@ -6328,9 +7095,9 @@ var soundEffectNode = delegated({
6328
7095
  category: "audio",
6329
7096
  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
7097
  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(),
7098
+ inputs: z25.object({}).loose(),
6332
7099
  params: SoundEffectParams,
6333
- outputs: z24.object({ audio: AudioRef }).strict(),
7100
+ outputs: z25.object({ audio: AudioRef }).strict(),
6334
7101
  outputKinds: { audio: "audio" },
6335
7102
  cost: ({ params }) => {
6336
7103
  const seconds = params.duration_seconds ?? 5;
@@ -6339,7 +7106,7 @@ var soundEffectNode = delegated({
6339
7106
  });
6340
7107
 
6341
7108
  // src/engine/nodes/remote/textGenerate.ts
6342
- import { z as z25 } from "zod";
7109
+ import { z as z26 } from "zod";
6343
7110
  var TEXT_GENERATE_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6344
7111
  var textGenerateNode = delegated({
6345
7112
  id: "text_generate",
@@ -6347,58 +7114,58 @@ var textGenerateNode = delegated({
6347
7114
  category: "language",
6348
7115
  summary: "Single-turn LLM text generation via OpenRouter. Returns a text response.",
6349
7116
  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()
7117
+ inputs: z26.object({}).loose(),
7118
+ params: z26.object({
7119
+ model: z26.enum(TEXT_GENERATE_MODELS),
7120
+ prompt: z26.string().min(1),
7121
+ system: z26.string().optional(),
7122
+ response_format: z26.enum(["text", "json_object"]).optional(),
7123
+ web_search: z26.boolean().optional(),
7124
+ temperature: z26.number().min(0).max(2).optional(),
7125
+ max_tokens: z26.number().int().positive().optional()
6359
7126
  }).strict(),
6360
- outputs: z25.object({ text: TextRef }).strict(),
7127
+ outputs: z26.object({ text: TextRef }).strict(),
6361
7128
  outputKinds: { text: "text" },
6362
7129
  cost: () => ({ credits: 1, seconds_estimate: 3 })
6363
7130
  });
6364
7131
 
6365
7132
  // src/engine/nodes/remote/tts.ts
6366
- import { z as z26 } from "zod";
7133
+ import { z as z27 } from "zod";
6367
7134
  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()
7135
+ var TtsVoiceSettings = z27.object({
7136
+ stability: z27.number().min(0).max(1).optional(),
7137
+ similarity_boost: z27.number().min(0).max(1).optional(),
7138
+ style: z27.number().min(0).max(1).optional(),
7139
+ use_speaker_boost: z27.boolean().optional(),
7140
+ speed: z27.number().min(0.25).max(4).optional()
6374
7141
  }).strict();
6375
- var TtsPronunciationLocator = z26.object({
6376
- pronunciation_dictionary_id: z26.string().min(1),
6377
- version_id: z26.string().nullable().optional()
7142
+ var TtsPronunciationLocator = z27.object({
7143
+ pronunciation_dictionary_id: z27.string().min(1),
7144
+ version_id: z27.string().nullable().optional()
6378
7145
  }).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),
7146
+ var TtsParams = z27.object({
7147
+ model: z27.enum(TTS_MODELS),
7148
+ text: z27.string().min(1).max(ELEVENLABS_MAX_TEXT_CHARS),
7149
+ voice: z27.string().min(1),
6383
7150
  /** 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(),
7151
+ output_format: z27.enum(ELEVENLABS_OUTPUT_FORMATS).optional(),
7152
+ seed: z27.number().int().min(0).max(4294967295).optional(),
6386
7153
  // 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(),
7154
+ stability: z27.number().min(0).max(1).optional(),
7155
+ similarity_boost: z27.number().min(0).max(1).optional(),
6389
7156
  voice_settings: TtsVoiceSettings.optional(),
6390
7157
  /** 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(),
7158
+ language_code: z27.string().optional(),
7159
+ pronunciation_dictionary_locators: z27.array(TtsPronunciationLocator).max(3).optional(),
7160
+ apply_text_normalization: z27.enum(["auto", "on", "off"]).optional(),
6394
7161
  /** Currently Japanese-only. Adds latency. */
6395
- apply_language_text_normalization: z26.boolean().optional(),
7162
+ apply_language_text_normalization: z27.boolean().optional(),
6396
7163
  /**
6397
7164
  * When true, hits `/v1/text-to-speech/{voice_id}/with-timestamps` and
6398
7165
  * adds a `timestamps` output (character-level alignment) for caption
6399
7166
  * rendering, lipsync, and beat-matched cuts.
6400
7167
  */
6401
- with_timestamps: z26.boolean().optional()
7168
+ with_timestamps: z27.boolean().optional()
6402
7169
  }).strict();
6403
7170
  var ttsNode = delegated({
6404
7171
  id: "tts",
@@ -6406,9 +7173,9 @@ var ttsNode = delegated({
6406
7173
  category: "audio",
6407
7174
  summary: "Single-voice text-to-speech via ElevenLabs Eleven v3. Optional character-level timestamps for caption rendering and beat-matched cuts.",
6408
7175
  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(),
7176
+ inputs: z27.object({}).loose(),
6410
7177
  params: TtsParams,
6411
- outputs: z26.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
7178
+ outputs: z27.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6412
7179
  outputKinds: { audio: "audio", timestamps: "json" },
6413
7180
  cost: ({ params }) => ({
6414
7181
  credits: Math.max(1, Math.ceil(params.text.length * 15e-4)),
@@ -6417,47 +7184,49 @@ var ttsNode = delegated({
6417
7184
  });
6418
7185
 
6419
7186
  // 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(),
7187
+ import { z as z28 } from "zod";
7188
+ var videoModelEnum = z28.enum(VIDEO_GENERATE_MODELS);
7189
+ var VideoGenerateParams = z28.object({
7190
+ model: videoModelEnum,
7191
+ prompt: z28.string().min(1),
7192
+ duration: z28.number().int().positive().optional(),
7193
+ resolution: z28.string().optional(),
6427
7194
  // Union of ratios accepted by at least one curated model (registry gates
6428
7195
  // 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(),
7196
+ aspect_ratio: z28.enum(["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"]).optional(),
7197
+ generate_audio: z28.boolean().optional(),
7198
+ seed: z28.number().int().nonnegative().optional(),
6432
7199
  // 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()
7200
+ negative_prompt: z28.string().optional(),
7201
+ person_generation: z28.string().optional(),
7202
+ enhance_prompt: z28.boolean().optional(),
7203
+ conditioning_scale: z28.number().optional(),
7204
+ // Kling-only passthrough (prompt-adherence dial, sent top-level).
7205
+ cfg_scale: z28.number().optional()
6437
7206
  }).strict();
6438
7207
  var videoGenerateNode = delegated({
6439
7208
  id: "video_generate",
6440
7209
  version: "2.0.0",
6441
7210
  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({
7211
+ 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.",
7212
+ 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.",
7213
+ inputs: z28.object({
6445
7214
  first_frame: ImageRef.optional(),
6446
7215
  last_frame: ImageRef.optional(),
6447
7216
  reference: ImageRef.optional()
6448
7217
  }).loose(),
6449
7218
  params: VideoGenerateParams,
6450
- outputs: z27.object({ video: VideoRef }).strict(),
7219
+ outputs: z28.object({ video: VideoRef }).strict(),
6451
7220
  outputKinds: { video: "video" },
6452
7221
  cost: () => ({ credits: 50, seconds_estimate: 120 })
6453
7222
  });
6454
7223
 
6455
7224
  // 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")
7225
+ import { z as z29 } from "zod";
7226
+ var VideoBackgroundRemoveParams = z29.object({
7227
+ model: z29.literal("fal/veed-video-background-removal").optional().default("fal/veed-video-background-removal"),
7228
+ edge_refinement: z29.boolean().optional().default(true),
7229
+ output_codec: z29.enum(["vp9", "h264"]).optional().default("vp9")
6461
7230
  }).strict();
6462
7231
  var videoBackgroundRemoveNode = delegated({
6463
7232
  id: "video_background_remove",
@@ -6465,18 +7234,18 @@ var videoBackgroundRemoveNode = delegated({
6465
7234
  category: "video",
6466
7235
  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
7236
  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({
7237
+ inputs: z29.object({
6469
7238
  video: VideoRef
6470
7239
  }).strict(),
6471
7240
  params: VideoBackgroundRemoveParams,
6472
- outputs: z28.object({ video: VideoRef }).strict(),
7241
+ outputs: z29.object({ video: VideoRef }).strict(),
6473
7242
  outputKinds: { video: "video" },
6474
7243
  // $0.012 per 30 frames (edge refinement on) — assume ~30fps; refine via fal dashboard.
6475
7244
  cost: () => ({ credits: 50, seconds_estimate: 60 })
6476
7245
  });
6477
7246
 
6478
7247
  // src/engine/nodes/remote/videoDeconstruct.ts
6479
- import { z as z29 } from "zod";
7248
+ import { z as z30 } from "zod";
6480
7249
  var VIDEO_DECONSTRUCT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6481
7250
  var videoDeconstructNode = delegated({
6482
7251
  id: "video_deconstruct",
@@ -6484,34 +7253,34 @@ var videoDeconstructNode = delegated({
6484
7253
  category: "video",
6485
7254
  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
7255
  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(),
7256
+ inputs: z30.object({ video: VideoRef }).loose(),
7257
+ params: z30.object({
7258
+ model: z30.enum(VIDEO_DECONSTRUCT_MODELS),
7259
+ mode: z30.enum(["full", "index"]).optional(),
7260
+ language: z30.string().min(2).max(8).optional(),
7261
+ max_scenes: z30.number().int().min(1).max(60).optional(),
7262
+ focus: z30.string().optional(),
7263
+ start_s: z30.number().min(0).optional(),
7264
+ end_s: z30.number().positive().optional(),
6496
7265
  // Real visual shot-cut timestamps (absolute seconds), detected locally with
6497
7266
  // ffmpeg before the deconstruct. The backend SNAPS its LLM scene boundaries
6498
7267
  // onto these and SPLITS any scene that spans one, so a scene's frames never
6499
7268
  // 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(),
7269
+ shot_cuts: z30.array(z30.number().min(0)).max(200).optional(),
6501
7270
  // The video model's per-clip ceiling (seconds). A shot longer than this is
6502
7271
  // split into seamless continuation sub-scenes (shared splice frame), so long
6503
7272
  // shots reproduce in full instead of being truncated. `scaffold-video` sets
6504
7273
  // the Seedance ceiling (15); omit to disable length splitting.
6505
- max_clip_s: z29.number().positive().max(60).optional(),
7274
+ max_clip_s: z30.number().positive().max(60).optional(),
6506
7275
  // Transcript provider for the blueprint's dialogue/transcript. Default
6507
7276
  // Groq Whisper; "deepgram" routes to Nova-3 so words carry punctuation.
6508
- transcriber: z29.enum(["groq", "deepgram"]).optional()
7277
+ transcriber: z30.enum(["groq", "deepgram"]).optional()
6509
7278
  }).strict(),
6510
- outputs: z29.object({
7279
+ outputs: z30.object({
6511
7280
  analysis: JsonRef,
6512
7281
  // 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(),
7282
+ start_frames: z30.array(ImageRef).min(1).optional(),
7283
+ end_frames: z30.array(ImageRef).min(1).optional(),
6515
7284
  transcript: JsonRef
6516
7285
  }).strict(),
6517
7286
  outputKinds: { analysis: "json", start_frames: "image", end_frames: "image", transcript: "json" },
@@ -6519,31 +7288,31 @@ var videoDeconstructNode = delegated({
6519
7288
  });
6520
7289
 
6521
7290
  // 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")
7291
+ import { z as z31 } from "zod";
7292
+ var FalLipsyncParams = z31.object({
7293
+ model: z31.literal("fal/veed-lipsync")
6525
7294
  }).strict();
6526
- var VideoLipsyncParams = z30.discriminatedUnion("model", [FalLipsyncParams]);
7295
+ var VideoLipsyncParams = z31.discriminatedUnion("model", [FalLipsyncParams]);
6527
7296
  var videoLipsyncNode = delegated({
6528
7297
  id: "video_lipsync",
6529
7298
  version: "1.0.0",
6530
7299
  category: "video",
6531
7300
  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({
7301
+ inputs: z31.object({
6533
7302
  video: VideoRef,
6534
7303
  audio: AudioRef
6535
7304
  }).strict(),
6536
7305
  params: VideoLipsyncParams,
6537
- outputs: z30.object({ video: VideoRef }).strict(),
7306
+ outputs: z31.object({ video: VideoRef }).strict(),
6538
7307
  outputKinds: { video: "video" },
6539
7308
  cost: () => ({ credits: 20, seconds_estimate: 120 })
6540
7309
  });
6541
7310
 
6542
7311
  // src/engine/nodes/remote/videoTranscribe.ts
6543
- import { mkdtemp as mkdtemp6, readFile as readFile10, rm as rm6 } from "fs/promises";
7312
+ import { mkdtemp as mkdtemp6, readFile as readFile11, rm as rm6 } from "fs/promises";
6544
7313
  import { tmpdir as tmpdir6 } from "os";
6545
- import path13 from "path";
6546
- import { z as z31 } from "zod";
7314
+ import path14 from "path";
7315
+ import { z as z32 } from "zod";
6547
7316
 
6548
7317
  // src/engine/nodes/local/lib/ffmpeg.ts
6549
7318
  import { execFile as execFile7 } from "child_process";
@@ -6622,29 +7391,32 @@ ${detail.slice(-4e3)}`);
6622
7391
  }
6623
7392
 
6624
7393
  // src/engine/nodes/remote/videoTranscribe.ts
6625
- var VideoTranscribeParams = z31.object({
6626
- language: z31.string().min(2).max(8).optional(),
7394
+ var VideoTranscribeParams = z32.object({
7395
+ language: z32.string().min(2).max(8).optional(),
6627
7396
  // Provider choice is explicit (no env-based silent branching). Default Groq
6628
7397
  // Whisper; "deepgram" routes to Deepgram Nova-3, which additionally emits a
6629
7398
  // `rich` JSON output with punctuated words + paragraph/sentence grouping.
6630
- transcriber: z31.enum(["groq", "deepgram"]).optional()
7399
+ transcriber: z32.enum(["groq", "deepgram"]).optional()
6631
7400
  }).strict();
6632
- var VideoTranscribeInputs = z31.object({
6633
- video: VideoRef
7401
+ var VideoTranscribeInputs = z32.object({
7402
+ // A video (audio auto-extracted locally) OR a bare audio track. The key stays
7403
+ // `video` for back-compat; the backend already accepts audio-kind refs on it —
7404
+ // the local extraction path has been shipping one for every video input.
7405
+ video: z32.union([VideoRef, AudioRef])
6634
7406
  }).strict();
6635
- var VideoTranscribeOutputs = z31.object({
6636
- transcript: z31.custom(),
7407
+ var VideoTranscribeOutputs = z32.object({
7408
+ transcript: z32.custom(),
6637
7409
  // Only emitted by the Deepgram path: full punctuated words + paragraph /
6638
7410
  // sentence grouping with speaker indices. Absent for the default Groq path.
6639
- rich: z31.custom().optional()
7411
+ rich: z32.custom().optional()
6640
7412
  }).strict();
6641
7413
  var AUDIO_EXTRACT_TIMEOUT_MS = 6e4;
6642
7414
  var videoTranscribeNode = defineNode({
6643
7415
  id: "video_transcribe",
6644
- version: "2.2.0",
7416
+ version: "2.3.0",
6645
7417
  category: "language",
6646
7418
  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).',
7419
+ 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
7420
  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
7421
  inputs: VideoTranscribeInputs,
6650
7422
  params: VideoTranscribeParams,
@@ -6656,7 +7428,7 @@ var videoTranscribeNode = defineNode({
6656
7428
  const effectiveInputs = audioInput ?? inputs;
6657
7429
  return await callBackendExec({
6658
7430
  nodeType: "video_transcribe",
6659
- nodeVersion: "2.2.0",
7431
+ nodeVersion: "2.3.0",
6660
7432
  params,
6661
7433
  inputs: effectiveInputs,
6662
7434
  outputKinds: { transcript: "json", rich: "json" },
@@ -6674,14 +7446,14 @@ async function tryExtractAudio(inputs, ctx) {
6674
7446
  ctx.log("video_transcribe: no audio track detected, sending full video");
6675
7447
  return null;
6676
7448
  }
6677
- tmpDir = await mkdtemp6(path13.join(tmpdir6(), "vtx-"));
6678
- const audioPath = path13.join(tmpDir, "audio.mp3");
7449
+ tmpDir = await mkdtemp6(path14.join(tmpdir6(), "vtx-"));
7450
+ const audioPath = path14.join(tmpDir, "audio.mp3");
6679
7451
  ctx.log("video_transcribe: extracting audio (mono 16kHz mp3)");
6680
7452
  await runFfmpeg(
6681
7453
  ["-i", video.path, "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", "-f", "mp3", "-y", audioPath],
6682
7454
  { timeout_ms: AUDIO_EXTRACT_TIMEOUT_MS }
6683
7455
  );
6684
- const bytes = await readFile10(audioPath);
7456
+ const bytes = await readFile11(audioPath);
6685
7457
  if (bytes.byteLength === 0) {
6686
7458
  ctx.log("video_transcribe: extracted audio is empty, sending full video");
6687
7459
  return null;
@@ -6721,29 +7493,29 @@ async function tryExtractAudio(inputs, ctx) {
6721
7493
  }
6722
7494
 
6723
7495
  // src/engine/nodes/remote/voiceSelect.ts
6724
- import { z as z32 } from "zod";
7496
+ import { z as z33 } from "zod";
6725
7497
  var voiceSelectNode = delegated({
6726
7498
  id: "voice_select",
6727
7499
  version: "1.0.0",
6728
7500
  category: "audio",
6729
7501
  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
7502
  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()
7503
+ inputs: z33.object({}).loose(),
7504
+ params: z33.object({
7505
+ description: z33.string().min(1),
7506
+ gender: z33.string().optional(),
7507
+ age: z33.string().optional(),
7508
+ accent: z33.string().optional(),
7509
+ language: z33.string().optional(),
7510
+ limit: z33.number().int().min(1).max(20).optional()
6739
7511
  }).strict(),
6740
- outputs: z32.object({ voice_id: TextRef, candidates: JsonRef }).strict(),
7512
+ outputs: z33.object({ voice_id: TextRef, candidates: JsonRef }).strict(),
6741
7513
  outputKinds: { voice_id: "text", candidates: "json" },
6742
7514
  cost: () => ({ credits: 0, seconds_estimate: 5 })
6743
7515
  });
6744
7516
 
6745
7517
  // src/engine/schema/catalog.ts
6746
- import { z as z33 } from "zod";
7518
+ import { z as z34 } from "zod";
6747
7519
  function generateCatalog(registry, opts = {}) {
6748
7520
  const entries = registry.all().map((def) => {
6749
7521
  const cost = def.cost ? safeCost(def) : void 0;
@@ -6754,9 +7526,9 @@ function generateCatalog(registry, opts = {}) {
6754
7526
  summary: def.summary,
6755
7527
  when_to_use: def.when_to_use,
6756
7528
  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" }),
7529
+ inputs: z34.toJSONSchema(def.inputs, { unrepresentable: "any" }),
7530
+ params: z34.toJSONSchema(def.params, { unrepresentable: "any" }),
7531
+ outputs: z34.toJSONSchema(def.outputs, { unrepresentable: "any" }),
6760
7532
  cost_estimate_credits: cost?.credits,
6761
7533
  runtime_estimate_seconds: cost?.seconds_estimate
6762
7534
  };
@@ -6788,19 +7560,19 @@ function safeCost(def) {
6788
7560
 
6789
7561
  // src/engine/storage/cache-store.ts
6790
7562
  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";
7563
+ import { mkdir as mkdir3, readFile as readFile12, rename as rename2, writeFile as writeFile7 } from "fs/promises";
7564
+ import path15 from "path";
6793
7565
  var LocalCacheStore = class {
6794
7566
  rootDir;
6795
7567
  constructor(rootDir) {
6796
7568
  this.rootDir = rootDir;
6797
7569
  }
6798
7570
  filePath(cacheKey) {
6799
- return path14.join(this.rootDir, `${cacheKey}.json`);
7571
+ return path15.join(this.rootDir, `${cacheKey}.json`);
6800
7572
  }
6801
7573
  async get(cacheKey) {
6802
7574
  try {
6803
- const buf = await readFile11(this.filePath(cacheKey), "utf8");
7575
+ const buf = await readFile12(this.filePath(cacheKey), "utf8");
6804
7576
  return JSON.parse(buf);
6805
7577
  } catch (e) {
6806
7578
  if (e.code === "ENOENT") return null;
@@ -6809,7 +7581,7 @@ var LocalCacheStore = class {
6809
7581
  }
6810
7582
  async put(entry) {
6811
7583
  const dest = this.filePath(entry.cacheKey);
6812
- await mkdir3(path14.dirname(dest), { recursive: true });
7584
+ await mkdir3(path15.dirname(dest), { recursive: true });
6813
7585
  const tmp = `${dest}.tmp-${process.pid}-${randomUUID2()}`;
6814
7586
  await writeFile7(tmp, JSON.stringify(entry, null, 0));
6815
7587
  await rename2(tmp, dest);
@@ -6833,7 +7605,8 @@ var LOCAL_NODES = [
6833
7605
  imagemagickNode,
6834
7606
  videoTranscribeNode,
6835
7607
  fontSpecimenNode,
6836
- audioTimelineNode
7608
+ audioTimelineNode,
7609
+ collectNode
6837
7610
  ];
6838
7611
  var REMOTE_NODES = [
6839
7612
  textGenerateNode,
@@ -6863,12 +7636,12 @@ function defaultRegistry() {
6863
7636
  }
6864
7637
  function createEngineFromEnv(opts = {}) {
6865
7638
  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");
7639
+ const cacheDir = opts.cacheDir ?? path16.join(cwd, "canvas", ".cache");
7640
+ const outputsDir = opts.outputsDir ?? path16.join(cwd, "canvas");
6868
7641
  const creds = requireCredentialsFromEnv();
6869
7642
  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"));
7643
+ const assets = new LocalAssetStore(path16.join(cacheDir, "assets"));
7644
+ const localCache = new LocalCacheStore(path16.join(cacheDir, "index"));
6872
7645
  const remoteCacheEnabled = opts.remoteCache ?? remoteCacheEnabledFromEnv();
6873
7646
  const cache = remoteCacheEnabled ? new LayeredCacheStore({
6874
7647
  local: localCache,
@@ -6888,12 +7661,15 @@ function createEngineFromEnv(opts = {}) {
6888
7661
  }
6889
7662
 
6890
7663
  export {
7664
+ BackendClient,
6891
7665
  requireCredentialsFromEnv,
7666
+ RunAbortedError,
6892
7667
  LayerExecutionError,
6893
7668
  describeFailureReason,
6894
7669
  SEEDANCE_DURATIONS,
6895
7670
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
6896
7671
  IMAGE_GENERATE_MODELS,
7672
+ DEFAULT_VIDEO_GENERATE_MODEL,
6897
7673
  MODEL_REGISTRY,
6898
7674
  resolveConcurrency,
6899
7675
  ulid,
@@ -6902,9 +7678,15 @@ export {
6902
7678
  REF_PREFIX,
6903
7679
  parseRefExpr,
6904
7680
  sha256Hex,
7681
+ SEEDANCE_PROFILE,
7682
+ clipProfileFor,
7683
+ clipParamRecipe,
7684
+ imageProfileFor,
7685
+ spineInputFlags,
7686
+ spineInputOps,
6905
7687
  elementMentionKeywords,
6906
7688
  toModelSafeImage,
6907
- BackendClient2 as BackendClient,
7689
+ BackendClient2,
6908
7690
  Engine2 as Engine,
6909
7691
  LocalAssetStore2 as LocalAssetStore,
6910
7692
  LocalCacheStore2 as LocalCacheStore,
@@ -6914,4 +7696,4 @@ export {
6914
7696
  defaultRegistry,
6915
7697
  createEngineFromEnv
6916
7698
  };
6917
- //# sourceMappingURL=chunk-43KBQLP5.js.map
7699
+ //# sourceMappingURL=chunk-4QK7JC6O.js.map