@jokerized/decksmith 0.4.0 → 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,45 +7110,65 @@ 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
- async function chromePath(need = "open the deck with") {
6977
- const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
6978
- if (explicit) return explicit;
7118
+ async function resolveChrome(need = "open the deck with") {
7119
+ const env = process.env.HYPERFRAMES_BROWSER_PATH || process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
7120
+ if (env) return { path: env, source: "env" };
6979
7121
  const { getInstalledBrowsers } = await import("@puppeteer/browsers");
6980
- const cacheDir = process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer");
6981
- const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
6982
- const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
6983
- if (found) return found.executablePath;
7122
+ const pick = async (cacheDir) => {
7123
+ const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
7124
+ const ordered = [...installed2].sort((a, b) => b.buildId.localeCompare(a.buildId, "en"));
7125
+ return ordered.find((b) => b.browser === "chrome-headless-shell") ?? ordered.find((b) => b.browser === "chrome");
7126
+ };
7127
+ const hf = await pick(join3(homedir(), ".cache", "hyperframes", "chrome"));
7128
+ if (hf) return { path: hf.executablePath, source: "hyperframes" };
7129
+ const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join3(homedir(), ".cache", "puppeteer"));
7130
+ if (pup) return { path: pup.executablePath, source: "puppeteer" };
6984
7131
  throw new Error(
6985
7132
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
6986
7133
  );
6987
7134
  }
7135
+ async function chromePath(need = "open the deck with") {
7136
+ return (await resolveChrome(need)).path;
7137
+ }
6988
7138
  function runtimePath() {
6989
- 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");
6990
7140
  }
6991
7141
  function renderSeek(t2) {
6992
7142
  const player = window.__player;
6993
7143
  player.renderSeek(t2, { suppressEvents: true });
6994
7144
  }
