@koda-sl/baker-cli 0.389.0 → 0.391.0

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
@@ -4067,7 +4067,7 @@ baker canvas set-prompt my-canvas.json s0_start --text-file ./hook-frame.txt
4067
4067
  `baker canvas scaffold-ad` renders everything with a known, fixed appearance locally,
4068
4068
  because a video model garbles a wordmark every time:
4069
4069
 
4070
- - **`brand.logo`** — the repo path to the mark. It is copied into the overlay composition
4070
+ - **`brand.logo`** — the repo path to the mark. It is copied into the caption composition
4071
4071
  and drawn as a real `<img>`: small top-left for the whole ad, large on the closing card.
4072
4072
  - **`end_card`** — on by default whenever the spec carries a brand. The last beat's PICTURE
4073
4073
  becomes a flat brand colour plate (rendered by ffmpeg, not generated), with the mark and
@@ -4087,9 +4087,24 @@ because a video model garbles a wordmark every time:
4087
4087
  alongside the music bed. Most beats should not have one: it belongs where the picture
4088
4088
  shows a specific physical event (a drill, a door, rain on glass), not on a talking head
4089
4089
  or a calm lifestyle shot. A bed plus two well-placed effects beats eight.
4090
- - **A real brand mark is required.** `scaffold-ad` refuses to build without one in
4091
- `src/brand/logos/` (SVG or PNG): the mark is drawn on every frame and on the closing
4092
- card, so a stand-in ships an ad wearing the wrong brand.
4090
+ - **The logo is optional.** With no mark in `src/brand/logos/` (and none confirmed for the
4091
+ company's own domain), the ad is built without the corner mark, and one hint says so and
4092
+ how to add one: put the client's logo file in `src/brand/logos/` and re-run. Meta shows
4093
+ the page name and picture above every ad either way. The closing card still carries the
4094
+ call to action when the spec has a `cta`; with neither a mark nor a `cta`, the last beat
4095
+ keeps its footage. A typed stand-in (an SVG wordmark in live `<text>`) is still refused,
4096
+ and `--no-brand` still means no brand layer at all: no mark and no closing card.
4097
+ - **Captions and the mark sit inside the safe area.** On 9:16 the caption cards end above
4098
+ the bottom 37% of the frame (Reels covers the bottom 35%) and the mark starts below the
4099
+ top 16% (the Stories and Reels header); 4:5 and 1:1 use modest feed margins. The closing
4100
+ card is centred in the same area. Every composition is sized to the spec's
4101
+ `format.aspect_ratio`, so a 4:5 ad lays its captions out on a 4:5 frame.
4102
+ - **Captions and the closing button are set in the brand's face**: a font file in
4103
+ `src/brand/fonts/`, else the typeface `tokens.json` or `BRAND.md` names, loaded as a web
4104
+ font. With neither, they keep the default heavy sans.
4105
+ - **A spoken ad's captions print the script.** The voice track is transcribed only to time
4106
+ the words: each caption word is the `say` line as written, at the moment it was said, so
4107
+ a word the recogniser mishears never reaches the screen.
4093
4108
  - **An avatar with no pinned voice cannot speak on camera.** Their lines would be read by
4094
4109
  a separately cast voice, which is dubbing and looks like it.
4095
4110
  - **`voiceover: false`** — a music-led ad. No voice, no transcription: the `say` lines
@@ -5075,6 +5090,7 @@ Word-level transcription of a video OR a bare audio track (the `video` input acc
5075
5090
  | `language` | string | no | auto | ISO 639-1, 2–8 chars (e.g. `en`, `es`) |
5076
5091
  | `transcriber` | enum | no | `groq` | `groq` (Whisper) \| `deepgram` (Nova-3, adds the `rich` output) |
5077
5092
  | `keyterms` | string[] | no | — | Up to 30 names the recogniser must not mangle — a product, a person, a phrase the script coins. The **company's own name is always applied server-side**, so it never needs listing here |
5093
+ | `script` | string | no | — | The words the track was asked to say. Given, the `transcript` output is the script's words, spelled as written, at the times the recogniser heard them: aligned locally (misheard words, fillers, accents and numbers said as words are all tolerated), never sent to the recogniser. `scaffold-ad` sets it on every spoken ad |
5078
5094
 
5079
5095
  **Outputs**
5080
5096
 
@@ -5632,7 +5648,7 @@ It then scaffolds a canvas that draws **only what `prompt.json` holds**; no elem
5632
5648
 
5633
5649
  **Placement formats.** The canvas delivers the platform's full placement set, each format at its exact size, collected under `final` with the format as its label. The set is derived from `--platform` (`meta` → 4:5 + 9:16, `google` → 1:1 + 4:5 + 16:9, `linkedin` → 1:1, `tiktok` → 9:16, `youtube` → 16:9); override it with `--placements <preset>` or `--placements none` for a single format. The report's `aspect_ratio` is the ratio the plates really render at (the model's nearest to the lead format), and `format_plan` says how each format is made.
5634
5650
 
5635
- **After a run** the CLI reads the finished ad back on what makes a static win, not a checklist of elements: every delivered image's pixel shape against its format, and one vision pass that reads each word against the copy `prompt.json` holds and checks one dominant idea, clipping, artifacts, the brand with the logo covered and the concept's mechanisms, with every format shown a second time at phone-feed thumbnail size (240px wide, same call): a lead message unreadable at that size is blocking. A missing logo, CTA or copy is never a finding; a logo that is drawn and broken is. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`), `judge` (the per-plate scores) and hints that end with what the run made; the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed.
5651
+ **After a run** the CLI reads the finished ad back on what makes a static win, not a checklist of elements: every delivered image's pixel shape against its format, and one vision pass that reads each word against the copy `prompt.json` holds and checks one dominant idea, clipping, artifacts, the brand with the logo covered and the concept's mechanisms, with every format shown a second time at phone-feed thumbnail size (240px wide, same call): a lead message unreadable at that size is blocking. A missing logo, CTA or copy is never a finding; a logo that is drawn and broken is. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`), `judge` (the per-plate scores) and hints that end with what the run made; the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed; its hints name the cheapest fix (overlapping or unreadable copy is fixed with a smaller `size`, a `highlight` or `sticker` treatment or shorter words, which re-draw the formats from the cached plates). **Render budget:** a static ad gets 3 renders per creative in a chat before the person reacts; the fourth exits 2 with `RENDER_BUDGET` without rendering, and `baker creatives feedback record` or `baker creatives select` on that creative opens a new round. Resuming an interrupted run does not count again, and a run that died only on an outage (timeout, network, a provider that says retry) gives its render back.
5636
5652
 
5637
5653
  The logo is never a slot to fill: use the company's committed logo in `src/brand/logos/` (per `BRAND.md`), never `baker images logo <domain>`, whose Brandfetch CDN often serves an outdated mark.
5638
5654
 
@@ -6158,27 +6174,31 @@ import {
6158
6174
 
6159
6175
  ## Creatives
6160
6176
 
6161
- Publish an approved canvas render as a first-class Baker creative. The image uploads to the Baker image library (tagged `creative`), a creative record is created/updated, and the command prints the creative reference JSON the dashboard renders in chat.
6177
+ Publish an approved canvas render as a first-class Baker creative: a static image (PNG, JPG, WebP) or a finished video (MP4, MOV, WebM). An image uploads to the Baker image library (tagged `creative`); a video uploads to the Baker video library and is saved once it plays (bounded at 3 minutes). A creative record is created/updated, and the command prints the creative reference JSON the dashboard renders in chat.
6162
6178
 
6163
6179
  ```bash
6164
6180
  baker creatives publish ./canvas/<run_id>/<final>.png --title "Spring Offer 4x5" \
6165
6181
  --slug spring-offer-4x5 --run-id r_01JXYZ... \
6166
6182
  --source-reference-url "https://www.facebook.com/ads/library/?id=..."
6183
+ baker creatives publish ./canvas/<run_id>/final.mp4 --title "Spring Offer Reel" \
6184
+ --slug spring-offer-reel --run-id r_01JXYZ...
6167
6185
  ```
6168
6186
 
6169
6187
  | Flag | Effect |
6170
6188
  |---|---|
6171
6189
  | `--title <text>` | Required. Human title for the creative. |
6172
- | `--slug <slug>` | Creative slug (`src/creatives/<slug>/`) — attaches the image to that creative's row, marks it `published`. |
6190
+ | `--slug <slug>` | Creative slug (`src/creatives/<slug>/`): attaches the image or video to that creative's row, marks it `published`. Required for a video. |
6173
6191
  | `--run-id <r_…>` | Pins the approved generation from the creative's run history as the published one. |
6174
6192
  | `--source-reference-url <url>` | Original reference ad URL, recorded on the creative. |
6175
- | `--context <text>` | Optional describe context for the uploaded image asset. |
6193
+ | `--context <text>` | Optional describe context for the uploaded image or video. |
6176
6194
 
6177
- Without `--slug` the command behaves as before (one creative record per published image). With `--slug` it upserts the repo-convention row — the same one the dashboard's Creatives tab and the `src/creatives/{slug}/` folder describe — so publish, repo sync, and run history all land on a single record regardless of order.
6195
+ An image prints `{ ok, data: { creativeId, imageId, reference } }`. A video prints `{ ok, data: { creativeId, videoId, reference }, hints }`, where `reference.asset.kind` is `video` (with `videoId`, `muxPlaybackId`, the poster as `thumbnailUrl`, size and duration) and `hints` names the next step to put it in a Meta ad: `baker ads meta media upload --kind video --video-id <videoId>`.
6196
+
6197
+ Without `--slug` an image behaves as before (one creative record per published image). With `--slug` it upserts the repo-convention row — the same one the dashboard's Creatives tab and the `src/creatives/{slug}/` folder describe — so publish, repo sync, and run history all land on a single record regardless of order.
6178
6198
 
6179
6199
  ### `baker creatives performance`
6180
6200
 
6181
- How each published creative is doing in the live Meta ads that show it. A creative is linked to an ad when a chat's publish uploads its image to Meta and creates an ad with it (or swaps an existing ad onto it); the results are read live from Meta.
6201
+ How each published creative is doing in the live Meta ads that show it. A creative is linked to an ad when a chat's publish uploads its image or video to Meta and creates an ad with it (or swaps an existing ad onto it); the results are read live from Meta.
6182
6202
 
6183
6203
  ```bash
6184
6204
  baker creatives performance # every creative in an ad, last 30 days
@@ -6190,9 +6210,9 @@ baker creatives performance --days 14 --full # + ids, last-7-day figures, on
6190
6210
  |---|---|
6191
6211
  | `--slug <slug>` | Only this creative. |
6192
6212
  | `--days <n>` | Window ending today, 1-90 (default 30). |
6193
- | `--full` | Adds creative and image ids, result type, days live, last-7-day CTR and cost per result, and one row per ad. |
6213
+ | `--full` | Adds creative, image and video ids, result type, days live, last-7-day CTR and cost per result, a video's watch funnel (`video`: 3-second views, ThruPlays, 25/50/75/100% watched), and one row per ad. |
6194
6214
 
6195
- Each creative reports `spend`, `impressions`, `ctr` (percent), `cpc`, `results`, `costPerResult`, `frequency`, a `fatigue` verdict (`fresh`, `fading`, `fatigued`, `dead`, `too_early`, `no_delivery`) with its reason, and `definition`: the angle, hook, mechanism, dominantSlot, persona, awareness, hypothesis and reference fields read from the local `src/creatives/<slug>/_definition.md`. `hints` name worn-out creatives, the angle, mechanism and dominant slot with the lowest cost per result (each ranked once two or more values have results), and unreadable ad accounts.
6215
+ Each creative reports `spend`, `impressions`, `ctr` (percent), `cpc`, `results`, `costPerResult`, `frequency`, a `fatigue` verdict (`fresh`, `fading`, `fatigued`, `dead`, `too_early`, `no_delivery`) with its reason, and `definition`: the angle, hook, mechanism, dominantSlot, persona, awareness, hypothesis and reference fields read from the local `src/creatives/<slug>/_definition.md`. A video creative also reports `hookRate` (percent of impressions that watched 3 seconds) and `holdRate` (percent of those that reached a ThruPlay); a static one carries neither. `hints` name worn-out creatives, videos whose hook rate is under 25% (the first 3 seconds need new hooks) or whose hold rate is under 30% behind a working hook (the body loses people), the angle, mechanism and dominant slot with the lowest cost per result (each ranked once two or more values have results), and unreadable ad accounts.
6196
6216
 
6197
6217
  ### `baker creatives feedback [--slug <slug>] [--full]`
6198
6218
 
@@ -6221,7 +6241,7 @@ baker creatives feedback record --slug spring-offer --run r_01K… --option 2 --
6221
6241
  | `--run <r_…>` | Required. The version. |
6222
6242
  | `--verdict approved\|rejected` | Required. |
6223
6243
  | `--option <o>` | One option of a version that made several: its label, its number (`2`) or its slot. |
6224
- | `--reasons <a,b>` | Comma-separated: `text_mistakes`, `logo_or_brand_wrong`, `weak_hook`, `wrong_message`, `looks_fake`, `not_our_style`, `other`. A rejection needs reasons or a note. |
6244
+ | `--reasons <a,b>` | Comma-separated: `text_mistakes`, `logo_or_brand_wrong`, `weak_hook`, `wrong_message`, `looks_fake`, `not_our_style`, `voice_or_sound`, `pacing`, `other`. The two video reasons: `voice_or_sound` (the voiceover, music or mix) and `pacing` (too slow, too fast, the hook lands late). A rejection needs reasons or a note. |
6225
6245
  | `--note <text>` | The person's own words (stored up to 1000 characters). |
6226
6246
 
6227
6247
  After a rejection, `hints[]` restates what to change, starting from that version.
@@ -1023,6 +1023,10 @@ function runFailureCode(e) {
1023
1023
  const reasons = e instanceof LayerExecutionError ? e.failures.map((f) => f.reason) : [e];
1024
1024
  return reasons.length > 0 && reasons.every(isInputRejection) ? "input_invalid" : "runtime";
1025
1025
  }
1026
+ function failedOnlyOnTransientErrors(e) {
1027
+ const reasons = e instanceof LayerExecutionError ? e.failures.map((f) => f.reason) : [e];
1028
+ return reasons.length > 0 && reasons.every((r) => r instanceof NodeExecutionError && failureDetail(r).retryable === true);
1029
+ }
1026
1030
  function failureDetail(reason) {
1027
1031
  if (reason instanceof NodeExecutionError) {
1028
1032
  const c = reason.cause;
@@ -3657,13 +3661,13 @@ function spineInputFlags(work, lenS) {
3657
3661
  function spineInputOps(work, lenS, dims) {
3658
3662
  const fit = work.still?.fit ?? "fill";
3659
3663
  const scale = fit === "pad" ? `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=decrease,pad=${dims.w}:${dims.h}:(ow-iw)/2:(oh-ih)/2:color=black` : `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h}`;
3660
- const normalize = `${scale},format=yuv420p,fps=30,setsar=1,settb=AVTB`;
3664
+ const normalize2 = `${scale},format=yuv420p,fps=30,setsar=1,settb=AVTB`;
3661
3665
  if (work.trim) {
3662
3666
  const start = work.trim.offset_s > 0 ? `start=${work.trim.offset_s.toFixed(3)}:` : "";
3663
- return `${normalize},trim=${start}duration=${lenS.toFixed(3)},setpts=PTS-STARTPTS`;
3667
+ return `${normalize2},trim=${start}duration=${lenS.toFixed(3)},setpts=PTS-STARTPTS`;
3664
3668
  }
3665
- if (work.still) return `${normalize},trim=duration=${lenS.toFixed(3)}`;
3666
- return normalize;
3669
+ if (work.still) return `${normalize2},trim=duration=${lenS.toFixed(3)}`;
3670
+ return normalize2;
3667
3671
  }
3668
3672
  function parseSpineInputLens(args) {
3669
3673
  const lens = /* @__PURE__ */ new Map();
@@ -7382,10 +7386,10 @@ function applyDuck(params, labelFor, filterChains) {
7382
7386
  }
7383
7387
  const th = duck.threshold ?? 0.03;
7384
7388
  const ra = duck.ratio ?? 8;
7385
- const at = duck.attack ?? 5;
7389
+ const at2 = duck.attack ?? 5;
7386
7390
  const re = duck.release ?? 300;
7387
7391
  filterChains.push(
7388
- `[${labelFor[trackIdx]}]${keyOut}sidechaincompress=threshold=${th}:ratio=${ra}:attack=${at}:release=${re}[ducked]`
7392
+ `[${labelFor[trackIdx]}]${keyOut}sidechaincompress=threshold=${th}:ratio=${ra}:attack=${at2}:release=${re}[ducked]`
7389
7393
  );
7390
7394
  labelFor[trackIdx] = "ducked";
7391
7395
  }
@@ -8069,6 +8073,9 @@ var FINDING_ADVICE = {
8069
8073
  console_error: "the composition threw in the browser, so whatever that script was driving did not happen.",
8070
8074
  page_error: "a script failed to evaluate \u2014 animations and any JS-driven layout in this composition did not run.",
8071
8075
  http_error: "an asset 404ed at render time; it will be missing from the frame, not substituted.",
8076
+ // The most common reason a static ad failed its render in production (Sept 2026), and the
8077
+ // agent answered it by re-painting all three pictures when only the composition was wrong.
8078
+ content_overlap: "two lines of copy collide. In a static ad, make one smaller (`size` in its layout region's `text` in prompt.json) or shorten it: the formats re-draw from the cached pictures. Moving a layout region instead re-paints every picture.",
8072
8079
  console_warning: "if this names a GSAP target, the element it animates does not exist, so that tween is a no-op and whatever it was meant to reveal never appears. Fix the selector or add the element before rendering."
8073
8080
  };
8074
8081
  function explainGateFinding(finding) {
@@ -9656,6 +9663,165 @@ ${detail.slice(-4e3)}`);
9656
9663
  }
