@koda-sl/baker-cli 0.79.0 → 0.81.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
@@ -2502,7 +2502,7 @@ Local ImageMagick passthrough. Identical schema and safety contract to `ffmpeg`;
2502
2502
 
2503
2503
  Render an HTML/CSS/GSAP composition to **mp4** (default), or to a transparent **webm**/**mov** alpha overlay. Quality and worker count are fixed by the engine for ad-creative delivery; the canvas owns the composition path, the output format, and the composition vars.
2504
2504
 
2505
- Before each render the engine runs a **pre-render quality gate** on the *substituted* composition — Hyperframes' own `lint` + `inspect`. Layout **overflow** (clipped text) and structural lint **errors block** the render; **low-contrast** text and missing brand fonts/assets only **warn** (logged as `hyperframe check warning …`). If the `hyperframes` binary or its subcommands are unavailable the gate degrades gracefully and the render proceeds.
2505
+ Before each render the engine runs a **pre-render quality gate** on the *substituted* composition — Hyperframes' own `lint` + `inspect`. Layout **overflow** (clipped text) and structural lint **errors block** the render; **low-contrast** text and missing brand fonts/assets only **warn** (logged as `hyperframe check warning …`). When the composition embeds another via a live `data-composition-src` (a nested catalog block), the gate additionally runs `snapshot` as a render-path smoke test — the only check that catches a broken embed (id mismatch, `<style>`/`<script>` outside the sub-comp's `<template>`, or a class-styled sub-comp root) that `lint`/`inspect`/preview all miss. If the `hyperframes` binary or its subcommands are unavailable the gate degrades gracefully and the render proceeds.
2506
2506
 
2507
2507
  **Inputs**
2508
2508
 
@@ -3631,6 +3631,8 @@ It then scaffolds the full pipeline: per scene, two **static-ad-grade frames** (
3631
3631
 
3632
3632
  **Overlays are agent-painted HTML, not props.** The clips are concatenated, then the `video-overlay` composition (copied next to the canvas) composites the overlay layer. The scaffold **bakes the reference's overlays into that composition's `index.html` as real, editable HTML** (each overlay is a plain element with its text, a `.pos-*` position class, and `data-start`/`data-dur` timing); a tiny generic runtime only shows/hides each element at its timestamp (with an optional `data-anim` entrance). It makes **no styling decisions** — bars, tickers, colors, fonts, and a real logo `<img>` you drop into the dir all live in the HTML/CSS you edit. Floating elements (logo bugs) are seeded as commented `<img>` stubs so an un-edited render stays clean. Drop `brand-bold.otf`/`brand-regular.otf` for on-brand type.
3633
3633
 
3634
+ **Re-craft the script — the hook is the #1 decision.** A reproduction is *inspiration* from a proven ad, not a clone: its structure (hook → body → CTA) carries the persuasion, and the hook is *targeting*, so a competitor's hook often does **not** transfer. `metadata.todo.script_recraft` tags each scene with its `narrative_role` (from the deconstruct, else inferred) and carries the original line **flagged** so it is never shipped as-is — and the per-scene `recraft` instruction is **role-aware**: the **hook** scene's entry carries the diagnose → decide (keep/adapt/rebuild) → criteria (statement not question, benefit by ~2s, first frame legible **sound-off** in ~1s, no bait-and-switch) inline and routes to the skill's `references/hook-craft.md`. A dedicated top-level **`metadata.todo.hook`** key foregrounds it as the highest-leverage beat, mapped onto scene-0's artifacts (`s0_start` first frame, scene-0 overlay text, `s0_clip` line, micro-hook, hook-ramp).
3635
+
3634
3636
  The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, and every single-on-camera-speaker scene is a native talking head (its clip carries `generate_audio` and is wired to an `audio_voice_convert` node). The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
3635
3637
 
3636
3638
  ```bash
@@ -0,0 +1,136 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=1080, height=1920" />
6
+ <script src="./gsap.min.js"></script>
7
+ <style>
8
+ /* The "talking head on a video call" pattern — see the creative-canvas-ads
9
+ skill's references/hyperframes/talking-head-and-overlays.md. Presenter
10
+ (the `background` video) starts full-bleed, then shrinks to a corner PIP
11
+ pill while #content takes the stage. Replace #content with a real
12
+ screenshot <img> you drop in this dir, or nest a catalog block. Drop
13
+ brand-bold.otf / brand-regular.otf for on-brand type. */
14
+ @font-face {
15
+ font-family: 'BrandFont';
16
+ src: url('./brand-bold.otf') format('opentype'), url('./brand-bold.ttf') format('truetype');
17
+ font-weight: 700 900;
18
+ font-display: swap;
19
+ }
20
+ @font-face {
21
+ font-family: 'BrandFont';
22
+ src: url('./brand-regular.otf') format('opentype'), url('./brand-regular.ttf') format('truetype');
23
+ font-weight: 100 600;
24
+ font-display: swap;
25
+ }
26
+ * { margin: 0; padding: 0; box-sizing: border-box; }
27
+ html, body { width: 1080px; height: 1920px; overflow: hidden; background: #000; }
28
+ #root { position: absolute; inset: 0; font-family: 'BrandFont', 'Helvetica Neue', Arial, sans-serif; }
29
+
30
+ /* Content stage — sits BEHIND the presenter (DOM order: #content first, then
31
+ #video-wrap → the pill paints on top). Swap this panel for a screenshot. */
32
+ #content {
33
+ position: absolute; inset: 0; background: {{bg_color}}; color: {{text_color}};
34
+ display: flex; flex-direction: column; justify-content: center;
35
+ padding: 160px 96px; opacity: 0;
36
+ }
37
+ #content .kicker { width: 120px; height: 8px; background: {{accent_color}}; border-radius: 4px; transform: scaleX(0); transform-origin: left center; margin-bottom: 40px; }
38
+ #content .headline { font-size: 104px; font-weight: 900; line-height: 1.08; letter-spacing: -1px; text-wrap: balance; }
39
+
40
+ /* Presenter video — full-bleed at first, then TRANSFORM-scaled into a corner
41
+ pill. Transforms only: never animate left/top/width/height — they trigger
42
+ layout, and the renderer's parallel per-frame seeks can desync layout-driven
43
+ geometry (see references/hyperframes/composition-engine.md). The pill chrome
44
+ (radius/ring) is applied at PIP time and pre-multiplied for the scale. */
45
+ #video-wrap { position: absolute; top: 0; left: 0; width: 1080px; height: 1920px; overflow: hidden; transform-origin: 0 0; }
46
+ #bg { width: 100%; height: 100%; object-fit: cover; }
47
+
48
+ /* Floating glass claim card. */
49
+ #claim {
50
+ position: absolute; left: 64px; bottom: 360px; max-width: 720px;
51
+ padding: 36px 44px; border-radius: 24px;
52
+ background: rgba(255,255,255,0.10); backdrop-filter: blur(18px) saturate(140%);
53
+ -webkit-backdrop-filter: blur(18px) saturate(140%);
54
+ border: 1px solid rgba(255,255,255,0.22);
55
+ color: {{text_color}}; font-size: 46px; font-weight: 700; line-height: 1.2;
56
+ opacity: 0; transform: translateY(24px);
57
+ }
58
+
59
+ /* Caption band. */
60
+ #caption {
61
+ position: absolute; left: 50%; bottom: 150px; transform: translateX(-50%);
62
+ width: 88%; text-align: center; color: {{text_color}};
63
+ font-size: 56px; font-weight: 800; line-height: 1.15;
64
+ text-shadow: 0 2px 8px rgba(0,0,0,0.6), 0 0 2px rgba(0,0,0,0.9);
65
+ opacity: 0;
66
+ }
67
+ #caption .u { display: inline-block; height: 6px; width: 120px; background: {{accent_color}}; border-radius: 3px; vertical-align: middle; margin-left: 16px; transform: scaleX(0); transform-origin: left center; }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div id="root" data-composition-id="video-call" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
72
+ <div id="content">
73
+ <div class="kicker" id="kicker"></div>
74
+ <div class="headline" id="headline">{{headline}}</div>
75
+ </div>
76
+ <div id="video-wrap">
77
+ <video id="bg" src="background.mp4" muted></video>
78
+ </div>
79
+ <div id="claim">{{claim}}</div>
80
+ <div id="caption"><span id="caption-text">{{caption}}</span><span class="u" id="caption-u"></span></div>
81
+ </div>
82
+
83
+ <script>
84
+ (() => {
85
+ var DURATION = parseFloat('{{duration}}') || 8;
86
+ var PIP_AT = parseFloat('{{pip_at_s}}'); if (!(PIP_AT >= 0)) PIP_AT = 2;
87
+ var hasHeadline = ((document.getElementById('headline').textContent) || '').trim().length > 0;
88
+ var hasClaim = ((document.getElementById('claim').textContent) || '').trim().length > 0;
89
+ var hasCaption = ((document.getElementById('caption-text').textContent) || '').trim().length > 0;
90
+ if (!hasClaim) document.getElementById('claim').style.display = 'none';
91
+ if (!hasCaption) document.getElementById('caption').style.display = 'none';
92
+ // Kicker sits at scaleX(0) but still holds its layout box + 40px bottom margin,
93
+ // so hide it outright when there's no headline (the default) — no dead space.
94
+ if (!hasHeadline) document.getElementById('kicker').style.display = 'none';
95
+
96
+ // Top-right PIP pill, 9:16 to MATCH the full frame so a single UNIFORM scale
97
+ // shrinks the presenter without distorting the video (non-uniform scaleX/scaleY
98
+ // would squish it). Edit x/y for another corner. Transforms only.
99
+ var PILL = { x: 672, y: 56, w: 360, h: 640 };
100
+ var s = PILL.w / 1080; // === PILL.h / 1920 (uniform)
101
+ var k = 1 / s; // pre-multiplier so the chrome lands crisp at the target size after scaling
102
+
103
+ var tl = gsap.timeline({ paused: true });
104
+
105
+ // Presenter shrinks into the pill; content stage fades up underneath. The pill
106
+ // chrome (radius + white ring) is set at PIP time and pre-multiplied by k so,
107
+ // once the element is scaled by s, it reads as ~28px radius + a crisp ring.
108
+ tl.set('#video-wrap', {
109
+ borderRadius: 28 * k,
110
+ boxShadow: '0 ' + 24 * k + 'px ' + 60 * k + 'px ' + (-20 * k) + 'px rgba(0,0,0,0.45), 0 0 0 ' + 6 * k + 'px rgba(255,255,255,0.7), 0 0 0 ' + 8 * k + 'px rgba(11,16,32,0.18)',
111
+ }, PIP_AT);
112
+ tl.fromTo('#video-wrap',
113
+ { x: 0, y: 0, scale: 1 },
114
+ { x: PILL.x, y: PILL.y, scale: s, duration: 0.6, ease: 'power2.inOut' },
115
+ PIP_AT);
116
+ tl.fromTo('#content', { opacity: 0 }, { opacity: 1, duration: 0.4, ease: 'power2.out' }, PIP_AT + 0.1);
117
+ if (hasHeadline) tl.fromTo('#kicker', { scaleX: 0 }, { scaleX: 1, duration: 0.4, ease: 'power3.out' }, PIP_AT + 0.25);
118
+
119
+ // Glass claim card rises in.
120
+ if (hasClaim) tl.fromTo('#claim', { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.45, ease: 'power2.out' }, PIP_AT + 0.45);
121
+
122
+ // Caption fades in with an accent underline wipe.
123
+ if (hasCaption) {
124
+ tl.fromTo('#caption', { opacity: 0 }, { opacity: 1, duration: 0.35, ease: 'power1.out' }, PIP_AT + 0.6);
125
+ tl.fromTo('#caption-u', { scaleX: 0 }, { scaleX: 1, duration: 0.4, ease: 'power3.out' }, PIP_AT + 0.7);
126
+ }
127
+
128
+ // Hold to the full duration.
129
+ tl.to({}, { duration: 0.01 }, Math.max(0.02, DURATION - 0.01));
130
+
131
+ window.__timelines = window.__timelines || {};
132
+ window.__timelines["video-call"] = tl;
133
+ })();
134
+ </script>
135
+ </body>
136
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "id": "video-call",
3
+ "title": "Talking head on a video call — presenter shrinks to a PIP pill over content",
4
+ "description": "The 'AI person showing you something on a call' pattern. The presenter video starts full-bleed, then at pip_at_s shrinks into a rounded corner PIP pill while a content card (a claim, or your screen-share/app-UI image) takes the stage; a glass claim card and a caption band animate in. The presenter clip is the `background` input. Replace the #content panel with a real screenshot <img> you drop in this dir, or nest a catalog block (npx hyperframes add …) — see the creative-canvas-ads skill's references/hyperframes/talking-head-and-overlays.md. Drop brand-bold.otf / brand-regular.otf for on-brand type. Fixed 1080x1920; copy + edit width/height for other ratios.",
5
+ "width": 1080,
6
+ "height": 1920,
7
+ "fps": 30,
8
+ "default_duration": 8,
9
+ "inputs": {
10
+ "background": {
11
+ "kind": "video",
12
+ "required": true,
13
+ "staged_as": "background.mp4",
14
+ "description": "The presenter / talking-head clip (e.g. a Seedance native-voiced scene)."
15
+ }
16
+ },
17
+ "params": {
18
+ "headline": { "kind": "string", "default": "", "description": "Big line on the content stage (what the presenter is showing)." },
19
+ "claim": { "kind": "string", "default": "", "description": "Short proof/claim on the floating glass card (optional)." },
20
+ "caption": { "kind": "string", "default": "", "description": "A caption line in the lower band (optional)." },
21
+ "pip_at_s": { "kind": "number", "default": 2, "min": 0, "max": 30, "description": "When the presenter shrinks to the corner pill." },
22
+ "bg_color": { "kind": "color", "default": "#0b1020", "description": "Content-stage background (behind the presenter)." },
23
+ "text_color": { "kind": "color", "default": "#ffffff" },
24
+ "accent_color": { "kind": "color", "default": "#5b8cff", "description": "Headline accent + caption underline + pill ring tint." }
25
+ }
26
+ }
@@ -8,17 +8,33 @@
8
8
  /* ═══════════════════════════════════════════════════════════════════════
9
9
  OVERLAY LAYER — PAINT THIS YOURSELF.
10
10
 
11
+ This is a REAL Hyperframes composition (hyperframe_render runs the genuine
12
+ `npx hyperframes` renderer), so it can do FAR more than the seeded text.
13
+
11
14
  The scaffold seeds #overlay-root (below) with the reference ad's overlays
12
15
  as plain HTML elements — real text, a position class, and data-start/
13
16
  data-dur timing. This file is YOURS: restyle every rule here, add classes,
14
17
  build a lower-third bar or a scrolling ticker, drop a logo image into this
15
- dir and reference it with <img>. The only contract is:
18
+ dir and reference it with <img>. The simple contract for a seeded element:
16
19
 
17
20
  • keep each overlay element inside #overlay-root
18
21
  • keep its data-start / data-dur (seconds) — the runtime shows/hides by them
19
22
  • (optional) data-anim="fade|slide_up|slide_down|pop" for a canned entrance
20
23
 
21
- The runtime makes NO styling decisions. How it looks is 100% your CSS/markup.
24
+ …and the richer capabilities (see the two commented slots in #overlay-root,
25
+ and the full docs under the creative-canvas-ads skill's references/hyperframes/):
26
+
27
+ • PULL A READY-MADE BLOCK — `npx hyperframes add <id>` (run in THIS dir;
28
+ lands in compositions/) then nest it with a <div data-composition-src>
29
+ clip. ~97 blocks: lower-thirds, social-proof cards, stat counters, charts,
30
+ code, logo stings. `npx hyperframes catalog --type block` lists them.
31
+ • CAPTIONS — drive a karaoke/themed/kinetic caption track off the
32
+ transcript (sound-off feed). `npx hyperframes add caption-highlight`.
33
+ • MOTION — animate with GSAP using the named motion-rule vocabulary, not
34
+ just the four canned entrances.
35
+
36
+ The seeded runtime decides only WHEN a plain element shows; everything
37
+ else — look, motion, nested blocks — is your CSS/markup/GSAP.
22
38
  For on-brand type, drop brand-bold.otf / brand-regular.otf into this dir.
23
39
  ═══════════════════════════════════════════════════════════════════════ */
