@jokerized/decksmith 0.4.1 → 0.5.1

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