@koda-sl/baker-cli 0.97.0 → 0.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2436,14 +2436,26 @@ baker canvas validate my-canvas.json
2436
2436
 
2437
2437
  # 2. Run (executes nodes, writes files to ./canvas/<run_id>/)
2438
2438
  baker canvas run my-canvas.json
2439
+ # Only nodes reachable from the declared output execute (orphaned nodes are skipped,
2440
+ # not billed). Add --keep-runs N to prune old r_* run dirs, keeping the N newest.
2439
2441
 
2440
2442
  # 3. Inspect a finished run (per-node timing, file list, optional video thumbs)
2441
2443
  baker canvas inspect <run_id>
2442
2444
 
2443
2445
  # 4. Discover what's available (all node types, all models, all compositions)
2444
2446
  baker canvas catalog | jq
2447
+
2448
+ # 5. Safely edit a node's prompt without hand-jq (UTF-8 safe — use --text-file for
2449
+ # multi-line / accented copy; the edit is re-validated before it is written back)
2450
+ baker canvas set-prompt my-canvas.json s0_start --text "Close-up, flushed and breathless"
2451
+ baker canvas set-prompt my-canvas.json s0_start --text-file ./hook-frame.txt
2445
2452
  ```
2446
2453
 
2454
+ > Scaffolded canvases use **workspace-relative** composition/asset paths and are validated
2455
+ > against the engine's hyperframe pre-render gate at `validate` time, so a broken overlay
2456
+ > (or a moved/renamed workspace) is caught before any billed clip. The scaffold also adds
2457
+ > `canvas/` + `.context/` to your `.gitignore` on first run.
2458
+
2447
2459
  A re-run with no changes hits the cache for every node — total runtime drops to sub-second.
2448
2460
 
2449
2461
  ---
@@ -65,7 +65,7 @@
65
65
  data-width="1080"
66
66
  data-height="1920"
67
67
  >
68
- <video id="bg" src="background.mp4" muted></video>
68
+ <video id="bg" class="clip" src="background.mp4" data-start="0" data-duration="{{duration}}" muted></video>
69
69
  <div class="caption-container" id="captions"></div>
70
70
  </div>
71
71
 
@@ -84,8 +84,13 @@
84
84
 
85
85
  groups.forEach((group, gi) => {
86
86
  const el = document.createElement('div');
87
- el.className = 'caption-group';
87
+ // `clip` marks this as a hyperframes-timed element (the GSAP timeline
88
+ // below drives its actual show/hide); without it the pre-render gate
89
+ // flags every group as visible for the whole composition.
90
+ el.className = 'caption-group clip';
88
91
  el.id = 'cg_' + gi;
92
+ el.setAttribute('data-start', String(group[0].start));
93
+ el.setAttribute('data-duration', String((group[group.length - 1].end - group[0].start) || 0.5));
89
94
 
90
95
  group.forEach((word, wi) => {
91
96
  const span = document.createElement('span');
@@ -70,10 +70,8 @@
70
70
  bottom, but if you hand-place, respect the bands. */
71
71
 
72
72
  /* COLLISION-SAFE TOP BAR — wrap co-timed top items (logo + trust badge + …) in ONE
73
- .top-bar so they pack side-by-side with a gap and never overlap. Example:
74
- <div class="top-bar" data-start="0" data-dur="30">
75
- <img class="brandmark" src="logo.svg"><span class="trust">★ 4,5/5</span>
76
- </div> */
73
+ .top-bar (a div.ov.clip.top-bar with data-start/data-dur) so they pack side-by-side
74
+ with a gap and never overlap; put the brandmark img + a trust span inside it. */
77
75
  .top-bar {
78
76
  position: absolute; top: 70px; left: 56px; right: 56px;
79
77
  display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
@@ -102,30 +100,25 @@
102
100
  </head>
103
101
  <body>
104
102
  <div id="root" data-composition-id="main" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
105
- <video id="bg" src="background.mp4" muted></video>
103
+ <video id="bg" class="clip" src="background.mp4" data-start="0" data-duration="{{duration}}" muted></video>
106
104
  <div id="overlay-root">
107
105
  <!--OVERLAYS-->
108
106
 
109
- <!-- ── NESTED BLOCK SLOT (optional) ───────────────────────────────────
110
- For an ANIMATED graphic (lower-third, social-proof/review card, stat
111
- counter, chart, logo sting), pull a ready-made block instead of a
112
- static <img>. From this dir:
113
- npx hyperframes catalog --type block # find one by tag/name
114
- npx hyperframes add yt-lower-third # writes compositions/yt-lower-third.html
115
- then uncomment + retime the nested clip (add data-start/data-track-index;
116
- data-composition-id is optional but if set must match the block's id):
117
- <div data-composition-src="compositions/yt-lower-third.html"
118
- data-start="3" data-duration="4.5" data-track-index="2"
119
- data-width="1920" data-height="1080"></div>
120
- ──────────────────────────────────────────────────────────────────── -->
121
-
122
- <!-- ── CAPTION TRACK SLOT (optional, recommended for sound-off feeds) ──
123
- The feed plays muted — burn in captions from the word-level transcript
124
- the deconstruct produced. Floor: `npx hyperframes add caption-highlight`
125
- (TikTok karaoke); escalate by register (caption-kinetic-slam, etc.).
126
- Wiring + the {id,text,start,end} sync contract:
127
- references/hyperframes/captions-and-audio.md.
128
- ──────────────────────────────────────────────────────────────────── -->
107
+ <!-- NESTED BLOCK SLOT (optional): for an ANIMATED graphic (lower-third,
108
+ social/review card, stat counter, chart, logo sting), pull a ready-made
109
+ block instead of a static image. From this dir run `npx hyperframes
110
+ catalog --type block` to find one, `npx hyperframes add yt-lower-third`
111
+ to write it into compositions/, then add a nested clip element — a div
112
+ with class clip plus data-composition-src pointing at the block file,
113
+ data-start, data-duration and data-track-index. See
114
+ references/hyperframes/blueprints-and-transitions.md. -->
115
+
116
+ <!-- CAPTION TRACK SLOT (optional, recommended for sound-off feeds): the feed
117
+ plays muted, so burn in captions from the word-level transcript the
118
+ deconstruct produced. Floor: `npx hyperframes add caption-highlight`
119
+ (TikTok karaoke); escalate by register (caption-kinetic-slam, etc.). The
120
+ scaffold already wires a default caption track as a separate composition;
121
+ see references/hyperframes/captions-and-audio.md for the sync contract. -->
129
122
  </div>
130
123
  </div>
131
124
 
@@ -1937,6 +1937,25 @@ function topologicalLayers(graph) {
1937
1937
  }
1938
1938
  return layers;
1939
1939
  }
1940
+ function reachableNodes(graph, roots) {
1941
+ const reachable = /* @__PURE__ */ new Set();
1942
+ const stack = [...roots];
1943
+ while (stack.length > 0) {
1944
+ const id = stack.pop();
1945
+ if (id === void 0 || reachable.has(id) || !graph.has(id)) continue;
1946
+ reachable.add(id);
1947
+ for (const dep of graph.get(id) ?? []) stack.push(dep);
1948
+ }
1949
+ return reachable;
1950
+ }
1951
+ function subgraph(graph, keep) {
1952
+ const out = /* @__PURE__ */ new Map();
1953
+ for (const [id, deps] of graph) {
1954
+ if (!keep.has(id)) continue;
1955
+ out.set(id, new Set([...deps].filter((d) => keep.has(d))));
1956
+ }
1957
+ return out;
1958
+ }
1940
1959
  function cloneGraph(graph) {
1941
1960
  const out = /* @__PURE__ */ new Map();
1942
1961
  for (const [k, deps] of graph) out.set(k, new Set(deps));
@@ -2369,6 +2388,18 @@ function estimateCredits(ctx) {
2369
2388
  }
2370
2389
  return total;
2371
2390
  }
2391
+ function nativeAudioReachesMix(ctx, scene) {
2392
+ const wanted = [
2393
+ `$ref:s${scene}_voextract.audio`,
2394
+ `$ref:s${scene}_clip.audio`,
2395
+ new RegExp(`\\$ref:s${scene}_r\\d+_(voextract|clip)\\.audio`)
2396
+ ];
2397
+ return ctx.canvas.nodes.some((n) => {
2398
+ if (n.type !== "audio_timeline") return false;
2399
+ const blob = JSON.stringify(n.inputs ?? {});
2400
+ return wanted.some((w) => typeof w === "string" ? blob.includes(w) : w.test(blob));
2401
+ });
2402
+ }
2372
2403
  function talkingSceneSatisfied(ctx, entry, scene) {
2373
2404
  const nodes = ctx.canvas.nodes;
2374
2405
  if (typeof entry === "object" && "voice_convert_node" in entry) {
@@ -2377,7 +2408,7 @@ function talkingSceneSatisfied(ctx, entry, scene) {
2377
2408
  (n) => nativeClipRe.test(n.id) && n.type === "video_generate" && n.params?.generate_audio === true
2378
2409
  );
2379
2410
  const converted = nodes.some((n) => n.id === entry.voice_convert_node && n.type === "audio_voice_convert");
2380
- return clipNativeAudio && converted;
2411
+ return clipNativeAudio && (converted || nativeAudioReachesMix(ctx, scene));
2381
2412
  }
2382
2413
  if (typeof entry === "object") {
2383
2414
  return nodes.some((n) => n.id === entry.lipsync_node && n.type === "video_lipsync");
@@ -2418,7 +2449,7 @@ function checkVideoInvariants(ctx) {
2418
2449
  ctx.issues.push({
2419
2450
  path: "metadata.video.talking_scenes",
2420
2451
  code: STAGE_CODES.LIPSYNC_MISSING,
2421
- message: `scene ${scene} is a single-on-camera-speaker talking head but its clip lacks native audio (generate_audio) or the audio_voice_convert node is missing \u2014 the voice won't be brand-consistent / lips may drift`
2452
+ message: `scene ${scene} is a single-on-camera-speaker talking head but its clip lacks native audio (generate_audio: true) \u2014 the lips would drift. Generate the clip with native audio, then route it to the mix via either the audio_voice_convert node (one brand voice) or directly (s${scene}_voextract \u2192 audio_mix)`
2422
2453
  });