24
40
  @font-face {
@@ -68,6 +84,27 @@
68
84
  <video id="bg" src="background.mp4" muted></video>
69
85
  <div id="overlay-root">
70
86
  <!--OVERLAYS-->
87
+
88
+ <!-- ── NESTED BLOCK SLOT (optional) ───────────────────────────────────
89
+ For an ANIMATED graphic (lower-third, social-proof/review card, stat
90
+ counter, chart, logo sting), pull a ready-made block instead of a
91
+ static <img>. From this dir:
92
+ npx hyperframes catalog --type block # find one by tag/name
93
+ npx hyperframes add yt-lower-third # writes compositions/yt-lower-third.html
94
+ then uncomment + retime the nested clip (add data-start/data-track-index;
95
+ data-composition-id is optional but if set must match the block's id):
96
+ <div data-composition-src="compositions/yt-lower-third.html"
97
+ data-start="3" data-duration="4.5" data-track-index="2"
98
+ data-width="1920" data-height="1080"></div>
99
+ ──────────────────────────────────────────────────────────────────── -->
100
+
101
+ <!-- ── CAPTION TRACK SLOT (optional, recommended for sound-off feeds) ──
102
+ The feed plays muted — burn in captions from the word-level transcript
103
+ the deconstruct produced. Floor: `npx hyperframes add caption-highlight`
104
+ (TikTok karaoke); escalate by register (caption-kinetic-slam, etc.).
105
+ Wiring + the {id,text,start,end} sync contract:
106
+ references/hyperframes/captions-and-audio.md.
107
+ ──────────────────────────────────────────────────────────────────── -->
71
108
  </div>
72
109
  </div>
73
110
 
@@ -621,7 +621,7 @@ ${originalIndentation}`;
621
621
  });
622
622
 
623
623
  // src/engine/index.ts
624
- import path14 from "path";
624
+ import path15 from "path";
625
625
 
626
626
  // src/engine/client/http.ts
627
627
  var BackendHttpError = class extends Error {
@@ -667,14 +667,14 @@ var HttpClient = class {
667
667
  this.fetchFn = opts.fetchFn ?? fetch;
668
668
  this.sleepFn = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
669
669
  }
670
- async postJson(path15, body, signal) {
671
- return await this.requestJson("POST", path15, body, signal);
670
+ async postJson(path16, body, signal) {
671
+ return await this.requestJson("POST", path16, body, signal);
672
672
  }
673
- async getJson(path15, signal) {
674
- return await this.requestJson("GET", path15, void 0, signal);
673
+ async getJson(path16, signal) {
674
+ return await this.requestJson("GET", path16, void 0, signal);
675
675
  }
676
- async requestJson(method, path15, body, signal) {
677
- const url = `${this.baseUrl}${path15.startsWith("/") ? path15 : `/${path15}`}`;
676
+ async requestJson(method, path16, body, signal) {
677
+ const url = `${this.baseUrl}${path16.startsWith("/") ? path16 : `/${path16}`}`;
678
678
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
679
679
  const outcome = await this.attempt(method, url, body, attempt, signal);
680
680
  if (outcome.kind === "value") return outcome.value;
@@ -786,8 +786,8 @@ var BackendClient = class {
786
786
  );
787
787
  }
788
788
  getArtifact(kind, name, version, signal) {
789
- const path15 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
790
- return this.http.getJson(path15, signal);
789
+ const path16 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
790
+ return this.http.getJson(path16, signal);
791
791
  }
792
792
  };
793
793
 
@@ -2403,9 +2403,9 @@ function checkOutputRef(ctx) {
2403
2403
  function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2404
2404
  for (const issue of err.issues) {
2405
2405
  const tail2 = pathToString(issue.path);
2406
- const path15 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
2406
+ const path16 = pathPrefix ? tail2 ? `${pathPrefix}.${tail2}` : pathPrefix : tail2;
2407
2407
  issues.push({
2408
- path: path15,
2408
+ path: path16,
2409
2409
  code,
2410
2410
  message: issue.message,
2411
2411
  received: issue.code === "invalid_type" ? issue.received : void 0,
@@ -2414,8 +2414,8 @@ function pushZodIssues(issues, err, pathPrefix, code, nodeId, nodeType) {
2414
2414
  });
2415
2415
  }
2416
2416
  }
2417
- function pathToString(path15) {
2418
- return path15.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
2417
+ function pathToString(path16) {
2418
+ return path16.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("").replace(/^\./, "");
2419
2419
  }
2420
2420
  function buildDepGraph(canvas) {
2421
2421
  const graph = /* @__PURE__ */ new Map();
@@ -4044,10 +4044,10 @@ var fontSpecimenNode = defineNode({
4044
4044
 
4045
4045
  // src/engine/nodes/local/hyperframe.ts
4046
4046
  import { execFile as execFile4 } from "child_process";
4047
- import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile7, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
4047
+ import { copyFile as copyFile4, mkdtemp as mkdtemp4, readFile as readFile8, rm as rm4, stat as stat5, writeFile as writeFile5 } from "fs/promises";
4048
4048
  import { createRequire as createRequire2 } from "module";
4049
4049
  import { cpus, tmpdir as tmpdir4 } from "os";
4050
- import path10 from "path";
4050
+ import path11 from "path";
4051
4051
  import { promisify as promisify4 } from "util";
4052
4052
  import { z as z10 } from "zod";
4053
4053
 
@@ -4244,6 +4244,8 @@ function defaultFilenameForInput(key, kind) {
4244
4244
 
4245
4245
  // src/engine/nodes/local/lib/hyperframe-check.ts
4246
4246
  import { execFile as execFile3 } from "child_process";
4247
+ import { readFile as readFile7 } from "fs/promises";
4248
+ import path9 from "path";
4247
4249
  import { promisify as promisify3 } from "util";
4248
4250
  var execFileAsync = promisify3(execFile3);
4249
4251
  var NEVER_BLOCK = [/contrast/i, /\bwcag\b/i, /missing_local_asset/i, /font[_-]?family/i, /font[_-]?face/i];
@@ -4311,6 +4313,13 @@ function buildLintArgs(dir) {
4311
4313
  function buildInspectArgs(dir, samples) {
4312
4314
  return ["hyperframes", "inspect", dir, "--json", "--samples", String(samples)];
4313
4315
  }
4316
+ function buildSnapshotArgs(dir, frames) {
4317
+ return ["hyperframes", "snapshot", dir, "--frames", String(frames), "--describe", "false"];
4318
+ }
4319
+ function usesNestedCompositions(indexHtml) {
4320
+ const withoutComments = indexHtml.replace(/<!--[\s\S]*?-->/g, "");
4321
+ return /data-composition-src\s*=/.test(withoutComments);
4322
+ }
4314
4323
  async function runOne(args, timeoutMs) {
4315
4324
  try {
4316
4325
  const { stdout } = await execFileAsync("npx", args, { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
@@ -4323,6 +4332,17 @@ async function runOne(args, timeoutMs) {
4323
4332
  return null;
4324
4333
  }
4325
4334
  }
4335
+ async function runSnapshotSmoke(args, timeoutMs) {
4336
+ try {
4337
+ await execFileAsync("npx", args, { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
4338
+ return { ok: true, unavailable: false, message: "" };
4339
+ } catch (e) {
4340
+ const err = e;
4341
+ const blob = `${err.stderr ?? ""} ${err.message ?? ""}`;
4342
+ if (UNAVAILABLE.test(blob)) return { ok: false, unavailable: true, message: blob };
4343
+ return { ok: false, unavailable: false, message: (err.stderr || err.message || "snapshot failed").slice(0, 800) };
4344
+ }
4345
+ }
4326
4346
  async function runHyperframesCheck(opts) {
4327
4347
  const { dir, nodeId, ctx, timeoutMs, samples = 5 } = opts;
4328
4348
  const [lintRaw, inspectRaw] = await Promise.all([
@@ -4342,6 +4362,25 @@ async function runHyperframesCheck(opts) {
4342
4362
  throw new Error(`${nodeId}: pre-render check failed (${blocking.length} blocking)
4343
4363
  ${detail}`);
4344
4364
  }
4365
+ let indexHtml = "";
4366
+ try {
4367
+ indexHtml = await readFile7(path9.join(dir, "index.html"), "utf-8");
4368
+ } catch {
4369
+ indexHtml = "";
4370
+ }
4371
+ if (indexHtml && usesNestedCompositions(indexHtml)) {
4372
+ const snap = await runSnapshotSmoke(buildSnapshotArgs(dir, Math.min(samples, 3)), Math.max(timeoutMs, 12e4));
4373
+ if (snap.unavailable) {
4374
+ ctx.log(`${nodeId}: hyperframes snapshot unavailable \u2014 skipping nested-composition smoke test`);
4375
+ } else if (!snap.ok) {
4376
+ throw new Error(
4377
+ `${nodeId}: nested-composition smoke test failed \u2014 an embedded block did not render. Check the host\u2194block id match, that the block's <style>/<script> live inside its <template>, and that it styles #root (not a class).
4378
+ ${snap.message}`
4379
+ );
4380
+ } else {
4381
+ ctx.log(`${nodeId}: nested-composition smoke test passed`);
4382
+ }
4383
+ }
4345
4384
  ctx.log(`${nodeId}: pre-render check passed (${warnings.length} warning${warnings.length === 1 ? "" : "s"})`);
4346
4385
  }
4347
4386
 
@@ -4390,9 +4429,9 @@ ${stderr.slice(0, 1500)}`;
4390
4429
 
4391
4430
  // src/engine/nodes/local/lib/hyperframe-meta.ts
4392
4431
  import { writeFile as writeFile4 } from "fs/promises";
4393
- import path9 from "path";
4432
+ import path10 from "path";
4394
4433
  async function ensureHyperframesMetaJson(tmp, nodeId, meta, duration) {
4395
- const metaPath = path9.join(tmp, "meta.json");
4434
+ const metaPath = path10.join(tmp, "meta.json");
4396
4435
  await writeFile4(
4397
4436
  metaPath,
4398
4437
  JSON.stringify(
@@ -4492,7 +4531,7 @@ var hyperframeRenderNode = defineNode({
4492
4531
  const compositionDir = await resolveCompositionDir(params.composition);
4493
4532
  const meta = await loadCompositionMeta(compositionDir);
4494
4533
  const compositionParams = validateAndParseDynamicParams(meta, params);
4495
- const tmp = await mkdtemp4(path10.join(tmpdir4(), "hf-render-"));
4534
+ const tmp = await mkdtemp4(path11.join(tmpdir4(), "hf-render-"));
4496
4535
  try {
4497
4536
  await copyComposition(compositionDir, tmp);
4498
4537
  await vendorGsap(tmp, ctx);
@@ -4502,9 +4541,9 @@ var hyperframeRenderNode = defineNode({
4502
4541
  await substituteCompositionFiles(tmp, substitutionValues);
4503
4542
  await ensureHyperframesMetaJson(tmp, ctx.nodeId, meta, duration);
4504
4543
  await runHyperframesCheck({ dir: tmp, nodeId: "hyperframe_render", ctx, timeoutMs: params.timeout_ms });
4505
- const outputPath = path10.join(tmp, `output.${params.format}`);
4544
+ const outputPath = path11.join(tmp, `output.${params.format}`);
4506
4545
  await runRender({ tmp, outputPath, params, meta, ctx });
4507
- const bytes = await readFile7(outputPath);
4546
+ const bytes = await readFile8(outputPath);
4508
4547
  ctx.log(`rendered ${bytes.length} bytes`);
4509
4548
  const ref = await ctx.assets.ingestBytes({
4510
4549
  bytes: Buffer.from(bytes),
@@ -4526,10 +4565,10 @@ var hyperframeRenderNode = defineNode({
4526
4565
  }
4527
4566
  });
4528
4567
  async function resolveCompositionDir(composition) {
4529
- const compositionPath = path10.isAbsolute(composition) ? composition : path10.resolve(process.cwd(), composition);
4568
+ const compositionPath = path11.isAbsolute(composition) ? composition : path11.resolve(process.cwd(), composition);
4530
4569
  const s = await stat5(compositionPath);
4531
4570
  if (s.isDirectory()) return compositionPath;
4532
- return path10.dirname(compositionPath);
4571
+ return path11.dirname(compositionPath);
4533
4572
  }
4534
4573
  async function validateCompositionParams(rawParams) {
4535
4574
  const issues = [];
@@ -4596,7 +4635,7 @@ async function copyComposition(srcDir, destDir) {
4596
4635
  await cp(srcDir, destDir, {
4597
4636
  recursive: true,
4598
4637
  filter: (src) => {
4599
- const name = path10.basename(src);
4638
+ const name = path11.basename(src);
4600
4639
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
4601
4640
  return true;
4602
4641
  }
@@ -4605,7 +4644,7 @@ async function copyComposition(srcDir, destDir) {
4605
4644
  async function vendorGsap(tmp, ctx) {
4606
4645
  try {
4607
4646
  const gsapMin = require_2.resolve("gsap/dist/gsap.min.js");
4608
- await copyFile4(gsapMin, path10.join(tmp, "gsap.min.js"));
4647
+ await copyFile4(gsapMin, path11.join(tmp, "gsap.min.js"));
4609
4648
  } catch (e) {
4610
4649
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
4611
4650
  }
@@ -4620,7 +4659,7 @@ async function stageInputs2(tmp, inputs, meta, ctx) {
4620
4659
  await stageAsset(ref, tmp, filename);
4621
4660
  ctx.log(`staged ${spec.kind} \u2192 ${filename}`);
4622
4661
  if (spec.kind === "video" && primaryDuration === null) {
4623
- primaryDuration = await probeDurationSeconds(path10.join(tmp, filename));
4662
+ primaryDuration = await probeDurationSeconds(path11.join(tmp, filename));
4624
4663
  }
4625
4664
  }
4626
4665
  return primaryDuration;
@@ -4666,8 +4705,8 @@ function coerceImageParam(value) {
4666
4705
  throw new Error("hyperframe_render: image param must be a URL string or AssetRef");
4667
4706
  }
4668
4707
  async function substituteCompositionFiles(tmp, values) {
4669
- const entryPath = path10.join(tmp, "index.html");
4670
- const original = await readFile7(entryPath, "utf-8");
4708
+ const entryPath = path11.join(tmp, "index.html");
4709
+ const original = await readFile8(entryPath, "utf-8");
4671
4710
  const { output, missing } = substituteVariables(original, values);
4672
4711
  if (missing.length > 0) {
4673
4712
  throw new Error(
@@ -4683,7 +4722,7 @@ function workerCount() {
4683
4722
  async function runRender(opts) {
4684
4723
  const { tmp, outputPath, params, meta, ctx } = opts;
4685
4724
  const args = buildRenderArgs(tmp, outputPath, meta, params.format);
4686
- ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path10.basename(tmp)}`);
4725
+ ctx.log(`rendering ${meta.width}x${meta.height}@${meta.fps}fps ${params.format} from ${path11.basename(tmp)}`);
4687
4726
  try {
4688
4727
  await execFileAsync2("npx", args, { timeout: params.timeout_ms, maxBuffer: 64 * 1024 * 1024 });
4689
4728
  } catch (e) {
@@ -4727,10 +4766,10 @@ async function probeDurationSeconds(filePath) {
4727
4766
 
4728
4767
  // src/engine/nodes/local/hyperframe-snapshot.ts
4729
4768
  import { execFile as execFile5 } from "child_process";
4730
- import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile8, rm as rm5, writeFile as writeFile6 } from "fs/promises";
4769
+ import { copyFile as copyFile5, mkdtemp as mkdtemp5, readFile as readFile9, rm as rm5, writeFile as writeFile6 } from "fs/promises";
4731
4770
  import { createRequire as createRequire3 } from "module";
4732
4771
  import { tmpdir as tmpdir5 } from "os";
4733
- import path11 from "path";
4772
+ import path12 from "path";
4734
4773
  import { promisify as promisify5 } from "util";
4735
4774
  import { z as z11 } from "zod";
4736
4775
  var _execFileAsync = promisify5(execFile5);
@@ -4777,7 +4816,7 @@ var hyperframeSnapshotNode = defineNode({
4777
4816
  const compositionDir = await resolveCompositionDir(params.composition);
4778
4817
  const meta = await loadCompositionMeta(compositionDir);
4779
4818
  const compositionParams = validateAndParseDynamicParams2(meta, params);
4780
- const tmp = await mkdtemp5(path11.join(tmpdir5(), "hf-snap-"));
4819
+ const tmp = await mkdtemp5(path12.join(tmpdir5(), "hf-snap-"));
4781
4820
  try {
4782
4821
  await copyComposition2(compositionDir, tmp);
4783
4822
  await vendorGsap2(tmp, ctx);
@@ -4792,7 +4831,7 @@ var hyperframeSnapshotNode = defineNode({
4792
4831
  timeoutMs: params.timeout_ms,
4793
4832
  samples: 1
4794
4833
  });
4795
- const entryPath = path11.join(tmp, "index.html");
4834
+ const entryPath = path12.join(tmp, "index.html");
4796
4835
  const entryUrl = `file://${entryPath}`;
4797
4836
  ctx.log(`snapshotting ${meta.width}x${meta.height}@${DEVICE_SCALE_FACTOR2}x wait=${params.wait_for.kind}`);
4798
4837
  const pwSpecifier = ["play", "wright"].join("");
@@ -4853,7 +4892,7 @@ async function copyComposition2(srcDir, destDir) {
4853
4892
  await cp(srcDir, destDir, {
4854
4893
  recursive: true,
4855
4894
  filter: (src) => {
4856
- const name = path11.basename(src);
4895
+ const name = path12.basename(src);
4857
4896
  if (name === ".cache" || name === "node_modules" || name === ".git") return false;
4858
4897
  return true;
4859
4898
  }
@@ -4862,7 +4901,7 @@ async function copyComposition2(srcDir, destDir) {
4862
4901
  async function vendorGsap2(tmp, ctx) {
4863
4902
  try {
4864
4903
  const gsapMin = require_3.resolve("gsap/dist/gsap.min.js");
4865
- await copyFile5(gsapMin, path11.join(tmp, "gsap.min.js"));
4904
+ await copyFile5(gsapMin, path12.join(tmp, "gsap.min.js"));
4866
4905
  } catch (e) {
4867
4906
  ctx.log(`warning: could not vendor gsap.min.js (${e.message}); compositions must self-supply`);
4868
4907
  }
@@ -4896,8 +4935,8 @@ function coerceImageParam2(value) {
4896
4935
  throw new Error("hyperframe_snapshot: image param must be a URL string or AssetRef");
4897
4936
  }
4898
4937
  async function substituteCompositionFiles2(tmp, values) {
4899
- const entryPath = path11.join(tmp, "index.html");
4900
- const original = await readFile8(entryPath, "utf-8");
4938
+ const entryPath = path12.join(tmp, "index.html");
4939
+ const original = await readFile9(entryPath, "utf-8");
4901
4940
  const { output, missing } = substituteVariables(original, values);
4902
4941
  if (missing.length > 0) {
4903
4942
  throw new Error(
@@ -5580,9 +5619,9 @@ var videoLipsyncNode = delegated({
5580
5619
  });
5581
5620
 
5582
5621
  // src/engine/nodes/remote/videoTranscribe.ts
5583
- import { mkdtemp as mkdtemp6, readFile as readFile9, rm as rm6 } from "fs/promises";
5622
+ import { mkdtemp as mkdtemp6, readFile as readFile10, rm as rm6 } from "fs/promises";
5584
5623
  import { tmpdir as tmpdir6 } from "os";
5585
- import path12 from "path";
5624
+ import path13 from "path";
5586
5625
  import { z as z31 } from "zod";
5587
5626
 
5588
5627
  // src/engine/nodes/local/lib/ffmpeg.ts
@@ -5714,14 +5753,14 @@ async function tryExtractAudio(inputs, ctx) {
5714
5753
  ctx.log("video_transcribe: no audio track detected, sending full video");
5715
5754
  return null;
5716
5755
  }
5717
- tmpDir = await mkdtemp6(path12.join(tmpdir6(), "vtx-"));
5718
- const audioPath = path12.join(tmpDir, "audio.mp3");
5756
+ tmpDir = await mkdtemp6(path13.join(tmpdir6(), "vtx-"));
5757
+ const audioPath = path13.join(tmpDir, "audio.mp3");
5719
5758
  ctx.log("video_transcribe: extracting audio (mono 16kHz mp3)");
5720
5759
  await runFfmpeg(
5721
5760
  ["-i", video.path, "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", "-f", "mp3", "-y", audioPath],
5722
5761
  { timeout_ms: AUDIO_EXTRACT_TIMEOUT_MS }
5723
5762
  );
5724
- const bytes = await readFile9(audioPath);
5763
+ const bytes = await readFile10(audioPath);
5725
5764
  if (bytes.byteLength === 0) {
5726
5765
  ctx.log("video_transcribe: extracted audio is empty, sending full video");
5727
5766
  return null;
@@ -5828,19 +5867,19 @@ function safeCost(def) {
5828
5867
 
5829
5868
  // src/engine/storage/cache-store.ts
5830
5869
  import { randomUUID as randomUUID2 } from "crypto";
5831
- import { mkdir as mkdir3, readFile as readFile10, rename as rename2, writeFile as writeFile7 } from "fs/promises";
5832
- import path13 from "path";
5870
+ import { mkdir as mkdir3, readFile as readFile11, rename as rename2, writeFile as writeFile7 } from "fs/promises";
5871
+ import path14 from "path";
5833
5872
  var LocalCacheStore = class {
5834
5873
  rootDir;
5835
5874
  constructor(rootDir) {
5836
5875
  this.rootDir = rootDir;
5837
5876
  }
5838
5877
  filePath(cacheKey) {
5839
- return path13.join(this.rootDir, `${cacheKey}.json`);
5878
+ return path14.join(this.rootDir, `${cacheKey}.json`);
5840
5879
  }
5841
5880
  async get(cacheKey) {
5842
5881
  try {
5843
- const buf = await readFile10(this.filePath(cacheKey), "utf8");
5882
+ const buf = await readFile11(this.filePath(cacheKey), "utf8");
5844
5883
  return JSON.parse(buf);
5845
5884
  } catch (e) {
5846
5885
  if (e.code === "ENOENT") return null;
@@ -5849,7 +5888,7 @@ var LocalCacheStore = class {
5849
5888
  }
5850
5889
  async put(entry) {
5851
5890
  const dest = this.filePath(entry.cacheKey);
5852
- await mkdir3(path13.dirname(dest), { recursive: true });
5891
+ await mkdir3(path14.dirname(dest), { recursive: true });
5853
5892
  const tmp = `${dest}.tmp-${process.pid}-${randomUUID2()}`;
5854
5893
  await writeFile7(tmp, JSON.stringify(entry, null, 0));
5855
5894
  await rename2(tmp, dest);
@@ -5903,14 +5942,14 @@ function defaultRegistry() {
5903
5942
  }
5904
5943
  function createEngineFromEnv(opts = {}) {
5905
5944
  const cwd = opts.cwd ?? process.cwd();
5906
- const cacheDir = opts.cacheDir ?? path14.join(cwd, "canvas", ".cache");
5907
- const outputsDir = opts.outputsDir ?? path14.join(cwd, "canvas");
5945
+ const cacheDir = opts.cacheDir ?? path15.join(cwd, "canvas", ".cache");
5946
+ const outputsDir = opts.outputsDir ?? path15.join(cwd, "canvas");
5908
5947
  const creds = requireCredentialsFromEnv();
5909
5948
  return new Engine({
5910
5949
  registry: defaultRegistry(),
5911
5950
  client: new BackendClient({ baseUrl: creds.url, apiKey: creds.apiKey }),
5912
- assets: new LocalAssetStore(path14.join(cacheDir, "assets")),
5913
- cache: new LocalCacheStore(path14.join(cacheDir, "index")),
5951
+ assets: new LocalAssetStore(path15.join(cacheDir, "assets")),
5952
+ cache: new LocalCacheStore(path15.join(cacheDir, "index")),
5914
5953
  outputsDir,
5915
5954
  log: opts.log
5916
5955
  });
@@ -5931,4 +5970,4 @@ export {
5931
5970
  defaultRegistry,
5932
5971
  createEngineFromEnv
5933
5972
  };
5934
- //# sourceMappingURL=chunk-CCO34ACK.js.map
5973
+ //# sourceMappingURL=chunk-NBNUNCY7.js.map