@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/index.js CHANGED
@@ -1,12 +1,176 @@
1
1
  // src/index.ts
2
- import { cp, mkdir as mkdir10, readdir as readdir4, readFile as readFile17, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
3
- import { createRequire as createRequire4 } from "node:module";
4
- import { dirname as dirname5, join as join17, resolve as resolve6 } from "node:path";
2
+ import { cp as cp2, mkdir as mkdir11, readFile as readFile18, writeFile as writeFile13 } from "node:fs/promises";
3
+ import { createRequire as createRequire5 } from "node:module";
4
+ import { dirname as dirname6, join as join18, resolve as resolve7 } from "node:path";
5
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
6
+
7
+ // src/build/files.ts
8
+ import { cp, mkdir as mkdir2, readdir, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
9
+ import { createRequire } from "node:module";
10
+ import { dirname, join as join2, resolve } from "node:path";
5
11
  import { fileURLToPath } from "node:url";
6
12
 
7
- // src/pack/media.ts
13
+ // src/source/fonts.ts
8
14
  import { createHash } from "node:crypto";
9
- import { readFile } from "node:fs/promises";
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ function familyFor(lang) {
18
+ const tag = lang.toLowerCase();
19
+ if (tag.startsWith("ko")) return "Noto Sans KR";
20
+ if (tag.startsWith("ja")) return "Noto Sans JP";
21
+ if (tag.startsWith("zh")) return /hant|-tw|-hk|-mo/.test(tag) ? "Noto Sans TC" : "Noto Sans SC";
22
+ return null;
23
+ }
24
+ 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";
25
+ async function bundleFont(lang, glyphs, dir) {
26
+ const family = familyFor(lang);
27
+ if (!family) return null;
28
+ const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
29
+ const stamp = `/* decksmith ${createHash("sha256").update(`${family}
30
+ ${text2}`).digest("hex").slice(0, 16)} */`;
31
+ await mkdir(dir, { recursive: true });
32
+ const cssPath = join(dir, "fonts.css");
33
+ const cached2 = await readFile(cssPath, "utf8").catch(() => "");
34
+ if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
35
+ const res = await fetch(
36
+ `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
37
+ { headers: { "User-Agent": UA } }
38
+ );
39
+ if (!res.ok) throw new Error(`google fonts: HTTP ${res.status}`);
40
+ let css = await res.text();
41
+ const remote = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1] ?? ""))];
42
+ const slug = family.toLowerCase().replace(/[^a-z0-9]/g, "");
43
+ const files = [];
44
+ for (const [i, url] of remote.entries()) {
45
+ const font = await fetch(url);
46
+ if (!font.ok) throw new Error(`${url}: HTTP ${font.status}`);
47
+ const name = `${slug}-${i}.woff2`;
48
+ await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
49
+ css = css.replaceAll(url, name);
50
+ files.push(name);
51
+ }
52
+ css = `${stamp}
53
+ ${css}`;
54
+ await writeFile(cssPath, css);
55
+ return { family, css, files };
56
+ }
57
+ function localNames(css) {
58
+ return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
59
+ }
60
+
61
+ // src/build/files.ts
62
+ function audioNames(narration) {
63
+ return [
64
+ ...new Set(
65
+ Object.values(narration.beats).flat().map((s) => s.audio)
66
+ )
67
+ ].sort();
68
+ }
69
+ async function vendorKatex(out) {
70
+ const require2 = createRequire(import.meta.url);
71
+ const dist = join2(dirname(require2.resolve("katex/package.json")), "dist");
72
+ const css = await readFile2(join2(dist, "katex.min.css"), "utf8");
73
+ const written = [];
74
+ await mkdir2(join2(out, "katex/fonts"), { recursive: true });
75
+ for (const file of await readdir(join2(dist, "fonts"))) {
76
+ if (!file.endsWith(".woff2")) continue;
77
+ await cp(join2(dist, "fonts", file), join2(out, "katex/fonts", file));
78
+ written.push(join2(out, "katex/fonts", file));
79
+ }
80
+ const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
81
+ const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
82
+ return kept ? `src:${kept}` : whole;
83
+ });
84
+ await writeFile2(join2(out, "katex/katex.min.css"), woff2Only);
85
+ written.push(join2(out, "katex/katex.min.css"));
86
+ return written;
87
+ }
88
+ async function vendorScripts(out, composition) {
89
+ const require2 = createRequire(import.meta.url);
90
+ await mkdir2(join2(out, "vendor"), { recursive: true });
91
+ const wanted = (name) => composition.includes(`./vendor/${name}`);
92
+ const written = [];
93
+ for (const [pkg, rel, name] of [
94
+ ["gsap/package.json", "dist/gsap.min.js", "gsap.min.js"],
95
+ ["gsap/package.json", "dist/DrawSVGPlugin.min.js", "DrawSVGPlugin.min.js"],
96
+ ["gsap/package.json", "dist/MorphSVGPlugin.min.js", "MorphSVGPlugin.min.js"],
97
+ ["katex/package.json", "dist/katex.min.js", "katex.min.js"]
98
+ ]) {
99
+ if (!wanted(name)) continue;
100
+ const from = join2(dirname(require2.resolve(pkg)), rel);
101
+ await cp(from, join2(out, "vendor", name));
102
+ written.push(join2(out, "vendor", name));
103
+ }
104
+ if (wanted("ds-morph.js")) {
105
+ await cp(
106
+ fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
107
+ join2(out, "vendor", "ds-morph.js")
108
+ );
109
+ written.push(join2(out, "vendor", "ds-morph.js"));
110
+ }
111
+ return written;
112
+ }
113
+ async function copyAssets(sourceDir, out, figures, step) {
114
+ const from = join2(resolve(sourceDir), "assets");
115
+ if (!await stat(from).catch(() => null)) {
116
+ step(`build: no assets/ beside source.json, skipping`);
117
+ return [];
118
+ }
119
+ const wanted = new Set(
120
+ figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
121
+ );
122
+ await mkdir2(join2(out, "assets"), { recursive: true });
123
+ const written = [];
124
+ let copied = 0;
125
+ for (const name of wanted) {
126
+ const src = resolve(join2(from, name));
127
+ if (!src.startsWith(`${from}/`)) continue;
128
+ if (!await stat(src).catch(() => null)) continue;
129
+ await mkdir2(dirname(join2(out, "assets", name)), { recursive: true });
130
+ await cp(src, join2(out, "assets", name));
131
+ written.push(join2(out, "assets", name));
132
+ copied++;
133
+ }
134
+ const fonts = join2(from, "fonts");
135
+ if (await stat(fonts).catch(() => null)) {
136
+ await cp(fonts, join2(out, "assets", "fonts"), { recursive: true });
137
+ written.push(join2(out, "assets", "fonts"));
138
+ }
139
+ step(`build: copied ${copied} referenced figure(s)`);
140
+ return written;
141
+ }
142
+ async function copyAudio(from, narration, out, step) {
143
+ const dir = join2(out, narration.dir);
144
+ await mkdir2(dir, { recursive: true });
145
+ const names = audioNames(narration);
146
+ for (const name of names) {
147
+ await cp(join2(resolve(from), name), join2(dir, name)).catch(() => {
148
+ throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
149
+ });
150
+ }
151
+ step(`narration: ${names.length} audio file(s) \u2192 ${dir}`);
152
+ return names.map((n3) => join2(dir, n3));
153
+ }
154
+ async function refreshFont(storyboard, source, out, step) {
155
+ try {
156
+ const bundle = await bundleFont(
157
+ storyboard.lang,
158
+ JSON.stringify(source) + JSON.stringify(storyboard),
159
+ join2(out, "assets", "fonts")
160
+ );
161
+ if (bundle) step(`build: font bundle covers ${bundle.family}`);
162
+ return bundle?.css;
163
+ } catch (err) {
164
+ step(
165
+ `build: could not refresh the font bundle (${err instanceof Error ? err.message : err}); keeping the one from ingest`
166
+ );
167
+ return void 0;
168
+ }
169
+ }
170
+
171
+ // src/pack/media.ts
172
+ import { createHash as createHash2 } from "node:crypto";
173
+ import { readFile as readFile3 } from "node:fs/promises";
10
174
  import { extname } from "node:path";
11
175
  var PLAYERS = [
12
176
  "youtube.com",
@@ -180,7 +344,7 @@ function mediaSummary(plan) {
180
344
  }
181
345
  function bakedName(id2, url, mime) {
182
346
  const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
183
- const hash = createHash("sha256").update(url).digest("hex").slice(0, 8);
347
+ const hash = createHash2("sha256").update(url).digest("hex").slice(0, 8);
184
348
  return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
185
349
  }
186
350
  function extOf(url) {
@@ -235,7 +399,7 @@ async function fetchAsset(url) {
235
399
  mime: res.headers.get("content-type") ?? void 0
236
400
  };
237
401
  }
238
- return { bytes: new Uint8Array(await readFile(url)) };
402
+ return { bytes: new Uint8Array(await readFile3(url)) };
239
403
  }
240
404
 
241
405
  // src/types.ts
@@ -904,6 +1068,18 @@ var packSchema = z.object({
904
1068
  narration: narrationSchema.optional(),
905
1069
  media: z.array(mediaSchema).default([])
906
1070
  });
1071
+ var CAPTION_FONT_RATIO = 0.037;
1072
+ var CAPTION_MARGIN_V_RATIO = 0.09;
1073
+ var CAPTION_MARGIN_X_RATIO = 0.04;
1074
+ var CAPTION_LINE_RATIO = 1.6;
1075
+ var CAPTION_MAX_LINES = 2;
1076
+ function captionFontSize(width) {
1077
+ return Math.round(width * CAPTION_FONT_RATIO);
1078
+ }
1079
+ function bandReserve(width, height) {
1080
+ const line2 = Math.round(captionFontSize(width) * CAPTION_LINE_RATIO);
1081
+ return Math.round(height * CAPTION_MARGIN_V_RATIO) + CAPTION_MAX_LINES * line2;
1082
+ }
907
1083
  var DESTINATIONS = {
908
1084
  // A presented deck is paced by the person clicking through it, and long-form
909
1085
  // video has no ceiling worth encoding. An empty list is "unbudgeted", stated.
@@ -1223,54 +1399,6 @@ function clock(seconds) {
1223
1399
  return m === 0 ? `${whole}s` : `${m}m${String(whole % 60).padStart(2, "0")}s`;
1224
1400
  }
1225
1401
 
1226
- // src/source/fonts.ts
1227
- import { createHash as createHash2 } from "node:crypto";
1228
- import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
1229
- import { join } from "node:path";
1230
- function familyFor(lang) {
1231
- const tag = lang.toLowerCase();
1232
- if (tag.startsWith("ko")) return "Noto Sans KR";
1233
- if (tag.startsWith("ja")) return "Noto Sans JP";
1234
- if (tag.startsWith("zh")) return /hant|-tw|-hk|-mo/.test(tag) ? "Noto Sans TC" : "Noto Sans SC";
1235
- return null;
1236
- }
1237
- 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";
1238
- async function bundleFont(lang, glyphs, dir) {
1239
- const family = familyFor(lang);
1240
- if (!family) return null;
1241
- const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
1242
- const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
1243
- ${text2}`).digest("hex").slice(0, 16)} */`;
1244
- await mkdir(dir, { recursive: true });
1245
- const cssPath = join(dir, "fonts.css");
1246
- const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1247
- if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1248
- const res = await fetch(
1249
- `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
1250
- { headers: { "User-Agent": UA } }
1251
- );
1252
- if (!res.ok) throw new Error(`google fonts: HTTP ${res.status}`);
1253
- let css = await res.text();
1254
- const remote = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1] ?? ""))];
1255
- const slug = family.toLowerCase().replace(/[^a-z0-9]/g, "");
1256
- const files = [];
1257
- for (const [i, url] of remote.entries()) {
1258
- const font = await fetch(url);
1259
- if (!font.ok) throw new Error(`${url}: HTTP ${font.status}`);
1260
- const name = `${slug}-${i}.woff2`;
1261
- await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
1262
- css = css.replaceAll(url, name);
1263
- files.push(name);
1264
- }
1265
- css = `${stamp}
1266
- ${css}`;
1267
- await writeFile(cssPath, css);
1268
- return { family, css, files };
1269
- }
1270
- function localNames(css) {
1271
- return [...css.matchAll(/url\(([^)]+)\)/g)].map((m) => m[1] ?? "");
1272
- }
1273
-
1274
1402
  // src/emit/kit.ts
