@jokerized/decksmith 0.9.0 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +212 -0
  2. package/dist/cli.js +20183 -14325
  3. package/dist/deck-player-element.js +1 -1
  4. package/dist/deck-player.js +1 -1
  5. package/dist/index.js +10605 -4957
  6. package/dist/mcp.js +1558 -319
  7. package/dist/types/bespoke/art.d.ts +173 -0
  8. package/dist/types/bespoke/cache.d.ts +69 -0
  9. package/dist/types/bespoke/callouts.d.ts +76 -0
  10. package/dist/types/bespoke/contract.d.ts +57 -0
  11. package/dist/types/bespoke/inspect.d.ts +69 -0
  12. package/dist/types/bespoke/pipeline.d.ts +207 -0
  13. package/dist/types/bespoke/probe.d.ts +34 -0
  14. package/dist/types/bespoke/prompt.d.ts +182 -0
  15. package/dist/types/bespoke/references.d.ts +73 -0
  16. package/dist/types/bespoke/repair.d.ts +121 -0
  17. package/dist/types/bespoke/scene.d.ts +97 -0
  18. package/dist/types/bespoke/select.d.ts +52 -0
  19. package/dist/types/bespoke/sheet.d.ts +26 -0
  20. package/dist/types/bespoke/shots.d.ts +128 -0
  21. package/dist/types/bespoke/untangle.d.ts +5 -0
  22. package/dist/types/emit/archetypes/index.d.ts +0 -7
  23. package/dist/types/emit/composition.d.ts +37 -2
  24. package/dist/types/emit/kit.d.ts +17 -0
  25. package/dist/types/emit/themes/pick.d.ts +6 -3
  26. package/dist/types/index.d.ts +17 -0
  27. package/dist/types/plan/codex.d.ts +36 -0
  28. package/dist/types/prefs.d.ts +10 -1
  29. package/dist/types/render/capture.d.ts +6 -0
  30. package/dist/types/render/timing.d.ts +3 -0
  31. package/dist/types/types.d.ts +71 -0
  32. package/dist/types/verify/check.d.ts +1 -1
  33. package/dist/types/verify/index.d.ts +14 -0
  34. package/dist/types/verify/scenes.d.ts +325 -0
  35. package/package.json +2 -1
package/dist/mcp.js CHANGED
@@ -58,22 +58,43 @@ var VERSION = createRequire2(import.meta.url)("../package.json").version;
58
58
 
59
59
  // src/mcp/tools.ts
60
60
  import { randomBytes } from "node:crypto";
61
- import { mkdir as mkdir12, mkdtemp as mkdtemp3, readFile as readFile14, rm as rm8 } from "node:fs/promises";
61
+ import { mkdir as mkdir13, mkdtemp as mkdtemp3, readFile as readFile15, rm as rm8 } from "node:fs/promises";
62
62
  import { tmpdir as tmpdir4 } from "node:os";
63
- import { basename as basename5, isAbsolute as isAbsolute2, join as join15, resolve as resolve8, sep as sep3 } from "node:path";
63
+ import { basename as basename5, isAbsolute as isAbsolute2, join as join16, resolve as resolve9, sep as sep4 } from "node:path";
64
64
  import { zipSync as zipSync2 } from "fflate";
65
65
  import { z as z5 } from "zod";
66
66
 
67
67
  // src/index.ts
68
- import { cp as cp2, mkdir as mkdir10, readFile as readFile12, writeFile as writeFile12 } from "node:fs/promises";
68
+ import { cp as cp2, mkdir as mkdir11, readFile as readFile13, writeFile as writeFile13 } from "node:fs/promises";
69
69
  import { createRequire as createRequire4 } from "node:module";
70
- import { dirname as dirname5, join as join13, resolve as resolve6 } from "node:path";
70
+ import { dirname as dirname5, join as join14, resolve as resolve7 } from "node:path";
71
71
  import { fileURLToPath as fileURLToPath2 } from "node:url";
72
72
 