9657
9664
  }
9658
9665
 
9666
+ // src/engine/lib/spell-number.ts
9667
+ import { toCardinal as nwAr } from "n2words/ar-SA";
9668
+ import { toCardinal as nwDe } from "n2words/de-DE";
9669
+ import { toCardinal as nwEn } from "n2words/en-US";
9670
+ import { toCardinal as nwEs } from "n2words/es-ES";
9671
+ import { toCardinal as nwFr } from "n2words/fr-FR";
9672
+ import { toCardinal as nwHi } from "n2words/hi-IN";
9673
+ import { toCardinal as nwIt } from "n2words/it-IT";
9674
+ import { toCardinal as nwJa } from "n2words/ja-JP";
9675
+ import { toCardinal as nwKo } from "n2words/ko-KR";
9676
+ import { toCardinal as nwNl } from "n2words/nl-NL";
9677
+ import { toCardinal as nwPl } from "n2words/pl-PL";
9678
+ import { toCardinal as nwPt } from "n2words/pt-PT";
9679
+ var INTEGER_SPELLERS = {
9680
+ fr: nwFr,
9681
+ es: nwEs,
9682
+ en: nwEn,
9683
+ de: nwDe,
9684
+ it: nwIt,
9685
+ pt: nwPt,
9686
+ nl: nwNl,
9687
+ pl: nwPl,
9688
+ ar: nwAr,
9689
+ ja: nwJa,
9690
+ ko: nwKo,
9691
+ hi: nwHi
9692
+ };
9693
+ function canSpellNumbers(langCode) {
9694
+ return langCode in INTEGER_SPELLERS;
9695
+ }
9696
+ function spellNumber(langCode, n) {
9697
+ const spell = langCode ? INTEGER_SPELLERS[langCode] : void 0;
9698
+ if (!spell || !Number.isFinite(n)) return String(n);
9699
+ try {
9700
+ return spell(n);
9701
+ } catch {
9702
+ return String(n);
9703
+ }
9704
+ }
9705
+
9706
+ // src/engine/nodes/local/lib/script-word-timing.ts
9707
+ var UNBOUNDED_WORD_S = 0.3;
9708
+ var EXACT = 3;
9709
+ var NEAR = 2;
9710
+ var SUBSTITUTE = -1;
9711
+ var SKIP = -2;
9712
+ var NEAR_SIMILARITY = 0.7;
9713
+ function normalize(word) {
9714
+ return word.normalize("NFD").replace(new RegExp("\\p{M}", "gu"), "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
9715
+ }
9716
+ function tokensOf(word, index, language) {
9717
+ const key = normalize(word);
9718
+ if (!key) return [];
9719
+ if (language && /^\d{1,9}$/.test(key)) {
9720
+ const spoken = spellNumber(language, Number.parseInt(key, 10));
9721
+ if (spoken !== key) {
9722
+ const parts = spoken.split(/[\s-]+/).map(normalize).filter(Boolean);
9723
+ if (parts.length > 0) return parts.map((part) => ({ key: part, word: index }));
9724
+ }
9725
+ }
9726
+ return [{ key, word: index }];
9727
+ }
9728
+ function similarity(a, b) {
9729
+ if (a === b) return 1;
9730
+ const longest = Math.max(a.length, b.length);
9731
+ if (longest === 0) return 1;
9732
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
9733
+ for (let i = 1; i <= a.length; i++) {
9734
+ const row = [i];
9735
+ for (let j = 1; j <= b.length; j++) {
9736
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
9737
+ row[j] = Math.min((prev[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
9738
+ }
9739
+ prev = row;
9740
+ }
9741
+ return 1 - (prev[b.length] ?? longest) / longest;
9742
+ }
9743
+ function pairScore(a, b) {
9744
+ if (a === b) return EXACT;
9745
+ return similarity(a, b) >= NEAR_SIMILARITY ? NEAR : SUBSTITUTE;
9746
+ }
9747
+ var at = (grid, i, j) => grid[i]?.[j] ?? 0;
9748
+ function diagonal(grid, script, transcript, i, j) {
9749
+ return at(grid, i - 1, j - 1) + pairScore(script[i - 1]?.key ?? "", transcript[j - 1]?.key ?? "");
9750
+ }
9751
+ function scoreGrid(script, transcript) {
9752
+ const grid = Array.from(
9753
+ { length: script.length + 1 },
9754
+ (_, i) => Array.from({ length: transcript.length + 1 }, (_2, j) => i === 0 ? j * SKIP : i * SKIP)
9755
+ );
9756
+ for (let i = 1; i <= script.length; i++) {
9757
+ const row = grid[i];
9758
+ for (let j = 1; j <= transcript.length; j++) {
9759
+ row[j] = Math.max(diagonal(grid, script, transcript, i, j), at(grid, i - 1, j) + SKIP, at(grid, i, j - 1) + SKIP);
9760
+ }
9761
+ }
9762
+ return grid;
9763
+ }
9764
+ function align(script, transcript) {
9765
+ const grid = scoreGrid(script, transcript);
9766
+ const pairs = new Array(script.length).fill(-1);
9767
+ let i = script.length;
9768
+ let j = transcript.length;
9769
+ while (i > 0 && j > 0) {
9770
+ const here = at(grid, i, j);
9771
+ if (here === diagonal(grid, script, transcript, i, j)) {
9772
+ pairs[i - 1] = j - 1;
9773
+ i--;
9774
+ j--;
9775
+ } else if (here === at(grid, i - 1, j) + SKIP) {
9776
+ i--;
9777
+ } else {
9778
+ j--;
9779
+ }
9780
+ }
9781
+ return pairs;
9782
+ }
9783
+ var round = (s) => Math.round(s * 1e3) / 1e3;
9784
+ function pairedSpans(wordCount, scriptTokens, heardTokens, pairs, transcript) {
9785
+ const spans = new Array(wordCount).fill(null);
9786
+ pairs.forEach((heardIndex, t) => {
9787
+ const token = scriptTokens[t];
9788
+ const said = heardIndex >= 0 ? transcript[heardTokens[heardIndex]?.word ?? -1] : void 0;
9789
+ if (!token || !said) return;
9790
+ const current = spans[token.word];
9791
+ spans[token.word] = current ? { start: Math.min(current.start, said.start), end: Math.max(current.end, said.end) } : { start: said.start, end: said.end };
9792
+ });
9793
+ return spans;
9794
+ }
9795
+ function fillRun(spans, first, end) {
9796
+ const before = first > 0 ? spans[first - 1] : null;
9797
+ const after = end < spans.length ? spans[end] : null;
9798
+ const count = end - first;
9799
+ const from = before ? before.end : Math.max(0, (after?.start ?? 0) - count * UNBOUNDED_WORD_S);
9800
+ const to = after ? Math.max(after.start, from) : from + count * UNBOUNDED_WORD_S;
9801
+ const each = (to - from) / count;
9802
+ for (let w = first; w < end; w++) spans[w] = { start: from + (w - first) * each, end: from + (w - first + 1) * each };
9803
+ }
9804
+ function fillDropped(spans) {
9805
+ const filled = [...spans];
9806
+ let i = 0;
9807
+ while (i < filled.length) {
9808
+ let end = i;
9809
+ while (end < filled.length && !filled[end]) end++;
9810
+ if (end > i) fillRun(filled, i, end);
9811
+ i = end + 1;
9812
+ }
9813
+ return filled.map((span) => span ?? { start: 0, end: 0 });
9814
+ }
9815
+ function timeScriptWords(script, transcript, language) {
9816
+ const words = script.split(/\s+/).filter(Boolean);
9817
+ if (words.length === 0 || transcript.length === 0) return [];
9818
+ const scriptTokens = words.flatMap((word, i) => tokensOf(word, i, language));
9819
+ const heardTokens = transcript.flatMap((w, i) => tokensOf(String(w.text ?? ""), i, language));
9820
+ const pairs = align(scriptTokens, heardTokens);
9821
+ const spans = fillDropped(pairedSpans(words.length, scriptTokens, heardTokens, pairs, transcript));
9822
+ return words.map((text, i) => ({ text, start: round(spans[i]?.start ?? 0), end: round(spans[i]?.end ?? 0) }));
9823
+ }
9824
+
9659
9825
  // src/engine/nodes/remote/videoTranscribe.ts
9660
9826
  var VideoTranscribeParams = z33.object({
9661
9827
  language: z33.string().min(2).max(8).optional(),
@@ -9665,6 +9831,9 @@ var VideoTranscribeParams = z33.object({
9665
9831
  transcriber: z33.enum(["groq", "deepgram"]).optional(),
9666
9832
  keyterms: z33.array(z33.string().min(2).max(60)).max(30).optional().describe(
9667
9833
  "Names the recogniser must not mangle \u2014 a product, a person, a phrase the script coins. The COMPANY'S OWN NAME is always applied server-side, so you never list it: this is for the rest. Every term is biased into the request AND its spelling enforced on the way out, because a caption prints a mishearing at full size over the ad (a real cut shipped with the brand name spelled wrong). Never patch a misspelling inside a caption composition \u2014 that fix dies with that one creative."
9834
+ ),
9835
+ script: z33.string().min(1).optional().describe(
9836
+ "The words the track was asked to say, when there is a script: the transcript's words are then the script's, spelled as written, at the times the recogniser heard them. A misheard word stops being a typo on screen, and a word the recogniser added (a filler) is never printed. Aligned locally, never sent."
9668
9837
  )
9669
9838
  }).strict();
9670
9839
  var VideoTranscribeInputs = z33.object({
@@ -9695,16 +9864,40 @@ var videoTranscribeNode = defineNode({
9695
9864
  async execute({ inputs, params, ctx }) {
9696
9865
  const audioInput = await tryExtractAudio(inputs, ctx);
9697
9866
  const effectiveInputs = audioInput ?? inputs;
9698
- return await callBackendExec({
9867
+ const { script, ...recogniserParams } = params;
9868
+ const outputs = await callBackendExec({
9699
9869
  nodeType: "video_transcribe",
9700
9870
  nodeVersion: "2.4.0",
9701
- params,
9871
+ params: recogniserParams,
9702
9872
  inputs: effectiveInputs,
9703
9873
  outputKinds: { transcript: "json", rich: "json" },
9704
9874
  ctx
9705
9875
  });
9876
+ if (!script) return outputs;
9877
+ return { ...outputs, transcript: await scriptTranscript(outputs.transcript, script, params.language, ctx) };
9706
9878
  }
9707
9879
  });
9880
+ async function scriptTranscript(heardRef, script, language, ctx) {
9881
+ const local = heardRef.path ? heardRef : await ctx.assets.ingestRemote({
9882
+ kind: "json",
9883
+ url: String(heardRef.url),
9884
+ sha256: heardRef.sha256,
9885
+ mime: heardRef.mime
9886
+ });
9887
+ const heard = JSON.parse(await readFile13(String(local.path), "utf8"));
9888
+ const words = Array.isArray(heard) ? heard.filter(isTimedWord) : [];
9889
+ const timed = timeScriptWords(script, words, language);
9890
+ ctx.log(`video_transcribe: ${timed.length} script words timed off ${words.length} heard`);
9891
+ const bytes = Buffer.from(JSON.stringify(timed));
9892
+ const metadata = { ...heardRef.metadata, text_of_record: "script", heard_sha256: heardRef.sha256 };
9893
+ return uploadAndIngest({ bytes, kind: "json", mime: "application/json", metadata, ctx }).catch(
9894
+ () => ctx.assets.ingestBytes({ bytes, kind: "json", mime: "application/json", metadata })
9895
+ );
9896
+ }
9897
+ function isTimedWord(value) {
9898
+ const w = value;
9899
+ return Boolean(w) && typeof w?.start === "number" && typeof w?.end === "number" && w?.text !== void 0;
9900
+ }
9708
9901
  async function tryExtractAudio(inputs, ctx) {
9709
9902
  const video = inputs.video;
9710
9903
  if (!video.path || !video.mime?.startsWith("video/")) return null;
@@ -10025,6 +10218,7 @@ export {
10025
10218
  LayerExecutionError,
10026
10219
  describeFailureReason,
10027
10220
  runFailureCode,
10221
+ failedOnlyOnTransientErrors,
10028
10222
  AD_FORMAT_PLATFORMS,
10029
10223
  platformFormats,
10030
10224
  resolveAdaptFormats,
@@ -10084,6 +10278,8 @@ export {
10084
10278
  coercePathUrlToUrl,
10085
10279
  toModelSafeImage,
10086
10280
  loadCompositionMeta,
10281
+ canSpellNumbers,
10282
+ spellNumber,
10087
10283
  catalogView,
10088
10284
  BackendClient2,
10089
10285
  Engine2 as Engine,
@@ -10095,4 +10291,4 @@ export {
10095
10291
  defaultRegistry,
10096
10292
  createEngineFromEnv
10097
10293
  };
10098
- //# sourceMappingURL=chunk-IOWWKZRF.js.map
10294
+ //# sourceMappingURL=chunk-SZKTVOR7.js.map