1275
1403
  var PAD_X = 110;
1276
1404
  var PAD_Y = 84;
@@ -1289,7 +1417,12 @@ function contentW(format) {
1289
1417
  return refWidth(format) - 2 * PAD_X;
1290
1418
  }
1291
1419
  function contentH(format) {
1292
- return refHeight(format) - 2 * PAD_Y;
1420
+ return refHeight(format) - 2 * PAD_Y - reserveRef(format);
1421
+ }
1422
+ function reserveRef(format) {
1423
+ const reserve = format.captionReserve ?? 0;
1424
+ if (reserve <= 0) return 0;
1425
+ return Math.ceil(reserve * refHeight(format) / format.height);
1293
1426
  }
1294
1427
  function raw(js2) {
1295
1428
  return { __raw: js2 };
@@ -1788,8 +1921,8 @@ function charUnits(c, tabular, cjkRun, hangul) {
1788
1921
  }
1789
1922
  const measured = ADVANCE[c];
1790
1923
  if (measured !== void 0) return [measured, true];
1791
- const cp2 = c.codePointAt(0) ?? 0;
1792
- for (const [lo, hi, units] of BLOCK_ADVANCE) if (cp2 >= lo && cp2 <= hi) return [units, false];
1924
+ const cp3 = c.codePointAt(0) ?? 0;
1925
+ for (const [lo, hi, units] of BLOCK_ADVANCE) if (cp3 >= lo && cp3 <= hi) return [units, false];
1793
1926
  return [UNMEASURED, true];
1794
1927
  }
1795
1928
  function weightFactor(weight) {
@@ -2050,11 +2183,16 @@ function baseCss(theme, format) {
2050
2183
  gate still catches anything taller than the canvas; it just now
2051
2184
  overflows symmetrically instead of only downwards. */
2052
2185
  .scene { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
2053
- padding: ${PAD_Y}px ${PAD_X}px; display: flex; flex-direction: column;
2186
+ padding: ${scenePadding(format)}; display: flex; flex-direction: column;
2054
2187
  justify-content: center; }
2055
2188
  ${referenceSpaceCss(format)}
2056
2189
  ${AMBIENT_KEYFRAMES}`;
2057
2190
  }
2191
+ function scenePadding(format) {
2192
+ const reserve = reserveRef(format);
2193
+ const base = `${PAD_Y}px ${PAD_X}px`;
2194
+ return reserve > 0 ? `${base} ${PAD_Y + reserve}px` : base;
2195
+ }
2058
2196
  function referenceSpaceCss(format) {
2059
2197
  const zoom = zoomOf(format);
2060
2198
  if (zoom === 1) return "";
@@ -6385,6 +6523,10 @@ function planTiming(input) {
6385
6523
  lang: storyboard.lang,
6386
6524
  audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
6387
6525
  voice: segments2.length > 0 ? narration?.voice ?? "" : "",
6526
+ // The format the deck was LAID OUT under, which is the only honest source:
6527
+ // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
6528
+ // disagree with the box the archetypes actually drew into.
6529
+ captionReserve: format.captionReserve ?? 0,
6388
6530
  scenes,
6389
6531
  segments: segments2
6390
6532
  };
@@ -6677,8 +6819,8 @@ function textOf(node) {
6677
6819
 
6678
6820
  // src/source/harvest.ts
6679
6821
  import { createHash as createHash5 } from "node:crypto";
6680
- import { copyFile, mkdir as mkdir4, rm as rm2, stat, writeFile as writeFile4 } from "node:fs/promises";
6681
- import { basename as basename2, join as join4, resolve } from "node:path";
6822
+ import { copyFile, mkdir as mkdir5, rm as rm2, stat as stat2, writeFile as writeFile5 } from "node:fs/promises";
6823
+ import { basename as basename2, join as join5, resolve as resolve2 } from "node:path";
6682
6824
 
6683
6825
  // src/net/fetch.ts
6684
6826
  import { lookup as resolveHost } from "node:dns/promises";
@@ -6688,8 +6830,8 @@ import { isIP } from "node:net";
6688
6830
  import { brotliDecompressSync, gunzipSync } from "node:zlib";
6689
6831
 
6690
6832
  // src/version.ts
6691
- import { createRequire } from "node:module";
6692
- var VERSION = createRequire(import.meta.url)("../package.json").version;
6833
+ import { createRequire as createRequire2 } from "node:module";
6834
+ var VERSION = createRequire2(import.meta.url)("../package.json").version;
6693
6835
 
6694
6836
  // src/net/fetch.ts
6695
6837
  var BLOCKED_V4 = [
@@ -6884,8 +7026,8 @@ async function send(url, target, headers, signal, opts) {
6884
7026
  ...secure && isIP(host) === 0 ? { servername: host } : {}
6885
7027
  };
6886
7028
  try {
6887
- return await new Promise((resolve7, reject) => {
6888
- const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
7029
+ return await new Promise((resolve8, reject) => {
7030
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve8);
6889
7031
  req.on("error", reject);
6890
7032
  req.end();
6891
7033
  });
@@ -6968,10 +7110,10 @@ function message(err) {
6968
7110
  }
6969
7111
 
6970
7112
  // src/render/capture.ts
6971
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
6972
- import { createRequire as createRequire2 } from "node:module";
7113
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
7114
+ import { createRequire as createRequire3 } from "node:module";
6973
7115
  import { homedir } from "node:os";
6974
- import { join as join2 } from "node:path";
7116
+ import { join as join3 } from "node:path";
6975
7117
  import { pathToFileURL } from "node:url";
6976
7118
  async function resolveChrome(need = "open the deck with") {
6977
7119
  const env = process.env.HYPERFRAMES_BROWSER_PATH || process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
@@ -6982,9 +7124,9 @@ async function resolveChrome(need = "open the deck with") {
6982
7124
  const ordered = [...installed2].sort((a, b) => b.buildId.localeCompare(a.buildId, "en"));
6983
7125
  return ordered.find((b) => b.browser === "chrome-headless-shell") ?? ordered.find((b) => b.browser === "chrome");
6984
7126
  };
6985
- const hf = await pick(join2(homedir(), ".cache", "hyperframes", "chrome"));
7127
+ const hf = await pick(join3(homedir(), ".cache", "hyperframes", "chrome"));
6986
7128
  if (hf) return { path: hf.executablePath, source: "hyperframes" };
6987
- const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer"));
7129
+ const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join3(homedir(), ".cache", "puppeteer"));
6988
7130
  if (pup) return { path: pup.executablePath, source: "puppeteer" };
6989
7131
  throw new Error(
6990
7132
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
@@ -6994,15 +7136,15 @@ async function chromePath(need = "open the deck with") {
6994
7136
  return (await resolveChrome(need)).path;
6995
7137
  }
6996
7138
  function runtimePath() {
6997
- return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
7139
+ return createRequire3(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
6998
7140
  }
6999
7141
  function renderSeek(t2) {
7000
7142
  const player = window.__player;
7001
7143
  player.renderSeek(t2, { suppressEvents: true });
7002
7144
  }
7003
7145
  async function openDeck(dir, opts = {}) {
7004
- const index = join2(dir, "index.html");
7005
- const html = await readFile3(index, "utf8").catch(() => null);
7146
+ const index = join3(dir, "index.html");
7147
+ const html = await readFile4(index, "utf8").catch(() => null);
7006
7148
  if (html === null) throw new Error(`no index.html in ${dir}`);
7007
7149
  const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
7008
7150
  const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
@@ -7080,13 +7222,13 @@ async function openDeck(dir, opts = {}) {
7080
7222
 
7081
7223
  // src/source/assets.ts
7082
7224
  import { createHash as createHash4 } from "node:crypto";
7083
- import { mkdir as mkdir3, readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
7084
- import { extname as extname2, join as join3 } from "node:path";
7225
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
7226
+ import { extname as extname2, join as join4 } from "node:path";
7085
7227
  import { z as z2 } from "zod";
7086
7228
  async function fetchFigures(source, dir, warnings) {
7087
7229
  const drops = warnings ?? [];
7088
- await mkdir3(dir, { recursive: true });
7089
- const cached2 = await readdir(dir).catch(() => []);
7230
+ await mkdir4(dir, { recursive: true });
7231
+ const cached2 = await readdir2(dir).catch(() => []);
7090
7232
  const figures = [];
7091
7233
  for (const figure of source.figures) {
7092
7234
  if (figure.kind === "clip") {
@@ -7111,11 +7253,11 @@ async function fetchFigures(source, dir, warnings) {
7111
7253
  async function localize(figure, dir, cached2) {
7112
7254
  const stem = assetStem(figure.id, figure.src);
7113
7255
  const hit = cached2.find((name) => name.startsWith(`${stem}.`));
7114
- const bytes = hit ? await readFile4(join3(dir, hit)) : await load(figure.src);
7256
+ const bytes = hit ? await readFile5(join4(dir, hit)) : await load(figure.src);
7115
7257
  const size2 = imageSize(bytes);
7116
7258
  if (hit) return { ...figure, src: hit, ...size2 };
7117
7259
  const src = `${stem}${assetExt(bytes, figure.src)}`;
7118
- await writeFile3(join3(dir, src), bytes);
7260
+ await writeFile4(join4(dir, src), bytes);
7119
7261
  cached2.push(src);
7120
7262
  return { ...figure, src, ...size2 };
7121
7263
  }
@@ -7140,7 +7282,7 @@ function assetExt(bytes, src) {
7140
7282
  var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
7141
7283
  var FIGURE_TIMEOUT_MS = 2e4;
7142
7284
  async function load(src) {
7143
- if (!/^https?:/i.test(src)) return readFile4(src);
7285
+ if (!/^https?:/i.test(src)) return readFile5(src);
7144
7286
  const got = await fetchGuarded(src, {
7145
7287
  maxBytes: FIGURE_MAX_BYTES,
7146
7288
  timeoutMs: FIGURE_TIMEOUT_MS
@@ -7676,7 +7818,7 @@ function readContentRegion() {
7676
7818
  }
7677
7819
 
7678
7820
  // src/source/transcode.ts
7679
- import { readFile as readFile5, rm } from "node:fs/promises";
7821
+ import { readFile as readFile6, rm } from "node:fs/promises";
7680
7822
  import { basename } from "node:path";
7681
7823
 
7682
7824
  // src/render/ffmpeg.ts
@@ -7702,7 +7844,7 @@ ${tail3}`);
7702
7844
  }
