@koda-sl/baker-cli 0.389.0 → 0.390.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
 
@@ -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.
@@ -3657,13 +3657,13 @@ function spineInputFlags(work, lenS) {
3657
3657
  function spineInputOps(work, lenS, dims) {
3658
3658
  const fit = work.still?.fit ?? "fill";
3659
3659
  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`;
3660
+ const normalize2 = `${scale},format=yuv420p,fps=30,setsar=1,settb=AVTB`;
3661
3661
  if (work.trim) {
3662
3662
  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`;
3663
+ return `${normalize2},trim=${start}duration=${lenS.toFixed(3)},setpts=PTS-STARTPTS`;
3664
3664
  }
3665
- if (work.still) return `${normalize},trim=duration=${lenS.toFixed(3)}`;
3666
- return normalize;
3665
+ if (work.still) return `${normalize2},trim=duration=${lenS.toFixed(3)}`;
3666
+ return normalize2;
3667
3667
  }
3668
3668
  function parseSpineInputLens(args) {
3669
3669
  const lens = /* @__PURE__ */ new Map();
@@ -7382,10 +7382,10 @@ function applyDuck(params, labelFor, filterChains) {
7382
7382
  }
7383
7383
  const th = duck.threshold ?? 0.03;
7384
7384
  const ra = duck.ratio ?? 8;
7385
- const at = duck.attack ?? 5;
7385
+ const at2 = duck.attack ?? 5;
7386
7386
  const re = duck.release ?? 300;
7387
7387
  filterChains.push(
7388
- `[${labelFor[trackIdx]}]${keyOut}sidechaincompress=threshold=${th}:ratio=${ra}:attack=${at}:release=${re}[ducked]`
7388
+ `[${labelFor[trackIdx]}]${keyOut}sidechaincompress=threshold=${th}:ratio=${ra}:attack=${at2}:release=${re}[ducked]`
7389
7389
  );
7390
7390
  labelFor[trackIdx] = "ducked";
7391
7391
  }
