@jokerized/decksmith 0.4.1 → 0.5.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/dist/cli.js CHANGED
@@ -1,16 +1,181 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { cp, mkdir as mkdir10, mkdtemp as mkdtemp4, readdir as readdir4, readFile as readFile17, rm as rm9, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
5
- import { createRequire as createRequire4 } from "node:module";
4
+ import { cp as cp2, mkdir as mkdir11, mkdtemp as mkdtemp4, readFile as readFile18, rm as rm9, stat as stat3, writeFile as writeFile13 } from "node:fs/promises";
5
+ import { createRequire as createRequire5 } from "node:module";
6
6
  import { tmpdir as tmpdir5 } from "node:os";
7
- import { dirname as dirname5, join as join17, relative as relative2, resolve as resolve6 } from "node:path";
8
- import { fileURLToPath } from "node:url";
7
+ import { dirname as dirname6, join as join18, relative as relative2, resolve as resolve7 } from "node:path";
8
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
9
9
  import { Command } from "commander";
10
10
 
11
- // src/pack/media.ts
11
+ // src/build/files.ts
12
+ import { cp, mkdir as mkdir2, readdir, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
13
+ import { createRequire } from "node:module";
14
+ import { dirname, join as join2, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ // src/source/fonts.ts
12
18
  import { createHash } from "node:crypto";
13
- import { readFile } from "node:fs/promises";
19
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ function familyFor(lang) {
22
+ const tag = lang.toLowerCase();
23
+ if (tag.startsWith("ko")) return "Noto Sans KR";
24
+ if (tag.startsWith("ja")) return "Noto Sans JP";
25
+ if (tag.startsWith("zh")) return /hant|-tw|-hk|-mo/.test(tag) ? "Noto Sans TC" : "Noto Sans SC";
26
+ return null;
27
+ }
28
+ var UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
29
+ async function bundleFont(lang, glyphs2, dir) {
30
+ const family = familyFor(lang);
31
+ if (!family) return null;
32
+ const text2 = [...new Set(glyphs2)].filter((c) => c > " ").sort().join("");
33
+ const stamp = `/* decksmith ${createHash("sha256").update(`${family}
34
+ ${text2}`).digest("hex").slice(0, 16)} */`;
35
+ await mkdir(dir, { recursive: true });
36
+ const cssPath = join(dir, "fonts.css");
37
+ const cached2 = await readFile(cssPath, "utf8").catch(() => "");
38
+ if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
39
+ const res = await fetch(
40
+ `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
41
+ { headers: { "User-Agent": UA } }
42
+ );
43
+ if (!res.ok) throw new Error(`google fonts: HTTP ${res.status}`);
44
+ let css = await res.text();
45
+ const remote = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1] ?? ""))];
46
+ const slug = family.toLowerCase().replace(/[^a-z0-9]/g, "");
47
+ const files = [];
48
+ for (const [i, url2] of remote.entries()) {
49
+ const font = await fetch(url2);
50
+ if (!font.ok) throw new Error(`${url2}: HTTP ${font.status}`);
51
+ const name = `${slug}-${i}.woff2`;
52
+ await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
53
+ css = css.replaceAll(url2, name);
54
+ files.push(name);
55
+ }
56
+ css = `${stamp}
57
+ ${css}`;
58
+ await writeFile(cssPath, css);
59
+ return { family, css, files };
60
+ }
61
+ function localNames(css) {
62
+ return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
63
+ }
64
+
65
+ // src/build/files.ts
66
+ var AUDIO_DIR = "audio";
67
+ function audioNames(narration) {
68
+ return [
69
+ ...new Set(
70
+ Object.values(narration.beats).flat().map((s) => s.audio)
71
+ )
72
+ ].sort();
73
+ }
74
+ async function vendorKatex(out) {
75
+ const require2 = createRequire(import.meta.url);
76
+ const dist = join2(dirname(require2.resolve("katex/package.json")), "dist");
77
+ const css = await readFile2(join2(dist, "katex.min.css"), "utf8");
78
+ const written = [];
79
+ await mkdir2(join2(out, "katex/fonts"), { recursive: true });
80
+ for (const file of await readdir(join2(dist, "fonts"))) {
81
+ if (!file.endsWith(".woff2")) continue;
82
+ await cp(join2(dist, "fonts", file), join2(out, "katex/fonts", file));
83
+ written.push(join2(out, "katex/fonts", file));
84
+ }
85
+ const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
86
+ const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
87
+ return kept ? `src:${kept}` : whole;
88
+ });
89
+ await writeFile2(join2(out, "katex/katex.min.css"), woff2Only);
90
+ written.push(join2(out, "katex/katex.min.css"));
91
+ return written;
92
+ }
93
+ async function vendorScripts(out, composition) {
94
+ const require2 = createRequire(import.meta.url);
95
+ await mkdir2(join2(out, "vendor"), { recursive: true });
96
+ const wanted = (name) => composition.includes(`./vendor/${name}`);
97
+ const written = [];
98
+ for (const [pkg, rel, name] of [
99
+ ["gsap/package.json", "dist/gsap.min.js", "gsap.min.js"],
100
+ ["gsap/package.json", "dist/DrawSVGPlugin.min.js", "DrawSVGPlugin.min.js"],
101
+ ["gsap/package.json", "dist/MorphSVGPlugin.min.js", "MorphSVGPlugin.min.js"],
102
+ ["katex/package.json", "dist/katex.min.js", "katex.min.js"]
103
+ ]) {
104
+ if (!wanted(name)) continue;
105
+ const from = join2(dirname(require2.resolve(pkg)), rel);
106
+ await cp(from, join2(out, "vendor", name));
107
+ written.push(join2(out, "vendor", name));
108
+ }
109
+ if (wanted("ds-morph.js")) {
110
+ await cp(
111
+ fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
112
+ join2(out, "vendor", "ds-morph.js")
113
+ );
114
+ written.push(join2(out, "vendor", "ds-morph.js"));
115
+ }
116
+ return written;
117
+ }
118
+ async function copyAssets(sourceDir, out, figures, step2) {
119
+ const from = join2(resolve(sourceDir), "assets");
120
+ if (!await stat(from).catch(() => null)) {
121
+ step2(`build: no assets/ beside source.json, skipping`);
122
+ return [];
123
+ }
124
+ const wanted = new Set(
125
+ figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
126
+ );
127
+ await mkdir2(join2(out, "assets"), { recursive: true });
128
+ const written = [];
129
+ let copied = 0;
130
+ for (const name of wanted) {
131
+ const src = resolve(join2(from, name));
132
+ if (!src.startsWith(`${from}/`)) continue;
133
+ if (!await stat(src).catch(() => null)) continue;
134
+ await mkdir2(dirname(join2(out, "assets", name)), { recursive: true });
135
+ await cp(src, join2(out, "assets", name));
136
+ written.push(join2(out, "assets", name));
137
+ copied++;
138
+ }
139
+ const fonts = join2(from, "fonts");
140
+ if (await stat(fonts).catch(() => null)) {
141
+ await cp(fonts, join2(out, "assets", "fonts"), { recursive: true });
142
+ written.push(join2(out, "assets", "fonts"));
143
+ }
144
+ step2(`build: copied ${copied} referenced figure(s)`);
145
+ return written;
146
+ }
147
+ async function copyAudio(from, narration, out, step2) {
148
+ const dir = join2(out, narration.dir);
149
+ await mkdir2(dir, { recursive: true });
150
+ const names = audioNames(narration);
151
+ for (const name of names) {
152
+ await cp(join2(resolve(from), name), join2(dir, name)).catch(() => {
153
+ throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
154
+ });
155
+ }
156
+ step2(`narration: ${names.length} audio file(s) \u2192 ${dir}`);
157
+ return names.map((n3) => join2(dir, n3));
158
+ }
159
+ async function refreshFont(storyboard, source, out, step2) {
160
+ try {
161
+ const bundle = await bundleFont(
162
+ storyboard.lang,
163
+ JSON.stringify(source) + JSON.stringify(storyboard),
164
+ join2(out, "assets", "fonts")
165
+ );
166
+ if (bundle) step2(`build: font bundle covers ${bundle.family}`);
167
+ return bundle?.css;
168
+ } catch (err) {
169
+ step2(
170
+ `build: could not refresh the font bundle (${err instanceof Error ? err.message : err}); keeping the one from ingest`
171
+ );
172
+ return void 0;
173
+ }
174
+ }
175
+
176
+ // src/pack/media.ts
177
+ import { createHash as createHash2 } from "node:crypto";
178
+ import { readFile as readFile3 } from "node:fs/promises";
14
179
  import { extname } from "node:path";
15
180
  var PLAYERS = [
16
181
  "youtube.com",
@@ -184,7 +349,7 @@ function mediaSummary(plan) {
184
349
  }
185
350
  function bakedName(id2, url2, mime) {
186
351
  const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
187
- const hash = createHash("sha256").update(url2).digest("hex").slice(0, 8);
352
+ const hash = createHash2("sha256").update(url2).digest("hex").slice(0, 8);
188
353
  return `${slug}-${hash}${extOf(url2) || extFor(mime)}`;
189
354
  }
190
355
  function extOf(url2) {
@@ -239,7 +404,7 @@ async function fetchAsset(url2) {
239
404
  mime: res.headers.get("content-type") ?? void 0
240
405
  };
241
406
  }
242
- return { bytes: new Uint8Array(await readFile(url2)) };
407
+ return { bytes: new Uint8Array(await readFile3(url2)) };
243
408
  }
244
409
 
245
410
  // src/types.ts
@@ -908,6 +1073,18 @@ var packSchema = z.object({
908
1073
  narration: narrationSchema.optional(),
909
1074
  media: z.array(mediaSchema).default([])
910
1075
  });
1076
+ var CAPTION_FONT_RATIO = 0.037;
1077
+ var CAPTION_MARGIN_V_RATIO = 0.09;
1078
+ var CAPTION_MARGIN_X_RATIO = 0.04;
1079
+ var CAPTION_LINE_RATIO = 1.6;
1080
+ var CAPTION_MAX_LINES = 2;
1081
+ function captionFontSize(width) {
1082
+ return Math.round(width * CAPTION_FONT_RATIO);
1083
+ }
1084
+ function bandReserve(width, height) {
1085
+ const line2 = Math.round(captionFontSize(width) * CAPTION_LINE_RATIO);
1086
+ return Math.round(height * CAPTION_MARGIN_V_RATIO) + CAPTION_MAX_LINES * line2;
1087
+ }
911
1088
  var DESTINATIONS = {
912
1089
  // A presented deck is paced by the person clicking through it, and long-form
913
1090
  // video has no ceiling worth encoding. An empty list is "unbudgeted", stated.
@@ -1224,54 +1401,6 @@ function clock(seconds) {
1224
1401
  return m === 0 ? `${whole}s` : `${m}m${String(whole % 60).padStart(2, "0")}s`;
1225
1402
  }
1226
1403
 
1227
- // src/source/fonts.ts
1228
- import { createHash as createHash2 } from "node:crypto";
1229
- import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
1230
- import { join } from "node:path";
1231
- function familyFor(lang) {
1232
- const tag = lang.toLowerCase();
1233
- if (tag.startsWith("ko")) return "Noto Sans KR";
1234
- if (tag.startsWith("ja")) return "Noto Sans JP";
1235
- if (tag.startsWith("zh")) return /hant|-tw|-hk|-mo/.test(tag) ? "Noto Sans TC" : "Noto Sans SC";
1236
- return null;
1237
- }
1238
- var UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
1239
- async function bundleFont(lang, glyphs2, dir) {
1240
- const family = familyFor(lang);
1241
- if (!family) return null;
1242
- const text2 = [...new Set(glyphs2)].filter((c) => c > " ").sort().join("");
1243
- const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
1244
- ${text2}`).digest("hex").slice(0, 16)} */`;
1245
- await mkdir(dir, { recursive: true });
1246
- const cssPath = join(dir, "fonts.css");
1247
- const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1248
- if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1249
- const res = await fetch(
1250
- `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
1251
- { headers: { "User-Agent": UA } }
1252
- );
1253
- if (!res.ok) throw new Error(`google fonts: HTTP ${res.status}`);
1254
- let css = await res.text();
1255
- const remote = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1] ?? ""))];
1256
- const slug = family.toLowerCase().replace(/[^a-z0-9]/g, "");
1257
- const files = [];
1258
- for (const [i, url2] of remote.entries()) {
1259
- const font = await fetch(url2);
1260
- if (!font.ok) throw new Error(`${url2}: HTTP ${font.status}`);
1261
- const name = `${slug}-${i}.woff2`;
1262
- await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
1263
- css = css.replaceAll(url2, name);
1264
- files.push(name);
1265
- }
1266
- css = `${stamp}
1267
- ${css}`;
1268
- await writeFile(cssPath, css);
1269
- return { family, css, files };
1270
- }
1271
- function localNames(css) {
1272
- return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
1273
- }
1274
-
1275
1404
  // src/emit/kit.ts
