@koda-sl/baker-cli 0.125.0 → 0.129.1-dev.972f3c9aa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,9 @@ import {
3
3
  __toESM
4
4
  } from "./chunk-5WRI5ZAA.js";
5
5
 
6
- // ../../node_modules/.pnpm/safe-stable-stringify@2.5.0/node_modules/safe-stable-stringify/index.js
6
+ // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/810146e81bae4e3a061fe487864f2fde80c4b03b886877dc0f1fffbc6480b67e/node_modules/safe-stable-stringify/index.js
7
7
  var require_safe_stable_stringify = __commonJS({
8
- "../../node_modules/.pnpm/safe-stable-stringify@2.5.0/node_modules/safe-stable-stringify/index.js"(exports, module) {
8
+ "../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/810146e81bae4e3a061fe487864f2fde80c4b03b886877dc0f1fffbc6480b67e/node_modules/safe-stable-stringify/index.js"(exports, module) {
9
9
  "use strict";
10
10
  var { hasOwnProperty } = Object.prototype;
11
11
  var stringify = configure2();
@@ -138,9 +138,9 @@ var require_safe_stable_stringify = __commonJS({
138
138
  }
139
139
  if (value) {
140
140
  return (value2) => {
141
- let message = `Object can not safely be stringified. Received type ${typeof value2}`;
142
- if (typeof value2 !== "function") message += ` (${value2.toString()})`;
143
- throw new Error(message);
141
+ let message2 = `Object can not safely be stringified. Received type ${typeof value2}`;
142
+ if (typeof value2 !== "function") message2 += ` (${value2.toString()})`;
143
+ throw new Error(message2);
144
144
  };
145
145
  }
146
146
  }
@@ -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,14 +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
+ }
655
+ async putJson(path17, body, signal) {
656
+ return await this.requestJson("PUT", path17, body, signal);
654
657
  }
655
- async getJson(path16, signal) {
656
- return await this.requestJson("GET", path16, void 0, signal);
658
+ async getJson(path17, signal) {
659
+ return await this.requestJson("GET", path17, void 0, signal);
657
660
  }
658
- async requestJson(method, path16, body, signal) {
659
- const url = `${this.baseUrl}${path16.startsWith("/") ? path16 : `/${path16}`}`;
661
+ async requestJson(method, path17, body, signal) {
662
+ const url = `${this.baseUrl}${path17.startsWith("/") ? path17 : `/${path17}`}`;
660
663
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
661
664
  const outcome = await this.attempt(method, url, body, attempt, signal);
662
665
  if (outcome.kind === "value") return outcome.value;
@@ -679,8 +682,8 @@ var HttpClient = class {
679
682
  try {
680
683
  const res = await this.fetchFn(url, {
681
684
  method,
682
- headers: method === "POST" ? { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` } : { Authorization: `Bearer ${this.apiKey}` },
683
- body: method === "POST" ? JSON.stringify(body) : void 0,
685
+ headers: method === "GET" ? { Authorization: `Bearer ${this.apiKey}` } : { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
686
+ body: method === "GET" ? void 0 : JSON.stringify(body),
684
687
  signal: controller.signal
685
688
  });
686
689
  if (res.ok) return { kind: "value", value: await res.json() };
@@ -717,33 +720,33 @@ async function parseErrorBody(res) {
717
720
  const errObj = body.error ?? {};
718
721
  return classifyHttpError(res.status, errObj, errObj.message ?? `HTTP ${res.status}`);
719
722
  }
720
- function classifyHttpError(status, errObj, message) {
723
+ function classifyHttpError(status, errObj, message2) {
721
724
  if (errObj.code === CONTENT_POLICY_CODE) {
722
- return { kind: "content_policy", status, provider: errObj.provider, message };
725
+ return { kind: "content_policy", status, provider: errObj.provider, message: message2 };
723
726
  }
724
727
  if (status === 401 || status === 403) {
725
- return { kind: "unauthorized", status, message };
728
+ return { kind: "unauthorized", status, message: message2 };
726
729
  }
727
730
  if (status === 400 || status === 422) {
728
- return { kind: "validation", status, message, details: errObj.details };
731
+ return { kind: "validation", status, message: message2, details: errObj.details };
729
732
  }
730
733
  if (status === 502 || status === 504) {
731
734
  if (errObj.code === "provider_timeout" || status === 504) {
732
- return { kind: "timeout", provider: errObj.provider, message };
735
+ return { kind: "timeout", provider: errObj.provider, message: message2 };
733
736
  }
734
737
  return {
735
738
  kind: "provider",
736
739
  status,
737
740
  provider: errObj.provider,
738
741
  code: errObj.code ?? "provider_error",
739
- message,
742
+ message: message2,
740
743
  retryable: errObj.retryable ?? true
741
744
  };
742
745
  }
743
746
  if (status >= 500 || status === 429) {
744
- return { kind: "server", status, message };
747
+ return { kind: "server", status, message: message2 };
745
748
  }
746
- return { kind: "validation", status, message, details: errObj.details };
749
+ return { kind: "validation", status, message: message2, details: errObj.details };
747
750
  }
748
751
  function backoffMs(attempt) {
749
752
  return 1e3 * 2 ** attempt;
@@ -777,7 +780,9 @@ function failedJobError(error) {
777
780
  retryable: error.retryable ?? false
778
781
  });
779
782
  }
780
- var JOB_POLL_INTERVAL_MS = 3e3;
783
+ function pollInterval(attempt) {
784
+ return attempt < 15 ? 1e3 : 3e3;
785
+ }
781
786
  var JOB_POLL_MAX_MS = 20 * 60 * 1e3;
782
787
  var BackendClient = class {
783
788
  http;
@@ -793,30 +798,92 @@ var BackendClient = class {
793
798
  }
794
799
  async pollJob(jobId, signal) {
795
800
  const deadline = Date.now() + JOB_POLL_MAX_MS;
796
- const path16 = `/api/canvas/jobs/${encodeURIComponent(jobId)}`;
797
- while (true) {
801
+ const path17 = `/api/canvas/jobs/${encodeURIComponent(jobId)}`;
802
+ for (let attempt = 0; ; attempt++) {
798
803
  if (signal?.aborted) {
799
804
  throw new BackendHttpError({ kind: "network", cause: signal.reason ?? new Error("aborted") });
800
805
  }
801
- const job = await this.http.getJson(path16, signal);
806
+ const job = await this.http.getJson(path17, signal);
802
807
  if (job.status === "completed") return job.result;
803
808
  if (job.status === "failed") throw failedJobError(job.error);
804
809
  if (Date.now() > deadline) {
805
810
  throw new BackendHttpError({ kind: "timeout", message: `job ${jobId} did not finish in time` });
806
811
  }
807
- await sleep(JOB_POLL_INTERVAL_MS);
812
+ await sleep(pollInterval(attempt));
808
813
  }
809
814
  }
810
- presignAssetUpload(sha256, mime, signal) {
815
+ presignAssetUpload(sha256, mime, signal, purpose) {
811
816
  return this.http.postJson(
812
817
  "/api/canvas/assets/presign",
813
- { sha256, mime },
818
+ { sha256, mime, purpose },
814
819
  signal
815
820
  );
816
821
  }
822
+ /** Remote cache lookup. A miss (404) — or an old backend without the route — returns null. */
823
+ async getCacheEntry(cacheKey, signal) {
824
+ try {
825
+ const res = await this.http.getJson(`/api/canvas/cache/${encodeURIComponent(cacheKey)}`, signal);
826
+ return res.entry;
827
+ } catch (e) {
828
+ if (e instanceof BackendHttpError && "status" in e.detail && e.detail.status === 404) return null;
829
+ throw e;
830
+ }
831
+ }
832
+ async putCacheEntry(entry, signal) {
833
+ await this.http.putJson(
834
+ `/api/canvas/cache/${encodeURIComponent(entry.cacheKey)}`,
835
+ entry,
836
+ signal
837
+ );
838
+ }
839
+ /** Durable run-history record — POST /api/canvas/runs (idempotent server-side on runId). */
840
+ async recordRun(payload, signal) {
841
+ await this.http.postJson("/api/canvas/runs", payload, signal);
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
+ }
876
+ /**
877
+ * Chat-scoped blueprint sync — POST /api/creatives/definition. Lets the
878
+ * dashboard draw a scaffolded creative's workflow graph BEFORE the first run.
879
+ * Additive on the backend (never archives siblings, never sets definitionPath).
880
+ */
881
+ async syncCreativeDefinition(payload, signal) {
882
+ await this.http.postJson("/api/creatives/definition", payload, signal);
883
+ }
817
884
  getArtifact(kind, name, version, signal) {
818
- const path16 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
819
- 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);
820
887
  }
821
888
  };
822
889
 
@@ -837,14 +904,17 @@ function requireCredentialsFromEnv(env = process.env) {
837
904
  }
838
905
  return c;
839
906
  }
907
+ function remoteCacheEnabledFromEnv(env = process.env) {
908
+ return env.BAKER_CANVAS_REMOTE_CACHE !== "off";
909
+ }
840
910
 
841
911
  // src/engine/engine/errors.ts
842
912
  function isBlocking(issue) {
843
913
  return issue.severity !== "warning";
844
914
  }
845
915
  var CanvasError = class extends Error {
846
- constructor(message) {
847
- super(message);
916
+ constructor(message2) {
917
+ super(message2);
848
918
  this.name = "CanvasError";
849
919
  }
850
920
  };
@@ -870,6 +940,14 @@ var NodeExecutionError = class extends CanvasError {
870
940
  this.cause = cause;
871
941
  }
872
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
+ };
873
951
  var LayerExecutionError = class extends CanvasError {
874
952
  failures;
875
953
  constructor(failures) {
@@ -901,7 +979,7 @@ function describeCause(c) {
901
979
  }
902
980
  }
903
981
 
904
- // ../../node_modules/.pnpm/safe-stable-stringify@2.5.0/node_modules/safe-stable-stringify/esm/wrapper.js
982
+ // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/810146e81bae4e3a061fe487864f2fde80c4b03b886877dc0f1fffbc6480b67e/node_modules/safe-stable-stringify/esm/wrapper.js
905
983
  var import__ = __toESM(require_safe_stable_stringify(), 1);
906
984
  var configure = import__.default.configure;
907
985
  var wrapper_default = import__.default;
@@ -947,6 +1025,7 @@ var OPENROUTER_IMAGE_AR = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:1
947
1025
  var OPENROUTER_IMAGE_AR_EXTREME = [...OPENROUTER_IMAGE_AR, "1:4", "4:1", "1:8", "8:1"];
948
1026
  var OPENROUTER_IMAGE_SIZE = ["1K", "2K", "4K"];
949
1027
  var OPENROUTER_IMAGE_SIZE_EXTENDED = ["0.5K", ...OPENROUTER_IMAGE_SIZE];
1028
+ var OPENROUTER_IMAGE_QUALITY = ["auto", "low", "medium", "high"];
950
1029
  var SEEDANCE_DURATIONS = [4, 5, 6, 8, 10, 12, 15];
951
1030
  var ELEVENLABS_OUTPUT_FORMATS = [
952
1031
  "mp3_22050_32",
@@ -959,10 +1038,10 @@ var ELEVENLABS_OUTPUT_FORMATS = [
959
1038
  var ELEVENLABS_MAX_TEXT_CHARS = 45454;
960
1039
  var ELEVENLABS_MAX_MUSIC_LENGTH_MS = 454545;
961
1040
  var OPENROUTER_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
962
- var FAL_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp"];
963
- var FAL_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
1041
+ var REPLICATE_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp"];
1042
+ var REPLICATE_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
964
1043
  var DECONSTRUCT_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
965
- var FAL_AUDIO_MIMES = ["audio/wav", "audio/mpeg", "audio/mp3"];
1044
+ var REPLICATE_AUDIO_MIMES = ["audio/wav", "audio/mpeg", "audio/mp3"];
966
1045
  var IMAGE_GENERATE_MODELS = [
967
1046
  "openai/gpt-5.4-image-2",
968
1047
  "google/gemini-3.5-flash",
@@ -970,6 +1049,13 @@ var IMAGE_GENERATE_MODELS = [
970
1049
  "google/gemini-3-pro-image-preview",
971
1050
  "recraft/recraft-v4.1-pro-vector"
972
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";
973
1059
  var MODEL_REGISTRY = {
974
1060
  text_generate: {
975
1061
  "~google/gemini-flash-latest": {
@@ -1065,7 +1151,8 @@ var MODEL_REGISTRY = {
1065
1151
  params: {
1066
1152
  prompt: { kind: "string" },
1067
1153
  aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR },
1068
- 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 }
1069
1156
  }
1070
1157
  },
1071
1158
  "google/gemini-3.5-flash": {
@@ -1077,7 +1164,8 @@ var MODEL_REGISTRY = {
1077
1164
  params: {
1078
1165
  prompt: { kind: "string" },
1079
1166
  aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR_EXTREME },
1080
- 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 }
1081
1169
  }
1082
1170
  },
1083
1171
  "google/gemini-3.1-flash-image-preview": {
@@ -1099,7 +1187,8 @@ var MODEL_REGISTRY = {
1099
1187
  params: {
1100
1188
  prompt: { kind: "string" },
1101
1189
  aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR },
1102
- 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 }
1103
1192
  }
1104
1193
  },
1105
1194
  "recraft/recraft-v4.1-pro-vector": {
@@ -1180,25 +1269,75 @@ var MODEL_REGISTRY = {
1180
1269
  },
1181
1270
  video_generate: {
1182
1271
  "bytedance/seedance-2.0": {
1183
- // Routed via fal.ai (not OpenRouter) because OpenRouter's Seedance
1184
- // passthrough rejects photorealistic human reference frames via
1185
- // ByteDance's "real person" safety filter.
1272
+ // Routed via Replicate's official `bytedance/seedance-2.0` model. NOTE:
1273
+ // ByteDance's upstream "real person" likeness filter still blocks photoreal
1274
+ // human reference frames on ANY reseller — the escape is a synthetic/AI
1275
+ // presenter face or routing real faces to Veo, not the provider choice.
1186
1276
  label: "ByteDance Seedance 2.0",
1187
1277
  inputs: [],
1188
- optional_inputs: [{ kind: "image", mimes: FAL_IMAGE_MIMES }],
1278
+ optional_inputs: [{ kind: "image", mimes: REPLICATE_IMAGE_MIMES }],
1189
1279
  required: ["prompt"],
1190
1280
  params: {
1191
- prompt: { kind: "string" },
1281
+ // Replicate's Seedance wrapper hard-caps the prompt at 4000 chars; gate
1282
+ // it here so an over-length prompt fails validate (free) not the billed call.
1283
+ prompt: { kind: "string", maxLength: 4e3 },
1192
1284
  aspect_ratio: {
1193
1285
  kind: "string",
1194
1286
  enum: ["1:1", "3:4", "9:16", "4:3", "16:9", "21:9", "9:21"]
1195
1287
  },
1196
- resolution: { kind: "string", enum: ["480p", "720p", "1080p"] },
1288
+ resolution: { kind: "string", enum: ["480p", "720p", "1080p", "4k"] },
1197
1289
  duration: { kind: "number", enum: SEEDANCE_DURATIONS },
1198
1290
  seed: { kind: "number" },
1199
1291
  generate_audio: { kind: "boolean" }
1200
1292
  }
1201
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
+ },
1202
1341
  "google/veo-3.1-fast": {
1203
1342
  // Cheap test/iteration model. Forwarded by the backend via
1204
1343
  // `provider.options.google-vertex.parameters` (camelCased on the wire).
@@ -1214,7 +1353,10 @@ var MODEL_REGISTRY = {
1214
1353
  duration: { kind: "number", enum: [4, 6, 8] },
1215
1354
  seed: { kind: "number" },
1216
1355
  generate_audio: { kind: "boolean" },
1217
- person_generation: { kind: "string", enum: ["allow_all"] },
1356
+ // Image-to-video and EU/UK/CH/MENA regions cap this at `allow_adult`;
1357
+ // `allow_all` is text-to-video only. Allow both so an image-conditioned
1358
+ // Veo clip (the real-face fallback) validates.
1359
+ person_generation: { kind: "string", enum: ["allow_all", "allow_adult"] },
1218
1360
  enhance_prompt: { kind: "boolean" },
1219
1361
  conditioning_scale: { kind: "number" }
1220
1362
  }
@@ -1265,8 +1407,8 @@ var MODEL_REGISTRY = {
1265
1407
  "fal/veed-lipsync": {
1266
1408
  label: "VEED Lipsync (fal.ai)",
1267
1409
  inputs: [
1268
- { kind: "video", mimes: FAL_VIDEO_MIMES },
1269
- { kind: "audio", mimes: FAL_AUDIO_MIMES }
1410
+ { kind: "video", mimes: REPLICATE_VIDEO_MIMES },
1411
+ { kind: "audio", mimes: REPLICATE_AUDIO_MIMES }
1270
1412
  ],
1271
1413
  required: [],
1272
1414
  params: {}
@@ -1302,7 +1444,7 @@ var MODEL_REGISTRY = {
1302
1444
  // TARGET voice, preserving timing/prosody. Used to normalize a talking-head
1303
1445
  // clip's native (generator-chosen) voice into ONE consistent brand voice.
1304
1446
  label: "ElevenLabs Voice Changer (multilingual STS v2)",
1305
- inputs: [{ kind: "audio", mimes: FAL_AUDIO_MIMES }],
1447
+ inputs: [{ kind: "audio", mimes: REPLICATE_AUDIO_MIMES }],
1306
1448
  required: ["voice"],
1307
1449
  params: {
1308
1450
  voice: { kind: "string" },
@@ -1329,7 +1471,7 @@ var MODEL_REGISTRY = {
1329
1471
  },
1330
1472
  "elevenlabs/video-background-music-v1": {
1331
1473
  label: "ElevenLabs Video Background Music v1",
1332
- inputs: [{ kind: "video", mimes: FAL_VIDEO_MIMES }],
1474
+ inputs: [{ kind: "video", mimes: REPLICATE_VIDEO_MIMES }],
1333
1475
  required: [],
1334
1476
  params: {
1335
1477
  description: { kind: "string" },
@@ -1500,7 +1642,7 @@ function validateValue(key, value, schema, model) {
1500
1642
  }
1501
1643
 
1502
1644
  // src/engine/lib/concurrency.ts
1503
- var DEFAULT_CONCURRENCY = 5;
1645
+ var DEFAULT_CONCURRENCY = 8;
1504
1646
  function resolveConcurrency(...candidates) {
1505
1647
  for (const candidate of candidates) {
1506
1648
  if (candidate === void 0 || candidate === "") continue;
@@ -1562,6 +1704,185 @@ function encodeRandom() {
1562
1704
  return out;
1563
1705
  }
1564
1706
 
1707
+ // src/engine/storage/remote-cache-store.ts
1708
+ var CANVAS_ASSETS_URL_SEGMENT = "/canvas-assets/";
1709
+ function isPersistedAssetRef(ref) {
1710
+ const { url, sha256 } = ref;
1711
+ if (typeof url !== "string" || typeof sha256 !== "string") return false;
1712
+ return url.includes(`${CANVAS_ASSETS_URL_SEGMENT}${sha256.slice(0, 2)}/${sha256}`) || url.includes(`${CANVAS_ASSETS_URL_SEGMENT}${sha256}`);
1713
+ }
1714
+ function isAssetRefLike(value) {
1715
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.sha256 === "string" && typeof value.mime === "string";
1716
+ }
1717
+ function collectAssetRefLikes(value, out = []) {
1718
+ if (Array.isArray(value)) {
1719
+ for (const item of value) collectAssetRefLikes(item, out);
1720
+ return out;
1721
+ }
1722
+ if (typeof value !== "object" || value === null) return out;
1723
+ if (isAssetRefLike(value)) {
1724
+ out.push(value);
1725
+ }
1726
+ for (const item of Object.values(value)) collectAssetRefLikes(item, out);
1727
+ return out;
1728
+ }
1729
+ function entryFullyPersisted(entry) {
1730
+ return collectAssetRefLikes(entry.outputs).every((ref) => isPersistedAssetRef(ref));
1731
+ }
1732
+ function stripLocalFields(entry) {
1733
+ const clone = JSON.parse(JSON.stringify(entry));
1734
+ for (const ref of collectAssetRefLikes(clone.outputs)) {
1735
+ delete ref.path;
1736
+ delete ref.bytes;
1737
+ }
1738
+ return clone;
1739
+ }
1740
+ var RemoteCacheStore = class {
1741
+ client;
1742
+ log;
1743
+ constructor(client, log) {
1744
+ this.client = client;
1745
+ this.log = log ?? (() => void 0);
1746
+ }
1747
+ async get(cacheKey) {
1748
+ return await this.client.getCacheEntry(cacheKey);
1749
+ }
1750
+ async put(entry) {
1751
+ if (!entryFullyPersisted(entry)) {
1752
+ this.log(`[cache ] ${entry.cacheKey.slice(0, 12)}\u2026 has local-only assets, kept local`);
1753
+ return;
1754
+ }
1755
+ const stripped = stripLocalFields(entry);
1756
+ if (stripped.refs.length > MAX_REMOTE_REFS) {
1757
+ stripped.refs = stripped.refs.slice(0, MAX_REMOTE_REFS);
1758
+ }
1759
+ await this.client.putCacheEntry(stripped);
1760
+ }
1761
+ };
1762
+ var MAX_REMOTE_REFS = 512;
1763
+ var LayeredCacheStore = class {
1764
+ rootDir;
1765
+ local;
1766
+ remote;
1767
+ assets;
1768
+ log;
1769
+ constructor(opts) {
1770
+ this.local = opts.local;
1771
+ this.remote = opts.remote;
1772
+ this.assets = opts.assets;
1773
+ this.rootDir = opts.local.rootDir;
1774
+ this.log = opts.log ?? (() => void 0);
1775
+ }
1776
+ async get(cacheKey) {
1777
+ const localHit = await this.local.get(cacheKey);
1778
+ if (localHit) return localHit;
1779
+ let remoteEntry;
1780
+ try {
1781
+ remoteEntry = await this.remote.get(cacheKey);
1782
+ } catch (e) {
1783
+ this.log(`[cache ] remote lookup failed (${message(e)}) \u2014 treating as miss`);
1784
+ return null;
1785
+ }
1786
+ if (!remoteEntry) return null;
1787
+ let rehydrated;
1788
+ try {
1789
+ rehydrated = await this.rehydrate(remoteEntry);
1790
+ } catch (e) {
1791
+ this.log(`[cache ] ${cacheKey.slice(0, 12)}\u2026 rehydration failed (${message(e)}) \u2014 treating as miss`);
1792
+ return null;
1793
+ }
1794
+ await this.local.put(rehydrated);
1795
+ return rehydrated;
1796
+ }
1797
+ async put(entry) {
1798
+ await this.local.put(entry);
1799
+ try {
1800
+ await this.remote.put(entry);
1801
+ } catch (e) {
1802
+ this.log(`[cache ] remote write failed (${message(e)}) \u2014 entry kept local`);
1803
+ }
1804
+ }
1805
+ /**
1806
+ * Download every referenced asset into the local content-addressed store
1807
+ * (sha-verified) and stamp fresh local paths. Any ref that cannot be
1808
+ * rehydrated fails the WHOLE entry — a partially-hydrated cache hit would
1809
+ * crash materialization later with a far less actionable error.
1810
+ */
1811
+ async rehydrate(entry) {
1812
+ const clone = JSON.parse(JSON.stringify(entry));
1813
+ for (const ref of collectAssetRefLikes(clone.outputs)) {
1814
+ if (!isPersistedAssetRef(ref)) {
1815
+ throw new Error(`ref ${ref.sha256.slice(0, 12)}\u2026 has no persisted url`);
1816
+ }
1817
+ const ingested = await this.assets.ingestRemote({
1818
+ kind: typeof ref.kind === "string" ? ref.kind : "json",
1819
+ url: ref.url,
1820
+ sha256: ref.sha256,
1821
+ mime: ref.mime,
1822
+ metadata: ref.metadata ?? void 0
1823
+ });
1824
+ ref.path = ingested.path;
1825
+ }
1826
+ return clone;
1827
+ }
1828
+ };
1829
+ function message(e) {
1830
+ return e instanceof Error ? e.message : String(e);
1831
+ }
1832
+
1833
+ // src/engine/nodes/remote/upload.ts
1834
+ var PUT_MAX_ATTEMPTS = 4;
1835
+ var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1836
+ async function presignAndPut(args) {
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
+ };
1870
+ }
1871
+ }
1872
+ async function ensureUploaded(ref, ctx) {
1873
+ if (ref.url) return ref;
1874
+ const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
1875
+ const url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
1876
+ return { ...ref, url };
1877
+ }
1878
+ async function persistOutputAssetUrls(outputs, ctx) {
1879
+ for (const ref of collectAssetRefLikes(outputs)) {
1880
+ if (isPersistedAssetRef(ref)) continue;
1881
+ const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
1882
+ ref.url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
1883
+ }
1884
+ }
1885
+
1565
1886
  // src/engine/schema/canvas.ts
1566
1887
  import { z } from "zod";
1567
1888
  var REF_PREFIX = "$ref:";
@@ -1591,7 +1912,14 @@ var NodeDecl = z.object({
1591
1912
  version: z.string().min(1).optional(),
1592
1913
  inputs: z.record(z.string(), z.unknown()).optional(),
1593
1914
  params: z.record(z.string(), z.unknown()).optional(),
1594
- when: z.unknown().optional()
1915
+ when: z.unknown().optional(),
1916
+ // Regenerate knob. The engine is content-addressed: identical params + inputs
1917
+ // return the cached render, so re-running an unchanged node NEVER re-bills or
1918
+ // produces a new result. Bump this token (any string/number — a `2`, a `"v3"`,
1919
+ // a note) and re-run to force THIS node to render fresh; because its new output
1920
+ // changes downstream input hashes, everything depending on it regenerates too.
1921
+ // This is the declarative "change a value, re-run, get a new render" affordance.
1922
+ regenerate: z.union([z.string(), z.number()]).optional()
1595
1923
  }).strict();
1596
1924
  var OutputRef = z.object({
1597
1925
  node: z.string(),
@@ -1599,6 +1927,16 @@ var OutputRef = z.object({
1599
1927
  }).strict();
1600
1928
  var VideoMeta = z.object({
1601
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(),
1602
1940
  // Each sequenced voiceover turn on the absolute timeline.
1603
1941
  vo_segments: z.array(
1604
1942
  z.object({
@@ -2076,8 +2414,111 @@ function dfsCycle(u, color, stack, reverseAdj) {
2076
2414
  }
2077
2415
 
2078
2416
  // src/engine/engine/validator.ts
2417
+ import { readFile as readFile2 } from "fs/promises";
2418
+ import path3 from "path";
2079
2419
  import { z as z2 } from "zod";
2080
2420
 
2421
+ // src/engine/scaffold/lib/prompt-profiles.ts
2422
+ var VEO_PERSON_GENERATION = "allow_adult";
2423
+ var VEO_NEGATIVE_PROMPT = "subtitles, captions, on-screen text, watermark, logo, warped face, distorted hands, extra fingers, low quality";
2424
+ var VEO_DURATIONS = [4, 6, 8];
2425
+ var KLING_DURATIONS = [5, 10];
2426
+ var KLING_NEGATIVE_PROMPT = "warped face, distorted hands, extra fingers, morphing, flicker, on-screen text, watermark, low quality";
2427
+ var KLING_CFG_SCALE = 0.7;
2428
+ var SPEAKS_PROSE = (line) => `The person speaks to camera; lip-sync follows the dialogue verbatim, with delivery and emotion carried in the wording itself (no bracketed cues). Dialogue: "${line}"`;
2429
+ var SEEDANCE_PROFILE = {
2430
+ id: "seedance",
2431
+ dialogueDirective: SPEAKS_PROSE,
2432
+ extraDirectives: [],
2433
+ // Seedance has no negative_prompt field. Phrase the anti-artifact intent as the POSITIVE
2434
+ // state we want (crisp hands, one stable face, smooth coherent motion, locked identity) —
2435
+ // an "Avoid: warped fingers, morphing faces" list tends to plant those very artifacts.
2436
+ stabilityDirectives: [
2437
+ "hands and fingers crisp and anatomically correct",
2438
+ "one consistent face and identity across every frame",
2439
+ "smooth, temporally coherent motion that holds steady frame to frame"
2440
+ ],
2441
+ keyframeInstruction: "Preserve the composition and colors of the first frame; change only the motion described.",
2442
+ // ~120 words is the Seedance quality sweet spot: past it the model starts dropping beats and
2443
+ // the attention budget dilutes. The validator warns above this; composite scenes that stack
2444
+ // many brief `parts` are the usual offenders.
2445
+ wordBudget: 120,
2446
+ durationSet: SEEDANCE_DURATIONS,
2447
+ paramDefaults: {}
2448
+ };
2449
+ var VEO_PROFILE = {
2450
+ id: "veo",
2451
+ dialogueDirective: SPEAKS_PROSE,
2452
+ // Veo's known failure mode: quoted dialogue triggers burned-in subtitles. Belt
2453
+ // (inline) and braces (the negative_prompt param, below) — the param is the real lever.
2454
+ extraDirectives: ["No subtitles, no captions, no on-screen text of any kind."],
2455
+ stabilityDirectives: [],
2456
+ // Veo takes a negative_prompt PARAM instead (paramDefaults).
2457
+ keyframeInstruction: "Describe the transition and camera move between the frames; the frames fix the content.",
2458
+ wordBudget: 200,
2459
+ durationSet: VEO_DURATIONS,
2460
+ paramDefaults: { person_generation: VEO_PERSON_GENERATION, negative_prompt: VEO_NEGATIVE_PROMPT }
2461
+ };
2462
+ var KLING_PROFILE = {
2463
+ id: "kling",
2464
+ dialogueDirective: SPEAKS_PROSE,
2465
+ // Belt (inline) and braces (the negative_prompt param) — the param is the real lever.
2466
+ extraDirectives: ["Single, deliberate camera move \u2014 no whip pans or rapid cuts within the clip."],
2467
+ stabilityDirectives: [],
2468
+ // Kling takes a negative_prompt PARAM instead (paramDefaults).
2469
+ keyframeInstruction: "Preserve the composition and colors of the first frame; animate the motion described.",
2470
+ wordBudget: 200,
2471
+ durationSet: KLING_DURATIONS,
2472
+ paramDefaults: { negative_prompt: KLING_NEGATIVE_PROMPT, cfg_scale: KLING_CFG_SCALE }
2473
+ };
2474
+ function clipProfileFor(modelId) {
2475
+ if (/^bytedance\/seedance/.test(modelId)) return SEEDANCE_PROFILE;
2476
+ if (/^google\/veo/.test(modelId)) return VEO_PROFILE;
2477
+ if (/^kwaivgi\/kling|^kling\//.test(modelId)) return KLING_PROFILE;
2478
+ return void 0;
2479
+ }
2480
+ function clipParamRecipe(profile, intent) {
2481
+ const out = {};
2482
+ if (intent === "hero") {
2483
+ out.resolution = "1080p";
2484
+ }
2485
+ if (intent === "hook" && profile.id === "kling") {
2486
+ out.cfg_scale = 0.85;
2487
+ }
2488
+ return out;
2489
+ }
2490
+ function nativeDialogueOf(prompt) {
2491
+ if (typeof prompt !== "string") return void 0;
2492
+ const m = prompt.match(/Dialogue: "(.*)"/);
2493
+ return m?.[1]?.trim() || void 0;
2494
+ }
2495
+ var GPT_IMAGE_PROFILE = {
2496
+ id: "gpt-image",
2497
+ constraintPlacement: "last",
2498
+ photorealCue: true,
2499
+ // OpenRouter forwards `quality`; gpt-image-2 already processes inputs at high
2500
+ // fidelity automatically, so we deliberately do NOT send `input_fidelity`.
2501
+ paramDefaults: { quality: "high" }
2502
+ };
2503
+ var GEMINI_IMAGE_PROFILE = {
2504
+ id: "gemini",
2505
+ constraintPlacement: "inline",
2506
+ photorealCue: true,
2507
+ paramDefaults: { quality: "high" }
2508
+ };
2509
+ var RECRAFT_IMAGE_PROFILE = {
2510
+ id: "recraft",
2511
+ constraintPlacement: "inline",
2512
+ photorealCue: false,
2513
+ paramDefaults: {}
2514
+ };
2515
+ function imageProfileFor(modelId) {
2516
+ if (/^openai\/gpt-.*image/.test(modelId)) return GPT_IMAGE_PROFILE;
2517
+ if (/^google\/gemini/.test(modelId)) return GEMINI_IMAGE_PROFILE;
2518
+ if (/^recraft\//.test(modelId)) return RECRAFT_IMAGE_PROFILE;
2519
+ return void 0;
2520
+ }
2521
+
2081
2522
  // src/engine/engine/define.ts
2082
2523
  function resolveOutputKinds(spec, params) {
2083
2524
  if (!spec) return {};
@@ -2159,16 +2600,62 @@ var STAGE_CODES = {
2159
2600
  REFERENCE_MISSING: "VIDEO_REFERENCE_MISSING",
2160
2601
  SPAN_EXCEEDS_MODEL: "VIDEO_SPAN_EXCEEDS_MODEL",
2161
2602
  UI_IN_PROMPT: "VIDEO_UI_IN_PROMPT",
2162
- BRANDMARK_IN_PROMPT: "VIDEO_BRANDMARK_IN_PROMPT"
2603
+ BRANDMARK_IN_PROMPT: "VIDEO_BRANDMARK_IN_PROMPT",
2604
+ SPEECH_EXCEEDS_EXTRACT: "VIDEO_SPEECH_EXCEEDS_EXTRACT",
2605
+ PROMPT_PROFILE_MISSING: "VIDEO_PROMPT_PROFILE_MISSING",
2606
+ PROMPT_DECISION_MISSING: "VIDEO_PROMPT_DECISION_MISSING",
2607
+ HOOK_LAYER_MISSING: "VIDEO_HOOK_LAYER_MISSING",
2608
+ IMAGE_PROFILE_MISSING: "IMAGE_PROMPT_PROFILE_MISSING",
2609
+ PROMPT_OVER_BUDGET: "VIDEO_PROMPT_OVER_BUDGET",
2610
+ PERSON_GENERATION_MISSING: "VIDEO_PERSON_GENERATION_MISSING",
2611
+ RAW_FACE_KEYFRAME: "VIDEO_RAW_FACE_KEYFRAME",
2612
+ TIMELINE_TOTAL: "VIDEO_TIMELINE_TOTAL_MISMATCH",
2613
+ NATIVE_SEG_OVERLAP: "VIDEO_NATIVE_SEG_OVERLAP",
2614
+ SPINE_UNNORMALIZED: "VIDEO_SPINE_UNNORMALIZED",
2615
+ ODD_DIMENSIONS: "VIDEO_ODD_DIMENSIONS",
2616
+ REGION_DROPPED: "VIDEO_REGION_DROPPED",
2617
+ OVERLAY_OUT_OF_BOUNDS: "VIDEO_OVERLAY_OUT_OF_BOUNDS"
2163
2618
  };
2164
2619
  var SPAN_MODEL_SLACK_S = 0.25;
2165
2620
  var VIDEO_TIME_SLACK_S = 0.75;
2166
2621
  var SPEECH_WORDS_PER_SECOND = 2.5;
2167
2622
  var SPEECH_OVERRUN_RATIO = 1.6;
2168
- function nativeDialogueOf(prompt) {
2169
- if (typeof prompt !== "string") return void 0;
2170
- const m = prompt.match(/Dialogue: "(.*)"/);
2171
- return m?.[1]?.trim() || void 0;
2623
+ function ffmpegTrimSeconds(node) {
2624
+ const args = node.params?.args;
2625
+ if (!Array.isArray(args)) return null;
2626
+ let t = null;
2627
+ for (let i = 0; i < args.length - 1; i++) {
2628
+ if (args[i] === "-t") {
2629
+ const v = Number(args[i + 1]);
2630
+ if (Number.isFinite(v)) t = v;
2631
+ }
2632
+ }
2633
+ return t;
2634
+ }
2635
+ function refNodeOf(ctx, value) {
2636
+ if (typeof value !== "string" || !value.startsWith(REF_PREFIX)) return null;
2637
+ const parsed = parseRefExpr(value);
2638
+ if (!parsed) return null;
2639
+ const idx = ctx.idToIndex.get(parsed.nodeId);
2640
+ return idx === void 0 ? null : ctx.canvas.nodes[idx];
2641
+ }
2642
+ function liveNodeDurationS(ctx, node, depth = 0) {
2643
+ if (!node || depth > 3) return null;
2644
+ if (node.type === "video_generate") {
2645
+ const d = node.params?.duration;
2646
+ return typeof d === "number" ? d : null;
2647
+ }
2648
+ if (node.type === "ffmpeg") {
2649
+ const t = ffmpegTrimSeconds(node);
2650
+ if (t !== null) return t;
2651
+ for (const v of Object.values(node.inputs ?? {})) {
2652
+ const upstream = refNodeOf(ctx, v);
2653
+ const d = liveNodeDurationS(ctx, upstream, depth + 1);
2654
+ if (d !== null) return d;
2655
+ }
2656
+ return null;
2657
+ }
2658
+ return null;
2172
2659
  }
2173
2660
  function validateCanvas(input, registry) {
2174
2661
  const issues = [];
@@ -2230,6 +2717,7 @@ async function validateCanvasDeep(input, registry) {
2230
2717
  });
2231
2718
  }
2232
2719
  }
2720
+ await checkOverlayTimingBounds(canvas, issues);
2233
2721
  const hasBlocking = issues.some(isBlocking);
2234
2722
  if (hasBlocking) return { ok: false, issues };
2235
2723
  const warnings = [...shallow.warnings ?? [], ...issues.filter((i) => !isBlocking(i))];
@@ -2471,6 +2959,38 @@ function estimateCredits(ctx) {
2471
2959
  }
2472
2960
  return total;
2473
2961
  }
2962
+ function overlayTimingIssues(html, durationS) {
2963
+ const out = [];
2964
+ for (const m of html.matchAll(/<[^>]*\bdata-start="([\d.]+)"[^>]*\bdata-dur="([\d.]+)"[^>]*>/g)) {
2965
+ const start = Number(m[1]);
2966
+ const dur = Number(m[2]);
2967
+ if (!Number.isFinite(start) || !Number.isFinite(dur)) continue;
2968
+ if (start >= durationS || start + dur > durationS + 0.25) out.push({ start, dur });
2969
+ }
2970
+ return out;
2971
+ }
2972
+ async function checkOverlayTimingBounds(canvas, issues) {
2973
+ const durationS = canvas.metadata?.video?.duration_s;
2974
+ if (typeof durationS !== "number") return;
2975
+ for (let i = 0; i < canvas.nodes.length; i++) {
2976
+ const n = canvas.nodes[i];
2977
+ if (n?.type !== "hyperframe_render") continue;
2978
+ const composition = n.params?.composition;
2979
+ if (typeof composition !== "string" || !path3.isAbsolute(composition)) continue;
2980
+ const html = await readFile2(path3.join(composition, "index.html"), "utf8").catch(() => null);
2981
+ if (!html) continue;
2982
+ for (const w of overlayTimingIssues(html, durationS)) {
2983
+ issues.push({
2984
+ path: `nodes[${i}].params.composition`,
2985
+ code: STAGE_CODES.OVERLAY_OUT_OF_BOUNDS,
2986
+ severity: "warning",
2987
+ node_id: n.id,
2988
+ node_type: n.type,
2989
+ message: `an overlay in ${path3.basename(composition)}/index.html runs [${w.start}s, ${w.start + w.dur}s] but the video ends at ${durationS}s \u2014 it never hides on screen. Re-time its data-start/data-dur to fit the video`
2990
+ });
2991
+ }
2992
+ }
2993
+ }
2474
2994
  function nativeAudioReachesMix(ctx, scene) {
2475
2995
  const wanted = [
2476
2996
  `$ref:s${scene}_voextract.audio`,
@@ -2503,6 +3023,13 @@ function talkingSceneSatisfied(ctx, entry, scene) {
2503
3023
  });
2504
3024
  }
2505
3025
  function checkVideoInvariants(ctx) {
3026
+ checkPromptProfile(ctx);
3027
+ checkImageProfile(ctx);
3028
+ checkPersonGeneration(ctx);
3029
+ checkPromptBudget(ctx);
3030
+ checkRawFaceKeyframe(ctx);
3031
+ checkPromptDecisions(ctx);
3032
+ checkHookLayers(ctx);
2506
3033
  const meta = ctx.canvas.metadata?.video;
2507
3034
  if (!meta) return;
2508
3035
  const segments = [...meta.vo_segments].sort((a, b) => a.start_s - b.start_s);
@@ -2542,6 +3069,286 @@ function checkVideoInvariants(ctx) {
2542
3069
  checkBrandmarkInPrompt(ctx);
2543
3070
  checkReferenceCompleteness(ctx, meta);
2544
3071
  checkClipSpanFitsModel(ctx, meta);
3072
+ checkTimelineContract(ctx, meta);
3073
+ checkNativeSegOverlap(ctx);
3074
+ checkSpineNormalized(ctx, meta);
3075
+ checkOddDimensions(ctx);
3076
+ checkRegionDropped(ctx);
3077
+ }
3078
+ function checkPromptProfile(ctx) {
3079
+ for (const n of ctx.canvas.nodes) {
3080
+ if (n.type !== "video_generate") continue;
3081
+ const model = n.params?.model;
3082
+ if (typeof model !== "string" || clipProfileFor(model)) continue;
3083
+ ctx.issues.push({
3084
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.model`,
3085
+ code: STAGE_CODES.PROMPT_PROFILE_MISSING,
3086
+ severity: "warning",
3087
+ node_id: n.id,
3088
+ message: `"${n.id}" runs on "${model}", a model with no clip-prompt profile \u2014 its prompt was authored with the default Seedance syntax. Review the dialogue/emotion markup for this model before billing`
3089
+ });
3090
+ }
3091
+ }
3092
+ var TECHNIQUE_CUE = /\b(camera|push[- ]?in|pull[- ]?out|pan|dolly|zoom|tilt|track|handheld|locked|orbit|crane|whip|motion|move)\b/i;
3093
+ var NEGATIVES_CUE = /\bavoid:|\bno subtitles|\bnegative\b|keep it clean and stable/i;
3094
+ var VIBE_CUE = /\b(light|lighting|golden|moody|warm|cool|tone|grade|contrast|shadow|glow|rim|backlit|neon|soft|harsh)\b/i;
3095
+ function checkPromptDecisions(ctx) {
3096
+ for (const n of ctx.canvas.nodes) {
3097
+ if (n.type !== "video_generate") continue;
3098
+ const params = n.params;
3099
+ const prompt = typeof params?.prompt === "string" ? params.prompt : "";
3100
+ if (!prompt) continue;
3101
+ const hasTechnique = TECHNIQUE_CUE.test(prompt);
3102
+ const hasNegatives = NEGATIVES_CUE.test(prompt) || typeof params?.negative_prompt === "string";
3103
+ if (hasTechnique || hasNegatives) continue;
3104
+ ctx.issues.push({
3105
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3106
+ code: STAGE_CODES.PROMPT_DECISION_MISSING,
3107
+ severity: "warning",
3108
+ node_id: n.id,
3109
+ message: `"${n.id}" is a thin clip prompt \u2014 it names no camera/motion move (TECHNIQUE) and no constraints (NEGATIVES: an affirmative "Keep it clean and stable" line, a negative_prompt param, or an "Avoid:" tail). Make the six decisions (route / spec / beats / copy / technique / negatives) so the model doesn't fill the gaps with drift. See prompt-anatomy.md`
3110
+ });
3111
+ }
3112
+ }
3113
+ function checkHookLayers(ctx) {
3114
+ const scene0 = ctx.canvas.nodes.filter((n) => /^s0(?:[_a-z0-9])*_/.test(n.id) || n.id === "s0");
3115
+ const hookClip = scene0.find((n) => n.type === "video_generate");
3116
+ if (!hookClip) return;
3117
+ const scene0Text = scene0.map((n) => {
3118
+ const p = n.params?.prompt;
3119
+ return typeof p === "string" ? p : "";
3120
+ }).join("\n");
3121
+ const hookParams = hookClip.params;
3122
+ const hasOverlayLayer = ctx.canvas.nodes.some((n) => /hyperframe/.test(n.type));
3123
+ const hasSound = hookParams?.generate_audio === true || /Dialogue:|Audio:/.test(scene0Text) || ctx.canvas.nodes.some((n) => n.type === "tts" || n.type === "dialogue" || n.type === "music");
3124
+ const hasVisual = scene0Text.trim().length > 40;
3125
+ const hasVibe = VIBE_CUE.test(scene0Text);
3126
+ const missing = [];
3127
+ if (!hasOverlayLayer) missing.push("text (overlay)");
3128
+ if (!hasSound) missing.push("sound (line/SFX)");
3129
+ if (!hasVisual) missing.push("visual (the frame)");
3130
+ if (!hasVibe) missing.push("vibe (lighting/tone)");
3131
+ if (missing.length < 2) return;
3132
+ ctx.issues.push({
3133
+ path: `nodes[${ctx.idToIndex.get(hookClip.id) ?? -1}]`,
3134
+ code: STAGE_CODES.HOOK_LAYER_MISSING,
3135
+ severity: "warning",
3136
+ node_id: hookClip.id,
3137
+ message: `the hook (scene 0) is thin across layers \u2014 missing ${missing.join(", ")}. A scroll-stopping hook works on all four layers (text / sound / visual / vibe); strengthen the missing ones. See hook-craft.md`
3138
+ });
3139
+ }
3140
+ function checkImageProfile(ctx) {
3141
+ for (const n of ctx.canvas.nodes) {
3142
+ if (n.type !== "image_generate") continue;
3143
+ const model = n.params?.model;
3144
+ if (typeof model !== "string" || imageProfileFor(model)) continue;
3145
+ ctx.issues.push({
3146
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.model`,
3147
+ code: STAGE_CODES.IMAGE_PROFILE_MISSING,
3148
+ severity: "warning",
3149
+ node_id: n.id,
3150
+ message: `"${n.id}" renders on "${model}", an image model with no frame-prompt profile \u2014 it got the generic template (constraint placement / photoreal cue not tuned). Add an ImageModelProfile for this model`
3151
+ });
3152
+ }
3153
+ }
3154
+ function checkPersonGeneration(ctx) {
3155
+ for (const n of ctx.canvas.nodes) {
3156
+ if (n.type !== "video_generate") continue;
3157
+ const params = n.params;
3158
+ const model = params?.model;
3159
+ if (clipProfileFor(typeof model === "string" ? model : "")?.id !== "veo") continue;
3160
+ const hasKeyframe = Boolean(n.inputs?.first_frame ?? n.inputs?.reference);
3161
+ if (!hasKeyframe || params?.person_generation) continue;
3162
+ ctx.issues.push({
3163
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params`,
3164
+ code: STAGE_CODES.PERSON_GENERATION_MISSING,
3165
+ severity: "warning",
3166
+ node_id: n.id,
3167
+ message: `Veo clip "${n.id}" drives from a keyframe but sets no person_generation \u2014 set "allow_adult" (the only legal value for image-to-video, and the only one allowed in the EU/UK)`
3168
+ });
3169
+ }
3170
+ }
3171
+ function checkPromptBudget(ctx) {
3172
+ for (const n of ctx.canvas.nodes) {
3173
+ if (n.type !== "video_generate") continue;
3174
+ const params = n.params;
3175
+ const model = params?.model;
3176
+ const prompt = params?.prompt;
3177
+ const profile = typeof model === "string" ? clipProfileFor(model) : void 0;
3178
+ if (!profile || typeof prompt !== "string") continue;
3179
+ const words = prompt.trim().split(/\s+/).filter(Boolean).length;
3180
+ if (words <= profile.wordBudget) continue;
3181
+ ctx.issues.push({
3182
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3183
+ code: STAGE_CODES.PROMPT_OVER_BUDGET,
3184
+ severity: "warning",
3185
+ node_id: n.id,
3186
+ message: `"${n.id}" prompt is ${words} words \u2014 over ${model}'s ~${profile.wordBudget}-word budget; trim it (the frames carry the content, so keep the clip prompt to motion + audio)`
3187
+ });
3188
+ }
3189
+ }
3190
+ function checkRawFaceKeyframe(ctx) {
3191
+ for (const n of ctx.canvas.nodes) {
3192
+ if (n.type !== "video_generate") continue;
3193
+ const model = n.params?.model;
3194
+ if (clipProfileFor(typeof model === "string" ? model : "")?.id !== "seedance") continue;
3195
+ const src = refNodeOf(ctx, n.inputs?.first_frame ?? n.inputs?.reference);
3196
+ if (!src || src.type !== "ingest") continue;
3197
+ ctx.issues.push({
3198
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].inputs.first_frame`,
3199
+ code: STAGE_CODES.RAW_FACE_KEYFRAME,
3200
+ severity: "warning",
3201
+ node_id: n.id,
3202
+ message: `Seedance clip "${n.id}" animates a RAW ingested image ("${src.id}"), not a generated frame \u2014 if it shows a real human face, ByteDance's real-person filter rejects it (422). Anchor on an AI-generated portrait (recast), or route this clip to Veo`
3203
+ });
3204
+ }
3205
+ }
3206
+ function spineTotalS(ctx, spine) {
3207
+ let total = 0;
3208
+ const inputs = Object.entries(spine.inputs ?? {}).filter(([k]) => /^c\d+$/.test(k));
3209
+ if (inputs.length === 0) return null;
3210
+ for (const [, v] of inputs) {
3211
+ const d = liveNodeDurationS(ctx, refNodeOf(ctx, v));
3212
+ if (d === null) return null;
3213
+ total += d;
3214
+ }
3215
+ const graph = spine.params.args?.join?.(" ") ?? "";
3216
+ for (const m of String(graph).matchAll(/xfade=transition=[^:]+:duration=([\d.]+)/g)) {
3217
+ total -= Number(m[1]);
3218
+ }
3219
+ return total;
3220
+ }
3221
+ function checkTimelineContract(ctx, meta) {
3222
+ const stamp = meta.timeline;
3223
+ const spineId = stamp?.spine_node ?? (ctx.idToIndex.has("spine") ? "spine" : null);
3224
+ if (!spineId) return;
3225
+ const expected = stamp?.expected_total_s ?? meta.duration_s;
3226
+ checkSpineTotal(ctx, spineId, expected);
3227
+ checkAudioTimelineTotals(ctx, stamp?.audio_mix_node, expected);
3228
+ }
3229
+ function checkSpineTotal(ctx, spineId, expected) {
3230
+ const spineIdx = ctx.idToIndex.get(spineId);
3231
+ const spine = spineIdx === void 0 ? null : ctx.canvas.nodes[spineIdx];
3232
+ const spineLen = spine ? spineTotalS(ctx, spine) : null;
3233
+ if (spineLen === null || Math.abs(spineLen - expected) <= VIDEO_TIME_SLACK_S) return;
3234
+ ctx.issues.push({
3235
+ path: `nodes[${spineIdx}].params.args`,
3236
+ code: STAGE_CODES.TIMELINE_TOTAL,
3237
+ node_id: spineId,
3238
+ message: `the picture sums to ${round2(spineLen)}s but the timeline is pinned to ${expected}s \u2014 \`-shortest\` at the final mux will silently cut the longer stream. Re-time the seg trims (or the audio total_ms) so both match`
3239
+ });
3240
+ }
3241
+ function checkAudioTimelineTotals(ctx, audioMixNode, expected) {
3242
+ const mustBeFullLength = /* @__PURE__ */ new Set();
3243
+ if (audioMixNode) mustBeFullLength.add(audioMixNode);
3244
+ ctx.canvas.nodes.forEach((n) => {
3245
+ if (n.type !== "audio_voice_convert") return;
3246
+ const track = refNodeOf(ctx, (n.inputs ?? {}).audio);
3247
+ if (track?.type === "audio_timeline") mustBeFullLength.add(track.id);
3248
+ });
3249
+ for (const id of mustBeFullLength) {
3250
+ const idx = ctx.idToIndex.get(id);
3251
+ const node = idx === void 0 ? null : ctx.canvas.nodes[idx];
3252
+ const totalMs = node?.params?.total_ms;
3253
+ if (typeof totalMs !== "number") continue;
3254
+ if (Math.abs(totalMs / 1e3 - expected) > VIDEO_TIME_SLACK_S) {
3255
+ ctx.issues.push({
3256
+ path: `nodes[${idx}].params.total_ms`,
3257
+ code: STAGE_CODES.TIMELINE_TOTAL,
3258
+ node_id: id,
3259
+ message: `audio timeline "${id}" is pinned to ${totalMs}ms but the picture timeline is ${expected}s \u2014 \`-shortest\` at the final mux will silently cut the longer stream. Pin total_ms to ${Math.round(expected * 1e3)}`
3260
+ });
3261
+ }
3262
+ }
3263
+ }
3264
+ function checkNativeSegOverlap(ctx) {
3265
+ for (const conv of ctx.canvas.nodes) {
3266
+ if (conv.type !== "audio_voice_convert") continue;
3267
+ const track = refNodeOf(ctx, (conv.inputs ?? {}).audio);
3268
+ if (track?.type !== "audio_timeline") continue;
3269
+ const params = track.params;
3270
+ const windows = (params.tracks ?? []).map((t) => {
3271
+ const extract = refNodeOf(ctx, (track.inputs ?? {})[t.slot]);
3272
+ const len = t.duration_s ?? (extract ? ffmpegTrimSeconds(extract) : null);
3273
+ return len === null ? null : { slot: t.slot, start: t.start_s, end: t.start_s + len };
3274
+ }).filter((w) => w !== null).sort((a, b) => a.start - b.start);
3275
+ for (let i = 1; i < windows.length; i++) {
3276
+ const prev = windows[i - 1];
3277
+ const cur = windows[i];
3278
+ if (!prev || !cur || cur.start >= prev.end - 0.01) continue;
3279
+ const trackIdx = ctx.idToIndex.get(track.id);
3280
+ ctx.issues.push({
3281
+ path: `nodes[${trackIdx}].params.tracks`,
3282
+ code: STAGE_CODES.NATIVE_SEG_OVERLAP,
3283
+ node_id: track.id,
3284
+ message: `voice track "${track.id}": "${prev.slot}" runs to ${round2(prev.end)}s but "${cur.slot}" starts at ${cur.start}s \u2014 both play at once (echo). Cap the first with duration_s: ${round2(cur.start - prev.start)} or re-time the windows`
3285
+ });
3286
+ }
3287
+ }
3288
+ }
3289
+ function checkSpineNormalized(ctx, meta) {
3290
+ const spineId = meta.timeline?.spine_node ?? "spine";
3291
+ const idx = ctx.idToIndex.get(spineId);
3292
+ const spine = idx === void 0 ? null : ctx.canvas.nodes[idx];
3293
+ if (!spine || spine.type !== "ffmpeg") return;
3294
+ const args = spine.params.args;
3295
+ const graph = Array.isArray(args) ? args.join(" ") : "";
3296
+ if (!graph.includes("concat=n=")) return;
3297
+ if (/\[\d+:v\](?:\[|concat)/.test(graph)) {
3298
+ ctx.issues.push({
3299
+ path: `nodes[${idx}].params.args`,
3300
+ code: STAGE_CODES.SPINE_UNNORMALIZED,
3301
+ severity: "warning",
3302
+ node_id: spineId,
3303
+ message: `the spine concat feeds raw input labels \u2014 generated clips carry no fps/SAR guarantee, and one 24fps clip silently stretches the picture off the audio/overlay clock. Chain \`format=yuv420p,fps=30,setsar=1,settb=AVTB\` on every input before the concat`
3304
+ });
3305
+ }
3306
+ }
3307
+ function checkOddDimensions(ctx) {
3308
+ ctx.canvas.nodes.forEach((node, idx) => {
3309
+ if (node.type !== "ffmpeg") return;
3310
+ const args = node.params.args;
3311
+ const blob = Array.isArray(args) ? args.join(" ") : "";
3312
+ for (const m of blob.matchAll(/(?:scale|crop|pad)=(\d+):(\d+)/g)) {
3313
+ const w = Number(m[1]);
3314
+ const h = Number(m[2]);
3315
+ if (w % 2 === 0 && h % 2 === 0) continue;
3316
+ ctx.issues.push({
3317
+ path: `nodes[${idx}].params.args`,
3318
+ code: STAGE_CODES.ODD_DIMENSIONS,
3319
+ severity: "warning",
3320
+ node_id: node.id,
3321
+ message: `"${m[0]}" produces an odd dimension \u2014 libx264 yuv420p rejects odd sizes, so this node fails at render time. Round both to even numbers`
3322
+ });
3323
+ return;
3324
+ }
3325
+ });
3326
+ }
3327
+ function checkRegionDropped(ctx) {
3328
+ const regionClips = /* @__PURE__ */ new Map();
3329
+ for (const node of ctx.canvas.nodes) {
3330
+ const m = /^s(\d+)_r\d+_clip$/.exec(node.id);
3331
+ if (m && node.type === "video_generate")
3332
+ regionClips.set(m[1], (regionClips.get(m[1]) ?? 0) + 1);
3333
+ }
3334
+ for (const [scene, billed] of regionClips) {
3335
+ const idx = ctx.idToIndex.get(`s${scene}_composite`);
3336
+ if (idx === void 0) continue;
3337
+ const composite = ctx.canvas.nodes[idx];
3338
+ const consumed = Object.keys(composite.inputs ?? {}).filter((k) => /^c\d+$/.test(k)).length;
3339
+ if (billed > consumed) {
3340
+ ctx.issues.push({
3341
+ path: `nodes[${idx}].inputs`,
3342
+ code: STAGE_CODES.REGION_DROPPED,
3343
+ severity: "warning",
3344
+ node_id: composite.id,
3345
+ message: `scene s${scene} bills ${billed} region clips but its composite consumes ${consumed} \u2014 the extra generation is paid for and never reaches the frame. Wire every region into the composite or delete the unused clip node`
3346
+ });
3347
+ }
3348
+ }
3349
+ }
3350
+ function round2(n) {
3351
+ return Math.round(n * 100) / 100;
2545
3352
  }
2546
3353
  var ELEMENT_TYPE_KEYWORDS = {
2547
3354
  animal: ["dog", "puppy", "pup", "cat", "kitten", "kitty", "pet", "canine", "feline"],
@@ -2669,15 +3476,37 @@ function checkSpeechOverrun(ctx, talkingScenes) {
2669
3476
  for (const n of ctx.canvas.nodes) {
2670
3477
  if (!nativeClipRe.test(n.id) || n.type !== "video_generate") continue;
2671
3478
  const overrun = speechOverrunOf(n, secondsPerWord(entry));
2672
- if (!overrun) continue;
2673
- ctx.issues.push({
2674
- path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
2675
- code: STAGE_CODES.SPEECH_OVERRUN,
2676
- 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`
2677
- });
3479
+ if (overrun) {
3480
+ ctx.issues.push({
3481
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
3482
+ code: STAGE_CODES.SPEECH_OVERRUN,
3483
+ message: `"${n.id}" asks Seedance to speak ~${Math.round(overrun.estSpeechS * 10) / 10}s of dialogue inside a ${overrun.duration}s clip \u2014 the line cannot fit (>${SPEECH_OVERRUN_RATIO}\xD7 the clip). Shorten the line, split the scene, or lengthen the clip duration`
3484
+ });
3485
+ continue;
3486
+ }
3487
+ checkSpeechExceedsExtract(ctx, n, entry);
2678
3488
  }
2679
3489
  }
2680
3490
  }
3491
+ function checkSpeechExceedsExtract(ctx, clip, entry) {
3492
+ const params = clip.params;
3493
+ if (params?.generate_audio !== true) return;
3494
+ const line = nativeDialogueOf(params.prompt);
3495
+ if (!line) return;
3496
+ const regionTag = /^s\d+(_r\d+)?_clip$/.exec(clip.id)?.[1] ?? "";
3497
+ const extractIdx = ctx.idToIndex.get(`s${entry.scene}${regionTag}_voextract`) ?? ctx.idToIndex.get(`s${entry.scene}_voextract`);
3498
+ const extract = extractIdx === void 0 ? null : ctx.canvas.nodes[extractIdx];
3499
+ const windowS = extract ? ffmpegTrimSeconds(extract) : null;
3500
+ if (windowS === null) return;
3501
+ const estSpeechS = line.split(/\s+/).filter(Boolean).length * secondsPerWord(entry);
3502
+ if (estSpeechS <= windowS * SPEECH_OVERRUN_RATIO) return;
3503
+ ctx.issues.push({
3504
+ path: `nodes[${ctx.idToIndex.get(clip.id) ?? -1}].params.prompt`,
3505
+ code: STAGE_CODES.SPEECH_EXCEEDS_EXTRACT,
3506
+ node_id: clip.id,
3507
+ message: `"${clip.id}"'s line is ~${Math.round(estSpeechS * 10) / 10}s of speech but its extract window (s${entry.scene}_voextract) is ${windowS}s \u2014 the read gets cut mid-word on the spine. Shorten the Dialogue line, lengthen the scene in prompt.json and re-scaffold, or raise the voextract \`-t\` when the read should carry over the next cutaway`
3508
+ });
3509
+ }
2681
3510
  var UI_IN_PROMPT_RE = /\bscreen[- ]?(?:recording|capture|grab|share)\b|\bapp (?:interface|screen)\b|\bphone screen overlay\b/i;
2682
3511
  function checkUiInPrompt(ctx) {
2683
3512
  for (const n of ctx.canvas.nodes) {
@@ -2750,9 +3579,9 @@ function checkOutputRef(ctx) {
2750
3579
  function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2751
3580
  for (const issue of err.issues) {
2752
3581
  const tail2 = pathToString(issue.path);
2753
- const path16 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
3582
+ const path17 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
2754
3583
  issues.push({
2755
- path: path16,
3584
+ path: path17,
2756
3585
  code,
2757
3586
  message: issue.message,
2758
3587
  received: issue.code === "invalid_type" ? issue.received : void 0,
@@ -2761,8 +3590,8 @@ function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2761
3590
  });
2762
3591
  }
2763
3592
  }
2764
- function pathToString(path16) {
2765
- return path16.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
3593
+ function pathToString(path17) {
3594
+ return path17.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
2766
3595
  }
2767
3596
  function buildDepGraph(canvas) {
2768
3597
  const graph = /* @__PURE__ */ new Map();
@@ -2841,6 +3670,7 @@ var Engine = class {
2841
3670
  cache;
2842
3671
  outputsDir;
2843
3672
  log;
3673
+ persistAssets;
2844
3674
  constructor(opts) {
2845
3675
  this.registry = opts.registry;
2846
3676
  this.client = opts.client;
@@ -2848,6 +3678,7 @@ var Engine = class {
2848
3678
  this.cache = opts.cache;
2849
3679
  this.outputsDir = opts.outputsDir;
2850
3680
  this.log = opts.log ?? (() => void 0);
3681
+ this.persistAssets = opts.persistAssets ?? false;
2851
3682
  }
2852
3683
  validate(canvas) {
2853
3684
  return validateCanvas(canvas, this.registry);
@@ -2859,6 +3690,12 @@ var Engine = class {
2859
3690
  async run(input, opts = {}) {
2860
3691
  const validation = await this.validateDeep(input);
2861
3692
  if (!validation.ok) throw new ValidationError(validation.issues);
3693
+ if (opts.max_credits !== void 0 && validation.estimatedCredits > opts.max_credits) {
3694
+ throw new RunAbortedError(
3695
+ "cost_cap",
3696
+ `estimated ${validation.estimatedCredits} credits exceeds the ${opts.max_credits}-credit cap \u2014 nothing was billed; raise --max-credits or shrink the canvas`
3697
+ );
3698
+ }
2862
3699
  const canvas = validation.canvas;
2863
3700
  const runId = opts.run_id ?? `r_${ulid()}`;
2864
3701
  const writer = new OutputWriter({ outputsDir: this.outputsDir, runId });
@@ -2871,7 +3708,16 @@ var Engine = class {
2871
3708
  const outputs = {};
2872
3709
  const counters = { cachedNodes: 0, totalCredits: 0 };
2873
3710
  const nodeRuns = [];
2874
- await this.runLayers(canvas, outputs, runId, writer, opts, counters, nodeRuns);
3711
+ const graph = this.pruneToOutput(canvas, buildGraph(canvas));
3712
+ const needsBytes = computeNeedsLocalBytes(canvas, graph, this.registry);
3713
+ this.emitProgress(opts, {
3714
+ kind: "plan",
3715
+ nodes: [...graph.entries()].map(([id, deps]) => {
3716
+ const node = canvas.nodes.find((n) => n.id === id);
3717
+ return { node_id: id, node_type: node?.type ?? "unknown", deps: [...deps], params: node?.params };
3718
+ })
3719
+ });
3720
+ await this.runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns, needsBytes);
2875
3721
  const output = pickFinalOutput(canvas, outputs);
2876
3722
  const stats = {
2877
3723
  total_nodes: canvas.nodes.length,
@@ -2894,39 +3740,63 @@ var Engine = class {
2894
3740
  `[done ] ${stats.cached_nodes}/${stats.total_nodes} cached, ${stats.total_credits} credits, ${stats.duration_ms}ms`
2895
3741
  );
2896
3742
  this.log(`outputs in: ${writer.runDir}`);
2897
- return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir };
3743
+ return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir, node_runs: nodeRuns };
2898
3744
  }
2899
- async runLayers(canvas, outputs, runId, writer, opts, counters, nodeRuns) {
2900
- const layers = topologicalLayers(this.pruneToOutput(canvas, buildGraph(canvas)));
3745
+ async runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns, needsBytes) {
3746
+ const layers = topologicalLayers(graph);
2901
3747
  const limit = resolveConcurrency(opts.concurrency);
2902
3748
  for (const layer of layers) {
2903
- const settled = await mapWithConcurrency(
2904
- layer,
2905
- limit,
2906
- (nodeId) => this.executeOne(canvas, nodeId, outputs, runId, writer, opts).then((r) => {
3749
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted before layer dispatch");
3750
+ if (opts.max_credits !== void 0 && counters.totalCredits > opts.max_credits) {
3751
+ throw new RunAbortedError(
3752
+ "cost_cap",
3753
+ `spent ${counters.totalCredits} credits, over the ${opts.max_credits}-credit cap \u2014 completed nodes are cached; raise --max-credits to continue where this stopped`
3754
+ );
3755
+ }
3756
+ const settled = await mapWithConcurrency(layer, limit, (nodeId) => {
3757
+ if (opts.signal?.aborted) {
3758
+ return Promise.reject(new RunAbortedError("signal", "run aborted before node dispatch"));
3759
+ }
3760
+ this.emitProgress(opts, { kind: "node_start", node_id: nodeId });
3761
+ return this.executeOne(canvas, nodeId, outputs, runId, writer, opts, needsBytes.has(nodeId)).then((r) => {
2907
3762
  if (r.cached) counters.cachedNodes++;
2908
3763
  counters.totalCredits += r.credits;
2909
3764
  const node = canvas.nodes.find((n) => n.id === nodeId);
2910
3765
  if (node) {
2911
- nodeRuns.push({
3766
+ const run = {
2912
3767
  node_id: nodeId,
2913
3768
  node_type: node.type,
2914
3769
  cached: r.cached,
2915
3770
  duration_ms: r.durationMs,
2916
3771
  credits: r.credits
2917
- });
3772
+ };
3773
+ nodeRuns.push(run);
3774
+ this.emitProgress(opts, { kind: "node_settled", run, outputs: outputs[nodeId] ?? {} });
2918
3775
  }
2919
- })
2920
- );
3776
+ });
3777
+ });
2921
3778
  const failures = [];
2922
3779
  settled.forEach((result, i) => {
2923
3780
  const nodeId = layer[i];
2924
- if (result.status === "rejected" && nodeId) failures.push({ nodeId, reason: result.reason });
3781
+ if (result.status === "rejected" && nodeId) {
3782
+ if (result.reason instanceof RunAbortedError) return;
3783
+ failures.push({ nodeId, reason: result.reason });
3784
+ this.emitProgress(opts, { kind: "node_failed", node_id: nodeId });
3785
+ }
2925
3786
  });
3787
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted by signal");
2926
3788
  if (failures.length === 1 && failures[0]) throw failures[0].reason;
2927
3789
  if (failures.length > 1) throw new LayerExecutionError(failures);
2928
3790
  }
2929
3791
  }
3792
+ /** Progress consumers are observers only — an exception there must never fail the run. */
3793
+ emitProgress(opts, event) {
3794
+ if (!opts.onProgress) return;
3795
+ try {
3796
+ opts.onProgress(event);
3797
+ } catch {
3798
+ }
3799
+ }
2930
3800
  /**
2931
3801
  * Dead-node elimination: when the canvas declares an `output`, execute only the
2932
3802
  * nodes that output transitively depends on. Orphaned nodes (left by an edit or
@@ -2957,12 +3827,13 @@ var Engine = class {
2957
3827
  }
2958
3828
  await writer.writeManifest("_final", output);
2959
3829
  }
2960
- async executeOne(canvas, nodeId, outputs, runId, writer, opts) {
3830
+ async executeOne(canvas, nodeId, outputs, runId, writer, opts, downloadOutputs) {
2961
3831
  const node = canvas.nodes.find((n) => n.id === nodeId);
2962
3832
  if (!node) throw new Error(`executor: missing node ${nodeId}`);
2963
3833
  const def = this.registry.get(node.type);
2964
3834
  if (!def) throw new Error(`executor: missing registry entry for type ${node.type}`);
2965
- const prepared = await prepareForExecution(node, outputs, def, canvas.cache_salt, this.assets);
3835
+ const regenerateToken = resolveRegenerateToken(node, opts.regenerate, runId);
3836
+ const prepared = await prepareForExecution(node, outputs, def, canvas.cache_salt, regenerateToken, this.assets);
2966
3837
  const policy = opts.cache_policy ?? "read_write";
2967
3838
  if (policy !== "bypass") {
2968
3839
  const cacheT0 = Date.now();
@@ -2980,17 +3851,27 @@ var Engine = class {
2980
3851
  nodeId: node.id,
2981
3852
  nodeType: node.type,
2982
3853
  cacheKey: prepared.cacheKey,
3854
+ downloadOutputs,
2983
3855
  client: this.client,
2984
3856
  assets: this.assets,
2985
3857
  log: this.log,
2986
3858
  signal: opts.signal
2987
3859
  };
2988
- const { parsedInputs, parsedParams } = parseNodeArgs(def, prepared, node.id, node.type);
3860
+ const preparedForExec = needsLocalMaterialization(def) ? { ...prepared, resolvedInputs: await this.materializeLocalInputs(prepared.resolvedInputs) } : prepared;
3861
+ const { parsedInputs, parsedParams } = parseNodeArgs(def, preparedForExec, node.id, node.type);
2989
3862
  const result = await invokeExecute(def, parsedInputs, parsedParams, ctx, node.id, node.type);
2990
3863
  const elapsed = Date.now() - t0;
2991
3864
  const credits = def.cost ? def.cost({ params: parsedParams }).credits : 0;
2992
3865
  const outputsObj = result;
2993
3866
  outputs[node.id] = outputsObj;
3867
+ if (this.persistAssets) {
3868
+ try {
3869
+ await persistOutputAssetUrls(outputsObj, ctx);
3870
+ } catch (e) {
3871
+ const msg = e instanceof Error ? e.message : String(e);
3872
+ this.log(`[warn ] ${node.id}: asset persistence failed (${msg}) \u2014 outputs stay local-only`);
3873
+ }
3874
+ }
2994
3875
  if (policy === "read_write") {
2995
3876
  await this.cache.put({
2996
3877
  cacheKey: prepared.cacheKey,
@@ -3019,8 +3900,42 @@ var Engine = class {
3019
3900
  }
3020
3901
  }
3021
3902
  }
3903
+ /**
3904
+ * Download any URL-only asset ref reachable in a local node's inputs so the
3905
+ * bytes are on disk before the local runner stages them. Returns a copy —
3906
+ * refs are replaced, never mutated in place, so the producer's cached output
3907
+ * (shared object) keeps its URL-only shape.
3908
+ */
3909
+ async materializeLocalInputs(inputs) {
3910
+ const fix = async (value) => {
3911
+ if (Array.isArray(value)) return Promise.all(value.map(fix));
3912
+ if (value && typeof value === "object") {
3913
+ const v = value;
3914
+ if (typeof v.kind === "string" && typeof v.url === "string" && typeof v.sha256 === "string" && typeof v.mime === "string" && typeof v.path !== "string") {
3915
+ this.log(`[warn ] materializing URL-only input on demand (${v.kind}/${v.mime}) \u2014 missed graph edge`);
3916
+ return this.assets.ingestRemote({
3917
+ kind: v.kind,
3918
+ url: v.url,
3919
+ sha256: v.sha256,
3920
+ mime: v.mime,
3921
+ metadata: v.metadata
3922
+ });
3923
+ }
3924
+ const out = {};
3925
+ for (const [k, val] of Object.entries(v)) out[k] = await fix(val);
3926
+ return out;
3927
+ }
3928
+ return value;
3929
+ };
3930
+ return await fix(inputs);
3931
+ }
3022
3932
  };
3023
- async function prepareForExecution(node, outputs, def, cacheSalt, assets) {
3933
+ function resolveRegenerateToken(node, forced, runId) {
3934
+ if (forced?.has(node.id)) return `run:${runId}`;
3935
+ if (node.regenerate !== void 0) return `node:${String(node.regenerate)}`;
3936
+ return void 0;
3937
+ }
3938
+ async function prepareForExecution(node, outputs, def, cacheSalt, regenerateToken, assets) {
3024
3939
  const resolvedInputs = resolveRefs(node.inputs ?? {}, { outputs }) ?? {};
3025
3940
  const resolvedParams = resolveRefs(node.params ?? {}, { outputs }) ?? {};
3026
3941
  const slotValues = await hydrateTextSlots(resolvedInputs, assets, node.id, node.type);
@@ -3033,6 +3948,9 @@ async function prepareForExecution(node, outputs, def, cacheSalt, assets) {
3033
3948
  throw new NodeExecutionError(node.id, node.type, { kind: "local", cause: e });
3034
3949
  }
3035
3950
  }
3951
+ if (regenerateToken !== void 0) {
3952
+ extras = { ...extras ?? {}, __regenerate__: regenerateToken };
3953
+ }
3036
3954
  const cacheKey = computeCacheKey({
3037
3955
  node_id: node.type,
3038
3956
  node_version: def.version,
@@ -3061,6 +3979,9 @@ async function invokeExecute(def, parsedInputs, parsedParams, ctx, nodeId, nodeT
3061
3979
  throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
3062
3980
  }
3063
3981
  }
3982
+ function needsLocalMaterialization(def) {
3983
+ return def.location === "local" && !def.passthroughRefs;
3984
+ }
3064
3985
  function pickFinalOutput(canvas, outputs) {
3065
3986
  if (canvas.output) {
3066
3987
  const node = outputs[canvas.output.node];
@@ -3071,6 +3992,16 @@ function pickFinalOutput(canvas, outputs) {
3071
3992
  const lastOut = outputs[last.id];
3072
3993
  return lastOut ? Object.values(lastOut)[0] : void 0;
3073
3994
  }
3995
+ function computeNeedsLocalBytes(canvas, graph, registry) {
3996
+ const typeById = new Map(canvas.nodes.map((n) => [n.id, n.type]));
3997
+ const needs = /* @__PURE__ */ new Set();
3998
+ for (const [consumerId, deps] of graph) {
3999
+ const def = registry.get(typeById.get(consumerId) ?? "");
4000
+ if (def?.location !== "local" || def.passthroughRefs) continue;
4001
+ for (const dep of deps) needs.add(dep);
4002
+ }
4003
+ return needs;
4004
+ }
3074
4005
  function buildGraph(canvas) {
3075
4006
  const graph = /* @__PURE__ */ new Map();
3076
4007
  for (const n of canvas.nodes) graph.set(n.id, /* @__PURE__ */ new Set());
@@ -3105,11 +4036,11 @@ function hashInputs(inputs) {
3105
4036
  const out = {};
3106
4037
  for (const [k, v] of Object.entries(inputs)) {
3107
4038
  if (Array.isArray(v)) {
3108
- out[k] = v.map((el) => extractSha(el) ?? canonicalLiteral(el));
4039
+ out[k] = v.map((el) => extractSha(el) ?? canonicalLiteral(normalizeParamsForCacheKey(el)));
3109
4040
  } else {
3110
4041
  const s = extractSha(v);
3111
4042
  if (s !== null) out[k] = s;
3112
- else out[k] = canonicalLiteral(v);
4043
+ else out[k] = canonicalLiteral(normalizeParamsForCacheKey(v));
3113
4044
  }
3114
4045
  }
3115
4046
  return out;
@@ -3201,7 +4132,16 @@ async function hydrateSlotValue(value, assets, nodeId, nodeType) {
3201
4132
  try {
3202
4133
  bytes = await assets.readBytes(value.sha256, value.mime);
3203
4134
  } catch (e) {
3204
- throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
4135
+ if (value.url) {
4136
+ try {
4137
+ await assets.ingestRemote({ kind: value.kind, url: value.url, sha256: value.sha256, mime: value.mime });
4138
+ bytes = await assets.readBytes(value.sha256, value.mime);
4139
+ } catch (e2) {
4140
+ throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e2 });
4141
+ }
4142
+ } else {
4143
+ throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
4144
+ }
3205
4145
  }
3206
4146
  if (bytes.length > MAX_INLINE_TEXT_BYTES) {
3207
4147
  throw new NodeExecutionError(nodeId, nodeType, {
@@ -3261,9 +4201,9 @@ var NodeRegistry = class {
3261
4201
 
3262
4202
  // src/engine/nodes/ingest.ts
3263
4203
  import { execFile as execFileCb, spawn } from "child_process";
3264
- import { mkdtemp, readdir, readFile as readFile2, rm, stat as stat2 } from "fs/promises";
4204
+ import { mkdtemp, readdir, readFile as readFile3, rm, stat as stat2 } from "fs/promises";
3265
4205
  import { tmpdir } from "os";
3266
- import path3 from "path";
4206
+ import path4 from "path";
3267
4207
  import { promisify } from "util";
3268
4208
  import { z as z5 } from "zod";
3269
4209
 
@@ -3309,27 +4249,6 @@ var FontRef = BaseAssetRef.extend({
3309
4249
  });
3310
4250
  var AssetRef = z4.discriminatedUnion("kind", [ImageRef, VideoRef, AudioRef, JsonRef, TextRef, FontRef]);
3311
4251
 
3312
- // src/engine/nodes/remote/upload.ts
3313
- async function presignAndPut(args) {
3314
- const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(args.sha256, args.mime, args.ctx.signal);
3315
- const putRes = await fetch(putUrl, {
3316
- method: "PUT",
3317
- body: new Uint8Array(args.bytes),
3318
- headers: { "Content-Type": args.mime },
3319
- signal: args.ctx.signal
3320
- });
3321
- if (!putRes.ok) {
3322
- throw new Error(`upload: presigned PUT failed ${putRes.status} ${putRes.statusText}`);
3323
- }
3324
- return publicUrl;
3325
- }
3326
- async function ensureUploaded(ref, ctx) {
3327
- if (ref.url) return ref;
3328
- const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
3329
- const url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
3330
- return { ...ref, url };
3331
- }
3332
-
3333
4252
  // src/engine/nodes/remote/delegate.ts
3334
4253
  function delegated(spec) {
3335
4254
  return {
@@ -3357,7 +4276,9 @@ async function callBackendExec(args) {
3357
4276
  nodeVersion: args.nodeVersion,
3358
4277
  params: args.params,
3359
4278
  inputs: serialized,
3360
- idempotency_key: idempotencyKey
4279
+ idempotency_key: idempotencyKey,
4280
+ canvas_run_id: args.ctx.canvasRunId,
4281
+ node_id: args.ctx.nodeId
3361
4282
  },
3362
4283
  args.ctx.signal
3363
4284
  );
@@ -3409,6 +4330,9 @@ async function ingestValue(value, ctx, declaredKind) {
3409
4330
  }
3410
4331
  if (isRawAsset(value)) {
3411
4332
  const kind = value.kind ?? declaredKind ?? "json";
4333
+ if (ctx.downloadOutputs === false) {
4334
+ return buildRef({ kind, sha: value.sha256, mime: value.mime, url: value.url, metadata: value.metadata });
4335
+ }
3412
4336
  return ctx.assets.ingestRemote({
3413
4337
  kind,
3414
4338
  url: value.url,
@@ -3523,7 +4447,7 @@ function safePathname(rawUrl) {
3523
4447
  }
3524
4448
  var ingestNode = defineNode({
3525
4449
  id: "ingest",
3526
- version: "1.1.0",
4450
+ version: "1.2.0",
3527
4451
  category: "io",
3528
4452
  location: "local",
3529
4453
  summary: "Ingest an external URL or a local file into the asset store. Declare the kind you expect (image/video/audio/text/json/font); the node picks the strategy. For source=url: yt-dlp for video/audio (YouTube/TikTok/Vimeo/etc. and direct file URLs), Handinger for HTML/PDF pages \u2192 markdown, direct HTTP fetch for binary URLs (images, fonts) and raw .txt/.md. For source=path: read from the local filesystem and upload to R2.",
@@ -3566,6 +4490,9 @@ function runStrategy(strategy, params, ctx) {
3566
4490
  }
3567
4491
  }
3568
4492
  async function execDirectFetch(params, ctx) {
4493
+ if (params.expect === "image") {
4494
+ return ingestImageUrl(params.url, ctx);
4495
+ }
3569
4496
  const result = await callBackendExec({
3570
4497
  nodeType: "ingest",
3571
4498
  nodeVersion: ingestNode.version,
@@ -3576,6 +4503,37 @@ async function execDirectFetch(params, ctx) {
3576
4503
  });
3577
4504
  return assertAssetOutput(result, params.expect);
3578
4505
  }
4506
+ async function ingestImageUrl(url, ctx) {
4507
+ const res = await fetch(url);
4508
+ if (!res.ok) {
4509
+ throw localExecError(ctx, `fetch ${url} \u2192 ${res.status}`);
4510
+ }
4511
+ const ab = await res.arrayBuffer();
4512
+ if (ab.byteLength > MAX_ASSET_BYTES) {
4513
+ throw localExecError(ctx, `file_too_large: ${url} is ${ab.byteLength} bytes (limit ${MAX_ASSET_BYTES})`);
4514
+ }
4515
+ let normalized;
4516
+ try {
4517
+ normalized = await toModelSafeImage(Buffer.from(ab));
4518
+ } catch (e) {
4519
+ throw localExecError(ctx, `${url}: ${e.message}`);
4520
+ }
4521
+ if (normalized.rasterizedFrom) {
4522
+ ctx.log(`ingest: normalized ${normalized.rasterizedFrom} URL -> PNG (${normalized.bytes.length}B)`);
4523
+ }
4524
+ return uploadAndIngest({
4525
+ bytes: normalized.bytes,
4526
+ kind: "image",
4527
+ mime: normalized.mime,
4528
+ metadata: {
4529
+ source_url: url,
4530
+ strategy: "direct_fetch",
4531
+ ingested_at: (/* @__PURE__ */ new Date()).toISOString(),
4532
+ ...normalized.rasterizedFrom ? { rasterized_from: normalized.rasterizedFrom } : {}
4533
+ },
4534
+ ctx
4535
+ });
4536
+ }
3579
4537
  async function execHandinger(params, ctx) {
3580
4538
  const result = await callBackendExec({
3581
4539
  nodeType: "ingest",
@@ -3603,8 +4561,8 @@ function resolveLocalPath(input) {
3603
4561
  `ingest: ~ path expansion is not supported (got "${input}"). Use an absolute path or a cwd-relative path.`
3604
4562
  );
3605
4563
  }
3606
- if (path3.isAbsolute(input)) return input;
3607
- return path3.resolve(process.cwd(), input);
4564
+ if (path4.isAbsolute(input)) return input;
4565
+ return path4.resolve(process.cwd(), input);
3608
4566
  }
3609
4567
  var EXT_TO_MIME = {
3610
4568
  png: "image/png",
@@ -3612,7 +4570,15 @@ var EXT_TO_MIME = {
3612
4570
  jpeg: "image/jpeg",
3613
4571
  webp: "image/webp",
3614
4572
  gif: "image/gif",
4573
+ // Non-model-safe rasters `toModelSafeImage` transcodes to PNG at ingest — they
4574
+ // must resolve to an image mime here or the kind-check rejects the local file
4575
+ // before normalization ever runs.
3615
4576
  avif: "image/avif",
4577
+ heic: "image/heic",
4578
+ heif: "image/heif",
4579
+ tif: "image/tiff",
4580
+ tiff: "image/tiff",
4581
+ bmp: "image/bmp",
3616
4582
  mp4: "video/mp4",
3617
4583
  webm: "video/webm",
3618
4584
  mov: "video/quicktime",
@@ -3644,7 +4610,7 @@ function sniffSvg(buf) {
3644
4610
  return head.startsWith("<?xml") ? head.includes("<svg") : head.startsWith("<svg");
3645
4611
  }
3646
4612
  function inferMimeFromPath(absPath, sniffBytes) {
3647
- const ext = path3.extname(absPath).slice(1).toLowerCase();
4613
+ const ext = path4.extname(absPath).slice(1).toLowerCase();
3648
4614
  const fromExt = EXT_TO_MIME[ext];
3649
4615
  if (fromExt) return fromExt;
3650
4616
  const fromBytes = sniffImageMime(sniffBytes);
@@ -3663,15 +4629,45 @@ async function rasterizeSvgToPng(bytes) {
3663
4629
  }
3664
4630
  return await sharp(bytes, { density }).png({ force: true, palette: false }).toBuffer();
3665
4631
  }
4632
+ var MODEL_SAFE_IMAGE_MIMES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
4633
+ async function toModelSafeImage(bytes) {
4634
+ const safe = sniffImageMime(bytes);
4635
+ if (safe && MODEL_SAFE_IMAGE_MIMES.has(safe)) {
4636
+ return { bytes, mime: safe };
4637
+ }
4638
+ if (sniffSvg(bytes)) {
4639
+ return { bytes: await rasterizeSvgToPng(bytes), mime: "image/png", rasterizedFrom: "svg" };
4640
+ }
4641
+ const { default: sharp } = await import("sharp");
4642
+ try {
4643
+ const img = sharp(bytes);
4644
+ const format = (await img.metadata()).format;
4645
+ const png = await img.png({ force: true }).toBuffer();
4646
+ return { bytes: png, mime: "image/png", rasterizedFrom: format ?? "unknown" };
4647
+ } catch (e) {
4648
+ throw new Error(`bytes are not a decodable image (${e.message})`);
4649
+ }
4650
+ }
4651
+ function hasAscii(buf, offset, sig) {
4652
+ return buf.length >= offset + sig.length && buf.toString("ascii", offset, offset + sig.length) === sig;
4653
+ }
4654
+ var HEIC_BRANDS = /* @__PURE__ */ new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "mif1", "msf1", "heif"]);
4655
+ function sniffIsoBmff(buf) {
4656
+ if (!hasAscii(buf, 4, "ftyp")) return null;
4657
+ const brand = buf.subarray(8, 12).toString("ascii");
4658
+ if (brand === "avif" || brand === "avis") return "image/avif";
4659
+ if (HEIC_BRANDS.has(brand)) return "image/heic";
4660
+ return null;
4661
+ }
3666
4662
  function sniffImageMime(buf) {
3667
4663
  if (buf.length < 4) return null;
3668
- if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
4664
+ if (buf[0] === 137 && hasAscii(buf, 1, "PNG")) return "image/png";
3669
4665
  if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
3670
- if (buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
3671
- if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
3672
- return "image/webp";
3673
- }
3674
- return null;
4666
+ if (hasAscii(buf, 0, "GIF")) return "image/gif";
4667
+ if (hasAscii(buf, 0, "RIFF") && hasAscii(buf, 8, "WEBP")) return "image/webp";
4668
+ if (hasAscii(buf, 0, "II*\0") || hasAscii(buf, 0, "MM\0*")) return "image/tiff";
4669
+ if (hasAscii(buf, 0, "BM")) return "image/bmp";
4670
+ return sniffIsoBmff(buf);
3675
4671
  }
3676
4672
  function findBoxPayload(buf, start, end, type) {
3677
4673
  let offset = start;
@@ -3724,10 +4720,10 @@ function inferKindFromMime(mime) {
3724
4720
  if (mime.startsWith("font/")) return "font";
3725
4721
  return null;
3726
4722
  }
3727
- function localExecError(ctx, message) {
4723
+ function localExecError(ctx, message2) {
3728
4724
  return new NodeExecutionError(ctx.nodeId, ctx.nodeType, {
3729
4725
  kind: "local",
3730
- cause: new Error(`ingest: ${message}`)
4726
+ cause: new Error(`ingest: ${message2}`)
3731
4727
  });
3732
4728
  }
3733
4729
  async function execLocalFile(params, ctx) {
@@ -3753,7 +4749,7 @@ async function execLocalFile(params, ctx) {
3753
4749
  }
3754
4750
  let bytes;
3755
4751
  try {
3756
- bytes = await readFile2(absPath);
4752
+ bytes = await readFile3(absPath);
3757
4753
  } catch (e) {
3758
4754
  if (e.code === "EACCES") {
3759
4755
  throw localExecError(ctx, `permission_denied: ${absPath}`);
@@ -3774,17 +4770,20 @@ async function execLocalFile(params, ctx) {
3774
4770
  ctx.log(`ingest: local file ${stats.size}B mime=${mime}`);
3775
4771
  let outBytes = bytes;
3776
4772
  let outMime = mime;
3777
- if (mime === SVG_MIME) {
3778
- outBytes = await rasterizeSvgToPng(bytes);
3779
- outMime = "image/png";
3780
- ctx.log(`ingest: rasterized SVG -> PNG (${outBytes.length}B)`);
4773
+ let rasterizedFrom;
4774
+ if (kind === "image") {
4775
+ const normalized = await toModelSafeImage(bytes);
4776
+ outBytes = normalized.bytes;
4777
+ outMime = normalized.mime;
4778
+ rasterizedFrom = normalized.rasterizedFrom;
4779
+ if (rasterizedFrom) ctx.log(`ingest: normalized ${rasterizedFrom} -> PNG (${outBytes.length}B)`);
3781
4780
  }
3782
4781
  const durationMs = probeVideoDurationMs(params.expect, outBytes, ctx);
3783
4782
  const ref = await uploadAndIngest({
3784
4783
  bytes: outBytes,
3785
4784
  kind: params.expect,
3786
4785
  mime: outMime,
3787
- metadata: localFileMetadata({ absPath, fileSize: stats.size, mime, durationMs }),
4786
+ metadata: localFileMetadata({ absPath, fileSize: stats.size, mime, durationMs, rasterizedFrom }),
3788
4787
  ctx
3789
4788
  });
3790
4789
  return withProbedDuration(ref, durationMs);
@@ -3801,8 +4800,8 @@ function localFileMetadata(args) {
3801
4800
  strategy: "local_file",
3802
4801
  ingested_at: (/* @__PURE__ */ new Date()).toISOString(),
3803
4802
  file_size: args.fileSize,
3804
- original_filename: path3.basename(args.absPath),
3805
- ...args.mime === SVG_MIME ? { rasterized_from: "svg" } : {},
4803
+ original_filename: path4.basename(args.absPath),
4804
+ ...args.rasterizedFrom ? { rasterized_from: args.rasterizedFrom } : {},
3806
4805
  ...args.durationMs !== void 0 ? { duration_ms: args.durationMs } : {}
3807
4806
  };
3808
4807
  }
@@ -3825,7 +4824,7 @@ async function execYtDlp(params, ctx) {
3825
4824
  if (params.expect !== "video" && params.expect !== "audio") {
3826
4825
  throw new Error(`ingest: yt_dlp only handles video/audio, got expect=${params.expect}`);
3827
4826
  }
3828
- const workDir = await mkdtemp(path3.join(tmpdir(), "ingest-yt-"));
4827
+ const workDir = await mkdtemp(path4.join(tmpdir(), "ingest-yt-"));
3829
4828
  try {
3830
4829
  const { filePath, info } = await runYtDlp({
3831
4830
  url: params.url,
@@ -3840,7 +4839,7 @@ async function execYtDlp(params, ctx) {
3840
4839
  `file_too_large: yt-dlp output for ${params.url} is ${downloadedStats.size} bytes (limit ${MAX_ASSET_BYTES})`
3841
4840
  );
3842
4841
  }
3843
- const bytes = await readFile2(filePath);
4842
+ const bytes = await readFile3(filePath);
3844
4843
  const kind = params.expect;
3845
4844
  const mime = YT_DLP_MIME[kind];
3846
4845
  const metadata = buildYtDlpMetadata(params.url, info);
@@ -3852,8 +4851,8 @@ async function execYtDlp(params, ctx) {
3852
4851
  }
3853
4852
  }
3854
4853
  async function runYtDlp(args) {
3855
- const outTemplate = path3.join(args.workDir, "out.%(ext)s");
3856
- const infoPath = path3.join(args.workDir, "out.info.json");
4854
+ const outTemplate = path4.join(args.workDir, "out.%(ext)s");
4855
+ const infoPath = path4.join(args.workDir, "out.info.json");
3857
4856
  const argv = [
3858
4857
  args.url,
3859
4858
  "--no-playlist",
@@ -3886,11 +4885,11 @@ ${tail(stderr, 40)}`)
3886
4885
  }
3887
4886
  let info = {};
3888
4887
  try {
3889
- const raw = await readFile2(infoPath, "utf-8");
4888
+ const raw = await readFile3(infoPath, "utf-8");
3890
4889
  info = JSON.parse(raw);
3891
4890
  } catch {
3892
4891
  }
3893
- return { filePath: path3.join(args.workDir, downloaded), info };
4892
+ return { filePath: path4.join(args.workDir, downloaded), info };
3894
4893
  }
3895
4894
  function buildYtDlpMetadata(sourceUrl, info) {
3896
4895
  const out = {
@@ -3979,9 +4978,9 @@ import { z as z6 } from "zod";
3979
4978
 
3980
4979
  // src/engine/nodes/local/lib/cli-runner.ts
3981
4980
  import { execFile as execFileCb2, spawn as spawn2 } from "child_process";
3982
- import { copyFile as copyFile2, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm2, stat as stat3 } from "fs/promises";
4981
+ import { copyFile as copyFile2, mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2, stat as stat3 } from "fs/promises";
3983
4982
  import { tmpdir as tmpdir2 } from "os";
3984
- import path4 from "path";
4983
+ import path5 from "path";
3985
4984
  import { promisify as promisify2 } from "util";
3986
4985
  var execFile2 = promisify2(execFileCb2);
3987
4986
  var DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
@@ -4008,7 +5007,7 @@ function mimeForExt(ext) {
4008
5007
  }
4009
5008
  function extForAssetRef(ref) {
4010
5009
  if (ref.path) {
4011
- const e = path4.extname(ref.path);
5010
+ const e = path5.extname(ref.path);
4012
5011
  if (e) return e;
4013
5012
  }
4014
5013
  const reverse = {
@@ -4030,14 +5029,14 @@ function planArrayInputSlot(tmpDir, slot, values, lookup, stagedInputs) {
4030
5029
  const ref = values[i];
4031
5030
  if (!ref) continue;
4032
5031
  if (!ref.path) throw new Error(`cli-runner: inputs.${slot}[${i}] has no local path`);
4033
- const dest = path4.join(tmpDir, `in_${slot}_${i}${extForAssetRef(ref)}`);
5032
+ const dest = path5.join(tmpDir, `in_${slot}_${i}${extForAssetRef(ref)}`);
4034
5033
  stagedInputs.push({ srcPath: ref.path, destPath: dest });
4035
5034
  lookup.set(`in.${slot}.${i}`, dest);
4036
5035
  }
4037
5036
  }
4038
5037
  function planSingleInputSlot(tmpDir, slot, ref, lookup, stagedInputs) {
4039
5038
  if (!ref.path) throw new Error(`cli-runner: inputs.${slot} has no local path`);
4040
- const dest = path4.join(tmpDir, `in_${slot}${extForAssetRef(ref)}`);
5039
+ const dest = path5.join(tmpDir, `in_${slot}${extForAssetRef(ref)}`);
4041
5040
  stagedInputs.push({ srcPath: ref.path, destPath: dest });
4042
5041
  lookup.set(`in.${slot}`, dest);
4043
5042
  }
@@ -4045,7 +5044,7 @@ function planOutputs(tmpDir, outputs, lookup) {
4045
5044
  const outputPaths = [];
4046
5045
  for (const [name, spec] of Object.entries(outputs)) {
4047
5046
  const ext = spec.ext.startsWith(".") ? spec.ext : `.${spec.ext}`;
4048
- const absPath = path4.join(tmpDir, `out_${name}${ext}`);
5047
+ const absPath = path5.join(tmpDir, `out_${name}${ext}`);
4049
5048
  outputPaths.push({ name, absPath, spec });
4050
5049
  lookup.set(`out.${name}`, absPath);
4051
5050
  }
@@ -4086,8 +5085,8 @@ function rejectRawPaths(substituted, original, stagingDir) {
4086
5085
  throw new Error(`cli-runner: home-relative path "${original}" not allowed in args.`);
4087
5086
  }
4088
5087
  if (substituted.startsWith("/")) {
4089
- const resolved = path4.resolve(substituted);
4090
- if (!resolved.startsWith(`${stagingDir}${path4.sep}`) && resolved !== stagingDir) {
5088
+ const resolved = path5.resolve(substituted);
5089
+ if (!resolved.startsWith(`${stagingDir}${path5.sep}`) && resolved !== stagingDir) {
4091
5090
  throw new Error(
4092
5091
  `cli-runner: raw filesystem path "${original}" not allowed \u2014 declare an input slot and use {{in.<slot>}} instead.`
4093
5092
  );
@@ -4137,7 +5136,7 @@ function tailLines(text, maxLines) {
4137
5136
  }
4138
5137
  async function runCli(opts) {
4139
5138
  const { bin, args, inputs, outputs, ctx, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
4140
- const tmpDir = await mkdtemp2(path4.join(tmpdir2(), `cli-${bin.replace(/[^a-z0-9]/gi, "")}-`));
5139
+ const tmpDir = await mkdtemp2(path5.join(tmpdir2(), `cli-${bin.replace(/[^a-z0-9]/gi, "")}-`));
4141
5140
  try {
4142
5141
  const { lookup, stagedInputs, outputPaths } = planPlaceholders(tmpDir, inputs, outputs);
4143
5142
  await stageInputs(stagedInputs);
@@ -4159,7 +5158,7 @@ ${tailLines(stderr, 40)}`);
4159
5158
  if (!s?.isFile() || s.size === 0) {
4160
5159
  throw new Error(`cli-runner: declared output "${name}" missing or empty at ${absPath}`);
4161
5160
  }
4162
- const bytes = await readFile3(absPath);
5161
+ const bytes = await readFile4(absPath);
4163
5162
  const ref = await ctx.assets.ingestBytes({
4164
5163
  bytes: Buffer.from(bytes),
4165
5164
  kind: spec.kind,
@@ -4199,6 +5198,12 @@ var Track = z6.object({
4199
5198
  slot: z6.string().min(1),
4200
5199
  /** When this track starts on the timeline, seconds from 0. */
4201
5200
  start_s: z6.number().min(0),
5201
+ /**
5202
+ * Optional hard cap on this track's length, seconds. The clip is trimmed BEFORE
5203
+ * placement, so a source that runs long (an over-long voice extract, a converted
5204
+ * track that came back oversized) cannot bleed into the next track's window.
5205
+ */
5206
+ duration_s: z6.number().positive().optional(),
4202
5207
  /** Optional level adjustment in dB (negative ducks, e.g. a music bed at -12). */
4203
5208
  gain_db: z6.number().optional()
4204
5209
  }).strict();
@@ -4255,7 +5260,10 @@ function buildAudioTimelineArgs(params) {
4255
5260
  params.tracks.forEach((track, i) => {
4256
5261
  inputArgs.push("-i", `{{in.${track.slot}}}`);
4257
5262
  const delayMs = Math.round(track.start_s * 1e3);
4258
- const steps = [`adelay=${delayMs}:all=1`];
5263
+ const steps = [
5264
+ ...track.duration_s !== void 0 ? [`atrim=0:${track.duration_s}`] : [],
5265
+ `adelay=${delayMs}:all=1`
5266
+ ];
4259
5267
  if (track.gain_db !== void 0) steps.push(`volume=${track.gain_db}dB`);
4260
5268
  const label = `a${i}`;
4261
5269
  filterChains.push(`[${i}:a]${steps.join(",")}[${label}]`);
@@ -4273,11 +5281,11 @@ function buildAudioTimelineArgs(params) {
4273
5281
  }
4274
5282
  var audioTimelineNode = defineNode({
4275
5283
  id: "audio_timeline",
4276
- version: "1.1.0",
5284
+ version: "1.2.0",
4277
5285
  category: "audio",
4278
5286
  location: "local",
4279
5287
  summary: "Place and mix several audio clips onto one timeline: each track starts at a given second (optionally level-adjusted in dB), then they're combined into a single track. Built for laying a music bed plus timed voiceover lines and sound effects under a video.",
4280
- when_to_use: "Use to assemble a full audio bed from separately-generated clips \u2014 e.g. a `music` bed at 0 (ducked via `gain_db: -12`), each scene's `tts` voiceover at its scene start, and `sound_effect` hits at their timestamps. Wire each clip as `inputs.<slot>` (audio AssetRef) and list it in `params.tracks` with `{slot, start_s, gain_db?}`. Set `total_ms` to pin the final length to the video. Requires `ffmpeg` on PATH.",
5288
+ when_to_use: "Use to assemble a full audio bed from separately-generated clips \u2014 e.g. a `music` bed at 0 (ducked via `gain_db: -12`), each scene's `tts` voiceover at its scene start, and `sound_effect` hits at their timestamps. Wire each clip as `inputs.<slot>` (audio AssetRef) and list it in `params.tracks` with `{slot, start_s, duration_s?, gain_db?}` (`duration_s` hard-caps a clip so it can't bleed into the next track's window). Set `total_ms` to pin the final length to the video. Requires `ffmpeg` on PATH.",
4281
5289
  inputs: AudioTimelineInputs,
4282
5290
  params: AudioTimelineParams,
4283
5291
  outputs: AudioTimelineOutputs,
@@ -4329,19 +5337,56 @@ var audioTimelineNode = defineNode({
4329
5337
  }
4330
5338
  });
4331
5339
 
4332
- // src/engine/nodes/local/ffmpeg.ts
5340
+ // src/engine/nodes/local/collect.ts
4333
5341
  import { z as z7 } from "zod";
5342
+ var collectNode = defineNode({
5343
+ id: "collect",
5344
+ version: "1.0.0",
5345
+ category: "data",
5346
+ location: "local",
5347
+ passthroughRefs: true,
5348
+ summary: "Gather images from multiple upstream nodes into one ordered array \u2014 the standard terminal for multi-variant canvases whose final output is several images.",
5349
+ when_to_use: "Point the canvas `output` at this node when several independent branches (e.g. one image_generate per scene/variant) must ALL be finals. Wire `inputs.images` as an array of refs like `$ref:gen_billboard_03.images#0` \u2014 each final inherits its producer node id as its label (or set `params.labels` to override), so variants stay identifiable in the dashboard and selection.",
5350
+ inputs: z7.object({ images: z7.array(ImageRef).min(1) }).strict(),
5351
+ params: z7.object({ labels: z7.array(z7.string().min(1)).min(1).optional() }).strict(),
5352
+ outputs: z7.object({ images: z7.array(ImageRef).min(1) }).strict(),
5353
+ outputKinds: { images: "image" },
5354
+ cost: () => ({ credits: 0, seconds_estimate: 0 }),
5355
+ // Arity is only knowable at validate time when `images` is a literal array;
5356
+ // a single `$ref:` string to an upstream array output defers to runtime.
5357
+ validateExtra: ({ rawParams, rawInputs }) => {
5358
+ const labels = rawParams?.labels;
5359
+ if (!Array.isArray(labels)) return [];
5360
+ if (new Set(labels).size !== labels.length) {
5361
+ return [{ path: "params.labels", message: "labels must be unique \u2014 each names one output variant" }];
5362
+ }
5363
+ const images = rawInputs?.images;
5364
+ if (Array.isArray(images) && labels.length !== images.length) {
5365
+ return [
5366
+ {
5367
+ path: "params.labels",
5368
+ message: `labels has ${labels.length} entries but ${images.length} images are wired \u2014 provide one label per image`
5369
+ }
5370
+ ];
5371
+ }
5372
+ return [];
5373
+ },
5374
+ execute: ({ inputs }) => Promise.resolve({ images: inputs.images })
5375
+ });
5376
+
5377
+ // src/engine/nodes/local/ffmpeg.ts
5378
+ import { z as z8 } from "zod";
4334
5379
  var FFMPEG_BIN2 = "ffmpeg";
4335
- var OutputDecl = z7.object({
4336
- kind: z7.enum(["image", "video", "audio"]),
4337
- ext: z7.string().min(1).max(8)
5380
+ var OutputDecl = z8.object({
5381
+ kind: z8.enum(["image", "video", "audio"]),
5382
+ ext: z8.string().min(1).max(8)
4338
5383
  }).strict();
4339
- var FfmpegParams = z7.object({
4340
- args: z7.array(z7.string()).min(1),
4341
- outputs: z7.record(z7.string(), OutputDecl).default({})
5384
+ var FfmpegParams = z8.object({
5385
+ args: z8.array(z8.string()).min(1),
5386
+ outputs: z8.record(z8.string(), OutputDecl).default({})
4342
5387
  }).strict();
4343
- var FfmpegInputs = z7.record(z7.string(), z7.unknown());
4344
- var FfmpegOutputs = z7.record(z7.string(), z7.custom());
5388
+ var FfmpegInputs = z8.record(z8.string(), z8.unknown());
5389
+ var FfmpegOutputs = z8.record(z8.string(), z8.custom());
4345
5390
  var ffmpegNode = defineNode({
4346
5391
  id: "ffmpeg",
4347
5392
  version: "2.0.0",
@@ -4371,17 +5416,17 @@ var ffmpegNode = defineNode({
4371
5416
  import { mkdtemp as mkdtemp3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
4372
5417
  import { createRequire } from "module";
4373
5418
  import { tmpdir as tmpdir3 } from "os";
4374
- import path6 from "path";
4375
- import { z as z8 } from "zod";
5419
+ import path7 from "path";
5420
+ import { z as z9 } from "zod";
4376
5421
 
4377
5422
  // src/engine/nodes/local/lib/assets.ts
4378
- import { copyFile as copyFile3, readFile as readFile4 } from "fs/promises";
4379
- import path5 from "path";
5423
+ import { copyFile as copyFile3, readFile as readFile5 } from "fs/promises";
5424
+ import path6 from "path";
4380
5425
  async function stageAsset(ref, destDir, filename) {
4381
5426
  if (!ref.path) {
4382
5427
  throw new Error(`stageAsset: ref (${ref.kind}/${ref.mime}) has no local path`);
4383
5428
  }
4384
- const dest = path5.join(destDir, filename);
5429
+ const dest = path6.join(destDir, filename);
4385
5430
  await copyFile3(ref.path, dest);
4386
5431
  return dest;
4387
5432
  }
@@ -4390,11 +5435,11 @@ async function refToUrl(ref) {
4390
5435
  if (!ref.path) {
4391
5436
  throw new Error("refToUrl: AssetRef has neither url nor path");
4392
5437
  }
4393
- const bytes = await readFile4(ref.path);
5438
+ const bytes = await readFile5(ref.path);
4394
5439
  return `data:${ref.mime};base64,${bytes.toString("base64")}`;
4395
5440
  }
4396
5441
  var ASSET_KINDS = /* @__PURE__ */ new Set(["image", "video", "audio", "json", "text", "font"]);
4397
- function isAssetRefLike(value) {
5442
+ function isAssetRefLike2(value) {
4398
5443
  if (!value || typeof value !== "object") return false;
4399
5444
  const v = value;
4400
5445
  return typeof v.kind === "string" && ASSET_KINDS.has(v.kind) && typeof v.mime === "string" && typeof v.sha256 === "string" && (typeof v.url === "string" || typeof v.path === "string");
@@ -4408,15 +5453,15 @@ var DEFAULT_SPECIMEN = [
4408
5453
  "abcdefghijklmnopqrstuvwxyz",
4409
5454
  `0123456789 !?&@#$%().,:;'"-`
4410
5455
  ].join("\n");
4411
- var FontSpecimenParams = z8.object({
4412
- text: z8.string().min(1).max(2e3).optional().default(DEFAULT_SPECIMEN),
4413
- font_size: z8.number().int().min(8).max(512).optional().default(72),
4414
- padding: z8.number().int().min(0).max(512).optional().default(64),
4415
- line_height: z8.number().min(0.8).max(3).optional().default(1.35),
4416
- max_width: z8.number().int().min(256).max(4096).optional()
5456
+ var FontSpecimenParams = z9.object({
5457
+ text: z9.string().min(1).max(2e3).optional().default(DEFAULT_SPECIMEN),
5458
+ font_size: z9.number().int().min(8).max(512).optional().default(72),
5459
+ padding: z9.number().int().min(0).max(512).optional().default(64),
5460
+ line_height: z9.number().min(0.8).max(3).optional().default(1.35),
5461
+ max_width: z9.number().int().min(256).max(4096).optional()
4417
5462
  }).strict();
4418
- var FontSpecimenInputs = z8.object({ font: FontRef }).loose();
4419
- var FontSpecimenOutputs = z8.object({ image: ImageRef }).strict();
5463
+ var FontSpecimenInputs = z9.object({ font: FontRef }).loose();
5464
+ var FontSpecimenOutputs = z9.object({ image: ImageRef }).strict();
4420
5465
  var DEVICE_SCALE_FACTOR = 2;
4421
5466
  var PAGE_TIMEOUT_MS = 3e4;
4422
5467
  function escapeHtml(text) {
@@ -4464,11 +5509,11 @@ var fontSpecimenNode = defineNode({
4464
5509
  outputKinds: { image: "image" },
4465
5510
  cost: () => ({ credits: 0, seconds_estimate: 5 }),
4466
5511
  async execute({ inputs, params, ctx }) {
4467
- const tmp = await mkdtemp3(path6.join(tmpdir3(), "font-specimen-"));
5512
+ const tmp = await mkdtemp3(path7.join(tmpdir3(), "font-specimen-"));
4468
5513
  try {
4469
5514
  const fontFilename = `font.${extForMime(inputs.font.mime)}`;
4470
5515
  await stageAsset(inputs.font, tmp, fontFilename);
4471
- const entryPath = path6.join(tmp, "index.html");
5516
+ const entryPath = path7.join(tmp, "index.html");
4472
5517
  await writeFile3(entryPath, buildSpecimenHtml(params, fontFilename), "utf-8");
4473
5518
  ctx.log(`rendering specimen (${params.font_size}px, ${params.text.split("\n").length} lines)`);
4474
5519
  const pwSpecifier = ["play", "wright"].join("");
@@ -4552,16 +5597,16 @@ var fontSpecimenNode = defineNode({
4552
5597
 
4553
5598
  // src/engine/nodes/local/hyperframe.ts
4554
5599
  import { execFile as execFile4 } from "child_process";
4555
- import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile8, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
5600
+ import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile9, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
4556
5601
  import { createRequire as createRequire2 } from "module";
4557
5602
  import { cpus, tmpdir as tmpdir4 } from "os";
4558
- import path11 from "path";
5603
+ import path12 from "path";
4559
5604
  import { promisify as promisify4 } from "util";
4560
- import { z as z10 } from "zod";
5605
+ import { z as z11 } from "zod";
4561
5606
 
4562
5607
  // src/engine/engine/composition-hash.ts
4563
- import { readdir as readdir2, readFile as readFile5, stat as stat4 } from "fs/promises";
4564
- import path7 from "path";
5608
+ import { readdir as readdir2, readFile as readFile6, stat as stat4 } from "fs/promises";
5609
+ import path8 from "path";
4565
5610
  var SKIP_DIRS = /* @__PURE__ */ new Set([".cache", ".git", "node_modules", "dist", "build", ".next", ".turbo"]);
4566
5611
  function isSkippedName(name) {
4567
5612
  if (name.startsWith(".")) return true;
@@ -4578,79 +5623,79 @@ async function collectFiles(root, current) {
4578
5623
  const names = await readdir2(current);
4579
5624
  for (const name of names) {
4580
5625
  if (isSkippedName(name)) continue;
4581
- const abs = path7.join(current, name);
5626
+ const abs = path8.join(current, name);
4582
5627
  const s = await stat4(abs);
4583
5628
  if (s.isDirectory()) {
4584
5629
  out.push(...await collectFiles(root, abs));
4585
5630
  continue;
4586
5631
  }
4587
5632
  if (!s.isFile()) continue;
4588
- const bytes = await readFile5(abs);
4589
- const relPath = path7.relative(root, abs).split(path7.sep).join("/");
5633
+ const bytes = await readFile6(abs);
5634
+ const relPath = path8.relative(root, abs).split(path8.sep).join("/");
4590
5635
  out.push({ relPath, contentSha: sha256Hex(bytes) });
4591
5636
  }
4592
5637
  return out;
4593
5638
  }
4594
5639
 
4595
5640
  // src/engine/engine/composition-meta.ts
4596
- import { readFile as readFile6 } from "fs/promises";
4597
- import path8 from "path";
4598
- import { z as z9 } from "zod";
4599
- var InputKind = z9.enum(["video", "image", "audio", "json"]);
4600
- var InputSpec = z9.object({
5641
+ import { readFile as readFile7 } from "fs/promises";
5642
+ import path9 from "path";
5643
+ import { z as z10 } from "zod";
5644
+ var InputKind = z10.enum(["video", "image", "audio", "json"]);
5645
+ var InputSpec = z10.object({
4601
5646
  kind: InputKind,
4602
- required: z9.boolean().optional().default(false),
5647
+ required: z10.boolean().optional().default(false),
4603
5648
  // Filename the composition's HTML references (e.g. `input.mp4`, `logo.png`).
4604
5649
  // Defaults to `<key><ext>` derived from the kind.
4605
- staged_as: z9.string().min(1).optional(),
4606
- description: z9.string().optional()
5650
+ staged_as: z10.string().min(1).optional(),
5651
+ description: z10.string().optional()
4607
5652
  }).strict();
4608
5653
  var ParamSpecBase = {
4609
- required: z9.boolean().optional().default(false),
4610
- description: z9.string().optional()
5654
+ required: z10.boolean().optional().default(false),
5655
+ description: z10.string().optional()
4611
5656
  };
4612
- var StringParam = z9.object({
5657
+ var StringParam = z10.object({
4613
5658
  ...ParamSpecBase,
4614
- kind: z9.literal("string"),
4615
- default: z9.string().optional(),
4616
- enum: z9.array(z9.string()).optional()
5659
+ kind: z10.literal("string"),
5660
+ default: z10.string().optional(),
5661
+ enum: z10.array(z10.string()).optional()
4617
5662
  }).strict();
4618
- var IntegerParam = z9.object({
5663
+ var IntegerParam = z10.object({
4619
5664
  ...ParamSpecBase,
4620
- kind: z9.literal("integer"),
4621
- default: z9.number().int().optional(),
4622
- min: z9.number().int().optional(),
4623
- max: z9.number().int().optional()
5665
+ kind: z10.literal("integer"),
5666
+ default: z10.number().int().optional(),
5667
+ min: z10.number().int().optional(),
5668
+ max: z10.number().int().optional()
4624
5669
  }).strict();
4625
- var NumberParam = z9.object({
5670
+ var NumberParam = z10.object({
4626
5671
  ...ParamSpecBase,
4627
- kind: z9.literal("number"),
4628
- default: z9.number().optional(),
4629
- min: z9.number().optional(),
4630
- max: z9.number().optional()
5672
+ kind: z10.literal("number"),
5673
+ default: z10.number().optional(),
5674
+ min: z10.number().optional(),
5675
+ max: z10.number().optional()
4631
5676
  }).strict();
4632
- var BooleanParam = z9.object({
5677
+ var BooleanParam = z10.object({
4633
5678
  ...ParamSpecBase,
4634
- kind: z9.literal("boolean"),
4635
- default: z9.boolean().optional()
5679
+ kind: z10.literal("boolean"),
5680
+ default: z10.boolean().optional()
4636
5681
  }).strict();
4637
- var ColorParam = z9.object({
5682
+ var ColorParam = z10.object({
4638
5683
  ...ParamSpecBase,
4639
- kind: z9.literal("color"),
4640
- default: z9.string().optional()
5684
+ kind: z10.literal("color"),
5685
+ default: z10.string().optional()
4641
5686
  }).strict();
4642
- var ImageParam = z9.object({
5687
+ var ImageParam = z10.object({
4643
5688
  ...ParamSpecBase,
4644
- kind: z9.literal("image"),
4645
- default: z9.string().optional()
5689
+ kind: z10.literal("image"),
5690
+ default: z10.string().optional()
4646
5691
  }).strict();
4647
- var JsonParam = z9.object({
5692
+ var JsonParam = z10.object({
4648
5693
  ...ParamSpecBase,
4649
- kind: z9.literal("json"),
4650
- schema: z9.unknown().optional(),
4651
- default: z9.unknown().optional()
5694
+ kind: z10.literal("json"),
5695
+ schema: z10.unknown().optional(),
5696
+ default: z10.unknown().optional()
4652
5697
  }).strict();
4653
- var ParamSpec = z9.discriminatedUnion("kind", [
5698
+ var ParamSpec = z10.discriminatedUnion("kind", [
4654
5699
  StringParam,
4655
5700
  IntegerParam,
4656
5701
  NumberParam,
@@ -4659,22 +5704,22 @@ var ParamSpec = z9.discriminatedUnion("kind", [
4659
5704
  ImageParam,
4660
5705
  JsonParam
4661
5706
  ]);
4662
- var CompositionMetaSchema = z9.object({
4663
- id: z9.string().min(1),
4664
- title: z9.string().min(1),
4665
- description: z9.string().optional(),
4666
- width: z9.number().int().positive(),
4667
- height: z9.number().int().positive(),
4668
- fps: z9.number().int().positive().default(30),
4669
- default_duration: z9.number().positive().default(10),
4670
- inputs: z9.record(z9.string(), InputSpec).default({}),
4671
- params: z9.record(z9.string(), ParamSpec).default({})
5707
+ var CompositionMetaSchema = z10.object({
5708
+ id: z10.string().min(1),
5709
+ title: z10.string().min(1),
5710
+ description: z10.string().optional(),
5711
+ width: z10.number().int().positive(),
5712
+ height: z10.number().int().positive(),
5713
+ fps: z10.number().int().positive().default(30),
5714
+ default_duration: z10.number().positive().default(10),
5715
+ inputs: z10.record(z10.string(), InputSpec).default({}),
5716
+ params: z10.record(z10.string(), ParamSpec).default({})
4672
5717
  }).strict();
4673
5718
  async function loadCompositionMeta(compositionDir) {
4674
- const metaPath = path8.join(compositionDir, "meta.json");
5719
+ const metaPath = path9.join(compositionDir, "meta.json");
4675
5720
  let raw;
4676
5721
  try {
4677
- raw = await readFile6(metaPath, "utf-8");
5722
+ raw = await readFile7(metaPath, "utf-8");
4678
5723
  } catch (e) {
4679
5724
  throw new Error(`composition meta: cannot read ${metaPath} (${e.message})`);
4680
5725
  }
@@ -4696,39 +5741,39 @@ function buildParamsSchema(meta) {
4696
5741
  for (const [name, spec] of Object.entries(meta.params)) {
4697
5742
  shape[name] = buildParamFieldSchema(name, spec);
4698
5743
  }
4699
- return z9.object(shape).strict();
5744
+ return z10.object(shape).strict();
4700
5745
  }
4701
5746
  function buildParamFieldSchema(name, spec) {
4702
5747
  switch (spec.kind) {
4703
5748
  case "string": {
4704
- const s = spec.enum && spec.enum.length > 0 ? z9.enum(spec.enum) : z9.string();
5749
+ const s = spec.enum && spec.enum.length > 0 ? z10.enum(spec.enum) : z10.string();
4705
5750
  return finalize(s, spec.default, spec.required);
4706
5751
  }
4707
5752
  case "integer": {
4708
- let s = z9.number().int();
5753
+ let s = z10.number().int();
4709
5754
  if (spec.min !== void 0) s = s.min(spec.min);
4710
5755
  if (spec.max !== void 0) s = s.max(spec.max);
4711
5756
  return finalize(s, spec.default, spec.required);
4712
5757
  }
4713
5758
  case "number": {
4714
- let s = z9.number();
5759
+ let s = z10.number();
4715
5760
  if (spec.min !== void 0) s = s.min(spec.min);
4716
5761
  if (spec.max !== void 0) s = s.max(spec.max);
4717
5762
  return finalize(s, spec.default, spec.required);
4718
5763
  }
4719
5764
  case "boolean":
4720
- return finalize(z9.boolean(), spec.default, spec.required);
5765
+ return finalize(z10.boolean(), spec.default, spec.required);
4721
5766
  case "color": {
4722
- const s = z9.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
5767
+ const s = z10.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, {
4723
5768
  message: `param "${name}": must be a 3/6/8-digit hex color (e.g. "#ff0066")`
4724
5769
  });
4725
5770
  return finalize(s, spec.default, spec.required);
4726
5771
  }
4727
5772
  case "image":
4728
- return finalize(z9.union([z9.string().min(1), z9.record(z9.string(), z9.unknown())]), spec.default, spec.required);
5773
+ return finalize(z10.union([z10.string().min(1), z10.record(z10.string(), z10.unknown())]), spec.default, spec.required);
4729
5774
  case "json":
4730
5775
  return finalize(
4731
- z9.unknown().refine((v) => v !== void 0, { message: `param "${name}" is required` }),
5776
+ z10.unknown().refine((v) => v !== void 0, { message: `param "${name}" is required` }),
4732
5777
  spec.default,
4733
5778
  spec.required
4734
5779
  );
@@ -4752,8 +5797,8 @@ function defaultFilenameForInput(key, kind) {
4752
5797
 
4753
5798
  // src/engine/nodes/local/lib/hyperframe-check.ts
4754
5799
  import { execFile as execFile3 } from "child_process";
4755
- import { readFile as readFile7 } from "fs/promises";
4756
- import path9 from "path";
5800
+ import { readFile as readFile8 } from "fs/promises";
5801
+ import path10 from "path";
4757
5802
  import { promisify as promisify3 } from "util";
4758
5803
  var execFileAsync = promisify3(execFile3);
4759
5804
  var NEVER_BLOCK = [
@@ -4766,8 +5811,8 @@ var NEVER_BLOCK = [
4766
5811
  /text[_-]?occluded/i
4767
5812
  ];
4768
5813
  var UNAVAILABLE = /unknown command|command not found|not found|Did you mean|Unknown argument|ENOENT/i;
4769
- function isAdvisory(code, message) {
4770
- const hay = `${code} ${message}`;
5814
+ function isAdvisory(code, message2) {
5815
+ const hay = `${code} ${message2}`;
4771
5816
  return NEVER_BLOCK.some((re) => re.test(hay));
4772
5817
  }
4773
5818
  function parseCheckJson(raw) {
@@ -4795,10 +5840,10 @@ function classifyLint(json) {
4795
5840
  for (const f of findings) {
4796
5841
  const rec = f;
4797
5842
  const code = String(rec?.code ?? "");
4798
- const message = String(rec?.message ?? "");
5843
+ const message2 = String(rec?.message ?? "");
4799
5844
  const severity = String(rec?.severity ?? "info");
4800
- const blocking = severity === "error" && !isAdvisory(code, message);
4801
- out.push({ source: "lint", code, message, severity: blocking ? "blocking" : "warning" });
5845
+ const blocking = severity === "error" && !isAdvisory(code, message2);
5846
+ out.push({ source: "lint", code, message: message2, severity: blocking ? "blocking" : "warning" });
4802
5847
  }
4803
5848
  return out;
4804
5849
  }
@@ -4810,9 +5855,9 @@ function classifyInspect(json) {
4810
5855
  for (const iss of issues) {
4811
5856
  const rec = iss;
4812
5857
  const code = String(rec?.code ?? rec?.type ?? "overflow");
4813
- const message = String(rec?.message ?? rec?.detail ?? JSON.stringify(iss));
5858
+ const message2 = String(rec?.message ?? rec?.detail ?? JSON.stringify(iss));
4814
5859
  const severity = rec?.severity ? String(rec.severity) : obj?.ok === false ? "error" : "warning";
4815
- out.push({ source: "inspect", code, message, severity: severity === "error" ? "blocking" : "warning" });
5860
+ out.push({ source: "inspect", code, message: message2, severity: severity === "error" ? "blocking" : "warning" });
4816
5861
  }
4817
5862
  return out;
4818
5863
  }
@@ -4905,7 +5950,7 @@ ${detail}`);
4905
5950
  }
4906
5951
  let indexHtml = "";
4907
5952
  try {
4908
- indexHtml = await readFile7(path9.join(dir, "index.html"), "utf-8");
5953
+ indexHtml = await readFile8(path10.join(dir, "index.html"), "utf-8");
4909
5954
  } catch {
4910
5955
  indexHtml = "";
4911
5956
  }
@@ -4970,9 +6015,9 @@ ${stderr.slice(0, 1500)}`;
4970
6015
 
4971
6016
  // src/engine/nodes/local/lib/hyperframe-meta.ts
4972
6017
  import { writeFile as writeFile4 } from "fs/promises";
4973
- import path10 from "path";
6018
+ import path11 from "path";
4974
6019
  async function ensureHyperframesMetaJson(tmp, nodeId, meta, duration) {
4975
- const metaPath = path10.join(tmp, "meta.json");
6020
+ const metaPath = path11.join(tmp, "meta.json");
4976
6021
  await writeFile4(
4977
6022
  metaPath,
4978
6023
  JSON.stringify(
@@ -5028,17 +6073,17 @@ function literalize(value) {
5028
6073
  // src/engine/nodes/local/hyperframe.ts
5029
6074
  var execFileAsync2 = promisify4(execFile4);
5030
6075
  var require_2 = createRequire2(import.meta.url);
5031
- var HyperframeParams = z10.object({
5032
- composition: z10.string().min(1),
6076
+ var HyperframeParams = z11.object({
6077
+ composition: z11.string().min(1),
5033
6078
  // Output container. mp4 (default) for delivery; webm/mov render WITH
5034
6079
  // transparency (alpha) when the composition background is transparent —
5035
6080
  // use for motion-graphic overlays dropped into Premiere/AE/Nuke.
5036
- format: z10.enum(["mp4", "webm", "mov"]).optional().default("mp4"),
5037
- timeout_ms: z10.number().int().positive().optional().default(10 * 60 * 1e3)
5038
- }).catchall(z10.unknown());
5039
- var HyperframeInputs = z10.record(z10.string(), z10.custom()).optional().default({});
5040
- var HyperframeOutputs = z10.object({
5041
- video: z10.custom()
6081
+ format: z11.enum(["mp4", "webm", "mov"]).optional().default("mp4"),
6082
+ timeout_ms: z11.number().int().positive().optional().default(10 * 60 * 1e3)
6083
+ }).catchall(z11.unknown());
6084
+ var HyperframeInputs = z11.record(z11.string(), z11.custom()).optional().default({});
6085
+ var HyperframeOutputs = z11.object({
6086
+ video: z11.custom()
5042
6087
  }).strict();
5043
6088
  var NODE_OWNED_PARAM_KEYS = /* @__PURE__ */ new Set(["composition", "format", "timeout_ms"]);
5044
6089
  var MIME_BY_FORMAT = {
@@ -5072,7 +6117,7 @@ var hyperframeRenderNode = defineNode({
5072
6117
  const compositionDir = await resolveCompositionDir(params.composition);
5073
6118
  const meta = await loadCompositionMeta(compositionDir);
5074
6119
  const compositionParams = validateAndParseDynamicParams(meta, params);
5075
- const tmp = await mkdtemp4(path11.join(tmpdir4(), "hf-render-"));
6120
+ const tmp = await mkdtemp4(path12.join(tmpdir4(), "hf-render-"));
5076
6121
  try {
5077
6122
  await copyComposition(compositionDir, tmp);
5078
6123
  await vendorGsap(tmp, ctx);
@@ -5082,9 +6127,9 @@ var hyperframeRenderNode = defineNode({
5082
6127
  await substituteCompositionFiles(tmp, substitutionValues);
5083
6128
  await ensureHyperframesMetaJson(tmp, ctx.nodeId, meta, duration);
5084
6129
  await runHyperframesCheck({ dir: tmp, nodeId: "hyperframe_render", ctx, timeoutMs: params.timeout_ms });
5085
- const outputPath = path11.join(tmp, `output.${params.format}`);
6130
+ const outputPath = path12.join(tmp, `output.${params.format}`);
5086
6131
  await runRender({ tmp, outputPath, params, meta, ctx });
5087
- const bytes = await readFile8(outputPath);
6132
+ const bytes = await readFile9(outputPath);
5088
6133
  ctx.log(`rendered ${bytes.length} bytes`);
5089
6134
  const ref = await ctx.assets.ingestBytes({
5090
6135
  bytes: Buffer.from(bytes),
@@ -5106,10 +6151,10 @@ var hyperframeRenderNode = defineNode({
5106
6151
  }
5107
6152
  });
5108
6153
  async function resolveCompositionDir(composition) {
5109
- const compositionPath = path11.isAbsolute(composition) ? composition : path11.resolve(process.cwd(), composition);
6154
+ const compositionPath = path12.isAbsolute(composition) ? composition : path12.resolve(process.cwd(), composition);
5110
6155
  const s = await stat5(compositionPath);
5111
6156
  if (s.isDirectory()) return compositionPath;
5112
- return path11.dirname(compositionPath);
6157
+ return path12.dirname(compositionPath);
5113
6158
  }
5114
6159
  async function validateComposition(rawParams) {
5115
6160
  const issues = await validateCompositionParams(rawParams);
@@ -5191,7 +6236,7 @@ async function copyComposition(srcDir, destDir) {
5191
6236
  await cp(srcDir, destDir, {
5192
6237
  recursive: true,
5193
6238
  filter: (src) => {
5194
- const name = path11.basename(src);
6239
+ const name = path12.basename(src);
5195
6240
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
5196
6241
  return true;
5197
6242
  }
@@ -5200,7 +6245,7 @@ async function copyComposition(srcDir, destDir) {
5200
6245
  async function vendorGsap(tmp, ctx) {
5201
6246
  try {
5202
6247
  const gsapMin = require_2.resolve("gsap/dist/gsap.min.js");
5203
- await copyFile4(gsapMin, path11.join(tmp, "gsap.min.js"));
6248
+ await copyFile4(gsapMin, path12.join(tmp, "gsap.min.js"));
5204
6249
  } catch (e) {
5205
6250
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
5206
6251
  }
@@ -5215,7 +6260,7 @@ async function stageInputs2(tmp, inputs, meta, ctx) {
5215
6260
  await stageAsset(ref, tmp, filename);
5216
6261
  ctx.log(`staged ${spec.kind} \u2192 ${filename}`);
5217
6262
  if (spec.kind === "video" && primaryDuration === null) {
5218
- primaryDuration = await probeDurationSeconds(path11.join(tmp, filename));
6263
+ primaryDuration = await probeDurationSeconds(path12.join(tmp, filename));
5219
6264
  }
5220
6265
  }
5221
6266
  return primaryDuration;
@@ -5257,12 +6302,12 @@ async function buildSubstitutionValues(compositionParams, meta, duration) {
5257
6302
  }
5258
6303
  function coerceImageParam(value) {
5259
6304
  if (typeof value === "string") return Promise.resolve(value);
5260
- if (isAssetRefLike(value)) return refToUrl(value);
6305
+ if (isAssetRefLike2(value)) return refToUrl(value);
5261
6306
  throw new Error("hyperframe_render: image param must be a URL string or AssetRef");
5262
6307
  }
5263
6308
  async function substituteCompositionFiles(tmp, values) {
5264
- const entryPath = path11.join(tmp, "index.html");
5265
- const original = await readFile8(entryPath, "utf-8");
6309
+ const entryPath = path12.join(tmp, "index.html");
6310
+ const original = await readFile9(entryPath, "utf-8");
5266
6311
  const { output, missing } = substituteVariables(original, values);
5267
6312
  if (missing.length > 0) {
5268
6313
  throw new Error(
@@ -5278,7 +6323,7 @@ function workerCount() {
5278
6323
  async function runRender(opts) {
5279
6324
  const { tmp, outputPath, params, meta, ctx } = opts;
5280
6325
  const args = buildRenderArgs(tmp, outputPath, meta, params.format);
5281
- ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path11.basename(tmp)}`);
6326
+ ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path12.basename(tmp)}`);
5282
6327
  try {
5283
6328
  await execFileAsync2("npx", args, { timeout: params.timeout_ms, maxBuffer: 64 * 1024 * 1024 });
5284
6329
  } catch (e) {
@@ -5322,28 +6367,28 @@ async function probeDurationSeconds(filePath) {
5322
6367
 
5323
6368
  // src/engine/nodes/local/hyperframe-snapshot.ts
5324
6369
  import { execFile as execFile5 } from "child_process";
5325
- import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile9, rm as rm5, writeFile as writeFile6 } from "fs/promises";
6370
+ import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile10, rm as rm5, writeFile as writeFile6 } from "fs/promises";
5326
6371
  import { createRequire as createRequire3 } from "module";
5327
6372
  import { tmpdir as tmpdir5 } from "os";
5328
- import path12 from "path";
6373
+ import path13 from "path";
5329
6374
  import { promisify as promisify5 } from "util";
5330
- import { z as z11 } from "zod";
6375
+ import { z as z12 } from "zod";
5331
6376
  var _execFileAsync = promisify5(execFile5);
5332
6377
  var require_3 = createRequire3(import.meta.url);
5333
- var WaitForSpec = z11.discriminatedUnion("kind", [
5334
- z11.object({ kind: z11.literal("auto") }),
5335
- z11.object({ kind: z11.literal("selector"), value: z11.string().min(1) }),
5336
- z11.object({ kind: z11.literal("function"), value: z11.string().min(1) }),
5337
- z11.object({ kind: z11.literal("timeout"), ms: z11.number().int().min(0).max(6e4) })
6378
+ var WaitForSpec = z12.discriminatedUnion("kind", [
6379
+ z12.object({ kind: z12.literal("auto") }),
6380
+ z12.object({ kind: z12.literal("selector"), value: z12.string().min(1) }),
6381
+ z12.object({ kind: z12.literal("function"), value: z12.string().min(1) }),
6382
+ z12.object({ kind: z12.literal("timeout"), ms: z12.number().int().min(0).max(6e4) })
5338
6383
  ]);
5339
- var HyperframeSnapshotParams = z11.object({
5340
- composition: z11.string().min(1),
6384
+ var HyperframeSnapshotParams = z12.object({
6385
+ composition: z12.string().min(1),
5341
6386
  wait_for: WaitForSpec.optional().default({ kind: "auto" }),
5342
- timeout_ms: z11.number().int().positive().optional().default(6e4)
5343
- }).catchall(z11.unknown());
5344
- var HyperframeSnapshotInputs = z11.record(z11.string(), z11.custom()).optional().default({});
5345
- var HyperframeSnapshotOutputs = z11.object({
5346
- image: z11.custom()
6387
+ timeout_ms: z12.number().int().positive().optional().default(6e4)
6388
+ }).catchall(z12.unknown());
6389
+ var HyperframeSnapshotInputs = z12.record(z12.string(), z12.custom()).optional().default({});
6390
+ var HyperframeSnapshotOutputs = z12.object({
6391
+ image: z12.custom()
5347
6392
  }).strict();
5348
6393
  var NODE_OWNED_PARAM_KEYS2 = /* @__PURE__ */ new Set(["composition", "wait_for", "timeout_ms"]);
5349
6394
  var DEVICE_SCALE_FACTOR2 = 2;
@@ -5372,7 +6417,7 @@ var hyperframeSnapshotNode = defineNode({
5372
6417
  const compositionDir = await resolveCompositionDir(params.composition);
5373
6418
  const meta = await loadCompositionMeta(compositionDir);
5374
6419
  const compositionParams = validateAndParseDynamicParams2(meta, params);
5375
- const tmp = await mkdtemp5(path12.join(tmpdir5(), "hf-snap-"));
6420
+ const tmp = await mkdtemp5(path13.join(tmpdir5(), "hf-snap-"));
5376
6421
  try {
5377
6422
  await copyComposition2(compositionDir, tmp);
5378
6423
  await vendorGsap2(tmp, ctx);
@@ -5387,7 +6432,7 @@ var hyperframeSnapshotNode = defineNode({
5387
6432
  timeoutMs: params.timeout_ms,
5388
6433
  samples: 1
5389
6434
  });
5390
- const entryPath = path12.join(tmp, "index.html");
6435
+ const entryPath = path13.join(tmp, "index.html");
5391
6436
  const entryUrl = `file://${entryPath}`;
5392
6437
  ctx.log(`snapshotting ${meta.width}x${meta.height}@${DEVICE_SCALE_FACTOR2}x wait=${params.wait_for.kind}`);
5393
6438
  const pwSpecifier = ["play", "wright"].join("");
@@ -5448,7 +6493,7 @@ async function copyComposition2(srcDir, destDir) {
5448
6493
  await cp(srcDir, destDir, {
5449
6494
  recursive: true,
5450
6495
  filter: (src) => {
5451
- const name = path12.basename(src);
6496
+ const name = path13.basename(src);
5452
6497
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
5453
6498
  return true;
5454
6499
  }
@@ -5457,7 +6502,7 @@ async function copyComposition2(srcDir, destDir) {
5457
6502
  async function vendorGsap2(tmp, ctx) {
5458
6503
  try {
5459
6504
  const gsapMin = require_3.resolve("gsap/dist/gsap.min.js");
5460
- await copyFile5(gsapMin, path12.join(tmp, "gsap.min.js"));
6505
+ await copyFile5(gsapMin, path13.join(tmp, "gsap.min.js"));
5461
6506
  } catch (e) {
5462
6507
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
5463
6508
  }
@@ -5487,12 +6532,12 @@ async function buildSubstitutionValues2(compositionParams, meta) {
5487
6532
  }
5488
6533
  function coerceImageParam2(value) {
5489
6534
  if (typeof value === "string") return Promise.resolve(value);
5490
- if (isAssetRefLike(value)) return refToUrl(value);
6535
+ if (isAssetRefLike2(value)) return refToUrl(value);
5491
6536
  throw new Error("hyperframe_snapshot: image param must be a URL string or AssetRef");
5492
6537
  }
5493
6538
  async function substituteCompositionFiles2(tmp, values) {
5494
- const entryPath = path12.join(tmp, "index.html");
5495
- const original = await readFile9(entryPath, "utf-8");
6539
+ const entryPath = path13.join(tmp, "index.html");
6540
+ const original = await readFile10(entryPath, "utf-8");
5496
6541
  const { output, missing } = substituteVariables(original, values);
5497
6542
  if (missing.length > 0) {
5498
6543
  throw new Error(
@@ -5535,18 +6580,18 @@ async function waitForReady(page, waitFor, timeoutMs) {
5535
6580
  // src/engine/nodes/local/imagemagick.ts
5536
6581
  import { execFile as execFile6 } from "child_process";
5537
6582
  import { promisify as promisify6 } from "util";
5538
- import { z as z12 } from "zod";
6583
+ import { z as z13 } from "zod";
5539
6584
  var execFileAsync3 = promisify6(execFile6);
5540
- var OutputDecl2 = z12.object({
5541
- kind: z12.enum(["image", "video", "audio"]),
5542
- ext: z12.string().min(1).max(8)
6585
+ var OutputDecl2 = z13.object({
6586
+ kind: z13.enum(["image", "video", "audio"]),
6587
+ ext: z13.string().min(1).max(8)
5543
6588
  }).strict();
5544
- var ImageMagickParams = z12.object({
5545
- args: z12.array(z12.string()).min(1),
5546
- outputs: z12.record(z12.string(), OutputDecl2).default({})
6589
+ var ImageMagickParams = z13.object({
6590
+ args: z13.array(z13.string()).min(1),
6591
+ outputs: z13.record(z13.string(), OutputDecl2).default({})
5547
6592
  }).strict();
5548
- var ImageMagickInputs = z12.record(z12.string(), z12.unknown());
5549
- var ImageMagickOutputs = z12.record(z12.string(), z12.custom());
6593
+ var ImageMagickInputs = z13.record(z13.string(), z13.unknown());
6594
+ var ImageMagickOutputs = z13.record(z13.string(), z13.custom());
5550
6595
  var resolvedBin;
5551
6596
  async function resolveBin() {
5552
6597
  if (resolvedBin) return resolvedBin;
@@ -5588,29 +6633,29 @@ var imagemagickNode = defineNode({
5588
6633
  });
5589
6634
 
5590
6635
  // src/engine/nodes/local/text.ts
5591
- import { z as z13 } from "zod";
6636
+ import { z as z14 } from "zod";
5592
6637
  var textNode = defineNode({
5593
6638
  id: "text",
5594
6639
  version: "1.0.0",
5595
6640
  category: "data",
5596
6641
  location: "local",
5597
6642
  summary: "A literal text value. Use for prompts, descriptions, copy.",
5598
- inputs: z13.object({}).strict(),
5599
- params: z13.object({ value: z13.string() }).strict(),
5600
- outputs: z13.object({ text: z13.string() }).strict(),
6643
+ inputs: z14.object({}).strict(),
6644
+ params: z14.object({ value: z14.string() }).strict(),
6645
+ outputs: z14.object({ text: z14.string() }).strict(),
5601
6646
  cost: () => ({ credits: 0, seconds_estimate: 0 }),
5602
6647
  execute: ({ params }) => Promise.resolve({ text: params.value })
5603
6648
  });
5604
6649
 
5605
6650
  // src/engine/nodes/remote/audioVoiceConvert.ts
5606
- import { z as z14 } from "zod";
5607
- var AudioVoiceConvertParams = z14.object({
5608
- model: z14.literal("elevenlabs/eleven_multilingual_sts_v2"),
6651
+ import { z as z15 } from "zod";
6652
+ var AudioVoiceConvertParams = z15.object({
6653
+ model: z15.literal("elevenlabs/eleven_multilingual_sts_v2"),
5609
6654
  /** Target voice id. Splice an upstream `voice_select` via `"{{voice_ref}}"`. */
5610
- voice: z14.string().min(1),
5611
- output_format: z14.string().optional(),
6655
+ voice: z15.string().min(1),
6656
+ output_format: z15.string().optional(),
5612
6657
  /** Strip the source clip's background noise before re-voicing. */
5613
- remove_background_noise: z14.boolean().optional()
6658
+ remove_background_noise: z15.boolean().optional()
5614
6659
  }).strict();
5615
6660
  var audioVoiceConvertNode = delegated({
5616
6661
  id: "audio_voice_convert",
@@ -5618,44 +6663,44 @@ var audioVoiceConvertNode = delegated({
5618
6663
  category: "audio",
5619
6664
  summary: "Voice Changer / speech-to-speech via ElevenLabs (eleven_multilingual_sts_v2). Re-voices an existing audio clip in a TARGET voice while preserving timing/prosody.",
5620
6665
  when_to_use: 'Use to normalize a generator-chosen voice (e.g. a Seedance talking-head clip\'s native audio) into ONE consistent brand voice across every scene \u2014 the cadence is preserved so any lip-sync stays valid. Wire `inputs.voice_ref: $ref:<voice_select>.voice_id` and set `params.voice: "{{voice_ref}}"`.',
5621
- inputs: z14.object({
6666
+ inputs: z15.object({
5622
6667
  audio: AudioRef,
5623
6668
  voice_ref: TextRef.optional()
5624
6669
  }).strict(),
5625
6670
  params: AudioVoiceConvertParams,
5626
- outputs: z14.object({ audio: AudioRef }).strict(),
6671
+ outputs: z15.object({ audio: AudioRef }).strict(),
5627
6672
  outputKinds: { audio: "audio" },
5628
6673
  cost: () => ({ credits: 1, seconds_estimate: 20 })
5629
6674
  });
5630
6675
 
5631
6676
  // src/engine/nodes/remote/dialogue.ts
5632
- import { z as z15 } from "zod";
5633
- var DialogueInput = z15.object({
5634
- text: z15.string().min(1),
5635
- voice_id: z15.string().min(1)
6677
+ import { z as z16 } from "zod";
6678
+ var DialogueInput = z16.object({
6679
+ text: z16.string().min(1),
6680
+ voice_id: z16.string().min(1)
5636
6681
  });
5637
6682
  var DIALOGUE_MODELS = ["elevenlabs/eleven_v3"];
5638
- var DialogueParams = z15.object({
5639
- model: z15.enum(DIALOGUE_MODELS),
6683
+ var DialogueParams = z16.object({
6684
+ model: z16.enum(DIALOGUE_MODELS),
5640
6685
  /**
5641
6686
  * Ordered list of lines, each tagged with the voice that should speak it.
5642
6687
  * Up to 10 unique voice_ids; total text across all lines should stay under
5643
6688
  * ~2000 characters for best quality (ElevenLabs guidance).
5644
6689
  */
5645
- inputs: z15.array(DialogueInput).min(1).max(50),
5646
- language_code: z15.string().optional(),
6690
+ inputs: z16.array(DialogueInput).min(1).max(50),
6691
+ language_code: z16.string().optional(),
5647
6692
  /** ElevenLabs voice/model settings passthrough (e.g. `{ stability: 0.5 }`). */
5648
- settings: z15.record(z15.string(), z15.unknown()).optional(),
5649
- seed: z15.number().int().min(0).max(4294967295).optional(),
5650
- apply_text_normalization: z15.enum(["auto", "on", "off"]).optional(),
6693
+ settings: z16.record(z16.string(), z16.unknown()).optional(),
6694
+ seed: z16.number().int().min(0).max(4294967295).optional(),
6695
+ apply_text_normalization: z16.enum(["auto", "on", "off"]).optional(),
5651
6696
  /**
5652
6697
  * When true, hits `/v1/text-to-dialogue/with-timestamps` and emits a
5653
6698
  * separate `timestamps` output — character-level alignment plus
5654
6699
  * per-voice segment markers usable for captions, lipsync, or
5655
6700
  * beat-matched cuts in ad creatives.
5656
6701
  */
5657
- with_timestamps: z15.boolean().optional(),
5658
- output_format: z15.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6702
+ with_timestamps: z16.boolean().optional(),
6703
+ output_format: z16.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
5659
6704
  }).strict().refine((p) => p.inputs.reduce((sum, line) => sum + line.text.length, 0) <= ELEVENLABS_MAX_TEXT_CHARS, {
5660
6705
  message: `total dialogue text exceeds ${ELEVENLABS_MAX_TEXT_CHARS} characters`,
5661
6706
  path: ["inputs"]
@@ -5666,9 +6711,9 @@ var dialogueNode = delegated({
5666
6711
  category: "audio",
5667
6712
  summary: "Multi-voice dialogue / VO with ElevenLabs Eleven v3. Each line is tagged with a `voice_id`, so you can render two-character scripts (e.g. ad VO + customer testimonial reaction) in a single call. Setting `with_timestamps: true` adds character-level alignment for caption rendering and lipsync-friendly cuts.",
5668
6713
  when_to_use: "Use for any ad creative or website video VO that needs more than narration \u2014 interviews, two-actor scripts, character ads, testimonial reads. For single-voice flat reads the existing `tts` node is cheaper and simpler; reach for `dialogue` when you need multiple speakers in one stitched track or word-level timing for downstream lipsync / captions.",
5669
- inputs: z15.object({}).loose(),
6714
+ inputs: z16.object({}).loose(),
5670
6715
  params: DialogueParams,
5671
- outputs: z15.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6716
+ outputs: z16.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
5672
6717
  outputKinds: { audio: "audio", timestamps: "json" },
5673
6718
  cost: ({ params }) => {
5674
6719
  const chars = params.inputs.reduce((sum, line) => sum + line.text.length, 0);
@@ -5677,7 +6722,7 @@ var dialogueNode = delegated({
5677
6722
  });
5678
6723
 
5679
6724
  // src/engine/nodes/remote/image.ts
5680
- import { z as z16 } from "zod";
6725
+ import { z as z17 } from "zod";
5681
6726
  var IMAGE_GENERATE_MODELS2 = [
5682
6727
  "openai/gpt-5.4-image-2",
5683
6728
  "google/gemini-3.5-flash",
@@ -5685,41 +6730,44 @@ var IMAGE_GENERATE_MODELS2 = [
5685
6730
  "google/gemini-3-pro-image-preview",
5686
6731
  "recraft/recraft-v4.1-pro-vector"
5687
6732
  ];
5688
- var ImageGenerateParams = z16.object({
5689
- model: z16.enum(IMAGE_GENERATE_MODELS2),
5690
- prompt: z16.string().min(1),
5691
- 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(),
5692
- image_size: z16.enum(["0.5K", "1K", "2K", "4K"]).optional(),
6733
+ var ImageGenerateParams = z17.object({
6734
+ model: z17.enum(IMAGE_GENERATE_MODELS2),
6735
+ prompt: z17.string().min(1),
6736
+ aspect_ratio: z17.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
6737
+ image_size: z17.enum(["0.5K", "1K", "2K", "4K"]).optional(),
6738
+ // Rendering quality — forwarded into `image_config`. OpenRouter models without a
6739
+ // quality knob ignore it; the registry gates which models accept it (gpt-image, Gemini).
6740
+ quality: z17.enum(["auto", "low", "medium", "high"]).optional(),
5693
6741
  // Recraft v4 vector controls — forwarded into `image_config`. Registry
5694
6742
  // rejects them on non-Recraft models.
5695
- strength: z16.number().min(0).max(1).optional(),
5696
- rgb_colors: z16.array(z16.array(z16.number().int().min(0).max(255))).optional(),
5697
- background_rgb_color: z16.array(z16.number().int().min(0).max(255)).optional()
6743
+ strength: z17.number().min(0).max(1).optional(),
6744
+ rgb_colors: z17.array(z17.array(z17.number().int().min(0).max(255))).optional(),
6745
+ background_rgb_color: z17.array(z17.number().int().min(0).max(255)).optional()
5698
6746
  }).strict();
5699
6747
  var imageGenerateNode = delegated({
5700
6748
  id: "image_generate",
5701
- version: "2.1.0",
6749
+ version: "2.2.0",
5702
6750
  category: "image",
5703
6751
  summary: "Generate images for ad creatives. Curated model set: GPT-5.4 Image, Gemini 3.5 Flash, Gemini 3.1 Flash Image Preview, Gemini 3 Pro Image, Recraft v4.1 Pro Vector. Per-model param support comes from the canvas-engine model registry.",
5704
6752
  when_to_use: "Use for hero shots, product photography, illustrations, and vector logos. `recraft/recraft-v4.1-pro-vector` for crisp vector / logo work; `openai/gpt-5.4-image-2` for photorealistic; Gemini variants for fast iteration and editing via the `reference` input. `reference` accepts ONE image or an ARRAY of images \u2014 wire several to combine references in a single generation (e.g. a subject sheet + a font specimen + the original ad). Every reference is forwarded to the model in array order.",
5705
6753
  // `reference` is one image or an ordered array of images. The backend forwards
5706
6754
  // each as a separate `image_url` to the provider (OpenRouter accepts many).
5707
- inputs: z16.object({ reference: z16.union([ImageRef, z16.array(ImageRef).min(1)]).optional() }).loose(),
6755
+ inputs: z17.object({ reference: z17.union([ImageRef, z17.array(ImageRef).min(1)]).optional() }).loose(),
5708
6756
  params: ImageGenerateParams,
5709
- outputs: z16.object({ images: z16.array(ImageRef).min(1) }).strict(),
6757
+ outputs: z17.object({ images: z17.array(ImageRef).min(1) }).strict(),
5710
6758
  outputKinds: { images: "image" },
5711
6759
  cost: () => ({ credits: 5, seconds_estimate: 10 })
5712
6760
  });
5713
6761
 
5714
6762
  // src/engine/nodes/remote/imageAspectAdapt.ts
5715
- import { z as z17 } from "zod";
6763
+ import { z as z18 } from "zod";
5716
6764
  var ASPECT_ADAPT_MODELS = ["google/gemini-3-pro-image-preview", "google/gemini-3.1-flash-image-preview"];
5717
6765
  var ASPECT_ADAPT_FORMATS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
5718
- var ImageAspectAdaptParams = z17.object({
5719
- model: z17.enum(ASPECT_ADAPT_MODELS),
5720
- 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" }),
5721
- guidance: z17.string().min(1).optional(),
5722
- image_size: z17.enum(["0.5K", "1K", "2K", "4K"]).optional()
6766
+ var ImageAspectAdaptParams = z18.object({
6767
+ model: z18.enum(ASPECT_ADAPT_MODELS),
6768
+ formats: z18.array(z18.enum(ASPECT_ADAPT_FORMATS)).min(1).max(6).refine((formats) => new Set(formats).size === formats.length, { message: "formats must be unique" }),
6769
+ guidance: z18.string().min(1).optional(),
6770
+ image_size: z18.enum(["0.5K", "1K", "2K", "4K"]).optional()
5723
6771
  }).strict();
5724
6772
  var imageAspectAdaptNode = delegated({
5725
6773
  id: "image_aspect_adapt",
@@ -5727,9 +6775,9 @@ var imageAspectAdaptNode = delegated({
5727
6775
  category: "image",
5728
6776
  summary: "Adapt ONE creative into multiple aspect ratios (Meta: 9:16 stories, 1:1 feed, 4:5, 16:9\u2026) in a single step. AI recomposes the layout per format \u2014 identical subject, text, logos, colors, and style; the scene is extended/restructured, never stretched or cropped. Formats that already match the source ratio pass through unchanged at zero cost. Outputs are ordered exactly as `formats`.",
5729
6777
  when_to_use: "Use after a hero creative exists (image_generate, ingest, image_search) to fan it out to every placement format \u2014 wire the creative into `source` and list the target ratios in `formats`. Cost is estimated per format; formats matching the source ratio are free pass-throughs. Pick `google/gemini-3.1-flash-image-preview` (Nano Banana flash) while iterating, `google/gemini-3-pro-image-preview` (Nano Banana Pro) for final-quality adaptation.",
5730
- inputs: z17.object({ source: ImageRef }).loose(),
6778
+ inputs: z18.object({ source: ImageRef }).loose(),
5731
6779
  params: ImageAspectAdaptParams,
5732
- outputs: z17.object({ images: z17.array(ImageRef).min(1) }).strict(),
6780
+ outputs: z18.object({ images: z18.array(ImageRef).min(1) }).strict(),
5733
6781
  outputKinds: { images: "image" },
5734
6782
  cost: ({ params }) => {
5735
6783
  const p = params;
@@ -5742,12 +6790,12 @@ var imageAspectAdaptNode = delegated({
5742
6790
  });
5743
6791
 
5744
6792
  // src/engine/nodes/remote/imageBackgroundRemove.ts
5745
- import { z as z18 } from "zod";
5746
- var ImageBackgroundRemoveParams = z18.object({
5747
- model: z18.literal("fal/birefnet-v2").optional().default("fal/birefnet-v2"),
5748
- model_variant: z18.enum(["General Use (Light)", "General Use (Heavy)", "Matting", "Portrait", "DIS", "HRSOD", "COD"]).optional().default("General Use (Light)"),
5749
- operating_resolution: z18.enum(["1024x1024", "2048x2048", "2304x2304"]).optional(),
5750
- mask_only: z18.boolean().optional().default(false)
6793
+ import { z as z19 } from "zod";
6794
+ var ImageBackgroundRemoveParams = z19.object({
6795
+ model: z19.literal("fal/birefnet-v2").optional().default("fal/birefnet-v2"),
6796
+ model_variant: z19.enum(["General Use (Light)", "General Use (Heavy)", "Matting", "Portrait", "DIS", "HRSOD", "COD"]).optional().default("General Use (Light)"),
6797
+ operating_resolution: z19.enum(["1024x1024", "2048x2048", "2304x2304"]).optional(),
6798
+ mask_only: z19.boolean().optional().default(false)
5751
6799
  }).strict();
5752
6800
  var imageBackgroundRemoveNode = delegated({
5753
6801
  id: "image_background_remove",
@@ -5755,11 +6803,11 @@ var imageBackgroundRemoveNode = delegated({
5755
6803
  category: "image",
5756
6804
  summary: "Remove the background from an image and return a transparent PNG (or the segmentation mask). Powered by fal.ai `fal-ai/birefnet/v2`.",
5757
6805
  when_to_use: "Use to extract subjects from photos for use as overlays in hyperframe compositions, product shots, or compositing pipelines. Set `mask_only:true` to return the binary mask instead of the alpha-cut image.",
5758
- inputs: z18.object({
6806
+ inputs: z19.object({
5759
6807
  image: ImageRef
5760
6808
  }).strict(),
5761
6809
  params: ImageBackgroundRemoveParams,
5762
- outputs: z18.object({
6810
+ outputs: z19.object({
5763
6811
  image: ImageRef,
5764
6812
  mask: ImageRef.optional()
5765
6813
  }).strict(),
@@ -5768,7 +6816,7 @@ var imageBackgroundRemoveNode = delegated({
5768
6816
  });
5769
6817
 
5770
6818
  // src/engine/nodes/remote/imageDescribe.ts
5771
- import { z as z19 } from "zod";
6819
+ import { z as z20 } from "zod";
5772
6820
  var IMAGE_DESCRIBE_MODELS = ["~google/gemini-pro-latest", "~google/gemini-flash-latest"];
5773
6821
  var imageDescribeNode = delegated({
5774
6822
  id: "image_describe",
@@ -5776,33 +6824,33 @@ var imageDescribeNode = delegated({
5776
6824
  category: "vision",
5777
6825
  summary: "Reverse-engineer an image into an exhaustive, replication-grade JSON description: who the advertiser is and what they sell (source_context), composition, non-person subjects with expression/treatment, deeply detailed people, brand-identified logos (named by brand, not appearance), camera optics, lighting, color palette WITH per-color brand-ownership (brand vs borrowed-functional) and purpose, materials, visible text, ad signals (proof badges/CTA/price), the persuasion engine (ad_intent), style, post-processing.",
5778
6826
  when_to_use: 'Use to turn a reference image into a structured blueprint you can inject into downstream prompts via `{{slot}}` \u2014 e.g. restyle a competitor ad onto your own product, lock a look across a series, or feed exact palette/lighting into image_generate. Purpose-built for market adaptation: logos are identified by brand ("Trustpilot", never "green star"), people and animals carry expression/emotion/intent detail, and each color is tagged brand vs borrowed-functional so a recolor can keep the reds/yellows that do a job. The extraction prompt is baked in; use `focus` to emphasise aspects and `context` to pass known provenance (advertiser, category, market) so source_context and color ownership are grounded. Pick `~google/gemini-pro-latest` for the densest extraction (recommended for ad / market-adaptation passes), `~google/gemini-flash-latest` for cheap/fast passes. The output is rich \u2014 raise `max_tokens` (e.g. 8000+) for dense ads so the JSON isn\'t truncated.',
5779
- inputs: z19.object({ image: ImageRef }).loose(),
5780
- params: z19.object({
5781
- model: z19.enum(IMAGE_DESCRIBE_MODELS),
5782
- focus: z19.string().optional(),
5783
- context: z19.string().optional(),
5784
- temperature: z19.number().min(0).max(2).optional(),
5785
- max_tokens: z19.number().int().positive().optional()
6827
+ inputs: z20.object({ image: ImageRef }).loose(),
6828
+ params: z20.object({
6829
+ model: z20.enum(IMAGE_DESCRIBE_MODELS),
6830
+ focus: z20.string().optional(),
6831
+ context: z20.string().optional(),
6832
+ temperature: z20.number().min(0).max(2).optional(),
6833
+ max_tokens: z20.number().int().positive().optional()
5786
6834
  }).strict(),
5787
- outputs: z19.object({ description: JsonRef }).strict(),
6835
+ outputs: z20.object({ description: JsonRef }).strict(),
5788
6836
  outputKinds: { description: "json" },
5789
6837
  cost: () => ({ credits: 2, seconds_estimate: 10 })
5790
6838
  });
5791
6839
 
5792
6840
  // src/engine/nodes/remote/imageReferenceSheet.ts
5793
- import { z as z20 } from "zod";
6841
+ import { z as z21 } from "zod";
5794
6842
  var REFERENCE_SHEET_MODELS = ["google/gemini-3-pro-image-preview", "google/gemini-3.1-flash-image-preview"];
5795
- var ImageReferenceSheetParams = z20.object({
5796
- model: z20.enum(REFERENCE_SHEET_MODELS),
5797
- subject_description: z20.string().min(1),
6843
+ var ImageReferenceSheetParams = z21.object({
6844
+ model: z21.enum(REFERENCE_SHEET_MODELS),
6845
+ subject_description: z21.string().min(1),
5798
6846
  // `location` = a set/room shown from several camera ANGLES (not a rotated subject),
5799
6847
  // so a multi-scene shoot keeps one consistent set.
5800
- subject_type: z20.enum(["character", "person", "product", "location"]),
5801
- views: z20.array(z20.string().min(1)).min(2).max(8).optional(),
5802
- style: z20.string().optional(),
5803
- prompt_override: z20.string().min(1).optional(),
5804
- 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(),
5805
- image_size: z20.enum(["0.5K", "1K", "2K", "4K"]).optional()
6848
+ subject_type: z21.enum(["character", "person", "product", "location"]),
6849
+ views: z21.array(z21.string().min(1)).min(2).max(8).optional(),
6850
+ style: z21.string().optional(),
6851
+ prompt_override: z21.string().min(1).optional(),
6852
+ aspect_ratio: z21.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
6853
+ image_size: z21.enum(["0.5K", "1K", "2K", "4K"]).optional()
5806
6854
  }).strict();
5807
6855
  var imageReferenceSheetNode = delegated({
5808
6856
  id: "image_reference_sheet",
@@ -5810,9 +6858,9 @@ var imageReferenceSheetNode = delegated({
5810
6858
  category: "image",
5811
6859
  summary: "Fuse 1\u20136 images of a single subject (person, character, product, or location/set) into ONE multi-view reference sheet \u2014 a labeled grid in consistent style and lighting: a turnaround (FRONT / SIDE / BACK\u2026) for a person/character/product, or several camera angles of the same room (WIDE / REVERSE / DETAIL\u2026) for a location. Curated models: Gemini 3 Pro Image (best fusion + labels), Gemini 3.1 Flash Image (cheap iteration).",
5812
6860
  when_to_use: "Use before image_generate / video_generate when a subject must stay consistent across many creatives \u2014 wire the `sheet` output into their `reference` input instead of re-describing the subject per prompt. `subject_description` should be the exact wording you reuse downstream. Pick `google/gemini-3-pro-image-preview` for final 6-view sheets at 2K+, `google/gemini-3.1-flash-image-preview` while iterating.",
5813
- inputs: z20.object({ references: z20.array(ImageRef).min(1).max(6) }).loose(),
6861
+ inputs: z21.object({ references: z21.array(ImageRef).min(1).max(6) }).loose(),
5814
6862
  params: ImageReferenceSheetParams,
5815
- outputs: z20.object({ sheet: ImageRef }).strict(),
6863
+ outputs: z21.object({ sheet: ImageRef }).strict(),
5816
6864
  outputKinds: { sheet: "image" },
5817
6865
  cost: ({ params }) => ({
5818
6866
  credits: params?.model === "google/gemini-3-pro-image-preview" ? 20 : 5,
@@ -5821,10 +6869,10 @@ var imageReferenceSheetNode = delegated({
5821
6869
  });
5822
6870
 
5823
6871
  // src/engine/nodes/remote/imageSearch.ts
5824
- import { z as z21 } from "zod";
5825
- var ImageSearchParams = z21.object({
5826
- prompt: z21.string().min(1),
5827
- count: z21.number().int().min(1).max(20).default(5)
6872
+ import { z as z22 } from "zod";
6873
+ var ImageSearchParams = z22.object({
6874
+ prompt: z22.string().min(1),
6875
+ count: z22.number().int().min(1).max(20).default(5)
5828
6876
  }).strict();
5829
6877
  var imageSearchNode = delegated({
5830
6878
  id: "image_search",
@@ -5832,15 +6880,15 @@ var imageSearchNode = delegated({
5832
6880
  category: "image",
5833
6881
  summary: "Agentic image search across Google Images, stock photography (Freepik), and Pinterest. An LLM agent picks the search tools and queries, selects the best matches, and the results are downloaded into canvas assets.",
5834
6882
  when_to_use: "Use to gather real-world reference or inspiration images for a prompt (e.g. several photos of an australian shepherd) so a later step or the user can pick the best one. Not for creating new imagery \u2014 use image_generate for that.",
5835
- inputs: z21.object({}).loose(),
6883
+ inputs: z22.object({}).loose(),
5836
6884
  params: ImageSearchParams,
5837
- outputs: z21.object({ images: z21.array(ImageRef).min(1) }).strict(),
6885
+ outputs: z22.object({ images: z22.array(ImageRef).min(1) }).strict(),
5838
6886
  outputKinds: { images: "image" },
5839
6887
  cost: ({ params }) => ({ credits: Math.ceil(2 + params.count / 2), seconds_estimate: 30 })
5840
6888
  });
5841
6889
 
5842
6890
  // src/engine/nodes/remote/imageSelect.ts
5843
- import { z as z22 } from "zod";
6891
+ import { z as z23 } from "zod";
5844
6892
  var IMAGE_SELECT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
5845
6893
  var imageSelectNode = delegated({
5846
6894
  id: "image_select",
@@ -5848,15 +6896,15 @@ var imageSelectNode = delegated({
5848
6896
  category: "vision",
5849
6897
  summary: "Pick the best `count` images out of 2+ candidates with a vision LLM, judged against a prompt. Outputs a passthrough subset of the input refs (no new pixels) plus the model's comparative reasoning.",
5850
6898
  when_to_use: "Use after fanning out several image_generate variants (or any pool of 2+ images) to keep only the strongest before expensive downstream steps \u2014 video generation, reference sheets, final delivery. `count` fixes the output size, so `images#0`\u2026`images#count-1` are always safe to wire. Pick `~google/gemini-flash-latest` for cheap/fast picks and `~google/gemini-pro-latest` for harder aesthetic judgement.",
5851
- inputs: z22.object({ images: z22.array(ImageRef).min(2) }).loose(),
5852
- params: z22.object({
5853
- model: z22.enum(IMAGE_SELECT_MODELS),
5854
- prompt: z22.string().min(1),
5855
- count: z22.number().int().min(1).default(1),
5856
- temperature: z22.number().min(0).max(2).optional(),
5857
- max_tokens: z22.number().int().positive().optional()
6899
+ inputs: z23.object({ images: z23.array(ImageRef).min(2) }).loose(),
6900
+ params: z23.object({
6901
+ model: z23.enum(IMAGE_SELECT_MODELS),
6902
+ prompt: z23.string().min(1),
6903
+ count: z23.number().int().min(1).default(1),
6904
+ temperature: z23.number().min(0).max(2).optional(),
6905
+ max_tokens: z23.number().int().positive().optional()
5858
6906
  }).strict(),
5859
- outputs: z22.object({ images: z22.array(ImageRef).min(1), reasoning: TextRef }).strict(),
6907
+ outputs: z23.object({ images: z23.array(ImageRef).min(1), reasoning: TextRef }).strict(),
5860
6908
  outputKinds: { images: "image", reasoning: "text" },
5861
6909
  cost: () => ({ credits: 1, seconds_estimate: 5 }),
5862
6910
  // Arity is only knowable at validate time when `images` is a literal array
@@ -5881,34 +6929,34 @@ var imageSelectNode = delegated({
5881
6929
  });
5882
6930
 
5883
6931
  // src/engine/nodes/remote/music.ts
5884
- import { z as z23 } from "zod";
6932
+ import { z as z24 } from "zod";
5885
6933
  var MUSIC_MODELS = ["elevenlabs/music-v1", "elevenlabs/video-background-music-v1"];
5886
- var MusicParams = z23.object({
5887
- model: z23.enum(MUSIC_MODELS),
6934
+ var MusicParams = z24.object({
6935
+ model: z24.enum(MUSIC_MODELS),
5888
6936
  /** Free-form prompt. Used by `elevenlabs/music-v1` (compose-detailed). */
5889
- prompt: z23.string().optional(),
6937
+ prompt: z24.string().optional(),
5890
6938
  /**
5891
6939
  * Structured composition plan (intro / hook / verse / outro sections with
5892
6940
  * per-section styles + durations). Mutually exclusive with `prompt`.
5893
6941
  */
5894
- composition_plan: z23.record(z23.string(), z23.unknown()).optional(),
6942
+ composition_plan: z24.record(z24.string(), z24.unknown()).optional(),
5895
6943
  /** Target length when using `prompt`. 3000–454545ms (capped by the $10 per-node cost limit). */
5896
- music_length_ms: z23.number().int().min(3e3).max(ELEVENLABS_MAX_MUSIC_LENGTH_MS).optional(),
5897
- seed: z23.number().int().optional(),
6944
+ music_length_ms: z24.number().int().min(3e3).max(ELEVENLABS_MAX_MUSIC_LENGTH_MS).optional(),
6945
+ seed: z24.number().int().optional(),
5898
6946
  /** Prompt mode only — forces an instrumental (no vocals) track. */
5899
- force_instrumental: z23.boolean().optional(),
6947
+ force_instrumental: z24.boolean().optional(),
5900
6948
  /** composition_plan only — honor exact section durations. */
5901
- respect_sections_durations: z23.boolean().optional(),
6949
+ respect_sections_durations: z24.boolean().optional(),
5902
6950
  /** Emit word-level timestamps alongside the audio. */
5903
- with_timestamps: z23.boolean().optional(),
6951
+ with_timestamps: z24.boolean().optional(),
5904
6952
  /**
5905
6953
  * video-to-music only — short description of the desired score
5906
6954
  * ("upbeat synth, fast cuts, 80s") used to bias the model.
5907
6955
  */
5908
- description: z23.string().max(1e3).optional(),
6956
+ description: z24.string().max(1e3).optional(),
5909
6957
  /** video-to-music only — up to 10 style tags. */
5910
- tags: z23.array(z23.string()).max(10).optional(),
5911
- output_format: z23.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
6958
+ tags: z24.array(z24.string()).max(10).optional(),
6959
+ output_format: z24.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
5912
6960
  }).strict();
5913
6961
  var musicNode = delegated({
5914
6962
  id: "music",
@@ -5916,9 +6964,9 @@ var musicNode = delegated({
5916
6964
  category: "audio",
5917
6965
  summary: "Generate music for ad creatives and website video content. `elevenlabs/music-v1` composes from a text prompt or structured composition plan; `elevenlabs/video-background-music-v1` scores an existing video clip provided via `inputs.video`.",
5918
6966
  when_to_use: "Use to produce background music or a full score for video ads, hero-section reels, or any motion content. Prefer the video-to-music model when you already have a cut and want music timed to it; use compose-detailed when you have only a brief or want section-level control (intro / hook / outro). Pair the resulting audio with `video_generate` or `video_lipsync` at compose time.",
5919
- inputs: z23.object({ video: VideoRef.optional() }).loose(),
6967
+ inputs: z24.object({ video: VideoRef.optional() }).loose(),
5920
6968
  params: MusicParams,
5921
- outputs: z23.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6969
+ outputs: z24.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
5922
6970
  outputKinds: { audio: "audio", timestamps: "json" },
5923
6971
  cost: ({ params }) => {
5924
6972
  const seconds = params.music_length_ms ? Math.ceil(params.music_length_ms / 1e3) : 30;
@@ -5949,25 +6997,25 @@ var musicNode = delegated({
5949
6997
  });
5950
6998
 
5951
6999
  // src/engine/nodes/remote/soundEffect.ts
5952
- import { z as z24 } from "zod";
7000
+ import { z as z25 } from "zod";
5953
7001
  var SOUND_EFFECT_MODELS = ["elevenlabs/eleven_text_to_sound_v2"];
5954
- var SoundEffectParams = z24.object({
5955
- model: z24.enum(SOUND_EFFECT_MODELS),
7002
+ var SoundEffectParams = z25.object({
7003
+ model: z25.enum(SOUND_EFFECT_MODELS),
5956
7004
  /** Prompt describing the SFX ("metal door slam", "soft UI tap", "ocean waves"). */
5957
- text: z24.string().min(1),
7005
+ text: z25.string().min(1),
5958
7006
  /**
5959
7007
  * Target length in seconds. 0.5–30. Leave unset to let the model pick the
5960
7008
  * natural length for the described effect.
5961
7009
  */
5962
- duration_seconds: z24.number().min(0.5).max(30).optional(),
7010
+ duration_seconds: z25.number().min(0.5).max(30).optional(),
5963
7011
  /**
5964
7012
  * 0–1. Higher = stick closer to the prompt at the cost of variety; lower
5965
7013
  * = let the model interpret more freely. Defaults to 0.3 on the provider.
5966
7014
  */
5967
- prompt_influence: z24.number().min(0).max(1).optional(),
7015
+ prompt_influence: z25.number().min(0).max(1).optional(),
5968
7016
  /** Only valid on `eleven_text_to_sound_v2` — produce a seamless loop. */
5969
- loop: z24.boolean().optional(),
5970
- output_format: z24.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
7017
+ loop: z25.boolean().optional(),
7018
+ output_format: z25.enum(ELEVENLABS_OUTPUT_FORMATS).optional()
5971
7019
  }).strict();
5972
7020
  var soundEffectNode = delegated({
5973
7021
  id: "sound_effect",
@@ -5975,9 +7023,9 @@ var soundEffectNode = delegated({
5975
7023
  category: "audio",
5976
7024
  summary: "Generate short sound effects from a text prompt via ElevenLabs Text-to-Sound. Use for whooshes, impacts, UI clicks, ambient beds, or signature stingers in ad creatives and product videos.",
5977
7025
  when_to_use: "Reach for this when you need a punch-in SFX layered against `video_generate` or `hyperframe_render` output \u2014 e.g. a logo whoosh on a hero shot, a click on a CTA cut, a swelling ambient bed under VO. Set `loop: true` for atmospheric beds that need to tile under longer footage; leave `duration_seconds` unset and the model picks a natural length.",
5978
- inputs: z24.object({}).loose(),
7026
+ inputs: z25.object({}).loose(),
5979
7027
  params: SoundEffectParams,
5980
- outputs: z24.object({ audio: AudioRef }).strict(),
7028
+ outputs: z25.object({ audio: AudioRef }).strict(),
5981
7029
  outputKinds: { audio: "audio" },
5982
7030
  cost: ({ params }) => {
5983
7031
  const seconds = params.duration_seconds ?? 5;
@@ -5986,7 +7034,7 @@ var soundEffectNode = delegated({
5986
7034
  });
5987
7035
 
5988
7036
  // src/engine/nodes/remote/textGenerate.ts
5989
- import { z as z25 } from "zod";
7037
+ import { z as z26 } from "zod";
5990
7038
  var TEXT_GENERATE_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
5991
7039
  var textGenerateNode = delegated({
5992
7040
  id: "text_generate",
@@ -5994,58 +7042,58 @@ var textGenerateNode = delegated({
5994
7042
  category: "language",
5995
7043
  summary: "Single-turn LLM text generation via OpenRouter. Returns a text response.",
5996
7044
  when_to_use: 'Use for any short text generation step in a canvas \u2014 ad copy, hooks, headlines, JSON outputs for downstream nodes. Pick `~google/gemini-flash-latest` for cheap/fast work and `~google/gemini-pro-latest` for harder reasoning. When the output must be JSON for a downstream `{{slot}}` (e.g. the ad-blueprint transform), set `response_format: "json_object"` so the model returns clean JSON with no markdown fences or prose. Set `web_search: true` to let the model search the live web first (OpenRouter `:online`) \u2014 useful when the transform must adapt copy to the target brand\'s real facts (current pricing, the trust signals it actually has) rather than guess.',
5997
- inputs: z25.object({}).loose(),
5998
- params: z25.object({
5999
- model: z25.enum(TEXT_GENERATE_MODELS),
6000
- prompt: z25.string().min(1),
6001
- system: z25.string().optional(),
6002
- response_format: z25.enum(["text", "json_object"]).optional(),
6003
- web_search: z25.boolean().optional(),
6004
- temperature: z25.number().min(0).max(2).optional(),
6005
- max_tokens: z25.number().int().positive().optional()
7045
+ inputs: z26.object({}).loose(),
7046
+ params: z26.object({
7047
+ model: z26.enum(TEXT_GENERATE_MODELS),
7048
+ prompt: z26.string().min(1),
7049
+ system: z26.string().optional(),
7050
+ response_format: z26.enum(["text", "json_object"]).optional(),
7051
+ web_search: z26.boolean().optional(),
7052
+ temperature: z26.number().min(0).max(2).optional(),
7053
+ max_tokens: z26.number().int().positive().optional()
6006
7054
  }).strict(),
6007
- outputs: z25.object({ text: TextRef }).strict(),
7055
+ outputs: z26.object({ text: TextRef }).strict(),
6008
7056
  outputKinds: { text: "text" },
6009
7057
  cost: () => ({ credits: 1, seconds_estimate: 3 })
6010
7058
  });
6011
7059
 
6012
7060
  // src/engine/nodes/remote/tts.ts
6013
- import { z as z26 } from "zod";
7061
+ import { z as z27 } from "zod";
6014
7062
  var TTS_MODELS = ["elevenlabs/eleven_v3"];
6015
- var TtsVoiceSettings = z26.object({
6016
- stability: z26.number().min(0).max(1).optional(),
6017
- similarity_boost: z26.number().min(0).max(1).optional(),
6018
- style: z26.number().min(0).max(1).optional(),
6019
- use_speaker_boost: z26.boolean().optional(),
6020
- speed: z26.number().min(0.25).max(4).optional()
7063
+ var TtsVoiceSettings = z27.object({
7064
+ stability: z27.number().min(0).max(1).optional(),
7065
+ similarity_boost: z27.number().min(0).max(1).optional(),
7066
+ style: z27.number().min(0).max(1).optional(),
7067
+ use_speaker_boost: z27.boolean().optional(),
7068
+ speed: z27.number().min(0.25).max(4).optional()
6021
7069
  }).strict();
6022
- var TtsPronunciationLocator = z26.object({
6023
- pronunciation_dictionary_id: z26.string().min(1),
6024
- version_id: z26.string().nullable().optional()
7070
+ var TtsPronunciationLocator = z27.object({
7071
+ pronunciation_dictionary_id: z27.string().min(1),
7072
+ version_id: z27.string().nullable().optional()
6025
7073
  }).strict();
6026
- var TtsParams = z26.object({
6027
- model: z26.enum(TTS_MODELS),
6028
- text: z26.string().min(1).max(ELEVENLABS_MAX_TEXT_CHARS),
6029
- voice: z26.string().min(1),
7074
+ var TtsParams = z27.object({
7075
+ model: z27.enum(TTS_MODELS),
7076
+ text: z27.string().min(1).max(ELEVENLABS_MAX_TEXT_CHARS),
7077
+ voice: z27.string().min(1),
6030
7078
  /** Provider output_format (mp3 family only — assets are stored as audio/mpeg). */
6031
- output_format: z26.enum(ELEVENLABS_OUTPUT_FORMATS).optional(),
6032
- seed: z26.number().int().min(0).max(4294967295).optional(),
7079
+ output_format: z27.enum(ELEVENLABS_OUTPUT_FORMATS).optional(),
7080
+ seed: z27.number().int().min(0).max(4294967295).optional(),
6033
7081
  // Top-level shortcuts; structured form is `voice_settings`.
6034
- stability: z26.number().min(0).max(1).optional(),
6035
- similarity_boost: z26.number().min(0).max(1).optional(),
7082
+ stability: z27.number().min(0).max(1).optional(),
7083
+ similarity_boost: z27.number().min(0).max(1).optional(),
6036
7084
  voice_settings: TtsVoiceSettings.optional(),
6037
7085
  /** ISO 639-1 language code. eleven_v3 supports language hints. */
6038
- language_code: z26.string().optional(),
6039
- pronunciation_dictionary_locators: z26.array(TtsPronunciationLocator).max(3).optional(),
6040
- apply_text_normalization: z26.enum(["auto", "on", "off"]).optional(),
7086
+ language_code: z27.string().optional(),
7087
+ pronunciation_dictionary_locators: z27.array(TtsPronunciationLocator).max(3).optional(),
7088
+ apply_text_normalization: z27.enum(["auto", "on", "off"]).optional(),
6041
7089
  /** Currently Japanese-only. Adds latency. */
6042
- apply_language_text_normalization: z26.boolean().optional(),
7090
+ apply_language_text_normalization: z27.boolean().optional(),
6043
7091
  /**
6044
7092
  * When true, hits `/v1/text-to-speech/{voice_id}/with-timestamps` and
6045
7093
  * adds a `timestamps` output (character-level alignment) for caption
6046
7094
  * rendering, lipsync, and beat-matched cuts.
6047
7095
  */
6048
- with_timestamps: z26.boolean().optional()
7096
+ with_timestamps: z27.boolean().optional()
6049
7097
  }).strict();
6050
7098
  var ttsNode = delegated({
6051
7099
  id: "tts",
@@ -6053,9 +7101,9 @@ var ttsNode = delegated({
6053
7101
  category: "audio",
6054
7102
  summary: "Single-voice text-to-speech via ElevenLabs Eleven v3. Optional character-level timestamps for caption rendering and beat-matched cuts.",
6055
7103
  when_to_use: "Use for single-speaker VO \u2014 ad reads, hero-section narration, product walkthroughs. Reach for `dialogue` when you need multiple voices in one stitched track. Set `with_timestamps: true` when downstream needs character-level alignment (captions, lipsync).",
6056
- inputs: z26.object({}).loose(),
7104
+ inputs: z27.object({}).loose(),
6057
7105
  params: TtsParams,
6058
- outputs: z26.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
7106
+ outputs: z27.object({ audio: AudioRef, timestamps: JsonRef.optional() }).strict(),
6059
7107
  outputKinds: { audio: "audio", timestamps: "json" },
6060
7108
  cost: ({ params }) => ({
6061
7109
  credits: Math.max(1, Math.ceil(params.text.length * 15e-4)),
@@ -6064,47 +7112,49 @@ var ttsNode = delegated({
6064
7112
  });
6065
7113
 
6066
7114
  // src/engine/nodes/remote/video.ts
6067
- import { z as z27 } from "zod";
6068
- var VIDEO_GENERATE_MODELS = ["bytedance/seedance-2.0", "google/veo-3.1-fast"];
6069
- var VideoGenerateParams = z27.object({
6070
- model: z27.enum(VIDEO_GENERATE_MODELS),
6071
- prompt: z27.string().min(1),
6072
- duration: z27.number().int().positive().optional(),
6073
- resolution: z27.string().optional(),
7115
+ import { z as z28 } from "zod";
7116
+ var videoModelEnum = z28.enum(VIDEO_GENERATE_MODELS);
7117
+ var VideoGenerateParams = z28.object({
7118
+ model: videoModelEnum,
7119
+ prompt: z28.string().min(1),
7120
+ duration: z28.number().int().positive().optional(),
7121
+ resolution: z28.string().optional(),
6074
7122
  // Union of ratios accepted by at least one curated model (registry gates
6075
7123
  // per-model). 3:2/2:3 are deliberately absent: no registered model takes them.
6076
- aspect_ratio: z27.enum(["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"]).optional(),
6077
- generate_audio: z27.boolean().optional(),
6078
- seed: z27.number().int().nonnegative().optional(),
7124
+ aspect_ratio: z28.enum(["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"]).optional(),
7125
+ generate_audio: z28.boolean().optional(),
7126
+ seed: z28.number().int().nonnegative().optional(),
6079
7127
  // Veo-only passthroughs (routed via `provider.options.google-vertex.parameters`).
6080
- negative_prompt: z27.string().optional(),
6081
- person_generation: z27.string().optional(),
6082
- enhance_prompt: z27.boolean().optional(),
6083
- conditioning_scale: z27.number().optional()
7128
+ negative_prompt: z28.string().optional(),
7129
+ person_generation: z28.string().optional(),
7130
+ enhance_prompt: z28.boolean().optional(),
7131
+ conditioning_scale: z28.number().optional(),
7132
+ // Kling-only passthrough (prompt-adherence dial, sent top-level).
7133
+ cfg_scale: z28.number().optional()
6084
7134
  }).strict();
6085
7135
  var videoGenerateNode = delegated({
6086
7136
  id: "video_generate",
6087
7137
  version: "2.0.0",
6088
7138
  category: "video",
6089
- 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.",
6090
- 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.",
6091
- inputs: z27.object({
7139
+ summary: "Generate video for ad creatives. Curated roster: `bytedance/seedance-2.0` (identity/product workhorse), `google/veo-3.1` (photoreal cine ceiling + real-face fallback), `google/veo-3.1-fast` (cheap Veo iteration), `kwaivgi/kling-3.0` (motion-transfer/dynamic). Async with polling.",
7140
+ when_to_use: "Use `bytedance/seedance-2.0` for identity/product output. Route real human likenesses to `google/veo-3.1` (dodges the ByteDance real-person filter); use `google/veo-3.1-fast` while iterating to keep cost low; `kwaivgi/kling-3.0` for motion-transfer/hyper-dynamic beats. The scaffolder's scored router picks for you. Each model gates its own durations/resolutions/aspect ratios in the registry \u2014 see the README per-model section.",
7141
+ inputs: z28.object({
6092
7142
  first_frame: ImageRef.optional(),
6093
7143
  last_frame: ImageRef.optional(),
6094
7144
  reference: ImageRef.optional()
6095
7145
  }).loose(),
6096
7146
  params: VideoGenerateParams,
6097
- outputs: z27.object({ video: VideoRef }).strict(),
7147
+ outputs: z28.object({ video: VideoRef }).strict(),
6098
7148
  outputKinds: { video: "video" },
6099
7149
  cost: () => ({ credits: 50, seconds_estimate: 120 })
6100
7150
  });
6101
7151
 
6102
7152
  // src/engine/nodes/remote/videoBackgroundRemove.ts
6103
- import { z as z28 } from "zod";
6104
- var VideoBackgroundRemoveParams = z28.object({
6105
- model: z28.literal("fal/veed-video-background-removal").optional().default("fal/veed-video-background-removal"),
6106
- edge_refinement: z28.boolean().optional().default(true),
6107
- output_codec: z28.enum(["vp9", "h264"]).optional().default("vp9")
7153
+ import { z as z29 } from "zod";
7154
+ var VideoBackgroundRemoveParams = z29.object({
7155
+ model: z29.literal("fal/veed-video-background-removal").optional().default("fal/veed-video-background-removal"),
7156
+ edge_refinement: z29.boolean().optional().default(true),
7157
+ output_codec: z29.enum(["vp9", "h264"]).optional().default("vp9")
6108
7158
  }).strict();
6109
7159
  var videoBackgroundRemoveNode = delegated({
6110
7160
  id: "video_background_remove",
@@ -6112,18 +7162,18 @@ var videoBackgroundRemoveNode = delegated({
6112
7162
  category: "video",
6113
7163
  summary: "Remove the background from a video and return a transparent VP9-with-alpha WebM (or H264 RGB+alpha pair). Drops directly into a hyperframe composition as `<video src='...'>` for chroma-keyed picture-in-picture overlays. Powered by fal.ai `veed/video-background-removal/fast`.",
6114
7164
  when_to_use: "Use when you need a talking-head or subject to float over a custom background in a hyperframe composition. Pair with hyperframe_render(composition: screencast-with-talker) for screencast-with-narrator videos. Output is `video/webm` with alpha \u2014 feed straight into `<video src>` in a composition.",
6115
- inputs: z28.object({
7165
+ inputs: z29.object({
6116
7166
  video: VideoRef
6117
7167
  }).strict(),
6118
7168
  params: VideoBackgroundRemoveParams,
6119
- outputs: z28.object({ video: VideoRef }).strict(),
7169
+ outputs: z29.object({ video: VideoRef }).strict(),
6120
7170
  outputKinds: { video: "video" },
6121
7171
  // $0.012 per 30 frames (edge refinement on) — assume ~30fps; refine via fal dashboard.
6122
7172
  cost: () => ({ credits: 50, seconds_estimate: 60 })
6123
7173
  });
6124
7174
 
6125
7175
  // src/engine/nodes/remote/videoDeconstruct.ts
6126
- import { z as z29 } from "zod";
7176
+ import { z as z30 } from "zod";
6127
7177
  var VIDEO_DECONSTRUCT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
6128
7178
  var videoDeconstructNode = delegated({
6129
7179
  id: "video_deconstruct",
@@ -6131,34 +7181,34 @@ var videoDeconstructNode = delegated({
6131
7181
  category: "video",
6132
7182
  summary: 'Deconstruct a video into a replication-grade blueprint: scene boundaries, the real start/end frame of every scene (extracted from the video as images), and an exhaustive JSON analysis \u2014 per-scene action detail, camera motion, generation-ready frame/motion prompts, overlay text with full typographic style, floating elements, deeply detailed cast (perceived demographics, ethnicity/skin-tone, styling, market-recasting notes), brand-identified logos (named by brand and what they signal, not by appearance, with on-screen timestamps), dialogue with voice descriptions, music spec, SFX list, plus a word-level transcript. `mode:"index"` is the cheap structure-first pass: scene boundaries + global blueprint only (one LLM call, no frames).',
6133
7183
  when_to_use: 'Use to reverse-engineer a reference video (e.g. a competitor ad) so a new canvas can reproduce or remix it scene by scene. Agent loop: (1) optionally run `mode:"index"` to see the structure cheaply (scene count, boundaries, transcript) before planning; (2) run the full deconstruct; (3) read `analysis` and author the reproduction canvas. The blueprint maps 1:1 onto generation nodes: `analysis.scenes[i]` aligns positionally with `start_frames#i`/`end_frames#i`; per scene, `start_frame_prompt`/`end_frame_prompt` feed image_generate (overlay text is excluded from them by contract \u2014 recomposite it from `overlays`), `motion_prompt` + the two frames feed video_generate (first_frame/last_frame), `dialogue[].voice_description` casts tts/dialogue voices, `global.music.music_prompt` feeds music, `sfx[].sound_effect_prompt` feeds sound_effect, and `overlays`/`floating_elements` drive an ffmpeg/hyperframe overlay pass. Long videos (over ~8 min single-shot): run `mode:"index"` first, then several full nodes IN PARALLEL each with a `start_s`/`end_s` window (\u2264480s, snap edges to index scene boundaries), and merge by concatenating `analysis.scenes`; over-length errors include suggested windows. Inject fields into downstream prompts via `{{slot}}`. Pick `~google/gemini-pro-latest` for the densest extraction, `~google/gemini-flash-latest` for cheap/fast passes.',
6134
- inputs: z29.object({ video: VideoRef }).loose(),
6135
- params: z29.object({
6136
- model: z29.enum(VIDEO_DECONSTRUCT_MODELS),
6137
- mode: z29.enum(["full", "index"]).optional(),
6138
- language: z29.string().min(2).max(8).optional(),
6139
- max_scenes: z29.number().int().min(1).max(60).optional(),
6140
- focus: z29.string().optional(),
6141
- start_s: z29.number().min(0).optional(),
6142
- end_s: z29.number().positive().optional(),
7184
+ inputs: z30.object({ video: VideoRef }).loose(),
7185
+ params: z30.object({
7186
+ model: z30.enum(VIDEO_DECONSTRUCT_MODELS),
7187
+ mode: z30.enum(["full", "index"]).optional(),
7188
+ language: z30.string().min(2).max(8).optional(),
7189
+ max_scenes: z30.number().int().min(1).max(60).optional(),
7190
+ focus: z30.string().optional(),
7191
+ start_s: z30.number().min(0).optional(),
7192
+ end_s: z30.number().positive().optional(),
6143
7193
  // Real visual shot-cut timestamps (absolute seconds), detected locally with
6144
7194
  // ffmpeg before the deconstruct. The backend SNAPS its LLM scene boundaries
6145
7195
  // onto these and SPLITS any scene that spans one, so a scene's frames never
6146
7196
  // straddle a hard cut. `scaffold-video` populates this; omit for LLM-only cuts.
6147
- shot_cuts: z29.array(z29.number().min(0)).max(200).optional(),
7197
+ shot_cuts: z30.array(z30.number().min(0)).max(200).optional(),
6148
7198
  // The video model's per-clip ceiling (seconds). A shot longer than this is
6149
7199
  // split into seamless continuation sub-scenes (shared splice frame), so long
6150
7200
  // shots reproduce in full instead of being truncated. `scaffold-video` sets
6151
7201
  // the Seedance ceiling (15); omit to disable length splitting.
6152
- max_clip_s: z29.number().positive().max(60).optional(),
7202
+ max_clip_s: z30.number().positive().max(60).optional(),
6153
7203
  // Transcript provider for the blueprint's dialogue/transcript. Default
6154
7204
  // Groq Whisper; "deepgram" routes to Nova-3 so words carry punctuation.
6155
- transcriber: z29.enum(["groq", "deepgram"]).optional()
7205
+ transcriber: z30.enum(["groq", "deepgram"]).optional()
6156
7206
  }).strict(),
6157
- outputs: z29.object({
7207
+ outputs: z30.object({
6158
7208
  analysis: JsonRef,
6159
7209
  // Absent in mode:"index" (structure only, no Mux frame extraction).
6160
- start_frames: z29.array(ImageRef).min(1).optional(),
6161
- end_frames: z29.array(ImageRef).min(1).optional(),
7210
+ start_frames: z30.array(ImageRef).min(1).optional(),
7211
+ end_frames: z30.array(ImageRef).min(1).optional(),
6162
7212
  transcript: JsonRef
6163
7213
  }).strict(),
6164
7214
  outputKinds: { analysis: "json", start_frames: "image", end_frames: "image", transcript: "json" },
@@ -6166,31 +7216,31 @@ var videoDeconstructNode = delegated({
6166
7216
  });
6167
7217
 
6168
7218
  // src/engine/nodes/remote/videoLipsync.ts
6169
- import { z as z30 } from "zod";
6170
- var FalLipsyncParams = z30.object({
6171
- model: z30.literal("fal/veed-lipsync")
7219
+ import { z as z31 } from "zod";
7220
+ var FalLipsyncParams = z31.object({
7221
+ model: z31.literal("fal/veed-lipsync")
6172
7222
  }).strict();
6173
- var VideoLipsyncParams = z30.discriminatedUnion("model", [FalLipsyncParams]);
7223
+ var VideoLipsyncParams = z31.discriminatedUnion("model", [FalLipsyncParams]);
6174
7224
  var videoLipsyncNode = delegated({
6175
7225
  id: "video_lipsync",
6176
7226
  version: "1.0.0",
6177
7227
  category: "video",
6178
7228
  summary: "Lip-sync a video to an audio track. Currently backed by VEED via fal.ai (`fal/veed-lipsync`). $0.40/min of output.",
6179
- inputs: z30.object({
7229
+ inputs: z31.object({
6180
7230
  video: VideoRef,
6181
7231
  audio: AudioRef
6182
7232
  }).strict(),
6183
7233
  params: VideoLipsyncParams,
6184
- outputs: z30.object({ video: VideoRef }).strict(),
7234
+ outputs: z31.object({ video: VideoRef }).strict(),
6185
7235
  outputKinds: { video: "video" },
6186
7236
  cost: () => ({ credits: 20, seconds_estimate: 120 })
6187
7237
  });
6188
7238
 
6189
7239
  // src/engine/nodes/remote/videoTranscribe.ts
6190
- import { mkdtemp as mkdtemp6, readFile as readFile10, rm as rm6 } from "fs/promises";
7240
+ import { mkdtemp as mkdtemp6, readFile as readFile11, rm as rm6 } from "fs/promises";
6191
7241
  import { tmpdir as tmpdir6 } from "os";
6192
- import path13 from "path";
6193
- import { z as z31 } from "zod";
7242
+ import path14 from "path";
7243
+ import { z as z32 } from "zod";
6194
7244
 
6195
7245
  // src/engine/nodes/local/lib/ffmpeg.ts
6196
7246
  import { execFile as execFile7 } from "child_process";
@@ -6269,29 +7319,32 @@ ${detail.slice(-4e3)}`);
6269
7319
  }
6270
7320
 
6271
7321
  // src/engine/nodes/remote/videoTranscribe.ts
6272
- var VideoTranscribeParams = z31.object({
6273
- language: z31.string().min(2).max(8).optional(),
7322
+ var VideoTranscribeParams = z32.object({
7323
+ language: z32.string().min(2).max(8).optional(),
6274
7324
  // Provider choice is explicit (no env-based silent branching). Default Groq
6275
7325
  // Whisper; "deepgram" routes to Deepgram Nova-3, which additionally emits a
6276
7326
  // `rich` JSON output with punctuated words + paragraph/sentence grouping.
6277
- transcriber: z31.enum(["groq", "deepgram"]).optional()
7327
+ transcriber: z32.enum(["groq", "deepgram"]).optional()
6278
7328
  }).strict();
6279
- var VideoTranscribeInputs = z31.object({
6280
- video: VideoRef
7329
+ var VideoTranscribeInputs = z32.object({
7330
+ // A video (audio auto-extracted locally) OR a bare audio track. The key stays
7331
+ // `video` for back-compat; the backend already accepts audio-kind refs on it —
7332
+ // the local extraction path has been shipping one for every video input.
7333
+ video: z32.union([VideoRef, AudioRef])
6281
7334
  }).strict();
6282
- var VideoTranscribeOutputs = z31.object({
6283
- transcript: z31.custom(),
7335
+ var VideoTranscribeOutputs = z32.object({
7336
+ transcript: z32.custom(),
6284
7337
  // Only emitted by the Deepgram path: full punctuated words + paragraph /
6285
7338
  // sentence grouping with speaker indices. Absent for the default Groq path.
6286
- rich: z31.custom().optional()
7339
+ rich: z32.custom().optional()
6287
7340
  }).strict();
6288
7341
  var AUDIO_EXTRACT_TIMEOUT_MS = 6e4;
6289
7342
  var videoTranscribeNode = defineNode({
6290
7343
  id: "video_transcribe",
6291
- version: "2.2.0",
7344
+ version: "2.3.0",
6292
7345
  category: "language",
6293
7346
  location: "local",
6294
- summary: 'Transcribe a video\'s audio to a word-level JSON transcript. Default `transcriber:"groq"` uses Groq Whisper Large v3 Turbo ($0.04/hr, 10s min); `transcriber:"deepgram"` uses Deepgram Nova-3 ($0.0043/min) and additionally emits a `rich` JSON output with punctuated words + paragraph/sentence grouping (and speaker indices). Automatically extracts audio locally (mono 16 kHz MP3) before uploading \u2014 reduces payload ~100\xD7 and lifts the effective duration limit well beyond Groq\'s 100 MB file cap. The `transcript` output is always an array of {text, start, end} entries ready to feed Hyperframes caption compositions (Deepgram prefers the punctuated word form).',
7347
+ summary: 'Transcribe a video or audio track to a word-level JSON transcript. Default `transcriber:"groq"` uses Groq Whisper Large v3 Turbo ($0.04/hr, 10s min); `transcriber:"deepgram"` uses Deepgram Nova-3 ($0.0043/min) and additionally emits a `rich` JSON output with punctuated words + paragraph/sentence grouping (and speaker indices). Automatically extracts audio locally (mono 16 kHz MP3) before uploading \u2014 reduces payload ~100\xD7 and lifts the effective duration limit well beyond Groq\'s 100 MB file cap. The `transcript` output is always an array of {text, start, end} entries ready to feed Hyperframes caption compositions (Deepgram prefers the punctuated word form).',
6295
7348
  when_to_use: 'Use to generate burned-in captions for a stitched video. Pair with `hyperframe_render` and a captions composition (e.g. `tiktok-captions`) by passing the transcript JSON through `params.variables.transcript`. Pick `params.language` to filter out non-target speech (e.g. "es" for Spanish). Pick `transcriber:"deepgram"` when you want punctuation/paragraphs (read them from the `rich` output) or speaker grouping. Requires ffmpeg on PATH for audio extraction (falls back to full video upload if unavailable).',
6296
7349
  inputs: VideoTranscribeInputs,
6297
7350
  params: VideoTranscribeParams,
@@ -6303,7 +7356,7 @@ var videoTranscribeNode = defineNode({
6303
7356
  const effectiveInputs = audioInput ?? inputs;
6304
7357
  return await callBackendExec({
6305
7358
  nodeType: "video_transcribe",
6306
- nodeVersion: "2.2.0",
7359
+ nodeVersion: "2.3.0",
6307
7360
  params,
6308
7361
  inputs: effectiveInputs,
6309
7362
  outputKinds: { transcript: "json", rich: "json" },
@@ -6321,14 +7374,14 @@ async function tryExtractAudio(inputs, ctx) {
6321
7374
  ctx.log("video_transcribe: no audio track detected, sending full video");
6322
7375
  return null;
6323
7376
  }
6324
- tmpDir = await mkdtemp6(path13.join(tmpdir6(), "vtx-"));
6325
- const audioPath = path13.join(tmpDir, "audio.mp3");
7377
+ tmpDir = await mkdtemp6(path14.join(tmpdir6(), "vtx-"));
7378
+ const audioPath = path14.join(tmpDir, "audio.mp3");
6326
7379
  ctx.log("video_transcribe: extracting audio (mono 16kHz mp3)");
6327
7380
  await runFfmpeg(
6328
7381
  ["-i", video.path, "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", "-f", "mp3", "-y", audioPath],
6329
7382
  { timeout_ms: AUDIO_EXTRACT_TIMEOUT_MS }
6330
7383
  );
6331
- const bytes = await readFile10(audioPath);
7384
+ const bytes = await readFile11(audioPath);
6332
7385
  if (bytes.byteLength === 0) {
6333
7386
  ctx.log("video_transcribe: extracted audio is empty, sending full video");
6334
7387
  return null;
@@ -6368,29 +7421,29 @@ async function tryExtractAudio(inputs, ctx) {
6368
7421
  }
6369
7422
 
6370
7423
  // src/engine/nodes/remote/voiceSelect.ts
6371
- import { z as z32 } from "zod";
7424
+ import { z as z33 } from "zod";
6372
7425
  var voiceSelectNode = delegated({
6373
7426
  id: "voice_select",
6374
7427
  version: "1.0.0",
6375
7428
  category: "audio",
6376
7429
  summary: 'Cast an ElevenLabs voice from a natural-language description (e.g. "warm, authoritative female narrator, American accent"). Lists the account\'s voices and ranks them against the brief, emitting the best `voice_id` as a bare-string text asset plus a ranked `candidates` JSON.',
6377
7430
  when_to_use: 'Use to turn a voice description (e.g. from a `video_deconstruct` blueprint\'s `voice_description`) into a usable ElevenLabs voice id, then feed it into a `tts` node by wiring `inputs.voice_ref: $ref:<this>.voice_id` and setting `params.voice: "{{voice_ref}}"` \u2014 the engine splices the id in at run time. Review `candidates` (json) to pick a different voice. Optional `gender`/`age`/`accent`/`language` hints sharpen the ranking.',
6378
- inputs: z32.object({}).loose(),
6379
- params: z32.object({
6380
- description: z32.string().min(1),
6381
- gender: z32.string().optional(),
6382
- age: z32.string().optional(),
6383
- accent: z32.string().optional(),
6384
- language: z32.string().optional(),
6385
- limit: z32.number().int().min(1).max(20).optional()
7431
+ inputs: z33.object({}).loose(),
7432
+ params: z33.object({
7433
+ description: z33.string().min(1),
7434
+ gender: z33.string().optional(),
7435
+ age: z33.string().optional(),
7436
+ accent: z33.string().optional(),
7437
+ language: z33.string().optional(),
7438
+ limit: z33.number().int().min(1).max(20).optional()
6386
7439
  }).strict(),
6387
- outputs: z32.object({ voice_id: TextRef, candidates: JsonRef }).strict(),
7440
+ outputs: z33.object({ voice_id: TextRef, candidates: JsonRef }).strict(),
6388
7441
  outputKinds: { voice_id: "text", candidates: "json" },
6389
7442
  cost: () => ({ credits: 0, seconds_estimate: 5 })
6390
7443
  });
6391
7444
 
6392
7445
  // src/engine/schema/catalog.ts
6393
- import { z as z33 } from "zod";
7446
+ import { z as z34 } from "zod";
6394
7447
  function generateCatalog(registry, opts = {}) {
6395
7448
  const entries = registry.all().map((def) => {
6396
7449
  const cost = def.cost ? safeCost(def) : void 0;
@@ -6401,9 +7454,9 @@ function generateCatalog(registry, opts = {}) {
6401
7454
  summary: def.summary,
6402
7455
  when_to_use: def.when_to_use,
6403
7456
  location: def.location,
6404
- inputs: z33.toJSONSchema(def.inputs, { unrepresentable: "any" }),
6405
- params: z33.toJSONSchema(def.params, { unrepresentable: "any" }),
6406
- outputs: z33.toJSONSchema(def.outputs, { unrepresentable: "any" }),
7457
+ inputs: z34.toJSONSchema(def.inputs, { unrepresentable: "any" }),
7458
+ params: z34.toJSONSchema(def.params, { unrepresentable: "any" }),
7459
+ outputs: z34.toJSONSchema(def.outputs, { unrepresentable: "any" }),
6407
7460
  cost_estimate_credits: cost?.credits,
6408
7461
  runtime_estimate_seconds: cost?.seconds_estimate
6409
7462
  };
@@ -6435,19 +7488,19 @@ function safeCost(def) {
6435
7488
 
6436
7489
  // src/engine/storage/cache-store.ts
6437
7490
  import { randomUUID as randomUUID2 } from "crypto";
6438
- import { mkdir as mkdir3, readFile as readFile11, rename as rename2, writeFile as writeFile7 } from "fs/promises";
6439
- import path14 from "path";
7491
+ import { mkdir as mkdir3, readFile as readFile12, rename as rename2, writeFile as writeFile7 } from "fs/promises";
7492
+ import path15 from "path";
6440
7493
  var LocalCacheStore = class {
6441
7494
  rootDir;
6442
7495
  constructor(rootDir) {
6443
7496
  this.rootDir = rootDir;
6444
7497
  }
6445
7498
  filePath(cacheKey) {
6446
- return path14.join(this.rootDir, `${cacheKey}.json`);
7499
+ return path15.join(this.rootDir, `${cacheKey}.json`);
6447
7500
  }
6448
7501
  async get(cacheKey) {
6449
7502
  try {
6450
- const buf = await readFile11(this.filePath(cacheKey), "utf8");
7503
+ const buf = await readFile12(this.filePath(cacheKey), "utf8");
6451
7504
  return JSON.parse(buf);
6452
7505
  } catch (e) {
6453
7506
  if (e.code === "ENOENT") return null;
@@ -6456,7 +7509,7 @@ var LocalCacheStore = class {
6456
7509
  }
6457
7510
  async put(entry) {
6458
7511
  const dest = this.filePath(entry.cacheKey);
6459
- await mkdir3(path14.dirname(dest), { recursive: true });
7512
+ await mkdir3(path15.dirname(dest), { recursive: true });
6460
7513
  const tmp = `${dest}.tmp-${process.pid}-${randomUUID2()}`;
6461
7514
  await writeFile7(tmp, JSON.stringify(entry, null, 0));
6462
7515
  await rename2(tmp, dest);
@@ -6480,7 +7533,8 @@ var LOCAL_NODES = [
6480
7533
  imagemagickNode,
6481
7534
  videoTranscribeNode,
6482
7535
  fontSpecimenNode,
6483
- audioTimelineNode
7536
+ audioTimelineNode,
7537
+ collectNode
6484
7538
  ];
6485
7539
  var REMOTE_NODES = [
6486
7540
  textGenerateNode,
@@ -6510,29 +7564,55 @@ function defaultRegistry() {
6510
7564
  }
6511
7565
  function createEngineFromEnv(opts = {}) {
6512
7566
  const cwd = opts.cwd ?? process.cwd();
6513
- const cacheDir = opts.cacheDir ?? path15.join(cwd, "canvas", ".cache");
6514
- const outputsDir = opts.outputsDir ?? path15.join(cwd, "canvas");
7567
+ const cacheDir = opts.cacheDir ?? path16.join(cwd, "canvas", ".cache");
7568
+ const outputsDir = opts.outputsDir ?? path16.join(cwd, "canvas");
6515
7569
  const creds = requireCredentialsFromEnv();
7570
+ const client = new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey });
7571
+ const assets = new LocalAssetStore(path16.join(cacheDir, "assets"));
7572
+ const localCache = new LocalCacheStore(path16.join(cacheDir, "index"));
7573
+ const remoteCacheEnabled = opts.remoteCache ?? remoteCacheEnabledFromEnv();
7574
+ const cache = remoteCacheEnabled ? new LayeredCacheStore({
7575
+ local: localCache,
7576
+ remote: new RemoteCacheStore(client, opts.log),
7577
+ assets,
7578
+ log: opts.log
7579
+ }) : localCache;
6516
7580
  return new Engine({
6517
7581
  registry: defaultRegistry(),
6518
- client: new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey }),
6519
- assets: new LocalAssetStore(path15.join(cacheDir, "assets")),
6520
- cache: new LocalCacheStore(path15.join(cacheDir, "index")),
7582
+ client,
7583
+ assets,
7584
+ cache,
6521
7585
  outputsDir,
6522
- log: opts.log
7586
+ log: opts.log,
7587
+ persistAssets: remoteCacheEnabled
6523
7588
  });
6524
7589
  }
6525
7590
 
6526
7591
  export {
7592
+ BackendClient,
7593
+ requireCredentialsFromEnv,
7594
+ RunAbortedError,
6527
7595
  LayerExecutionError,
6528
7596
  describeFailureReason,
6529
7597
  SEEDANCE_DURATIONS,
6530
7598
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
6531
7599
  IMAGE_GENERATE_MODELS,
7600
+ DEFAULT_VIDEO_GENERATE_MODEL,
6532
7601
  MODEL_REGISTRY,
6533
7602
  resolveConcurrency,
7603
+ ulid,
7604
+ isPersistedAssetRef,
7605
+ collectAssetRefLikes,
7606
+ REF_PREFIX,
7607
+ parseRefExpr,
7608
+ sha256Hex,
7609
+ SEEDANCE_PROFILE,
7610
+ clipProfileFor,
7611
+ clipParamRecipe,
7612
+ imageProfileFor,
6534
7613
  elementMentionKeywords,
6535
- BackendClient2 as BackendClient,
7614
+ toModelSafeImage,
7615
+ BackendClient2,
6536
7616
  Engine2 as Engine,
6537
7617
  LocalAssetStore2 as LocalAssetStore,
6538
7618
  LocalCacheStore2 as LocalCacheStore,
@@ -6542,4 +7622,4 @@ export {
6542
7622
  defaultRegistry,
6543
7623
  createEngineFromEnv
6544
7624
  };
6545
- //# sourceMappingURL=chunk-4EPKHOTF.js.map
7625
+ //# sourceMappingURL=chunk-J2LYFDVC.js.map