2423
2454
  }
2424
2455
  }
@@ -2596,7 +2627,7 @@ var Engine = class {
2596
2627
  return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir };
2597
2628
  }
2598
2629
  async runLayers(canvas, outputs, runId, writer, opts, counters, nodeRuns) {
2599
- const layers = topologicalLayers(buildGraph(canvas));
2630
+ const layers = topologicalLayers(this.pruneToOutput(canvas, buildGraph(canvas)));
2600
2631
  for (const layer of layers) {
2601
2632
  const tasks = layer.map(
2602
2633
  (nodeId) => this.executeOne(canvas, nodeId, outputs, runId, writer, opts).then((r) => {
@@ -2617,6 +2648,21 @@ var Engine = class {
2617
2648
  await Promise.all(tasks);
2618
2649
  }
2619
2650
  }
2651
+ /**
2652
+ * Dead-node elimination: when the canvas declares an `output`, execute only the
2653
+ * nodes that output transitively depends on. Orphaned nodes (left by an edit or
2654
+ * a rewire) otherwise still run, bill, and abort the run on their own failure.
2655
+ * No declared output ⇒ intent is ambiguous, so run everything (back-compat).
2656
+ */
2657
+ pruneToOutput(canvas, graph) {
2658
+ const rootId = canvas.output?.node;
2659
+ if (!rootId || !graph.has(rootId)) return graph;
2660
+ const keep = reachableNodes(graph, [rootId]);
2661
+ if (keep.size === graph.size) return graph;
2662
+ const dropped = [...graph.keys()].filter((id) => !keep.has(id));
2663
+ this.log(`[prune ] skipping ${dropped.length} node(s) unreachable from output: ${dropped.join(", ")}`);
2664
+ return subgraph(graph, keep);
2665
+ }
2620
2666
  async writeFinal(writer, output) {
2621
2667
  if (output === void 0) return;
2622
2668
  if (isAssetRefArray(output)) {
@@ -2984,6 +3030,27 @@ var FontRef = BaseAssetRef.extend({
2984
3030
  });
2985
3031
  var AssetRef = z4.discriminatedUnion("kind", [ImageRef, VideoRef, AudioRef, JsonRef, TextRef, FontRef]);
2986
3032
 
3033
+ // src/engine/nodes/remote/upload.ts
3034
+ async function presignAndPut(args) {
3035
+ const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(args.sha256, args.mime, args.ctx.signal);
3036
+ const putRes = await fetch(putUrl, {
3037
+ method: "PUT",
3038
+ body: new Uint8Array(args.bytes),
3039
+ headers: { "Content-Type": args.mime },
3040
+ signal: args.ctx.signal
3041
+ });
3042
+ if (!putRes.ok) {
3043
+ throw new Error(`upload: presigned PUT failed ${putRes.status} ${putRes.statusText}`);
3044
+ }
3045
+ return publicUrl;
3046
+ }
3047
+ async function ensureUploaded(ref, ctx) {
3048
+ if (ref.url) return ref;
3049
+ const bytes = await ctx.assets.readBytes(ref.sha256, ref.mime);
3050
+ const url = await presignAndPut({ bytes, sha256: ref.sha256, mime: ref.mime, ctx });
3051
+ return { ...ref, url };
3052
+ }
3053
+
2987
3054
  // src/engine/nodes/remote/delegate.ts
2988
3055
  function delegated(spec) {
2989
3056
  return {
@@ -3003,7 +3070,7 @@ function delegated(spec) {
3003
3070
  }
3004
3071
  async function callBackendExec(args) {
3005
3072
  try {
3006
- const serialized = serializeInputs(args.inputs);
3073
+ const serialized = await serializeInputs(args.inputs, args.ctx);
3007
3074
  const idempotencyKey = args.ctx.cacheKey ? `${args.ctx.canvasRunId}:${args.ctx.cacheKey}` : void 0;
3008
3075
  const res = await args.ctx.client.exec(
3009
3076
  {
@@ -3020,19 +3087,19 @@ async function callBackendExec(args) {
3020
3087
  throw mapClientError(args.ctx, e);
3021
3088
  }
3022
3089
  }
3023
- function serializeInputs(inputs) {
3090
+ async function serializeInputs(inputs, ctx) {
3024
3091
  if (!inputs || typeof inputs !== "object") return {};
3025
3092
  const out = {};
3026
3093
  for (const [k, v] of Object.entries(inputs)) {
3027
- out[k] = stripLocalRefs(v);
3094
+ out[k] = await stripLocalRefs(v, ctx);
3028
3095
  }
3029
3096
  return out;
3030
3097
  }
3031
- function stripLocalRefs(value) {
3032
- if (Array.isArray(value)) return value.map(stripLocalRefs);
3098
+ async function stripLocalRefs(value, ctx) {
3099
+ if (Array.isArray(value)) return Promise.all(value.map((v) => stripLocalRefs(v, ctx)));
3033
3100
  if (value && typeof value === "object") {
3034
3101
  if ("kind" in value && "sha256" in value && "mime" in value) {
3035
- const ref = value;
3102
+ const ref = await ensureUploaded(value, ctx);
3036
3103
  return {
3037
3104
  kind: ref.kind,
3038
3105
  url: ref.url,
@@ -3042,7 +3109,7 @@ function stripLocalRefs(value) {
3042
3109
  };
3043
3110
  }
3044
3111
  const out = {};
3045
- for (const [k, v] of Object.entries(value)) out[k] = stripLocalRefs(v);
3112
+ for (const [k, v] of Object.entries(value)) out[k] = await stripLocalRefs(v, ctx);
3046
3113
  return out;
3047
3114
  }
3048
3115
  return value;
@@ -3555,16 +3622,7 @@ function buildYtDlpMetadata(sourceUrl, info) {
3555
3622
  }
3556
3623
  async function uploadAndIngest(args) {
3557
3624
  const sha = sha256Hex(args.bytes);
3558
- const { putUrl, publicUrl } = await args.ctx.client.presignAssetUpload(sha, args.mime, args.ctx.signal);
3559
- const putRes = await fetch(putUrl, {
3560
- method: "PUT",
3561
- body: new Uint8Array(args.bytes),
3562
- headers: { "Content-Type": args.mime },
3563
- signal: args.ctx.signal
3564
- });
3565
- if (!putRes.ok) {
3566
- throw new Error(`ingest: presigned PUT failed ${putRes.status} ${putRes.statusText}`);
3567
- }
3625
+ const publicUrl = await presignAndPut({ bytes: args.bytes, sha256: sha, mime: args.mime, ctx: args.ctx });
3568
3626
  const localRef = await args.ctx.assets.ingestBytes({
3569
3627
  bytes: args.bytes,
3570
3628
  kind: args.kind,
@@ -4412,7 +4470,15 @@ import { readFile as readFile7 } from "fs/promises";
4412
4470
  import path9 from "path";
4413
4471
  import { promisify as promisify3 } from "util";
4414
4472
  var execFileAsync = promisify3(execFile3);
4415
- var NEVER_BLOCK = [/contrast/i, /\bwcag\b/i, /missing_local_asset/i, /font[_-]?family/i, /font[_-]?face/i];
4473
+ var NEVER_BLOCK = [
4474
+ /contrast/i,
4475
+ /\bwcag\b/i,
4476
+ /missing_local_asset/i,
4477
+ /font[_-]?family/i,
4478
+ /font[_-]?face/i,
4479
+ /transcript[_-]?not[_-]?inline/i,
4480
+ /text[_-]?occluded/i
4481
+ ];
4416
4482
  var UNAVAILABLE = /unknown command|command not found|not found|Did you mean|Unknown argument|ENOENT/i;
4417
4483
  function isAdvisory(code, message) {
4418
4484
  const hay = `${code} ${message}`;
@@ -4474,6 +4540,31 @@ function classifyCheckOutput(lintRaw, inspectRaw) {
4474
4540
  function buildLintArgs(dir) {
4475
4541
  return ["hyperframes", "lint", dir, "--json"];
4476
4542
  }
4543
+ function buildLintArgsNoInstall(dir) {
4544
+ return ["--no-install", "hyperframes", "lint", dir, "--json"];
4545
+ }
4546
+ var VALIDATE_STRUCTURAL_CODES = /media_missing_data_start|timed_element_missing_clip_class/i;
4547
+ function filterStructuralFindings(findings) {
4548
+ return findings.filter((f) => VALIDATE_STRUCTURAL_CODES.test(f.code));
4549
+ }
4550
+ var hyperframesAvailable = null;
4551
+ async function isHyperframesAvailable() {
4552
+ if (hyperframesAvailable !== null) return hyperframesAvailable;
4553
+ try {
4554
+ await execFileAsync("hyperframes", ["--version"], { timeout: 1e4 });
4555
+ hyperframesAvailable = true;
4556
+ } catch {
4557
+ hyperframesAvailable = false;
4558
+ }
4559
+ return hyperframesAvailable;
4560
+ }
4561
+ async function lintStructuralIssues(dir, timeoutMs) {
4562
+ if (!await isHyperframesAvailable()) return [];
4563
+ const lintRaw = await runOne(buildLintArgsNoInstall(dir), timeoutMs);
4564
+ if (lintRaw === null) return [];
4565
+ const findings = classifyLint(parseCheckJson(lintRaw)).map((f) => ({ code: f.code, message: f.message }));
4566
+ return filterStructuralFindings(findings);
4567
+ }
4477
4568
  function buildInspectArgs(dir, samples) {
4478
4569
  return ["hyperframes", "inspect", dir, "--json", "--samples", String(samples)];
4479
4570
  }
@@ -4681,7 +4772,7 @@ var hyperframeRenderNode = defineNode({
4681
4772
  params: HyperframeParams,
4682
4773
  outputs: HyperframeOutputs,
4683
4774
  cost: () => ({ credits: 0, seconds_estimate: 30 }),
4684
- validateExtra: ({ rawParams }) => validateCompositionParams(rawParams),
4775
+ validateExtra: ({ rawParams }) => validateComposition(rawParams),
4685
4776
  cacheKeyExtras: async ({ params }) => {
4686
4777
  const out = {};
4687
4778
  const composition = params.composition;
@@ -4734,6 +4825,21 @@ async function resolveCompositionDir(composition) {
4734
4825
  if (s.isDirectory()) return compositionPath;
4735
4826
  return path11.dirname(compositionPath);
4736
4827
  }
4828
+ async function validateComposition(rawParams) {
4829
+ const issues = await validateCompositionParams(rawParams);
4830
+ const composition = rawParams?.composition;
4831
+ if (typeof composition !== "string") return issues;
4832
+ const dir = await resolveCompositionDir(composition).catch(() => null);
4833
+ if (!dir) return issues;
4834
+ const structural = await lintStructuralIssues(dir, 2e4);
4835
+ for (const s of structural) {
4836
+ issues.push({
4837
+ path: "params.composition",
4838
+ message: `pre-render gate would fail: [${s.code}] ${s.message}`
4839
+ });
4840
+ }
4841
+ return issues;
4842
+ }
4737
4843
  async function validateCompositionParams(rawParams) {
4738
4844
  const issues = [];
4739
4845
  if (!rawParams || typeof rawParams !== "object") return issues;
@@ -5735,7 +5841,7 @@ import { z as z29 } from "zod";
5735
5841
  var VIDEO_DECONSTRUCT_MODELS = ["~google/gemini-flash-latest", "~google/gemini-pro-latest"];
5736
5842
  var videoDeconstructNode = delegated({
5737
5843
  id: "video_deconstruct",
5738
- version: "1.3.0",
5844
+ version: "1.4.0",
5739
5845
  category: "video",
5740
5846
  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).',
5741
5847
  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.',
@@ -6146,4 +6252,4 @@ export {
6146
6252
  defaultRegistry,
6147
6253
  createEngineFromEnv
6148
6254
  };
6149
- //# sourceMappingURL=chunk-RCPMJKI7.js.map
6255
+ //# sourceMappingURL=chunk-3JVYU72O.js.map