1276
1405
  var PAD_X = 110;
1277
1406
  var PAD_Y = 84;
@@ -1290,7 +1419,12 @@ function contentW(format) {
1290
1419
  return refWidth(format) - 2 * PAD_X;
1291
1420
  }
1292
1421
  function contentH(format) {
1293
- return refHeight(format) - 2 * PAD_Y;
1422
+ return refHeight(format) - 2 * PAD_Y - reserveRef(format);
1423
+ }
1424
+ function reserveRef(format) {
1425
+ const reserve = format.captionReserve ?? 0;
1426
+ if (reserve <= 0) return 0;
1427
+ return Math.ceil(reserve * refHeight(format) / format.height);
1294
1428
  }
1295
1429
  function raw(js2) {
1296
1430
  return { __raw: js2 };
@@ -1789,8 +1923,8 @@ function charUnits(c, tabular, cjkRun, hangul) {
1789
1923
  }
1790
1924
  const measured = ADVANCE[c];
1791
1925
  if (measured !== void 0) return [measured, true];
1792
- const cp2 = c.codePointAt(0) ?? 0;
1793
- for (const [lo, hi, units] of BLOCK_ADVANCE) if (cp2 >= lo && cp2 <= hi) return [units, false];
1926
+ const cp3 = c.codePointAt(0) ?? 0;
1927
+ for (const [lo, hi, units] of BLOCK_ADVANCE) if (cp3 >= lo && cp3 <= hi) return [units, false];
1794
1928
  return [UNMEASURED, true];
1795
1929
  }