6995
7145
  async function openDeck(dir, opts = {}) {
6996
- const index = join2(dir, "index.html");
6997
- const html = await readFile3(index, "utf8").catch(() => null);
7146
+ const index = join3(dir, "index.html");
7147
+ const html = await readFile4(index, "utf8").catch(() => null);
6998
7148
  if (html === null) throw new Error(`no index.html in ${dir}`);
6999
7149
  const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
7000
7150
  const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
7001
7151
  if (!width || !height) throw new Error("index.html declares no canvas size");
7002
7152
  const timeout = opts.timeoutMs ?? 6e4;
7003
7153
  const { default: puppeteer } = await import("puppeteer-core");
7154
+ const chrome2 = await resolveChrome();
7004
7155
  const browser = await puppeteer.launch({
7005
- executablePath: await chromePath(),
7156
+ executablePath: chrome2.path,
7006
7157
  headless: true,
7007
- // A retina host would otherwise hand back a 2x frame, whose clip is not the
7008
- // renderer's.
7009
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
7158
+ args: [
7159
+ // A retina host would otherwise hand back a 2x frame, whose clip is not the
7160
+ // renderer's.
7161
+ "--force-device-scale-factor=1",
7162
+ "--hide-scrollbars",
7163
+ // WITHOUT THESE A WebGL CANVAS GETS NO CONTEXT AND SCREENSHOTS EMPTY. The
7164
+ // renderer passes its own GL arguments; this path passed none, so it drew
7165
+ // a blank rectangle and reported success. SwiftShader rather than the host
7166
+ // GPU on purpose: it is the one backend available on every machine, and a
7167
+ // gate that quietly follows the host's hardware is measuring the host.
7168
+ "--use-gl=angle",
7169
+ "--use-angle=swiftshader",
7170
+ "--enable-unsafe-swiftshader"
7171
+ ]
7010
7172
  });
7011
7173
  try {
7012
7174
  const page = await browser.newPage();
@@ -7022,6 +7184,18 @@ async function openDeck(dir, opts = {}) {
7022
7184
  { timeout }
7023
7185
  );
7024
7186
  await page.evaluate(() => document.fonts.ready);
7187
+ const canvas = await page.evaluate(() => {
7188
+ const el = document.querySelector("canvas");
7189
+ if (!el) return { present: false, gl: true };
7190
+ const probe2 = document.createElement("canvas");
7191
+ const gl = !!(probe2.getContext("webgl2") ?? probe2.getContext("webgl"));
7192
+ return { present: true, gl };
7193
+ });
7194
+ if (canvas.present && !canvas.gl) {
7195
+ throw new Error(
7196
+ `this deck draws on a <canvas> and ${chrome2.path} cannot create a WebGL context, so the capture would be of an empty rectangle. Chrome came from ${chrome2.source}; the renderer uses hyperframes' own shell. Set HYPERFRAMES_BROWSER_PATH to the binary \`hyperframes render\` uses so the gate and the render are the same browser.`
7197
+ );
7198
+ }
7025
7199
  const cdp = await page.createCDPSession();
7026
7200
  return {
7027
7201
  width,
@@ -7048,13 +7222,13 @@ async function openDeck(dir, opts = {}) {
7048
7222
 
7049
7223
  // src/source/assets.ts
7050
7224
  import { createHash as createHash4 } from "node:crypto";
7051
- import { mkdir as mkdir3, readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
7052
- 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";
7053
7227
  import { z as z2 } from "zod";
7054
7228
  async function fetchFigures(source, dir, warnings) {
7055
7229
  const drops = warnings ?? [];
7056
- await mkdir3(dir, { recursive: true });
7057
- const cached2 = await readdir(dir).catch(() => []);
7230
+ await mkdir4(dir, { recursive: true });
7231
+ const cached2 = await readdir2(dir).catch(() => []);
7058
7232
  const figures = [];
7059
7233
  for (const figure of source.figures) {
7060
7234
  if (figure.kind === "clip") {
@@ -7079,11 +7253,11 @@ async function fetchFigures(source, dir, warnings) {
7079
7253
  async function localize(figure, dir, cached2) {
7080
7254
  const stem = assetStem(figure.id, figure.src);
7081
7255
  const hit = cached2.find((name) => name.startsWith(`${stem}.`));
7082
- 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);
7083
7257
  const size2 = imageSize(bytes);
7084
7258
  if (hit) return { ...figure, src: hit, ...size2 };
7085
7259
  const src = `${stem}${assetExt(bytes, figure.src)}`;
7086
- await writeFile3(join3(dir, src), bytes);
7260
+ await writeFile4(join4(dir, src), bytes);
7087
7261
  cached2.push(src);
7088
7262
  return { ...figure, src, ...size2 };
7089
7263
  }
@@ -7108,7 +7282,7 @@ function assetExt(bytes, src) {
7108
7282
  var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
7109
7283
  var FIGURE_TIMEOUT_MS = 2e4;
7110
7284
  async function load(src) {
7111
- if (!/^https?:/i.test(src)) return readFile4(src);
7285
+ if (!/^https?:/i.test(src)) return readFile5(src);
7112
7286
  const got = await fetchGuarded(src, {
7113
7287
  maxBytes: FIGURE_MAX_BYTES,
7114
7288
  timeoutMs: FIGURE_TIMEOUT_MS
@@ -7644,7 +7818,7 @@ function readContentRegion() {
7644
7818
  }
7645
7819
 
7646
7820
  // src/source/transcode.ts
7647
- import { readFile as readFile5, rm } from "node:fs/promises";
7821
+ import { readFile as readFile6, rm } from "node:fs/promises";
7648
7822
  import { basename } from "node:path";
7649
7823
 
7650
7824
  // src/render/ffmpeg.ts
@@ -7670,7 +7844,7 @@ ${tail3}`);
7670
7844
  }
7671
7845
  }
7672
7846
  function runLive(file, args) {
7673
- return new Promise((resolve7, reject) => {
7847
+ return new Promise((resolve8, reject) => {
7674
7848
  const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7675
7849
  child.on("error", (err) => {
7676
7850
  reject(
@@ -7678,7 +7852,7 @@ function runLive(file, args) {
7678
7852
  );
7679
7853
  });
7680
7854
  child.on("close", (code, signal) => {
7681
- if (code === 0) resolve7();
7855
+ if (code === 0) resolve8();
7682
7856
  else if (signal) {
7683
7857
  reject(
7684
7858
  new Error(
@@ -7805,11 +7979,16 @@ function burnStyle(width, height, font = "Arial") {
7805
7979
  // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
7806
7980
  // "4% of the width" looked like on paper — overflows to a THIRD line, and a
7807
7981
  // three-line band covers the bottom of the slide.
7808
- 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),
7809
7988
  // Clear of the play button, the progress bar and the handle every vertical
7810
7989
  // player draws across the bottom eighth of the frame.
7811
- marginV: Math.round(height * 0.09),
7812
- marginX: Math.round(width * 0.04)
7990
+ marginV: Math.round(height * CAPTION_MARGIN_V_RATIO),
7991
+ marginX: Math.round(width * CAPTION_MARGIN_X_RATIO)
7813
7992
  };
7814
7993
  }
7815
7994
 
@@ -7875,7 +8054,7 @@ async function transcode(input, out, opts = {}) {
7875
8054
  const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
7876
8055
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
7877
8056
  const name = basename(input);
7878
- const source = videoSize(await readFile5(input));
8057
+ const source = videoSize(await readFile6(input));
7879
8058
  const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
7880
8059
  const kept = (why3) => ({
7881
8060
  path: input,
@@ -7907,7 +8086,7 @@ async function transcode(input, out, opts = {}) {
7907
8086
  }
7908
8087
  let measured;
7909
8088
  try {
7910
- measured = videoSize(await readFile5(out));
8089
+ measured = videoSize(await readFile6(out));
7911
8090
  } catch (err) {
7912
8091
  await rm(out, { force: true });
7913
8092
  return kept(
@@ -7943,8 +8122,8 @@ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
7943
8122
  async function harvest(url, dir, opts = {}) {
7944
8123
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
7945
8124
  const warnings = [];
7946
- const assets = resolve(dir);
7947
- await mkdir4(assets, { recursive: true });
8125
+ const assets = resolve2(dir);
8126
+ await mkdir5(assets, { recursive: true });
7948
8127
  const page = await fetchGuarded(url, {
7949
8128
  maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
7950
8129
  timeoutMs,
@@ -8005,7 +8184,11 @@ async function readInBrowser(html, timeoutMs) {
8005
8184
  "--disable-background-networking",
8006
8185
  "--disable-extensions",
8007
8186
  "--no-default-browser-check",
8008
- "--no-first-run"
8187
+ "--no-first-run",
8188
+ // The floor under interception — see the note above. Not a hardening
8189
+ // nicety: without it the open-proxy property holds only as fast as CDP
8190
+ // happens to be that run.
8191
+ "--host-resolver-rules=MAP * ~NOTFOUND"
8009
8192
  ]
8010
8193
  });
8011
8194
  try {
@@ -8322,8 +8505,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8322
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`
8323
8506
  );
8324
8507
  }
8325
- const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
8326
- await writeFile4(path2, bytes);
8508
+ const path2 = join5(dir, assetName(url, extFor2(sniffFormat(bytes))));
8509
+ await writeFile5(path2, bytes);
8327
8510
  assets.push(path2);
8328
8511
  got = { path: path2, width: size2.width, height: size2.height };
8329
8512
  } catch (err) {
@@ -8335,7 +8518,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8335
8518
  };
8336
8519
  const shrink = async (url, path2, measured, what) => {
8337
8520
  if (opts.transcode === false) return { path: path2, ...measured };
8338
- const out2 = join4(dir, assetName(url, ".vp9.webm"));
8521
+ const out2 = join5(dir, assetName(url, ".vp9.webm"));
8339
8522
  let small;
8340
8523
  try {
8341
8524
  small = await transcode(path2, out2, {
@@ -8377,8 +8560,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8377
8560
  try {
8378
8561
  const bytes = await bytesOf(url);
8379
8562
  const measured = videoSize(bytes);
8380
- const path2 = join4(dir, assetName(url, `.${measured.container}`));
8381
- await writeFile4(path2, bytes);
8563
+ const path2 = join5(dir, assetName(url, `.${measured.container}`));
8564
+ await writeFile5(path2, bytes);
8382
8565
  return await shrink(url, path2, measured, what);
8383
8566
  } catch (err) {
8384
8567
  warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
@@ -8467,7 +8650,7 @@ function unheld(policy) {
8467
8650
  return "the file could not be held";
8468
8651
  }
8469
8652
  async function sizeOf(path2) {
8470
- return await stat(path2).then(
8653
+ return await stat2(path2).then(
8471
8654
  (s) => s.size,
8472
8655
  () => 0
8473
8656
  );
@@ -8675,8 +8858,8 @@ function sane(seconds) {
8675
8858
  }
8676
8859
  async function attachClips(source, clips, dir) {
8677
8860
  if (clips.length === 0) return source;
8678
- const assets = resolve(dir);
8679
- await mkdir4(assets, { recursive: true });
8861
+ const assets = resolve2(dir);
8862
+ await mkdir5(assets, { recursive: true });
8680
8863
  const figures = [...source.figures];
8681
8864
  for (const clip3 of clips) {
8682
8865
  const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
@@ -8707,7 +8890,7 @@ async function attachClips(source, clips, dir) {
8707
8890
  }
8708
8891
  async function adopt(file, dir) {
8709
8892
  const name = basename2(file);
8710
- await copyFile(file, join4(dir, name));
8893
+ await copyFile(file, join5(dir, name));
8711
8894
  return name;
8712
8895
  }
8713
8896
  function freeId(figures) {
@@ -8784,9 +8967,9 @@ ${fence}`;
8784
8967
 
8785
8968
  // src/plan/codex.ts
8786
8969
  import { spawn as spawn2 } from "node:child_process";
8787
- 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";
8788
8971
  import { tmpdir } from "node:os";
8789
- import { join as join5 } from "node:path";
8972
+ import { join as join6 } from "node:path";
8790
8973
  import { z as z3 } from "zod";
8791
8974
 
8792
8975
  // src/plan/arc.ts
@@ -9905,11 +10088,11 @@ function schemaFor(prefs) {
9905
10088
  var SCHEMA = schemaFor({ genre: "general" });
9906
10089
  async function codexPlanner(source, opts = {}) {
9907
10090
  const prefs = opts.prefs ?? prefsSchema.parse({});
9908
- const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
10091
+ const dir = await mkdtemp(join6(tmpdir(), "decksmith-plan-"));
9909
10092
  try {
9910
- const schemaPath = join5(dir, "storyboard.schema.json");
9911
- const outPath = join5(dir, "storyboard.json");
9912
- 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)));
9913
10096
  await (opts.run ?? runCodex)({
9914
10097
  prompt: buildPrompt(source, prefs),
9915
10098
  schemaPath,
@@ -9917,7 +10100,7 @@ async function codexPlanner(source, opts = {}) {
9917
10100
  ...opts.model === void 0 ? {} : { model: opts.model },
9918
10101
  timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
9919
10102
  });
9920
- const raw2 = await readFile6(outPath, "utf8").catch(() => "");
10103
+ const raw2 = await readFile7(outPath, "utf8").catch(() => "");
9921
10104
  if (raw2.trim() === "") {
9922
10105
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
9923
10106
  }
@@ -9990,7 +10173,7 @@ function codexCommand(args) {
9990
10173
  }
9991
10174
  function runCodex(args) {
9992
10175
  const { argv, env } = codexCommand(args);
9993
- return new Promise((resolve7, reject) => {
10176
+ return new Promise((resolve8, reject) => {
9994
10177
  const child = spawn2("codex", argv, {
9995
10178
  stdio: ["pipe", "ignore", "pipe"],
9996
10179
  ...env === void 0 ? {} : { env }
@@ -10013,7 +10196,7 @@ function runCodex(args) {
10013
10196
  });
10014
10197
  child.on("close", (code) => {
10015
10198
  clearTimeout(timer);
10016
- if (code === 0) return resolve7();
10199
+ if (code === 0) return resolve8();
10017
10200
  reject(
10018
10201
  new Error(`codex exec exited ${code}.
10019
10202
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -10025,14 +10208,14 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
10025
10208
 
10026
10209
  // src/images/illustrate.ts
10027
10210
  import { createHash as createHash7 } from "node:crypto";
10028
- import { mkdir as mkdir5, readFile as readFile8, rename, rm as rm5, writeFile as writeFile7 } from "node:fs/promises";
10029
- 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";
10030
10213
 
10031
10214
  // src/images/providers.ts
10032
10215
  import { createHash as createHash6 } from "node:crypto";
10033
- 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";
10034
10217
  import { tmpdir as tmpdir2 } from "node:os";
10035
- import { join as join6, resolve as resolve2 } from "node:path";
10218
+ import { join as join7, resolve as resolve3 } from "node:path";
10036
10219
  import { z as z4 } from "zod";
10037
10220
  var SIZE = {
10038
10221
  landscape: { width: 1536, height: 1024 },
@@ -10164,11 +10347,11 @@ function codexImages(opts = {}) {
10164
10347
  async check() {
10165
10348
  },
10166
10349
  async generate(req) {
10167
- const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
10350
+ const dir = await mkdtemp2(join7(tmpdir2(), "decksmith-image-"));
10168
10351
  try {
10169
- const schemaPath = join6(dir, "answer.schema.json");
10170
- const outPath = join6(dir, "answer.json");
10171
- 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));
10172
10355
  await run4({
10173
10356
  prompt: codexPrompt(req),
10174
10357
  schemaPath,
@@ -10177,7 +10360,7 @@ function codexImages(opts = {}) {
10177
10360
  cwd: dir,
10178
10361
  sandbox: "workspace-write"
10179
10362
  });
10180
- const raw2 = await readFile7(outPath, "utf8").catch(() => "");
10363
+ const raw2 = await readFile8(outPath, "utf8").catch(() => "");
10181
10364
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
10182
10365
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
10183
10366
  if (!answer.data.ok) {
@@ -10185,10 +10368,10 @@ function codexImages(opts = {}) {
10185
10368
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
10186
10369
  );
10187
10370
  }
10188
- const candidates2 = [join6(dir, "picture.png")];
10189
- 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));
10190
10373
  for (const path2 of candidates2) {
10191
- const bytes = await readFile7(path2).catch(() => null);
10374
+ const bytes = await readFile8(path2).catch(() => null);
10192
10375
  if (bytes) return raster(bytes, "codex");
10193
10376
  }
10194
10377
  throw new Error("codex said ok but wrote no picture.png");
@@ -10320,7 +10503,7 @@ async function illustrate(storyboard, source, opts) {
10320
10503
  const tool = last?.id === "svg" ? last : toolSvg();
10321
10504
  if (tool !== last) chain.push(tool);
10322
10505
  const dropped = /* @__PURE__ */ new Set();
10323
- if (pending.length > 0) await mkdir5(opts.assetsDir, { recursive: true });
10506
+ if (pending.length > 0) await mkdir6(opts.assetsDir, { recursive: true });
10324
10507
  for (const [i, slot] of pending.entries()) {
10325
10508
  let rungs = chain;
10326
10509
  if (i >= images.max) {
@@ -10417,16 +10600,16 @@ function cacheKey(providerId, req) {
10417
10600
  async function draw(provider, req, name, dir) {
10418
10601
  for (const ext of Object.values(EXT2)) {
10419
10602
  const src2 = `${name}${ext}`;
10420
- const bytes = await readFile8(join7(dir, src2)).catch(() => null);
10603
+ const bytes = await readFile9(join8(dir, src2)).catch(() => null);
10421
10604
  if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
10422
10605
  }
10423
10606
  await provider.check();
10424
10607
  const img = await provider.generate(req);
10425
10608
  const src = `${name}${EXT2[img.mime]}`;
10426
- const tmp = join7(dir, `.${name}.tmp`);
10609
+ const tmp = join8(dir, `.${name}.tmp`);
10427
10610
  try {
10428
- await writeFile7(tmp, img.bytes);
10429
- await rename(tmp, join7(dir, src));
10611
+ await writeFile8(tmp, img.bytes);
10612
+ await rename(tmp, join8(dir, src));
10430
10613
  } finally {
10431
10614
  await rm5(tmp, { force: true });
10432
10615
  }
@@ -10439,9 +10622,9 @@ function sizeOf2(bytes, ext) {
10439
10622
  // src/narrate/tts.ts
10440
10623
  import { spawn as spawn3 } from "node:child_process";
10441
10624
  import { createHash as createHash8 } from "node:crypto";
10442
- 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";
10443
10626
  import { homedir as homedir2 } from "node:os";
10444
- import { join as join8 } from "node:path";
10627
+ import { join as join9 } from "node:path";
10445
10628
  var MISSING = [
10446
10629
  "edge-tts is not installed, so narration cannot be synthesised.",
10447
10630
  "",
@@ -10458,8 +10641,8 @@ function candidates() {
10458
10641
  ["edge-tts"],
10459
10642
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
10460
10643
  // default, and both are where this actually lands in practice.
10461
- [join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10462
- [join8(home, ".local", "bin", "edge-tts")],
10644
+ [join9(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10645
+ [join9(home, ".local", "bin", "edge-tts")],
10463
10646
  // Last resort: the module is installed even though its console script is
10464
10647
  // nowhere findable, which is the normal state of a pip --user install.
10465
10648
  ["python3", "-m", "edge_tts"]
@@ -10484,7 +10667,7 @@ async function find2(can) {
10484
10667
  throw new Error(MISSING);
10485
10668
  }
10486
10669
  function runArgv(cmd, args) {
10487
- return new Promise((resolve7) => {
10670
+ return new Promise((resolve8) => {
10488
10671
  const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10489
10672
  let stdout = "";
10490
10673
  let stderr = "";
@@ -10494,8 +10677,8 @@ function runArgv(cmd, args) {
10494
10677
  child.stderr.on("data", (b) => {
10495
10678
  stderr += b.toString();
10496
10679
  });
10497
- child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
10498
- 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 }));
10499
10682
  });
10500
10683
  }
10501
10684
  var edgeTts = {
@@ -10562,7 +10745,7 @@ function edgeProvider(runner = edgeTts) {
10562
10745
  async speak(req) {
10563
10746
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
10564
10747
  await runner.speak({ ...req, subs });
10565
- const raw2 = await readFile9(subs, "utf8").catch(() => "");
10748
+ const raw2 = await readFile10(subs, "utf8").catch(() => "");
10566
10749
  await rm6(subs, { force: true });
10567
10750
  const cues = parseCues(raw2);
10568
10751
  const measured = await runner.measure(req.audio);
@@ -10592,11 +10775,11 @@ async function synthesize(text2, opts) {
10592
10775
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
10593
10776
  const key = cacheKey2(text2, opts.voice, rate, pitch);
10594
10777
  const file = `${key}.mp3`;
10595
- const audio = join8(opts.dir, file);
10596
- const sidecar = join8(opts.dir, `${key}.json`);
10778
+ const audio = join9(opts.dir, file);
10779
+ const sidecar = join9(opts.dir, `${key}.json`);
10597
10780
  const cached2 = await readSidecar(sidecar);
10598
10781
  if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
10599
- await mkdir6(opts.dir, { recursive: true });
10782
+ await mkdir7(opts.dir, { recursive: true });
10600
10783
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
10601
10784
  const { cues, seconds } = spoken;
10602
10785
  const clamped = cues.map((c) => ({
@@ -10605,13 +10788,13 @@ async function synthesize(text2, opts) {
10605
10788
  text: c.text
10606
10789
  }));
10607
10790
  const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
10608
- await writeFile8(sidecar, `${JSON.stringify(body, null, 2)}
10791
+ await writeFile9(sidecar, `${JSON.stringify(body, null, 2)}
10609
10792
  `);
10610
10793
  return { audio, file, seconds, cues: clamped };
10611
10794
  }
10612
10795
  async function readSidecar(path2) {
10613
10796
  try {
10614
- const parsed = JSON.parse(await readFile9(path2, "utf8"));
10797
+ const parsed = JSON.parse(await readFile10(path2, "utf8"));
10615
10798
  const s = parsed;
10616
10799
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
10617
10800
  } catch {
@@ -10734,8 +10917,8 @@ async function narrate(storyboard, source, prefs, opts) {
10734
10917
  }
10735
10918
 
10736
10919
  // src/verify/index.ts
10737
- import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
10738
- 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";
10739
10922
 
10740
10923
  // src/verify/budget.ts
10741
10924
  function readCanvas(html) {
@@ -10842,8 +11025,8 @@ function clock2(seconds) {
10842
11025
 
10843
11026
  // src/verify/check.ts
10844
11027
  import { execFile as execFile2 } from "node:child_process";
10845
- import { readFile as readFile10 } from "node:fs/promises";
10846
- import { join as join9 } from "node:path";
11028
+ import { readFile as readFile11 } from "node:fs/promises";
11029
+ import { join as join10 } from "node:path";
10847
11030
  import { promisify as promisify2 } from "node:util";
10848
11031
  var run2 = promisify2(execFile2);
10849
11032
  var DEFAULT_TIMEOUT_MS3 = 24e4;
@@ -10873,7 +11056,7 @@ async function check(dir, opts = {}) {
10873
11056
  async function readComposition(dir) {
10874
11057
  let html;
10875
11058
  try {
10876
- html = await readFile10(join9(dir, "index.html"), "utf8");
11059
+ html = await readFile11(join10(dir, "index.html"), "utf8");
10877
11060
  } catch {
10878
11061
  return { transit: [], duration: 0 };
10879
11062
  }
@@ -11024,8 +11207,8 @@ function tail(s) {
11024
11207
  }
11025
11208
 
11026
11209
  // src/verify/fidelity.ts
11027
- import { readFile as readFile11 } from "node:fs/promises";
11028
- import { join as join10 } from "node:path";
11210
+ import { readFile as readFile12 } from "node:fs/promises";
11211
+ import { join as join11 } from "node:path";
11029
11212
 
11030
11213
  // src/verify/typefloor.ts
11031
11214
  var TYPE_FLOOR_PX = 40;
@@ -11281,6 +11464,16 @@ var INK_FLOOR = 15e-4;
11281
11464
  var INK_DELTA = 12;
11282
11465
  var FALLBACK_BAND_TOP = 0.22;
11283
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
+ }
11284
11477
  async function decodePng(png) {
11285
11478
  const buf = Buffer.from(png.buffer, png.byteOffset, png.byteLength);
11286
11479
  if (buf.length < 8 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG");
@@ -11447,6 +11640,25 @@ function gradeFidelity(rows, floor = INK_FLOOR) {
11447
11640
  };
11448
11641
  });
11449
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
+ }
11450
11662
  function captionBottom(sid, selector, fallbackPx) {
11451
11663
  const scene = document.querySelector(`[data-composition-id="${CSS.escape(sid)}"]`);
11452
11664
  let bottom = 0;
@@ -11471,11 +11683,10 @@ async function fidelity(dir, opts = {}) {
11471
11683
  ],
11472
11684
  elapsedMs: Date.now() - started
11473
11685
  });
11474
- const stops = opts.stops ?? readStops(
11475
- await readFile11(join10(dir, TIMING_FILE), "utf8").catch(() => null),
11476
- await readFile11(join10(dir, DECK_PAGE), "utf8").catch(() => null)
11477
- );
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));
11478
11688
  if (stops.length === 0) return notMeasured("the deck declares no stops");
11689
+ const reserve = opts.captionReserve ?? readReserve(timingText);
11479
11690
  let deck = null;
11480
11691
  try {
11481
11692
  deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
@@ -11504,7 +11715,13 @@ async function fidelity(dir, opts = {}) {
11504
11715
  measured.push({
11505
11716
  ...stop,
11506
11717
  ink: inkBelow(frame, bandTopPx),
11507
- 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) } : {}
11508
11725
  });
11509
11726
  }
11510
11727
  for (const mid of midpoints(stops)) {
@@ -11515,6 +11732,7 @@ async function fidelity(dir, opts = {}) {
11515
11732
  stops: measured,
11516
11733
  findings: [
11517
11734
  ...gradeFidelity(measured, floor),
11735
+ ...gradeReserve(measured),
11518
11736
  ...gradeOverprint(collided),
11519
11737
  ...gradeApparent(apparent)
11520
11738
  ],
@@ -11531,9 +11749,9 @@ async function fidelity(dir, opts = {}) {
11531
11749
  // src/verify/drift.ts
11532
11750
  import { execFile as execFile3 } from "node:child_process";
11533
11751
  import { createHash as createHash9 } from "node:crypto";
11534
- 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";
11535
11753
  import { tmpdir as tmpdir3 } from "node:os";
11536
- import { join as join11 } from "node:path";
11754
+ import { join as join12 } from "node:path";
11537
11755
  import { promisify as promisify3 } from "node:util";
11538
11756
  var run3 = promisify3(execFile3);
11539
11757
  var FLOOR_DB = 40;
@@ -11572,9 +11790,9 @@ function measureMotion(hashes, scenes, seconds) {
11572
11790
  async function drift(dir, opts = {}) {
11573
11791
  const mode = opts.mode ?? "psnr";
11574
11792
  const floorDb = opts.floorDb ?? FLOOR_DB;
11575
- const work = opts.workDir ?? await mkdtemp3(join11(tmpdir3(), "decksmith-drift-"));
11576
- const a = join11(work, "a");
11577
- 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");
11578
11796
  const report = await compare(dir, a, b, mode, floorDb, opts);
11579
11797
  const keep = opts.keep || !report.passed;
11580
11798
  if (keep) report.kept = { a, b };
@@ -11629,11 +11847,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
11629
11847
  const differing = [];
11630
11848
  const ha = [];
11631
11849
  for (const [i, name] of fa.entries()) {
11632
- const one = await sha(join11(a, name));
11850
+ const one = await sha(join12(a, name));
11633
11851
  ha.push(one);
11634
- if (one !== await sha(join11(b, name))) differing.push(i + 1);
11852
+ if (one !== await sha(join12(b, name))) differing.push(i + 1);
11635
11853
  }
11636
- const html = await readFile12(join11(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11854
+ const html = await readFile13(join12(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11637
11855
  const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
11638
11856
  let worst;
11639
11857
  if (differing.length > 0) {
@@ -11744,11 +11962,11 @@ async function render(dir, out, opts) {
11744
11962
  }
11745
11963
  }
11746
11964
  async function frames(dir) {
11747
- const names = await readdir2(dir).catch(() => []);
11965
+ const names = await readdir3(dir).catch(() => []);
11748
11966
  return names.filter((n3) => n3.endsWith(".png")).sort();
11749
11967
  }
11750
11968
  async function sha(file) {
11751
- return createHash9("sha256").update(await readFile12(file)).digest("hex");
11969
+ return createHash9("sha256").update(await readFile13(file)).digest("hex");
11752
11970
  }
11753
11971
  var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
11754
11972
  function framePattern(name) {
@@ -11781,11 +11999,11 @@ async function psnr(a, b, firstFrame) {
11781
11999
  "-start_number",
11782
12000
  String(seq.start),
11783
12001
  "-i",
11784
- join11(a, seq.pattern),
12002
+ join12(a, seq.pattern),
11785
12003
  "-start_number",
11786
12004
  String(seq.start),
11787
12005
  "-i",
11788
- join11(b, seq.pattern),
12006
+ join12(b, seq.pattern),
11789
12007
  "-lavfi",
11790
12008
  "psnr=stats_file=-",
11791
12009
  "-f",
@@ -11860,7 +12078,7 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
11860
12078
  const html = await readCompositions(dir);
11861
12079
  const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
11862
12080
  const narration = scanNarration(
11863
- await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => ""),
12081
+ await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => ""),
11864
12082
  await listFiles(dir)
11865
12083
  );
11866
12084
  const budget2 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
@@ -11897,8 +12115,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
11897
12115
  }
11898
12116
  async function declaredStops(dir) {
11899
12117
  return readStops(
11900
- await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => null),
11901
- 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)
11902
12120
  );
11903
12121
  }
11904
12122
  var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
@@ -11938,7 +12156,7 @@ function scanNarration(page, files) {
11938
12156
  ];
11939
12157
  }
11940
12158
  async function readTiming(dir) {
11941
- const raw2 = await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => "");
12159
+ const raw2 = await readFile14(join13(dir, TIMING_FILE), "utf8").catch(() => "");
11942
12160
  if (!raw2) return void 0;
11943
12161
  try {
11944
12162
  const parsed = JSON.parse(raw2);
@@ -11948,9 +12166,9 @@ async function readTiming(dir) {
11948
12166
  }
11949
12167
  }
11950
12168
  async function listFiles(dir) {
11951
- const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12169
+ const entries = await readdir4(dir, { recursive: true, withFileTypes: true }).catch(() => []);
11952
12170
  return new Set(
11953
- 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("/"))
11954
12172
  );
11955
12173
  }
11956
12174
  var INSTEAD = {
@@ -12206,22 +12424,22 @@ function scanDeterminism(html, file) {
12206
12424
  return findings;
12207
12425
  }
12208
12426
  async function readCompositions(dir) {
12209
- const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12427
+ const entries = await readdir4(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12210
12428
  const files = entries.filter(
12211
12429
  (e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
12212
- ).map((e) => join12(e.parentPath, e.name));
12430
+ ).map((e) => join13(e.parentPath, e.name));
12213
12431
  return Promise.all(
12214
- files.map(async (f) => [relative(dir, f), await readFile13(f, "utf8")])
12432
+ files.map(async (f) => [relative(dir, f), await readFile14(f, "utf8")])
12215
12433
  );
12216
12434
  }
12217
12435
 
12218
12436
  // src/render/render.ts
12219
- import { mkdir as mkdir8, readFile as readFile14, rename as rename2, rm as rm8, writeFile as writeFile10 } from "node:fs/promises";
12220
- 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";
12221
12439
 
12222
12440
  // src/render/captions.ts
12223
- import { mkdir as mkdir7, writeFile as writeFile9 } from "node:fs/promises";
12224
- 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";
12225
12443
  import { pathToFileURL as pathToFileURL2 } from "node:url";
12226
12444
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
12227
12445
  function cssString(value) {
@@ -12312,11 +12530,11 @@ async function captionBlocker() {
12312
12530
  }
12313
12531
  async function renderCaptions(cues, style, deck, work) {
12314
12532
  if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
12315
- const fontCss = join13(deck, DECK_FONT_CSS);
12533
+ const fontCss = join14(deck, DECK_FONT_CSS);
12316
12534
  const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
12317
- const page = join13(work, "captions.html");
12318
- await mkdir7(work, { recursive: true });
12319
- 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));
12320
12538
  const { default: puppeteer } = await import("puppeteer-core");
12321
12539
  const browser = await puppeteer.launch({
12322
12540
  executablePath: await chromePath(CAPTION_NEED),
@@ -12336,7 +12554,7 @@ async function renderCaptions(cues, style, deck, work) {
12336
12554
  const name = `cap${String(i).padStart(4, "0")}.png`;
12337
12555
  await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
12338
12556
  await tab.screenshot({
12339
- path: join13(work, name),
12557
+ path: join14(work, name),
12340
12558
  type: "png",
12341
12559
  omitBackground: true,
12342
12560
  clip: { x: box.x, y: box.y, width: box.width, height: box.height }
@@ -12377,9 +12595,9 @@ function subtitlePlan(mode) {
12377
12595
  async function render2(opts) {
12378
12596
  const log = opts.log ?? (() => {
12379
12597
  });
12380
- const deck = resolve3(opts.deck);
12381
- const out = resolve3(opts.out);
12382
- 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`);
12383
12601
  const timing = await readTiming2(deck);
12384
12602
  if (opts.targetSeconds && !opts.allowFastPlayback) {
12385
12603
  const refusal = playbackRefusal(
@@ -12391,13 +12609,17 @@ async function render2(opts) {
12391
12609
  }
12392
12610
  const plan0 = subtitlePlan(opts.subtitles ?? "sidecar");
12393
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
+ );
12394
12616
  const blocker = await captionBlocker();
12395
12617
  if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
12396
12618
  }
12397
12619
  const burnable = plan0.burn;
12398
- await mkdir8(work, { recursive: true });
12620
+ await mkdir9(work, { recursive: true });
12399
12621
  try {
12400
- 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);
12401
12623
  const shot = await probe(raw2);
12402
12624
  log(
12403
12625
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -12410,18 +12632,18 @@ async function render2(opts) {
12410
12632
  );
12411
12633
  }
12412
12634
  const retimed = await retime(raw2, plan, work, log);
12413
- const srtPath = join14(dirname(out), SRT_NAME(out));
12635
+ const srtPath = join15(dirname2(out), SRT_NAME(out));
12414
12636
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
12415
12637
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
12416
12638
  const srt = plan0.sidecar ? toSrt(cues) : "";
12417
- if (srt) await writeFile10(srtPath, srt);
12639
+ if (srt) await writeFile11(srtPath, srt);
12418
12640
  const burn = burnable && srt.length > 0;
12419
12641
  await mux(retimed, timing, plan, deck, out, work, burn ? plan.cues : void 0, shot.fps, log);
12420
12642
  if (playback > 1) {
12421
12643
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
12422
12644
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
12423
12645
  if (warning) log(`render: ${warning}`);
12424
- const fast = join14(work, `fast.${basename3(out)}`);
12646
+ const fast = join15(work, `fast.${basename3(out)}`);
12425
12647
  const muxed = await probe(out);
12426
12648
  await runTool(
12427
12649
  "ffmpeg",
@@ -12451,8 +12673,8 @@ async function render2(opts) {
12451
12673
  }
12452
12674
  }
12453
12675
  async function readTiming2(deck) {
12454
- const path2 = join14(deck, TIMING_FILE);
12455
- const text2 = await readFile14(path2, "utf8").catch(() => {
12676
+ const path2 = join15(deck, TIMING_FILE);
12677
+ const text2 = await readFile15(path2, "utf8").catch(() => {
12456
12678
  throw new Error(
12457
12679
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
12458
12680
  );
@@ -12491,20 +12713,20 @@ async function retime(raw2, plan, work, log) {
12491
12713
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
12492
12714
  const list = [];
12493
12715
  for (const [i, piece] of plan.pieces.entries()) {
12494
- const file = join14(work, `p${String(i).padStart(4, "0")}.ts`);
12716
+ const file = join15(work, `p${String(i).padStart(4, "0")}.ts`);
12495
12717
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
12496
12718
  list.push(file);
12497
12719
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
12498
12720
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
12499
12721
  }
12500
12722
  }
12501
- const listFile = join14(work, "pieces.txt");
12502
- await writeFile10(
12723
+ const listFile = join15(work, "pieces.txt");
12724
+ await writeFile11(
12503
12725
  listFile,
12504
12726
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
12505
12727
  `
12506
12728
  );
12507
- const joined = join14(work, "retimed.mp4");
12729
+ const joined = join15(work, "retimed.mp4");
12508
12730
  await runTool("ffmpeg", [
12509
12731
  "-y",
12510
12732
  "-hide_banner",
@@ -12526,7 +12748,7 @@ async function retime(raw2, plan, work, log) {
12526
12748
  }
12527
12749
  async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
12528
12750
  const inputs = plan.audio.map((a) => ({
12529
- file: join14(deck, timing.audioDir, a.audio),
12751
+ file: join15(deck, timing.audioDir, a.audio),
12530
12752
  delayMs: a.delayMs
12531
12753
  }));
12532
12754
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -12547,8 +12769,8 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
12547
12769
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
12548
12770
  }
12549
12771
  if (graph.length > 0) {
12550
- const script = join14(work, "mux.filter");
12551
- await writeFile10(script, `${graph.join(";\n")}
12772
+ const script = join15(work, "mux.filter");
12773
+ await writeFile11(script, `${graph.join(";\n")}
12552
12774
  `);
12553
12775
  args.push("-filter_complex_script", script);
12554
12776
  }
@@ -12563,13 +12785,13 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
12563
12785
  log(
12564
12786
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
12565
12787
  );
12566
- await mkdir8(dirname(out), { recursive: true });
12788
+ await mkdir9(dirname2(out), { recursive: true });
12567
12789
  await runTool("ffmpeg", args, { cwd: work });
12568
12790
  }
12569
12791
 
12570
12792
  // src/pack/pack.ts
12571
- import { mkdir as mkdir9, readFile as readFile15, writeFile as writeFile11 } from "node:fs/promises";
12572
- 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";
12573
12795
  import { unzipSync, zipSync } from "fflate";
12574
12796
  var MTIME = Date.UTC(1980, 0, 2, 12);
12575
12797
  var STORED = /* @__PURE__ */ new Set([
@@ -12608,12 +12830,12 @@ async function writePack(pack2, files, out) {
12608
12830
  entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
12609
12831
  }
12610
12832
  const zip = zipSync(entries, { mtime: MTIME });
12611
- await mkdir9(dirname2(out), { recursive: true });
12612
- await writeFile11(out, zip);
12833
+ await mkdir10(dirname3(out), { recursive: true });
12834
+ await writeFile12(out, zip);
12613
12835
  return zip.length;
12614
12836
  }
12615
12837
  async function readPack(path2) {
12616
- return openPack(new Uint8Array(await readFile15(path2)), path2);
12838
+ return openPack(new Uint8Array(await readFile16(path2)), path2);
12617
12839
  }
12618
12840
  function openPack(bytes, label = "pack") {
12619
12841
  if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
@@ -12657,8 +12879,8 @@ function why2(error) {
12657
12879
  }
12658
12880
 
12659
12881
  // src/prefs.ts
12660
- import { readFile as readFile16 } from "node:fs/promises";
12661
- 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";
12662
12884
  var CONFIG_FILE = "decksmith.config.json";
12663
12885
  var PREF_KEYS = Object.keys(prefsSchema.shape);
12664
12886
  var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
@@ -12675,10 +12897,10 @@ async function loadPrefs(overrides = {}, cwd = process.cwd(), source) {
12675
12897
  return parsed;
12676
12898
  }
12677
12899
  async function findConfig(from) {
12678
- let dir = resolve4(from);
12900
+ let dir = resolve5(from);
12679
12901
  for (; ; ) {
12680
- const path2 = join15(dir, CONFIG_FILE);
12681
- 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);
12682
12904
  if (text2 !== void 0) {
12683
12905
  try {
12684
12906
  return { path: path2, value: JSON.parse(text2) };
@@ -12686,7 +12908,7 @@ async function findConfig(from) {
12686
12908
  throw new Error(`${path2} is not valid JSON.`);
12687
12909
  }
12688
12910
  }
12689
- const up = dirname3(dir);
12911
+ const up = dirname4(dir);
12690
12912
  if (up === dir) return void 0;
12691
12913
  dir = up;
12692
12914
  }
@@ -12732,26 +12954,26 @@ ${issues}`);
12732
12954
 
12733
12955
  // src/tmpdir.ts
12734
12956
  import { realpathSync } from "node:fs";
12735
- import { createRequire as createRequire3 } from "node:module";
12957
+ import { createRequire as createRequire4 } from "node:module";
12736
12958
  import { tmpdir as tmpdir4 } from "node:os";
12737
- 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";
12738
12960
  var cached;
12739
12961
  function real(dir) {
12740
- let at = resolve5(dir);
12962
+ let at = resolve6(dir);
12741
12963
  const tail3 = [];
12742
12964
  for (; ; ) {
12743
12965
  try {
12744
- return join16(realpathSync(at), ...tail3);
12966
+ return join17(realpathSync(at), ...tail3);
12745
12967
  } catch {
12746
- const up = dirname4(at);
12747
- if (up === at) return resolve5(dir);
12968
+ const up = dirname5(at);
12969
+ if (up === at) return resolve6(dir);
12748
12970
  tail3.unshift(basename4(at));
12749
12971
  at = up;
12750
12972
  }
12751
12973
  }
12752
12974
  }
12753
12975
  function packageRoot() {
12754
- cached ??= real(dirname4(createRequire3(import.meta.url).resolve("../package.json")));
12976
+ cached ??= real(dirname5(createRequire4(import.meta.url).resolve("../package.json")));
12755
12977
  return cached;
12756
12978
  }
12757
12979
  function insideRoot(dir) {
@@ -12784,8 +13006,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12784
13006
  if (!format) throw new Error("no deck-16x9 format");
12785
13007
  const step = opts.onStep ?? (() => {
12786
13008
  });
12787
- const out = resolve6(outDir);
12788
- await mkdir10(out, { recursive: true });
13009
+ const out = resolve7(outDir);
13010
+ await mkdir11(out, { recursive: true });
12789
13011
  const speed = opts.speed ?? 1;
12790
13012
  const fontCss = await refreshFont(storyboard, source, out, step);
12791
13013
  const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
@@ -12798,47 +13020,48 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
12798
13020
  });
12799
13021
  const files = [];
12800
13022
  const write = async (name, text2) => {
12801
- await writeFile12(join17(out, name), text2);
12802
- files.push(join17(out, name));
13023
+ await writeFile13(join18(out, name), text2);
13024
+ files.push(join18(out, name));
12803
13025
  };
12804
13026
  await write("index.html", deck.composition);
12805
13027
  await write("hyperframes.json", HYPERFRAMES_JSON);
12806
- if (opts.narration) {
12807
- try {
12808
- const timing = planTiming({
12809
- storyboard,
12810
- source,
12811
- format,
12812
- composition: deck.composition,
12813
- // What was drawn, not what the threshold would have kept: a budgeted
12814
- // format cuts to its own length as well, and the two lists differ.
12815
- beats: deck.cut.kept,
12816
- narration: opts.narration,
12817
- speed,
12818
- ...opts.theme ? { theme: opts.theme } : {}
12819
- });
12820
- 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)}
12821
13042
  `);
12822
- step(`build: timing for ${timing.segments.length} narration segment(s)`);
12823
- } catch (err) {
12824
- step(
12825
- `build: cannot place narration on this deck, so no ${TIMING_FILE} was written and \`render\` will refuse. ${err instanceof Error ? err.message : err}`
12826
- );
12827
- }
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
+ );
12828
13048
  }
12829
13049
  if (deck.page) {
12830
13050
  await write(DECK_PAGE, deck.page);
12831
- await cp(playerBundle(), join17(out, PLAYER_FILE));
12832
- files.push(join17(out, PLAYER_FILE));
13051
+ await cp2(playerBundle(), join18(out, PLAYER_FILE));
13052
+ files.push(join18(out, PLAYER_FILE));
12833
13053
  }
12834
13054
  files.push(...await vendorKatex(out));
12835
- 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
+ }
12836
13059
  if (opts.narration && opts.audioFrom) {
12837
- files.push(...await copyAudio(opts.audioFrom, opts.narration, out));
13060
+ files.push(...await copyAudio(opts.audioFrom, opts.narration, out, step));
12838
13061
  }
12839
13062
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
12840
13063
  step(
12841
- `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")}`
12842
13065
  );
12843
13066
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
12844
13067
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -12854,79 +13077,28 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
12854
13077
  )}
12855
13078
  `;
12856
13079
  async function deckRuntime() {
12857
- const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
13080
+ const path2 = fileURLToPath2(new URL(`./${"deck-runtime.js"}`, import.meta.url));
12858
13081
  try {
12859
- return await readFile17(path2, "utf8");
13082
+ return await readFile18(path2, "utf8");
12860
13083
  } catch {
12861
13084
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
12862
13085
  }
12863
13086
  }
12864
13087
  function playerBundle() {
12865
- const require2 = createRequire4(import.meta.url);
13088
+ const require2 = createRequire5(import.meta.url);
12866
13089
  try {
12867
- return join17(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
13090
+ return join18(dirname6(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12868
13091
  } catch {
12869
13092
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
12870
13093
  }
12871
13094
  }
12872
- async function vendorKatex(out) {
12873
- const require2 = createRequire4(import.meta.url);
12874
- const dist = join17(dirname5(require2.resolve("katex/package.json")), "dist");
12875
- const css = await readFile17(join17(dist, "katex.min.css"), "utf8");
12876
- const written = [];
12877
- await mkdir10(join17(out, "katex/fonts"), { recursive: true });
12878
- for (const file of await readdir4(join17(dist, "fonts"))) {
12879
- if (!file.endsWith(".woff2")) continue;
12880
- await cp(join17(dist, "fonts", file), join17(out, "katex/fonts", file));
12881
- written.push(join17(out, "katex/fonts", file));
12882
- }
12883
- const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
12884
- const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
12885
- return kept ? `src:${kept}` : whole;
12886
- });
12887
- await writeFile12(join17(out, "katex/katex.min.css"), woff2Only);
12888
- written.push(join17(out, "katex/katex.min.css"));
12889
- return written;
12890
- }
12891
- async function copyAssets(sourceDir, out) {
12892
- const from = join17(resolve6(sourceDir), "assets");
12893
- if (!await stat2(from).catch(() => null)) return [];
12894
- await cp(from, join17(out, "assets"), { recursive: true });
12895
- return [join17(out, "assets")];
12896
- }
12897
- async function copyAudio(from, narration, out) {
12898
- const dir = join17(out, narration.dir);
12899
- await mkdir10(dir, { recursive: true });
12900
- const names = [
12901
- ...new Set(
12902
- Object.values(narration.beats).flat().map((s) => s.audio)
12903
- )
12904
- ].sort();
12905
- for (const name of names) {
12906
- await cp(join17(resolve6(from), name), join17(dir, name)).catch(() => {
12907
- throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
12908
- });
12909
- }
12910
- return names.map((n3) => join17(dir, n3));
12911
- }
12912
- async function refreshFont(storyboard, source, out, step) {
12913
- try {
12914
- const bundle = await bundleFont(
12915
- storyboard.lang,
12916
- JSON.stringify(source) + JSON.stringify(storyboard),
12917
- join17(out, "assets", "fonts")
12918
- );
12919
- if (bundle) step(`build: font bundle covers ${bundle.family}`);
12920
- return bundle?.css;
12921
- } catch (err) {
12922
- step(
12923
- `build: could not refresh the font bundle (${err instanceof Error ? err.message : err}); keeping the one from ingest`
12924
- );
12925
- return void 0;
12926
- }
12927
- }
12928
13095
  export {
12929
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,
12930
13102
  COMFORTABLE_CPS,
12931
13103
  CONFIG_FILE,
12932
13104
  DECK_PAGE,
@@ -12956,6 +13128,7 @@ export {
12956
13128
  assertInsideResolves,
12957
13129
  assertRefsResolve,
12958
13130
  attachClips,
13131
+ bandReserve,
12959
13132
  barCompareParamsSchema,
12960
13133
  beatRoleSchema,
12961
13134
  beatSchema,
@@ -12964,6 +13137,7 @@ export {
12964
13137
  calloutParamsSchema,
12965
13138
  canvasProblem,
12966
13139
  canvasWarnings,
13140
+ captionFontSize,
12967
13141
  check,
12968
13142
  claimFigureParamsSchema,
12969
13143
  codexPlanner,