@@ -9656,6 +9656,165 @@ ${detail.slice(-4e3)}`);
9656
9656
  }
9657
9657
  }
9658
9658
 
9659
+ // src/engine/lib/spell-number.ts
9660
+ import { toCardinal as nwAr } from "n2words/ar-SA";
9661
+ import { toCardinal as nwDe } from "n2words/de-DE";
9662
+ import { toCardinal as nwEn } from "n2words/en-US";
9663
+ import { toCardinal as nwEs } from "n2words/es-ES";
9664
+ import { toCardinal as nwFr } from "n2words/fr-FR";
9665
+ import { toCardinal as nwHi } from "n2words/hi-IN";
9666
+ import { toCardinal as nwIt } from "n2words/it-IT";
9667
+ import { toCardinal as nwJa } from "n2words/ja-JP";
9668
+ import { toCardinal as nwKo } from "n2words/ko-KR";
9669
+ import { toCardinal as nwNl } from "n2words/nl-NL";
9670
+ import { toCardinal as nwPl } from "n2words/pl-PL";
9671
+ import { toCardinal as nwPt } from "n2words/pt-PT";
9672
+ var INTEGER_SPELLERS = {
9673
+ fr: nwFr,
9674
+ es: nwEs,
9675
+ en: nwEn,
9676
+ de: nwDe,
9677
+ it: nwIt,
9678
+ pt: nwPt,
9679
+ nl: nwNl,
9680
+ pl: nwPl,
9681
+ ar: nwAr,
9682
+ ja: nwJa,
9683
+ ko: nwKo,
9684
+ hi: nwHi
9685
+ };
9686
+ function canSpellNumbers(langCode) {
9687
+ return langCode in INTEGER_SPELLERS;
9688
+ }
9689
+ function spellNumber(langCode, n) {
9690
+ const spell = langCode ? INTEGER_SPELLERS[langCode] : void 0;
9691
+ if (!spell || !Number.isFinite(n)) return String(n);
9692
+ try {
9693
+ return spell(n);
9694
+ } catch {
9695
+ return String(n);
9696
+ }
9697
+ }
9698
+
9699
+ // src/engine/nodes/local/lib/script-word-timing.ts
9700
+ var UNBOUNDED_WORD_S = 0.3;
9701
+ var EXACT = 3;
9702
+ var NEAR = 2;
9703
+ var SUBSTITUTE = -1;
9704
+ var SKIP = -2;
9705
+ var NEAR_SIMILARITY = 0.7;
9706
+ function normalize(word) {
9707
+ return word.normalize("NFD").replace(new RegExp("\\p{M}", "gu"), "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
9708
+ }
9709
+ function tokensOf(word, index, language) {
9710
+ const key = normalize(word);
9711
+ if (!key) return [];
9712
+ if (language && /^\d{1,9}$/.test(key)) {
9713
+ const spoken = spellNumber(language, Number.parseInt(key, 10));
9714
+ if (spoken !== key) {
9715
+ const parts = spoken.split(/[\s-]+/).map(normalize).filter(Boolean);
9716
+ if (parts.length > 0) return parts.map((part) => ({ key: part, word: index }));
9717
+ }
9718
+ }
9719
+ return [{ key, word: index }];
9720
+ }
9721
+ function similarity(a, b) {
9722
+ if (a === b) return 1;
9723
+ const longest = Math.max(a.length, b.length);
9724
+ if (longest === 0) return 1;
9725
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
9726
+ for (let i = 1; i <= a.length; i++) {
9727
+ const row = [i];
9728
+ for (let j = 1; j <= b.length; j++) {
9729
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
9730
+ row[j] = Math.min((prev[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
9731
+ }
9732
+ prev = row;
9733
+ }
9734
+ return 1 - (prev[b.length] ?? longest) / longest;
9735
+ }
9736
+ function pairScore(a, b) {
9737
+ if (a === b) return EXACT;
9738
+ return similarity(a, b) >= NEAR_SIMILARITY ? NEAR : SUBSTITUTE;
9739
+ }
9740
+ var at = (grid, i, j) => grid[i]?.[j] ?? 0;
9741
+ function diagonal(grid, script, transcript, i, j) {
9742
+ return at(grid, i - 1, j - 1) + pairScore(script[i - 1]?.key ?? "", transcript[j - 1]?.key ?? "");
9743
+ }
9744
+ function scoreGrid(script, transcript) {
9745
+ const grid = Array.from(
9746
+ { length: script.length + 1 },
9747
+ (_, i) => Array.from({ length: transcript.length + 1 }, (_2, j) => i === 0 ? j * SKIP : i * SKIP)
9748
+ );
9749
+ for (let i = 1; i <= script.length; i++) {
9750
+ const row = grid[i];
9751
+ for (let j = 1; j <= transcript.length; j++) {
9752
+ row[j] = Math.max(diagonal(grid, script, transcript, i, j), at(grid, i - 1, j) + SKIP, at(grid, i, j - 1) + SKIP);
9753
+ }
9754
+ }
9755
+ return grid;
9756
+ }
9757
+ function align(script, transcript) {
9758
+ const grid = scoreGrid(script, transcript);
9759
+ const pairs = new Array(script.length).fill(-1);
9760
+ let i = script.length;
9761
+ let j = transcript.length;
9762
+ while (i > 0 && j > 0) {
9763
+ const here = at(grid, i, j);
9764
+ if (here === diagonal(grid, script, transcript, i, j)) {
9765
+ pairs[i - 1] = j - 1;
9766
+ i--;
9767
+ j--;
9768
+ } else if (here === at(grid, i - 1, j) + SKIP) {
9769
+ i--;
9770
+ } else {
9771
+ j--;
9772
+ }
9773
+ }
9774
+ return pairs;
9775
+ }
9776
+ var round = (s) => Math.round(s * 1e3) / 1e3;
9777
+ function pairedSpans(wordCount, scriptTokens, heardTokens, pairs, transcript) {
9778
+ const spans = new Array(wordCount).fill(null);
9779
+ pairs.forEach((heardIndex, t) => {
9780
+ const token = scriptTokens[t];
9781
+ const said = heardIndex >= 0 ? transcript[heardTokens[heardIndex]?.word ?? -1] : void 0;
9782
+ if (!token || !said) return;
9783
+ const current = spans[token.word];
9784
+ spans[token.word] = current ? { start: Math.min(current.start, said.start), end: Math.max(current.end, said.end) } : { start: said.start, end: said.end };
9785
+ });
9786
+ return spans;
9787
+ }
9788
+ function fillRun(spans, first, end) {
9789
+ const before = first > 0 ? spans[first - 1] : null;
9790
+ const after = end < spans.length ? spans[end] : null;
9791
+ const count = end - first;
9792
+ const from = before ? before.end : Math.max(0, (after?.start ?? 0) - count * UNBOUNDED_WORD_S);
9793
+ const to = after ? Math.max(after.start, from) : from + count * UNBOUNDED_WORD_S;
9794
+ const each = (to - from) / count;
9795
+ for (let w = first; w < end; w++) spans[w] = { start: from + (w - first) * each, end: from + (w - first + 1) * each };
9796
+ }
9797
+ function fillDropped(spans) {
9798
+ const filled = [...spans];
9799
+ let i = 0;
9800
+ while (i < filled.length) {
9801
+ let end = i;
9802
+ while (end < filled.length && !filled[end]) end++;
9803
+ if (end > i) fillRun(filled, i, end);
9804
+ i = end + 1;
9805
+ }
9806
+ return filled.map((span) => span ?? { start: 0, end: 0 });
9807
+ }
9808
+ function timeScriptWords(script, transcript, language) {
9809
+ const words = script.split(/\s+/).filter(Boolean);
9810
+ if (words.length === 0 || transcript.length === 0) return [];
9811
+ const scriptTokens = words.flatMap((word, i) => tokensOf(word, i, language));
9812
+ const heardTokens = transcript.flatMap((w, i) => tokensOf(String(w.text ?? ""), i, language));
9813
+ const pairs = align(scriptTokens, heardTokens);
9814
+ const spans = fillDropped(pairedSpans(words.length, scriptTokens, heardTokens, pairs, transcript));
9815
+ return words.map((text, i) => ({ text, start: round(spans[i]?.start ?? 0), end: round(spans[i]?.end ?? 0) }));
9816
+ }
9817
+
9659
9818
  // src/engine/nodes/remote/videoTranscribe.ts
9660
9819
  var VideoTranscribeParams = z33.object({
9661
9820
  language: z33.string().min(2).max(8).optional(),
@@ -9665,6 +9824,9 @@ var VideoTranscribeParams = z33.object({
9665
9824
  transcriber: z33.enum(["groq", "deepgram"]).optional(),
9666
9825
  keyterms: z33.array(z33.string().min(2).max(60)).max(30).optional().describe(
9667
9826
  "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."
9827
+ ),
9828
+ script: z33.string().min(1).optional().describe(
9829
+ "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
9830
  )
9669
9831
  }).strict();
9670
9832
  var VideoTranscribeInputs = z33.object({
@@ -9695,16 +9857,40 @@ var videoTranscribeNode = defineNode({
9695
9857
  async execute({ inputs, params, ctx }) {
9696
9858
  const audioInput = await tryExtractAudio(inputs, ctx);
9697
9859
  const effectiveInputs = audioInput ?? inputs;
9698
- return await callBackendExec({
9860
+ const { script, ...recogniserParams } = params;
9861
+ const outputs = await callBackendExec({
9699
9862
  nodeType: "video_transcribe",
9700
9863
  nodeVersion: "2.4.0",
9701
- params,
9864
+ params: recogniserParams,
9702
9865
  inputs: effectiveInputs,
9703
9866
  outputKinds: { transcript: "json", rich: "json" },
9704
9867
  ctx
9705
9868
  });
9869
+ if (!script) return outputs;
9870
+ return { ...outputs, transcript: await scriptTranscript(outputs.transcript, script, params.language, ctx) };
9706
9871
  }
9707
9872
  });
9873
+ async function scriptTranscript(heardRef, script, language, ctx) {
9874
+ const local = heardRef.path ? heardRef : await ctx.assets.ingestRemote({
9875
+ kind: "json",
9876
+ url: String(heardRef.url),
9877
+ sha256: heardRef.sha256,
9878
+ mime: heardRef.mime
9879
+ });
9880
+ const heard = JSON.parse(await readFile13(String(local.path), "utf8"));
9881
+ const words = Array.isArray(heard) ? heard.filter(isTimedWord) : [];
9882
+ const timed = timeScriptWords(script, words, language);
9883
+ ctx.log(`video_transcribe: ${timed.length} script words timed off ${words.length} heard`);
9884
+ const bytes = Buffer.from(JSON.stringify(timed));
9885
+ const metadata = { ...heardRef.metadata, text_of_record: "script", heard_sha256: heardRef.sha256 };
9886
+ return uploadAndIngest({ bytes, kind: "json", mime: "application/json", metadata, ctx }).catch(
9887
+ () => ctx.assets.ingestBytes({ bytes, kind: "json", mime: "application/json", metadata })
9888
+ );
9889
+ }
9890
+ function isTimedWord(value) {
9891
+ const w = value;
9892
+ return Boolean(w) && typeof w?.start === "number" && typeof w?.end === "number" && w?.text !== void 0;
9893
+ }
9708
9894
  async function tryExtractAudio(inputs, ctx) {
9709
9895
  const video = inputs.video;
9710
9896
  if (!video.path || !video.mime?.startsWith("video/")) return null;
@@ -10084,6 +10270,8 @@ export {
10084
10270
  coercePathUrlToUrl,
10085
10271
  toModelSafeImage,
10086
10272
  loadCompositionMeta,
10273
+ canSpellNumbers,
10274
+ spellNumber,
10087
10275
  catalogView,
10088
10276
  BackendClient2,
10089
10277
  Engine2 as Engine,
@@ -10095,4 +10283,4 @@ export {
10095
10283
  defaultRegistry,
10096
10284
  createEngineFromEnv
10097
10285
  };
10098
- //# sourceMappingURL=chunk-IOWWKZRF.js.map
10286
+ //# sourceMappingURL=chunk-6UE43HEQ.js.map