7703
7845
  }
7704
7846
  function runLive(file, args) {
7705
- return new Promise((resolve7, reject) => {
7847
+ return new Promise((resolve8, reject) => {
7706
7848
  const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7707
7849
  child.on("error", (err) => {
7708
7850
  reject(
@@ -7710,7 +7852,7 @@ function runLive(file, args) {
7710
7852
  );
7711
7853
  });
7712
7854
  child.on("close", (code, signal) => {
7713
- if (code === 0) resolve7();
7855
+ if (code === 0) resolve8();
7714
7856
  else if (signal) {
7715
7857
  reject(
7716
7858
  new Error(
@@ -7837,11 +7979,16 @@ function burnStyle(width, height, font = "Arial") {
7837
7979
  // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
7838
7980
  // "4% of the width" looked like on paper — overflows to a THIRD line, and a
7839
7981
  // three-line band covers the bottom of the slide.
7840
- fontSize: Math.round(width * 0.037),
7982
+ //
7983
+ // THE RATIOS ARE NOT HERE. They are in src/types.ts beside `bandReserve`,
7984
+ // because `src/emit` has to reserve exactly the strip this draws into and
7985
+ // may not import this file. A local copy of 0.037 is how the two drift back
7986
+ // apart, which is the bug this whole path exists to close.
7987
+ fontSize: captionFontSize(width),
7841
7988
  // Clear of the play button, the progress bar and the handle every vertical
7842
7989
  // player draws across the bottom eighth of the frame.
7843
- marginV: Math.round(height * 0.09),
7844
- marginX: Math.round(width * 0.04)
7990
+ marginV: Math.round(height * CAPTION_MARGIN_V_RATIO),
7991
+ marginX: Math.round(width * CAPTION_MARGIN_X_RATIO)
7845
7992
  };
7846
7993
  }
7847
7994
 
@@ -7907,7 +8054,7 @@ async function transcode(input, out, opts = {}) {
7907
8054
  const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
7908
8055
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
7909
8056
  const name = basename(input);
7910
- const source = videoSize(await readFile5(input));
8057
+ const source = videoSize(await readFile6(input));
7911
8058
  const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
7912
8059
  const kept = (why3) => ({
7913
8060
  path: input,
@@ -7939,7 +8086,7 @@ async function transcode(input, out, opts = {}) {
7939
8086
  }
7940
8087
  let measured;
7941
8088
  try {
7942
- measured = videoSize(await readFile5(out));
8089
+ measured = videoSize(await readFile6(out));
7943
8090
  } catch (err) {
7944
8091
  await rm(out, { force: true });
7945
8092
  return kept(
@@ -7975,8 +8122,8 @@ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
7975
8122
  async function harvest(url, dir, opts = {}) {
7976
8123
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
7977
8124
  const warnings = [];
7978
- const assets = resolve(dir);
7979
- await mkdir4(assets, { recursive: true });
8125
+ const assets = resolve2(dir);
8126
+ await mkdir5(assets, { recursive: true });
7980
8127
  const page = await fetchGuarded(url, {
7981
8128
  maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
7982
8129
  timeoutMs,
@@ -8358,8 +8505,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8358
8505
  `it is ${size2.width}x${size2.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
8359
8506
  );
8360
8507
  }
8361
- const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
8362
- await writeFile4(path2, bytes);
8508
+ const path2 = join5(dir, assetName(url, extFor2(sniffFormat(bytes))));
8509
+ await writeFile5(path2, bytes);
8363
8510
  assets.push(path2);
8364
8511
  got = { path: path2, width: size2.width, height: size2.height };
8365
8512
  } catch (err) {
@@ -8371,7 +8518,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8371
8518
  };
8372
8519
  const shrink = async (url, path2, measured, what) => {
8373
8520
  if (opts.transcode === false) return { path: path2, ...measured };
8374
- const out2 = join4(dir, assetName(url, ".vp9.webm"));
8521
+ const out2 = join5(dir, assetName(url, ".vp9.webm"));
8375
8522
  let small;
8376
8523
  try {
8377
8524
  small = await transcode(path2, out2, {
@@ -8413,8 +8560,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8413
8560
  try {
8414
8561
  const bytes = await bytesOf(url);
8415
8562
  const measured = videoSize(bytes);
8416
- const path2 = join4(dir, assetName(url, `.${measured.container}`));
8417
- await writeFile4(path2, bytes);
8563
+ const path2 = join5(dir, assetName(url, `.${measured.container}`));
8564
+ await writeFile5(path2, bytes);
8418
8565
  return await shrink(url, path2, measured, what);
8419
8566
  } catch (err) {
8420
8567
  warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
@@ -8503,7 +8650,7 @@ function unheld(policy) {
8503
8650
  return "the file could not be held";
8504
8651
  }
8505
8652
  async function sizeOf(path2) {
8506
- return await stat(path2).then(
8653
+ return await stat2(path2).then(
8507
8654
  (s) => s.size,
8508
8655
  () => 0
8509
8656
  );
@@ -8711,8 +8858,8 @@ function sane(seconds) {
8711
8858
  }
8712
8859
  async function attachClips(source, clips, dir) {
8713
8860
  if (clips.length === 0) return source;
8714
- const assets = resolve(dir);
8715
- await mkdir4(assets, { recursive: true });
8861
+ const assets = resolve2(dir);
8862
+ await mkdir5(assets, { recursive: true });
8716
8863
  const figures = [...source.figures];
8717
8864
  for (const clip3 of clips) {
8718
8865
  const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
@@ -8743,7 +8890,7 @@ async function attachClips(source, clips, dir) {
8743
8890
  }
8744
8891
  async function adopt(file, dir) {
8745
8892
  const name = basename2(file);
8746
- await copyFile(file, join4(dir, name));
8893
+ await copyFile(file, join5(dir, name));
8747
8894
  return name;
8748
8895
  }
8749
8896
  function freeId(figures) {
@@ -8820,9 +8967,9 @@ ${fence}`;
8820
8967
 
8821
8968
  // src/plan/codex.ts
8822
8969
  import { spawn as spawn2 } from "node:child_process";
8823
- import { mkdtemp, readFile as readFile6, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
8970
+ import { mkdtemp, readFile as readFile7, rm as rm3, writeFile as writeFile6 } from "node:fs/promises";
8824
8971
  import { tmpdir } from "node:os";
8825
- import { join as join5 } from "node:path";
8972
+ import { join as join6 } from "node:path";
8826
8973
  import { z as z3 } from "zod";
8827
8974
 
8828
8975
  // src/plan/arc.ts
@@ -9941,11 +10088,11 @@ function schemaFor(prefs) {
9941
10088
  var SCHEMA = schemaFor({ genre: "general" });
9942
10089
  async function codexPlanner(source, opts = {}) {
9943
10090
  const prefs = opts.prefs ?? prefsSchema.parse({});
9944
- const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
10091
+ const dir = await mkdtemp(join6(tmpdir(), "decksmith-plan-"));
9945
10092
  try {
9946
- const schemaPath = join5(dir, "storyboard.schema.json");
9947
- const outPath = join5(dir, "storyboard.json");
9948
- await writeFile5(schemaPath, JSON.stringify(schemaFor(prefs)));
10093
+ const schemaPath = join6(dir, "storyboard.schema.json");
10094
+ const outPath = join6(dir, "storyboard.json");
10095
+ await writeFile6(schemaPath, JSON.stringify(schemaFor(prefs)));
9949
10096
  await (opts.run ?? runCodex)({
9950
10097
  prompt: buildPrompt(source, prefs),
9951
10098
  schemaPath,
@@ -9953,7 +10100,7 @@ async function codexPlanner(source, opts = {}) {
9953
10100
  ...opts.model === void 0 ? {} : { model: opts.model },
9954
10101
  timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
9955
10102
  });
9956
- const raw2 = await readFile6(outPath, "utf8").catch(() => "");
10103
+ const raw2 = await readFile7(outPath, "utf8").catch(() => "");
9957
10104
  if (raw2.trim() === "") {
9958
10105
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
9959
10106
  }
@@ -10026,7 +10173,7 @@ function codexCommand(args) {
10026
10173
  }
10027
10174
  function runCodex(args) {
10028
10175
  const { argv, env } = codexCommand(args);
10029
- return new Promise((resolve7, reject) => {
10176
+ return new Promise((resolve8, reject) => {
10030
10177
  const child = spawn2("codex", argv, {
10031
10178
  stdio: ["pipe", "ignore", "pipe"],
10032
10179
  ...env === void 0 ? {} : { env }
@@ -10049,7 +10196,7 @@ function runCodex(args) {
10049
10196
  });
10050
10197
  child.on("close", (code) => {
10051
10198
  clearTimeout(timer);
10052
- if (code === 0) return resolve7();
10199
+ if (code === 0) return resolve8();
10053
10200
  reject(
10054
10201
  new Error(`codex exec exited ${code}.
10055
10202
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -10061,14 +10208,14 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
10061
10208
 
10062
10209
  // src/images/illustrate.ts
10063
10210
  import { createHash as createHash7 } from "node:crypto";
10064
- import { mkdir as mkdir5, readFile as readFile8, rename, rm as rm5, writeFile as writeFile7 } from "node:fs/promises";
10065
- import { join as join7 } from "node:path";
10211
+ import { mkdir as mkdir6, readFile as readFile9, rename, rm as rm5, writeFile as writeFile8 } from "node:fs/promises";
10212
+ import { join as join8 } from "node:path";
10066
10213
 
10067
10214
  // src/images/providers.ts
10068
10215
  import { createHash as createHash6 } from "node:crypto";
10069
- import { mkdtemp as mkdtemp2, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
10216
+ import { mkdtemp as mkdtemp2, readFile as readFile8, rm as rm4, writeFile as writeFile7 } from "node:fs/promises";
10070
10217
  import { tmpdir as tmpdir2 } from "node:os";
10071
- import { join as join6, resolve as resolve2 } from "node:path";
10218
+ import { join as join7, resolve as resolve3 } from "node:path";
10072
10219
  import { z as z4 } from "zod";
10073
10220
  var SIZE = {
10074
10221
  landscape: { width: 1536, height: 1024 },
@@ -10200,11 +10347,11 @@ function codexImages(opts = {}) {
10200
10347
  async check() {
10201
10348
  },
10202
10349
  async generate(req) {
10203
- const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
10350
+ const dir = await mkdtemp2(join7(tmpdir2(), "decksmith-image-"));
10204
10351
  try {
10205
- const schemaPath = join6(dir, "answer.schema.json");
10206
- const outPath = join6(dir, "answer.json");
10207
- await writeFile6(schemaPath, JSON.stringify(ANSWER_SCHEMA));
10352
+ const schemaPath = join7(dir, "answer.schema.json");
10353
+ const outPath = join7(dir, "answer.json");
10354
+ await writeFile7(schemaPath, JSON.stringify(ANSWER_SCHEMA));
10208
10355
  await run4({
10209
10356
  prompt: codexPrompt(req),
10210
10357
  schemaPath,
@@ -10213,7 +10360,7 @@ function codexImages(opts = {}) {
10213
10360
  cwd: dir,
10214
10361
  sandbox: "workspace-write"
10215
10362
  });
10216
- const raw2 = await readFile7(outPath, "utf8").catch(() => "");
10363
+ const raw2 = await readFile8(outPath, "utf8").catch(() => "");
10217
10364
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
10218
10365
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
10219
10366
  if (!answer.data.ok) {
@@ -10221,10 +10368,10 @@ function codexImages(opts = {}) {
10221
10368
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
10222
10369
  );
10223
10370
  }
10224
- const candidates2 = [join6(dir, "picture.png")];
10225
- if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
10371
+ const candidates2 = [join7(dir, "picture.png")];
10372
+ if (answer.data.file) candidates2.push(resolve3(dir, answer.data.file));
10226
10373
  for (const path2 of candidates2) {
10227
- const bytes = await readFile7(path2).catch(() => null);
10374
+ const bytes = await readFile8(path2).catch(() => null);
10228
10375
  if (bytes) return raster(bytes, "codex");
10229
10376
  }
10230
10377
  throw new Error("codex said ok but wrote no picture.png");
@@ -10356,7 +10503,7 @@ async function illustrate(storyboard, source, opts) {
10356
10503
  const tool = last?.id === "svg" ? last : toolSvg();
10357
10504
  if (tool !== last) chain.push(tool);
10358
10505
  const dropped = /* @__PURE__ */ new Set();
10359
- if (pending.length > 0) await mkdir5(opts.assetsDir, { recursive: true });
10506
+ if (pending.length > 0) await mkdir6(opts.assetsDir, { recursive: true });
10360
10507
  for (const [i, slot] of pending.entries()) {
10361
10508
  let rungs = chain;
10362
10509
  if (i >= images.max) {
@@ -10453,16 +10600,16 @@ function cacheKey(providerId, req) {
10453
10600
  async function draw(provider, req, name, dir) {
10454
10601
  for (const ext of Object.values(EXT2)) {
10455
10602
  const src2 = `${name}${ext}`;
10456
- const bytes = await readFile8(join7(dir, src2)).catch(() => null);
10603
+ const bytes = await readFile9(join8(dir, src2)).catch(() => null);
10457
10604
  if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
10458
10605
  }
10459
10606
  await provider.check();
10460
10607
  const img = await provider.generate(req);
10461
10608
  const src = `${name}${EXT2[img.mime]}`;
10462
- const tmp = join7(dir, `.${name}.tmp`);
10609
+ const tmp = join8(dir, `.${name}.tmp`);
10463
10610
  try {
10464
- await writeFile7(tmp, img.bytes);
10465
- await rename(tmp, join7(dir, src));
10611
+ await writeFile8(tmp, img.bytes);
10612
+ await rename(tmp, join8(dir, src));
10466
10613
  } finally {
10467
10614
  await rm5(tmp, { force: true });
10468
10615
  }
@@ -10475,9 +10622,9 @@ function sizeOf2(bytes, ext) {
10475
10622
  // src/narrate/tts.ts
10476
10623
  import { spawn as spawn3 } from "node:child_process";
10477
10624
  import { createHash as createHash8 } from "node:crypto";
10478
- import { mkdir as mkdir6, readFile as readFile9, rm as rm6, writeFile as writeFile8 } from "node:fs/promises";
10625
+ import { mkdir as mkdir7, readFile as readFile10, rm as rm6, writeFile as writeFile9 } from "node:fs/promises";
10479
10626
  import { homedir as homedir2 } from "node:os";
10480
- import { join as join8 } from "node:path";
10627
+ import { join as join9 } from "node:path";
10481
10628
  var MISSING = [
10482
10629
  "edge-tts is not installed, so narration cannot be synthesised.",
10483
10630
  "",
@@ -10494,8 +10641,8 @@ function candidates() {
10494
10641
  ["edge-tts"],
10495
10642
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
10496
10643
  // default, and both are where this actually lands in practice.
10497
- [join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10498
- [join8(home, ".local", "bin", "edge-tts")],
10644
+ [join9(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10645
+ [join9(home, ".local", "bin", "edge-tts")],
10499
10646
  // Last resort: the module is installed even though its console script is
10500
10647
  // nowhere findable, which is the normal state of a pip --user install.
10501
10648
  ["python3", "-m", "edge_tts"]
@@ -10520,7 +10667,7 @@ async function find2(can) {
10520
10667
  throw new Error(MISSING);
10521
10668
  }
10522
10669
  function runArgv(cmd, args) {
10523
- return new Promise((resolve7) => {
10670
+ return new Promise((resolve8) => {
10524
10671
  const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10525
10672
  let stdout = "";
10526
10673
  let stderr = "";
@@ -10530,8 +10677,8 @@ function runArgv(cmd, args) {
10530
10677
  child.stderr.on("data", (b) => {
10531
10678
  stderr += b.toString();
10532
10679
  });
10533
- child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
10534
- child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
10680
+ child.on("error", (e) => resolve8({ code: -1, stderr: String(e), stdout }));
10681
+ child.on("close", (code) => resolve8({ code: code ?? -1, stderr, stdout }));
10535
10682
  });
10536
10683
  }
10537
10684
  var edgeTts = {
@@ -10598,7 +10745,7 @@ function edgeProvider(runner = edgeTts) {
10598
10745
  async speak(req) {
10599
10746
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
10600
10747
  await runner.speak({ ...req, subs });
10601
- const raw2 = await readFile9(subs, "utf8").catch(() => "");
10748
+ const raw2 = await readFile10(subs, "utf8").catch(() => "");
10602
10749
  await rm6(subs, { force: true });
10603
10750
  const cues = parseCues(raw2);
10604
10751
  const measured = await runner.measure(req.audio);
@@ -10628,11 +10775,11 @@ async function synthesize(text2, opts) {
10628
10775
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
10629
10776
  const key = cacheKey2(text2, opts.voice, rate, pitch);
10630
10777
  const file = `${key}.mp3`;
10631
- const audio = join8(opts.dir, file);
10632
- const sidecar = join8(opts.dir, `${key}.json`);
10778
+ const audio = join9(opts.dir, file);
10779
+ const sidecar = join9(opts.dir, `${key}.json`);
10633
10780
  const cached2 = await readSidecar(sidecar);
10634
10781
  if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
10635
- await mkdir6(opts.dir, { recursive: true });
10782
+ await mkdir7(opts.dir, { recursive: true });
10636
10783
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
10637
10784
  const { cues, seconds } = spoken;
10638
10785
  const clamped = cues.map((c) => ({
@@ -10641,13 +10788,13 @@ async function synthesize(text2, opts) {
10641
10788
  text: c.text
10642
10789
  }));
10643
10790
  const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
10644
- await writeFile8(sidecar, `${JSON.stringify(body, null, 2)}
10791
+ await writeFile9(sidecar, `${JSON.stringify(body, null, 2)}
10645
10792
  `);
10646
10793
  return { audio, file, seconds, cues: clamped };
10647
10794
  }
10648
10795
  async function readSidecar(path2) {
10649
10796
  try {
10650
- const parsed = JSON.parse(await readFile9(path2, "utf8"));
10797
+ const parsed = JSON.parse(await readFile10(path2, "utf8"));
10651
10798
  const s = parsed;
10652
10799
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
10653
10800
  } catch {
@@ -10770,8 +10917,8 @@ async function narrate(storyboard, source, prefs, opts) {
10770
10917
  }
10771
10918
 
10772
10919
  // src/verify/index.ts
10773
- import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
10774
- import { join as join12, relative, sep } from "node:path";
10920
+ import { readdir as readdir4, readFile as readFile14 } from "node:fs/promises";
10921
+ import { join as join13, relative, sep } from "node:path";
10775
10922
 
10776
10923
  // src/verify/budget.ts
10777
10924
  function readCanvas(html) {
@@ -10878,8 +11025,8 @@ function clock2(seconds) {
10878
11025
 
10879
11026
  // src/verify/check.ts
10880
11027
  import { execFile as execFile2 } from "node:child_process";
10881
- import { readFile as readFile10 } from "node:fs/promises";
10882
- import { join as join9 } from "node:path";
11028
+ import { readFile as readFile11 } from "node:fs/promises";
11029
+ import { join as join10 } from "node:path";
10883
11030
  import { promisify as promisify2 } from "node:util";
10884
11031
  var run2 = promisify2(execFile2);
10885
11032
  var DEFAULT_TIMEOUT_MS3 = 24e4;
@@ -10909,7 +11056,7 @@ async function check(dir, opts = {}) {
10909
11056
  async function readComposition(dir) {
10910
11057
  let html;
10911
11058
  try {
10912
- html = await readFile10(join9(dir, "index.html"), "utf8");
11059
+ html = await readFile11(join10(dir, "index.html"), "utf8");
10913
11060
  } catch {
10914
11061
  return { transit: [], duration: 0 };
10915
11062
  }
@@ -11060,8 +11207,8 @@ function tail(s) {
11060
11207
  }
11061
11208
 
11062
11209
  // src/verify/fidelity.ts
11063
- import { readFile as readFile11 } from "node:fs/promises";
11064
- import { join as join10 } from "node:path";
11210
+ import { readFile as readFile12 } from "node:fs/promises";
11211
+ import { join as join11 } from "node:path";
11065
11212
 
11066
11213
  // src/verify/typefloor.ts
11067
11214
  var TYPE_FLOOR_PX = 40;
@@ -11317,6 +11464,16 @@ var INK_FLOOR = 15e-4;
11317
11464
  var INK_DELTA = 12;
11318
11465
  var FALLBACK_BAND_TOP = 0.22;
11319
11466
  var CAPTION = ".headline, .eyebrow";
11467
+ var RESERVE_INK_TOLERANCE = 1e-4;
11468
+ function readReserve(timingText) {
11469
+ if (!timingText) return 0;
11470
+ try {
11471
+ const reserve = JSON.parse(timingText).captionReserve;
11472
+ return typeof reserve === "number" && reserve > 0 ? reserve : 0;
11473
+ } catch {
11474
+ return 0;
11475
+ }
11476
+ }
11320
11477
  async function decodePng(png) {
11321
11478
  const buf = Buffer.from(png.buffer, png.byteOffset, png.byteLength);
11322
11479
  if (buf.length < 8 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG");
@@ -11483,6 +11640,25 @@ function gradeFidelity(rows, floor = INK_FLOOR) {
11483
11640
  };
11484
11641
  });
11485
11642
  }
11643
+ function gradeReserve(rows, tolerance = RESERVE_INK_TOLERANCE) {
11644
+ const dirty = /* @__PURE__ */ new Map();
11645
+ for (const row of rows) {
11646
+ if (row.reserveInk === void 0 || row.reserveInk <= tolerance) continue;
11647
+ const seen = dirty.get(row.sid);
11648
+ if (seen) seen.push(row);
11649
+ else dirty.set(row.sid, [row]);
11650
+ }
11651
+ const pct = (v) => `${(100 * v).toFixed(3)}%`;
11652
+ return [...dirty].map(([sid, stops]) => {
11653
+ const worst = stops.reduce((a, b) => (a.reserveInk ?? 0) >= (b.reserveInk ?? 0) ? a : b);
11654
+ return {
11655
+ severity: "error",
11656
+ gate: "fidelity",
11657
+ rule: "ink_in_caption_reserve",
11658
+ 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(", ")}.`
11659
+ };
11660
+ });
11661
+ }
11486
11662
  function captionBottom(sid, selector, fallbackPx) {
11487
11663
  const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
11488
11664
  let bottom = 0;
@@ -11507,11 +11683,10 @@ async function fidelity(dir, opts = {}) {
11507
11683
  ],
11508
11684
  elapsedMs: Date.now() - started
11509
11685
  });
11510
- const stops = opts.stops ?? readStops(
11511
- await readFile11(join10(dir, TIMING_FILE), "utf8").catch(() => null),
11512
- await readFile11(join10(dir, DECK_PAGE), "utf8").catch(() => null)
11513
- );
11686
+ const timingText = await readFile12(join11(dir, TIMING_FILE), "utf8").catch(() => null);
11687
+ const stops = opts.stops ?? readStops(timingText, await readFile12(join11(dir, DECK_PAGE), "utf8").catch(() => null));
11514
11688
  if (stops.length === 0) return notMeasured("the deck declares no stops");
11689
+ const reserve = opts.captionReserve ?? readReserve(timingText);
11515
11690
  let deck = null;
11516
11691
  try {
11517
11692
  deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
@@ -11540,7 +11715,13 @@ async function fidelity(dir, opts = {}) {
11540
11715
  measured.push({
11541
11716
  ...stop,
11542
11717
  ink: inkBelow(frame, bandTopPx),
11543
- bandTop: Math.round(1e3 * bandTopPx / height) / 1e3
11718
+ bandTop: Math.round(1e3 * bandTopPx / height) / 1e3,
11719
+ // THE SAME FRAME, a second strip. Free: it is one more pass over pixels
11720
+ // that are already decoded, so the gate that stops the caption
11721
+ // collision regressing costs no extra capture, no extra seek and no
11722
+ // extra browser. That matters — a gate with its own capture is a gate
11723
+ // someone turns off.
11724
+ ...reserve > 0 ? { reserveInk: inkBelow(frame, height - reserve) } : {}
11544
11725
  });
11545
11726
  }
11546
11727
  for (const mid of midpoints(stops)) {
@@ -11551,6 +11732,7 @@ async function fidelity(dir, opts = {}) {
11551
11732
  stops: measured,
11552
11733
  findings: [
11553
11734
  ...gradeFidelity(measured, floor),
11735
+ ...gradeReserve(measured),
11554
11736
  ...gradeOverprint(collided),
11555
11737
  ...gradeApparent(apparent)
11556
11738
  ],
@@ -11567,9 +11749,9 @@ async function fidelity(dir, opts = {}) {
11567
11749
  // src/verify/drift.ts
11568
11750
  import { execFile as execFile3 } from "node:child_process";
11569
11751
  import { createHash as createHash9 } from "node:crypto";
11570
- import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile12, rm as rm7 } from "node:fs/promises";
11752
+ import { mkdtemp as mkdtemp3, readdir as readdir3, readFile as readFile13, rm as rm7 } from "node:fs/promises";
11571
11753
  import { tmpdir as tmpdir3 } from "node:os";
11572
- import { join as join11 } from "node:path";
11754
+ import { join as join12 } from "node:path";
11573
11755
  import { promisify as promisify3 } from "node:util";
11574
11756
  var run3 = promisify3(execFile3);
11575
11757
  var FLOOR_DB = 40;
@@ -11608,9 +11790,9 @@ function measureMotion(hashes, scenes, seconds) {
11608
11790
  async function drift(dir, opts = {}) {
11609
11791
  const mode = opts.mode ?? "psnr";
11610
11792
  const floorDb = opts.floorDb ?? FLOOR_DB;
11611
- const work = opts.workDir ?? await mkdtemp3(join11(tmpdir3(), "decksmith-drift-"));
11612
- const a = join11(work, "a");
11613
- const b = join11(work, "b");
11793
+ const work = opts.workDir ?? await mkdtemp3(join12(tmpdir3(), "decksmith-drift-"));
11794
+ const a = join12(work, "a");
11795
+ const b = join12(work, "b");
11614
11796
  const report = await compare(dir, a, b, mode, floorDb, opts);
11615
11797
  const keep = opts.keep || !report.passed;
11616
11798
  if (keep) report.kept = { a, b };
@@ -11665,11 +11847,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
11665
11847
  const differing = [];
11666
11848
  const ha = [];
11667
11849
  for (const [i, name] of fa.entries()) {
11668
- const one = await sha(join11(a, name));
11850
+ const one = await sha(join12(a, name));
11669
11851
  ha.push(one);
11670
- if (one !== await sha(join11(b, name))) differing.push(i + 1);
11852
+ if (one !== await sha(join12(b, name))) differing.push(i + 1);
11671
11853
  }
11672
- const html = await readFile12(join11(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11854
+ const html = await readFile13(join12(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11673
11855
  const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
11674
11856
  let worst;
11675
11857
  if (differing.length > 0) {
@@ -11780,11 +11962,11 @@ async function render(dir, out, opts) {
11780
11962
  }
11781
11963
  }
11782
11964
  async function frames(dir) {
11783
- const names = await readdir2(dir).catch(() => []);
11965
+ const names = await readdir3(dir).catch(() => []);
11784
11966
  return names.filter((n3) => n3.endsWith(".png")).sort();
11785
11967
  }
11786
11968
  async function sha(file) {
11787
- return createHash9("sha256").update(await readFile12(file)).digest("hex");
11969
+ return createHash9("sha256").update(await readFile13(file)).digest("hex");
11788
11970
  }
11789
11971
  var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
11790
11972
  function framePattern(name) {
@@ -11817,11 +11999,11 @@ async function psnr(a, b, firstFrame) {
11817
11999
  "-start_number",
11818
12000
  String(seq.start),
11819
12001
  "-i",
11820
- join11(a, seq.pattern),
12002
+ join12(a, seq.pattern),
11821
12003
  "-start_number",
11822
12004
  String(seq.start),
11823
12005
  "-i",
11824
- join11(b, seq.pattern),
12006
+ join12(b, seq.pattern),
11825
12007
  "-lavfi",
11826
12008
  "psnr=stats_file=-",
11827
12009
  "-f",
@@ -11896,7 +12078,7 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
11896
12078
  const html = await readCompositions(dir);
11897
12079
  const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
11898
12080
  const narration = scanNarration(
11899
- await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => ""),
12081
+ await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => ""),
11900
12082
  await listFiles(dir)
11901
12083
  );
11902
12084
  const budget2 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
@@ -11933,8 +12115,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
11933
12115
  }
11934
12116
  async function declaredStops(dir) {
11935
12117
  return readStops(
11936
- await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => null),
11937
- await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => null)
12118
+ await readFile14(join13(dir, TIMING_FILE), "utf8").catch(() => null),
12119
+ await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => null)
11938
12120
  );
11939
12121
  }
11940
12122
  var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
@@ -11974,7 +12156,7 @@ function scanNarration(page, files) {
11974
12156
  ];
11975
12157
  }
11976
12158
  async function readTiming(dir) {
11977
- const raw2 = await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => "");
12159
+ const raw2 = await readFile14(join13(dir, TIMING_FILE), "utf8").catch(() => "");
11978
12160
  if (!raw2) return void 0;
11979
12161
  try {
11980
12162
  const parsed = JSON.parse(raw2);
@@ -11984,9 +12166,9 @@ async function readTiming(dir) {
11984
12166
  }
11985
12167
  }
11986
12168
  async function listFiles(dir) {
11987
- const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12169
+ const entries = await readdir4(dir, { recursive: true, withFileTypes: true }).catch(() => []);
11988
12170
  return new Set(
11989
- entries.filter((e) => e.isFile()).map((e) => relative(dir, join12(e.parentPath, e.name)).split(sep).join("/"))
12171
+ entries.filter((e) => e.isFile()).map((e) => relative(dir, join13(e.parentPath, e.name)).split(sep).join("/"))
11990
12172
  );
11991
12173
  }
11992
12174
  var INSTEAD = {
@@ -12242,22 +12424,22 @@ function scanDeterminism(html, file) {
12242
12424
  return findings;
12243
12425
  }
12244
12426
  async function readCompositions(dir) {
12245
- const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12427
+ const entries = await readdir4(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12246
12428
  const files = entries.filter(
12247
12429
  (e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
12248
- ).map((e) => join12(e.parentPath, e.name));
12430
+ ).map((e) => join13(e.parentPath, e.name));
12249
12431
  return Promise.all(
12250
- files.map(async (f) => [relative(dir, f), await readFile13(f, "utf8")])
12432
+ files.map(async (f) => [relative(dir, f), await readFile14(f, "utf8")])
12251
12433
  );
12252
12434
  }
12253
12435
 
12254
12436
  // src/render/render.ts
12255
- import { mkdir as mkdir8, readFile as readFile14, rename as rename2, rm as rm8, writeFile as writeFile10 } from "node:fs/promises";
12256
- import { basename as basename3, dirname, join as join14, resolve as resolve3 } from "node:path";
12437
+ import { mkdir as mkdir9, readFile as readFile15, rename as rename2, rm as rm8, writeFile as writeFile11 } from "node:fs/promises";
12438
+ import { basename as basename3, dirname as dirname2, join as join15, resolve as resolve4 } from "node:path";
12257
12439
 
12258
12440
  // src/render/captions.ts
12259
- import { mkdir as mkdir7, writeFile as writeFile9 } from "node:fs/promises";
12260
- import { join as join13 } from "node:path";
12441
+ import { mkdir as mkdir8, writeFile as writeFile10 } from "node:fs/promises";
12442
+ import { join as join14 } from "node:path";
12261
12443
  import { pathToFileURL as pathToFileURL2 } from "node:url";
12262
12444
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
12263
12445
  function cssString(value) {
@@ -12348,11 +12530,11 @@ async function captionBlocker() {
12348
12530
  }
12349
12531
  async function renderCaptions(cues, style, deck, work) {
12350
12532
  if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
12351
- const fontCss = join13(deck, DECK_FONT_CSS);
12533
+ const fontCss = join14(deck, DECK_FONT_CSS);
12352
12534
  const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
12353
- const page = join13(work, "captions.html");
12354
- await mkdir7(work, { recursive: true });
12355
- await writeFile9(page, captionPage(cues, style, href));
12535
+ const page = join14(work, "captions.html");
12536
+ await mkdir8(work, { recursive: true });
12537
+ await writeFile10(page, captionPage(cues, style, href));
12356
12538
  const { default: puppeteer } = await import("puppeteer-core");
12357
12539
  const browser = await puppeteer.launch({
12358
12540
  executablePath: await chromePath(CAPTION_NEED),
@@ -12372,7 +12554,7 @@ async function renderCaptions(cues, style, deck, work) {
12372
12554
  const name = `cap${String(i).padStart(4, "0")}.png`;
12373
12555
  await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
12374
12556
  await tab.screenshot({
12375
- path: join13(work, name),
12557
+ path: join14(work, name),
12376
12558
  type: "png",
12377
12559
  omitBackground: true,
12378
12560
  clip: { x: box.x, y: box.y, width: box.width, height: box.height }
@@ -12413,9 +12595,9 @@ function subtitlePlan(mode) {
12413
12595
  async function render2(opts) {
12414
12596
  const log = opts.log ?? (() => {
12415
12597
  });
12416
- const deck = resolve3(opts.deck);
12417
- const out = resolve3(opts.out);
12418
- const work = join14(dirname(out), `.${basename3(out)}.parts`);
12598
+ const deck = resolve4(opts.deck);
12599
+ const out = resolve4(opts.out);
12600
+ const work = join15(dirname2(out), `.${basename3(out)}.parts`);
12419
12601
  const timing = await readTiming2(deck);
12420
12602
  if (opts.targetSeconds && !opts.allowFastPlayback) {
12421
12603
  const refusal = playbackRefusal(
@@ -12427,13 +12609,17 @@ async function render2(opts) {
12427
12609
  }
12428
12610
  const plan0 = subtitlePlan(opts.subtitles ?? "sidecar");
12429
12611
  if (plan0.burn) {
12612
+ if (!timing.captionReserve)
12613
+ throw new Error(
12614
+ "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`."
12615
+ );
12430
12616
  const blocker = await captionBlocker();
12431
12617
  if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
12432
12618
  }
12433
12619
  const burnable = plan0.burn;
12434
- await mkdir8(work, { recursive: true });
12620
+ await mkdir9(work, { recursive: true });
12435
12621
  try {
12436
- const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join14(work, "raw.mp4"), opts, log);
12622
+ const raw2 = opts.video ? resolve4(opts.video) : await capture(deck, join15(work, "raw.mp4"), opts, log);
12437
12623
  const shot = await probe(raw2);
12438
12624
  log(
12439
12625
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -12446,18 +12632,18 @@ async function render2(opts) {
12446
12632
  );
12447
12633
  }
12448
12634
  const retimed = await retime(raw2, plan, work, log);
12449
- const srtPath = join14(dirname(out), SRT_NAME(out));
12635
+ const srtPath = join15(dirname2(out), SRT_NAME(out));
12450
12636
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
12451
12637
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
12452
12638
  const srt = plan0.sidecar ? toSrt(cues) : "";
12453
- if (srt) await writeFile10(srtPath, srt);
12639
+ if (srt) await writeFile11(srtPath, srt);
12454
12640
  const burn = burnable && srt.length > 0;
12455
12641
  await mux(retimed, timing, plan, deck, out, work, burn ? plan.cues : void 0, shot.fps, log);
12456
12642
  if (playback > 1) {
12457
12643
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
12458
12644
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
12459
12645
  if (warning) log(`render: ${warning}`);
12460
- const fast = join14(work, `fast.${basename3(out)}`);
12646
+ const fast = join15(work, `fast.${basename3(out)}`);
12461
12647
  const muxed = await probe(out);
12462
12648
  await runTool(
12463
12649
  "ffmpeg",
@@ -12487,8 +12673,8 @@ async function render2(opts) {
12487
12673
  }
12488
12674
  }
12489
12675
  async function readTiming2(deck) {
12490
- const path2 = join14(deck, TIMING_FILE);
12491
- const text2 = await readFile14(path2, "utf8").catch(() => {
12676
+ const path2 = join15(deck, TIMING_FILE);
12677
+ const text2 = await readFile15(path2, "utf8").catch(() => {
12492
12678
  throw new Error(
12493
12679
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
12494
12680
  );
@@ -12527,20 +12713,20 @@ async function retime(raw2, plan, work, log) {
12527
12713
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
12528
12714
  const list = [];
12529
12715
  for (const [i, piece] of plan.pieces.entries()) {
12530
- const file = join14(work, `p${String(i).padStart(4, "0")}.ts`);
12716
+ const file = join15(work, `p${String(i).padStart(4, "0")}.ts`);
12531
12717
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
12532
12718
  list.push(file);
12533
12719
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
12534
12720
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
12535
12721
  }
12536
12722
  }
12537
- const listFile = join14(work, "pieces.txt");
12538
- await writeFile10(
12723
+ const listFile = join15(work, "pieces.txt");
12724
+ await writeFile11(
12539
12725
  listFile,
12540
12726
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
12541
12727
  `
12542
12728
  );
12543
- const joined = join14(work, "retimed.mp4");
12729
+ const joined = join15(work, "retimed.mp4");
12544
12730
  await runTool("ffmpeg", [
12545
12731
  "-y",
12546
12732
  "-hide_banner",
@@ -12562,7 +12748,7 @@ async function retime(raw2, plan, work, log) {
12562
12748
  }
12563
12749
  async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
12564
12750
  const inputs = plan.audio.map((a) => ({
12565
- file: join14(deck, timing.audioDir, a.audio),
12751
+ file: join15(deck, timing.audioDir, a.audio),
12566
12752
  delayMs: a.delayMs
12567
12753
  }));
12568
12754
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -12583,8 +12769,8 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
12583
12769
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
12584
12770
  }
12585
12771
  if (graph.length > 0) {
12586
- const script = join14(work, "mux.filter");
12587
- await writeFile10(script, `${graph.join(";\n")}
12772
+ const script = join15(work, "mux.filter");
12773
+ await writeFile11(script, `${graph.join(";\n")}
12588
12774
  `);
12589
12775
  args.push("-filter_complex_script", script);
12590
12776
  }
@@ -12599,13 +12785,13 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
12599
12785
  log(
12600
12786
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
12601
12787
  );
12602
- await mkdir8(dirname(out), { recursive: true });
12788
+ await mkdir9(dirname2(out), { recursive: true });
12603
12789
  await runTool("ffmpeg", args, { cwd: work });
12604
12790
  }
12605
12791
 
12606
12792
  // src/pack/pack.ts
12607
- import { mkdir as mkdir9, readFile as readFile15, writeFile as writeFile11 } from "node:fs/promises";
12608
- import { dirname as dirname2, extname as extname3 } from "node:path";
12793
+ import { mkdir as mkdir10, readFile as readFile16, writeFile as writeFile12 } from "node:fs/promises";
12794
+ import { dirname as dirname3, extname as extname3 } from "node:path";
12609
12795
  import { unzipSync, zipSync } from "fflate";
12610
12796
  var MTIME = Date.UTC(1980, 0, 2, 12);
12611
12797
  var STORED = /* @__PURE__ */ new Set([
@@ -12644,12 +12830,12 @@ async function writePack(pack2, files, out) {
12644
12830
  entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
12645
12831
  }
12646
12832
  const zip = zipSync(entries, { mtime: MTIME });
12647
- await mkdir9(dirname2(out), { recursive: true });
12648
- await writeFile11(out, zip);
12833
+ await mkdir10(dirname3(out), { recursive: true });
12834
+ await writeFile12(out, zip);
12649
12835
  return zip.length;
12650
12836
  }
12651
12837
  async function readPack(path2) {
12652
- return openPack(new Uint8Array(await readFile15(path2)), path2);
12838
+ return openPack(new Uint8Array(await readFile16(path2)), path2);
12653
12839
  }
12654
12840
  function openPack(bytes, label = "pack") {
12655
12841
  if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
@@ -12693,8 +12879,8 @@ function why2(error) {
12693
12879
  }
12694
12880
 
12695
12881
  // src/prefs.ts
12696
- import { readFile as readFile16 } from "node:fs/promises";
12697
- import { dirname as dirname3, join as join15, resolve as resolve4 } from "node:path";
12882
+ import { readFile as readFile17 } from "node:fs/promises";
12883
+ import { dirname as dirname4, join as join16, resolve as resolve5 } from "node:path";
12698
12884
  var CONFIG_FILE = "decksmith.config.json";
12699
12885
  var PREF_KEYS = Object.keys(prefsSchema.shape);
12700
12886
  var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
@@ -12711,10 +12897,10 @@ async function loadPrefs(overrides = {}, cwd = process.cwd(), source) {
12711
12897
  return parsed;
12712
12898
  }
12713
12899
  async function findConfig(from) {
12714
- let dir = resolve4(from);
12900
+ let dir = resolve5(from);
12715
12901
  for (; ; ) {
12716
- const path2 = join15(dir, CONFIG_FILE);
12717
- const text2 = await readFile16(path2, "utf8").catch(() => void 0);
12902
+ const path2 = join16(dir, CONFIG_FILE);
12903
+ const text2 = await readFile17(path2, "utf8").catch(() => void 0);
12718
12904
  if (text2 !== void 0) {
12719
12905
  try {
12720
12906
  return { path: path2, value: JSON.parse(text2) };
@@ -12722,7 +12908,7 @@ async function findConfig(from) {
12722
12908
  throw new Error(`${path2} is not valid JSON.`);
12723
12909
  }
12724
12910
  }
12725
- const up = dirname3(dir);
12911
+ const up = dirname4(dir);
12726
12912
  if (up === dir) return void 0;
12727
12913
  dir = up;
12728
12914
  }
@@ -12768,26 +12954,26 @@ ${issues}`);
12768
12954
 
12769
12955
  // src/tmpdir.ts
12770
12956
  import { realpathSync } from "node:fs";
12771
- import { createRequire as createRequire3 } from "node:module";
12957
+ import { createRequire as createRequire4 } from "node:module";
12772
12958
  import { tmpdir as tmpdir4 } from "node:os";
12773
- import { basename as basename4, dirname as dirname4, join as join16, resolve as resolve5, sep as sep2 } from "node:path";
12959
+ import { basename as basename4, dirname as dirname5, join as join17, resolve as resolve6, sep as sep2 } from "node:path";
12774
12960
  var cached;
12775
12961
  function real(dir) {
12776
- let at = resolve5(dir);
12962
+ let at = resolve6(dir);
12777
12963
  const tail3 = [];
12778
12964
  for (; ; ) {
12779
12965
  try {
12780
- return join16(realpathSync(at), ...tail3);
12966
+ return join17(realpathSync(at), ...tail3);
12781
12967
  } catch {
12782
- const up = dirname4(at);
12783
- if (up === at) return resolve5(dir);
12968
+ const up = dirname5(at);
12969
+ if (up === at) return resolve6(dir);
12784
12970
  tail3.unshift(basename4(at));
12785
12971
  at = up;
12786
12972
  }
12787
12973
  }
12788
12974
  }
12789
12975
  function packageRoot() {
12790
- cached ??= real(dirname4(createRequire3(import.meta.url).resolve("../package.json")));
12976
+ cached ??= real(dirname5(createRequire4(import.meta.url).resolve("../package.json")));
12791
12977
  return cached;
12792
12978
  }
12793
12979
  function insideRoot(dir) {
@@ -12820,8 +13006,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12820
13006
  if (!format) throw new Error("no deck-16x9 format");
12821
13007
  const step = opts.onStep ?? (() => {
12822
13008
  });
12823
- const out = resolve6(outDir);
12824
- await mkdir10(out, { recursive: true });
13009
+ const out = resolve7(outDir);
13010
+ await mkdir11(out, { recursive: true });
12825
13011
  const speed = opts.speed ?? 1;
12826
13012
  const fontCss = await refreshFont(storyboard, source, out, step);
12827
13013
  const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
@@ -12834,47 +13020,48 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12834
13020
  });
12835
13021
  const files = [];
12836
13022
  const write = async (name, text2) => {
12837
- await writeFile12(join17(out, name), text2);
12838
- files.push(join17(out, name));
13023
+ await writeFile13(join18(out, name), text2);
13024
+ files.push(join18(out, name));
12839
13025
  };
12840
13026
  await write("index.html", deck.composition);
12841
13027
  await write("hyperframes.json", HYPERFRAMES_JSON);
12842
- if (opts.narration) {
12843
- try {
12844
- const timing = planTiming({
12845
- storyboard,
12846
- source,
12847
- format,
12848
- composition: deck.composition,
12849
- // What was drawn, not what the threshold would have kept: a budgeted
12850
- // format cuts to its own length as well, and the two lists differ.
12851
- beats: deck.cut.kept,
12852
- narration: opts.narration,
12853
- speed,
12854
- ...opts.theme ? { theme: opts.theme } : {}
12855
- });
12856
- await write(TIMING_FILE, `${JSON.stringify(timing, null, 2)}
13028
+ try {
13029
+ const timing = planTiming({
13030
+ storyboard,
13031
+ source,
13032
+ format,
13033
+ composition: deck.composition,
13034
+ // What was drawn, not what the threshold would have kept: a budgeted
13035
+ // format cuts to its own length as well, and the two lists differ.
13036
+ beats: deck.cut.kept,
13037
+ speed,
13038
+ ...opts.narration ? { narration: opts.narration } : {},
13039
+ ...opts.theme ? { theme: opts.theme } : {}
13040
+ });
13041
+ await write(TIMING_FILE, `${JSON.stringify(timing, null, 2)}
12857
13042
  `);
12858
- step(`build: timing for ${timing.segments.length} narration segment(s)`);
12859
- } catch (err) {
12860
- step(
12861
- `build: cannot place narration on this deck, so no ${TIMING_FILE} was written and \`render\` will refuse. ${err instanceof Error ? err.message : err}`
12862
- );
12863
- }
13043
+ step(`build: timing for ${timing.segments.length} narration segment(s)`);
13044
+ } catch (err) {
13045
+ step(
13046
+ `build: cannot place narration on this deck, so no ${TIMING_FILE} was written and \`render\` will refuse. ${err instanceof Error ? err.message : err}`
13047
+ );
12864
13048
  }
12865
13049
  if (deck.page) {
12866
13050
  await write(DECK_PAGE, deck.page);
12867
- await cp(playerBundle(), join17(out, PLAYER_FILE));
12868
- files.push(join17(out, PLAYER_FILE));
13051
+ await cp2(playerBundle(), join18(out, PLAYER_FILE));
13052
+ files.push(join18(out, PLAYER_FILE));
12869
13053
  }
12870
13054
  files.push(...await vendorKatex(out));
12871
- if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
13055
+ files.push(...await vendorScripts(out, deck.composition));
13056
+ if (opts.assetsFrom) {
13057
+ files.push(...await copyAssets(opts.assetsFrom, out, source.figures, step));
13058
+ }
12872
13059
  if (opts.narration && opts.audioFrom) {
12873
- files.push(...await copyAudio(opts.audioFrom, opts.narration, out));
13060
+ files.push(...await copyAudio(opts.audioFrom, opts.narration, out, step));
12874
13061
  }
12875
13062
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
12876
13063
  step(
12877
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join17(out, "index.html")}`
13064
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join18(out, "index.html")}`
12878
13065
  );
12879
13066
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
12880
13067
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -12890,79 +13077,28 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
12890
13077
  )}
12891
13078
  `;
12892
13079
  async function deckRuntime() {
12893
- const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
13080
+ const path2 = fileURLToPath2(new URL(`./${"deck-runtime.js"}`, import.meta.url));
12894
13081
  try {
12895
- return await readFile17(path2, "utf8");
13082
+ return await readFile18(path2, "utf8");
12896
13083
  } catch {
12897
13084
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
12898
13085
  }
12899
13086
  }
12900
13087
  function playerBundle() {
12901
- const require2 = createRequire4(import.meta.url);
13088
+ const require2 = createRequire5(import.meta.url);
12902
13089
  try {
12903
- return join17(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
13090
+ return join18(dirname6(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12904
13091
  } catch {
12905
13092
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
12906
13093
  }
12907
13094
  }
12908
- async function vendorKatex(out) {
12909
- const require2 = createRequire4(import.meta.url);
12910
- const dist = join17(dirname5(require2.resolve("katex/package.json")), "dist");
12911
- const css = await readFile17(join17(dist, "katex.min.css"), "utf8");
12912
- const written = [];
12913
- await mkdir10(join17(out, "katex/fonts"), { recursive: true });
12914
- for (const file of await readdir4(join17(dist, "fonts"))) {
12915
- if (!file.endsWith(".woff2")) continue;
12916
- await cp(join17(dist, "fonts", file), join17(out, "katex/fonts", file));
12917
- written.push(join17(out, "katex/fonts", file));
12918
- }
12919
- const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
12920
- const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
12921
- return kept ? `src:${kept}` : whole;
12922
- });
12923
- await writeFile12(join17(out, "katex/katex.min.css"), woff2Only);
12924
- written.push(join17(out, "katex/katex.min.css"));
12925
- return written;
12926
- }
12927
- async function copyAssets(sourceDir, out) {
12928
- const from = join17(resolve6(sourceDir), "assets");
12929
- if (!await stat2(from).catch(() => null)) return [];
12930
- await cp(from, join17(out, "assets"), { recursive: true });
12931
- return [join17(out, "assets")];
12932
- }
12933
- async function copyAudio(from, narration, out) {
12934
- const dir = join17(out, narration.dir);
12935
- await mkdir10(dir, { recursive: true });
12936
- const names = [
12937
- ...new Set(
12938
- Object.values(narration.beats).flat().map((s) => s.audio)
12939
- )
12940
- ].sort();
12941
- for (const name of names) {
12942
- await cp(join17(resolve6(from), name), join17(dir, name)).catch(() => {
12943
- throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
12944
- });
12945
- }
12946
- return names.map((n3) => join17(dir, n3));
12947
- }
12948
- async function refreshFont(storyboard, source, out, step) {
12949
- try {
12950
- const bundle = await bundleFont(
12951
- storyboard.lang,
12952
- JSON.stringify(source) + JSON.stringify(storyboard),
12953
- join17(out, "assets", "fonts")
12954
- );
12955
- if (bundle) step(`build: font bundle covers ${bundle.family}`);
12956
- return bundle?.css;
12957
- } catch (err) {
12958
- step(
12959
- `build: could not refresh the font bundle (${err instanceof Error ? err.message : err}); keeping the one from ingest`
12960
- );
12961
- return void 0;
12962
- }
12963
- }
12964
13095
  export {
12965
13096
  ARCHETYPE_FAMILY,
13097
+ CAPTION_FONT_RATIO,
13098
+ CAPTION_LINE_RATIO,
13099
+ CAPTION_MARGIN_V_RATIO,
13100
+ CAPTION_MARGIN_X_RATIO,
13101
+ CAPTION_MAX_LINES,
12966
13102
  COMFORTABLE_CPS,
12967
13103
  CONFIG_FILE,
12968
13104
  DECK_PAGE,
@@ -12992,6 +13128,7 @@ export {
12992
13128
  assertInsideResolves,
12993
13129
  assertRefsResolve,
12994
13130
  attachClips,
13131
+ bandReserve,
12995
13132
  barCompareParamsSchema,
12996
13133
  beatRoleSchema,
12997
13134
  beatSchema,
@@ -13000,6 +13137,7 @@ export {
13000
13137
  calloutParamsSchema,
13001
13138
  canvasProblem,
13002
13139
  canvasWarnings,
13140
+ captionFontSize,
13003
13141
  check,
13004
13142
  claimFigureParamsSchema,
13005
13143
  codexPlanner,