73
+ // src/bespoke/art.ts
74
+ import { copyFile, mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
75
+ import { join as join2, resolve as resolve2, sep as sep2 } from "node:path";
76
+ var MAX_ART_BYTES = 12 * 1024 * 1024;
77
+ async function copyArt(entries, out) {
78
+ const written = [];
79
+ const seen = /* @__PURE__ */ new Set();
80
+ for (const e of entries) {
81
+ if (!e.art || seen.has(e.art.name)) continue;
82
+ seen.add(e.art.name);
83
+ const dir = join2(out, "assets", "bespoke");
84
+ await mkdir(dir, { recursive: true });
85
+ await copyFile(e.art.file, join2(dir, e.art.name));
86
+ written.push(join2(dir, e.art.name));
87
+ }
88
+ return written;
89
+ }
90
+ function artHref(art) {
91
+ return `assets/bespoke/${art.name}`;
92
+ }
93
+
73
94
  // src/build/files.ts
74
- import { cp, mkdir as mkdir2, readdir, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
95
+ import { cp, mkdir as mkdir3, readdir, readFile as readFile3, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
75
96
  import { createRequire as createRequire3 } from "node:module";
76
- import { dirname as dirname2, join as join3, resolve as resolve2 } from "node:path";
97
+ import { dirname as dirname2, join as join4, resolve as resolve3 } from "node:path";
77
98
  import { fileURLToPath } from "node:url";
78
99
 
79
100
  // src/emit/faces.ts
@@ -1065,11 +1086,11 @@ function reshape(target, to, at, seconds, first) {
1065
1086
  sec(at)
1066
1087
  );
1067
1088
  }
1068
- function travel(target, route, at, seconds) {
1089
+ function travel(target, route2, at, seconds) {
1069
1090
  if (!(seconds > 0)) throw new Error(`travel ${target}: ${seconds}s is no time to travel in`);
1070
1091
  const legs = [];
1071
1092
  let prev;
1072
- for (const p of route) {
1093
+ for (const p of route2) {
1073
1094
  const q = { x: nv(p.x), y: nv(p.y) };
1074
1095
  if (prev === void 0) {
1075
1096
  prev = q;
@@ -1610,8 +1631,8 @@ function fitBoxes(req) {
1610
1631
 
1611
1632
  // src/source/fonts.ts
1612
1633
  import { createHash } from "node:crypto";
1613
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1614
- import { join as join2 } from "node:path";
1634
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
1635
+ import { join as join3 } from "node:path";
1615
1636
  function serifFamilyFor(lang) {
1616
1637
  return familyFor(lang)?.replace("Noto Sans", "Noto Serif") ?? null;
1617
1638
  }
@@ -1631,9 +1652,9 @@ async function bundleFont(lang, glyphs, dir, opts = {}) {
1631
1652
  const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
1632
1653
  const stamp = `/* decksmith ${createHash("sha256").update(`${families.join("+")}
1633
1654
  ${text2}`).digest("hex").slice(0, 16)} */`;
1634
- await mkdir(dir, { recursive: true });
1635
- const cssPath = join2(dir, "fonts.css");
1636
- const cached2 = await readFile(cssPath, "utf8").catch(() => "");
1655
+ await mkdir2(dir, { recursive: true });
1656
+ const cssPath = join3(dir, "fonts.css");
1657
+ const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1637
1658
  if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1638
1659
  const res = await fetch(
1639
1660
  `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f.replaceAll(" ", "+")}:wght@400;500;700`).join("&")}&text=${encodeURIComponent(text2)}&display=block`,
@@ -1648,13 +1669,13 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1648
1669
  const font = await fetch(url);
1649
1670
  if (!font.ok) throw new Error(`${url}: HTTP ${font.status}`);
1650
1671
  const name = `${slug}-${i}.woff2`;
1651
- await writeFile(join2(dir, name), Buffer.from(await font.arrayBuffer()));
1672
+ await writeFile2(join3(dir, name), Buffer.from(await font.arrayBuffer()));
1652
1673
  css = css.replaceAll(url, name);
1653
1674
  files.push(name);
1654
1675
  }
1655
1676
  css = `${stamp}
1656
1677
  ${css}`;
1657
- await writeFile(cssPath, css);
1678
+ await writeFile2(cssPath, css);
1658
1679
  return { family, css, files };
1659
1680
  }
1660
1681
  function localNames(css) {
@@ -1960,9 +1981,9 @@ function scenePadding(format) {
1960
1981
  return reserve > 0 ? `${base} ${PAD_Y + reserve}px` : base;
1961
1982
  }
1962
1983
  function referenceSpaceCss(format) {
1963
- const zoom = zoomOf(format);
1964
- if (zoom === 1) return "";
1965
- return ` .scene { width: ${refWidth(format)}px; height: ${refHeight(format)}px; zoom: ${zoom}; }
1984
+ const zoom2 = zoomOf(format);
1985
+ if (zoom2 === 1) return "";
1986
+ return ` .scene { width: ${refWidth(format)}px; height: ${refHeight(format)}px; zoom: ${zoom2}; }
1966
1987
  .scene:has(.scene) { width: 100%; height: 100%; padding: 0; zoom: 1; }`;
1967
1988
  }
1968
1989
  function pace(scene, speed) {
@@ -2017,26 +2038,26 @@ function audioNames(narration) {
2017
2038
  }
2018
2039
  async function vendorKatex(out) {
2019
2040
  const require2 = createRequire3(import.meta.url);
2020
- const dist = join3(dirname2(require2.resolve("katex/package.json")), "dist");
2021
- const css = await readFile2(join3(dist, "katex.min.css"), "utf8");
2041
+ const dist = join4(dirname2(require2.resolve("katex/package.json")), "dist");
2042
+ const css = await readFile3(join4(dist, "katex.min.css"), "utf8");
2022
2043
  const written = [];
2023
- await mkdir2(join3(out, "katex/fonts"), { recursive: true });
2024
- for (const file of await readdir(join3(dist, "fonts"))) {
2044
+ await mkdir3(join4(out, "katex/fonts"), { recursive: true });
2045
+ for (const file of await readdir(join4(dist, "fonts"))) {
2025
2046
  if (!file.endsWith(".woff2")) continue;
2026
- await cp(join3(dist, "fonts", file), join3(out, "katex/fonts", file));
2027
- written.push(join3(out, "katex/fonts", file));
2047
+ await cp(join4(dist, "fonts", file), join4(out, "katex/fonts", file));
2048
+ written.push(join4(out, "katex/fonts", file));
2028
2049
  }
2029
2050
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
2030
2051
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
2031
2052
  return kept ? `src:${kept}` : whole;
2032
2053
  });
2033
- await writeFile2(join3(out, "katex/katex.min.css"), woff2Only);
2034
- written.push(join3(out, "katex/katex.min.css"));
2054
+ await writeFile3(join4(out, "katex/katex.min.css"), woff2Only);
2055
+ written.push(join4(out, "katex/katex.min.css"));
2035
2056
  return written;
2036
2057
  }
2037
2058
  async function vendorScripts(out, composition) {
2038
2059
  const require2 = createRequire3(import.meta.url);
2039
- await mkdir2(join3(out, "vendor"), { recursive: true });
2060
+ await mkdir3(join4(out, "vendor"), { recursive: true });
2040
2061
  const wanted = (name) => composition.includes(`./vendor/${name}`);
2041
2062
  const written = [];
2042
2063
  for (const [pkg, rel, name] of [
@@ -2046,62 +2067,62 @@ async function vendorScripts(out, composition) {
2046
2067
  ["katex/package.json", "dist/katex.min.js", "katex.min.js"]
2047
2068
  ]) {
2048
2069
  if (!wanted(name)) continue;
2049
- const from = join3(dirname2(require2.resolve(pkg)), rel);
2050
- await cp(from, join3(out, "vendor", name));
2051
- written.push(join3(out, "vendor", name));
2070
+ const from = join4(dirname2(require2.resolve(pkg)), rel);
2071
+ await cp(from, join4(out, "vendor", name));
2072
+ written.push(join4(out, "vendor", name));
2052
2073
  }
2053
2074
  if (wanted("ds-morph.js")) {
2054
2075
  await cp(
2055
2076
  fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
2056
- join3(out, "vendor", "ds-morph.js")
2077
+ join4(out, "vendor", "ds-morph.js")
2057
2078
  );
2058
- written.push(join3(out, "vendor", "ds-morph.js"));
2079
+ written.push(join4(out, "vendor", "ds-morph.js"));
2059
2080
  }
2060
2081
  return written;
2061
2082
  }
2062
2083
  async function copyAssets(sourceDir, out, figures, step) {
2063
- const from = join3(resolve2(sourceDir), "assets");
2064
- if (!await stat(from).catch(() => null)) {
2084
+ const from = join4(resolve3(sourceDir), "assets");
2085
+ if (!await stat2(from).catch(() => null)) {
2065
2086
  step(`build: no assets/ beside source.json, skipping`);
2066
2087
  return [];
2067
2088
  }
2068
2089
  const wanted = new Set(
2069
2090
  figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
2070
2091
  );
2071
- await mkdir2(join3(out, "assets"), { recursive: true });
2092
+ await mkdir3(join4(out, "assets"), { recursive: true });
2072
2093
  const written = [];
2073
2094
  let copied = 0;
2074
2095
  for (const name of wanted) {
2075
- const src = resolve2(join3(from, name));
2096
+ const src = resolve3(join4(from, name));
2076
2097
  if (!src.startsWith(`${from}/`)) continue;
2077
- if (!await stat(src).catch(() => null)) continue;
2078
- await mkdir2(dirname2(join3(out, "assets", name)), { recursive: true });
2079
- await cp(src, join3(out, "assets", name));
2080
- written.push(join3(out, "assets", name));
2098
+ if (!await stat2(src).catch(() => null)) continue;
2099
+ await mkdir3(dirname2(join4(out, "assets", name)), { recursive: true });
2100
+ await cp(src, join4(out, "assets", name));
2101
+ written.push(join4(out, "assets", name));
2081
2102
  copied++;
2082
2103
  }
2083
- const fonts = join3(from, "fonts");
2084
- if (await stat(fonts).catch(() => null)) {
2085
- await cp(fonts, join3(out, "assets", "fonts"), { recursive: true });
2086
- written.push(join3(out, "assets", "fonts"));
2104
+ const fonts = join4(from, "fonts");
2105
+ if (await stat2(fonts).catch(() => null)) {
2106
+ await cp(fonts, join4(out, "assets", "fonts"), { recursive: true });
2107
+ written.push(join4(out, "assets", "fonts"));
2087
2108
  }
2088
2109
  step(`build: copied ${copied} referenced figure(s)`);
2089
2110
  return written;
2090
2111
  }
2091
2112
  async function copyAudio(from, narration, out, step) {
2092
- const dir = join3(out, narration.dir);
2093
- await mkdir2(dir, { recursive: true });
2113
+ const dir = join4(out, narration.dir);
2114
+ await mkdir3(dir, { recursive: true });
2094
2115
  const names = audioNames(narration);
2095
2116
  for (const name of names) {
2096
- await cp(join3(resolve2(from), name), join3(dir, name)).catch(() => {
2117
+ await cp(join4(resolve3(from), name), join4(dir, name)).catch(() => {
2097
2118
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
2098
2119
  });
2099
2120
  }
2100
2121
  step(`narration: ${names.length} audio file(s) \u2192 ${dir}`);
2101
- return names.map((n3) => join3(dir, n3));
2122
+ return names.map((n3) => join4(dir, n3));
2102
2123
  }
2103
2124
  async function refreshFont(storyboard, source, out, step, theme) {
2104
- const dir = join3(out, "assets", "fonts");
2125
+ const dir = join4(out, "assets", "fonts");
2105
2126
  const look = deckLook(storyboard, theme).theme;
2106
2127
  const type = typeOf(faceOf(look.fontStack));
2107
2128
  const serif = /"Noto Serif /.test(`${look.fontStack} ${look.displayStack ?? ""}`);
@@ -2147,10 +2168,10 @@ async function vendorFace(face, dir) {
2147
2168
  var PACK_DISPLAY = "font-display: block;\n ascent-override: 96.875%;\n descent-override: 24.1211%;\n line-gap-override: 0%";
2148
2169
  async function vendorFontsource(pkgName, family, dir) {
2149
2170
  const pkg = dirname2(createRequire3(import.meta.url).resolve(pkgName));
2150
- const css = await readFile2(join3(pkg, "index.css"), "utf8");
2151
- await mkdir2(dir, { recursive: true });
2171
+ const css = await readFile3(join4(pkg, "index.css"), "utf8");
2172
+ await mkdir3(dir, { recursive: true });
2152
2173
  for (const [, name] of css.matchAll(/url\(\.\/files\/([^)]+)\)/g)) {
2153
- await cp(join3(pkg, "files", name ?? ""), join3(dir, name ?? ""));
2174
+ await cp(join4(pkg, "files", name ?? ""), join4(dir, name ?? ""));
2154
2175
  }
2155
2176
  const declared = `${family} Variable`;
2156
2177
  const ours = css.replaceAll(`font-family: '${declared}'`, `font-family: '${family}'`).replaceAll("font-display: swap", family === "Inter" ? "font-display: block" : PACK_DISPLAY).replace(/url\(\.\/files\/([^)]+)\) format\('woff2-variations'\)/g, "url($1) format('woff2')");
@@ -2160,43 +2181,6 @@ async function vendorFontsource(pkgName, family, dir) {
2160
2181
  return ours;
2161
2182
  }
2162
2183
 
2163
- // src/deck/playback.ts
2164
- var PLAYER_META = "decksmith-player";
2165
- var PLAYER_MARKER = `<meta name="${PLAYER_META}" content="2" />`;
2166
- function markV2(page) {
2167
- const existing = new RegExp(`<meta name="${PLAYER_META}"[^>]*>`);
2168
- if (existing.test(page)) return page.replace(existing, PLAYER_MARKER);
2169
- const head = /<head[^>]*>/i.exec(page);
2170
- if (!head) throw new Error("not a deck page: it has no <head> to mark");
2171
- const at = head.index + head[0].length;
2172
- return `${page.slice(0, at)}
2173
- ${PLAYER_MARKER}${page.slice(at)}`;
2174
- }
2175
-
2176
- // src/emit/fit.ts
2177
- function fitOf(ink2, region) {
2178
- const r = (n3) => Math.round(n3 * 1e3) / 1e3;
2179
- return { fill: region > 0 ? r(ink2 / region) : 0, region: r(region), ink: r(ink2) };
2180
- }
2181
- var GROWTH = 1.6;
2182
- function growToFit(height, budget2, lo = 1, hi = GROWTH) {
2183
- if (height(hi) <= budget2) return hi;
2184
- if (height(lo) > budget2) return lo;
2185
- let a = lo;
2186
- let b = hi;
2187
- for (let i = 0; i < 24; i++) {
2188
- const mid = (a + b) / 2;
2189
- if (height(mid) <= budget2) a = mid;
2190
- else b = mid;
2191
- }
2192
- return Math.floor(a * 128) / 128;
2193
- }
2194
- var MEASURE_SLACK = 0.92;
2195
- function isV2(ctx) {
2196
- return ctx.design === "v2";
2197
- }
2198
- var FIT_FILE = "fit.json";
2199
-
2200
2184
  // src/emit/archetypes/title.ts
2201
2185
  function isPortrait(format) {
2202
2186
  return format.height > format.width;
@@ -2396,6 +2380,452 @@ var title = (beat, ctx) => {
2396
2380
  };
2397
2381
  };
2398
2382
 
2383
+ // src/bespoke/callouts.ts
2384
+ var LABEL_PX = 56;
2385
+ var LABEL_MIN_PX = 44;
2386
+ var ZONE_H = Math.round(LABEL_PX * 1.7);
2387
+ var GAP = 12;
2388
+ var ZONE_W = 560;
2389
+ var EDGE = 8;
2390
+ var DOT_IN = 14;
2391
+ function calloutZones(subjects, W) {
2392
+ const cx = subjects.map((s) => s.x + s.w / 2);
2393
+ const order = cx.map((_, i) => i).sort((a, b) => cx[a] - cx[b]);
2394
+ const zones = [];
2395
+ order.forEach((i, rank) => {
2396
+ const s = subjects[i];
2397
+ const c = cx[i];
2398
+ const left = rank > 0 ? (c - cx[order[rank - 1]]) / 2 - GAP / 2 : c - EDGE;
2399
+ const right = rank < order.length - 1 ? (cx[order[rank + 1]] - c) / 2 - GAP / 2 : W - EDGE - c;
2400
+ const half = Math.max(80, Math.min(ZONE_W / 2, left, right));
2401
+ const x0 = Math.max(EDGE, Math.min(W - EDGE - 2 * half, c - half));
2402
+ const above = s.y - GAP - ZONE_H;
2403
+ const side = above >= EDGE ? "above" : "top";
2404
+ const y = side === "above" ? above : Math.max(EDGE, s.y + GAP);
2405
+ const dotY = side === "above" ? Math.min(s.y + s.h - 10, s.y + DOT_IN) : Math.min(s.y + s.h - 10, y + ZONE_H + DOT_IN);
2406
+ zones.push({
2407
+ subject: i + 1,
2408
+ side,
2409
+ x: Math.round(x0),
2410
+ y: Math.round(y),
2411
+ w: Math.round(2 * half),
2412
+ h: ZONE_H,
2413
+ dot: { x: Math.round(c), y: Math.round(dotY) }
2414
+ });
2415
+ });
2416
+ return zones.sort((a, b) => a.subject - b.subject);
2417
+ }
2418
+ function halves(text2) {
2419
+ const t2 = text2.trim();
2420
+ const spaces = [...t2.matchAll(/ /g)].map((m) => m.index ?? 0);
2421
+ if (spaces.length) {
2422
+ const mid = t2.length / 2;
2423
+ const at = spaces.reduce((a, b) => Math.abs(b - mid) < Math.abs(a - mid) ? b : a);
2424
+ return [t2.slice(0, at), t2.slice(at + 1)];
2425
+ }
2426
+ const chars = [...t2];
2427
+ if (chars.length < 4 || chars.every((c) => (c.codePointAt(0) ?? 0) < 128)) return void 0;
2428
+ const k = Math.ceil(chars.length / 2);
2429
+ return [chars.slice(0, k).join(""), chars.slice(k).join("")];
2430
+ }
2431
+ var plateH = (n3, fs) => Math.round(n3 === 1 ? fs * 1.6 : fs * (1.25 * n3 + 0.5));
2432
+ function fitLabel(text2, zone, theme, boxH = Infinity) {
2433
+ const face = faceOf(theme.fontStack);
2434
+ const w = (s, fs) => textWidth(s, fs, 700, 0, false, face) + 0.8 * fs;
2435
+ for (let fs = LABEL_PX; fs >= LABEL_MIN_PX; fs -= 2)
2436
+ if (w(text2, fs) <= zone.w) return { fs, lines: [text2] };
2437
+ const two = halves(text2);
2438
+ if (!two) return void 0;
2439
+ for (let fs = LABEL_PX; fs >= LABEL_MIN_PX; fs -= 2) {
2440
+ if (Math.max(w(two[0], fs), w(two[1], fs)) > zone.w) continue;
2441
+ const h = plateH(2, fs);
2442
+ const ok = zone.side === "above" ? zone.y + zone.h - h >= EDGE : zone.y + h <= boxH - EDGE;
2443
+ if (ok) return { fs, lines: two };
2444
+ }
2445
+ return void 0;
2446
+ }
2447
+ function withZone(s, z7) {
2448
+ if (!z7) return s;
2449
+ const x0 = Math.min(s.x, z7.x);
2450
+ const y0 = Math.min(s.y, z7.y);
2451
+ const x1 = Math.max(s.x + s.w, z7.x + z7.w);
2452
+ const y1 = Math.max(s.y + s.h, z7.y + z7.h);
2453
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
2454
+ }
2455
+ var esc2 = (s) => s.replace(
2456
+ /[&<>"]/g,
2457
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]
2458
+ );
2459
+ function place(label, zone, theme, boxH) {
2460
+ const fit = fitLabel(label.text, zone, theme, boxH);
2461
+ if (!fit) return void 0;
2462
+ const { fs, lines } = fit;
2463
+ const face = faceOf(theme.fontStack);
2464
+ const pw = Math.ceil(
2465
+ Math.max(...lines.map((l) => textWidth(l, fs, 700, 0, false, face))) + 0.8 * fs
2466
+ );
2467
+ const ph = plateH(lines.length, fs);
2468
+ const cx = Math.max(zone.x + pw / 2, Math.min(zone.x + zone.w - pw / 2, zone.dot.x));
2469
+ const py = lines.length === 1 ? zone.y + (zone.h - ph) / 2 : zone.side === "above" ? zone.y + zone.h - ph : zone.y;
2470
+ const plate2 = { x: Math.round(cx - pw / 2), y: Math.round(py), w: pw, h: ph };
2471
+ const lx = Math.max(plate2.x + 24, Math.min(plate2.x + plate2.w - 24, zone.dot.x));
2472
+ const dotY = Math.max(zone.dot.y, plate2.y + plate2.h + 22);
2473
+ return {
2474
+ k: label.subject,
2475
+ fs,
2476
+ lines,
2477
+ plate: plate2,
2478
+ text: { x: Math.round(cx), y: Math.round(py + ph / 2) },
2479
+ lead: { x1: lx, y1: plate2.y + plate2.h, x2: zone.dot.x, y2: dotY - 10 },
2480
+ dot: { x: zone.dot.x, y: dotY }
2481
+ };
2482
+ }
2483
+ function entrances(moves, subjects) {
2484
+ const out = /* @__PURE__ */ new Map();
2485
+ for (const m of moves)
2486
+ if (m.subject > 0 && !out.has(m.subject)) out.set(m.subject, m.t + 0.6 * m.dur);
2487
+ const reveal = moves.find((m, i) => m.subject === 0 && i === moves.length - 1);
2488
+ for (let k = 1; k <= subjects; k++) if (!out.has(k)) out.set(k, reveal ? reveal.t + 0.4 : 0.5);
2489
+ return out;
2490
+ }
2491
+ function calloutLayer(sid, labels, zones, moves, theme, W, H) {
2492
+ const seen = /* @__PURE__ */ new Set();
2493
+ const placed = [];
2494
+ for (const l of labels) {
2495
+ const z7 = zones.find((x) => x.subject === l.subject);
2496
+ if (!z7 || seen.has(l.subject)) continue;
2497
+ const p = place(l, z7, theme, H);
2498
+ if (!p) continue;
2499
+ seen.add(l.subject);
2500
+ placed.push({ ...p, k: l.subject });
2501
+ }
2502
+ if (!placed.length) return { markup: "", script: "" };
2503
+ const tone2 = [theme.tones.a, theme.tones.b, theme.tones.c, theme.tones.d];
2504
+ const markup = `<svg class="ds-callouts" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="position:absolute;left:0;top:0;overflow:visible;pointer-events:none">${placed.map((p) => {
2505
+ const lab = labels.find((l) => l.subject === p.k);
2506
+ const len = Math.round(Math.hypot(p.lead.x2 - p.lead.x1, p.lead.y2 - p.lead.y1));
2507
+ return `<g id="${sid}-callout${p.k}" data-subject="${p.k}"><line id="${sid}-callout${p.k}-lead" x1="${p.lead.x1}" y1="${p.lead.y1}" x2="${p.lead.x2}" y2="${p.lead.y2}" stroke="${theme.fg}" stroke-width="5" stroke-linecap="round" stroke-dasharray="${len}" stroke-dashoffset="0"/><circle id="${sid}-callout${p.k}-dot" cx="${p.dot.x}" cy="${p.dot.y}" r="11" fill="${theme.fg}" stroke="${theme.bg}" stroke-width="4"/><g id="${sid}-callout${p.k}-tag"><rect x="${p.plate.x}" y="${p.plate.y}" width="${p.plate.w}" height="${p.plate.h}" rx="${Math.round(p.fs * 0.4)}" fill="${theme.panel}" stroke="${tone2[(p.k - 1) % 4]}" stroke-width="5"/><text x="${p.text.x}" y="${p.text.y}" font-size="${p.fs}" font-weight="700" fill="${theme.fg}" text-anchor="middle" dominant-baseline="middle">${p.lines.length === 1 ? esc2(lab.text) : p.lines.map(
2508
+ (l, i) => `<tspan x="${p.text.x}" dy="${i === 0 ? -0.625 * p.fs : 1.25 * p.fs}">${esc2(l)}</tspan>`
2509
+ ).join("")}</text></g></g>`;
2510
+ }).join("")}</svg>`;
2511
+ const at = entrances(moves, Math.max(...placed.map((p) => p.k)));
2512
+ const r2 = (n3) => Math.round(n3 * 100) / 100;
2513
+ const lines = [
2514
+ "// The shell's labels (src/bespoke/callouts.ts): each enters as the camera arrives on its subject."
2515
+ ];
2516
+ for (const p of placed) {
2517
+ const g = JSON.stringify(`#${sid}-callout${p.k}`);
2518
+ const tag = JSON.stringify(`#${sid}-callout${p.k}-tag`);
2519
+ const lead = JSON.stringify(`#${sid}-callout${p.k}-lead`);
2520
+ const dot = JSON.stringify(`#${sid}-callout${p.k}-dot`);
2521
+ const len = Math.round(Math.hypot(p.lead.x2 - p.lead.x1, p.lead.y2 - p.lead.y1));
2522
+ const t2 = r2(at.get(p.k) ?? 0.5);
2523
+ lines.push(
2524
+ `gsap.set(${g}, { opacity: 0 });`,
2525
+ `gsap.set(${tag}, { y: 14 });`,
2526
+ `gsap.set(${lead}, { strokeDashoffset: ${len} });`,
2527
+ `gsap.set(${dot}, { scale: 0, transformOrigin: "50% 50%" });`,
2528
+ `tl.fromTo(${g}, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power2.out", immediateRender: false }, ${t2});`,
2529
+ `tl.fromTo(${tag}, { y: 14 }, { y: 0, duration: 0.45, ease: "power3.out", immediateRender: false }, ${t2});`,
2530
+ `tl.fromTo(${lead}, { strokeDashoffset: ${len} }, { strokeDashoffset: 0, duration: 0.4, ease: "power2.out", immediateRender: false }, ${r2(t2 + 0.1)});`,
2531
+ `tl.fromTo(${dot}, { scale: 0, transformOrigin: "50% 50%" }, { scale: 1, duration: 0.3, ease: "back.out(2)", immediateRender: false }, ${r2(t2 + 0.4)});`
2532
+ );
2533
+ }
2534
+ return { markup, script: lines.join("\n") };
2535
+ }
2536
+
2537
+ // src/bespoke/contract.ts
2538
+ import { parse } from "acorn";
2539
+ var SID_TOKEN = "SCENEID";
2540
+ function instantiate(f, sid) {
2541
+ const swap = (s) => s.split(SID_TOKEN).join(sid);
2542
+ return {
2543
+ markup: swap(f.markup),
2544
+ css: swap(f.css),
2545
+ script: swap(f.script),
2546
+ ...f.shots ? { shots: f.shots } : {},
2547
+ ...f.labels ? { labels: f.labels } : {}
2548
+ };
2549
+ }
2550
+ var SCOPED = new RegExp(`^#${SID_TOKEN}(?![A-Za-z0-9_])`);
2551
+ var LOCAL_URL = new RegExp(`url\\(\\s*['"]?#${SID_TOKEN}-[\\w-]+['"]?\\s*\\)`, "g");
2552
+
2553
+ // src/bespoke/shots.ts
2554
+ var CAMERA_MAX_SCALE = 2.2;
2555
+ var CLOSE_MIN = 1.6;
2556
+ var MOVE = 1.1;
2557
+ var REVEAL = 1.3;
2558
+ var MIN_HOLD = 1.6;
2559
+ var ESTABLISH = 1.6;
2560
+ var CREEP = 1.03;
2561
+ var CREEP_GAP = 0.05;
2562
+ var ART_BAND = 120;
2563
+ function artPlacement(box) {
2564
+ return { x: 0, y: ART_BAND, w: box.width, h: Math.max(1, box.height - ART_BAND) };
2565
+ }
2566
+ function subjectsInBox(units, art, box) {
2567
+ const at = artPlacement(box);
2568
+ const k = Math.max(at.w / art.width, at.h / art.height);
2569
+ const ox = at.x + (at.w - art.width * k) / 2;
2570
+ const oy = at.y + (at.h - art.height * k) / 2;
2571
+ const out = [];
2572
+ for (const [ux, uy, uw, uh] of units) {
2573
+ const x0 = ox + ux * art.width * k;
2574
+ const y0 = oy + uy * art.height * k;
2575
+ const x1 = x0 + uw * art.width * k;
2576
+ const y1 = y0 + uh * art.height * k;
2577
+ const cx0 = Math.max(at.x, x0);
2578
+ const cy0 = Math.max(at.y, y0);
2579
+ const cx1 = Math.min(at.x + at.w, x1);
2580
+ const cy1 = Math.min(at.y + at.h, y1);
2581
+ if (cx1 <= cx0 || cy1 <= cy0) continue;
2582
+ if ((cx1 - cx0) * (cy1 - cy0) < 0.5 * (x1 - x0) * (y1 - y0)) continue;
2583
+ out.push({
2584
+ x: Math.round(cx0),
2585
+ y: Math.round(cy0),
2586
+ w: Math.round(cx1 - cx0),
2587
+ h: Math.round(cy1 - cy0)
2588
+ });
2589
+ }
2590
+ return out;
2591
+ }
2592
+ function frameOn(b, W, H) {
2593
+ const padX = Math.max(40, 0.15 * b.w);
2594
+ const padY = Math.max(40, 0.15 * b.h);
2595
+ const fit = Math.min(W / (b.w + 2 * padX), H / (b.h + 2 * padY));
2596
+ const s = Math.min(CAMERA_MAX_SCALE, Math.max(CLOSE_MIN, fit));
2597
+ const cx = b.x + b.w / 2;
2598
+ const cy = fit < CLOSE_MIN ? b.y - 12 + H / (2 * s) : b.y + b.h / 2;
2599
+ const clamp3 = (v, lo) => Math.min(0, Math.max(lo, v));
2600
+ const r = (v) => Math.round(v * 1e3) / 1e3;
2601
+ return {
2602
+ s: r(s),
2603
+ x: Math.round(clamp3(W / 2 - cx * s, W - W * s)),
2604
+ y: Math.round(clamp3(H / 2 - cy * s, H - H * s))
2605
+ };
2606
+ }
2607
+ function defaultShots(cues, subjects) {
2608
+ if (subjects === 0 || cues === 0) return [];
2609
+ const out = [];
2610
+ if (cues <= 2) {
2611
+ out.push({ cue: 1, at: 0.5, subject: 1 });
2612
+ if (subjects > 1 && cues === 2) out.push({ cue: 2, at: 0, subject: 2 });
2613
+ return out;
2614
+ }
2615
+ for (let c = 2; c < cues; c++) out.push({ cue: c, at: 0, subject: (c - 2) % subjects + 1 });
2616
+ return out;
2617
+ }
2618
+ function compileShots(shots, cues, duration, subjects, W, H) {
2619
+ const n3 = cues.length;
2620
+ if (n3 === 0 || subjects.length === 0) return [];
2621
+ const valid = shots.filter(
2622
+ (s) => Number.isInteger(s.cue) && s.cue >= 1 && s.cue <= n3 && Number.isInteger(s.subject) && s.subject >= 0 && s.subject <= subjects.length && Number.isFinite(s.at)
2623
+ );
2624
+ const list = valid.some((s) => s.subject > 0) ? valid : defaultShots(n3, subjects.length);
2625
+ const first = cues[0];
2626
+ const last = cues[n3 - 1];
2627
+ const latest = Math.max(0, duration - 0.6 - REVEAL);
2628
+ const timed = list.map((s) => {
2629
+ const c = cues[s.cue - 1];
2630
+ const share2 = s.at > 1 ? s.at / Math.max(0.1, c.t1 - c.t0) : s.at;
2631
+ const at2 = Math.min(0.85, Math.max(0, share2));
2632
+ return { t: c.t0 + at2 * (c.t1 - c.t0), subject: s.subject };
2633
+ }).sort((a, b) => a.t - b.t);
2634
+ const earliest = Math.min(first.t0 + ESTABLISH, Math.max(first.t0, first.t1 - MOVE));
2635
+ const r2 = (v) => Math.round(v * 100) / 100;
2636
+ const moves = [];
2637
+ let at = earliest;
2638
+ let current = 0;
2639
+ for (const s of timed) {
2640
+ const t2 = Math.max(s.t, at);
2641
+ if (t2 > latest - MIN_HOLD) break;
2642
+ if (s.subject === current) continue;
2643
+ const f = s.subject === 0 ? { s: 1, x: 0, y: 0 } : frameOn(subjects[s.subject - 1], W, H);
2644
+ moves.push({ t: r2(t2), dur: MOVE, ...f, ease: "power3.inOut", subject: s.subject });
2645
+ current = s.subject;
2646
+ at = t2 + Math.max(MIN_HOLD, MOVE + 0.5);
2647
+ }
2648
+ if (current !== 0) {
2649
+ const t2 = Math.min(latest, Math.max(last.t0, at));
2650
+ moves.push({ t: r2(t2), dur: REVEAL, s: 1, x: 0, y: 0, ease: "power2.inOut", subject: 0 });
2651
+ }
2652
+ return moves;
2653
+ }
2654
+ function cameraScript(sid, moves, W, H) {
2655
+ const cam = JSON.stringify(`#${sid}-cam`);
2656
+ const lines = [
2657
+ "// The shell's camera (src/bespoke/shots.ts): establishing, push in, reveal.",
2658
+ `gsap.set(${cam}, { scale: 1, x: 0, y: 0, transformOrigin: "0 0" });`
2659
+ ];
2660
+ const v = (f) => `scale: ${f.s}, x: ${f.x}, y: ${f.y}`;
2661
+ const crept = (f) => ({
2662
+ s: Math.round(f.s * CREEP * 1e3) / 1e3,
2663
+ x: Math.round(W / 2 - (W / 2 - f.x) * CREEP),
2664
+ y: Math.round(H / 2 - (H / 2 - f.y) * CREEP)
2665
+ });
2666
+ let from = { s: 1, x: 0, y: 0 };
2667
+ let free2 = 0;
2668
+ for (const m of moves) {
2669
+ const start = Math.ceil((free2 + (free2 > 0 ? CREEP_GAP : 0)) * 100) / 100;
2670
+ const hold = Math.floor((m.t - CREEP_GAP - start) * 100) / 100;
2671
+ if (hold > 0.6) {
2672
+ const to = crept(from);
2673
+ lines.push(
2674
+ `tl.fromTo(${cam}, { ${v(from)} }, { ${v(to)}, duration: ${hold}, ease: "sine.inOut", immediateRender: false }, ${start});`
2675
+ );
2676
+ from = to;
2677
+ }
2678
+ lines.push(
2679
+ `tl.fromTo(${cam}, { ${v(from)} }, { ${v(m)}, duration: ${m.dur}, ease: "${m.ease}", immediateRender: false }, ${m.t});`
2680
+ );
2681
+ from = { s: m.s, x: m.x, y: m.y };
2682
+ free2 = m.t + m.dur;
2683
+ }
2684
+ return lines.join("\n");
2685
+ }
2686
+
2687
+ // src/bespoke/scene.ts
2688
+ var BODY_TOP = 34;
2689
+ function bespokeRegion(beat, ctx) {
2690
+ const p = beat.params;
2691
+ const face = faceOf(ctx.theme.fontStack);
2692
+ return {
2693
+ width: contentW(ctx.format),
2694
+ height: Math.round(bodyBudget(ctx.format, p.eyebrow, p.headline, 0, BODY_TOP, 320, face))
2695
+ };
2696
+ }
2697
+ function usesCamera(script) {
2698
+ return /#[\w]+-cam(?![\w-])/.test(script);
2699
+ }
2700
+ function withArt(markup, art, box) {
2701
+ if (!art) return markup;
2702
+ const href = (s) => s.replace(
2703
+ /<image\b([^>]*?)\sdata-art\s*=\s*(["']?)1\2/gi,
2704
+ (m) => `${m} href="${artHref(art)}"`
2705
+ );
2706
+ if (!box) return href(markup);
2707
+ return href(
2708
+ markup.replace(/<image\b([^>]*?)(\/?)>/gi, (m, attrs2, close) => {
2709
+ if (!/\sdata-art\s*=\s*(["']?)1\1/i.test(` ${attrs2}`)) return m;
2710
+ const kept = attrs2.replace(
2711
+ /\s(x|y|width|height|preserveAspectRatio)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi,
2712
+ ""
2713
+ );
2714
+ const at = artPlacement(box);
2715
+ return `<image${kept} x="${at.x}" y="${at.y}" width="${at.w}" height="${at.h}" preserveAspectRatio="xMidYMid slice"${close}>`;
2716
+ })
2717
+ );
2718
+ }
2719
+ function subjectLayer(subjects, width, height) {
2720
+ if (!subjects.length) return "";
2721
+ return `<svg class="ds-subjects" aria-hidden="true" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="position:absolute;left:0;top:0;overflow:visible;pointer-events:none">${subjects.map(
2722
+ (b, i) => `<rect data-ds-subject="${i + 1}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" fill="none" visibility="hidden"/>`
2723
+ ).join("")}</svg>`;
2724
+ }
2725
+ function staging(entry, box) {
2726
+ const art = entry.art;
2727
+ if (!art?.subjects?.length || !entry.stage) return { subjects: [], zones: [], moves: [] };
2728
+ const subjects = subjectsInBox(art.subjects, art, box);
2729
+ const zones = calloutZones(subjects, box.width);
2730
+ const labelled = new Set((entry.fragment.labels ?? []).map((l) => l.subject));
2731
+ const targets = subjects.map(
2732
+ (s, i) => labelled.has(i + 1) ? withZone(
2733
+ s,
2734
+ zones.find((z7) => z7.subject === i + 1)
2735
+ ) : s
2736
+ );
2737
+ const moves = compileShots(
2738
+ entry.fragment.shots ?? [],
2739
+ entry.stage.cues,
2740
+ entry.stage.duration,
2741
+ targets,
2742
+ box.width,
2743
+ box.height
2744
+ );
2745
+ return { subjects, zones, moves };
2746
+ }
2747
+ var ART_FEATHER = "-webkit-mask-image:linear-gradient(to right,transparent,#000 6%,#000 94%,transparent),linear-gradient(to bottom,transparent,#000 10%,#000 90%,transparent);-webkit-mask-composite:source-in;mask-composite:intersect";
2748
+ function usesMorph(script) {
2749
+ return /\bmorphSVG\b/.test(script);
2750
+ }
2751
+ function bespokeScene(beat, ctx, entry) {
2752
+ const p = beat.params;
2753
+ const { sid, theme } = ctx;
2754
+ const face = faceOf(theme.fontStack);
2755
+ const f = instantiate(entry.fragment, sid);
2756
+ const { width, height } = bespokeRegion(beat, ctx);
2757
+ const { subjects, zones, moves } = staging(entry, { width, height });
2758
+ const shot = moves.length ? cameraScript(sid, moves, width, height) : "";
2759
+ const callouts = subjects.length ? calloutLayer(sid, f.labels ?? [], zones, moves, theme, width, height) : { markup: "", script: "" };
2760
+ const script = [shot, callouts.script, f.script].filter(Boolean).join("\n");
2761
+ const camera = usesCamera(script) || /\bviewBox\b/.test(script);
2762
+ return {
2763
+ html: `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format), face)}
2764
+ <div class="ds-bespoke" id="${sid}-g"${camera ? " data-ds-clip" : ""}>
2765
+ <div class="ds-cam" id="${sid}-cam">
2766
+ ${withArt(f.markup, entry.art, subjects.length ? { width, height } : void 0)}
2767
+ ${subjectLayer(subjects, width, height)}
2768
+ ${callouts.markup}
2769
+ </div>
2770
+ </div>`,
2771
+ tl: chromeIn(sid, p.eyebrow !== void 0),
2772
+ script,
2773
+ holds: entry.holds,
2774
+ // Vendored only when a scene morphs, so no other deck carries its bytes.
2775
+ ...usesMorph(f.script) ? { plugins: ["morphSVG"] } : {},
2776
+ css: [
2777
+ chromeCss(theme),
2778
+ `#${sid}-g{position:relative;flex:none;width:${width}px;height:${height}px;margin-top:${BODY_TOP}px;color:${theme.fg}${camera ? ";overflow:hidden" : ""}}`,
2779
+ `#${sid}-cam{position:absolute;left:0;top:0;width:${width}px;height:${height}px;transform-origin:0 0}`,
2780
+ // The illustration's edges, feathered by the shell unless the scene masks
2781
+ // it itself: its flat ground meets a pack ground that is often a
2782
+ // gradient, and an unmasked picture showed as a lighter rectangle.
2783
+ // A picture whose copy is feathered already (alpha baked in by the pass)
2784
+ // needs none: MEASURED 2026-10-09, this CSS mask made every screenshot of
2785
+ // its scene ~0.7s slower (13.5s against 4.1s for one scene's 13 frames).
2786
+ ...entry.art && !entry.art.feathered ? [`#${sid}-g image[data-art]:not([mask]){${ART_FEATHER}}`] : [],
2787
+ f.css
2788
+ ].join("\n")
2789
+ };
2790
+ }
2791
+
2792
+ // src/deck/playback.ts
2793
+ var PLAYER_META = "decksmith-player";
2794
+ var PLAYER_MARKER = `<meta name="${PLAYER_META}" content="2" />`;
2795
+ function markV2(page) {
2796
+ const existing = new RegExp(`<meta name="${PLAYER_META}"[^>]*>`);
2797
+ if (existing.test(page)) return page.replace(existing, PLAYER_MARKER);
2798
+ const head = /<head[^>]*>/i.exec(page);
2799
+ if (!head) throw new Error("not a deck page: it has no <head> to mark");
2800
+ const at = head.index + head[0].length;
2801
+ return `${page.slice(0, at)}
2802
+ ${PLAYER_MARKER}${page.slice(at)}`;
2803
+ }
2804
+
2805
+ // src/emit/fit.ts
2806
+ function fitOf(ink2, region) {
2807
+ const r = (n3) => Math.round(n3 * 1e3) / 1e3;
2808
+ return { fill: region > 0 ? r(ink2 / region) : 0, region: r(region), ink: r(ink2) };
2809
+ }
2810
+ var GROWTH = 1.6;
2811
+ function growToFit(height, budget2, lo = 1, hi = GROWTH) {
2812
+ if (height(hi) <= budget2) return hi;
2813
+ if (height(lo) > budget2) return lo;
2814
+ let a = lo;
2815
+ let b = hi;
2816
+ for (let i = 0; i < 24; i++) {
2817
+ const mid = (a + b) / 2;
2818
+ if (height(mid) <= budget2) a = mid;
2819
+ else b = mid;
2820
+ }
2821
+ return Math.floor(a * 128) / 128;
2822
+ }
2823
+ var MEASURE_SLACK = 0.92;
2824
+ function isV2(ctx) {
2825
+ return ctx.design === "v2";
2826
+ }
2827
+ var FIT_FILE = "fit.json";
2828
+
2399
2829
  // src/emit/archetypes/annotated-figure.ts
2400
2830
  var STAGE_GAP = 30;
2401
2831
  var CAP_GAP = 26;
@@ -2403,7 +2833,7 @@ var CAP_LH = 1.35;
2403
2833
  var CAP_LINES = 2;
2404
2834
  var PLATE = 15;
2405
2835
  var LEAD = 18;
2406
- var EDGE = 10;
2836
+ var EDGE2 = 10;
2407
2837
  var LAB = MIN_FONT;
2408
2838
  var LAB_WEIGHT = 600;
2409
2839
  var LAB_LH = 1.3;
@@ -2469,8 +2899,8 @@ function attempt(stageW, notes, fig, stageH, want, tall, face, sides = { l: true
2469
2899
  const stacks = row ? 1 : Math.max(1, Math.ceil(notes.length / 2));
2470
2900
  const bandMin = tall ? stacks * (ASCENT + RULE_LIFT + RULE_W) + (stacks - 1) * LAB_GAP + BAND_GAP : 0;
2471
2901
  const asked = Math.min(want, stageW / 3);
2472
- const margin = (s) => tall || !sides[s] ? EDGE : asked + LEAD + EDGE;
2473
- const plateMax = tall ? stageW - 2 * EDGE : stageW - margin("l") - margin("r");
2902
+ const margin = (s) => tall || !sides[s] ? EDGE2 : asked + LEAD + EDGE2;
2903
+ const plateMax = tall ? stageW - 2 * EDGE2 : stageW - margin("l") - margin("r");
2474
2904
  const scale = Math.min(
2475
2905
  (plateMax - 2 * PLATE) / fig.width,
2476
2906
  (stageH - bandMin - 2 * PLATE) / fig.height,
@@ -2487,7 +2917,7 @@ function attempt(stageW, notes, fig, stageH, want, tall, face, sides = { l: true
2487
2917
  // band under it, so centring it would open a gap the labels then hang off.
2488
2918
  y: tall ? PLATE : (stageH - fig.height * scale) / 2
2489
2919
  };
2490
- const col = row ? band[0]?.w ?? stageW : tall ? (stageW - MID_GAP) / 2 : sides.l ? img.x - PLATE - LEAD - EDGE : stageW - (img.x + img.w) - PLATE - LEAD - EDGE;
2920
+ const col = row ? band[0]?.w ?? stageW : tall ? (stageW - MID_GAP) / 2 : sides.l ? img.x - PLATE - LEAD - EDGE2 : stageW - (img.x + img.w) - PLATE - LEAD - EDGE2;
2491
2921
  const stackTop = tall ? img.y + img.h + PLATE + BAND_GAP : 0;
2492
2922
  const room = stageH - stackTop;
2493
2923
  const innerOf = (side) => tall ? side === "l" ? col : stageW - col : side === "l" ? img.x - PLATE - LEAD : img.x + img.w + PLATE + LEAD;
@@ -3041,7 +3471,7 @@ function asideFor(ctx, input, railW, room) {
3041
3471
  }
3042
3472
 
3043
3473
  // src/emit/archetypes/bar-compare.ts
3044
- var BODY_TOP = 34;
3474
+ var BODY_TOP2 = 34;
3045
3475
  var NOTE_H = noteHeight("x", MIN_FONT, 26);
3046
3476
  var UNIT_BAND = 58;
3047
3477
  var FOOT = 40;
@@ -3201,7 +3631,7 @@ var barCompare = (beat, ctx) => {
3201
3631
  const face = faceOf(ctx.theme.fontStack);
3202
3632
  const tall = isPortrait(ctx.format);
3203
3633
  const unitBand = p.unit ? UNIT_BAND : 0;
3204
- const avail = F.budget(p.note ? NOTE_H : 0, BODY_TOP, 0);
3634
+ const avail = F.budget(p.note ? NOTE_H : 0, BODY_TOP2, 0);
3205
3635
  const variant = variantOf(ctx, "bar-compare");
3206
3636
  if (variant === "columns") return columns2(beat, ctx, F, face, avail);
3207
3637
  if (variant === "versus") return versus(beat, ctx, F, face, avail);
@@ -3210,7 +3640,7 @@ var barCompare = (beat, ctx) => {
3210
3640
  let L;
3211
3641
  if (v2) {
3212
3642
  const noteBand = p.note ? Math.max(NOTE_H, noteHeight(p.note, F.noteW * MEASURE_SLACK, 26, face)) : 0;
3213
- const grownAvail = F.budget(noteBand, BODY_TOP, 0);
3643
+ const grownAvail = F.budget(noteBand, BODY_TOP2, 0);
3214
3644
  try {
3215
3645
  L = barLayout({ ...args, avail: grownAvail, caps: GROWN });
3216
3646
  } catch {
@@ -3414,7 +3844,7 @@ ${svg(id(sid, "chart"), W, H, body)}
3414
3844
  }
3415
3845
  if (p.unit || p.note) holds.push(tailAt + 0.7);
3416
3846
  const region = F.budget(0, 0, 0);
3417
- const fit = v2 ? { fit: fitOf(p.note ? region - BODY_TOP : H, region) } : {};
3847
+ const fit = v2 ? { fit: fitOf(p.note ? region - BODY_TOP2 : H, region) } : {};
3418
3848
  return {
3419
3849
  html,
3420
3850
  tl,
@@ -3423,7 +3853,7 @@ ${svg(id(sid, "chart"), W, H, body)}
3423
3853
  fill: Math.min(1, H / avail),
3424
3854
  css: [
3425
3855
  chromeCss(theme),
3426
- `.bc-wrap{margin-top:${BODY_TOP}px}`,
3856
+ `.bc-wrap{margin-top:${BODY_TOP2}px}`,
3427
3857
  // Scoped to the scene, so a classic scene in the same document — there is
3428
3858
  // none today, but the dedup set does not know that — keeps its centring.
3429
3859
  ...v2 && p.note ? [`#${sid} .bc-wrap{margin-bottom:auto}`] : [],
@@ -3646,7 +4076,7 @@ ${svg(id(sid, "chart"), W, H, body)}
3646
4076
  fill: Math.min(1, H / avail),
3647
4077
  css: [
3648
4078
  chromeCss(theme),
3649
- `.bc-wrap{margin-top:${BODY_TOP}px}`,
4079
+ `.bc-wrap{margin-top:${BODY_TOP2}px}`,
3650
4080
  `.bc-rail{fill:${theme.panel}}`,
3651
4081
  noteCss("bc-note", theme, 26),
3652
4082
  ambient(sid, `-bar${focal.i}`, BREATHE),
@@ -3865,7 +4295,7 @@ ${svg(id(sid, "chart"), W, Math.max(H, avail), body)}
3865
4295
  fill: Math.min(1, H / avail),
3866
4296
  css: [
3867
4297
  chromeCss(theme),
3868
- `.bc-wrap{margin-top:${BODY_TOP}px}`,
4298
+ `.bc-wrap{margin-top:${BODY_TOP2}px}`,
3869
4299
  `.bc-rail{fill:${theme.panel}}`,
3870
4300
  noteCss("bc-note", theme, 26),
3871
4301
  ambient(sid, `-bar${focal.i}`, BREATHE),
@@ -5004,6 +5434,7 @@ ${slide}`;
5004
5434
  const holds = [];
5005
5435
  const spot = spotlighter(sid, ".term");
5006
5436
  const swell = (t2) => raw(`dsSwell.${t2.tone}`);
5437
+ const later = v2 ? { immediateRender: false } : {};
5007
5438
  terms.forEach((term, i) => {
5008
5439
  const at = first + i * step;
5009
5440
  const colour = theme.tones[term.tone];
@@ -5027,7 +5458,12 @@ ${slide}`;
5027
5458
  const prev = terms[i - 1];
5028
5459
  if (prev) {
5029
5460
  tl.push(
5030
- tween(`#${sid} .t-${prev.tone}`, { scale: swell(prev) }, { scale: 1, duration: 0.4 }, at)
5461
+ tween(
5462
+ `#${sid} .t-${prev.tone}`,
5463
+ { scale: swell(prev) },
5464
+ { scale: 1, duration: 0.4, ...later },
5465
+ at
5466
+ )
5031
5467
  );
5032
5468
  }
5033
5469
  holds.push(at + 0.6);
@@ -5036,7 +5472,12 @@ ${slide}`;
5036
5472
  if (last) {
5037
5473
  const at = first + terms.length * step;
5038
5474
  tl.push(
5039
- tween(`#${sid} .t-${last.tone}`, { scale: swell(last) }, { scale: 1, duration: 0.4 }, at),
5475
+ tween(
5476
+ `#${sid} .t-${last.tone}`,
5477
+ { scale: swell(last) },
5478
+ { scale: 1, duration: 0.4, ...later },
5479
+ at
5480
+ ),
5040
5481
  ...spot.restore(at)
5041
5482
  );
5042
5483
  }
@@ -5259,7 +5700,7 @@ var equationMorph = (beat, ctx) => {
5259
5700
  var LABEL = 42;
5260
5701
  var LH = 1.25;
5261
5702
  var PAD = 16;
5262
- var GAP = 0.14;
5703
+ var GAP2 = 0.14;
5263
5704
  var CELL_MAX2 = 280;
5264
5705
  var MARGIN = 6;
5265
5706
  var MAX_LINES = 2;
@@ -5275,8 +5716,8 @@ function noteColumn(note, size, face) {
5275
5716
  function solveCell(cols, rows, boxW, boxH) {
5276
5717
  return Math.min(
5277
5718
  CELL_MAX2,
5278
- (boxW - 2 * MARGIN) / (cols + GAP * (cols - 1)),
5279
- (boxH - 2 * MARGIN) / (rows + GAP * (rows - 1))
5719
+ (boxW - 2 * MARGIN) / (cols + GAP2 * (cols - 1)),
5720
+ (boxH - 2 * MARGIN) / (rows + GAP2 * (rows - 1))
5280
5721
  );
5281
5722
  }
5282
5723
  var grid = (beat, ctx) => {
@@ -5305,7 +5746,7 @@ var grid = (beat, ctx) => {
5305
5746
  );
5306
5747
  const solve3 = (boxW, budget3) => {
5307
5748
  const cell = solveCell(p.cols, p.rows, boxW, budget3);
5308
- const gap = cell * GAP;
5749
+ const gap = cell * GAP2;
5309
5750
  return {
5310
5751
  cell,
5311
5752
  gap,
@@ -5881,10 +6322,10 @@ var lineChart = (beat, ctx) => {
5881
6322
  }
5882
6323
  const walk = cmp ? sec(step * count2) : DRAW_SECONDS;
5883
6324
  if (count2 > 1) {
5884
- const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
6325
+ const route2 = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
5885
6326
  tl.push(
5886
6327
  tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
5887
- ...travel(`#${sid}-ring`, route, settled, walk),
6328
+ ...travel(`#${sid}-ring`, route2, settled, walk),
5888
6329
  // And it leaves once the curve is whole: a marker parked on the last
5889
6330
  // point for the rest of the beat reads as a defect, not as emphasis.
5890
6331
  tween(
@@ -5984,7 +6425,7 @@ var NOTE_TOP = 16;
5984
6425
  var PAD_X_EM = 0.45;
5985
6426
  var PAD_Y2 = 34;
5986
6427
  var MIN_BOX_H = 172;
5987
- var GAP2 = 120;
6428
+ var GAP3 = 120;
5988
6429
  var MIN_GAP = 30;
5989
6430
  var MAX_BOX_W = 430;
5990
6431
  var MAX_BOX_H = 340;
@@ -6131,7 +6572,7 @@ function fitAt(stages, k, width, stageW, face, size = LABEL2, noteK = k, split =
6131
6572
  ),
6132
6573
  width,
6133
6574
  size,
6134
- gap: GAP2,
6575
+ gap: GAP3,
6135
6576
  minGap: MIN_GAP,
6136
6577
  padEm: PAD_X_EM,
6137
6578
  weight: 600,
@@ -6146,7 +6587,7 @@ function solve(stages, width, stageW, face, size = LABEL2) {
6146
6587
  function pipeLayout(stageW, stages, loop, face = "latin", grow) {
6147
6588
  const width = stageW - 2 * M;
6148
6589
  if (grow) return grownRow(stageW, stages, loop, face, grow);
6149
- const capped = Math.min(width, stages.length * MAX_BOX_W + (stages.length - 1) * GAP2);
6590
+ const capped = Math.min(width, stages.length * MAX_BOX_W + (stages.length - 1) * GAP3);
6150
6591
  let fit = solve(stages, capped, stageW, face);
6151
6592
  if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
6152
6593
  const row = measureRow(fit, stages, loop, face, stageW, width);
@@ -6191,7 +6632,7 @@ function measureRow(fit, stages, loop, face, stageW, width, grow) {
6191
6632
  }
6192
6633
  function grownRow(stageW, stages, loop, face, grow) {
6193
6634
  const width = stageW - 2 * M;
6194
- const capped = Math.min(width, stages.length * GROWN_BOX_W + (stages.length - 1) * GAP2);
6635
+ const capped = Math.min(width, stages.length * GROWN_BOX_W + (stages.length - 1) * GAP3);
6195
6636
  const preferred = LABEL2 * GROWTH;
6196
6637
  let best;
6197
6638
  for (const span of capped < width ? [capped, width] : [width]) {
@@ -6342,7 +6783,7 @@ var pipeline = (beat, ctx) => {
6342
6783
  const selfSpan = loop.self ? Math.min((L.boxes[loop.from]?.w ?? 200) * 0.55, 180) : 0;
6343
6784
  const leaveAt = cx(loop.from) + selfSpan / 2;
6344
6785
  const landAt = cx(loop.to) - selfSpan / 2;
6345
- const route = elbow(
6786
+ const route2 = elbow(
6346
6787
  sid,
6347
6788
  L.vertical ? { x: edge, y: leaveAt } : { x: leaveAt, y: edge },
6348
6789
  L.vertical ? { x: edge, y: landAt } : { x: landAt, y: edge },
@@ -6369,7 +6810,7 @@ var pipeline = (beat, ctx) => {
6369
6810
  const a0 = Math.max(0, Math.min(cx(loop.to) - 30, mid - half));
6370
6811
  sweep = Math.min(span, Math.max(cx(loop.from) + 30, mid + half)) - a0;
6371
6812
  const clip3 = L.vertical ? { x: 0, y: a0, w: W, h: sweep } : { x: a0, y: 0, w: sweep, h: L.svgH };
6372
- loopSvg = `<defs><clipPath id="${id(sid, "loopclip")}">${rect(clip3, { id: id(sid, "sweep") })}</clipPath></defs>` + group([route, label], { "clip-path": `url(#${id(sid, "loopclip")})` });
6813
+ loopSvg = `<defs><clipPath id="${id(sid, "loopclip")}">${rect(clip3, { id: id(sid, "sweep") })}</clipPath></defs>` + group([route2, label], { "clip-path": `url(#${id(sid, "loopclip")})` });
6373
6814
  }
6374
6815
  const body = [
6375
6816
  arrowDefs(sid, loop ? [theme.muted, loopColour] : [theme.muted]),
@@ -6909,7 +7350,7 @@ function depthCss(sid, pose, part) {
6909
7350
  }
6910
7351
 
6911
7352
  // src/emit/archetypes/stack.ts
6912
- var GAP3 = 44;
7353
+ var GAP4 = 44;
6913
7354
  var NUM_X = 48;
6914
7355
  function numSpine(floor) {
6915
7356
  return Math.round(NUM_X * floor * 100 / MIN_FONT) / 100;
@@ -6922,7 +7363,7 @@ var RISE_MAX = { wide: 180, tall: 230 };
6922
7363
  var RISE_MIN = 24;
6923
7364
  var SY_MAX = { wide: 100, tall: 132 };
6924
7365
  var T_MAX = { wide: 18, tall: 24 };
6925
- var EDGE2 = 10;
7366
+ var EDGE3 = 10;
6926
7367
  var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
6927
7368
  function stackLayout(p, format, face = "latin", grow = false) {
6928
7369
  if (grow) {
@@ -6989,7 +7430,7 @@ function solve2(p, format, inline2, face, growth = 1) {
6989
7430
  (l) => inline2 ? Math.max(l.label.length * labelSize, l.note.length * floor) * 1.16 : l.label.length * labelSize * 1.16 + (l.note.length > 0 ? 6 + l.note.length * floor * 1.16 : 0)
6990
7431
  )
6991
7432
  );
6992
- const pad = Math.max(EDGE2, blockH / 2);
7433
+ const pad = Math.max(EDGE3, blockH / 2);
6993
7434
  const chromeH = chromeHeight(p.eyebrow, p.headline, width, face);
6994
7435
  const noteH = noteHeight(p.note, noteWidth(format), 28, face);
6995
7436
  const free2 = boxH - chromeH - 20 - noteH;
@@ -6998,7 +7439,7 @@ function solve2(p, format, inline2, face, growth = 1) {
6998
7439
  const rise = clamp(room, RISE_MIN, riseMax);
6999
7440
  const sy = clamp(rise * 0.62, 46, syMax);
7000
7441
  const t2 = clamp(rise * 0.14, 10, tMax);
7001
- const span = width - NUM_W - GAP3 - colW;
7442
+ const span = width - NUM_W - GAP4 - colW;
7002
7443
  const sx = clamp(span * 0.2, 110, 220);
7003
7444
  const height = 2 * pad + (count2 - 1) * rise + sy + t2;
7004
7445
  return {
@@ -7158,12 +7599,12 @@ var stack = (beat, ctx) => {
7158
7599
  holds.push(at + 0.62);
7159
7600
  });
7160
7601
  if (mids.length > 1) {
7161
- const route = mids.map((mid) => ({ x: 0, y: nv(mid - probeY) }));
7602
+ const route2 = mids.map((mid) => ({ x: 0, y: nv(mid - probeY) }));
7162
7603
  const walkFrom = first + 0.3;
7163
7604
  const walkTo = first + (mids.length - 1) * step + 0.55;
7164
7605
  tl.push(
7165
7606
  tween(`#${sid}-probe`, { opacity: 0 }, { opacity: 1, duration: 0.3 }, walkFrom),
7166
- ...travel(`#${sid}-probe`, route, walkFrom, Math.max(0.4, walkTo - walkFrom))
7607
+ ...travel(`#${sid}-probe`, route2, walkFrom, Math.max(0.4, walkTo - walkFrom))
7167
7608
  );
7168
7609
  }
7169
7610
  if (p.note) {
@@ -7218,6 +7659,8 @@ var emitters = {
7218
7659
  callout
7219
7660
  };
7220
7661
  function emitScene(beat, ctx) {
7662
+ const bespoke = ctx.bespoke?.[beat.id];
7663
+ if (bespoke) return bespokeScene(beat, ctx, bespoke);
7221
7664
  return emitters[beat.archetype](beat, ctx);
7222
7665
  }
7223
7666
 
@@ -7798,7 +8241,15 @@ var beatCore = {
7798
8241
  /** Salience, 0–1. Decides what survives a 30-second cut. */
7799
8242
  weight: z.number().min(0).max(1).default(0.5),
7800
8243
  /** Seconds this beat holds in a linear render. Deck mode is human-paced. */
7801
- seconds: z.number().positive().max(60).default(7)
8244
+ seconds: z.number().positive().max(60).default(7),
8245
+ /**
8246
+ * OPTIONAL, and only read by `build --design v2 --bespoke`: the planner's hint
8247
+ * that this beat explains a mechanism worth a generated scene (`true`), or
8248
+ * must keep its archetype (`false`). A hint, not an order — see
8249
+ * src/bespoke/select.ts. Hidden from the planner's schema unless the plan was
8250
+ * asked for with `--bespoke`, so every other plan is byte-for-byte unchanged.
8251
+ */
8252
+ bespoke: z.boolean().optional()
7802
8253
  };
7803
8254
  var beatTail = {
7804
8255
  /** What the presenter says over this beat. Written last, knowing the picture. */
@@ -8046,7 +8497,50 @@ var prefsSchema = z.object({
8046
8497
  * Zero is legal and means "every picture by the tool".
8047
8498
  */
8048
8499
  max: z.int().min(0).default(4)
8049
- }).default({ enabled: false, provider: "codex", style: "flat vector illustration", max: 4 })
8500
+ }).default({ enabled: false, provider: "codex", style: "flat vector illustration", max: 4 }),
8501
+ /* --- bespoke scenes --- */
8502
+ /**
8503
+ * `--design v2` only: a few beats per deck drawn by a model-written GSAP+SVG
8504
+ * scene instead of their archetype (src/bespoke/). OPTIONAL WITH NO DEFAULT,
8505
+ * unlike the two blocks above, and that is deliberate: a resolved prefs
8506
+ * object travels into `.deck` manifests, and a defaulted block would move the
8507
+ * bytes of every pack built without it.
8508
+ */
8509
+ bespoke: z.object({
8510
+ enabled: z.boolean().default(false),
8511
+ /** Hard cap on Codex calls for one deck. Each bespoke beat costs two. */
8512
+ maxCalls: z.int().min(0).max(40).default(12),
8513
+ /** Hard cap on the bespoke pass's wall time, seconds. */
8514
+ maxSeconds: z.int().min(60).max(7200).default(1800),
8515
+ /** Passed to `codex exec --model`. Absent: whatever the account is configured for. */
8516
+ model: z.string().optional(),
8517
+ /** The Codex CLI to run. Absent: `codex` on PATH. */
8518
+ cli: z.string().optional(),
8519
+ /** Where generated scenes are cached. Absent: the user cache directory. */
8520
+ cache: z.string().optional(),
8521
+ /**
8522
+ * Codex calls in flight at once. Five: MEASURED 2026-10-08, a deck's
8523
+ * five drafts at three in flight took two waves (252s) where one wave is
8524
+ * ~150s, and the second wave alone put the pass over ten minutes. Still
8525
+ * bounded by `maxCalls` per deck.
8526
+ */
8527
+ concurrency: z.int().min(1).max(8).default(5),
8528
+ /**
8529
+ * `model_reasoning_effort` for the bespoke calls. MEASURED 2026-10-08, one
8530
+ * draft prompt, tools off: medium 166s and 24k tokens, high 371s and 50k.
8531
+ * The references and the gates carry the look; the extra reasoning bought
8532
+ * time. Absent from a config file means medium; "default" leaves the
8533
+ * account's own setting.
8534
+ */
8535
+ effort: z.enum(["low", "medium", "high", "default"]).default("medium"),
8536
+ /**
8537
+ * Illustrations per deck: each bespoke beat may first have ONE picture
8538
+ * drawn by the same account's image tool (src/bespoke/art.ts), which its
8539
+ * scene is built around. 0 turns them off. Counted apart from `maxCalls`:
8540
+ * MEASURED 2026-10-09, one picture is 36-49s and 19-25k tokens.
8541
+ */
8542
+ art: z.int().min(0).max(6).default(6)
8543
+ }).optional()
8050
8544
  });
8051
8545
  var cueSchema = z.object({
8052
8546
  /** Seconds from the start of this segment's audio. */
@@ -8239,9 +8733,9 @@ function resizeFormat(base, width, height) {
8239
8733
  // src/narrate/tts.ts
8240
8734
  import { spawn } from "node:child_process";
8241
8735
  import { createHash as createHash2 } from "node:crypto";
8242
- import { mkdir as mkdir3, readFile as readFile3, rm, writeFile as writeFile3 } from "node:fs/promises";
8736
+ import { mkdir as mkdir4, readFile as readFile4, rm, writeFile as writeFile4 } from "node:fs/promises";
8243
8737
  import { homedir } from "node:os";
8244
- import { join as join4 } from "node:path";
8738
+ import { join as join5 } from "node:path";
8245
8739
  var MISSING = [
8246
8740
  "edge-tts is not installed, so narration cannot be synthesised.",
8247
8741
  "",
@@ -8258,8 +8752,8 @@ function candidates2() {
8258
8752
  ["edge-tts"],
8259
8753
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
8260
8754
  // default, and both are where this actually lands in practice.
8261
- [join4(home, "Library", "Python", "3.9", "bin", "edge-tts")],
8262
- [join4(home, ".local", "bin", "edge-tts")],
8755
+ [join5(home, "Library", "Python", "3.9", "bin", "edge-tts")],
8756
+ [join5(home, ".local", "bin", "edge-tts")],
8263
8757
  // Last resort: the module is installed even though its console script is
8264
8758
  // nowhere findable, which is the normal state of a pip --user install.
8265
8759
  ["python3", "-m", "edge_tts"]
@@ -8284,7 +8778,7 @@ async function find(can) {
8284
8778
  throw new Error(MISSING);
8285
8779
  }
8286
8780
  function runArgv(cmd, args) {
8287
- return new Promise((resolve9) => {
8781
+ return new Promise((resolve10) => {
8288
8782
  const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
8289
8783
  let stdout = "";
8290
8784
  let stderr = "";
@@ -8294,8 +8788,8 @@ function runArgv(cmd, args) {
8294
8788
  child.stderr.on("data", (b) => {
8295
8789
  stderr += b.toString();
8296
8790
  });
8297
- child.on("error", (e) => resolve9({ code: -1, stderr: String(e), stdout }));
8298
- child.on("close", (code) => resolve9({ code: code ?? -1, stderr, stdout }));
8791
+ child.on("error", (e) => resolve10({ code: -1, stderr: String(e), stdout }));
8792
+ child.on("close", (code) => resolve10({ code: code ?? -1, stderr, stdout }));
8299
8793
  });
8300
8794
  }
8301
8795
  var edgeTts = {
@@ -8362,7 +8856,7 @@ function edgeProvider(runner = edgeTts) {
8362
8856
  async speak(req) {
8363
8857
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
8364
8858
  await runner.speak({ ...req, subs });
8365
- const raw2 = await readFile3(subs, "utf8").catch(() => "");
8859
+ const raw2 = await readFile4(subs, "utf8").catch(() => "");
8366
8860
  await rm(subs, { force: true });
8367
8861
  const cues = parseCues(raw2);
8368
8862
  const measured = await runner.measure(req.audio);
@@ -8380,11 +8874,11 @@ async function synthesize(text2, opts) {
8380
8874
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
8381
8875
  const key = cacheKey(text2, opts.voice, rate, pitch);
8382
8876
  const file = `${key}.mp3`;
8383
- const audio = join4(opts.dir, file);
8384
- const sidecar = join4(opts.dir, `${key}.json`);
8877
+ const audio = join5(opts.dir, file);
8878
+ const sidecar = join5(opts.dir, `${key}.json`);
8385
8879
  const cached2 = await readSidecar(sidecar);
8386
8880
  if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
8387
- await mkdir3(opts.dir, { recursive: true });
8881
+ await mkdir4(opts.dir, { recursive: true });
8388
8882
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
8389
8883
  const { cues, seconds } = spoken;
8390
8884
  const clamped = cues.map((c) => ({
@@ -8393,13 +8887,13 @@ async function synthesize(text2, opts) {
8393
8887
  text: c.text
8394
8888
  }));
8395
8889
  const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
8396
- await writeFile3(sidecar, `${JSON.stringify(body, null, 2)}
8890
+ await writeFile4(sidecar, `${JSON.stringify(body, null, 2)}
8397
8891
  `);
8398
8892
  return { audio, file, seconds, cues: clamped };
8399
8893
  }
8400
8894
  async function readSidecar(path2) {
8401
8895
  try {
8402
- const parsed = JSON.parse(await readFile3(path2, "utf8"));
8896
+ const parsed = JSON.parse(await readFile4(path2, "utf8"));
8403
8897
  const s = parsed;
8404
8898
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
8405
8899
  } catch {
@@ -8576,7 +9070,7 @@ function assertNarrationStaging(narration, staged, format) {
8576
9070
 
8577
9071
  // src/pack/media.ts
8578
9072
  import { createHash as createHash3 } from "node:crypto";
8579
- import { readFile as readFile4 } from "node:fs/promises";
9073
+ import { readFile as readFile5 } from "node:fs/promises";
8580
9074
  import { extname } from "node:path";
8581
9075
  var PLAYERS = [
8582
9076
  "youtube.com",
@@ -8785,7 +9279,7 @@ async function fetchAsset(url) {
8785
9279
  mime: res.headers.get("content-type") ?? void 0
8786
9280
  };
8787
9281
  }
8788
- return { bytes: new Uint8Array(await readFile4(url)) };
9282
+ return { bytes: new Uint8Array(await readFile5(url)) };
8789
9283
  }
8790
9284
 
8791
9285
  // src/plan/direct.ts
@@ -9332,15 +9826,15 @@ function fnv1a2(s) {
9332
9826
  var RISE_SHARE = 0.25;
9333
9827
  function planMotion(seed, beats) {
9334
9828
  const n3 = beats.length;
9335
- const entrances = [];
9829
+ const entrances2 = [];
9336
9830
  const riseCap = Math.floor(n3 * RISE_SHARE);
9337
9831
  let rises = 0;
9338
9832
  for (const [i, beat] of beats.entries()) {
9339
- const prev = entrances[i - 1];
9833
+ const prev = entrances2[i - 1];
9340
9834
  const allowed = ENTRANCES.filter((v) => v !== prev && (v !== "rise" || rises < riseCap));
9341
9835
  const pick = allowed[fnv1a2(`${seed}|enter|${beat.id}|${i}`) % allowed.length];
9342
9836
  if (pick === "rise") rises++;
9343
- entrances.push(pick);
9837
+ entrances2.push(pick);
9344
9838
  }
9345
9839
  const seams = [];
9346
9840
  for (let i = 0; i + 1 < n3; i++) {
@@ -9371,7 +9865,7 @@ function planMotion(seed, beats) {
9371
9865
  const start = fnv1a2(`${seed}|emph|${beat.id}|${i}`) % EMPHASES.length;
9372
9866
  return EMPHASES.map((_, k) => EMPHASES[(start + k) % EMPHASES.length]);
9373
9867
  });
9374
- return { entrances, seams, emphases };
9868
+ return { entrances: entrances2, seams, emphases };
9375
9869
  }
9376
9870
  function ensureSeamKinds(seams, want) {
9377
9871
  const kinds = () => new Set(seams.filter((s) => s !== "dive"));
@@ -9876,8 +10370,9 @@ function layout(storyboard, source, format, options = {}) {
9876
10370
  };
9877
10371
  const segments2 = opts.narration?.beats[beat.id];
9878
10372
  const emitted = emitScene(beat, ctx);
10373
+ const bespoke = opts.bespoke?.[beat.id] !== void 0;
9879
10374
  const { scene } = stageScene(
9880
- motion ? restyleEntrance(emitted, sid, motion.entrances[i] ?? "rise") : emitted,
10375
+ motion && !bespoke ? restyleEntrance(emitted, sid, motion.entrances[i] ?? "rise") : emitted,
9881
10376
  speed
9882
10377
  );
9883
10378
  const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
@@ -9886,7 +10381,7 @@ function layout(storyboard, source, format, options = {}) {
9886
10381
  const duration = dive ? seconds + diveTail(dive) : seconds;
9887
10382
  const start = at;
9888
10383
  at += duration;
9889
- return { beat, sid, scene, segments: segments2, inside, dive, duration, start, seconds };
10384
+ return { beat, sid, scene, segments: segments2, inside, dive, duration, start, seconds, bespoke };
9890
10385
  });
9891
10386
  let builds = false;
9892
10387
  const plugins = /* @__PURE__ */ new Set();
@@ -9967,11 +10462,17 @@ function layout(storyboard, source, format, options = {}) {
9967
10462
  } : void 0,
9968
10463
  looks,
9969
10464
  motion,
9970
- holdMotion
10465
+ holdMotion,
10466
+ // Whether any scene runs generated code — the one thing that puts a CSP in
10467
+ // the head. Classic and plain v2 decks never set it, so their heads are unchanged.
10468
+ scripted: cuts.some((c) => c.bespoke)
9971
10469
  };
9972
10470
  }
9973
10471
  function design(opts) {
9974
- return opts.design ? { design: opts.design } : {};
10472
+ return {
10473
+ ...opts.design ? { design: opts.design } : {},
10474
+ ...opts.bespoke ? { bespoke: opts.bespoke } : {}
10475
+ };
9975
10476
  }
9976
10477
  function withMotion(motion, i, cut, scene, accent, speed, holdMotion) {
9977
10478
  const { sid } = cut;
@@ -9983,7 +10484,17 @@ function withMotion(motion, i, cut, scene, accent, speed, holdMotion) {
9983
10484
  const incoming = Number.isFinite(firstHold) ? seamIn(before, sid, over, firstHold) : [];
9984
10485
  if (incoming.length) out = { ...out, tl: [...out.tl, ...incoming] };
9985
10486
  }
9986
- if (cut.segments?.length) {
10487
+ if (cut.segments?.length && cut.bespoke) {
10488
+ const windows = bespokeWindows(out, cut.segments, cut.seconds);
10489
+ if (windows.length) {
10490
+ holdMotion[sid] = windows.map((w) => ({
10491
+ stop: w.stop,
10492
+ at: rnd(cut.start + w.at),
10493
+ from: rnd(cut.start + w.from),
10494
+ to: rnd(cut.start + w.to)
10495
+ }));
10496
+ }
10497
+ } else if (cut.segments?.length) {
9987
10498
  const { scene: emphasised, windows } = emphasize(out, sid, {
9988
10499
  segments: cut.segments,
9989
10500
  starts: spokenStarts(out, cut.segments).starts,
@@ -10003,6 +10514,23 @@ function withMotion(motion, i, cut, scene, accent, speed, holdMotion) {
10003
10514
  }
10004
10515
  return out;
10005
10516
  }
10517
+ function bespokeWindows(scene, segments2, end) {
10518
+ const holds = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
10519
+ (a, b) => a - b
10520
+ );
10521
+ const { starts } = spokenStarts(scene, segments2);
10522
+ const out = [];
10523
+ segments2.forEach((segment2, i) => {
10524
+ const index = Math.min(segment2.stop, holds.length - 1);
10525
+ const at = holds[index];
10526
+ if (at === void 0) return;
10527
+ const next = holds[index + 1];
10528
+ const to = rnd((next ?? end) - 1e-3);
10529
+ if (to <= at) return;
10530
+ out.push({ stop: segment2.stop, at, from: rnd(starts[i] ?? at), to });
10531
+ });
10532
+ return out;
10533
+ }
10006
10534
  function motionIsland(holds) {
10007
10535
  const json = JSON.stringify({ version: 1, seams: true, holds }, null, 2).replace(/</g, "\\u003c");
10008
10536
  return `
@@ -10099,7 +10627,8 @@ ${emitIsland(slides)}` : "";
10099
10627
  return `<!doctype html>
10100
10628
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
10101
10629
  <head>
10102
- <meta charset="UTF-8" />
10630
+ <meta charset="UTF-8" />${laid.scripted ? `
10631
+ <meta http-equiv="Content-Security-Policy" content="${BESPOKE_CSP}" />` : ""}
10103
10632
  <title>${esc(storyboard.title)}</title>
10104
10633
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
10105
10634
  <script src="${GSAP_SRC}"></script>
@@ -10136,6 +10665,17 @@ ${scenes.join("\n")}
10136
10665
  </html>
10137
10666
  `;
10138
10667
  }
10668
+ var BESPOKE_CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
10669
+ function bespokeCall(sid, script) {
10670
+ return `try {
10671
+ (function (tl, root) {
10672
+ "use strict";
10673
+ ${closeSafe(script)}
10674
+ })(tl, document.getElementById("${sid}"));
10675
+ } catch (e) {
10676
+ console.error("decksmith bespoke #${sid}: " + (e && e.message));
10677
+ }`;
10678
+ }
10139
10679
  function buildingFlag() {
10140
10680
  return `
10141
10681
  <script>window.__hfTimelinesBuilding = true;</script>`;
@@ -10234,6 +10774,9 @@ function sceneHtml(sid, start, clip3, label, scene, transit) {
10234
10774
  `// times relative to its start \u2014 one absolute root timeline yields a`,
10235
10775
  `// deck the slideshow controller cannot bind to.`,
10236
10776
  `var tl = gsap.timeline({ paused: true });`,
10777
+ // A bespoke scene's generated script, checked before it got here. Before
10778
+ // the shell's tweens so its `gsap.set` baselines are in place first.
10779
+ ...scene.script ? [bespokeCall(sid, scene.script)] : [],
10237
10780
  // `tweenText` is the ONLY place a tween becomes GSAP source. Everything
10238
10781
  // above this line is a typed object the checker can see; below it is text.
10239
10782
  ...scene.tl.map(tweenText),
@@ -10288,11 +10831,9 @@ function rnd(n3) {
10288
10831
  var AFFINITY = {
10289
10832
  signal: { quantity: 0.6, frame: 0.3 },
10290
10833
  blueprint: { structure: 0.7, formal: 0.2 },
10291
- atlas: { frame: 0.6, structure: 0.2 },
10292
- folio: { formal: 0.8, frame: 0.2 },
10293
- chalk: { quantity: 0.5, structure: 0.4 },
10294
- journal: { formal: 0.4, frame: 0.4 }
10834
+ atlas: { frame: 0.6, structure: 0.2 }
10295
10835
  };
10836
+ var PICKED = Object.keys(PACKS).filter((n3) => PACKS[n3]?.ground === "dark");
10296
10837
  function fnv1a3(text2) {
10297
10838
  let h = 2166136261;
10298
10839
  for (let i = 0; i < text2.length; i++) {
@@ -10309,7 +10850,7 @@ function mix(beats) {
10309
10850
  function packWeights(beats) {
10310
10851
  const m = mix(beats);
10311
10852
  const out = {};
10312
- for (const name of Object.keys(PACKS)) {
10853
+ for (const name of PICKED) {
10313
10854
  const a = AFFINITY[name] ?? {};
10314
10855
  out[name] = 1 + Object.entries(a).reduce((s, [f, w]) => s + (w ?? 0) * m[f], 0);
10315
10856
  }
@@ -10321,7 +10862,7 @@ function rankPacks(beats, seed) {
10321
10862
  const u = (fnv1a3(`${seed}\0${name}`) + 0.5) / 2 ** 32;
10322
10863
  return -Math.log(u) / (weights[name] ?? 1);
10323
10864
  };
10324
- return Object.keys(PACKS).map((name) => ({ name, d: draw2(name) })).sort((a, b) => a.d - b.d || (a.name < b.name ? -1 : 1)).map((r) => r.name);
10865
+ return PICKED.map((name) => ({ name, d: draw2(name) })).sort((a, b) => a.d - b.d || (a.name < b.name ? -1 : 1)).map((r) => r.name);
10325
10866
  }
10326
10867
  function chooseLook(c) {
10327
10868
  if (c.stated) return c.stated;
@@ -10445,8 +10986,15 @@ function readFragments(html) {
10445
10986
  }
10446
10987
  return out;
10447
10988
  }
10448
- function holdsFor(beat, source, format, theme, sid, speed) {
10449
- const ctx = { source, format, theme, sid, start: 0 };
10989
+ function holdsFor(beat, source, format, theme, sid, speed, bespoke) {
10990
+ const ctx = {
10991
+ source,
10992
+ format,
10993
+ theme,
10994
+ sid,
10995
+ start: 0,
10996
+ ...bespoke ? { bespoke } : {}
10997
+ };
10450
10998
  const { scene, open } = stageScene(emitScene(beat, ctx), speed);
10451
10999
  return {
10452
11000
  holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
@@ -10467,7 +11015,7 @@ function assertHoldsAgree(scenes, fragments) {
10467
11015
  }
10468
11016
  }
10469
11017
  }
10470
- function place(scenes, spoken) {
11018
+ function place2(scenes, spoken) {
10471
11019
  const out = [];
10472
11020
  for (const scene of scenes) {
10473
11021
  const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
@@ -10536,10 +11084,10 @@ function planTiming(input) {
10536
11084
  const stops = /* @__PURE__ */ new Map();
10537
11085
  beats.forEach((beat, i) => {
10538
11086
  const scene = scenes[i];
10539
- const staging = holdsFor(beat, source, format, theme, scene.id, speed);
10540
- scene.holds = staging.holds;
10541
- scene.open = staging.open;
10542
- stops.set(beat.id, stopCount(staging.holds));
11087
+ const staging2 = holdsFor(beat, source, format, theme, scene.id, speed, input.bespoke);
11088
+ scene.holds = staging2.holds;
11089
+ scene.open = staging2.open;
11090
+ stops.set(beat.id, stopCount(staging2.holds));
10543
11091
  const segments3 = narration?.beats[beat.id];
10544
11092
  if (segments3?.length) spoken[scene.id] = segments3;
10545
11093
  });
@@ -10551,7 +11099,7 @@ function planTiming(input) {
10551
11099
  fragments
10552
11100
  );
10553
11101
  }
10554
- const segments2 = place(scenes, spoken);
11102
+ const segments2 = place2(scenes, spoken);
10555
11103
  assertFits(scenes, segments2);
10556
11104
  return {
10557
11105
  version: 1,
@@ -10866,8 +11414,8 @@ function textOf(node) {
10866
11414
 
10867
11415
  // src/source/harvest.ts
10868
11416
  import { createHash as createHash6 } from "node:crypto";
10869
- import { copyFile, mkdir as mkdir5, rm as rm3, stat as stat2, writeFile as writeFile5 } from "node:fs/promises";
10870
- import { basename as basename3, join as join7, resolve as resolve3 } from "node:path";
11417
+ import { copyFile as copyFile2, mkdir as mkdir6, rm as rm3, stat as stat3, writeFile as writeFile6 } from "node:fs/promises";
11418
+ import { basename as basename3, join as join8, resolve as resolve4 } from "node:path";
10871
11419
 
10872
11420
  // src/net/fetch.ts
10873
11421
  import { lookup as resolveHost } from "node:dns/promises";
@@ -10929,10 +11477,10 @@ function expandV6(v6) {
10929
11477
  const head = text2.slice(0, text2.length - quad[1].length);
10930
11478
  text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
10931
11479
  }
10932
- const halves = text2.split("::");
10933
- if (halves.length > 2) return null;
10934
- const left = halves[0] ? halves[0].split(":") : [];
10935
- const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
11480
+ const halves2 = text2.split("::");
11481
+ if (halves2.length > 2) return null;
11482
+ const left = halves2[0] ? halves2[0].split(":") : [];
11483
+ const right = halves2.length === 2 ? halves2[1] ? halves2[1].split(":") : [] : null;
10936
11484
  const fill = 8 - left.length - (right?.length ?? 0);
10937
11485
  if (right !== null && fill < 0) return null;
10938
11486
  const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
@@ -11067,8 +11615,8 @@ async function send(url, target, headers, signal2, opts) {
11067
11615
  ...secure && isIP(host2) === 0 ? { servername: host2 } : {}
11068
11616
  };
11069
11617
  try {
11070
- return await new Promise((resolve9, reject) => {
11071
- const req = (secure ? httpsRequest : httpRequest)(options, resolve9);
11618
+ return await new Promise((resolve10, reject) => {
11619
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve10);
11072
11620
  req.on("error", reject);
11073
11621
  req.end();
11074
11622
  });
@@ -11152,7 +11700,7 @@ function message(err) {
11152
11700
 
11153
11701
  // src/render/capture.ts
11154
11702
  import { homedir as homedir2 } from "node:os";
11155
- import { join as join5 } from "node:path";
11703
+ import { join as join6 } from "node:path";
11156
11704
  async function resolveChrome(need = "open the deck with") {
11157
11705
  const env2 = process.env.HYPERFRAMES_BROWSER_PATH || process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
11158
11706
  if (env2) return { path: env2, source: "env" };
@@ -11162,9 +11710,9 @@ async function resolveChrome(need = "open the deck with") {
11162
11710
  const ordered = [...installed2].sort((a, b) => b.buildId.localeCompare(a.buildId, "en"));
11163
11711
  return ordered.find((b) => b.browser === "chrome-headless-shell") ?? ordered.find((b) => b.browser === "chrome");
11164
11712
  };
11165
- const hf = await pick(join5(homedir2(), ".cache", "hyperframes", "chrome"));
11713
+ const hf = await pick(join6(homedir2(), ".cache", "hyperframes", "chrome"));
11166
11714
  if (hf) return { path: hf.executablePath, source: "hyperframes" };
11167
- const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join5(homedir2(), ".cache", "puppeteer"));
11715
+ const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join6(homedir2(), ".cache", "puppeteer"));
11168
11716
  if (pup) return { path: pup.executablePath, source: "puppeteer" };
11169
11717
  throw new Error(
11170
11718
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
@@ -11192,12 +11740,12 @@ async function launchOwnPage(launch, args, what) {
11192
11740
 
11193
11741
  // src/source/assets.ts
11194
11742
  import { createHash as createHash5 } from "node:crypto";
11195
- import { mkdir as mkdir4, readdir as readdir2, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
11196
- import { extname as extname2, join as join6 } from "node:path";
11743
+ import { mkdir as mkdir5, readdir as readdir2, readFile as readFile6, writeFile as writeFile5 } from "node:fs/promises";
11744
+ import { extname as extname2, join as join7 } from "node:path";
11197
11745
  import { z as z2 } from "zod";
11198
11746
  async function fetchFigures(source, dir, warnings) {
11199
11747
  const drops = warnings ?? [];
11200
- await mkdir4(dir, { recursive: true });
11748
+ await mkdir5(dir, { recursive: true });
11201
11749
  const cached2 = await readdir2(dir).catch(() => []);
11202
11750
  const figures = [];
11203
11751
  for (const figure of source.figures) {
@@ -11223,11 +11771,11 @@ async function fetchFigures(source, dir, warnings) {
11223
11771
  async function localize(figure, dir, cached2) {
11224
11772
  const stem = assetStem(figure.id, figure.src);
11225
11773
  const hit = cached2.find((name) => name.startsWith(`${stem}.`));
11226
- const bytes = hit ? await readFile5(join6(dir, hit)) : await load(figure.src);
11774
+ const bytes = hit ? await readFile6(join7(dir, hit)) : await load(figure.src);
11227
11775
  const size = imageSize(bytes);
11228
11776
  if (hit) return { ...figure, src: hit, ...size };
11229
11777
  const src = `${stem}${assetExt(bytes, figure.src)}`;
11230
- await writeFile4(join6(dir, src), bytes);
11778
+ await writeFile5(join7(dir, src), bytes);
11231
11779
  cached2.push(src);
11232
11780
  return { ...figure, src, ...size };
11233
11781
  }
@@ -11252,7 +11800,7 @@ function assetExt(bytes, src) {
11252
11800
  var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
11253
11801
  var FIGURE_TIMEOUT_MS = 2e4;
11254
11802
  async function load(src) {
11255
- if (!/^https?:/i.test(src)) return readFile5(src);
11803
+ if (!/^https?:/i.test(src)) return readFile6(src);
11256
11804
  const got = await fetchGuarded(src, {
11257
11805
  maxBytes: FIGURE_MAX_BYTES,
11258
11806
  timeoutMs: FIGURE_TIMEOUT_MS
@@ -11788,7 +12336,7 @@ function readContentRegion() {
11788
12336
  }
11789
12337
 
11790
12338
  // src/source/transcode.ts
11791
- import { readFile as readFile6, rm as rm2 } from "node:fs/promises";
12339
+ import { readFile as readFile7, rm as rm2 } from "node:fs/promises";
11792
12340
  import { basename as basename2 } from "node:path";
11793
12341
 
11794
12342
  // src/render/ffmpeg.ts
@@ -11814,7 +12362,7 @@ ${tail}`);
11814
12362
  }
11815
12363
  }
11816
12364
  function runLive(file, args) {
11817
- return new Promise((resolve9, reject) => {
12365
+ return new Promise((resolve10, reject) => {
11818
12366
  const child = spawn2(file, args, { stdio: ["ignore", "inherit", "inherit"] });
11819
12367
  child.on("error", (err) => {
11820
12368
  reject(
@@ -11822,7 +12370,7 @@ function runLive(file, args) {
11822
12370
  );
11823
12371
  });
11824
12372
  child.on("close", (code, signal2) => {
11825
- if (code === 0) resolve9();
12373
+ if (code === 0) resolve10();
11826
12374
  else if (signal2) {
11827
12375
  reject(
11828
12376
  new Error(
@@ -12024,7 +12572,7 @@ async function transcode(input, out, opts = {}) {
12024
12572
  const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
12025
12573
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
12026
12574
  const name = basename2(input);
12027
- const source = videoSize(await readFile6(input));
12575
+ const source = videoSize(await readFile7(input));
12028
12576
  const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
12029
12577
  const kept = (why2) => ({
12030
12578
  path: input,
@@ -12056,7 +12604,7 @@ async function transcode(input, out, opts = {}) {
12056
12604
  }
12057
12605
  let measured;
12058
12606
  try {
12059
- measured = videoSize(await readFile6(out));
12607
+ measured = videoSize(await readFile7(out));
12060
12608
  } catch (err) {
12061
12609
  await rm2(out, { force: true });
12062
12610
  return kept(
@@ -12092,8 +12640,8 @@ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
12092
12640
  async function harvest(url, dir, opts = {}) {
12093
12641
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
12094
12642
  const warnings = [];
12095
- const assets = resolve3(dir);
12096
- await mkdir5(assets, { recursive: true });
12643
+ const assets = resolve4(dir);
12644
+ await mkdir6(assets, { recursive: true });
12097
12645
  const page = await fetchGuarded(url, {
12098
12646
  maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
12099
12647
  timeoutMs,
@@ -12483,8 +13031,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
12483
13031
  `it is ${size.width}x${size.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
12484
13032
  );
12485
13033
  }
12486
- const path2 = join7(dir, assetName(url, extFor2(sniffFormat(bytes))));
12487
- await writeFile5(path2, bytes);
13034
+ const path2 = join8(dir, assetName(url, extFor2(sniffFormat(bytes))));
13035
+ await writeFile6(path2, bytes);
12488
13036
  assets.push(path2);
12489
13037
  got = { path: path2, width: size.width, height: size.height };
12490
13038
  } catch (err) {
@@ -12496,7 +13044,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
12496
13044
  };
12497
13045
  const shrink = async (url, path2, measured, what) => {
12498
13046
  if (opts.transcode === false) return { path: path2, ...measured };
12499
- const out2 = join7(dir, assetName(url, ".vp9.webm"));
13047
+ const out2 = join8(dir, assetName(url, ".vp9.webm"));
12500
13048
  let small;
12501
13049
  try {
12502
13050
  small = await transcode(path2, out2, {
@@ -12538,8 +13086,8 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
12538
13086
  try {
12539
13087
  const bytes = await bytesOf(url);
12540
13088
  const measured = videoSize(bytes);
12541
- const path2 = join7(dir, assetName(url, `.${measured.container}`));
12542
- await writeFile5(path2, bytes);
13089
+ const path2 = join8(dir, assetName(url, `.${measured.container}`));
13090
+ await writeFile6(path2, bytes);
12543
13091
  return await shrink(url, path2, measured, what);
12544
13092
  } catch (err) {
12545
13093
  warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
@@ -12628,7 +13176,7 @@ function unheld(policy) {
12628
13176
  return "the file could not be held";
12629
13177
  }
12630
13178
  async function sizeOf(path2) {
12631
- return await stat2(path2).then(
13179
+ return await stat3(path2).then(
12632
13180
  (s) => s.size,
12633
13181
  () => 0
12634
13182
  );
@@ -12902,9 +13450,9 @@ ${fence}`;
12902
13450
 
12903
13451
  // src/plan/codex.ts
12904
13452
  import { spawn as spawn3 } from "node:child_process";
12905
- import { mkdtemp, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
13453
+ import { mkdtemp, readFile as readFile8, rm as rm4, writeFile as writeFile7 } from "node:fs/promises";
12906
13454
  import { tmpdir as tmpdir2 } from "node:os";
12907
- import { join as join8 } from "node:path";
13455
+ import { join as join9 } from "node:path";
12908
13456
  import { z as z3 } from "zod";
12909
13457
 
12910
13458
  // src/plan/arc.ts
@@ -13408,9 +13956,19 @@ nothing, so it can never dangle.
13408
13956
  - At most ${images.max} pictures in the whole deck; a split-compare with two
13409
13957
  briefs spends two. Past that, find the point's shape and draw it.`;
13410
13958
  }
13959
+ var BESPOKE_HINT = `
13960
+
13961
+ BESPOKE SCENES
13962
+
13963
+ Set \`bespoke: true\` on the four to six beats that explain a MECHANISM \u2014 a
13964
+ process that runs, an equation whose terms act, a curve that moves, a
13965
+ comparison that is a distance \u2014 and that the narration talks over for a while.
13966
+ Those beats get a custom animation drawn for them; everything else keeps its
13967
+ archetype. Set \`bespoke: false\` on a beat whose archetype must stay (a real
13968
+ figure the viewer must see). Leave it null otherwise.`;
13411
13969
  function systemPrompt(prefs) {
13412
13970
  const plan = durationPlan(prefs);
13413
- return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
13971
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}${prefs.bespoke?.enabled ? BESPOKE_HINT : ""}
13414
13972
 
13415
13973
  PREFERENCES \u2014 chosen by the person who asked for this deck.
13416
13974
  ${prefs.duration === void 0 ? "" : `
@@ -13704,7 +14262,10 @@ function stripNulls(node) {
13704
14262
  }
13705
14263
  var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
13706
14264
  function plannerInvisible(prefs) {
13707
- return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
14265
+ const hidden = new Set(PLANNER_INVISIBLE);
14266
+ if (!paperArcRequested(prefs)) hidden.add("role");
14267
+ if (!prefs.bespoke?.enabled) hidden.add("bespoke");
14268
+ return hidden;
13708
14269
  }
13709
14270
  function hideFromPlanner(node, hidden) {
13710
14271
  if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
@@ -13736,11 +14297,11 @@ function schemaFor(prefs) {
13736
14297
  var SCHEMA = schemaFor({ genre: "general" });
13737
14298
  async function codexPlanner(source, opts = {}) {
13738
14299
  const prefs = opts.prefs ?? prefsSchema.parse({});
13739
- const dir = await mkdtemp(join8(tmpdir2(), "decksmith-plan-"));
14300
+ const dir = await mkdtemp(join9(tmpdir2(), "decksmith-plan-"));
13740
14301
  try {
13741
- const schemaPath = join8(dir, "storyboard.schema.json");
13742
- const outPath = join8(dir, "storyboard.json");
13743
- await writeFile6(schemaPath, JSON.stringify(schemaFor(prefs)));
14302
+ const schemaPath = join9(dir, "storyboard.schema.json");
14303
+ const outPath = join9(dir, "storyboard.json");
14304
+ await writeFile7(schemaPath, JSON.stringify(schemaFor(prefs)));
13744
14305
  await (opts.run ?? runCodex)({
13745
14306
  prompt: buildPrompt(source, prefs),
13746
14307
  schemaPath,
@@ -13748,7 +14309,7 @@ async function codexPlanner(source, opts = {}) {
13748
14309
  ...opts.model === void 0 ? {} : { model: opts.model },
13749
14310
  timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
13750
14311
  });
13751
- const raw2 = await readFile7(outPath, "utf8").catch(() => "");
14312
+ const raw2 = await readFile8(outPath, "utf8").catch(() => "");
13752
14313
  if (raw2.trim() === "") {
13753
14314
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
13754
14315
  }
@@ -13815,18 +14376,24 @@ function codexCommand(args) {
13815
14376
  "--color",
13816
14377
  "never",
13817
14378
  ...args.model ? ["--model", args.model] : [],
14379
+ ...(args.config ?? []).flatMap((c) => ["-c", c]),
14380
+ ...(args.images ?? []).flatMap((path2) => ["-i", path2]),
13818
14381
  "-"
13819
14382
  ];
13820
14383
  return sandbox === "workspace-write" && args.cwd !== void 0 ? { argv, env: { ...process.env, TMPDIR: args.cwd } } : { argv };
13821
14384
  }
13822
14385
  function runCodex(args) {
13823
14386
  const { argv, env: env2 } = codexCommand(args);
13824
- return new Promise((resolve9, reject) => {
13825
- const child = spawn3("codex", argv, {
13826
- stdio: ["pipe", "ignore", "pipe"],
14387
+ return new Promise((resolve10, reject) => {
14388
+ const child = spawn3(args.bin ?? "codex", argv, {
14389
+ stdio: ["pipe", args.onUsage ? "pipe" : "ignore", "pipe"],
13827
14390
  ...env2 === void 0 ? {} : { env: env2 }
13828
14391
  });
13829
14392
  let stderr = "";
14393
+ let stdout = "";
14394
+ child.stdout?.on("data", (c) => {
14395
+ stdout = (stdout + c.toString()).slice(-4e3);
14396
+ });
13830
14397
  const timer = setTimeout(() => {
13831
14398
  child.kill("SIGKILL");
13832
14399
  reject(new Error(`Codex did not finish within ${Math.round(args.timeoutMs / 1e3)}s.`));
@@ -13838,32 +14405,35 @@ function runCodex(args) {
13838
14405
  clearTimeout(timer);
13839
14406
  reject(
13840
14407
  err.code === "ENOENT" ? new Error(
13841
- 'The "codex" CLI is not on PATH. Install it, or sign in with `codex login`, then retry.'
14408
+ `The "${args.bin ?? "codex"}" CLI is not on PATH. Install it, or sign in with \`codex login\`, then retry.`
13842
14409
  ) : err
13843
14410
  );
13844
14411
  });
13845
14412
  child.on("close", (code) => {
13846
14413
  clearTimeout(timer);
13847
- if (code === 0) return resolve9();
14414
+ const used = /tokens used\s*\n?\s*([\d,]+)/i.exec(`${stdout}
14415
+ ${stderr}`);
14416
+ if (used?.[1] && args.onUsage) args.onUsage(Number(used[1].replace(/,/g, "")));
14417
+ if (code === 0) return resolve10();
13848
14418
  reject(
13849
14419
  new Error(`codex exec exited ${code}.
13850
14420
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
13851
14421
  );
13852
14422
  });
13853
- child.stdin.end(args.prompt);
14423
+ child.stdin?.end(args.prompt);
13854
14424
  });
13855
14425
  }
13856
14426
 
13857
14427
  // src/images/illustrate.ts
13858
14428
  import { createHash as createHash8 } from "node:crypto";
13859
- import { mkdir as mkdir6, readFile as readFile9, rename, rm as rm6, writeFile as writeFile8 } from "node:fs/promises";
13860
- import { join as join10 } from "node:path";
14429
+ import { mkdir as mkdir7, readFile as readFile10, rename as rename2, rm as rm6, writeFile as writeFile9 } from "node:fs/promises";
14430
+ import { join as join11 } from "node:path";
13861
14431
 
13862
14432
  // src/images/providers.ts
13863
14433
  import { createHash as createHash7 } from "node:crypto";
13864
- import { mkdtemp as mkdtemp2, readFile as readFile8, rm as rm5, writeFile as writeFile7 } from "node:fs/promises";
14434
+ import { mkdtemp as mkdtemp2, readFile as readFile9, rm as rm5, writeFile as writeFile8 } from "node:fs/promises";
13865
14435
  import { tmpdir as tmpdir3 } from "node:os";
13866
- import { join as join9, resolve as resolve4 } from "node:path";
14436
+ import { join as join10, resolve as resolve5 } from "node:path";
13867
14437
  import { z as z4 } from "zod";
13868
14438
  var SIZE = {
13869
14439
  landscape: { width: 1536, height: 1024 },
@@ -13995,11 +14565,11 @@ function codexImages(opts = {}) {
13995
14565
  async check() {
13996
14566
  },
13997
14567
  async generate(req) {
13998
- const dir = await mkdtemp2(join9(tmpdir3(), "decksmith-image-"));
14568
+ const dir = await mkdtemp2(join10(tmpdir3(), "decksmith-image-"));
13999
14569
  try {
14000
- const schemaPath = join9(dir, "answer.schema.json");
14001
- const outPath = join9(dir, "answer.json");
14002
- await writeFile7(schemaPath, JSON.stringify(ANSWER_SCHEMA));
14570
+ const schemaPath = join10(dir, "answer.schema.json");
14571
+ const outPath = join10(dir, "answer.json");
14572
+ await writeFile8(schemaPath, JSON.stringify(ANSWER_SCHEMA));
14003
14573
  await run5({
14004
14574
  prompt: codexPrompt(req),
14005
14575
  schemaPath,
@@ -14008,7 +14578,7 @@ function codexImages(opts = {}) {
14008
14578
  cwd: dir,
14009
14579
  sandbox: "workspace-write"
14010
14580
  });
14011
- const raw2 = await readFile8(outPath, "utf8").catch(() => "");
14581
+ const raw2 = await readFile9(outPath, "utf8").catch(() => "");
14012
14582
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
14013
14583
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
14014
14584
  if (!answer.data.ok) {
@@ -14016,10 +14586,10 @@ function codexImages(opts = {}) {
14016
14586
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
14017
14587
  );
14018
14588
  }
14019
- const candidates3 = [join9(dir, "picture.png")];
14020
- if (answer.data.file) candidates3.push(resolve4(dir, answer.data.file));
14589
+ const candidates3 = [join10(dir, "picture.png")];
14590
+ if (answer.data.file) candidates3.push(resolve5(dir, answer.data.file));
14021
14591
  for (const path2 of candidates3) {
14022
- const bytes = await readFile8(path2).catch(() => null);
14592
+ const bytes = await readFile9(path2).catch(() => null);
14023
14593
  if (bytes) return raster(bytes, "codex");
14024
14594
  }
14025
14595
  throw new Error("codex said ok but wrote no picture.png");
@@ -14151,7 +14721,7 @@ async function illustrate(storyboard, source, opts) {
14151
14721
  const tool = last?.id === "svg" ? last : toolSvg();
14152
14722
  if (tool !== last) chain.push(tool);
14153
14723
  const dropped = /* @__PURE__ */ new Set();
14154
- if (pending.length > 0) await mkdir6(opts.assetsDir, { recursive: true });
14724
+ if (pending.length > 0) await mkdir7(opts.assetsDir, { recursive: true });
14155
14725
  for (const [i, slot] of pending.entries()) {
14156
14726
  let rungs = chain;
14157
14727
  if (i >= images.max) {
@@ -14248,16 +14818,16 @@ function cacheKey2(providerId, req) {
14248
14818
  async function draw(provider, req, name, dir) {
14249
14819
  for (const ext of Object.values(EXT2)) {
14250
14820
  const src2 = `${name}${ext}`;
14251
- const bytes = await readFile9(join10(dir, src2)).catch(() => null);
14821
+ const bytes = await readFile10(join11(dir, src2)).catch(() => null);
14252
14822
  if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
14253
14823
  }
14254
14824
  await provider.check();
14255
14825
  const img = await provider.generate(req);
14256
14826
  const src = `${name}${EXT2[img.mime]}`;
14257
- const tmp = join10(dir, `.${name}.tmp`);
14827
+ const tmp = join11(dir, `.${name}.tmp`);
14258
14828
  try {
14259
- await writeFile8(tmp, img.bytes);
14260
- await rename(tmp, join10(dir, src));
14829
+ await writeFile9(tmp, img.bytes);
14830
+ await rename2(tmp, join11(dir, src));
14261
14831
  } finally {
14262
14832
  await rm6(tmp, { force: true });
14263
14833
  }
@@ -14272,6 +14842,353 @@ import { execFile as execFile2 } from "node:child_process";
14272
14842
  import { promisify as promisify2 } from "node:util";
14273
14843
  var run2 = promisify2(execFile2);
14274
14844
 
14845
+ // src/verify/scenes.ts
14846
+ var TYPE_FLOOR_PX2 = 39.5;
14847
+ var MEASURE = `(sid, wantGeo) => {
14848
+ const root = document.getElementById(sid);
14849
+ if (!root) return { crossings: [], occlusions: [], overlaps: [], small: [], off: [], strays: [] };
14850
+ const W = window.innerWidth, H = window.innerHeight;
14851
+ // A bespoke scene that moves the camera is clipped to its body box: what the
14852
+ // push-in leaves outside it is not drawn, so it is neither off the frame nor
14853
+ // crossing anything.
14854
+ const bodyEl = document.getElementById(sid + "-g");
14855
+ const bodyClip = bodyEl && getComputedStyle(bodyEl).overflow !== "visible" ? bodyEl.getBoundingClientRect() : null;
14856
+ const meet = (a, b) => {
14857
+ const x0 = Math.max(a.x, b.x), y0 = Math.max(a.y, b.y);
14858
+ const x1 = Math.min(a.x + a.width, b.x + b.width), y1 = Math.min(a.y + a.height, b.y + b.height);
14859
+ return { x: x0, y: y0, width: Math.max(0, x1 - x0), height: Math.max(0, y1 - y0) };
14860
+ };
14861
+ const alpha = (el) => {
14862
+ let o = 1;
14863
+ for (let e = el; e && e !== document.body; e = e.parentElement) {
14864
+ const cs = getComputedStyle(e);
14865
+ if (cs.display === "none" || cs.visibility === "hidden") return 0;
14866
+ o *= Number(cs.opacity);
14867
+ }
14868
+ return o;
14869
+ };
14870
+ const name = (el) => el.id || (el.tagName.toLowerCase() + ":" + (el.textContent || "").trim().slice(0, 16));
14871
+ // What an element's box is after any clipping <svg> (overflow other than
14872
+ // visible) \u2014 a camera move zooms a viewBox, and what it leaves outside is
14873
+ // not drawn, so it is neither off the frame nor crossing anything.
14874
+ const clipOf = (el) => {
14875
+ const svg = el.ownerSVGElement || (el.closest && el.closest("svg"));
14876
+ let c = svg && getComputedStyle(svg).overflow !== "visible" ? svg.getBoundingClientRect() : null;
14877
+ if (bodyClip && bodyEl.contains(el)) c = c ? meet(c, bodyClip) : bodyClip;
14878
+ return c;
14879
+ };
14880
+ const boxOf = (el) => {
14881
+ const r = el.getBoundingClientRect();
14882
+ const c = clipOf(el);
14883
+ if (!c) return { x: r.x, y: r.y, width: r.width, height: r.height, clip: null };
14884
+ const x0 = Math.max(r.x, c.x), y0 = Math.max(r.y, c.y);
14885
+ const x1 = Math.min(r.x + r.width, c.x + c.width), y1 = Math.min(r.y + r.height, c.y + c.height);
14886
+ // >=, not >: a horizontal line has no height and is still drawn.
14887
+ return x1 >= x0 && y1 >= y0 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0, clip: c } : { x: 0, y: 0, width: 0, height: 0, clip: c };
14888
+ };
14889
+ // Text boxes: SVG <text>, a KaTeX run, or an HTML element with its own text.
14890
+ const texts = [];
14891
+ // Where a label sits, in the body box's own px (= the scene's svg units when it
14892
+ // has no camera move): a fix round can act on "x 1210-1330" where it cannot on
14893
+ // "these two overlap" \u2014 round 2's drafts failed on overlaps it could not see.
14894
+ const origin = document.getElementById(sid + "-g");
14895
+ const o0 = origin ? origin.getBoundingClientRect() : { x: 0, y: 0 };
14896
+ const at = (t) => t.id + " [x " + Math.round(t.x - o0.x) + "-" + Math.round(t.x + t.w - o0.x) + ", y " + Math.round(t.y - o0.y) + "-" + Math.round(t.y + t.h - o0.y) + "]";
14897
+ const walk = (el) => {
14898
+ const tag = el.tagName.toLowerCase();
14899
+ if (tag === "script" || tag === "style" || tag === "defs") return;
14900
+ const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
14901
+ const katex = el.classList && el.classList.contains("katex");
14902
+ if (tag === "text" || katex || (own && !(el instanceof SVGElement))) {
14903
+ const r = boxOf(el);
14904
+ const o = alpha(el);
14905
+ if (r.width > 0 && r.height > 0 && o > 0.15) {
14906
+ let fs = parseFloat(getComputedStyle(el).fontSize) || 0;
14907
+ if (el instanceof SVGGraphicsElement && el.getScreenCTM) {
14908
+ const m = el.getScreenCTM();
14909
+ if (m) fs *= Math.hypot(m.a, m.b);
14910
+ }
14911
+ texts.push({ el, id: name(el), x: r.x, y: r.y, w: r.width, h: r.height, fs, o });
14912
+ }
14913
+ if (tag === "text" || katex) return;
14914
+ }
14915
+ for (const c of el.children) walk(c);
14916
+ };
14917
+ walk(root);
14918
+
14919
+ const overlaps = [];
14920
+ for (let i = 0; i < texts.length; i++)
14921
+ for (let j = i + 1; j < texts.length; j++) {
14922
+ const a = texts[i], b = texts[j];
14923
+ if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
14924
+ const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
14925
+ const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
14926
+ if (ix > 4 && iy > 4) overlaps.push(at(a) + " \xD7 " + at(b));
14927
+ }
14928
+ const small = texts.filter((t) => t.fs > 0 && t.fs < ${TYPE_FLOOR_PX2}).map((t) => t.id + "@" + t.fs.toFixed(1) + "px");
14929
+
14930
+ // Strokes and fills. Every shape that paints something at this instant is a
14931
+ // LEAF: what the stage-fill and early-reveal measures below are made of.
14932
+ const crossings = [], occlusions = [], off = [], strays = [], leaves = [];
14933
+ // <image> is a bespoke scene's illustration: it paints its whole box.
14934
+ const shapes = root.querySelectorAll("path, line, polyline, polygon, circle, ellipse, rect, image");
14935
+ // Repair geometry (bespoke scenes, on request): stroke points, filled boxes.
14936
+ const geoPoints = [], geoBoxes = [];
14937
+ const inset = 4;
14938
+ const inside = (p, t) => p.x > t.x + inset && p.x < t.x + t.w - inset && p.y > t.y + inset && p.y < t.y + t.h - inset;
14939
+ for (const g of shapes) {
14940
+ if (g.closest("defs, marker, clipPath, mask, pattern, symbol")) continue;
14941
+ const o = alpha(g);
14942
+ if (o <= 0.15) continue;
14943
+ const cs = getComputedStyle(g);
14944
+ const r = boxOf(g);
14945
+ if (r.width === 0 && r.height === 0) continue;
14946
+ if (r.x < -2 || r.y < -2 || r.x + r.width > W + 2 || r.y + r.height > H + 2) off.push(name(g));
14947
+ // A shape that wholly contains a text box is that label's plate, not a collision.
14948
+ const plate = (t) => r.x <= t.x + 1 && r.y <= t.y + 1 && r.x + r.width >= t.x + t.w - 1 && r.y + r.height >= t.y + t.h - 1;
14949
+ const sw = parseFloat(cs.strokeWidth) || 0;
14950
+ const stroked = cs.stroke && cs.stroke !== "none" && sw > 0 && Number(cs.strokeOpacity) * o > 0.15;
14951
+ // A <line> has no inside, and a shape scaled flat paints no area, whatever its fill says.
14952
+ const filled = cs.fill && cs.fill !== "none" && Number(cs.fillOpacity) * o > 0.15 &&
14953
+ g.tagName.toLowerCase() !== "line" && r.width >= 1 && r.height >= 1;
14954
+ // A marker paints with the element's opacity, not its stroke's, and DrawSVG
14955
+ // draws by dashing: an arrowhead stays where it is while its line is drawn
14956
+ // to 0%, or half way \u2014 the "stray arrowhead" of an undrawn line.
14957
+ const markerStart = cs.markerStart && cs.markerStart !== "none";
14958
+ const markerEnd = cs.markerEnd && cs.markerEnd !== "none";
14959
+ const picture = g.tagName.toLowerCase() === "image";
14960
+ let paints = filled;
14961
+ if (!stroked && (markerStart || markerEnd)) strays.push(name(g) + " (its arrowhead shows, its line does not)");
14962
+ if (stroked && typeof g.getTotalLength !== "function") paints = true;
14963
+ if (stroked && typeof g.getTotalLength === "function") {
14964
+ let len = 0;
14965
+ try { len = g.getTotalLength(); } catch (e) { len = 0; }
14966
+ const m = g.getScreenCTM();
14967
+ if (len > 0 && m) {
14968
+ const dash = (cs.strokeDasharray && cs.strokeDasharray !== "none")
14969
+ ? cs.strokeDasharray.split(/[ ,]+/).map(parseFloat).filter((v) => v >= 0)
14970
+ : [];
14971
+ const pattern = dash.length % 2 ? dash.concat(dash) : dash;
14972
+ const period = pattern.reduce((a, b) => a + b, 0);
14973
+ const offset = parseFloat(cs.strokeDashoffset) || 0;
14974
+ const visible = (d) => {
14975
+ if (!period) return true;
14976
+ let pos = ((d + offset) % period + period) % period;
14977
+ for (let k = 0; k < pattern.length; k++) {
14978
+ if (pos < pattern[k]) return k % 2 === 0;
14979
+ pos -= pattern[k];
14980
+ }
14981
+ return true;
14982
+ };
14983
+ const step = Math.max(4, len / 400);
14984
+ const hit = new Set();
14985
+ let drawn = 0;
14986
+ let kept = -1e9;
14987
+ for (let d = 0; d <= len; d += step) {
14988
+ if (!visible(d)) continue;
14989
+ drawn += step;
14990
+ const q = g.getPointAtLength(d);
14991
+ const p = { x: m.a * q.x + m.c * q.y + m.e, y: m.b * q.x + m.d * q.y + m.f };
14992
+ if (r.clip && (p.x < r.clip.x || p.y < r.clip.y || p.x > r.clip.x + r.clip.width || p.y > r.clip.y + r.clip.height)) continue;
14993
+ if (wantGeo && d - kept >= 8 && geoPoints.length < 6000) { kept = d; geoPoints.push({ el: g, x: p.x, y: p.y }); }
14994
+ // No plate exemption here: a long curve's bounding box contains half
14995
+ // the labels on a chart, and its stroke still runs through them.
14996
+ for (const t of texts) if (!hit.has(t) && inside(p, t)) hit.add(t);
14997
+ }
14998
+ for (const t of hit) crossings.push(name(g) + " through " + at(t));
14999
+ if (drawn > 2) paints = true;
15000
+ const edge = Math.min(2, len / 100);
15001
+ if (markerEnd && !visible(len - edge)) strays.push(name(g) + " (an arrowhead at the end of a line not drawn that far)");
15002
+ else if (markerStart && !visible(edge)) strays.push(name(g) + " (an arrowhead at the start of a line not drawn from there)");
15003
+ } else if (len > 0) paints = true;
15004
+ }
15005
+ if (paints) leaves.push({ el: g, x: r.x, y: r.y, w: r.width, h: r.height, o });
15006
+ if (wantGeo && filled) geoBoxes.push({ el: g, x: r.x, y: r.y, w: r.width, h: r.height });
15007
+ if (filled) {
15008
+ for (const t of texts) {
15009
+ // A picture over a label is never its plate: it covers it.
15010
+ if (plate(t) && !picture) continue;
15011
+ // Only a shape painted AFTER the text can cover it.
15012
+ if (!(t.el.compareDocumentPosition(g) & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
15013
+ const ix = Math.min(r.x + r.width, t.x + t.w) - Math.max(r.x, t.x);
15014
+ const iy = Math.min(r.y + r.height, t.y + t.h) - Math.max(r.y, t.y);
15015
+ if (ix > inset && iy > inset && ix * iy > 0.15 * t.w * t.h) occlusions.push(name(g) + " over " + at(t));
15016
+ }
15017
+ }
15018
+ }
15019
+ for (const t of texts)
15020
+ if (t.x < -1 || t.y < -1 || t.x + t.w > W + 1 || t.y + t.h > H + 1) off.push(t.id);
15021
+
15022
+ // The body box a bespoke scene draws in. Absent on an archetype's scene, and
15023
+ // then there is no stage to fill and no cue group to reveal.
15024
+ const box = document.getElementById(sid + "-g");
15025
+ let fill, maxType, cells, groups, revealed, dimmed;
15026
+ if (box) {
15027
+ const b = box.getBoundingClientRect();
15028
+ const mine = texts.filter((t) => box.contains(t.el)).map((t) => ({ el: t.el, x: t.x, y: t.y, w: t.w, h: t.h }))
15029
+ .concat(leaves.filter((l) => box.contains(l.el)));
15030
+ // A backdrop the size of the box fills it by being there; it is not content.
15031
+ // A picture is content however big it is: it is what the stage shows.
15032
+ const content = mine.filter((l) => l.w * l.h < 0.85 * b.width * b.height || l.el.tagName.toLowerCase() === "image");
15033
+ const clip = (l) => {
15034
+ const x0 = Math.max(b.x, l.x), y0 = Math.max(b.y, l.y);
15035
+ const x1 = Math.min(b.x + b.width, l.x + l.w), y1 = Math.min(b.y + b.height, l.y + l.h);
15036
+ return x1 > x0 && y1 > y0 ? { x0, y0, x1, y1 } : null;
15037
+ };
15038
+ const parts = content.map(clip).filter(Boolean);
15039
+ if (parts.length && b.width > 0 && b.height > 0) {
15040
+ const u = parts.reduce((a, p) => ({ x0: Math.min(a.x0, p.x0), y0: Math.min(a.y0, p.y0), x1: Math.max(a.x1, p.x1), y1: Math.max(a.y1, p.y1) }));
15041
+ fill = ((u.x1 - u.x0) * (u.y1 - u.y0)) / (b.width * b.height);
15042
+ // How many of a 6x4 grid's cells something is drawn in: a bbox can be
15043
+ // stretched by one stray dot, a grid cannot.
15044
+ let n = 0;
15045
+ for (let cx = 0; cx < 6; cx++)
15046
+ for (let cy = 0; cy < 4; cy++) {
15047
+ const gx0 = b.x + (cx * b.width) / 6, gx1 = b.x + ((cx + 1) * b.width) / 6;
15048
+ const gy0 = b.y + (cy * b.height) / 4, gy1 = b.y + ((cy + 1) * b.height) / 4;
15049
+ if (parts.some((p) => p.x0 < gx1 && p.x1 > gx0 && p.y0 < gy1 && p.y1 > gy0)) n++;
15050
+ }
15051
+ cells = n / 24;
15052
+ } else fill = 0;
15053
+ maxType = texts.filter((t) => box.contains(t.el)).reduce((a, t) => Math.max(a, t.fs), 0);
15054
+ // How much of what is drawn is drawn dimmed (focus): at the end, a summary
15055
+ // that is mostly ghosts is not one.
15056
+ const shown = texts.filter((t) => box.contains(t.el)).concat(leaves.filter((l) => box.contains(l.el)));
15057
+ dimmed = shown.length ? shown.filter((x) => x.o < 0.6).length / shown.length : 0;
15058
+ // Cue groups: what the scene says each cue introduces, and which are showing now.
15059
+ const tagged = [...box.querySelectorAll("[data-cue]")];
15060
+ groups = [...new Set(tagged.map((e) => Number(e.getAttribute("data-cue"))))].sort((a, c) => a - c);
15061
+ const showing = (e) => texts.some((t) => e === t.el || e.contains(t.el)) || leaves.some((l) => e === l.el || e.contains(l.el));
15062
+ revealed = tagged.filter(showing).map((e) => ({ id: name(e), cue: Number(e.getAttribute("data-cue")) }));
15063
+ }
15064
+ // Where the camera is, for \`camera_end\`: the shell's wrapper, and a viewBox camera.
15065
+ let camOff;
15066
+ const cam = document.getElementById(sid + "-cam");
15067
+ if (cam) {
15068
+ const t = getComputedStyle(cam).transform;
15069
+ if (t && t !== "none") {
15070
+ const v = t.slice(t.indexOf("(") + 1, t.lastIndexOf(")")).split(",").map(Number);
15071
+ if (v.length === 6 && (Math.abs(v[0] - 1) > 0.01 || Math.abs(v[4]) > 2 || Math.abs(v[5]) > 2))
15072
+ camOff = "#" + sid + "-cam at scale " + v[0].toFixed(2) + ", x " + Math.round(v[4]) + ", y " + Math.round(v[5]);
15073
+ }
15074
+ }
15075
+ // Round 4: the shell's camera (scale, x, y and the box it moves), the
15076
+ // subjects of an illustrated scene, and how its labels sit on them.
15077
+ let shot, subjects, anchors, onPicture;
15078
+ if (cam && box) {
15079
+ const t = getComputedStyle(cam).transform;
15080
+ const v = t && t !== "none" ? t.slice(t.indexOf("(") + 1, t.lastIndexOf(")")).split(",").map(Number) : [1, 0, 0, 1, 0, 0];
15081
+ const s = v.length === 6 ? v[0] : 1;
15082
+ shot = [Math.round(s * 1000) / 1000, Math.round(v[4] || 0), Math.round(v[5] || 0), cam.offsetWidth, cam.offsetHeight];
15083
+ const subs = [...cam.querySelectorAll("[data-ds-subject]")].map((e) => ({ k: Number(e.getAttribute("data-ds-subject")), r: e.getBoundingClientRect() }));
15084
+ subjects = subs.length;
15085
+ if (subs.length) {
15086
+ const area = (r) => Math.max(0, r.width) * Math.max(0, r.height);
15087
+ anchors = [];
15088
+ for (const g of box.querySelectorAll("[data-subject]")) {
15089
+ const k = Number(g.getAttribute("data-subject"));
15090
+ const mine = texts.filter((x) => g === x.el || g.contains(x.el));
15091
+ if (!mine.length) continue;
15092
+ const x0 = Math.min(...mine.map((x) => x.x)), y0 = Math.min(...mine.map((x) => x.y));
15093
+ const x1 = Math.max(...mine.map((x) => x.x + x.w)), y1 = Math.max(...mine.map((x) => x.y + x.h));
15094
+ const lab = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
15095
+ const own = subs.find((q) => q.k === k);
15096
+ // Gap between the label and its subject, in the box's own px (camera-free).
15097
+ const gap = own ? Math.hypot(Math.max(0, own.r.x - x1, x0 - (own.r.x + own.r.width)), Math.max(0, own.r.y - y1, y0 - (own.r.y + own.r.height))) / s : 1e9;
15098
+ // The most of the label any OTHER subject's box covers (the part not also its own).
15099
+ let over = 0;
15100
+ for (const q of subs) {
15101
+ if (q.k === k) continue;
15102
+ const m = meet(lab, q.r);
15103
+ let a = area(m);
15104
+ if (own) a -= area(meet(m, own.r));
15105
+ over = Math.max(over, a / Math.max(1, area(lab)));
15106
+ }
15107
+ anchors.push({ id: g.id || "g:" + k, k, d: Math.round(gap), o: Math.round(over * 100) / 100 });
15108
+ }
15109
+ }
15110
+ // A table or chart painted over the picture: numbers sitting on the illustration.
15111
+ const pic = box.querySelector("image[data-art]");
15112
+ if (pic && alpha(pic) > 0.15) {
15113
+ const pr = boxOf(pic);
15114
+ onPicture = texts.filter((x) => box.contains(x.el) && /[0-9]/.test(x.el.textContent || "") &&
15115
+ x.x + x.w / 2 > pr.x && x.x + x.w / 2 < pr.x + pr.width && x.y + x.h / 2 > pr.y && x.y + x.h / 2 < pr.y + pr.height).length;
15116
+ }
15117
+ }
15118
+ const svg0 = document.getElementById(sid + "-svg");
15119
+ if (svg0 && !camOff) {
15120
+ // The contract's viewBox is "0 0 W H" with W, H the svg's own size.
15121
+ const vb = (svg0.getAttribute("viewBox") || "").trim().split(/[ ,]+/).map(Number);
15122
+ const w0 = Number(svg0.getAttribute("width")), h0 = Number(svg0.getAttribute("height"));
15123
+ if (vb.length === 4 && w0 > 0 && h0 > 0 && (Math.abs(vb[0]) > 1 || Math.abs(vb[1]) > 1 || Math.abs(vb[2] - w0) > 1 || Math.abs(vb[3] - h0) > 1))
15124
+ camOff = "#" + sid + "-svg viewBox " + vb.join(" ");
15125
+ }
15126
+
15127
+ // What the repair pass needs (bespoke scenes only): every element of the body
15128
+ // addressed as tag:index in document order \u2014 the markup's own order, which
15129
+ // is how a fix finds it again in the fragment \u2014 the labels with the unit a
15130
+ // nudge would move (the text, or the smallest <g> that holds it and its
15131
+ // plate and no other label), what is painted, and each part's opacity.
15132
+ let geo;
15133
+ if (box && wantGeo) {
15134
+ const index = new Map(), order = new Map();
15135
+ const n = {};
15136
+ let k = 0;
15137
+ for (const e of box.querySelectorAll("*")) {
15138
+ // Not the markup's: KaTeX's rendered spans, and the shell's camera wrapper.
15139
+ if (e.closest(".katex") || e.id === sid + "-cam") continue;
15140
+ const t = e.tagName.toLowerCase();
15141
+ n[t] = n[t] || 0;
15142
+ index.set(e, t + ":" + n[t]++);
15143
+ order.set(e, k++);
15144
+ }
15145
+ const ob = box.getBoundingClientRect();
15146
+ const rel = (x, y, w, h) => [x - ob.x, y - ob.y, w, h].map((v) => Math.round(v * 10) / 10);
15147
+ const unitOf = (el) => {
15148
+ let u = el;
15149
+ const tb = el.getBoundingClientRect();
15150
+ const area = Math.max(1, tb.width * tb.height);
15151
+ for (let p = el.parentElement; p && p !== box && p.tagName.toLowerCase() === "g" && box.contains(p); p = p.parentElement) {
15152
+ if (p.querySelectorAll("text").length !== 1) break;
15153
+ const pb = p.getBoundingClientRect();
15154
+ if (pb.width * pb.height > 4 * area) break;
15155
+ u = p;
15156
+ }
15157
+ return u;
15158
+ };
15159
+ const scaleOf = (u) => {
15160
+ const par = u.parentElement;
15161
+ const m = par && par.getScreenCTM ? par.getScreenCTM() : null;
15162
+ return m ? Math.hypot(m.a, m.b) || 1 : 1;
15163
+ };
15164
+ const units = [];
15165
+ const labels = texts.filter((t) => box.contains(t.el)).map((t) => {
15166
+ // The shell's own labels (round 4) are not the scene's to move: obstacles.
15167
+ const movable = t.el.tagName.toLowerCase() === "text" && !t.el.closest(".ds-callouts");
15168
+ const u = movable ? unitOf(t.el) : null;
15169
+ if (u) units.push(u);
15170
+ return { a: index.get(t.el) || "", u: u ? index.get(u) : null, o: order.get(t.el) ?? -1, b: rel(t.x, t.y, t.w, t.h), s: u ? Math.round(scaleOf(u) * 1000) / 1000 : 1, fs: Math.round(t.fs) };
15171
+ });
15172
+ const ownerOf = (el) => { for (const u of units) if (u === el || u.contains(el)) return index.get(u); return null; };
15173
+ const points = geoPoints.filter((p) => box.contains(p.el)).map((p) => [Math.round(p.x - ob.x), Math.round(p.y - ob.y), ownerOf(p.el)]);
15174
+ const boxes = geoBoxes.filter((b) => box.contains(b.el)).map((b) => ({ a: index.get(b.el) || "", u: ownerOf(b.el), o: order.get(b.el) ?? -1, b: rel(b.x, b.y, b.w, b.h) }));
15175
+ // Each visible part's opacity, and the elements above it (itself included)
15176
+ // that dim it: what a relight would have to bring back.
15177
+ const own = (e) => Number(getComputedStyle(e).opacity);
15178
+ const parts = texts.filter((t) => box.contains(t.el)).map((t) => ({ el: t.el, o: t.o }))
15179
+ .concat(leaves.filter((l) => box.contains(l.el)).map((l) => ({ el: l.el, o: l.o })))
15180
+ .map((p) => {
15181
+ const dim = [];
15182
+ if (p.o < 0.95)
15183
+ for (let e = p.el; e && e !== box; e = e.parentElement)
15184
+ if (index.has(e) && own(e) < 0.95) dim.push(index.get(e) + (e.id ? "#" + e.id : ""));
15185
+ return { a: index.get(p.el) || "", al: Math.round(p.o * 100) / 100, dim };
15186
+ });
15187
+ geo = { w: Math.round(ob.width), h: Math.round(ob.height), labels, boxes, points, parts };
15188
+ }
15189
+ return { crossings, occlusions, overlaps, small, off, strays, fill, cells, maxType, groups, revealed, dimmed, camOff, geo, shot, subjects, anchors, onPicture };
15190
+ }`;
15191
+
14275
15192
  // src/verify/drift.ts
14276
15193
  import { execFile as execFile3 } from "node:child_process";
14277
15194
  import { promisify as promisify3 } from "node:util";
@@ -14295,12 +15212,12 @@ function scanBeatCount(storyboard, prefs) {
14295
15212
  }
14296
15213
 
14297
15214
  // src/render/render.ts
14298
- import { mkdir as mkdir8, readFile as readFile10, rename as rename2, rm as rm7, writeFile as writeFile10 } from "node:fs/promises";
14299
- import { basename as basename4, dirname as dirname3, join as join12, resolve as resolve5 } from "node:path";
15215
+ import { mkdir as mkdir9, readFile as readFile11, rename as rename3, rm as rm7, writeFile as writeFile11 } from "node:fs/promises";
15216
+ import { basename as basename4, dirname as dirname3, join as join13, resolve as resolve6 } from "node:path";
14300
15217
 
14301
15218
  // src/render/captions.ts
14302
- import { mkdir as mkdir7, writeFile as writeFile9 } from "node:fs/promises";
14303
- import { join as join11 } from "node:path";
15219
+ import { mkdir as mkdir8, writeFile as writeFile10 } from "node:fs/promises";
15220
+ import { join as join12 } from "node:path";
14304
15221
  import { pathToFileURL } from "node:url";
14305
15222
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
14306
15223
  function cssString(value) {
@@ -14391,11 +15308,11 @@ async function captionBlocker() {
14391
15308
  }
14392
15309
  async function renderCaptions(cues, style, deck, work2) {
14393
15310
  if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
14394
- const fontCss = join11(deck, DECK_FONT_CSS);
15311
+ const fontCss = join12(deck, DECK_FONT_CSS);
14395
15312
  const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL(fontCss).href : null;
14396
- const page = join11(work2, "captions.html");
14397
- await mkdir7(work2, { recursive: true });
14398
- await writeFile9(page, captionPage(cues, style, href));
15313
+ const page = join12(work2, "captions.html");
15314
+ await mkdir8(work2, { recursive: true });
15315
+ await writeFile10(page, captionPage(cues, style, href));
14399
15316
  const { default: puppeteer } = await import("puppeteer-core");
14400
15317
  const executablePath = await chromePath(CAPTION_NEED);
14401
15318
  const browser = await launchOwnPage(
@@ -14416,7 +15333,7 @@ async function renderCaptions(cues, style, deck, work2) {
14416
15333
  const name = `cap${String(i).padStart(4, "0")}.png`;
14417
15334
  await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
14418
15335
  await tab.screenshot({
14419
- path: join11(work2, name),
15336
+ path: join12(work2, name),
14420
15337
  type: "png",
14421
15338
  omitBackground: true,
14422
15339
  clip: { x: box.x, y: box.y, width: box.width, height: box.height }
@@ -14457,9 +15374,9 @@ function subtitlePlan(mode) {
14457
15374
  async function render(opts) {
14458
15375
  const log = opts.log ?? (() => {
14459
15376
  });
14460
- const deck = resolve5(opts.deck);
14461
- const out = resolve5(opts.out);
14462
- const work2 = join12(dirname3(out), `.${basename4(out)}.parts`);
15377
+ const deck = resolve6(opts.deck);
15378
+ const out = resolve6(opts.out);
15379
+ const work2 = join13(dirname3(out), `.${basename4(out)}.parts`);
14463
15380
  const timing = await readTiming(deck);
14464
15381
  if (opts.targetSeconds && !opts.allowFastPlayback) {
14465
15382
  const refusal = playbackRefusal(
@@ -14479,9 +15396,9 @@ async function render(opts) {
14479
15396
  if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
14480
15397
  }
14481
15398
  const burnable = plan0.burn;
14482
- await mkdir8(work2, { recursive: true });
15399
+ await mkdir9(work2, { recursive: true });
14483
15400
  try {
14484
- const raw2 = opts.video ? resolve5(opts.video) : await capture(deck, join12(work2, "raw.mp4"), opts, log);
15401
+ const raw2 = opts.video ? resolve6(opts.video) : await capture(deck, join13(work2, "raw.mp4"), opts, log);
14485
15402
  const shot = await probe(raw2);
14486
15403
  log(
14487
15404
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -14494,24 +15411,24 @@ async function render(opts) {
14494
15411
  );
14495
15412
  }
14496
15413
  const retimed = await retime(raw2, plan, work2, log);
14497
- const srtPath = join12(dirname3(out), SRT_NAME(out));
15414
+ const srtPath = join13(dirname3(out), SRT_NAME(out));
14498
15415
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
14499
15416
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
14500
15417
  const srt = plan0.sidecar ? toSrt(cues) : "";
14501
- if (srt) await writeFile10(srtPath, srt);
15418
+ if (srt) await writeFile11(srtPath, srt);
14502
15419
  const burn = burnable && srt.length > 0;
14503
15420
  await mux(retimed, timing, plan, deck, out, work2, burn ? plan.cues : void 0, shot.fps, log);
14504
15421
  if (playback > 1) {
14505
15422
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
14506
15423
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
14507
15424
  if (warning) log(`render: ${warning}`);
14508
- const fast = join12(work2, `fast.${basename4(out)}`);
15425
+ const fast = join13(work2, `fast.${basename4(out)}`);
14509
15426
  const muxed = await probe(out);
14510
15427
  await runTool(
14511
15428
  "ffmpeg",
14512
15429
  respeedArgs(out, playback, tempoChain(playback), shot.fps, muxed.hasAudio, fast)
14513
15430
  );
14514
- await rename2(fast, out);
15431
+ await rename3(fast, out);
14515
15432
  }
14516
15433
  const final = await probe(out);
14517
15434
  if (plan.audio.length > 0 && !final.hasAudio) {
@@ -14535,8 +15452,8 @@ async function render(opts) {
14535
15452
  }
14536
15453
  }
14537
15454
  async function readTiming(deck) {
14538
- const path2 = join12(deck, TIMING_FILE);
14539
- const text2 = await readFile10(path2, "utf8").catch(() => {
15455
+ const path2 = join13(deck, TIMING_FILE);
15456
+ const text2 = await readFile11(path2, "utf8").catch(() => {
14540
15457
  throw new Error(
14541
15458
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
14542
15459
  );
@@ -14575,20 +15492,20 @@ async function retime(raw2, plan, work2, log) {
14575
15492
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
14576
15493
  const list = [];
14577
15494
  for (const [i, piece] of plan.pieces.entries()) {
14578
- const file = join12(work2, `p${String(i).padStart(4, "0")}.ts`);
15495
+ const file = join13(work2, `p${String(i).padStart(4, "0")}.ts`);
14579
15496
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
14580
15497
  list.push(file);
14581
15498
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
14582
15499
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
14583
15500
  }
14584
15501
  }
14585
- const listFile = join12(work2, "pieces.txt");
14586
- await writeFile10(
15502
+ const listFile = join13(work2, "pieces.txt");
15503
+ await writeFile11(
14587
15504
  listFile,
14588
15505
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
14589
15506
  `
14590
15507
  );
14591
- const joined = join12(work2, "retimed.mp4");
15508
+ const joined = join13(work2, "retimed.mp4");
14592
15509
  await runTool("ffmpeg", [
14593
15510
  "-y",
14594
15511
  "-hide_banner",
@@ -14610,7 +15527,7 @@ async function retime(raw2, plan, work2, log) {
14610
15527
  }
14611
15528
  async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
14612
15529
  const inputs = plan.audio.map((a) => ({
14613
- file: join12(deck, timing.audioDir, a.audio),
15530
+ file: join13(deck, timing.audioDir, a.audio),
14614
15531
  delayMs: a.delayMs
14615
15532
  }));
14616
15533
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -14631,8 +15548,8 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
14631
15548
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
14632
15549
  }
14633
15550
  if (graph.length > 0) {
14634
- const script = join12(work2, "mux.filter");
14635
- await writeFile10(script, `${graph.join(";\n")}
15551
+ const script = join13(work2, "mux.filter");
15552
+ await writeFile11(script, `${graph.join(";\n")}
14636
15553
  `);
14637
15554
  args.push("-filter_complex_script", script);
14638
15555
  }
@@ -14647,12 +15564,12 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
14647
15564
  log(
14648
15565
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
14649
15566
  );
14650
- await mkdir8(dirname3(out), { recursive: true });
15567
+ await mkdir9(dirname3(out), { recursive: true });
14651
15568
  await runTool("ffmpeg", args, { cwd: work2 });
14652
15569
  }
14653
15570
 
14654
15571
  // src/pack/pack.ts
14655
- import { mkdir as mkdir9, readFile as readFile11, writeFile as writeFile11 } from "node:fs/promises";
15572
+ import { mkdir as mkdir10, readFile as readFile12, writeFile as writeFile12 } from "node:fs/promises";
14656
15573
  import { dirname as dirname4, extname as extname3 } from "node:path";
14657
15574
  import { unzipSync, zipSync } from "fflate";
14658
15575
  var MTIME = Date.UTC(1980, 0, 2, 12);
@@ -14692,8 +15609,8 @@ async function writePack(pack3, files, out) {
14692
15609
  entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
14693
15610
  }
14694
15611
  const zip = zipSync(entries, { mtime: MTIME });
14695
- await mkdir9(dirname4(out), { recursive: true });
14696
- await writeFile11(out, zip);
15612
+ await mkdir10(dirname4(out), { recursive: true });
15613
+ await writeFile12(out, zip);
14697
15614
  return zip.length;
14698
15615
  }
14699
15616
  function check2(label, path2 = label) {
@@ -14705,6 +15622,325 @@ function check2(label, path2 = label) {
14705
15622
  var PREF_KEYS = Object.keys(prefsSchema.shape);
14706
15623
  var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
14707
15624
  var IMAGES_KEYS = Object.keys(prefsSchema.shape.images.unwrap().shape);
15625
+ var BESPOKE_KEYS = Object.keys(prefsSchema.shape.bespoke.unwrap().shape);
15626
+
15627
+ // src/bespoke/references.ts
15628
+ var TOKEN_Y = [110, 240, 370, 500, 630];
15629
+ var EXPERT_Y = [30, 206, 382, 558];
15630
+ var SCORES = [0.62, 0.21, 0.71, 0.09];
15631
+ var route = {
15632
+ name: "route",
15633
+ shows: "a mechanism that RUNS: a queue of tokens streams particles into a router, wires draw out to four experts, scores grow, the two chosen paths light while the rest dim (focus) and particles flow along them, and a big counter lands the consequence",
15634
+ fits: ["pipeline", "stack", "grid", "process", "flow", "cycle", "split-compare"],
15635
+ cues: [
15636
+ { t0: 1, t1: 4.6, text: "Every token arrives at the router." },
15637
+ { t0: 4.6, t1: 9, text: "The router scores all four experts," },
15638
+ { t0: 9, t1: 13.4, text: "and sends each token to its top two." },
15639
+ { t0: 13.4, t1: 17.8, text: "Only those run, so compute drops by half." }
15640
+ ],
15641
+ duration: 19,
15642
+ fragment: {
15643
+ markup: `<svg id="SCENEID-svg" width="1700" height="732" viewBox="0 0 1700 732" style="position:absolute;left:0;top:0;overflow:visible">
15644
+ <g id="SCENEID-tokens" data-cue="1">
15645
+ <text id="SCENEID-tokens-label" x="130" y="34" font-size="52" fill="{{muted}}" text-anchor="middle" dominant-baseline="middle">Tokens</text>
15646
+ ${TOKEN_Y.map((y, i) => ` <circle id="SCENEID-tok${i}" cx="130" cy="${y}" r="40" fill="{{${"abcda"[i]}}}"/>`).join("\n")}
15647
+ </g>
15648
+ <g id="SCENEID-stream" data-cue="1">
15649
+ ${TOKEN_Y.map((y, i) => ` <circle id="SCENEID-s${i}" cx="130" cy="${y}" r="16" fill="{{${"abcda"[i]}}}"/>`).join("\n")}
15650
+ </g>
15651
+ <g id="SCENEID-wires" data-cue="2">
15652
+ ${EXPERT_Y.map((y, i) => ` <line id="SCENEID-wire${i}" x1="890" y1="370" x2="1176" y2="${y + 72}" stroke="{{rule}}" stroke-width="7" stroke-linecap="round"/>`).join("\n")}
15653
+ </g>
15654
+ <g id="SCENEID-flow" data-cue="3">
15655
+ ${[0, 1, 2, 3, 4, 5].map((i) => ` <circle id="SCENEID-p${i}" cx="890" cy="370" r="15" fill="{{accent}}"/>`).join("\n")}
15656
+ </g>
15657
+ <g id="SCENEID-router" data-cue="1">
15658
+ <circle id="SCENEID-disc-fill" cx="700" cy="370" r="190" fill="{{bg}}"/>
15659
+ <circle id="SCENEID-disc" cx="700" cy="370" r="190" fill="{{accent}}" fill-opacity="0.22" stroke="{{accent}}" stroke-width="7"/>
15660
+ <text id="SCENEID-router-label" x="700" y="370" font-size="80" font-weight="700" fill="{{fg}}" text-anchor="middle" dominant-baseline="middle">Router</text>
15661
+ </g>
15662
+ ${EXPERT_Y.map(
15663
+ (y, i) => ` <g id="SCENEID-expert${i}" data-cue="2">
15664
+ <rect id="SCENEID-box${i}" x="1180" y="${y}" width="480" height="144" rx="22" fill="{{${"abcd"[i]}}}" fill-opacity="0.16" stroke="{{rule}}" stroke-width="5"/>
15665
+ <text id="SCENEID-name${i}" x="1224" y="${y + 56}" font-size="60" font-weight="600" fill="{{fg}}" dominant-baseline="middle">Expert ${i + 1}</text>
15666
+ <rect id="SCENEID-bar${i}" x="1224" y="${y + 100}" width="${Math.round(380 * SCORES[i])}" height="22" rx="11" fill="{{${"abcd"[i]}}}"/>
15667
+ </g>`
15668
+ ).join("\n")}
15669
+ <g id="SCENEID-saving" data-cue="4">
15670
+ <text id="SCENEID-count" x="770" y="660" font-size="120" font-weight="800" fill="{{accent}}" text-anchor="end" dominant-baseline="middle">100</text>
15671
+ <text id="SCENEID-count-unit" x="786" y="668" font-size="60" fill="{{fg}}" dominant-baseline="middle">% compute</text>
15672
+ </g>
15673
+ </svg>`,
15674
+ css: "",
15675
+ script: `// Layout (box 1700x732): tokens x 90-170, y 10-670 \xB7 router disc 510-890 x 180-560 \xB7
15676
+ // wires 890-1176 \xB7 experts 1180-1660 x 30-702 \xB7 counter 560-1090 x 600-700.
15677
+ // Particles are drawn BEFORE the router so they slide under its disc (absorbed).
15678
+ var tokens = [], stream = [], ps = [];
15679
+ var ys = [110, 240, 370, 500, 630];
15680
+ for (var i = 0; i < 5; i++) {
15681
+ tokens.push("#SCENEID-tok" + i);
15682
+ stream.push("#SCENEID-s" + i);
15683
+ }
15684
+ for (var j = 0; j < 6; j++) ps.push("#SCENEID-p" + j);
15685
+ var experts = ["#SCENEID-expert0", "#SCENEID-expert1", "#SCENEID-expert2", "#SCENEID-expert3"];
15686
+ var wires = ["#SCENEID-wire0", "#SCENEID-wire1", "#SCENEID-wire2", "#SCENEID-wire3"];
15687
+ var bars = ["#SCENEID-bar0", "#SCENEID-bar1", "#SCENEID-bar2", "#SCENEID-bar3"];
15688
+
15689
+ // Baselines: every animated thing's start state, set once, before any tween.
15690
+ gsap.set(tokens, { scale: 0, transformOrigin: "50% 50%" });
15691
+ gsap.set("#SCENEID-tokens-label", { opacity: 0 });
15692
+ gsap.set(stream, { opacity: 0, x: 0, y: 0 });
15693
+ gsap.set(["#SCENEID-disc", "#SCENEID-disc-fill"], { scale: 0, transformOrigin: "50% 50%" });
15694
+ gsap.set("#SCENEID-router-label", { opacity: 0 });
15695
+ gsap.set(experts, { opacity: 0, x: 60 });
15696
+ gsap.set(wires, { drawSVG: "0% 0%", opacity: 0 });
15697
+ gsap.set(bars, { scaleX: 0, transformOrigin: "0% 50%" });
15698
+ gsap.set(ps, { opacity: 0, x: 0, y: 0 });
15699
+ gsap.set("#SCENEID-saving", { opacity: 0, y: 30 });
15700
+
15701
+ // C1 1.0-4.6 "Every token arrives at the router." \u2014 the queue pops in, then streams into the router.
15702
+ tl.to("#SCENEID-tokens-label", { opacity: 1, duration: 0.4 }, 1.0);
15703
+ tl.to(tokens, { scale: 1, duration: 0.5, ease: "back.out(2)", stagger: 0.08 }, 1.05);
15704
+ tl.to(["#SCENEID-disc", "#SCENEID-disc-fill"], { scale: 1, duration: 0.7, ease: "expo.out" }, 1.4);
15705
+ tl.to("#SCENEID-router-label", { opacity: 1, duration: 0.4 }, 1.8);
15706
+ for (var k = 0; k < 5; k++) {
15707
+ tl.to(stream[k], { opacity: 1, duration: 0.1 }, 2.2 + k * 0.15);
15708
+ tl.to(stream[k], { x: 440, y: 370 - ys[k], duration: 0.9, ease: "power2.in", repeat: 2 }, 2.2 + k * 0.15);
15709
+ tl.to(stream[k], { opacity: 0, duration: 0.1 }, 4.9 + k * 0.15);
15710
+ }
15711
+
15712
+ // C2 4.6-9.0 "The router scores all four experts," \u2014 experts slide in, wires draw, scores grow.
15713
+ tl.to(experts, { opacity: 1, x: 0, duration: 0.6, ease: "power3.out", stagger: 0.15 }, 4.7);
15714
+ tl.to(wires, { opacity: 1, duration: 0.01, stagger: 0.2 }, 5.0);
15715
+ tl.to(wires, { drawSVG: "0% 100%", duration: 0.7, ease: "power2.inOut", stagger: 0.2 }, 5.0);
15716
+ tl.to(bars, { scaleX: 1, duration: 0.9, ease: "power2.out", stagger: 0.3 }, 6.4);
15717
+ tl.to("#SCENEID-disc", { scale: 1.05, duration: 0.35, yoyo: true, repeat: 3, ease: "sine.inOut" }, 7.6);
15718
+
15719
+ // C3 9.0-13.4 "and sends each token to its top two." \u2014 focus: the two winners light, the rest dim; particles flow.
15720
+ tl.to(["#SCENEID-expert1", "#SCENEID-expert3", "#SCENEID-wire1", "#SCENEID-wire3"], { opacity: 0.25, duration: 0.5 }, 9.1);
15721
+ tl.to(["#SCENEID-wire0", "#SCENEID-wire2"], { stroke: "{{accent}}", strokeWidth: 12, duration: 0.5 }, 9.1);
15722
+ var dest = [[286, -268], [286, 84]];
15723
+ for (var n = 0; n < 6; n++) {
15724
+ var d = dest[n % 2];
15725
+ tl.to(ps[n], { opacity: 1, duration: 0.15 }, 9.6 + n * 0.35);
15726
+ tl.to(ps[n], { x: d[0], y: d[1], duration: 0.9, ease: "none", repeat: 2 }, 9.6 + n * 0.35);
15727
+ tl.to(ps[n], { opacity: 0, duration: 0.2 }, 13.1);
15728
+ }
15729
+
15730
+ // C4 13.4-17.8 "Only those run, so compute drops by half." \u2014 the consequence, as a number that moves.
15731
+ tl.to("#SCENEID-saving", { opacity: 1, y: 0, duration: 0.5, ease: "power3.out" }, 13.5);
15732
+ tl.to("#SCENEID-count", { textContent: 50, snap: { textContent: 1 }, duration: 1.8, ease: "power2.inOut" }, 13.7);
15733
+ tl.to(["#SCENEID-box0", "#SCENEID-box2"], { stroke: "{{accent}}", fillOpacity: 0.32, duration: 0.5 }, 13.7);
15734
+ tl.to(["#SCENEID-bar0", "#SCENEID-bar2"], { opacity: 0.55, duration: 0.45, yoyo: true, repeat: 3, ease: "sine.inOut" }, 15.6);
15735
+ // The end frame is the summary: everything back at full strength; the winners keep their accent.
15736
+ tl.to(["#SCENEID-expert1", "#SCENEID-expert3", "#SCENEID-wire1", "#SCENEID-wire3"], { opacity: 1, duration: 0.5 }, 16.6);`
15737
+ }
15738
+ };
15739
+ var SLABS = Array.from({ length: 12 }, (_, i) => 70 + i * 132);
15740
+ var INSIDE = "translate(634.5 259.5) scale(0.5)";
15741
+ var zoom = {
15742
+ name: "zoom",
15743
+ shows: "structure then detail: a staggered build of the whole, a CAMERA zoom (viewBox tween) into one part whose inside is drawn at its own scale, a draw that explains it, and a zoom back out where a pulse runs through every part",
15744
+ fits: ["stack", "grid", "annotated-figure", "claim-figure", "layers", "pipeline"],
15745
+ cues: [
15746
+ { t0: 1, t1: 4.8, text: "The model is a stack of twelve identical blocks." },
15747
+ { t0: 4.8, t1: 9.2, text: "Inside each one, attention" },
15748
+ { t0: 9.2, t1: 13.6, text: "lets every token read from every other." },
15749
+ { t0: 13.6, t1: 18, text: "Then the next block does it again, twelve times over." }
15750
+ ],
15751
+ duration: 19.2,
15752
+ fragment: {
15753
+ markup: `<svg id="SCENEID-svg" width="1700" height="732" viewBox="0 0 1700 732" style="position:absolute;left:0;top:0;overflow:hidden">
15754
+ <g id="SCENEID-stack" data-cue="1">
15755
+ ${SLABS.map(
15756
+ (x, i) => ` <rect id="SCENEID-slab${i}" x="${x}" y="150" width="104" height="440" rx="18" fill="{{panel}}" stroke="{{${i === 5 ? "accent" : "rule"}}}" stroke-width="5"/>`
15757
+ ).join("\n")}
15758
+ </g>
15759
+ <g id="SCENEID-count" data-cue="1">
15760
+ <text id="SCENEID-times" x="850" y="60" font-size="96" font-weight="700" fill="{{fg}}" text-anchor="middle" dominant-baseline="middle">\xD7 12</text>
15761
+ <text id="SCENEID-in" x="122" y="664" font-size="48" fill="{{muted}}" text-anchor="middle" dominant-baseline="middle">input</text>
15762
+ <text id="SCENEID-out" x="1574" y="664" font-size="48" fill="{{muted}}" text-anchor="middle" dominant-baseline="middle">output</text>
15763
+ </g>
15764
+ <g id="SCENEID-inside" data-cue="2" transform="${INSIDE}">
15765
+ <text id="SCENEID-att" x="295" y="40" font-size="64" font-weight="700" fill="{{accent}}" text-anchor="middle" dominant-baseline="middle">Attention</text>
15766
+ ${[0, 1, 2, 3, 4].map((i) => ` <circle id="SCENEID-q${i}" cx="${55 + i * 120}" cy="150" r="34" fill="{{${"abcda"[i]}}}"/>`).join("\n")}
15767
+ ${[0, 1, 2, 3, 4].map((i) => ` <circle id="SCENEID-k${i}" cx="${55 + i * 120}" cy="400" r="34" fill="{{${"abcda"[i]}}}"/>`).join("\n")}
15768
+ </g>
15769
+ <g id="SCENEID-links" data-cue="3" transform="${INSIDE}">
15770
+ ${[0, 1, 2, 3, 4].flatMap(
15771
+ (a) => [0, 1, 2, 3, 4].map(
15772
+ (b) => ` <line id="SCENEID-l${a}${b}" x1="${55 + a * 120}" y1="186" x2="${55 + b * 120}" y2="364" stroke="{{accent}}" stroke-width="${a === 2 ? 7 : 4}" stroke-linecap="round"/>`
15773
+ )
15774
+ ).join("\n")}
15775
+ </g>
15776
+ <g id="SCENEID-pulse" data-cue="4">
15777
+ ${SLABS.map((x, i) => ` <rect id="SCENEID-glow${i}" x="${x}" y="150" width="104" height="440" rx="18" fill="{{accent}}"/>`).join("\n")}
15778
+ </g>
15779
+ </svg>`,
15780
+ css: "",
15781
+ script: `// Layout (box 1700x732): "\xD7 12" centre top \xB7 12 slabs x 70-1626, y 150-590 \xB7 input/output
15782
+ // under the ends. Block 6 (x 730-834) has an inside drawn in its own units at half scale
15783
+ // (x 645-919, y 263-477): only legible with the camera in (viewBox 482 240.8 600 258.4,
15784
+ // 2.83x), so it shows only then. overflow:hidden on the svg keeps the zoomed view in its box.
15785
+ var slabs = [], glows = [];
15786
+ for (var i = 0; i < 12; i++) {
15787
+ slabs.push("#SCENEID-slab" + i);
15788
+ glows.push("#SCENEID-glow" + i);
15789
+ }
15790
+ var links = root.querySelectorAll("#SCENEID-links line");
15791
+ var row3 = ["#SCENEID-l20", "#SCENEID-l21", "#SCENEID-l22", "#SCENEID-l23", "#SCENEID-l24"];
15792
+ var labels = ["#SCENEID-times", "#SCENEID-in", "#SCENEID-out"];
15793
+ var wide = "0 0 1700 732";
15794
+ var close = "482 240.8 600 258.4";
15795
+
15796
+ gsap.set("#SCENEID-svg", { attr: { viewBox: wide } });
15797
+ gsap.set(slabs, { scaleY: 0, transformOrigin: "50% 100%" });
15798
+ gsap.set(labels, { opacity: 0 });
15799
+ gsap.set("#SCENEID-inside", { opacity: 0 });
15800
+ gsap.set(links, { drawSVG: "0% 0%", opacity: 0 });
15801
+ gsap.set(glows, { opacity: 0 });
15802
+
15803
+ // C1 1.0-4.8 "a stack of twelve identical blocks." \u2014 a staggered build, then the count lands.
15804
+ tl.to(slabs, { scaleY: 1, duration: 0.6, ease: "expo.out", stagger: 0.12 }, 1.0);
15805
+ tl.to(["#SCENEID-in", "#SCENEID-out"], { opacity: 1, duration: 0.4 }, 2.4);
15806
+ tl.to("#SCENEID-times", { opacity: 1, duration: 0.5 }, 2.9);
15807
+ tl.to(slabs, { y: -10, duration: 0.3, yoyo: true, repeat: 1, ease: "sine.inOut", stagger: 0.08 }, 3.4);
15808
+
15809
+ // C2 4.8-9.2 "Inside each one, attention" \u2014 the camera flies into block 6; the others become ghosts.
15810
+ tl.to(labels, { opacity: 0, duration: 0.3 }, 4.85);
15811
+ tl.to("#SCENEID-svg", { attr: { viewBox: close }, duration: 1.4, ease: "power3.inOut" }, 4.9);
15812
+ tl.to(slabs, { opacity: 0.15, duration: 0.5 }, 5.9);
15813
+ tl.to("#SCENEID-inside", { opacity: 1, duration: 0.5 }, 6.2);
15814
+ tl.to("#SCENEID-att", { scale: 1.08, transformOrigin: "50% 50%", duration: 0.4, yoyo: true, repeat: 3, ease: "sine.inOut" }, 7.2);
15815
+
15816
+ // C3 9.2-13.6 "lets every token read from every other." \u2014 every link draws; one token's reads are the focus.
15817
+ tl.to(links, { opacity: 0.35, duration: 0.01 }, 9.25);
15818
+ tl.to(links, { drawSVG: "0% 100%", duration: 0.6, ease: "power2.out", stagger: 0.06 }, 9.3);
15819
+ tl.to(row3, { opacity: 1, duration: 0.4 }, 11.4);
15820
+ tl.to("#SCENEID-q2", { scale: 1.3, transformOrigin: "50% 50%", duration: 0.4, yoyo: true, repeat: 3 }, 11.6);
15821
+
15822
+ // C4 13.6-18.0 "the next block does it again, twelve times over." \u2014 out to the whole; block after block lights.
15823
+ tl.to(["#SCENEID-inside", "#SCENEID-links"], { opacity: 0, duration: 0.3 }, 13.65);
15824
+ tl.to(slabs, { opacity: 1, duration: 0.4 }, 13.9);
15825
+ tl.to("#SCENEID-svg", { attr: { viewBox: wide }, duration: 1.3, ease: "power3.inOut" }, 13.9);
15826
+ tl.to(labels, { opacity: 1, duration: 0.4 }, 15.0);
15827
+ tl.to(glows, { opacity: 0.5, duration: 0.3, ease: "power2.out", stagger: 0.14 }, 15.3);`
15828
+ }
15829
+ };
15830
+ var SPREAD = Array.from({ length: 24 }, (_, i) => {
15831
+ const h = (n3) => {
15832
+ const s = Math.sin(n3 * 12.9898) * 43758.5453;
15833
+ return s - Math.floor(s);
15834
+ };
15835
+ return { x: Math.round(160 + h(i + 1) * 1380), y: Math.round(210 + h(i + 31) * 380) };
15836
+ });
15837
+ var BELL = (() => {
15838
+ const counts = [1, 2, 3, 4, 4, 4, 3, 2, 1];
15839
+ const slots2 = [];
15840
+ counts.forEach((c, b) => {
15841
+ for (let k = 0; k < c; k++) slots2.push([850 + (b - 4) * 150, 596 - k * 68]);
15842
+ });
15843
+ const byX = SPREAD.map((p, i) => ({ i, x: p.x })).sort((a, b) => a.x - b.x);
15844
+ const to = new Array(24);
15845
+ byX.forEach((d, rank) => {
15846
+ to[d.i] = slots2[rank];
15847
+ });
15848
+ return to;
15849
+ })();
15850
+ var OUTLIERS = [0, 7, 14, 21];
15851
+ var gather = {
15852
+ name: "gather",
15853
+ shows: "one set of objects keeps its identity through a change of state: a scatter whose outliers are singled out, then every dot TRAVELS (staggered) into a bell on a shared axis, a band marks one standard deviation and a counter measures the spread shrinking",
15854
+ fits: ["split-compare", "bar-compare", "grid", "claim-figure", "definition", "concept"],
15855
+ cues: [
15856
+ { t0: 1, t1: 4.8, text: "Without normalization, activations spread wide," },
15857
+ { t0: 4.8, t1: 9, text: "and a few outliers dominate every update." },
15858
+ { t0: 9, t1: 13.4, text: "Normalizing reshapes them onto one standard scale," },
15859
+ { t0: 13.4, t1: 17.6, text: "cutting the spread from three point two to one." }
15860
+ ],
15861
+ duration: 18.8,
15862
+ fragment: {
15863
+ markup: `<svg id="SCENEID-svg" width="1700" height="732" viewBox="0 0 1700 732" style="position:absolute;left:0;top:0;overflow:visible">
15864
+ <g id="SCENEID-scale" data-cue="1">
15865
+ <line id="SCENEID-axis" x1="120" y1="652" x2="1580" y2="652" stroke="{{muted}}" stroke-width="5" stroke-linecap="round"/>
15866
+ <text id="SCENEID-lo" x="120" y="702" font-size="48" fill="{{muted}}" text-anchor="middle" dominant-baseline="middle">\u22123\u03C3</text>
15867
+ <text id="SCENEID-mid" x="850" y="702" font-size="48" fill="{{muted}}" text-anchor="middle" dominant-baseline="middle">0</text>
15868
+ <text id="SCENEID-hi" x="1580" y="702" font-size="48" fill="{{muted}}" text-anchor="middle" dominant-baseline="middle">+3\u03C3</text>
15869
+ <text id="SCENEID-sigma" x="850" y="60" font-size="96" font-weight="700" fill="{{fg}}" text-anchor="end" dominant-baseline="middle">\u03C3 =</text>
15870
+ <text id="SCENEID-sigma-n" x="880" y="60" font-size="96" font-weight="700" fill="{{fg}}" dominant-baseline="middle">3.2</text>
15871
+ </g>
15872
+ <g id="SCENEID-band" data-cue="4">
15873
+ <rect id="SCENEID-band-fill" x="700" y="130" width="300" height="500" rx="16" fill="{{accent}}" fill-opacity="0.16"/>
15874
+ </g>
15875
+ <g id="SCENEID-dots" data-cue="1">
15876
+ ${SPREAD.map((p, i) => ` <circle id="SCENEID-d${i}" cx="${p.x}" cy="${p.y}" r="${OUTLIERS.includes(i) ? 36 : 26}" fill="{{${OUTLIERS.includes(i) ? "d" : "a"}}}"/>`).join("\n")}
15877
+ </g>
15878
+ <g id="SCENEID-outliers" data-cue="2">
15879
+ ${OUTLIERS.map((i, k) => ` <circle id="SCENEID-ring${k}" cx="${SPREAD[i]?.x}" cy="${SPREAD[i]?.y}" r="54" fill="none" stroke="{{d}}" stroke-width="6"/>`).join("\n")}
15880
+ <text id="SCENEID-outlier-label" x="1600" y="60" font-size="52" fill="{{d}}" text-anchor="end" dominant-baseline="middle">outliers</text>
15881
+ </g>
15882
+ <g id="SCENEID-norm" data-cue="3">
15883
+ <text id="SCENEID-norm-label" x="200" y="60" font-size="52" fill="{{accent}}" dominant-baseline="middle">normalized</text>
15884
+ </g>
15885
+ <g id="SCENEID-band-tag" data-cue="4">
15886
+ <text id="SCENEID-band-label" x="850" y="176" font-size="64" font-weight="700" fill="{{accent}}" text-anchor="middle" dominant-baseline="middle">\xB11\u03C3</text>
15887
+ </g>
15888
+ </svg>`,
15889
+ css: "",
15890
+ script: `// Layout (box 1700x732): \u03C3 readout top centre \xB7 scatter x 150-1550, y 200-600 \xB7 axis at
15891
+ // y 652 (x 120-1580) with \u22123\u03C3/0/+3\u03C3 under it. The same 24 dots travel into a bell of
15892
+ // 9 bins on that axis (x 250-1450, stacks up from y 600) \u2014 one set changing state,
15893
+ // not two pictures \u2014 and a \xB11\u03C3 band (x 700-1000) lands behind them.
15894
+ var dots = [], rest = [], rings = [];
15895
+ for (var i = 0; i < 24; i++) {
15896
+ dots.push("#SCENEID-d" + i);
15897
+ if (i % 7 !== 0) rest.push("#SCENEID-d" + i);
15898
+ }
15899
+ for (var k = 0; k < 4; k++) rings.push("#SCENEID-ring" + k);
15900
+ var from = ${JSON.stringify(SPREAD.map((p) => [p.x, p.y]))};
15901
+ var to = ${JSON.stringify(BELL)};
15902
+
15903
+ gsap.set(dots, { scale: 0, transformOrigin: "50% 50%", x: 0, y: 0 });
15904
+ gsap.set("#SCENEID-axis", { drawSVG: "50% 50%" });
15905
+ gsap.set(["#SCENEID-lo", "#SCENEID-mid", "#SCENEID-hi", "#SCENEID-sigma", "#SCENEID-sigma-n"], { opacity: 0 });
15906
+ gsap.set(rings, { drawSVG: "0% 0%", opacity: 0 });
15907
+ gsap.set("#SCENEID-outlier-label", { opacity: 0 });
15908
+ gsap.set("#SCENEID-norm-label", { opacity: 0, x: -40 });
15909
+ gsap.set("#SCENEID-band-fill", { scaleX: 0, transformOrigin: "50% 50%" });
15910
+ gsap.set("#SCENEID-band-label", { opacity: 0 });
15911
+
15912
+ // C1 1.0-4.8 "activations spread wide," \u2014 the scatter lands, the scale opens under it.
15913
+ tl.to(dots, { scale: 1, duration: 0.45, ease: "back.out(2)", stagger: 0.06 }, 1.0);
15914
+ tl.to("#SCENEID-axis", { drawSVG: "0% 100%", duration: 1.1, ease: "power3.out" }, 2.4);
15915
+ tl.to(["#SCENEID-lo", "#SCENEID-mid", "#SCENEID-hi"], { opacity: 1, duration: 0.4 }, 2.9);
15916
+ tl.to(["#SCENEID-sigma", "#SCENEID-sigma-n"], { opacity: 1, duration: 0.4 }, 3.1);
15917
+ tl.to(dots, { y: -8, duration: 0.5, yoyo: true, repeat: 1, ease: "sine.inOut", stagger: 0.03 }, 3.6);
15918
+
15919
+ // C2 4.8-9.0 "a few outliers dominate every update." \u2014 focus: rings draw on the four, the rest dim.
15920
+ tl.to(rings, { opacity: 1, duration: 0.01, stagger: 0.2 }, 4.9);
15921
+ tl.to(rings, { drawSVG: "0% 100%", duration: 0.6, ease: "power2.out", stagger: 0.2 }, 4.9);
15922
+ tl.to("#SCENEID-outlier-label", { opacity: 1, duration: 0.4 }, 5.6);
15923
+ tl.to(rest, { opacity: 0.3, duration: 0.5 }, 6.0);
15924
+ tl.to(rings, { scale: 1.15, transformOrigin: "50% 50%", duration: 0.4, yoyo: true, repeat: 3, ease: "sine.inOut" }, 6.8);
15925
+
15926
+ // C3 9.0-13.4 "Normalizing reshapes them onto one standard scale," \u2014 every dot travels into the bell.
15927
+ tl.to(rings.concat(["#SCENEID-outlier-label"]), { opacity: 0, duration: 0.4 }, 9.05);
15928
+ tl.to(rest, { opacity: 1, duration: 0.4 }, 9.1);
15929
+ tl.to("#SCENEID-norm-label", { opacity: 1, x: 0, duration: 0.5, ease: "power3.out" }, 9.2);
15930
+ for (var n = 0; n < 24; n++) {
15931
+ tl.to(dots[n], { x: to[n][0] - from[n][0], y: to[n][1] - from[n][1], attr: { r: 30 }, fill: "{{accent}}", duration: 1.2, ease: "power3.inOut" }, 9.5 + n * 0.07);
15932
+ }
15933
+
15934
+ // C4 13.4-17.6 "cutting the spread from three point two to one." \u2014 the band opens, the number follows it.
15935
+ tl.to("#SCENEID-band-fill", { scaleX: 1, duration: 0.9, ease: "power3.out" }, 13.5);
15936
+ tl.to("#SCENEID-band-label", { opacity: 1, duration: 0.4 }, 14.1);
15937
+ tl.to("#SCENEID-sigma-n", { textContent: 1, snap: { textContent: 0.1 }, fill: "{{accent}}", duration: 1.4, ease: "power3.inOut" }, 13.6);
15938
+ tl.to(dots, { scale: 1.12, duration: 0.35, yoyo: true, repeat: 3, ease: "sine.inOut", stagger: 0.02 }, 15.3);`
15939
+ }
15940
+ };
15941
+
15942
+ // src/bespoke/untangle.ts
15943
+ import { parse as parse2 } from "acorn";
14708
15944
 
14709
15945
  // src/index.ts
14710
15946
  async function buildDeck(storyboard, source, outDir, opts = {}) {
@@ -14712,8 +15948,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
14712
15948
  if (!format) throw new Error("no deck-16x9 format");
14713
15949
  const step = opts.onStep ?? (() => {
14714
15950
  });
14715
- const out = resolve6(outDir);
14716
- await mkdir10(out, { recursive: true });
15951
+ const out = resolve7(outDir);
15952
+ await mkdir11(out, { recursive: true });
14717
15953
  const speed = opts.speed ?? 1;
14718
15954
  const design2 = opts.design ?? storyboard.design ?? "classic";
14719
15955
  const theme = pickTheme(storyboard, source, format, {
@@ -14732,12 +15968,13 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
14732
15968
  ...opts.narration ? { narration: opts.narration } : {},
14733
15969
  ...opts.onBeatError ? { onBeatError: opts.onBeatError } : {},
14734
15970
  ...opts.onBeatWarning ? { onBeatWarning: opts.onBeatWarning } : {},
14735
- ...fontCss ? { fontCss } : {}
15971
+ ...fontCss ? { fontCss } : {},
15972
+ ...opts.bespoke ? { bespoke: opts.bespoke } : {}
14736
15973
  });
14737
15974
  const files = [];
14738
15975
  const write = async (name, text2) => {
14739
- await writeFile12(join13(out, name), text2);
14740
- files.push(join13(out, name));
15976
+ await writeFile13(join14(out, name), text2);
15977
+ files.push(join14(out, name));
14741
15978
  };
14742
15979
  await write("index.html", deck.composition);
14743
15980
  await write("hyperframes.json", HYPERFRAMES_JSON);
@@ -14756,7 +15993,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
14756
15993
  beats: deck.cut.kept,
14757
15994
  speed,
14758
15995
  ...opts.narration ? { narration: opts.narration } : {},
14759
- ...opts.theme ? { theme: opts.theme } : {}
15996
+ ...opts.theme ? { theme: opts.theme } : {},
15997
+ ...opts.bespoke ? { bespoke: opts.bespoke } : {}
14760
15998
  });
14761
15999
  await write(TIMING_FILE, `${JSON.stringify(timing, null, 2)}
14762
16000
  `);
@@ -14768,20 +16006,21 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
14768
16006
  }
14769
16007
  if (deck.page) {
14770
16008
  await write(DECK_PAGE, deck.page);
14771
- await cp2(playerBundle(), join13(out, PLAYER_FILE));
14772
- files.push(join13(out, PLAYER_FILE));
16009
+ await cp2(playerBundle(), join14(out, PLAYER_FILE));
16010
+ files.push(join14(out, PLAYER_FILE));
14773
16011
  }
14774
16012
  files.push(...await vendorKatex(out));
14775
16013
  files.push(...await vendorScripts(out, deck.composition));
14776
16014
  if (opts.assetsFrom) {
14777
16015
  files.push(...await copyAssets(opts.assetsFrom, out, source.figures, step));
14778
16016
  }
16017
+ if (opts.bespoke) files.push(...await copyArt(Object.values(opts.bespoke), out));
14779
16018
  if (opts.narration && opts.audioFrom) {
14780
16019
  files.push(...await copyAudio(opts.audioFrom, opts.narration, out, step));
14781
16020
  }
14782
16021
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
14783
16022
  step(
14784
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join13(out, "index.html")}`
16023
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join14(out, "index.html")}`
14785
16024
  );
14786
16025
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
14787
16026
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -14799,7 +16038,7 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
14799
16038
  async function deckRuntime() {
14800
16039
  const path2 = fileURLToPath2(new URL(`./${"deck-runtime.js"}`, import.meta.url));
14801
16040
  try {
14802
- return await readFile12(path2, "utf8");
16041
+ return await readFile13(path2, "utf8");
14803
16042
  } catch {
14804
16043
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
14805
16044
  }
@@ -14807,14 +16046,14 @@ async function deckRuntime() {
14807
16046
  function playerBundle() {
14808
16047
  const require2 = createRequire4(import.meta.url);
14809
16048
  try {
14810
- return join13(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
16049
+ return join14(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
14811
16050
  } catch {
14812
16051
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
14813
16052
  }
14814
16053
  }
14815
16054
 
14816
16055
  // src/server/upload.ts
14817
- import { posix, sep as sep2 } from "node:path";
16056
+ import { posix, sep as sep3 } from "node:path";
14818
16057
  import { unzipSync as unzipSync2 } from "fflate";
14819
16058
  var MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
14820
16059
  var ZIP_LIMITS = {
@@ -14852,7 +16091,7 @@ function safeEntryPath(name) {
14852
16091
  return path2;
14853
16092
  }
14854
16093
  function insideRoot2(root2, joined) {
14855
- return joined === root2 || joined.startsWith(root2.endsWith(sep2) ? root2 : root2 + sep2);
16094
+ return joined === root2 || joined.startsWith(root2.endsWith(sep3) ? root2 : root2 + sep3);
14856
16095
  }
14857
16096
  function readZip(bytes, limits = ZIP_LIMITS) {
14858
16097
  const seen = [];
@@ -15156,8 +16395,8 @@ function int(name, raw2) {
15156
16395
  }
15157
16396
 
15158
16397
  // src/server/pipeline.ts
15159
- import { mkdir as mkdir11, readFile as readFile13, stat as stat3, writeFile as writeFile13 } from "node:fs/promises";
15160
- import { dirname as dirname6, isAbsolute, join as join14, relative, resolve as resolve7 } from "node:path";
16398
+ import { mkdir as mkdir12, readFile as readFile14, stat as stat4, writeFile as writeFile14 } from "node:fs/promises";
16399
+ import { dirname as dirname6, isAbsolute, join as join15, relative, resolve as resolve8 } from "node:path";
15161
16400
  var AUDIO_DIR = "audio";
15162
16401
  var NARRATION_FILE = "narration.json";
15163
16402
  var MAX_REMOTE_FIGURES = 40;
@@ -15180,10 +16419,10 @@ function stagesFor(options) {
15180
16419
  async function runPipeline(job, input) {
15181
16420
  const { options } = input;
15182
16421
  const dirs = {
15183
- upload: join14(job.dir, "upload"),
15184
- src: join14(job.dir, "src"),
15185
- audio: join14(job.dir, AUDIO_DIR),
15186
- deck: join14(job.dir, "deck")
16422
+ upload: join15(job.dir, "upload"),
16423
+ src: join15(job.dir, "src"),
16424
+ audio: join15(job.dir, AUDIO_DIR),
16425
+ deck: join15(job.dir, "deck")
15187
16426
  };
15188
16427
  const warnings = [...options.warnings];
15189
16428
  const url = (rel) => `/d/${job.id}/${rel}`;
@@ -15207,21 +16446,21 @@ async function runPipeline(job, input) {
15207
16446
  theme: options.stated.theme ? prefs.theme : planned.theme
15208
16447
  };
15209
16448
  assertRefsResolve(storyboard, source, { pending: options.images ? "allow" : "refuse" });
15210
- await writeJson(join14(job.dir, "storyboard.json"), storyboard);
16449
+ await writeJson(join15(job.dir, "storyboard.json"), storyboard);
15211
16450
  job.done("plan", `${storyboard.beats.length} beats`);
15212
16451
  for (const f of scanBeatCount(storyboard, prefs)) warnings.push(f.message);
15213
16452
  if (options.images) {
15214
16453
  job.begin("illustrate");
15215
16454
  const drawn = await illustrate(storyboard, source, {
15216
16455
  prefs,
15217
- assetsDir: join14(dirs.src, "assets"),
16456
+ assetsDir: join15(dirs.src, "assets"),
15218
16457
  ...input.imageChain ? { chain: input.imageChain } : {},
15219
16458
  onStep: (line2) => job.log(line2)
15220
16459
  });
15221
16460
  storyboard = drawn.storyboard;
15222
16461
  source = drawn.source;
15223
- await writeJson(join14(dirs.src, "source.json"), source);
15224
- await writeJson(join14(job.dir, "storyboard.json"), storyboard);
16462
+ await writeJson(join15(dirs.src, "source.json"), source);
16463
+ await writeJson(join15(job.dir, "storyboard.json"), storyboard);
15225
16464
  for (const p of drawn.illustrated) {
15226
16465
  job.log(
15227
16466
  `illustrate: ${p.beatId} \u2192 assets/${p.src} via ${p.provider}${p.cached ? " (cached)" : ""}`
@@ -15243,7 +16482,7 @@ async function runPipeline(job, input) {
15243
16482
  warnings.push("the plan had nothing to say aloud, so the deck is silent");
15244
16483
  } else {
15245
16484
  job.log(`narrate: ${speaking} of ${storyboard.beats.length} beats speak`);
15246
- await mkdir11(dirs.audio, { recursive: true });
16485
+ await mkdir12(dirs.audio, { recursive: true });
15247
16486
  const staged = pickTheme(storyboard, source, options.format, {
15248
16487
  stated: options.stated.theme ? prefs.theme : void 0,
15249
16488
  design: prefs.design ?? storyboard.design ?? "classic",
@@ -15254,7 +16493,7 @@ async function runPipeline(job, input) {
15254
16493
  dir: dirs.audio,
15255
16494
  format: options.format
15256
16495
  });
15257
- await writeJson(join14(dirs.audio, NARRATION_FILE), spoken);
16496
+ await writeJson(join15(dirs.audio, NARRATION_FILE), spoken);
15258
16497
  narration = { ...spoken, dir: AUDIO_DIR };
15259
16498
  const segments2 = Object.values(spoken.beats).flat();
15260
16499
  const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
@@ -15312,7 +16551,7 @@ async function runPipeline(job, input) {
15312
16551
  job.log("render: capturing frames \u2014 two minutes for a four-minute video");
15313
16552
  const out = await render({
15314
16553
  deck: dirs.deck,
15315
- out: join14(dirs.deck, "video.mp4"),
16554
+ out: join15(dirs.deck, "video.mp4"),
15316
16555
  subtitles: "sidecar",
15317
16556
  log: (line2) => job.log(line2),
15318
16557
  // The last of the three length levers, and the only one that acts on a
@@ -15361,8 +16600,8 @@ async function runPipeline(job, input) {
15361
16600
  };
15362
16601
  }
15363
16602
  async function ingest(job, input, dirs, warnings) {
15364
- await mkdir11(dirs.upload, { recursive: true });
15365
- const root2 = resolve7(dirs.upload);
16603
+ await mkdir12(dirs.upload, { recursive: true });
16604
+ const root2 = resolve8(dirs.upload);
15366
16605
  let docPath;
15367
16606
  const upload = input.upload;
15368
16607
  const bothOrNeither = new UploadError(
@@ -15372,13 +16611,13 @@ async function ingest(job, input, dirs, warnings) {
15372
16611
  );
15373
16612
  if (input.url !== void 0) {
15374
16613
  if (upload !== void 0) throw bothOrNeither;
15375
- const harvested = await harvestBounded(input.url, join14(root2, "assets"), {
16614
+ const harvested = await harvestBounded(input.url, join15(root2, "assets"), {
15376
16615
  ...HARVEST_LIMITS,
15377
16616
  ...input.harvest
15378
16617
  });
15379
16618
  warnings.push(...harvested.warnings);
15380
- docPath = join14(root2, "document.md");
15381
- await writeFile13(docPath, harvested.markdown);
16619
+ docPath = join15(root2, "document.md");
16620
+ await writeFile14(docPath, harvested.markdown);
15382
16621
  job.log(
15383
16622
  `ingest: harvested ${input.url} \u2014 "${harvested.title}", ${harvested.assets.length} asset(s)`
15384
16623
  );
@@ -15394,13 +16633,13 @@ async function ingest(job, input, dirs, warnings) {
15394
16633
  const { files, warnings: zipWarnings } = readZip(upload.bytes);
15395
16634
  warnings.push(...zipWarnings);
15396
16635
  for (const [rel, bytes] of Object.entries(files)) {
15397
- const to = resolve7(join14(root2, rel));
16636
+ const to = resolve8(join15(root2, rel));
15398
16637
  if (!insideRoot2(root2, to))
15399
16638
  throw new UploadError(`Refusing to write ${rel}.`, "Re-zip from inside the folder.");
15400
- await mkdir11(dirname6(to), { recursive: true });
15401
- await writeFile13(to, bytes);
16639
+ await mkdir12(dirname6(to), { recursive: true });
16640
+ await writeFile14(to, bytes);
15402
16641
  }
15403
- docPath = join14(root2, pickMarkdown(files));
16642
+ docPath = join15(root2, pickMarkdown(files));
15404
16643
  job.log(
15405
16644
  `ingest: unpacked ${Object.keys(files).length} file(s), reading ${pickMarkdown(files)}`
15406
16645
  );
@@ -15412,10 +16651,10 @@ async function ingest(job, input, dirs, warnings) {
15412
16651
  `DeckSmith reads ${MARKDOWN_EXTS.join(", ")} or a .zip containing one. Export the document to markdown first.`
15413
16652
  );
15414
16653
  }
15415
- docPath = join14(root2, "document.md");
15416
- await writeFile13(docPath, upload.bytes);
16654
+ docPath = join15(root2, "document.md");
16655
+ await writeFile14(docPath, upload.bytes);
15417
16656
  }
15418
- const text2 = await readFile13(docPath, "utf8");
16657
+ const text2 = await readFile14(docPath, "utf8");
15419
16658
  const parsed = parseMarkdown(text2, {
15420
16659
  lang: input.options.stated.lang ? input.options.prefs.lang : void 0
15421
16660
  });
@@ -15435,8 +16674,8 @@ async function ingest(job, input, dirs, warnings) {
15435
16674
  `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations, ${parsed.tables.length} tables`
15436
16675
  );
15437
16676
  const guarded = await guardFigures(parsed, root2, input.fetchRemoteFigures, warnings);
15438
- const source = await fetchFigures(guarded, join14(resolve7(dirs.src), "assets"), warnings);
15439
- await writeJson(join14(dirs.src, "source.json"), source);
16677
+ const source = await fetchFigures(guarded, join15(resolve8(dirs.src), "assets"), warnings);
16678
+ await writeJson(join15(dirs.src, "source.json"), source);
15440
16679
  return source;
15441
16680
  }
15442
16681
  async function harvestBounded(url, dir, opts) {
@@ -15512,12 +16751,12 @@ async function guardFigures(source, root2, allowRemote, warnings) {
15512
16751
  warnings.push(`figure ${figure.id} was left out: "${scheme}:" figures are not read`);
15513
16752
  continue;
15514
16753
  }
15515
- const abs = isAbsolute(src) ? resolve7(src) : resolve7(join14(root2, src.replace(/^\.?\//, "")));
16754
+ const abs = isAbsolute(src) ? resolve8(src) : resolve8(join15(root2, src.replace(/^\.?\//, "")));
15516
16755
  if (!insideRoot2(root2, abs)) {
15517
16756
  warnings.push(`figure ${figure.id} was left out: "${src}" points outside the upload`);
15518
16757
  continue;
15519
16758
  }
15520
- if (!(await stat3(abs).catch(() => null))?.isFile()) {
16759
+ if (!(await stat4(abs).catch(() => null))?.isFile()) {
15521
16760
  warnings.push(`figure ${figure.id} was left out: "${src}" is not in the upload`);
15522
16761
  continue;
15523
16762
  }
@@ -15527,19 +16766,19 @@ async function guardFigures(source, root2, allowRemote, warnings) {
15527
16766
  }
15528
16767
  async function pack2(job, ctx) {
15529
16768
  try {
15530
- const assets = join14(resolve7(ctx.dirs.src), "assets");
16769
+ const assets = join15(resolve8(ctx.dirs.src), "assets");
15531
16770
  const requests = ctx.source.figures.map((f) => ({
15532
16771
  id: f.id,
15533
- url: join14(assets, f.src),
16772
+ url: join15(assets, f.src),
15534
16773
  prefer: "bake"
15535
16774
  }));
15536
16775
  const plan = await planMedia(requests, async (url) => ({
15537
- bytes: new Uint8Array(await readFile13(url))
16776
+ bytes: new Uint8Array(await readFile14(url))
15538
16777
  }));
15539
16778
  const files = { ...plan.files };
15540
16779
  if (ctx.narration) {
15541
16780
  for (const name of audioNames2(ctx.narration)) {
15542
- files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile13(join14(ctx.dirs.audio, name)));
16781
+ files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile14(join15(ctx.dirs.audio, name)));
15543
16782
  }
15544
16783
  }
15545
16784
  const container = {
@@ -15556,10 +16795,10 @@ async function pack2(job, ctx) {
15556
16795
  },
15557
16796
  source: ctx.source,
15558
16797
  storyboard: ctx.storyboard,
15559
- ...ctx.narration ? { narration: await readJson(join14(ctx.dirs.audio, NARRATION_FILE)) } : {},
16798
+ ...ctx.narration ? { narration: await readJson(join15(ctx.dirs.audio, NARRATION_FILE)) } : {},
15560
16799
  media: plan.media
15561
16800
  };
15562
- const bytes = await writePack(container, files, join14(ctx.dirs.deck, "deck.deck"));
16801
+ const bytes = await writePack(container, files, join15(ctx.dirs.deck, "deck.deck"));
15563
16802
  job.log(`pack: ${Math.round(bytes / 1024)} KB \u2192 deck.deck`);
15564
16803
  return ctx.url("deck.deck");
15565
16804
  } catch (err) {
@@ -15582,12 +16821,12 @@ function host(src) {
15582
16821
  }
15583
16822
  }
15584
16823
  async function writeJson(path2, value) {
15585
- await mkdir11(dirname6(path2), { recursive: true });
15586
- await writeFile13(path2, `${JSON.stringify(value, null, 2)}
16824
+ await mkdir12(dirname6(path2), { recursive: true });
16825
+ await writeFile14(path2, `${JSON.stringify(value, null, 2)}
15587
16826
  `);
15588
16827
  }
15589
16828
  async function readJson(path2) {
15590
- return JSON.parse(await readFile13(path2, "utf8"));
16829
+ return JSON.parse(await readFile14(path2, "utf8"));
15591
16830
  }
15592
16831
 
15593
16832
  // src/server/errors.ts
@@ -15993,14 +17232,14 @@ var PAGE_BUDGET = {
15993
17232
  refs: "relative"
15994
17233
  };
15995
17234
  async function readPage(url, work2) {
15996
- await mkdir12(work2, { recursive: true });
15997
- const dir = await mkdtemp3(join15(work2, "harvest-"));
17235
+ await mkdir13(work2, { recursive: true });
17236
+ const dir = await mkdtemp3(join16(work2, "harvest-"));
15998
17237
  try {
15999
17238
  const page = await harvest(url, dir, PAGE_BUDGET);
16000
17239
  const files = {
16001
17240
  "document.md": new TextEncoder().encode(page.markdown)
16002
17241
  };
16003
- for (const asset of page.assets) files[basename5(asset)] = await readFile14(asset);
17242
+ for (const asset of page.assets) files[basename5(asset)] = await readFile15(asset);
16004
17243
  const warnings = [...page.warnings];
16005
17244
  if (page.clips.length > 0) {
16006
17245
  warnings.push(
@@ -16034,7 +17273,7 @@ var statusSchema = z5.object({
16034
17273
  var DEFAULT_WAIT = 45;
16035
17274
  function deckTools(opts) {
16036
17275
  const queue = new Queue({ maxQueued: 8 });
16037
- const root2 = resolve8(opts.root);
17276
+ const root2 = resolve9(opts.root);
16038
17277
  let cached2;
16039
17278
  const found = async () => {
16040
17279
  cached2 ??= await (opts.probe ?? prereqs)();
@@ -16042,8 +17281,8 @@ function deckTools(opts) {
16042
17281
  };
16043
17282
  const insideRoot3 = (p) => {
16044
17283
  if (!isAbsolute2(p)) throw new Error(`document_path must be absolute; got "${p}".`);
16045
- const full = resolve8(p);
16046
- if (full !== root2 && !full.startsWith(root2 + sep3)) {
17284
+ const full = resolve9(p);
17285
+ if (full !== root2 && !full.startsWith(root2 + sep4)) {
16047
17286
  throw new Error(
16048
17287
  `document_path is outside the server's root. It may only read files under ${root2}.`
16049
17288
  );
@@ -16120,16 +17359,16 @@ function deckTools(opts) {
16120
17359
  }
16121
17360
  }
16122
17361
  const page = input.document_url === void 0 ? void 0 : await readPage(input.document_url, opts.work);
16123
- const filename = page ? "page.zip" : input.document_path ? input.document_path.split(sep3).pop() : "document.md";
16124
- const bytes = page ? page.zip : file ? await readFile14(file) : new TextEncoder().encode(input.document_text);
17362
+ const filename = page ? "page.zip" : input.document_path ? input.document_path.split(sep4).pop() : "document.md";
17363
+ const bytes = page ? page.zip : file ? await readFile15(file) : new TextEncoder().encode(input.document_text);
16125
17364
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
16126
17365
  throw new Error(
16127
17366
  `Document is ${(bytes.byteLength / 1e6).toFixed(1)} MB, over the ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB cap.` + (page ? " Harvest fewer figures with a smaller maxAssets, or save the page and edit it." : "")
16128
17367
  );
16129
17368
  }
16130
17369
  const id2 = randomBytes(16).toString("base64url");
16131
- const dir = join15(opts.work, id2);
16132
- await mkdir12(dir, { recursive: true });
17370
+ const dir = join16(opts.work, id2);
17371
+ await mkdir13(dir, { recursive: true });
16133
17372
  queue.submit({
16134
17373
  id: id2,
16135
17374
  dir,
@@ -16156,7 +17395,7 @@ function deckTools(opts) {
16156
17395
  };
16157
17396
  }
16158
17397
  function dirOf(work2, id2) {
16159
- return join15(work2, id2);
17398
+ return join16(work2, id2);
16160
17399
  }
16161
17400
  function waitFor(queue, id2, seconds) {
16162
17401
  const now = queue.view(id2);
@@ -16188,9 +17427,9 @@ function report(view, dir) {
16188
17427
  // (src/server/pipeline.ts). This said `src/storyboard.json` for one run, which
16189
17428
  // is the shape of the CLI's output directory and not the server's, and an
16190
17429
  // agent told to read it would have found nothing there.
16191
- storyboard_path: join15(dir, "storyboard.json"),
17430
+ storyboard_path: join16(dir, "storyboard.json"),
16192
17431
  ...done && view.result ? {
16193
- deck_path: join15(dir, "deck"),
17432
+ deck_path: join16(dir, "deck"),
16194
17433
  slides: view.result.slides,
16195
17434
  duration_seconds: view.result.duration,
16196
17435
  warnings: view.result.warnings
@@ -16199,7 +17438,7 @@ function report(view, dir) {
16199
17438
  };
16200
17439
  }
16201
17440
  function defaultWork() {
16202
- return join15(tmpdir4(), "decksmith-mcp");
17441
+ return join16(tmpdir4(), "decksmith-mcp");
16203
17442
  }
16204
17443
 
16205
17444
  // src/mcp/main.ts