1796
1930
  function weightFactor(weight) {
@@ -2051,11 +2185,16 @@ function baseCss(theme, format) {
2051
2185
  gate still catches anything taller than the canvas; it just now
2052
2186
  overflows symmetrically instead of only downwards. */
2053
2187
  .scene { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
2054
- padding: ${PAD_Y}px ${PAD_X}px; display: flex; flex-direction: column;
2188
+ padding: ${scenePadding(format)}; display: flex; flex-direction: column;
2055
2189
  justify-content: center; }
2056
2190
  ${referenceSpaceCss(format)}
2057
2191
  ${AMBIENT_KEYFRAMES}`;
2058
2192
  }
2193
+ function scenePadding(format) {
2194
+ const reserve = reserveRef(format);
2195
+ const base = `${PAD_Y}px ${PAD_X}px`;
2196
+ return reserve > 0 ? `${base} ${PAD_Y + reserve}px` : base;
2197
+ }
2059
2198
  function referenceSpaceCss(format) {
2060
2199
  const zoom = zoomOf(format);
2061
2200
  if (zoom === 1) return "";
@@ -6189,13 +6328,13 @@ function rnd(n3) {
6189
6328
 
6190
6329
  // src/images/illustrate.ts
6191
6330
  import { createHash as createHash5 } from "node:crypto";
6192
- import { mkdir as mkdir3, readFile as readFile6, rename, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
6193
- import { join as join5 } from "node:path";
6331
+ import { mkdir as mkdir4, readFile as readFile7, rename, rm as rm3, writeFile as writeFile6 } from "node:fs/promises";
6332
+ import { join as join6 } from "node:path";
6194
6333
 
6195
6334
  // src/source/assets.ts
6196
6335
  import { createHash as createHash3 } from "node:crypto";
6197
- import { mkdir as mkdir2, readdir, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
6198
- import { extname as extname2, join as join2 } from "node:path";
6336
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
6337
+ import { extname as extname2, join as join3 } from "node:path";
6199
6338
  import { z as z2 } from "zod";
6200
6339
 
6201
6340
  // src/net/fetch.ts
@@ -6206,8 +6345,8 @@ import { isIP } from "node:net";
6206
6345
  import { brotliDecompressSync, gunzipSync } from "node:zlib";
6207
6346
 
6208
6347
  // src/version.ts
6209
- import { createRequire } from "node:module";
6210
- var VERSION = createRequire(import.meta.url)("../package.json").version;
6348
+ import { createRequire as createRequire2 } from "node:module";
6349
+ var VERSION = createRequire2(import.meta.url)("../package.json").version;
6211
6350
 
6212
6351
  // src/net/fetch.ts
6213
6352
  var BLOCKED_V4 = [
@@ -6402,8 +6541,8 @@ async function send(url2, target, headers, signal, opts) {
6402
6541
  ...secure && isIP(host) === 0 ? { servername: host } : {}
6403
6542
  };
6404
6543
  try {
6405
- return await new Promise((resolve7, reject) => {
6406
- const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
6544
+ return await new Promise((resolve8, reject) => {
6545
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve8);
6407
6546
  req.on("error", reject);
6408
6547
  req.end();
6409
6548
  });
@@ -6488,8 +6627,8 @@ function message(err) {
6488
6627
  // src/source/assets.ts
6489
6628
  async function fetchFigures(source, dir, warnings) {
6490
6629
  const drops = warnings ?? [];
6491
- await mkdir2(dir, { recursive: true });
6492
- const cached2 = await readdir(dir).catch(() => []);
6630
+ await mkdir3(dir, { recursive: true });
6631
+ const cached2 = await readdir2(dir).catch(() => []);
6493
6632
  const figures = [];
6494
6633
  for (const figure of source.figures) {
6495
6634
  if (figure.kind === "clip") {
@@ -6514,11 +6653,11 @@ async function fetchFigures(source, dir, warnings) {
6514
6653
  async function localize(figure, dir, cached2) {
6515
6654
  const stem = assetStem(figure.id, figure.src);
6516
6655
  const hit = cached2.find((name) => name.startsWith(`${stem}.`));
6517
- const bytes = hit ? await readFile3(join2(dir, hit)) : await load(figure.src);
6656
+ const bytes = hit ? await readFile4(join3(dir, hit)) : await load(figure.src);
6518
6657
  const size3 = imageSize(bytes);
6519
6658
  if (hit) return { ...figure, src: hit, ...size3 };
6520
6659
  const src = `${stem}${assetExt(bytes, figure.src)}`;
6521
- await writeFile2(join2(dir, src), bytes);
6660
+ await writeFile3(join3(dir, src), bytes);
6522
6661
  cached2.push(src);
6523
6662
  return { ...figure, src, ...size3 };
6524
6663
  }
@@ -6543,7 +6682,7 @@ function assetExt(bytes, src) {
6543
6682
  var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
6544
6683
  var FIGURE_TIMEOUT_MS = 2e4;
6545
6684
  async function load(src) {
6546
- if (!/^https?:/i.test(src)) return readFile3(src);
6685
+ if (!/^https?:/i.test(src)) return readFile4(src);
6547
6686
  const got = await fetchGuarded(src, {
6548
6687
  maxBytes: FIGURE_MAX_BYTES,
6549
6688
  timeoutMs: FIGURE_TIMEOUT_MS
@@ -6788,16 +6927,16 @@ function cssPixels(value) {
6788
6927
 
6789
6928
  // src/images/providers.ts
6790
6929
  import { createHash as createHash4 } from "node:crypto";
6791
- import { mkdtemp as mkdtemp2, readFile as readFile5, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
6930
+ import { mkdtemp as mkdtemp2, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "node:fs/promises";
6792
6931
  import { tmpdir as tmpdir2 } from "node:os";
6793
- import { join as join4, resolve } from "node:path";
6932
+ import { join as join5, resolve as resolve2 } from "node:path";
6794
6933
  import { z as z4 } from "zod";
6795
6934
 
6796
6935
  // src/plan/codex.ts
6797
6936
  import { spawn } from "node:child_process";
6798
- import { mkdtemp, readFile as readFile4, rm, writeFile as writeFile3 } from "node:fs/promises";
6937
+ import { mkdtemp, readFile as readFile5, rm, writeFile as writeFile4 } from "node:fs/promises";
6799
6938
  import { tmpdir } from "node:os";
6800
- import { join as join3 } from "node:path";
6939
+ import { join as join4 } from "node:path";
6801
6940
  import { z as z3 } from "zod";
6802
6941
 
6803
6942
  // src/plan/arc.ts
@@ -7916,11 +8055,11 @@ function schemaFor(prefs) {
7916
8055
  var SCHEMA = schemaFor({ genre: "general" });
7917
8056
  async function codexPlanner(source, opts = {}) {
7918
8057
  const prefs = opts.prefs ?? prefsSchema.parse({});
7919
- const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
8058
+ const dir = await mkdtemp(join4(tmpdir(), "decksmith-plan-"));
7920
8059
  try {
7921
- const schemaPath = join3(dir, "storyboard.schema.json");
7922
- const outPath = join3(dir, "storyboard.json");
7923
- await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
8060
+ const schemaPath = join4(dir, "storyboard.schema.json");
8061
+ const outPath = join4(dir, "storyboard.json");
8062
+ await writeFile4(schemaPath, JSON.stringify(schemaFor(prefs)));
7924
8063
  await (opts.run ?? runCodex)({
7925
8064
  prompt: buildPrompt(source, prefs),
7926
8065
  schemaPath,
@@ -7928,7 +8067,7 @@ async function codexPlanner(source, opts = {}) {
7928
8067
  ...opts.model === void 0 ? {} : { model: opts.model },
7929
8068
  timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
7930
8069
  });
7931
- const raw2 = await readFile4(outPath, "utf8").catch(() => "");
8070
+ const raw2 = await readFile5(outPath, "utf8").catch(() => "");
7932
8071
  if (raw2.trim() === "") {
7933
8072
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
7934
8073
  }
@@ -8001,7 +8140,7 @@ function codexCommand(args) {
8001
8140
  }
8002
8141
  function runCodex(args) {
8003
8142
  const { argv, env } = codexCommand(args);
8004
- return new Promise((resolve7, reject) => {
8143
+ return new Promise((resolve8, reject) => {
8005
8144
  const child = spawn("codex", argv, {
8006
8145
  stdio: ["pipe", "ignore", "pipe"],
8007
8146
  ...env === void 0 ? {} : { env }
@@ -8024,7 +8163,7 @@ function runCodex(args) {
8024
8163
  });
8025
8164
  child.on("close", (code) => {
8026
8165
  clearTimeout(timer);
8027
- if (code === 0) return resolve7();
8166
+ if (code === 0) return resolve8();
8028
8167
  reject(
8029
8168
  new Error(`codex exec exited ${code}.
8030
8169
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -8165,11 +8304,11 @@ function codexImages(opts = {}) {
8165
8304
  async check() {
8166
8305
  },
8167
8306
  async generate(req) {
8168
- const dir = await mkdtemp2(join4(tmpdir2(), "decksmith-image-"));
8307
+ const dir = await mkdtemp2(join5(tmpdir2(), "decksmith-image-"));
8169
8308
  try {
8170
- const schemaPath = join4(dir, "answer.schema.json");
8171
- const outPath = join4(dir, "answer.json");
8172
- await writeFile4(schemaPath, JSON.stringify(ANSWER_SCHEMA));
8309
+ const schemaPath = join5(dir, "answer.schema.json");
8310
+ const outPath = join5(dir, "answer.json");
8311
+ await writeFile5(schemaPath, JSON.stringify(ANSWER_SCHEMA));
8173
8312
  await run4({
8174
8313
  prompt: codexPrompt(req),
8175
8314
  schemaPath,
@@ -8178,7 +8317,7 @@ function codexImages(opts = {}) {
8178
8317
  cwd: dir,
8179
8318
  sandbox: "workspace-write"
8180
8319
  });
8181
- const raw2 = await readFile5(outPath, "utf8").catch(() => "");
8320
+ const raw2 = await readFile6(outPath, "utf8").catch(() => "");
8182
8321
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
8183
8322
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
8184
8323
  if (!answer.data.ok) {
@@ -8186,10 +8325,10 @@ function codexImages(opts = {}) {
8186
8325
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
8187
8326
  );
8188
8327
  }
8189
- const candidates2 = [join4(dir, "picture.png")];
8190
- if (answer.data.file) candidates2.push(resolve(dir, answer.data.file));
8328
+ const candidates2 = [join5(dir, "picture.png")];
8329
+ if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
8191
8330
  for (const path2 of candidates2) {
8192
- const bytes = await readFile5(path2).catch(() => null);
8331
+ const bytes = await readFile6(path2).catch(() => null);
8193
8332
  if (bytes) return raster(bytes, "codex");
8194
8333
  }
8195
8334
  throw new Error("codex said ok but wrote no picture.png");
@@ -8321,7 +8460,7 @@ async function illustrate(storyboard, source, opts) {
8321
8460
  const tool = last?.id === "svg" ? last : toolSvg();
8322
8461
  if (tool !== last) chain.push(tool);
8323
8462
  const dropped = /* @__PURE__ */ new Set();
8324
- if (pending.length > 0) await mkdir3(opts.assetsDir, { recursive: true });
8463
+ if (pending.length > 0) await mkdir4(opts.assetsDir, { recursive: true });
8325
8464
  for (const [i, slot] of pending.entries()) {
8326
8465
  let rungs = chain;
8327
8466
  if (i >= images.max) {
@@ -8418,16 +8557,16 @@ function cacheKey(providerId, req) {
8418
8557
  async function draw(provider, req, name, dir) {
8419
8558
  for (const ext of Object.values(EXT2)) {
8420
8559
  const src2 = `${name}${ext}`;
8421
- const bytes = await readFile6(join5(dir, src2)).catch(() => null);
8560
+ const bytes = await readFile7(join6(dir, src2)).catch(() => null);
8422
8561
  if (bytes) return { src: src2, ...sizeOf(bytes, ext), cached: true };
8423
8562
  }
8424
8563
  await provider.check();
8425
8564
  const img = await provider.generate(req);
8426
8565
  const src = `${name}${EXT2[img.mime]}`;
8427
- const tmp = join5(dir, `.${name}.tmp`);
8566
+ const tmp = join6(dir, `.${name}.tmp`);
8428
8567
  try {
8429
- await writeFile5(tmp, img.bytes);
8430
- await rename(tmp, join5(dir, src));
8568
+ await writeFile6(tmp, img.bytes);
8569
+ await rename(tmp, join6(dir, src));
8431
8570
  } finally {
8432
8571
  await rm3(tmp, { force: true });
8433
8572
  }
@@ -8440,9 +8579,9 @@ function sizeOf(bytes, ext) {
8440
8579
  // src/narrate/tts.ts
8441
8580
  import { spawn as spawn2 } from "node:child_process";
8442
8581
  import { createHash as createHash6 } from "node:crypto";
8443
- import { mkdir as mkdir4, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
8582
+ import { mkdir as mkdir5, readFile as readFile8, rm as rm4, writeFile as writeFile7 } from "node:fs/promises";
8444
8583
  import { homedir } from "node:os";
8445
- import { join as join6 } from "node:path";
8584
+ import { join as join7 } from "node:path";
8446
8585
  var MISSING = [
8447
8586
  "edge-tts is not installed, so narration cannot be synthesised.",
8448
8587
  "",
@@ -8459,8 +8598,8 @@ function candidates() {
8459
8598
  ["edge-tts"],
8460
8599
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
8461
8600
  // default, and both are where this actually lands in practice.
8462
- [join6(home, "Library", "Python", "3.9", "bin", "edge-tts")],
8463
- [join6(home, ".local", "bin", "edge-tts")],
8601
+ [join7(home, "Library", "Python", "3.9", "bin", "edge-tts")],
8602
+ [join7(home, ".local", "bin", "edge-tts")],
8464
8603
  // Last resort: the module is installed even though its console script is
8465
8604
  // nowhere findable, which is the normal state of a pip --user install.
8466
8605
  ["python3", "-m", "edge_tts"]
@@ -8485,7 +8624,7 @@ async function find2(can) {
8485
8624
  throw new Error(MISSING);
8486
8625
  }
8487
8626
  function runArgv(cmd, args) {
8488
- return new Promise((resolve7) => {
8627
+ return new Promise((resolve8) => {
8489
8628
  const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
8490
8629
  let stdout = "";
8491
8630
  let stderr = "";
@@ -8495,8 +8634,8 @@ function runArgv(cmd, args) {
8495
8634
  child.stderr.on("data", (b) => {
8496
8635
  stderr += b.toString();
8497
8636
  });
8498
- child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
8499
- child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
8637
+ child.on("error", (e) => resolve8({ code: -1, stderr: String(e), stdout }));
8638
+ child.on("close", (code) => resolve8({ code: code ?? -1, stderr, stdout }));
8500
8639
  });
8501
8640
  }
8502
8641
  var edgeTts = {
@@ -8563,7 +8702,7 @@ function edgeProvider(runner = edgeTts) {
8563
8702
  async speak(req) {
8564
8703
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
8565
8704
  await runner.speak({ ...req, subs });
8566
- const raw2 = await readFile7(subs, "utf8").catch(() => "");
8705
+ const raw2 = await readFile8(subs, "utf8").catch(() => "");
8567
8706
  await rm4(subs, { force: true });
8568
8707
  const cues = parseCues(raw2);
8569
8708
  const measured = await runner.measure(req.audio);
@@ -8581,11 +8720,11 @@ async function synthesize(text2, opts) {
8581
8720
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
8582
8721
  const key = cacheKey2(text2, opts.voice, rate, pitch);
8583
8722
  const file = `${key}.mp3`;
8584
- const audio = join6(opts.dir, file);
8585
- const sidecar = join6(opts.dir, `${key}.json`);
8723
+ const audio = join7(opts.dir, file);
8724
+ const sidecar = join7(opts.dir, `${key}.json`);
8586
8725
  const cached2 = await readSidecar(sidecar);
8587
8726
  if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
8588
- await mkdir4(opts.dir, { recursive: true });
8727
+ await mkdir5(opts.dir, { recursive: true });
8589
8728
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
8590
8729
  const { cues, seconds } = spoken;
8591
8730
  const clamped = cues.map((c) => ({
@@ -8594,13 +8733,13 @@ async function synthesize(text2, opts) {
8594
8733
  text: c.text
8595
8734
  }));
8596
8735
  const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
8597
- await writeFile6(sidecar, `${JSON.stringify(body, null, 2)}
8736
+ await writeFile7(sidecar, `${JSON.stringify(body, null, 2)}
8598
8737
  `);
8599
8738
  return { audio, file, seconds, cues: clamped };
8600
8739
  }
8601
8740
  async function readSidecar(path2) {
8602
8741
  try {
8603
- const parsed = JSON.parse(await readFile7(path2, "utf8"));
8742
+ const parsed = JSON.parse(await readFile8(path2, "utf8"));
8604
8743
  const s = parsed;
8605
8744
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
8606
8745
  } catch {
@@ -8720,8 +8859,8 @@ async function narrate(storyboard, source, prefs, opts) {
8720
8859
  }
8721
8860
 
8722
8861
  // src/pack/pack.ts
8723
- import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
8724
- import { dirname, extname as extname3 } from "node:path";
8862
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "node:fs/promises";
8863
+ import { dirname as dirname2, extname as extname3 } from "node:path";
8725
8864
  import { unzipSync, zipSync } from "fflate";
8726
8865
  var MTIME = Date.UTC(1980, 0, 2, 12);
8727
8866
  var STORED = /* @__PURE__ */ new Set([
@@ -8760,12 +8899,12 @@ async function writePack(pack2, files, out) {
8760
8899
  entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
8761
8900
  }
8762
8901
  const zip = zipSync(entries, { mtime: MTIME });
8763
- await mkdir5(dirname(out), { recursive: true });
8764
- await writeFile7(out, zip);
8902
+ await mkdir6(dirname2(out), { recursive: true });
8903
+ await writeFile8(out, zip);
8765
8904
  return zip.length;
8766
8905
  }
8767
8906
  async function readPack(path2) {
8768
- return openPack(new Uint8Array(await readFile8(path2)), path2);
8907
+ return openPack(new Uint8Array(await readFile9(path2)), path2);
8769
8908
  }
8770
8909
  function openPack(bytes, label = "pack") {
8771
8910
  if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
@@ -8809,8 +8948,8 @@ function why(error) {
8809
8948
  }
8810
8949
 
8811
8950
  // src/prefs.ts
8812
- import { readFile as readFile9 } from "node:fs/promises";
8813
- import { dirname as dirname2, join as join7, resolve as resolve2 } from "node:path";
8951
+ import { readFile as readFile10 } from "node:fs/promises";
8952
+ import { dirname as dirname3, join as join8, resolve as resolve3 } from "node:path";
8814
8953
  var CONFIG_FILE = "decksmith.config.json";
8815
8954
  var PREF_KEYS = Object.keys(prefsSchema.shape);
8816
8955
  var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
@@ -8856,10 +8995,10 @@ function prefsFromFlags(flags2) {
8856
8995
  return patch;
8857
8996
  }
8858
8997
  async function findConfig(from) {
8859
- let dir = resolve2(from);
8998
+ let dir = resolve3(from);
8860
8999
  for (; ; ) {
8861
- const path2 = join7(dir, CONFIG_FILE);
8862
- const text2 = await readFile9(path2, "utf8").catch(() => void 0);
9000
+ const path2 = join8(dir, CONFIG_FILE);
9001
+ const text2 = await readFile10(path2, "utf8").catch(() => void 0);
8863
9002
  if (text2 !== void 0) {
8864
9003
  try {
8865
9004
  return { path: path2, value: JSON.parse(text2) };
@@ -8867,7 +9006,7 @@ async function findConfig(from) {
8867
9006
  throw new Error(`${path2} is not valid JSON.`);
8868
9007
  }
8869
9008
  }
8870
- const up = dirname2(dir);
9009
+ const up = dirname3(dir);
8871
9010
  if (up === dir) return void 0;
8872
9011
  dir = up;
8873
9012
  }
@@ -8917,10 +9056,10 @@ function number(flag, value) {
8917
9056
  }
8918
9057
 
8919
9058
  // src/render/capture.ts
8920
- import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile8 } from "node:fs/promises";
8921
- import { createRequire as createRequire2 } from "node:module";
9059
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile9 } from "node:fs/promises";
9060
+ import { createRequire as createRequire3 } from "node:module";
8922
9061
  import { homedir as homedir2 } from "node:os";
8923
- import { join as join8 } from "node:path";
9062
+ import { join as join9 } from "node:path";
8924
9063
  import { pathToFileURL } from "node:url";
8925
9064
  async function resolveChrome(need = "open the deck with") {
8926
9065
  const env = process.env.HYPERFRAMES_BROWSER_PATH || process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
@@ -8931,9 +9070,9 @@ async function resolveChrome(need = "open the deck with") {
8931
9070
  const ordered = [...installed2].sort((a, b) => b.buildId.localeCompare(a.buildId, "en"));
8932
9071
  return ordered.find((b) => b.browser === "chrome-headless-shell") ?? ordered.find((b) => b.browser === "chrome");
8933
9072
  };
8934
- const hf = await pick(join8(homedir2(), ".cache", "hyperframes", "chrome"));
9073
+ const hf = await pick(join9(homedir2(), ".cache", "hyperframes", "chrome"));
8935
9074
  if (hf) return { path: hf.executablePath, source: "hyperframes" };
8936
- const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer"));
9075
+ const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join9(homedir2(), ".cache", "puppeteer"));
8937
9076
  if (pup) return { path: pup.executablePath, source: "puppeteer" };
8938
9077
  throw new Error(
8939
9078
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
@@ -8943,15 +9082,15 @@ async function chromePath(need = "open the deck with") {
8943
9082
  return (await resolveChrome(need)).path;
8944
9083
  }
8945
9084
  function runtimePath() {
8946
- return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
9085
+ return createRequire3(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
8947
9086
  }
8948
9087
  function renderSeek(t2) {
8949
9088
  const player = window.__player;
8950
9089
  player.renderSeek(t2, { suppressEvents: true });
8951
9090
  }
8952
9091
  async function openDeck(dir, opts = {}) {
8953
- const index = join8(dir, "index.html");
8954
- const html = await readFile10(index, "utf8").catch(() => null);
9092
+ const index = join9(dir, "index.html");
9093
+ const html = await readFile11(index, "utf8").catch(() => null);
8955
9094
  if (html === null) throw new Error(`no index.html in ${dir}`);
8956
9095
  const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
8957
9096
  const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
@@ -9032,7 +9171,7 @@ function frameName(index, t2, total) {
9032
9171
  }
9033
9172
  async function captureFrames(dir, times, outDir, opts = {}) {
9034
9173
  if (times.length === 0) return [];
9035
- await mkdir6(outDir, { recursive: true });
9174
+ await mkdir7(outDir, { recursive: true });
9036
9175
  const deck = await openDeck(dir, opts);
9037
9176
  try {
9038
9177
  const out = [];
@@ -9040,8 +9179,8 @@ async function captureFrames(dir, times, outDir, opts = {}) {
9040
9179
  const t2 = Math.round(raw2 * 1e3) / 1e3;
9041
9180
  await deck.seek(t2);
9042
9181
  const png = await deck.shoot();
9043
- const path2 = join8(outDir, frameName(i, t2, times.length));
9044
- await writeFile8(path2, png);
9182
+ const path2 = join9(outDir, frameName(i, t2, times.length));
9183
+ await writeFile9(path2, png);
9045
9184
  out.push({ t: t2, path: path2, bytes: png.length });
9046
9185
  }
9047
9186
  return out;
@@ -9052,12 +9191,12 @@ async function captureFrames(dir, times, outDir, opts = {}) {
9052
9191
  }
9053
9192
 
9054
9193
  // src/render/render.ts
9055
- import { mkdir as mkdir8, readFile as readFile11, rename as rename2, rm as rm5, writeFile as writeFile10 } from "node:fs/promises";
9056
- import { basename, dirname as dirname3, join as join10, resolve as resolve3 } from "node:path";
9194
+ import { mkdir as mkdir9, readFile as readFile12, rename as rename2, rm as rm5, writeFile as writeFile11 } from "node:fs/promises";
9195
+ import { basename, dirname as dirname4, join as join11, resolve as resolve4 } from "node:path";
9057
9196
 
9058
9197
  // src/render/captions.ts
9059
- import { mkdir as mkdir7, writeFile as writeFile9 } from "node:fs/promises";
9060
- import { join as join9 } from "node:path";
9198
+ import { mkdir as mkdir8, writeFile as writeFile10 } from "node:fs/promises";
9199
+ import { join as join10 } from "node:path";
9061
9200
  import { pathToFileURL as pathToFileURL2 } from "node:url";
9062
9201
 
9063
9202
  // src/deck/subtitles.ts
@@ -9256,6 +9395,10 @@ function planTiming(input) {
9256
9395
  lang: storyboard.lang,
9257
9396
  audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
9258
9397
  voice: segments2.length > 0 ? narration?.voice ?? "" : "",
9398
+ // The format the deck was LAID OUT under, which is the only honest source:
9399
+ // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
9400
+ // disagree with the box the archetypes actually drew into.
9401
+ captionReserve: format.captionReserve ?? 0,
9259
9402
  scenes,
9260
9403
  segments: segments2
9261
9404
  };
@@ -9444,11 +9587,11 @@ async function captionBlocker() {
9444
9587
  }
9445
9588
  async function renderCaptions(cues, style, deck, work) {
9446
9589
  if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
9447
- const fontCss = join9(deck, DECK_FONT_CSS);
9590
+ const fontCss = join10(deck, DECK_FONT_CSS);
9448
9591
  const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
9449
- const page = join9(work, "captions.html");
9450
- await mkdir7(work, { recursive: true });
9451
- await writeFile9(page, captionPage(cues, style, href));
9592
+ const page = join10(work, "captions.html");
9593
+ await mkdir8(work, { recursive: true });
9594
+ await writeFile10(page, captionPage(cues, style, href));
9452
9595
  const { default: puppeteer } = await import("puppeteer-core");
9453
9596
  const browser = await puppeteer.launch({
9454
9597
  executablePath: await chromePath(CAPTION_NEED),
@@ -9468,7 +9611,7 @@ async function renderCaptions(cues, style, deck, work) {
9468
9611
  const name = `cap${String(i).padStart(4, "0")}.png`;
9469
9612
  await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
9470
9613
  await tab.screenshot({
9471
- path: join9(work, name),
9614
+ path: join10(work, name),
9472
9615
  type: "png",
9473
9616
  omitBackground: true,
9474
9617
  clip: { x: box.x, y: box.y, width: box.width, height: box.height }
@@ -9524,7 +9667,7 @@ ${tail3}`);
9524
9667
  }
9525
9668
  }
9526
9669
  function runLive(file, args) {
9527
- return new Promise((resolve7, reject) => {
9670
+ return new Promise((resolve8, reject) => {
9528
9671
  const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
9529
9672
  child.on("error", (err) => {
9530
9673
  reject(
@@ -9532,7 +9675,7 @@ function runLive(file, args) {
9532
9675
  );
9533
9676
  });
9534
9677
  child.on("close", (code, signal) => {
9535
- if (code === 0) resolve7();
9678
+ if (code === 0) resolve8();
9536
9679
  else if (signal) {
9537
9680
  reject(
9538
9681
  new Error(
@@ -9659,11 +9802,16 @@ function burnStyle(width, height, font = "Arial") {
9659
9802
  // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
9660
9803
  // "4% of the width" looked like on paper — overflows to a THIRD line, and a
9661
9804
  // three-line band covers the bottom of the slide.
9662
- fontSize: Math.round(width * 0.037),
9805
+ //
9806
+ // THE RATIOS ARE NOT HERE. They are in src/types.ts beside `bandReserve`,
9807
+ // because `src/emit` has to reserve exactly the strip this draws into and
9808
+ // may not import this file. A local copy of 0.037 is how the two drift back
9809
+ // apart, which is the bug this whole path exists to close.
9810
+ fontSize: captionFontSize(width),
9663
9811
  // Clear of the play button, the progress bar and the handle every vertical
9664
9812
  // player draws across the bottom eighth of the frame.
9665
- marginV: Math.round(height * 0.09),
9666
- marginX: Math.round(width * 0.04)
9813
+ marginV: Math.round(height * CAPTION_MARGIN_V_RATIO),
9814
+ marginX: Math.round(width * CAPTION_MARGIN_X_RATIO)
9667
9815
  };
9668
9816
  }
9669
9817
 
@@ -9675,9 +9823,9 @@ function subtitlePlan(mode) {
9675
9823
  async function render(opts) {
9676
9824
  const log = opts.log ?? (() => {
9677
9825
  });
9678
- const deck = resolve3(opts.deck);
9679
- const out = resolve3(opts.out);
9680
- const work = join10(dirname3(out), `.${basename(out)}.parts`);
9826
+ const deck = resolve4(opts.deck);
9827
+ const out = resolve4(opts.out);
9828
+ const work = join11(dirname4(out), `.${basename(out)}.parts`);
9681
9829
  const timing = await readTiming(deck);
9682
9830
  if (opts.targetSeconds && !opts.allowFastPlayback) {
9683
9831
  const refusal = playbackRefusal(
@@ -9689,13 +9837,17 @@ async function render(opts) {
9689
9837
  }
9690
9838
  const plan0 = subtitlePlan(opts.subtitles ?? "sidecar");
9691
9839
  if (plan0.burn) {
9840
+ if (!timing.captionReserve)
9841
+ throw new Error(
9842
+ "This deck was built without room for a burned caption, so the band would sit on the slide's own text. Rebuild with `decksmith build --reserve-captions`, or render with `--subtitles sidecar`."
9843
+ );
9692
9844
  const blocker = await captionBlocker();
9693
9845
  if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
9694
9846
  }
9695
9847
  const burnable = plan0.burn;
9696
- await mkdir8(work, { recursive: true });
9848
+ await mkdir9(work, { recursive: true });
9697
9849
  try {
9698
- const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join10(work, "raw.mp4"), opts, log);
9850
+ const raw2 = opts.video ? resolve4(opts.video) : await capture(deck, join11(work, "raw.mp4"), opts, log);
9699
9851
  const shot = await probe(raw2);
9700
9852
  log(
9701
9853
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -9708,18 +9860,18 @@ async function render(opts) {
9708
9860
  );
9709
9861
  }
9710
9862
  const retimed = await retime(raw2, plan, work, log);
9711
- const srtPath = join10(dirname3(out), SRT_NAME(out));
9863
+ const srtPath = join11(dirname4(out), SRT_NAME(out));
9712
9864
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
9713
9865
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
9714
9866
  const srt = plan0.sidecar ? toSrt(cues) : "";
9715
- if (srt) await writeFile10(srtPath, srt);
9867
+ if (srt) await writeFile11(srtPath, srt);
9716
9868
  const burn = burnable && srt.length > 0;
9717
9869
  await mux(retimed, timing, plan, deck, out, work, burn ? plan.cues : void 0, shot.fps, log);
9718
9870
  if (playback > 1) {
9719
9871
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
9720
9872
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
9721
9873
  if (warning) log(`render: ${warning}`);
9722
- const fast = join10(work, `fast.${basename(out)}`);
9874
+ const fast = join11(work, `fast.${basename(out)}`);
9723
9875
  const muxed = await probe(out);
9724
9876
  await runTool(
9725
9877
  "ffmpeg",
@@ -9749,8 +9901,8 @@ async function render(opts) {
9749
9901
  }
9750
9902
  }
9751
9903
  async function readTiming(deck) {
9752
- const path2 = join10(deck, TIMING_FILE);
9753
- const text2 = await readFile11(path2, "utf8").catch(() => {
9904
+ const path2 = join11(deck, TIMING_FILE);
9905
+ const text2 = await readFile12(path2, "utf8").catch(() => {
9754
9906
  throw new Error(
9755
9907
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
9756
9908
  );
@@ -9789,20 +9941,20 @@ async function retime(raw2, plan, work, log) {
9789
9941
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
9790
9942
  const list = [];
9791
9943
  for (const [i, piece] of plan.pieces.entries()) {
9792
- const file = join10(work, `p${String(i).padStart(4, "0")}.ts`);
9944
+ const file = join11(work, `p${String(i).padStart(4, "0")}.ts`);
9793
9945
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
9794
9946
  list.push(file);
9795
9947
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
9796
9948
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
9797
9949
  }
9798
9950
  }
9799
- const listFile = join10(work, "pieces.txt");
9800
- await writeFile10(
9951
+ const listFile = join11(work, "pieces.txt");
9952
+ await writeFile11(
9801
9953
  listFile,
9802
9954
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
9803
9955
  `
9804
9956
  );
9805
- const joined = join10(work, "retimed.mp4");
9957
+ const joined = join11(work, "retimed.mp4");
9806
9958
  await runTool("ffmpeg", [
9807
9959
  "-y",
9808
9960
  "-hide_banner",
@@ -9824,7 +9976,7 @@ async function retime(raw2, plan, work, log) {
9824
9976
  }
9825
9977
  async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
9826
9978
  const inputs = plan.audio.map((a) => ({
9827
- file: join10(deck, timing.audioDir, a.audio),
9979
+ file: join11(deck, timing.audioDir, a.audio),
9828
9980
  delayMs: a.delayMs
9829
9981
  }));
9830
9982
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -9845,8 +9997,8 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
9845
9997
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
9846
9998
  }
9847
9999
  if (graph.length > 0) {
9848
- const script = join10(work, "mux.filter");
9849
- await writeFile10(script, `${graph.join(";\n")}
10000
+ const script = join11(work, "mux.filter");
10001
+ await writeFile11(script, `${graph.join(";\n")}
9850
10002
  `);
9851
10003
  args.push("-filter_complex_script", script);
9852
10004
  }
@@ -9861,14 +10013,14 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
9861
10013
  log(
9862
10014
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
9863
10015
  );
9864
- await mkdir8(dirname3(out), { recursive: true });
10016
+ await mkdir9(dirname4(out), { recursive: true });
9865
10017
  await runTool("ffmpeg", args, { cwd: work });
9866
10018
  }
9867
10019
 
9868
10020
  // src/source/harvest.ts
9869
10021
  import { createHash as createHash7 } from "node:crypto";
9870
- import { copyFile, mkdir as mkdir9, rm as rm7, stat, writeFile as writeFile11 } from "node:fs/promises";
9871
- import { basename as basename3, join as join11, resolve as resolve4 } from "node:path";
10022
+ import { copyFile, mkdir as mkdir10, rm as rm7, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
10023
+ import { basename as basename3, join as join12, resolve as resolve5 } from "node:path";
9872
10024
 
9873
10025
  // src/source/readability.ts
9874
10026
  var CONTENT_MARKER = "data-ds-content";
@@ -10163,7 +10315,7 @@ function readContentRegion() {
10163
10315
  }
10164
10316
 
10165
10317
  // src/source/transcode.ts
10166
- import { readFile as readFile12, rm as rm6 } from "node:fs/promises";
10318
+ import { readFile as readFile13, rm as rm6 } from "node:fs/promises";
10167
10319
  import { basename as basename2 } from "node:path";
10168
10320
  var CLIP_EDGE_PX = 1280;
10169
10321
  var MAX_CLIP_SECONDS = 60;
@@ -10226,7 +10378,7 @@ async function transcode(input, out, opts = {}) {
10226
10378
  const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
10227
10379
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
10228
10380
  const name = basename2(input);
10229
- const source = videoSize(await readFile12(input));
10381
+ const source = videoSize(await readFile13(input));
10230
10382
  const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
10231
10383
  const kept = (why3) => ({
10232
10384
  path: input,
@@ -10258,7 +10410,7 @@ async function transcode(input, out, opts = {}) {
10258
10410
  }
10259
10411
  let measured;
10260
10412
  try {
10261
- measured = videoSize(await readFile12(out));
10413
+ measured = videoSize(await readFile13(out));
10262
10414
  } catch (err) {
10263
10415
  await rm6(out, { force: true });
10264
10416
  return kept(
@@ -10294,8 +10446,8 @@ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
10294
10446
  async function harvest(url2, dir, opts = {}) {
10295
10447
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
10296
10448
  const warnings = [];
10297
- const assets = resolve4(dir);
10298
- await mkdir9(assets, { recursive: true });
10449
+ const assets = resolve5(dir);
10450
+ await mkdir10(assets, { recursive: true });
10299
10451
  const page = await fetchGuarded(url2, {
10300
10452
  maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
10301
10453
  timeoutMs,
@@ -10677,8 +10829,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
10677
10829
  `it is ${size3.width}x${size3.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
10678
10830
  );
10679
10831
  }
10680
- const path2 = join11(dir, assetName(url2, extFor2(sniffFormat(bytes))));
10681
- await writeFile11(path2, bytes);
10832
+ const path2 = join12(dir, assetName(url2, extFor2(sniffFormat(bytes))));
10833
+ await writeFile12(path2, bytes);
10682
10834
  assets.push(path2);
10683
10835
  got = { path: path2, width: size3.width, height: size3.height };
10684
10836
  } catch (err) {
@@ -10690,7 +10842,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
10690
10842
  };
10691
10843
  const shrink = async (url2, path2, measured, what) => {
10692
10844
  if (opts.transcode === false) return { path: path2, ...measured };
10693
- const out2 = join11(dir, assetName(url2, ".vp9.webm"));
10845
+ const out2 = join12(dir, assetName(url2, ".vp9.webm"));
10694
10846
  let small;
10695
10847
  try {
10696
10848
  small = await transcode(path2, out2, {
@@ -10732,8 +10884,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
10732
10884
  try {
10733
10885
  const bytes = await bytesOf(url2);
10734
10886
  const measured = videoSize(bytes);
10735
- const path2 = join11(dir, assetName(url2, `.${measured.container}`));
10736
- await writeFile11(path2, bytes);
10887
+ const path2 = join12(dir, assetName(url2, `.${measured.container}`));
10888
+ await writeFile12(path2, bytes);
10737
10889
  return await shrink(url2, path2, measured, what);
10738
10890
  } catch (err) {
10739
10891
  warnings.push(`${what} was not downloaded: ${url2} \u2014 ${why2(err)}`);
@@ -10822,7 +10974,7 @@ function unheld(policy) {
10822
10974
  return "the file could not be held";
10823
10975
  }
10824
10976
  async function sizeOf2(path2) {
10825
- return await stat(path2).then(
10977
+ return await stat2(path2).then(
10826
10978
  (s) => s.size,
10827
10979
  () => 0
10828
10980
  );
@@ -11030,8 +11182,8 @@ function sane(seconds) {
11030
11182
  }
11031
11183
  async function attachClips(source, clips, dir) {
11032
11184
  if (clips.length === 0) return source;
11033
- const assets = resolve4(dir);
11034
- await mkdir9(assets, { recursive: true });
11185
+ const assets = resolve5(dir);
11186
+ await mkdir10(assets, { recursive: true });
11035
11187
  const figures = [...source.figures];
11036
11188
  for (const clip3 of clips) {
11037
11189
  const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
@@ -11062,7 +11214,7 @@ async function attachClips(source, clips, dir) {
11062
11214
  }
11063
11215
  async function adopt(file, dir) {
11064
11216
  const name = basename3(file);
11065
- await copyFile(file, join11(dir, name));
11217
+ await copyFile(file, join12(dir, name));
11066
11218
  return name;
11067
11219
  }
11068
11220
  function freeId(figures) {
@@ -11331,26 +11483,26 @@ function textOf(node) {
11331
11483
 
11332
11484
  // src/tmpdir.ts
11333
11485
  import { realpathSync } from "node:fs";
11334
- import { createRequire as createRequire3 } from "node:module";
11486
+ import { createRequire as createRequire4 } from "node:module";
11335
11487
  import { tmpdir as tmpdir3 } from "node:os";
11336
- import { basename as basename4, dirname as dirname4, join as join12, resolve as resolve5, sep } from "node:path";
11488
+ import { basename as basename4, dirname as dirname5, join as join13, resolve as resolve6, sep } from "node:path";
11337
11489
  var cached;
11338
11490
  function real(dir) {
11339
- let at = resolve5(dir);
11491
+ let at = resolve6(dir);
11340
11492
  const tail3 = [];
11341
11493
  for (; ; ) {
11342
11494
  try {
11343
- return join12(realpathSync(at), ...tail3);
11495
+ return join13(realpathSync(at), ...tail3);
11344
11496
  } catch {
11345
- const up = dirname4(at);
11346
- if (up === at) return resolve5(dir);
11497
+ const up = dirname5(at);
11498
+ if (up === at) return resolve6(dir);
11347
11499
  tail3.unshift(basename4(at));
11348
11500
  at = up;
11349
11501
  }
11350
11502
  }
11351
11503
  }
11352
11504
  function packageRoot() {
11353
- cached ??= real(dirname4(createRequire3(import.meta.url).resolve("../package.json")));
11505
+ cached ??= real(dirname5(createRequire4(import.meta.url).resolve("../package.json")));
11354
11506
  return cached;
11355
11507
  }
11356
11508
  function insideRoot(dir) {
@@ -11378,8 +11530,8 @@ function guardTmpdir() {
11378
11530
  }
11379
11531
 
11380
11532
  // src/verify/index.ts
11381
- import { readdir as readdir3, readFile as readFile16 } from "node:fs/promises";
11382
- import { join as join16, relative, sep as sep2 } from "node:path";
11533
+ import { readdir as readdir4, readFile as readFile17 } from "node:fs/promises";
11534
+ import { join as join17, relative, sep as sep2 } from "node:path";
11383
11535
 
11384
11536
  // src/verify/budget.ts
11385
11537
  function readCanvas(html) {
@@ -11486,8 +11638,8 @@ function clock2(seconds) {
11486
11638
 
11487
11639
  // src/verify/check.ts
11488
11640
  import { execFile as execFile2 } from "node:child_process";
11489
- import { readFile as readFile13 } from "node:fs/promises";
11490
- import { join as join13 } from "node:path";
11641
+ import { readFile as readFile14 } from "node:fs/promises";
11642
+ import { join as join14 } from "node:path";
11491
11643
  import { promisify as promisify2 } from "node:util";
11492
11644
  var run2 = promisify2(execFile2);
11493
11645
  var DEFAULT_TIMEOUT_MS3 = 24e4;
@@ -11517,7 +11669,7 @@ async function check2(dir, opts = {}) {
11517
11669
  async function readComposition(dir) {
11518
11670
  let html;
11519
11671
  try {
11520
- html = await readFile13(join13(dir, "index.html"), "utf8");
11672
+ html = await readFile14(join14(dir, "index.html"), "utf8");
11521
11673
  } catch {
11522
11674
  return { transit: [], duration: 0 };
11523
11675
  }
@@ -11665,8 +11817,8 @@ function tail(s) {
11665
11817
  }
11666
11818
 
11667
11819
  // src/verify/fidelity.ts
11668
- import { readFile as readFile14 } from "node:fs/promises";
11669
- import { join as join14 } from "node:path";
11820
+ import { readFile as readFile15 } from "node:fs/promises";
11821
+ import { join as join15 } from "node:path";
11670
11822
 
11671
11823
  // src/verify/typefloor.ts
11672
11824
  var TYPE_FLOOR_PX = 40;
@@ -11922,6 +12074,16 @@ var INK_FLOOR = 15e-4;
11922
12074
  var INK_DELTA = 12;
11923
12075
  var FALLBACK_BAND_TOP = 0.22;
11924
12076
  var CAPTION = ".headline, .eyebrow";
12077
+ var RESERVE_INK_TOLERANCE = 1e-4;
12078
+ function readReserve(timingText) {
12079
+ if (!timingText) return 0;
12080
+ try {
12081
+ const reserve = JSON.parse(timingText).captionReserve;
12082
+ return typeof reserve === "number" && reserve > 0 ? reserve : 0;
12083
+ } catch {
12084
+ return 0;
12085
+ }
12086
+ }
11925
12087
  async function decodePng(png) {
11926
12088
  const buf = Buffer.from(png.buffer, png.byteOffset, png.byteLength);
11927
12089
  if (buf.length < 8 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG");
@@ -12088,6 +12250,25 @@ function gradeFidelity(rows, floor = INK_FLOOR) {
12088
12250
  };
12089
12251
  });
12090
12252
  }
12253
+ function gradeReserve(rows, tolerance = RESERVE_INK_TOLERANCE) {
12254
+ const dirty = /* @__PURE__ */ new Map();
12255
+ for (const row of rows) {
12256
+ if (row.reserveInk === void 0 || row.reserveInk <= tolerance) continue;
12257
+ const seen = dirty.get(row.sid);
12258
+ if (seen) seen.push(row);
12259
+ else dirty.set(row.sid, [row]);
12260
+ }
12261
+ const pct = (v) => `${(100 * v).toFixed(3)}%`;
12262
+ return [...dirty].map(([sid, stops]) => {
12263
+ const worst = stops.reduce((a, b) => (a.reserveInk ?? 0) >= (b.reserveInk ?? 0) ? a : b);
12264
+ return {
12265
+ severity: "error",
12266
+ gate: "fidelity",
12267
+ rule: "ink_in_caption_reserve",
12268
+ message: `#${sid} draws into the caption reserve ${stops.length === 1 ? "" : `at ${stops.length} stops `}\u2014 ${pct(worst.reserveInk ?? 0)} of the frame is ink inside the band at t=${worst.t}s (tolerance ${pct(tolerance)}). A burned caption would sit on top of it. Times: ${stops.map((s) => `${s.t}s`).join(", ")}.`
12269
+ };
12270
+ });
12271
+ }
12091
12272
  function captionBottom(sid, selector, fallbackPx) {
12092
12273
  const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
12093
12274
  let bottom = 0;
@@ -12112,11 +12293,10 @@ async function fidelity(dir, opts = {}) {
12112
12293
  ],
12113
12294
  elapsedMs: Date.now() - started
12114
12295
  });
12115
- const stops = opts.stops ?? readStops(
12116
- await readFile14(join14(dir, TIMING_FILE), "utf8").catch(() => null),
12117
- await readFile14(join14(dir, DECK_PAGE), "utf8").catch(() => null)
12118
- );
12296
+ const timingText = await readFile15(join15(dir, TIMING_FILE), "utf8").catch(() => null);
12297
+ const stops = opts.stops ?? readStops(timingText, await readFile15(join15(dir, DECK_PAGE), "utf8").catch(() => null));
12119
12298
  if (stops.length === 0) return notMeasured("the deck declares no stops");
12299
+ const reserve = opts.captionReserve ?? readReserve(timingText);
12120
12300
  let deck = null;
12121
12301
  try {
12122
12302
  deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
@@ -12145,7 +12325,13 @@ async function fidelity(dir, opts = {}) {
12145
12325
  measured.push({
12146
12326
  ...stop,
12147
12327
  ink: inkBelow(frame, bandTopPx),
12148
- bandTop: Math.round(1e3 * bandTopPx / height) / 1e3
12328
+ bandTop: Math.round(1e3 * bandTopPx / height) / 1e3,
12329
+ // THE SAME FRAME, a second strip. Free: it is one more pass over pixels
12330
+ // that are already decoded, so the gate that stops the caption
12331
+ // collision regressing costs no extra capture, no extra seek and no
12332
+ // extra browser. That matters — a gate with its own capture is a gate
12333
+ // someone turns off.
12334
+ ...reserve > 0 ? { reserveInk: inkBelow(frame, height - reserve) } : {}
12149
12335
  });
12150
12336
  }
12151
12337
  for (const mid of midpoints(stops)) {
@@ -12156,6 +12342,7 @@ async function fidelity(dir, opts = {}) {
12156
12342
  stops: measured,
12157
12343
  findings: [
12158
12344
  ...gradeFidelity(measured, floor),
12345
+ ...gradeReserve(measured),
12159
12346
  ...gradeOverprint(collided),
12160
12347
  ...gradeApparent(apparent)
12161
12348
  ],
@@ -12172,9 +12359,9 @@ async function fidelity(dir, opts = {}) {
12172
12359
  // src/verify/drift.ts
12173
12360
  import { execFile as execFile3 } from "node:child_process";
12174
12361
  import { createHash as createHash9 } from "node:crypto";
12175
- import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile15, rm as rm8 } from "node:fs/promises";
12362
+ import { mkdtemp as mkdtemp3, readdir as readdir3, readFile as readFile16, rm as rm8 } from "node:fs/promises";
12176
12363
  import { tmpdir as tmpdir4 } from "node:os";
12177
- import { join as join15 } from "node:path";
12364
+ import { join as join16 } from "node:path";
12178
12365
  import { promisify as promisify3 } from "node:util";
12179
12366
  var run3 = promisify3(execFile3);
12180
12367
  var FLOOR_DB = 40;
@@ -12213,9 +12400,9 @@ function measureMotion(hashes, scenes, seconds) {
12213
12400
  async function drift(dir, opts = {}) {
12214
12401
  const mode = opts.mode ?? "psnr";
12215
12402
  const floorDb = opts.floorDb ?? FLOOR_DB;
12216
- const work = opts.workDir ?? await mkdtemp3(join15(tmpdir4(), "decksmith-drift-"));
12217
- const a = join15(work, "a");
12218
- const b = join15(work, "b");
12403
+ const work = opts.workDir ?? await mkdtemp3(join16(tmpdir4(), "decksmith-drift-"));
12404
+ const a = join16(work, "a");
12405
+ const b = join16(work, "b");
12219
12406
  const report2 = await compare(dir, a, b, mode, floorDb, opts);
12220
12407
  const keep = opts.keep || !report2.passed;
12221
12408
  if (keep) report2.kept = { a, b };
@@ -12270,11 +12457,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
12270
12457
  const differing = [];
12271
12458
  const ha = [];
12272
12459
  for (const [i, name] of fa.entries()) {
12273
- const one = await sha(join15(a, name));
12460
+ const one = await sha(join16(a, name));
12274
12461
  ha.push(one);
12275
- if (one !== await sha(join15(b, name))) differing.push(i + 1);
12462
+ if (one !== await sha(join16(b, name))) differing.push(i + 1);
12276
12463
  }
12277
- const html = await readFile15(join15(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
12464
+ const html = await readFile16(join16(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
12278
12465
  const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
12279
12466
  let worst;
12280
12467
  if (differing.length > 0) {
@@ -12385,11 +12572,11 @@ async function render2(dir, out, opts) {
12385
12572
  }
12386
12573
  }
12387
12574
  async function frames(dir) {
12388
- const names = await readdir2(dir).catch(() => []);
12575
+ const names = await readdir3(dir).catch(() => []);
12389
12576
  return names.filter((n3) => n3.endsWith(".png")).sort();
12390
12577
  }
12391
12578
  async function sha(file) {
12392
- return createHash9("sha256").update(await readFile15(file)).digest("hex");
12579
+ return createHash9("sha256").update(await readFile16(file)).digest("hex");
12393
12580
  }
12394
12581
  var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
12395
12582
  function framePattern(name) {
@@ -12422,11 +12609,11 @@ async function psnr(a, b, firstFrame) {
12422
12609
  "-start_number",
12423
12610
  String(seq.start),
12424
12611
  "-i",
12425
- join15(a, seq.pattern),
12612
+ join16(a, seq.pattern),
12426
12613
  "-start_number",
12427
12614
  String(seq.start),
12428
12615
  "-i",
12429
- join15(b, seq.pattern),
12616
+ join16(b, seq.pattern),
12430
12617
  "-lavfi",
12431
12618
  "psnr=stats_file=-",
12432
12619
  "-f",
@@ -12501,7 +12688,7 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12501
12688
  const html = await readCompositions(dir);
12502
12689
  const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
12503
12690
  const narration = scanNarration(
12504
- await readFile16(join16(dir, DECK_PAGE), "utf8").catch(() => ""),
12691
+ await readFile17(join17(dir, DECK_PAGE), "utf8").catch(() => ""),
12505
12692
  await listFiles(dir)
12506
12693
  );
12507
12694
  const budget3 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
@@ -12538,8 +12725,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12538
12725
  }
12539
12726
  async function declaredStops(dir) {
12540
12727
  return readStops(
12541
- await readFile16(join16(dir, TIMING_FILE), "utf8").catch(() => null),
12542
- await readFile16(join16(dir, DECK_PAGE), "utf8").catch(() => null)
12728
+ await readFile17(join17(dir, TIMING_FILE), "utf8").catch(() => null),
12729
+ await readFile17(join17(dir, DECK_PAGE), "utf8").catch(() => null)
12543
12730
  );
12544
12731
  }
12545
12732
  var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
@@ -12579,7 +12766,7 @@ function scanNarration(page, files) {
12579
12766
  ];
12580
12767
  }
12581
12768
  async function readTiming2(dir) {
12582
- const raw2 = await readFile16(join16(dir, TIMING_FILE), "utf8").catch(() => "");
12769
+ const raw2 = await readFile17(join17(dir, TIMING_FILE), "utf8").catch(() => "");
12583
12770
  if (!raw2) return void 0;
12584
12771
  try {
12585
12772
  const parsed = JSON.parse(raw2);
@@ -12589,9 +12776,9 @@ async function readTiming2(dir) {
12589
12776
  }
12590
12777
  }
12591
12778
  async function listFiles(dir) {
12592
- const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12779
+ const entries = await readdir4(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12593
12780
  return new Set(
12594
- entries.filter((e) => e.isFile()).map((e) => relative(dir, join16(e.parentPath, e.name)).split(sep2).join("/"))
12781
+ entries.filter((e) => e.isFile()).map((e) => relative(dir, join17(e.parentPath, e.name)).split(sep2).join("/"))
12595
12782
  );
12596
12783
  }
12597
12784
  var INSTEAD = {
@@ -12847,72 +13034,35 @@ function scanDeterminism(html, file) {
12847
13034
  return findings;
12848
13035
  }
12849
13036
  async function readCompositions(dir) {
12850
- const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
13037
+ const entries = await readdir4(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12851
13038
  const files = entries.filter(
12852
13039
  (e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
12853
- ).map((e) => join16(e.parentPath, e.name));
13040
+ ).map((e) => join17(e.parentPath, e.name));
12854
13041
  return Promise.all(
12855
- files.map(async (f) => [relative(dir, f), await readFile16(f, "utf8")])
13042
+ files.map(async (f) => [relative(dir, f), await readFile17(f, "utf8")])
12856
13043
  );
12857
13044
  }
12858
13045
 
12859
13046
  // src/cli.ts
12860
13047
  guardTmpdir();
12861
- var AUDIO_DIR = "audio";
12862
13048
  var NARRATION_FILE = "narration.json";
12863
13049
  var DEFAULTS = prefsSchema.parse({});
12864
13050
  async function deckRuntime() {
12865
- const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
13051
+ const path2 = fileURLToPath2(new URL(`./${"deck-runtime.js"}`, import.meta.url));
12866
13052
  try {
12867
- return await readFile17(path2, "utf8");
13053
+ return await readFile18(path2, "utf8");
12868
13054
  } catch {
12869
13055
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
12870
13056
  }
12871
13057
  }
12872
13058
  function playerBundle() {
12873
- const require2 = createRequire4(import.meta.url);
13059
+ const require2 = createRequire5(import.meta.url);
12874
13060
  try {
12875
- return join17(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
13061
+ return join18(dirname6(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12876
13062
  } catch {
12877
13063
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
12878
13064
  }
12879
13065
  }
12880
- async function vendorKatex(out) {
12881
- const require2 = createRequire4(import.meta.url);
12882
- const dist = join17(dirname5(require2.resolve("katex/package.json")), "dist");
12883
- const css = await readFile17(join17(dist, "katex.min.css"), "utf8");
12884
- await mkdir10(join17(out, "katex/fonts"), { recursive: true });
12885
- for (const file of await readdir4(join17(dist, "fonts"))) {
12886
- if (file.endsWith(".woff2"))
12887
- await cp(join17(dist, "fonts", file), join17(out, "katex/fonts", file));
12888
- }
12889
- const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
12890
- const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
12891
- return kept ? `src:${kept}` : whole;
12892
- });
12893
- await writeFile12(join17(out, "katex/katex.min.css"), woff2Only);
12894
- }
12895
- async function vendorScripts(out, composition) {
12896
- const require2 = createRequire4(import.meta.url);
12897
- await mkdir10(join17(out, "vendor"), { recursive: true });
12898
- const wanted = (name) => composition.includes(`./vendor/${name}`);
12899
- for (const [pkg, rel, name] of [
12900
- ["gsap/package.json", "dist/gsap.min.js", "gsap.min.js"],
12901
- ["gsap/package.json", "dist/DrawSVGPlugin.min.js", "DrawSVGPlugin.min.js"],
12902
- ["gsap/package.json", "dist/MorphSVGPlugin.min.js", "MorphSVGPlugin.min.js"],
12903
- ["katex/package.json", "dist/katex.min.js", "katex.min.js"]
12904
- ]) {
12905
- if (!wanted(name)) continue;
12906
- const from = join17(dirname5(require2.resolve(pkg)), rel);
12907
- await cp(from, join17(out, "vendor", name));
12908
- }
12909
- if (wanted("ds-morph.js")) {
12910
- await cp(
12911
- fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
12912
- join17(out, "vendor", "ds-morph.js")
12913
- );
12914
- }
12915
- }
12916
13066
  var HYPERFRAMES_JSON = `${JSON.stringify(
12917
13067
  {
12918
13068
  $schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
@@ -13014,13 +13164,13 @@ program.command("ingest").description("Parse a document or a web page into sourc
13014
13164
  "--max-clip-seconds <n>",
13015
13165
  "URL only: seconds of each clip kept, the rest trimmed (maxClipSeconds; default 60)"
13016
13166
  ).option("--no-transcode", "URL only: ship each clip as the page served it, unshrunk").action(async (input, o) => {
13017
- const assets = join17(dirname5(resolve6(o.out)), "assets");
13018
- const scratch = url(input) ? await mkdtemp4(join17(tmpdir5(), "decksmith-harvest-")) : void 0;
13167
+ const assets = join18(dirname6(resolve7(o.out)), "assets");
13168
+ const scratch = url(input) ? await mkdtemp4(join18(tmpdir5(), "decksmith-harvest-")) : void 0;
13019
13169
  try {
13020
13170
  let md;
13021
13171
  let clips = [];
13022
13172
  if (scratch === void 0) {
13023
- md = await readFile17(resolve6(input), "utf8").catch(() => {
13173
+ md = await readFile18(resolve7(input), "utf8").catch(() => {
13024
13174
  throw new Error(`Cannot read ${input}.`);
13025
13175
  });
13026
13176
  } else {
@@ -13041,7 +13191,7 @@ program.command("ingest").description("Parse a document or a web page into sourc
13041
13191
  const dropped = [];
13042
13192
  const source = await fetchFigures(withClips, assets, dropped);
13043
13193
  for (const why3 of dropped) step(`ingest: ${why3}`);
13044
- const bundle = await bundleFont(source.lang, glyphs(source), join17(assets, "fonts"));
13194
+ const bundle = await bundleFont(source.lang, glyphs(source), join18(assets, "fonts"));
13045
13195
  if (bundle) step(`ingest: bundled ${bundle.family} for ${source.lang}`);
13046
13196
  await writeJson(o.out, source);
13047
13197
  step(`ingest: wrote ${o.out}`);
@@ -13102,7 +13252,7 @@ imageFlags(
13102
13252
  program.command("illustrate").description("Draw the pictures the plan asked for, and point the storyboard at them.").argument("<storyboard>", "storyboard.json carrying illustration briefs").requiredOption("--source <file>", "source.json the storyboard was planned from")
13103
13253
  ).action(async (sbPath, o) => {
13104
13254
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
13105
- const sourcePath = resolve6(String(o.source));
13255
+ const sourcePath = resolve7(String(o.source));
13106
13256
  const source = await readValidated(sourcePath, sourceSchema, "source");
13107
13257
  assertRefsResolve(storyboard, source, { pending: "allow" });
13108
13258
  const prefs = await loadPrefs(prefsFromFlags({ ...flags(o), images: true }));
@@ -13116,7 +13266,7 @@ imageFlags(
13116
13266
  );
13117
13267
  const drawn = await illustrate(storyboard, source, {
13118
13268
  prefs,
13119
- assetsDir: join17(dirname5(sourcePath), "assets"),
13269
+ assetsDir: join18(dirname6(sourcePath), "assets"),
13120
13270
  onStep: step
13121
13271
  });
13122
13272
  for (const p of drawn.illustrated) {
@@ -13141,34 +13291,44 @@ voiceFlags(
13141
13291
  const format = pickFormat(String(o.format), o.width, o.height);
13142
13292
  const chosen = await loadPrefs(prefsFromFlags(flags(o)));
13143
13293
  const prefs = stated(chosen, "lang") ? chosen : { ...chosen, lang: storyboard.lang };
13144
- const dir = resolve6(String(o.out));
13294
+ const dir = resolve7(String(o.out));
13145
13295
  const speaking = storyboard.beats.filter((b) => b.narration?.trim()).length;
13146
13296
  if (speaking === 0) {
13147
13297
  throw new Error(`No beat in ${sbPath} has a "narration" field, so there is nothing to speak.`);
13148
13298
  }
13149
13299
  step(`narrate: ${speaking} of ${storyboard.beats.length} beats have narration`);
13150
13300
  const narration = await narrate(storyboard, source, prefs, { dir, format });
13151
- await writeJson(join17(dir, NARRATION_FILE), narration);
13301
+ await writeJson(join18(dir, NARRATION_FILE), narration);
13152
13302
  const segments2 = Object.values(narration.beats).flat();
13153
13303
  const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
13154
13304
  step(
13155
- `narrate: ${segments2.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join17(dir, NARRATION_FILE)}`
13305
+ `narrate: ${segments2.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join18(dir, NARRATION_FILE)}`
13156
13306
  );
13157
13307
  });
13158
13308
  lookFlags(
13159
13309
  sizeFlags(
13160
- lengthFlags(program.command("build")).description("Emit the composition, write its assets, and run the gates.").argument("<storyboard>", "storyboard.json, edited to taste").requiredOption("--source <file>", "source.json the storyboard was planned from").requiredOption("-o, --out <dir>", "directory to write the deck into").option("--format <id>", `output profile: ${Object.keys(FORMATS).join(" | ")}`, "deck-16x9").option("--min-weight <n>", "keep only beats at or above this weight \u2014 see the budget gate").option("--genre <genre>", "general | paper \u2014 report when a paper deck lacks its arc").option("--narration <file>", `${NARRATION_FILE} from \`decksmith narrate\``).option("--no-narration", "ignore narration sitting beside the storyboard").option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser")
13310
+ lengthFlags(program.command("build")).description("Emit the composition, write its assets, and run the gates.").argument("<storyboard>", "storyboard.json, edited to taste").requiredOption("--source <file>", "source.json the storyboard was planned from").requiredOption("-o, --out <dir>", "directory to write the deck into").option("--format <id>", `output profile: ${Object.keys(FORMATS).join(" | ")}`, "deck-16x9").option("--min-weight <n>", "keep only beats at or above this weight \u2014 see the budget gate").option("--genre <genre>", "general | paper \u2014 report when a paper deck lacks its arc").option("--narration <file>", `${NARRATION_FILE} from \`decksmith narrate\``).option("--no-narration", "ignore narration sitting beside the storyboard").option(
13311
+ "--reserve-captions",
13312
+ "keep the bottom of the frame clear for a burned caption \u2014 required by `render --subtitles burn`"
13313
+ ).option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser")
13161
13314
  )
13162
13315
  ).action(
13163
13316
  async (sbPath, o) => {
13164
13317
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
13165
13318
  const source = await readValidated(o.source, sourceSchema, "source");
13166
13319
  assertRefsResolve(storyboard, source);
13167
- const format = withMinWeight(pickFormat(o.format, o.width, o.height), o.minWeight);
13320
+ const format = withCaptionReserve(
13321
+ withMinWeight(pickFormat(o.format, o.width, o.height), o.minWeight),
13322
+ o.reserveCaptions === true
13323
+ );
13324
+ if (format.captionReserve)
13325
+ step(
13326
+ `build: reserving ${format.captionReserve}px at the bottom for burned captions \u2014 ${(100 * format.captionReserve / format.height).toFixed(1)}% of the frame`
13327
+ );
13168
13328
  const prefs = await loadPrefs(prefsFromFlags(flags(o)), process.cwd(), source);
13169
13329
  const theme = stated(prefs, "theme") ?? storyboard.theme;
13170
- const out = resolve6(o.out);
13171
- await mkdir10(out, { recursive: true });
13330
+ const out = resolve7(o.out);
13331
+ await mkdir11(out, { recursive: true });
13172
13332
  const found = await findNarration(sbPath, o.narration);
13173
13333
  const narration = found ? await loadNarration(found) : void 0;
13174
13334
  if (narration) {
@@ -13181,7 +13341,7 @@ lookFlags(
13181
13341
  const paced = durationPlan(prefs, storyboard.beats.length);
13182
13342
  for (const f of scanBeatCount(storyboard, prefs)) step(`build: ${f.message}`);
13183
13343
  for (const w of paced.warnings) step(`build: ${w}`);
13184
- const fontCss = await refreshFont(storyboard, source, out);
13344
+ const fontCss = await refreshFont(storyboard, source, out, step);
13185
13345
  const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
13186
13346
  theme,
13187
13347
  ...fontCss ? { fontCss } : {},
@@ -13198,8 +13358,8 @@ lookFlags(
13198
13358
  // place anyone learns it is not.
13199
13359
  onBeatWarning: (id2, warning) => step(`build: kept ${id2} \u2014 ${warning}`)
13200
13360
  });
13201
- await writeFile12(join17(out, "index.html"), deck.composition);
13202
- await writeFile12(join17(out, "hyperframes.json"), HYPERFRAMES_JSON);
13361
+ await writeFile13(join18(out, "index.html"), deck.composition);
13362
+ await writeFile13(join18(out, "hyperframes.json"), HYPERFRAMES_JSON);
13203
13363
  await writeTiming(out, {
13204
13364
  storyboard,
13205
13365
  source,
@@ -13215,36 +13375,36 @@ lookFlags(
13215
13375
  ...narration ? { narration } : {}
13216
13376
  });
13217
13377
  if (deck.page) {
13218
- await writeFile12(join17(out, DECK_PAGE), deck.page);
13219
- await cp(playerBundle(), join17(out, PLAYER_FILE));
13378
+ await writeFile13(join18(out, DECK_PAGE), deck.page);
13379
+ await cp2(playerBundle(), join18(out, PLAYER_FILE));
13220
13380
  }
13221
13381
  await vendorKatex(out);
13222
13382
  await vendorScripts(out, deck.composition);
13223
- await copyAssets(dirname5(resolve6(o.source)), out, source.figures);
13224
- if (found && narration) await copyAudio(dirname5(found), narration, out);
13383
+ await copyAssets(dirname6(resolve7(o.source)), out, source.figures, step);
13384
+ if (found && narration) await copyAudio(dirname6(found), narration, out, step);
13225
13385
  const look = [theme, paced.speed === 1 ? "" : `${paced.speed}\xD7 speed`].filter(Boolean).join(", ");
13226
13386
  const { cut } = deck;
13227
13387
  const floor = cut.dropped.filter((d) => d.rule === "below_min_weight").length;
13228
13388
  const of = cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
13229
13389
  step(
13230
- `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join17(out, "index.html")}`
13390
+ `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join18(out, "index.html")}`
13231
13391
  );
13232
13392
  for (const f of scanPaperArc({ ...storyboard, beats: cut.kept }, prefs))
13233
13393
  step(`build: ${f.message}`);
13234
13394
  reportCut(cut);
13235
- if (deck.page) step(`build: navigable deck \u2192 ${join17(out, DECK_PAGE)}`);
13395
+ if (deck.page) step(`build: navigable deck \u2192 ${join18(out, DECK_PAGE)}`);
13236
13396
  await gate(out, false, storyboard, cut.kept, o.fidelity !== false, source);
13237
13397
  }
13238
13398
  );
13239
13399
  program.command("verify").description("Re-run the gates on a built deck.").argument("<dir>", "a built deck directory").option("--snapshots", "also write the contrast-pass PNGs to <dir>/snapshots", false).option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser").action(async (dir, o) => {
13240
- await gate(resolve6(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
13400
+ await gate(resolve7(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
13241
13401
  });
13242
13402
  program.command("frames").description("Write PNGs of a built deck through the capture path the renderer uses.").argument("<dir>", "a built deck directory").option(
13243
13403
  "--at <seconds...>",
13244
13404
  "absolute times to photograph; defaults to every hold the deck declares"
13245
13405
  ).option("--out <dir>", "where to write the PNGs (default: <dir>/frames)").action(async (dir, o) => {
13246
- const deck = resolve6(dir);
13247
- const out = resolve6(o.out ?? join17(deck, "frames"));
13406
+ const deck = resolve7(dir);
13407
+ const out = resolve7(o.out ?? join18(deck, "frames"));
13248
13408
  let times;
13249
13409
  if (o.at?.length) {
13250
13410
  times = o.at.map((raw2) => {
@@ -13254,8 +13414,8 @@ program.command("frames").description("Write PNGs of a built deck through the ca
13254
13414
  });
13255
13415
  } else {
13256
13416
  times = readStops(
13257
- await readFile17(join17(deck, TIMING_FILE), "utf8").catch(() => null),
13258
- await readFile17(join17(deck, DECK_PAGE), "utf8").catch(() => null)
13417
+ await readFile18(join18(deck, TIMING_FILE), "utf8").catch(() => null),
13418
+ await readFile18(join18(deck, DECK_PAGE), "utf8").catch(() => null)
13259
13419
  ).map((s) => s.t);
13260
13420
  if (times.length === 0)
13261
13421
  throw new Error(
@@ -13269,7 +13429,7 @@ program.command("frames").description("Write PNGs of a built deck through the ca
13269
13429
  program.command("drift").description("Render a built deck twice and compare, frame by frame. Minutes, not seconds.").argument("<dir>", "a built deck directory").option("--identical", "require every frame byte-identical \u2014 image-free decks only", false).option("--floor <dB>", "per-frame PSNR floor", String(FLOOR_DB)).option("--keep", "keep both frame directories even when they agree").option("--workers <n>", "pin both renders to one worker count (default: 1 vs 3)").action(
13270
13430
  async (dir, o) => {
13271
13431
  step("drift: rendering twice, several minutes a render");
13272
- const verdict = await drift(resolve6(dir), {
13432
+ const verdict = await drift(resolve7(dir), {
13273
13433
  mode: o.identical ? "identical" : "psnr",
13274
13434
  floorDb: Number(o.floor),
13275
13435
  ...o.keep ? { keep: true } : {},
@@ -13296,8 +13456,8 @@ program.command("render").description("Render a built deck to a finished video:
13296
13456
  throw new Error(`Unknown --subtitles "${o.subtitles}". Use sidecar, burn or none.`);
13297
13457
  }
13298
13458
  const result = await render({
13299
- deck: resolve6(dir),
13300
- out: resolve6(o.out),
13459
+ deck: resolve7(dir),
13460
+ out: resolve7(o.out),
13301
13461
  subtitles: o.subtitles,
13302
13462
  protocolTimeoutMs: Number(o.protocolTimeout),
13303
13463
  log: step,
@@ -13325,7 +13485,7 @@ voiceFlags(
13325
13485
  )
13326
13486
  ).action(async (sbPath, o) => {
13327
13487
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
13328
- const sourcePath = resolve6(String(o.source));
13488
+ const sourcePath = resolve7(String(o.source));
13329
13489
  const source = await readValidated(sourcePath, sourceSchema, "source");
13330
13490
  assertRefsResolve(storyboard, source);
13331
13491
  if (o.bake && o.link) throw new Error("Choose --bake or --link, not both.");
@@ -13340,7 +13500,7 @@ voiceFlags(
13340
13500
  const prefer = o.link ? "link" : "bake";
13341
13501
  const plan = await planMedia(figureAssets(source, sourcePath, prefer));
13342
13502
  const files = { ...plan.files };
13343
- if (found && narration) Object.assign(files, await audioFiles(dirname5(found), narration));
13503
+ if (found && narration) Object.assign(files, await audioFiles(dirname6(found), narration));
13344
13504
  const pack2 = {
13345
13505
  version: PACK_VERSION,
13346
13506
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -13355,7 +13515,7 @@ voiceFlags(
13355
13515
  ...narration ? { narration } : {},
13356
13516
  media: plan.media
13357
13517
  };
13358
- const out = resolve6(String(o.out));
13518
+ const out = resolve7(String(o.out));
13359
13519
  const bytes = await writePack(pack2, files, out);
13360
13520
  if (plan.demoted.length) {
13361
13521
  step(`pack: kept as links, not bakeable \u2014 ${plan.demoted.join(", ")}`);
@@ -13369,19 +13529,19 @@ voiceFlags(
13369
13529
  step(`pack: ${size2(bytes)} \u2192 ${out}`);
13370
13530
  });
13371
13531
  program.command("unpack").description("Open a .deck back into the files build reads.").argument("<file>", "a .deck archive").requiredOption("-o, --out <dir>", "directory to open it into").action(async (file, o) => {
13372
- const { pack: pack2, files } = await readPack(resolve6(file));
13373
- const out = resolve6(o.out);
13374
- await writeJson(join17(out, "source.json"), pack2.source);
13375
- await writeJson(join17(out, "storyboard.json"), pack2.storyboard);
13376
- await writeJson(join17(out, "decksmith.config.json"), pack2.prefs);
13377
- if (pack2.narration) await writeJson(join17(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
13532
+ const { pack: pack2, files } = await readPack(resolve7(file));
13533
+ const out = resolve7(o.out);
13534
+ await writeJson(join18(out, "source.json"), pack2.source);
13535
+ await writeJson(join18(out, "storyboard.json"), pack2.storyboard);
13536
+ await writeJson(join18(out, "decksmith.config.json"), pack2.prefs);
13537
+ if (pack2.narration) await writeJson(join18(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
13378
13538
  const figures = new Map(pack2.source.figures.map((f) => [f.id, f.src]));
13379
13539
  for (const [path2, bytes] of Object.entries(files)) {
13380
13540
  const media = pack2.media.find((m) => m.path === path2);
13381
13541
  const src = media && figures.get(media.id);
13382
- const to = src ? join17("assets", src) : path2;
13383
- await mkdir10(dirname5(join17(out, to)), { recursive: true });
13384
- await writeFile12(join17(out, to), bytes);
13542
+ const to = src ? join18("assets", src) : path2;
13543
+ await mkdir11(dirname6(join18(out, to)), { recursive: true });
13544
+ await writeFile13(join18(out, to), bytes);
13385
13545
  }
13386
13546
  const linked = pack2.media.filter((m) => m.policy !== "bake");
13387
13547
  step(`unpack: "${pack2.title}", ${pack2.storyboard.beats.length} beats \u2192 ${out}`);
@@ -13391,7 +13551,7 @@ program.command("unpack").description("Open a .deck back into the files build re
13391
13551
  );
13392
13552
  }
13393
13553
  step(
13394
- `unpack: decksmith build ${join17(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join17(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
13554
+ `unpack: decksmith build ${join18(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join18(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
13395
13555
  );
13396
13556
  });
13397
13557
  program.parseAsync(process.argv).catch((err) => {
@@ -13412,7 +13572,7 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
13412
13572
  );
13413
13573
  const verdict = await verify(dir, { snapshots, fidelity: fidelity2 }, storyboard, kept, source);
13414
13574
  process.stdout.write(report(verdict));
13415
- if (snapshots) step(`verify: snapshots in ${join17(dir, "snapshots")}`);
13575
+ if (snapshots) step(`verify: snapshots in ${join18(dir, "snapshots")}`);
13416
13576
  if (!verdict.passed) process.exitCode = 1;
13417
13577
  }
13418
13578
  function report(v) {
@@ -13429,13 +13589,13 @@ function report(v) {
13429
13589
  async function findNarration(sbPath, flag) {
13430
13590
  if (flag === false) return void 0;
13431
13591
  if (typeof flag === "string") {
13432
- const path2 = resolve6(flag);
13433
- if (!await stat2(path2).catch(() => null)) throw new Error(`Cannot read narration ${flag}.`);
13592
+ const path2 = resolve7(flag);
13593
+ if (!await stat3(path2).catch(() => null)) throw new Error(`Cannot read narration ${flag}.`);
13434
13594
  return path2;
13435
13595
  }
13436
- const beside = dirname5(resolve6(sbPath));
13437
- for (const candidate of [join17(beside, AUDIO_DIR, NARRATION_FILE), join17(beside, NARRATION_FILE)]) {
13438
- if (await stat2(candidate).catch(() => null)) {
13596
+ const beside = dirname6(resolve7(sbPath));
13597
+ for (const candidate of [join18(beside, AUDIO_DIR, NARRATION_FILE), join18(beside, NARRATION_FILE)]) {
13598
+ if (await stat3(candidate).catch(() => null)) {
13439
13599
  step(`narration: using ${candidate}`);
13440
13600
  return candidate;
13441
13601
  }
@@ -13446,28 +13606,10 @@ async function loadNarration(path2) {
13446
13606
  const narration = await readValidated(path2, narrationSchema, "narration");
13447
13607
  return { voice: narration.voice, dir: AUDIO_DIR, beats: narration.beats };
13448
13608
  }
13449
- function audioNames(narration) {
13450
- return [
13451
- ...new Set(
13452
- Object.values(narration.beats).flat().map((s) => s.audio)
13453
- )
13454
- ].sort();
13455
- }
13456
- async function copyAudio(from, narration, out) {
13457
- const dir = join17(out, AUDIO_DIR);
13458
- await mkdir10(dir, { recursive: true });
13459
- const names = audioNames(narration);
13460
- for (const name of names) {
13461
- await cp(join17(from, name), join17(dir, name)).catch(() => {
13462
- throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
13463
- });
13464
- }
13465
- step(`narration: ${names.length} audio file(s) \u2192 ${dir}`);
13466
- }
13467
13609
  async function audioFiles(from, narration) {
13468
13610
  const files = {};
13469
13611
  for (const name of audioNames(narration)) {
13470
- const bytes = await readFile17(join17(from, name)).catch(() => {
13612
+ const bytes = await readFile18(join18(from, name)).catch(() => {
13471
13613
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
13472
13614
  });
13473
13615
  files[`${AUDIO_DIR}/${name}`] = new Uint8Array(bytes);
@@ -13489,9 +13631,9 @@ function wrapScript(text2, width) {
13489
13631
  async function writeTiming(out, input) {
13490
13632
  try {
13491
13633
  const timing = planTiming(input);
13492
- await writeJson(join17(out, TIMING_FILE), timing);
13634
+ await writeJson(join18(out, TIMING_FILE), timing);
13493
13635
  step(
13494
- `build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join17(out, TIMING_FILE)}`
13636
+ `build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join18(out, TIMING_FILE)}`
13495
13637
  );
13496
13638
  } catch (err) {
13497
13639
  step(
@@ -13500,10 +13642,10 @@ async function writeTiming(out, input) {
13500
13642
  }
13501
13643
  }
13502
13644
  function figureAssets(source, sourcePath, prefer) {
13503
- const assets = join17(dirname5(sourcePath), "assets");
13645
+ const assets = join18(dirname6(sourcePath), "assets");
13504
13646
  return source.figures.map((f) => ({
13505
13647
  id: f.id,
13506
- url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join17(assets, f.src),
13648
+ url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join18(assets, f.src),
13507
13649
  prefer
13508
13650
  }));
13509
13651
  }
@@ -13521,6 +13663,10 @@ function pickFormat(id2, width, height) {
13521
13663
  for (const warning of canvasWarnings(format.width, format.height)) step(`format: ${warning}`);
13522
13664
  return format;
13523
13665
  }
13666
+ function withCaptionReserve(format, on) {
13667
+ if (!on) return format;
13668
+ return { ...format, captionReserve: bandReserve(format.width, format.height) };
13669
+ }
13524
13670
  function sizeFlags(cmd) {
13525
13671
  return cmd.option("--width <px>", `canvas width in pixels (${MIN_EDGE}\u2013${MAX_EDGE})`).option("--height <px>", `canvas height in pixels (${MIN_EDGE}\u2013${MAX_EDGE})`);
13526
13672
  }
@@ -13531,52 +13677,11 @@ function withMinWeight(format, raw2) {
13531
13677
  throw new Error(`--min-weight takes a number from 0 to 1, not "${raw2}".`);
13532
13678
  return { ...format, minWeight };
13533
13679
  }
13534
- async function copyAssets(sourceDir, out, figures) {
13535
- const from = join17(sourceDir, "assets");
13536
- if (!await stat2(from).catch(() => null)) {
13537
- step(`build: no assets/ beside source.json, skipping`);
13538
- return;
13539
- }
13540
- const wanted = new Set(
13541
- figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
13542
- );
13543
- await mkdir10(join17(out, "assets"), { recursive: true });
13544
- let copied = 0;
13545
- for (const name of wanted) {
13546
- const src = resolve6(join17(from, name));
13547
- if (!src.startsWith(`${resolve6(from)}/`)) continue;
13548
- if (!await stat2(src).catch(() => null)) continue;
13549
- await mkdir10(dirname5(join17(out, "assets", name)), { recursive: true });
13550
- await cp(src, join17(out, "assets", name));
13551
- copied++;
13552
- }
13553
- const fonts = join17(from, "fonts");
13554
- if (await stat2(fonts).catch(() => null)) {
13555
- await cp(fonts, join17(out, "assets", "fonts"), { recursive: true });
13556
- }
13557
- step(`build: copied ${copied} referenced figure(s)`);
13558
- }
13559
- async function refreshFont(storyboard, source, out) {
13560
- try {
13561
- const bundle = await bundleFont(
13562
- storyboard.lang,
13563
- glyphs(source) + glyphs(storyboard),
13564
- join17(out, "assets", "fonts")
13565
- );
13566
- if (bundle) step(`build: font bundle covers ${bundle.family}`);
13567
- return bundle?.css;
13568
- } catch (err) {
13569
- step(
13570
- `build: could not refresh the font bundle (${err instanceof Error ? err.message : err}); keeping the one from ingest`
13571
- );
13572
- return void 0;
13573
- }
13574
- }
13575
13680
  function glyphs(value) {
13576
13681
  return JSON.stringify(value);
13577
13682
  }
13578
13683
  async function readValidated(file, schema, label) {
13579
- const text2 = await readFile17(resolve6(file), "utf8").catch(() => {
13684
+ const text2 = await readFile18(resolve7(file), "utf8").catch(() => {
13580
13685
  throw new Error(`Cannot read ${label} file ${file}.`);
13581
13686
  });
13582
13687
  let json;
@@ -13594,9 +13699,9 @@ ${issues}`);
13594
13699
  return parsed.data;
13595
13700
  }
13596
13701
  async function writeJson(file, value) {
13597
- const path2 = resolve6(file);
13598
- await mkdir10(dirname5(path2), { recursive: true });
13599
- await writeFile12(path2, `${JSON.stringify(value, null, 2)}
13702
+ const path2 = resolve7(file);
13703
+ await mkdir11(dirname6(path2), { recursive: true });
13704
+ await writeFile13(path2, `${JSON.stringify(value, null, 2)}
13600
13705
  `);
13601
13706
  }
13602
13707
  function size2(bytes) {