@jokerized/decksmith 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,12 +1,247 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { cp, mkdir as mkdir9, readdir as readdir3, readFile as readFile16, stat, writeFile as writeFile11 } from "node:fs/promises";
4
+ import { cp, mkdir as mkdir10, mkdtemp as mkdtemp4, readdir as readdir4, readFile as readFile17, rm as rm9, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
5
5
  import { createRequire as createRequire3 } from "node:module";
6
- import { dirname as dirname4, join as join15, relative as relative2, resolve as resolve4 } from "node:path";
6
+ import { tmpdir as tmpdir4 } from "node:os";
7
+ import { dirname as dirname4, join as join16, relative as relative2, resolve as resolve5 } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { Command } from "commander";
9
10
 
11
+ // src/pack/media.ts
12
+ import { createHash } from "node:crypto";
13
+ import { readFile } from "node:fs/promises";
14
+ import { extname } from "node:path";
15
+ var PLAYERS = [
16
+ "youtube.com",
17
+ "youtube-nocookie.com",
18
+ "youtu.be",
19
+ "vimeo.com",
20
+ "dailymotion.com",
21
+ "dai.ly",
22
+ "twitch.tv",
23
+ "loom.com",
24
+ "wistia.com",
25
+ "wistia.net",
26
+ "streamable.com",
27
+ "bilibili.com",
28
+ "soundcloud.com",
29
+ "tiktok.com"
30
+ ];
31
+ var FILE_EXT = /* @__PURE__ */ new Set([
32
+ ".png",
33
+ ".jpg",
34
+ ".jpeg",
35
+ ".gif",
36
+ ".webp",
37
+ ".avif",
38
+ ".svg",
39
+ ".mp4",
40
+ ".webm",
41
+ ".mov",
42
+ ".m4v",
43
+ ".mp3",
44
+ ".m4a",
45
+ ".wav",
46
+ ".ogg",
47
+ ".opus",
48
+ ".pdf"
49
+ ]);
50
+ function isEmbed(url2) {
51
+ const host = hostOf(url2);
52
+ return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
53
+ }
54
+ function segments(u) {
55
+ return u.pathname.split("/").filter(Boolean);
56
+ }
57
+ function token(raw2) {
58
+ return raw2 && /^[A-Za-z0-9_-]{1,64}$/.test(raw2) ? raw2 : void 0;
59
+ }
60
+ var YOUTUBE_EMBED = "https://www.youtube-nocookie.com";
61
+ var youtube = (id2) => id2 === void 0 ? void 0 : `${YOUTUBE_EMBED}/embed/${id2}`;
62
+ var YOUTUBE = {
63
+ origin: YOUTUBE_EMBED,
64
+ embed: (u) => {
65
+ const seg = segments(u);
66
+ const path2 = seg[0];
67
+ return youtube(
68
+ token(
69
+ path2 === "watch" ? u.searchParams.get("v") : path2 === "embed" || path2 === "shorts" || path2 === "live" || path2 === "v" ? seg[1] : void 0
70
+ )
71
+ );
72
+ }
73
+ };
74
+ var YOUTU_BE = {
75
+ origin: YOUTUBE_EMBED,
76
+ embed: (u) => youtube(token(segments(u)[0]))
77
+ };
78
+ var VIMEO_EMBED = "https://player.vimeo.com";
79
+ var VIMEO = {
80
+ origin: VIMEO_EMBED,
81
+ embed: (u) => {
82
+ const seg = segments(u);
83
+ const [head, next] = seg[0] === "video" ? [seg[1], seg[2]] : [seg[0], seg[1]];
84
+ if (head === void 0 || !/^\d+$/.test(head)) return void 0;
85
+ const hash = token(next ?? u.searchParams.get("h"));
86
+ return `${VIMEO_EMBED}/video/${head}?${hash ? `h=${hash}&` : ""}dnt=1`;
87
+ }
88
+ };
89
+ var DAILYMOTION_EMBED = "https://www.dailymotion.com";
90
+ var dailymotion = (id2) => id2 === void 0 ? void 0 : `${DAILYMOTION_EMBED}/embed/video/${id2}`;
91
+ var DAILYMOTION = {
92
+ origin: DAILYMOTION_EMBED,
93
+ embed: (u) => {
94
+ const seg = segments(u);
95
+ return dailymotion(
96
+ token(seg[0] === "video" ? seg[1] : seg[0] === "embed" ? seg[2] : void 0)
97
+ );
98
+ }
99
+ };
100
+ var DAI_LY = {
101
+ origin: DAILYMOTION_EMBED,
102
+ embed: (u) => dailymotion(token(segments(u)[0]))
103
+ };
104
+ var LOOM = {
105
+ origin: "https://www.loom.com",
106
+ embed: (u) => {
107
+ const seg = segments(u);
108
+ const id2 = token(seg[0] === "share" || seg[0] === "embed" ? seg[1] : void 0);
109
+ return id2 === void 0 ? void 0 : `https://www.loom.com/embed/${id2}`;
110
+ }
111
+ };
112
+ var EMBEDS = {
113
+ "youtube.com": YOUTUBE,
114
+ "youtube-nocookie.com": YOUTUBE,
115
+ "youtu.be": YOUTU_BE,
116
+ "vimeo.com": VIMEO,
117
+ "dailymotion.com": DAILYMOTION,
118
+ "dai.ly": DAI_LY,
119
+ "loom.com": LOOM
120
+ };
121
+ var EMBED_ORIGINS = [
122
+ ...new Set(Object.values(EMBEDS).map((e) => e.origin))
123
+ ];
124
+ function embedUrl(url2) {
125
+ const host = hostOf(url2);
126
+ if (host === null) return void 0;
127
+ let u;
128
+ try {
129
+ u = new URL(url2);
130
+ } catch {
131
+ return void 0;
132
+ }
133
+ if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
134
+ const rule = Object.entries(EMBEDS).find(([h]) => host === h || host.endsWith(`.${h}`))?.[1];
135
+ return rule?.embed(u);
136
+ }
137
+ function policyFor(url2, prefer) {
138
+ if (isEmbed(url2)) return "embed";
139
+ const host = hostOf(url2);
140
+ if (host === null || /^data:/i.test(url2)) return "bake";
141
+ if (prefer === "link") return "link";
142
+ return FILE_EXT.has(extOf(url2)) ? "bake" : "link";
143
+ }
144
+ async function planMedia(assets, fetcher = fetchAsset) {
145
+ const plan = {
146
+ media: [],
147
+ files: {},
148
+ bakedCount: 0,
149
+ bakedBytes: 0,
150
+ demoted: [],
151
+ promoted: []
152
+ };
153
+ for (const asset of assets) {
154
+ const policy = policyFor(asset.url, asset.prefer);
155
+ if (policy !== "bake") {
156
+ if (asset.prefer === "bake") plan.demoted.push(asset.id);
157
+ plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
158
+ continue;
159
+ }
160
+ if (asset.prefer === "link") plan.promoted.push(asset.id);
161
+ const got = await fetcher(asset.url);
162
+ const mime = clean(got.mime) ?? asset.mime;
163
+ if (mime === "text/html") {
164
+ plan.demoted.push(asset.id);
165
+ plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
166
+ continue;
167
+ }
168
+ const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
169
+ plan.files[path2] = got.bytes;
170
+ plan.bakedCount += 1;
171
+ plan.bakedBytes += got.bytes.length;
172
+ plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
173
+ }
174
+ return plan;
175
+ }
176
+ function mediaSummary(plan) {
177
+ const linked = plan.media.filter((m) => m.policy === "link").length;
178
+ const embedded = plan.media.filter((m) => m.policy === "embed").length;
179
+ const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
180
+ if (linked) parts.push(`${linked} linked`);
181
+ if (embedded) parts.push(`${embedded} embedded`);
182
+ if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
183
+ return parts.join(", ");
184
+ }
185
+ function bakedName(id2, url2, mime) {
186
+ const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
187
+ const hash = createHash("sha256").update(url2).digest("hex").slice(0, 8);
188
+ return `${slug}-${hash}${extOf(url2) || extFor(mime)}`;
189
+ }
190
+ function extOf(url2) {
191
+ const path2 = hostOf(url2) === null ? url2 : new URL(url2).pathname;
192
+ const ext = extname(path2).toLowerCase();
193
+ return FILE_EXT.has(ext) ? ext : "";
194
+ }
195
+ var MIME_EXT = {
196
+ "image/png": ".png",
197
+ "image/jpeg": ".jpg",
198
+ "image/gif": ".gif",
199
+ "image/webp": ".webp",
200
+ "image/avif": ".avif",
201
+ "image/svg+xml": ".svg",
202
+ "video/mp4": ".mp4",
203
+ "video/webm": ".webm",
204
+ "audio/mpeg": ".mp3",
205
+ "application/pdf": ".pdf"
206
+ };
207
+ function extFor(mime) {
208
+ return (mime && MIME_EXT[mime]) ?? ".bin";
209
+ }
210
+ function hostOf(url2) {
211
+ try {
212
+ const u = new URL(url2);
213
+ if (u.protocol === "file:" || u.protocol === "data:") return null;
214
+ return u.hostname.toLowerCase().replace(/^www\./, "");
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+ function clean(mime) {
220
+ return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
221
+ }
222
+ function size(bytes) {
223
+ if (bytes < 1024) return `${bytes} B`;
224
+ const units = ["KB", "MB", "GB"];
225
+ let n3 = bytes / 1024;
226
+ let i = 0;
227
+ while (n3 >= 1024 && i < units.length - 1) {
228
+ n3 /= 1024;
229
+ i += 1;
230
+ }
231
+ return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
232
+ }
233
+ async function fetchAsset(url2) {
234
+ if (/^(https?|data):/i.test(url2)) {
235
+ const res = await fetch(url2);
236
+ if (!res.ok) throw new Error(`${url2}: HTTP ${res.status}`);
237
+ return {
238
+ bytes: new Uint8Array(await res.arrayBuffer()),
239
+ mime: res.headers.get("content-type") ?? void 0
240
+ };
241
+ }
242
+ return { bytes: new Uint8Array(await readFile(url2)) };
243
+ }
244
+
10
245
  // src/types.ts
11
246
  import { z } from "zod";
12
247
  var refSchema = z.object({
@@ -15,9 +250,34 @@ var refSchema = z.object({
15
250
  });
16
251
  var figureSchema = z.object({
17
252
  id: z.string(),
253
+ /**
254
+ * WHAT THE ASSET IS. A CLIP IS A FIGURE.
255
+ *
256
+ * A harvested page hands back stills and video in one pass, and the obvious
257
+ * shape for the video — a fifth `Source` array and a fifth `refSchema` kind —
258
+ * costs every layer that already knows what a figure is: the inventory
259
+ * `renderSource` prints, `assertRefsResolve`, the archetypes that take a
260
+ * `figureId`, and the pack. All of them would need a second word for "the
261
+ * thing this beat points at", and `refSchema` is a CLOSED enum of four kinds
262
+ * that a fifth entry would put into every stored plan's schema. A clip is a
263
+ * rectangle with intrinsic pixels and a caption that a beat points at, which
264
+ * is what a figure is; what differs is one branch at emit.
265
+ *
266
+ * DEFAULTED rather than required, and that is the whole point of the field
267
+ * being an enum with a default: every `source.json` written before clips
268
+ * existed parses unchanged and comes back an `image`, which is what it has
269
+ * always been.
270
+ */
271
+ kind: z.enum(["image", "clip"]).default("image"),
18
272
  /** Path relative to the deck's asset directory. */
19
273
  src: z.string(),
20
274
  caption: z.string(),
275
+ /**
276
+ * The intrinsic pixel size layout keys off — for a clip, the VIDEO's own
277
+ * dimensions, not the poster's. Every fit, crop and leader-line fraction
278
+ * downstream is expressed against this box, so a clip whose poster was
279
+ * letterboxed to another shape would put every annotation in the wrong place.
280
+ */
21
281
  width: z.int().positive(),
22
282
  height: z.int().positive(),
23
283
  /**
@@ -35,7 +295,26 @@ var figureSchema = z.object({
35
295
  */
36
296
  sectionId: z.string().optional(),
37
297
  /** The sentence or paragraph that refers to it, verbatim from the document. */
38
- mention: z.string().optional()
298
+ mention: z.string().optional(),
299
+ // THE THREE FIELDS ONLY A CLIP USES. All optional, for the same reason `kind`
300
+ // is defaulted: an image carries none of them, and a source written before
301
+ // clips existed parses into exactly the object it always did.
302
+ /**
303
+ * The local still that represents the clip — what the deck shows before
304
+ * anyone presses play, and what stands in wherever the video cannot run at
305
+ * all. A clip whose video could not be downloaded is this still and nothing
306
+ * else, so it is the picture the beat is really planned around.
307
+ */
308
+ poster: z.string().optional(),
309
+ /** How long the clip runs. Seconds, as measured off the file, never guessed. */
310
+ seconds: z.number().positive().optional(),
311
+ /**
312
+ * The page the video lives ON, when the video itself is not a file we can
313
+ * fetch — a player page, an embed, anything whose terms or DRM make the bytes
314
+ * unavailable. Then `poster` is all the deck can show, and this is where a
315
+ * viewer goes to watch the thing. Absent for a clip we hold the file for.
316
+ */
317
+ href: z.string().optional()
39
318
  });
40
319
  var equationSchema = z.object({
41
320
  id: z.string(),
@@ -707,22 +986,22 @@ function resizeFormat(base, width, height) {
707
986
  }
708
987
 
709
988
  // src/plan/select.ts
710
- function selectBeats(storyboard, budget2, seconds = {}) {
989
+ function selectBeats(storyboard, budget3, seconds = {}) {
711
990
  const len = (b) => seconds[b.id] ?? b.seconds;
712
991
  const dropped = [];
713
- const where2 = budget2.id ? `${budget2.id}'s` : "the";
992
+ const where2 = budget3.id ? `${budget3.id}'s` : "the";
714
993
  const live = storyboard.beats.filter((b) => {
715
- if (b.weight >= budget2.minWeight) return true;
994
+ if (b.weight >= budget3.minWeight) return true;
716
995
  dropped.push({
717
996
  beat: b,
718
997
  seconds: len(b),
719
998
  rule: "below_min_weight",
720
- reason: `Weight ${b.weight} is below ${where2} floor of ${budget2.minWeight}.`
999
+ reason: `Weight ${b.weight} is below ${where2} floor of ${budget3.minWeight}.`
721
1000
  });
722
1001
  return false;
723
1002
  });
724
1003
  const total = live.reduce((s, b) => s + len(b), 0);
725
- const cap = budget2.maxSeconds ?? Number.POSITIVE_INFINITY;
1004
+ const cap = budget3.maxSeconds ?? Number.POSITIVE_INFINITY;
726
1005
  if (!Number.isFinite(cap) || total <= cap) {
727
1006
  return finish(live, dropped, storyboard, len);
728
1007
  }
@@ -734,9 +1013,9 @@ function selectBeats(storyboard, budget2, seconds = {}) {
734
1013
  );
735
1014
  const all = knapsack(live, len, cap, ends) ?? knapsack(live, len, cap, /* @__PURE__ */ new Set());
736
1015
  const chosen = all ?? [live[0]];
737
- return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, false);
1016
+ return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget3, false);
738
1017
  }
739
- return budgetDrops(live, keep, dropped, storyboard, len, cap, budget2, true);
1018
+ return budgetDrops(live, keep, dropped, storyboard, len, cap, budget3, true);
740
1019
  }
741
1020
  function protect(live, len, cap) {
742
1021
  const ids = /* @__PURE__ */ new Set();
@@ -821,7 +1100,7 @@ function knapsack(live, len, cap, locked) {
821
1100
  }
822
1101
  return kept;
823
1102
  }
824
- function budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, fits) {
1103
+ function budgetDrops(live, chosen, dropped, storyboard, len, cap, budget3, fits) {
825
1104
  const keptIds = new Set(chosen.map((b) => b.id));
826
1105
  const families = /* @__PURE__ */ new Map();
827
1106
  for (const b of chosen) {
@@ -829,7 +1108,7 @@ function budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, fits)
829
1108
  families.set(fam, (families.get(fam) ?? 0) + 1);
830
1109
  }
831
1110
  const lightest = Math.min(...chosen.map((b) => b.weight));
832
- const target = budget2.id ? `${budget2.id}'s ${clock(cap)}` : clock(cap);
1111
+ const target = budget3.id ? `${budget3.id}'s ${clock(cap)}` : clock(cap);
833
1112
  for (const b of live) {
834
1113
  if (keptIds.has(b.id)) continue;
835
1114
  const fam = ARCHETYPE_FAMILY[b.archetype];
@@ -908,8 +1187,8 @@ function clock(seconds) {
908
1187
  }
909
1188
 
910
1189
  // src/source/fonts.ts
911
- import { createHash } from "node:crypto";
912
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1190
+ import { createHash as createHash2 } from "node:crypto";
1191
+ import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
913
1192
  import { join } from "node:path";
914
1193
  function familyFor(lang) {
915
1194
  const tag = lang.toLowerCase();
@@ -923,11 +1202,11 @@ async function bundleFont(lang, glyphs2, dir) {
923
1202
  const family = familyFor(lang);
924
1203
  if (!family) return null;
925
1204
  const text2 = [...new Set(glyphs2)].filter((c) => c > " ").sort().join("");
926
- const stamp = `/* decksmith ${createHash("sha256").update(`${family}
1205
+ const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
927
1206
  ${text2}`).digest("hex").slice(0, 16)} */`;
928
1207
  await mkdir(dir, { recursive: true });
929
1208
  const cssPath = join(dir, "fonts.css");
930
- const cached = await readFile(cssPath, "utf8").catch(() => "");
1209
+ const cached = await readFile2(cssPath, "utf8").catch(() => "");
931
1210
  if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
932
1211
  const res = await fetch(
933
1212
  `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
@@ -938,12 +1217,12 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
938
1217
  const remote = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1] ?? ""))];
939
1218
  const slug = family.toLowerCase().replace(/[^a-z0-9]/g, "");
940
1219
  const files = [];
941
- for (const [i, url] of remote.entries()) {
942
- const font = await fetch(url);
943
- if (!font.ok) throw new Error(`${url}: HTTP ${font.status}`);
1220
+ for (const [i, url2] of remote.entries()) {
1221
+ const font = await fetch(url2);
1222
+ if (!font.ok) throw new Error(`${url2}: HTTP ${font.status}`);
944
1223
  const name = `${slug}-${i}.woff2`;
945
1224
  await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
946
- css = css.replaceAll(url, name);
1225
+ css = css.replaceAll(url2, name);
947
1226
  files.push(name);
948
1227
  }
949
1228
  css = `${stamp}
@@ -1809,11 +2088,11 @@ function unwidow(text2, width, size3, weight = 700, face = "latin") {
1809
2088
  function wrapTokens(tokens, size3, width, weight, face) {
1810
2089
  const lines = [];
1811
2090
  let line2 = "";
1812
- for (const token of tokens) {
1813
- const candidate = line2 ? `${line2} ${token}` : token;
2091
+ for (const token2 of tokens) {
2092
+ const candidate = line2 ? `${line2} ${token2}` : token2;
1814
2093
  if (line2 && textWidth(candidate, size3, weight, 0, false, face) > width) {
1815
2094
  lines.push(line2);
1816
- line2 = token;
2095
+ line2 = token2;
1817
2096
  continue;
1818
2097
  }
1819
2098
  line2 = candidate;
@@ -2163,6 +2442,11 @@ var annotatedFigure = (beat, ctx) => {
2163
2442
  `annotated-figure ${beat.id}: no figure "${p.figureId}" in source ${ctx.source.id}`
2164
2443
  );
2165
2444
  }
2445
+ if (fig.kind === "clip") {
2446
+ throw new Error(
2447
+ `annotated-figure ${beat.id}: figure "${fig.id}" is a clip, and notes can only be pinned to a still \u2014 use claim-figure, which plays it, or point this beat at the clip's poster as a figure of its own`
2448
+ );
2449
+ }
2166
2450
  const crop = p.crop;
2167
2451
  const view = crop ? { width: fig.width * crop.w, height: fig.height * crop.h } : { width: fig.width, height: fig.height };
2168
2452
  const clamp3 = (v) => Math.min(1, Math.max(0, v));
@@ -2173,15 +2457,15 @@ var annotatedFigure = (beat, ctx) => {
2173
2457
  })) : p.notes;
2174
2458
  const STAGE_W = contentW(ctx.format);
2175
2459
  const face = faceOf(ctx.theme.fontStack);
2176
- const budget2 = stageBudget(ctx.format, p.eyebrow, p.headline, fig.caption, face);
2177
- if (budget2 < MIN_STAGE) {
2460
+ const budget3 = stageBudget(ctx.format, p.eyebrow, p.headline, fig.caption, face);
2461
+ if (budget3 < MIN_STAGE) {
2178
2462
  throw new Error(
2179
- `annotated-figure ${beat.id}: the headline and caption leave ${Math.round(budget2)}px for the figure, under the ${MIN_STAGE}px floor \u2014 shorten the headline or split the beat`
2463
+ `annotated-figure ${beat.id}: the headline and caption leave ${Math.round(budget3)}px for the figure, under the ${MIN_STAGE}px floor \u2014 shorten the headline or split the beat`
2180
2464
  );
2181
2465
  }
2182
- const plan = planFigure(STAGE_W, notes, view, budget2, isPortrait(ctx.format), face);
2466
+ const plan = planFigure(STAGE_W, notes, view, budget3, isPortrait(ctx.format), face);
2183
2467
  const stageH = plan.height;
2184
- const plate = {
2468
+ const plate2 = {
2185
2469
  x: plan.img.x - PLATE,
2186
2470
  y: plan.img.y - PLATE,
2187
2471
  w: plan.img.w + 2 * PLATE,
@@ -2335,7 +2619,7 @@ var annotatedFigure = (beat, ctx) => {
2335
2619
  // Per-scene geometry: the overlay is only correct because the image box is
2336
2620
  // stated rather than negotiated with the layout engine.
2337
2621
  `#${sid}-stage{width:${STAGE_W}px;height:${n(stageH)}px;margin-top:${STAGE_GAP}px}`,
2338
- `#${sid}-plate{left:${n(plate.x)}px;top:${n(plate.y)}px;width:${n(plate.w)}px;height:${n(plate.h)}px}`,
2622
+ `#${sid}-plate{left:${n(plate2.x)}px;top:${n(plate2.y)}px;width:${n(plate2.w)}px;height:${n(plate2.h)}px}`,
2339
2623
  // `overflow:visible` because a dot sitting on the figure's own edge puts its
2340
2624
  // halo outside the viewBox, and a clipped halo reads as a rendering fault.
2341
2625
  `#${sid}-ov{position:absolute;left:0;top:0;overflow:visible}`,
@@ -2698,7 +2982,7 @@ var callout = (beat, ctx) => {
2698
2982
  });
2699
2983
  const stackedH = heights.reduce((a, b) => a + b, 0) + PANEL_GAP * (heights.length - 1);
2700
2984
  const need = cols === 1 ? stackedH : Math.max(...heights);
2701
- const budget2 = bodyBudget(
2985
+ const budget3 = bodyBudget(
2702
2986
  ctx.format,
2703
2987
  p.eyebrow,
2704
2988
  p.headline,
@@ -2707,12 +2991,12 @@ var callout = (beat, ctx) => {
2707
2991
  void 0,
2708
2992
  face
2709
2993
  );
2710
- if (need > budget2) {
2994
+ if (need > budget3) {
2711
2995
  throw new Error(
2712
- `callout ${beat.id}: ${Math.round(need)}px of panel in a ${Math.round(budget2)}px box \u2014 shorten the lines or split the beat`
2996
+ `callout ${beat.id}: ${Math.round(need)}px of panel in a ${Math.round(budget3)}px box \u2014 shorten the lines or split the beat`
2713
2997
  );
2714
2998
  }
2715
- const cap = Math.min(budget2, Math.round(need * (cols === 1 ? 1.1 : 1.22)));
2999
+ const cap = Math.min(budget3, Math.round(need * (cols === 1 ? 1.1 : 1.22)));
2716
3000
  const note2 = p.note ? `
2717
3001
  <div class="conote" id="${sid}-note">${esc(p.note)}</div>` : "";
2718
3002
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -2791,6 +3075,31 @@ var CLAIM_LH = 1.5;
2791
3075
  var CLAIM_RULE = 6 + 32;
2792
3076
  var BESIDE_COL = 560;
2793
3077
  var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
3078
+ function plate(fig, sid, beatId, start) {
3079
+ const img = (src) => ({
3080
+ html: `<img src="assets/${esc(src)}" alt="${esc(fig.caption)}" />`,
3081
+ el: "img"
3082
+ });
3083
+ if (fig.kind !== "clip") return img(fig.src);
3084
+ if (fig.href !== void 0) {
3085
+ if (fig.poster === void 0) {
3086
+ throw new Error(
3087
+ `claim-figure ${beatId}: figure "${fig.id}" is a clip we hold no file for and no still of \u2014 re-ingest it so its poster is measured, or point the beat at a figure this deck has`
3088
+ );
3089
+ }
3090
+ return img(fig.poster);
3091
+ }
3092
+ if (start === void 0) {
3093
+ throw new Error(
3094
+ `claim-figure ${beatId}: figure "${fig.id}" is a clip, and nobody said when this scene starts \u2014 the video is seeked on the deck's absolute clock, so \`EmitContext.start\` has to be passed`
3095
+ );
3096
+ }
3097
+ const poster = fig.poster === void 0 ? "" : ` poster="assets/${esc(fig.poster)}"`;
3098
+ return {
3099
+ html: `<video id="${sid}-v" src="assets/${esc(fig.src)}"${poster} data-start="${start}" preload="auto" playsinline muted></video>`,
3100
+ el: "video"
3101
+ };
3102
+ }
2794
3103
  var claimFigure = (beat, ctx) => {
2795
3104
  const { sid, theme } = ctx;
2796
3105
  const p = beat.params;
@@ -2830,7 +3139,8 @@ var claimFigure = (beat, ctx) => {
2830
3139
  );
2831
3140
  }
2832
3141
  const claim = `<div class="claim" id="${sid}-c">${words(p.claim)}</div>`;
2833
- const figure = `<div class="figwrap" id="${sid}-f"><img src="assets/${esc(fig.src)}" alt="${esc(fig.caption)}" /></div>`;
3142
+ const held = plate(fig, sid, beat.id, ctx.start);
3143
+ const figure = `<div class="figwrap" id="${sid}-f">${held.html}</div>`;
2834
3144
  const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
2835
3145
  const body = tall ? `<div class="cf-stack">${claim}
2836
3146
  <div>${figure}
@@ -2898,12 +3208,22 @@ ${body}`,
2898
3208
  // Height-capped rather than width-driven: a square figure in the beside
2899
3209
  // layout would otherwise be ~970px tall and run off the canvas. The cap is
2900
3210
  // the measured remainder, not a constant — see `figMax`.
2901
- `.figwrap img{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
3211
+ //
3212
+ // NAMED FOR THE TAG THAT IS ACTUALLY THERE rather than written for both.
3213
+ // A rule listing `img, video` would move the bytes of every deck we have
3214
+ // ever built to describe an element almost none of them contain, and a
3215
+ // rule naming only `img` over a clip is a cap that silently does not
3216
+ // apply — the video would render at its natural 1920x1080 and run off the
3217
+ // canvas, which is invariant-5 territory that no gate reads.
3218
+ `.figwrap ${held.el}{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
2902
3219
  `.caption{font-size:${BODY_SIZE}px;line-height:${BODY_LH};color:${theme.dim};margin-top:16px}`,
2903
3220
  // The image, not its wrapper: the wrapper's entrance already writes
2904
3221
  // `transform`. 1.2% of the 550px cap is 3.3px a side, which the wrapper's
2905
- // 16px padding absorbs — the swell can never reach the canvas edge.
2906
- ambient(sid, "-f img", DRIFT)
3222
+ // 16px padding absorbs — the swell can never reach the canvas edge. Same
3223
+ // reason as the cap above for naming the tag: a drift rule aimed at `img`
3224
+ // over a `<video>` is one ambient rule that animates nothing, and the
3225
+ // slide reads as dead rather than as held.
3226
+ ambient(sid, `-f ${held.el}`, DRIFT)
2907
3227
  ].join("\n")
2908
3228
  };
2909
3229
  };
@@ -2959,7 +3279,7 @@ var dataTable = (beat, ctx) => {
2959
3279
  }
2960
3280
  const rows = shown.length + 1;
2961
3281
  const width = noteWidth(ctx.format);
2962
- const budget2 = bodyBudget(
3282
+ const budget3 = bodyBudget(
2963
3283
  ctx.format,
2964
3284
  p.eyebrow,
2965
3285
  p.headline,
@@ -2968,11 +3288,11 @@ var dataTable = (beat, ctx) => {
2968
3288
  0,
2969
3289
  face
2970
3290
  );
2971
- const spare = budget2 - rows * cell * 1.2;
3291
+ const spare = budget3 - rows * cell * 1.2;
2972
3292
  const roomiest = isPortrait(ctx.format) ? PAD_Y_MAX.tall : PAD_Y_MAX.wide;
2973
3293
  const padY = Math.round(Math.max(PAD_Y_MIN, Math.min(roomiest, spare / (2 * rows))));
2974
3294
  const drawn = rows * (cell * 1.2 + 2 * padY);
2975
- const canvas = budget2 + PAD_Y;
3295
+ const canvas = budget3 + PAD_Y;
2976
3296
  if (drawn > canvas) {
2977
3297
  const has = said === void 0 ? `has ${table.rows.length} rows, which` : `has ${table.rows.length} rows and this beat names ${shown.length} of them, which`;
2978
3298
  const lever = said === void 0 ? `name the rows that carry the argument in params.rows \u2014 the slide draws those and states on itself how many it left out \u2014 or take the one column that carries it to bar-compare` : `name fewer rows in params.rows, or take the one column that carries the argument to bar-compare`;
@@ -3023,8 +3343,8 @@ var dataTable = (beat, ctx) => {
3023
3343
  p.highlight.forEach((h, i) => {
3024
3344
  const index = shown.findIndex((row) => row[0] === h.row);
3025
3345
  if (index < 0) {
3026
- const why2 = table.rows.some((row) => row[0] === h.row) ? `no row labelled "${h.row}" is drawn \u2014 table ${table.id} has one, but params.rows left it out` : `no row labelled "${h.row}" in table ${table.id}`;
3027
- throw new Error(`data-table ${beat.id}: ${why2}`);
3346
+ const why3 = table.rows.some((row) => row[0] === h.row) ? `no row labelled "${h.row}" is drawn \u2014 table ${table.id} has one, but params.rows left it out` : `no row labelled "${h.row}" in table ${table.id}`;
3347
+ throw new Error(`data-table ${beat.id}: ${why3}`);
3028
3348
  }
3029
3349
  const at = settled + 0.3 + i * step2;
3030
3350
  tl.push(
@@ -3339,14 +3659,14 @@ var MORPH_SECONDS = 1.6;
3339
3659
  var equationMorph = (beat, ctx) => {
3340
3660
  const { sid, theme } = ctx;
3341
3661
  const p = beat.params;
3342
- const find2 = (id2) => {
3662
+ const find3 = (id2) => {
3343
3663
  const eq = ctx.source.equations.find((e) => e.id === id2);
3344
3664
  if (!eq)
3345
3665
  throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3346
3666
  return eq;
3347
3667
  };
3348
- const a = find2(p.fromId);
3349
- const b = find2(p.toId);
3668
+ const a = find3(p.fromId);
3669
+ const b = find3(p.toId);
3350
3670
  const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3351
3671
  if (both.length === 0) {
3352
3672
  throw new Error(
@@ -3492,8 +3812,8 @@ var grid = (beat, ctx) => {
3492
3812
  void 0,
3493
3813
  face
3494
3814
  );
3495
- const solve3 = (boxW, budget3) => {
3496
- const cell = solveCell(p.cols, p.rows, boxW, budget3);
3815
+ const solve3 = (boxW, budget4) => {
3816
+ const cell = solveCell(p.cols, p.rows, boxW, budget4);
3497
3817
  const gap = cell * GAP;
3498
3818
  return {
3499
3819
  cell,
@@ -3516,13 +3836,13 @@ var grid = (beat, ctx) => {
3516
3836
  const widest2 = Math.max(...p.regions.map((r) => textWidth(r.label, LABEL, 600, 0, false, face)));
3517
3837
  const oneLine = Math.min(620, widest2 * 1.06);
3518
3838
  const wrapped = Math.min(620, widest2 / MAX_LINES * 1.12);
3519
- const layout2 = (budget3) => {
3520
- const bare = solve3(W, budget3);
3839
+ const layout2 = (budget4) => {
3840
+ const bare = solve3(W, budget4);
3521
3841
  const free = W - bare.w - 2 * MARGIN;
3522
3842
  const needsGutter = p.regions.some((r, i) => crowded[i] || !fitsInside(bare, r));
3523
3843
  const lw2 = needsGutter ? Math.max(wrapped, Math.min(oneLine, free - 90)) : 0;
3524
3844
  const gutter2 = needsGutter ? lw2 + 90 : 0;
3525
- const f2 = needsGutter ? solve3(W - gutter2, budget3) : bare;
3845
+ const f2 = needsGutter ? solve3(W - gutter2, budget4) : bare;
3526
3846
  const inGutter2 = p.regions.map((r, i) => crowded[i] || !fitsInside(f2, r));
3527
3847
  const lines2 = (r) => wrap(r.label, LABEL, lw2, 600, 0, face).length;
3528
3848
  const stackH2 = p.regions.filter((_, i) => inGutter2[i]).reduce((t2, r, i) => t2 + lines2(r) * LABEL * LH + (i > 0 ? 14 : 16), 0);
@@ -3531,9 +3851,9 @@ var grid = (beat, ctx) => {
3531
3851
  const roomy = layout2(full);
3532
3852
  const roomBeside = W - Math.ceil(roomy.f.w + 2 * MARGIN + roomy.gutter) - NOTE_GAP;
3533
3853
  const beside = !isPortrait(ctx.format) && !!col && roomBeside >= col.min;
3534
- const budget2 = beside ? full : stackedBudget;
3535
- const { f, lw, gutter, inGutter, lines, stackH } = beside ? roomy : layout2(budget2);
3536
- const H = Math.min(budget2, Math.max(f.h + 2 * MARGIN, stackH));
3854
+ const budget3 = beside ? full : stackedBudget;
3855
+ const { f, lw, gutter, inGutter, lines, stackH } = beside ? roomy : layout2(budget3);
3856
+ const H = Math.min(budget3, Math.max(f.h + 2 * MARGIN, stackH));
3537
3857
  const fx = MARGIN;
3538
3858
  const fy = (H - f.h) / 2;
3539
3859
  const cols = tracks(f.w, p.cols, f.gap, fx);
@@ -3556,10 +3876,10 @@ var grid = (beat, ctx) => {
3556
3876
  h: r.h * y0.w + (r.h - 1) * f.gap + 2 * bleed
3557
3877
  };
3558
3878
  };
3559
- const boxes = p.regions.map(boxOf);
3879
+ const boxes3 = p.regions.map(boxOf);
3560
3880
  const lx = W - gutter + 40;
3561
3881
  const outside = p.regions.map((r, i) => ({ r, i })).filter(({ i }) => inGutter[i]).map(({ r, i }) => {
3562
- const b = boxes[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3882
+ const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3563
3883
  const half = lines(r) * LABEL * LH / 2;
3564
3884
  return { i, half, y: b.y + b.h / 2, from: { x: b.x + b.w, y: b.y + b.h / 2 } };
3565
3885
  }).sort((a, b) => a.y - b.y || a.i - b.i);
@@ -3577,7 +3897,7 @@ var grid = (beat, ctx) => {
3577
3897
  const parts = {};
3578
3898
  p.regions.forEach((r, i) => {
3579
3899
  parts[`rgn${i}`] = r.label;
3580
- const b = boxes[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3900
+ const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3581
3901
  const tone2 = theme.tones[r.tone];
3582
3902
  rects.push(
3583
3903
  `<defs><clipPath id="${id(sid, "rgnclip", i)}">${rect({ x: b.x, y: b.y, w: 0, h: b.h }, { id: id(sid, "sweep", i) })}</clipPath></defs>` + roundRect(b, corner, {
@@ -3685,7 +4005,7 @@ var grid = (beat, ctx) => {
3685
4005
  tl.push(...dimCells);
3686
4006
  p.regions.forEach((_, i) => {
3687
4007
  const at = first + i * step2;
3688
- const b = boxes[i] ?? { x: 0, y: 0, w: 0, h: 0 };
4008
+ const b = boxes3[i] ?? { x: 0, y: 0, w: 0, h: 0 };
3689
4009
  tl.push(
3690
4010
  tween(
3691
4011
  `#${id(sid, "rgn", i)}`,
@@ -3816,8 +4136,8 @@ var lineChart = (beat, ctx) => {
3816
4136
  const tall = isPortrait(ctx.format);
3817
4137
  const width = tall ? box : box - (p.readout ? CHART_GAP + READOUT_W : 0);
3818
4138
  const below = tall && p.readout ? wrap(p.readout, BODY_SIZE, box, 400, 0, face).length * Math.round(BODY_SIZE * READOUT_LH) + CHART_GAP : 0;
3819
- const budget2 = bodyBudget(ctx.format, p.eyebrow, p.headline, below, CHART_TOP, void 0, face);
3820
- const H = Math.round(tall ? Math.min(budget2, width * TALL_ASPECT) : budget2);
4139
+ const budget3 = bodyBudget(ctx.format, p.eyebrow, p.headline, below, CHART_TOP, void 0, face);
4140
+ const H = Math.round(tall ? Math.min(budget3, width * TALL_ASPECT) : budget3);
3821
4141
  const last = p.points[p.points.length - 1];
3822
4142
  const valueW = textWidth(String(last?.y ?? ""), 40, 400, 0, false, face);
3823
4143
  const labelW = textWidth(last?.x ?? "", 40, 400, 0, false, face);
@@ -4085,12 +4405,12 @@ function balance(label, k, face) {
4085
4405
  if (cur.length > 0) lines.push(cur.join(" "));
4086
4406
  return lines;
4087
4407
  }
4088
- function centre(boxes, i) {
4089
- const b = boxes[i];
4408
+ function centre(boxes3, i) {
4409
+ const b = boxes3[i];
4090
4410
  return b ? b.x + b.w / 2 : 0;
4091
4411
  }
4092
- function loopLabelWidth(boxes, from, to, stageW) {
4093
- const mid = (centre(boxes, from) + centre(boxes, to)) / 2;
4412
+ function loopLabelWidth(boxes3, from, to, stageW) {
4413
+ const mid = (centre(boxes3, from) + centre(boxes3, to)) / 2;
4094
4414
  return Math.max(NOTE * 6, Math.min(stageW - 2 * M - 80, 2 * Math.min(mid, stageW - mid) - 40));
4095
4415
  }
4096
4416
  function widest(lines, face) {
@@ -4123,11 +4443,11 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
4123
4443
  let fit = solve(stages, capped, stageW, face);
4124
4444
  if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
4125
4445
  const size3 = Math.max(MIN_FONT, Math.floor(fit.size));
4126
- const boxes = fit.boxes;
4127
- const innerW = Math.max(size3, (boxes[0]?.w ?? width) - 2 * PAD_X_EM * size3);
4446
+ const boxes3 = fit.boxes;
4447
+ const innerW = Math.max(size3, (boxes3[0]?.w ?? width) - 2 * PAD_X_EM * size3);
4128
4448
  const labelLines = stages.map((s) => wrap(s.label, size3, innerW, 600, 0, face));
4129
4449
  const noteLines = stages.map((s) => s.note ? wrap(s.note, NOTE, innerW, 400, 0, face) : []);
4130
- const boxW = boxes[0]?.w ?? width;
4450
+ const boxW = boxes3[0]?.w ?? width;
4131
4451
  const need = Math.max(
4132
4452
  MIN_BOX_H,
4133
4453
  Math.min(MAX_BOX_H, boxW * BOX_ASPECT),
@@ -4137,15 +4457,15 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
4137
4457
  return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
4138
4458
  })
4139
4459
  );
4140
- const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes, loop.from, loop.to, stageW), 500, 0, face) : [];
4460
+ const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes3, loop.from, loop.to, stageW), 500, 0, face) : [];
4141
4461
  const below = loop ? LOOP_TOP + LOOP_LABEL_TOP + loopLines.length * NOTE * NOTE_LH + LOOP_BOTTOM : M;
4142
4462
  const boxH = need;
4143
4463
  return {
4144
4464
  size: size3,
4145
- boxes,
4465
+ boxes: boxes3,
4146
4466
  boxH,
4147
4467
  boxX: M,
4148
- boxW: boxes[0]?.w ?? width,
4468
+ boxW: boxes3[0]?.w ?? width,
4149
4469
  vertical: false,
4150
4470
  innerW,
4151
4471
  labelLines,
@@ -4473,6 +4793,11 @@ var splitCompare = (beat, ctx) => {
4473
4793
  if (fig.width <= 0 || fig.height <= 0) {
4474
4794
  throw new Error(`split-compare ${beat.id}: figure "${fig.id}" has no usable dimensions`);
4475
4795
  }
4796
+ if (fig.kind === "clip") {
4797
+ throw new Error(
4798
+ `split-compare ${beat.id}: the ${NAME[i]} figure "${fig.id}" is a clip, and a side draws a still <image> \u2014 use claim-figure, which plays it, or point this side at the clip's poster as a figure of its own`
4799
+ );
4800
+ }
4476
4801
  return fig;
4477
4802
  });
4478
4803
  sides.forEach((side, i) => {
@@ -4771,9 +5096,9 @@ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
4771
5096
  function stackLayout(p, format, face = "latin") {
4772
5097
  const stacked = solve2(p, format, false, face);
4773
5098
  if (stacked.fits || !p.layers.some((l) => l.note)) return stacked;
4774
- const inline = solve2(p, format, true, face);
4775
- if (inline.fits) return inline;
4776
- return inline.wide && inline.blockH < stacked.blockH ? inline : stacked;
5099
+ const inline2 = solve2(p, format, true, face);
5100
+ if (inline2.fits) return inline2;
5101
+ return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4777
5102
  }
4778
5103
  function labelWeight(i, count) {
4779
5104
  return i === count - 1 ? 700 : 600;
@@ -4782,7 +5107,7 @@ function floorFor(p, format) {
4782
5107
  if (!p.tilt) return MIN_FONT;
4783
5108
  return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4784
5109
  }
4785
- function solve2(p, format, inline, face) {
5110
+ function solve2(p, format, inline2, face) {
4786
5111
  const width = contentW(format);
4787
5112
  const boxH = contentH(format);
4788
5113
  const floor = floorFor(p, format);
@@ -4791,15 +5116,15 @@ function solve2(p, format, inline, face) {
4791
5116
  const riseMax = RISE_MAX[k];
4792
5117
  const syMax = SY_MAX[k];
4793
5118
  const tMax = T_MAX[k];
4794
- const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
5119
+ const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4795
5120
  const want = Math.max(
4796
5121
  ...p.layers.map(
4797
5122
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
4798
5123
  )
4799
5124
  );
4800
- const colCap = inline ? width * 0.56 : width * 0.5;
5125
+ const colCap = inline2 ? width * 0.56 : width * 0.5;
4801
5126
  const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
4802
- const wide = !inline || Math.ceil(want) + 12 <= colCap;
5127
+ const wide = !inline2 || Math.ceil(want) + 12 <= colCap;
4803
5128
  const labelX = width - colW;
4804
5129
  const labelRoom = Math.min(
4805
5130
  ...p.layers.map(
@@ -4814,14 +5139,14 @@ function solve2(p, format, inline, face) {
4814
5139
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4815
5140
  // Inline notes stay on one line by contract — the schema calls a note "one
4816
5141
  // short line" — and wrapping one would put its second line under the label.
4817
- note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
5142
+ note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4818
5143
  noteW: nw,
4819
5144
  labelMaxW
4820
5145
  };
4821
5146
  });
4822
5147
  const blockH = Math.max(
4823
5148
  ...lines.map(
4824
- (l) => inline ? 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)
5149
+ (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)
4825
5150
  )
4826
5151
  );
4827
5152
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4843,7 +5168,7 @@ function solve2(p, format, inline, face) {
4843
5168
  fits: room >= blockH + 10 && height <= free && wide,
4844
5169
  floor,
4845
5170
  wide,
4846
- inline,
5171
+ inline: inline2,
4847
5172
  width,
4848
5173
  height,
4849
5174
  avail,
@@ -5247,7 +5572,8 @@ function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
5247
5572
  format,
5248
5573
  laid.slides,
5249
5574
  runtimeJs,
5250
- narrationIsland(opts.narration, laid.spoken)
5575
+ narrationIsland(opts.narration, laid.spoken),
5576
+ videoIsland(laid.embeds)
5251
5577
  )
5252
5578
  };
5253
5579
  }
@@ -5258,17 +5584,20 @@ function planCut(storyboard, source, format, opts = {}) {
5258
5584
  const seconds = {};
5259
5585
  const undrawable = /* @__PURE__ */ new Set();
5260
5586
  floor.forEach((beat, i) => {
5261
- const segments = opts.narration?.beats[beat.id];
5587
+ const segments2 = opts.narration?.beats[beat.id];
5262
5588
  let scene;
5263
5589
  try {
5264
- ({ scene } = stageScene(emitScene(beat, { source, format, theme, sid: `s${i + 1}` }), speed));
5590
+ ({ scene } = stageScene(
5591
+ emitScene(beat, { source, format, theme, sid: `s${i + 1}`, start: 0 }),
5592
+ speed
5593
+ ));
5265
5594
  } catch (err) {
5266
5595
  if (!opts.onBeatError) throw err;
5267
5596
  opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
5268
5597
  undrawable.add(beat.id);
5269
5598
  return;
5270
5599
  }
5271
- seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments);
5600
+ seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments2);
5272
5601
  });
5273
5602
  if (floor.length > 0 && undrawable.size === floor.length) {
5274
5603
  throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
@@ -5301,31 +5630,29 @@ function layout(storyboard, source, format, opts = {}) {
5301
5630
  const scenes = [];
5302
5631
  const slides = [];
5303
5632
  const spoken = {};
5633
+ const embeds = {};
5304
5634
  const entered = enteredParts(beats);
5635
+ let at = 0;
5305
5636
  const cuts = beats.map((beat, i) => {
5306
5637
  const sid = `s${i + 1}`;
5307
- const ctx = { source, format, theme, sid };
5308
- const segments = opts.narration?.beats[beat.id];
5638
+ const ctx = { source, format, theme, sid, start: rnd(at) };
5639
+ const segments2 = opts.narration?.beats[beat.id];
5309
5640
  const { scene } = stageScene(emitScene(beat, ctx), speed);
5310
- const seconds = beatSeconds(beat.seconds * speed, scene, segments);
5641
+ const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
5311
5642
  const inside = entered[i];
5312
5643
  const dive = inside ? { t0: rnd(seconds), dur: rnd(MOVE_SECONDS * speed), fade: rnd(FADE_SECONDS * speed) } : void 0;
5313
- return {
5314
- beat,
5315
- sid,
5316
- scene,
5317
- segments,
5318
- inside,
5319
- dive,
5320
- duration: dive ? seconds + diveTail(dive) : seconds
5321
- };
5644
+ const duration = dive ? seconds + diveTail(dive) : seconds;
5645
+ const start = at;
5646
+ at += duration;
5647
+ return { beat, sid, scene, segments: segments2, inside, dive, duration, start };
5322
5648
  });
5323
- let start = 0;
5324
5649
  let builds = false;
5325
5650
  const plugins = /* @__PURE__ */ new Set();
5326
5651
  cuts.forEach((cut2, i) => {
5327
- const { beat, sid, dive, inside, duration } = cut2;
5652
+ const { beat, sid, dive, inside, duration, start } = cut2;
5328
5653
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
5654
+ const embed = playerEmbed(beat, source);
5655
+ if (embed) embeds[sid] = embed;
5329
5656
  const next = cuts[i + 1];
5330
5657
  const over = next ? rnd(Math.min(HANDOFF_SECONDS * speed, next.duration)) : 0;
5331
5658
  let scene = cut2.scene;
@@ -5355,7 +5682,6 @@ function layout(storyboard, source, format, opts = {}) {
5355
5682
  notes: beat.narration ?? beat.intent,
5356
5683
  holds: scene.holds
5357
5684
  });
5358
- start += duration;
5359
5685
  });
5360
5686
  return {
5361
5687
  family,
@@ -5367,7 +5693,11 @@ function layout(storyboard, source, format, opts = {}) {
5367
5693
  scenes,
5368
5694
  slides,
5369
5695
  spoken,
5370
- total: start,
5696
+ embeds,
5697
+ // The clock after the last scene: the sum the map above finished with, which
5698
+ // is the number the second pass used to arrive at by re-adding the same
5699
+ // durations in the same order.
5700
+ total: at,
5371
5701
  cut,
5372
5702
  builds,
5373
5703
  plugins
@@ -5418,23 +5748,23 @@ function openSeconds(scene) {
5418
5748
  const first = scene.holds.filter((h) => Number.isFinite(h) && h > 0).sort((a, b) => a - b)[0];
5419
5749
  return rnd(first ?? 0);
5420
5750
  }
5421
- function beatSeconds(authored, scene, segments) {
5422
- if (!segments?.length) return authored;
5751
+ function beatSeconds(authored, scene, segments2) {
5752
+ if (!segments2?.length) return authored;
5423
5753
  const lastHold = scene.holds.reduce((a, b) => Math.max(a, b), 0);
5424
5754
  const usable = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
5425
5755
  (a, b) => a - b
5426
5756
  );
5427
- const ends = speechPlan(openSeconds(scene), usable, segments).end;
5757
+ const ends = speechPlan(openSeconds(scene), usable, segments2).end;
5428
5758
  return Math.max(authored, lastHold + SETTLE_SECONDS, ends + SETTLE_SECONDS);
5429
5759
  }
5430
5760
  function stageScene(scene, speed) {
5431
5761
  const paced = pace(scene, speed);
5432
5762
  return { scene: paced, open: openSeconds(paced) };
5433
5763
  }
5434
- function speechPlan(open, holds, segments) {
5764
+ function speechPlan(open, holds, segments2) {
5435
5765
  const starts = [];
5436
5766
  let at = open;
5437
- for (const [i, segment] of segments.entries()) {
5767
+ for (const [i, segment] of segments2.entries()) {
5438
5768
  const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
5439
5769
  if (i > 0) at = Math.max(at, hold);
5440
5770
  starts.push(at);
@@ -5526,9 +5856,9 @@ function narrationIsland(narration, scenes) {
5526
5856
  voice: narration.voice,
5527
5857
  dir: narration.dir,
5528
5858
  scenes: Object.fromEntries(
5529
- Object.entries(scenes).map(([sid, segments]) => [
5859
+ Object.entries(scenes).map(([sid, segments2]) => [
5530
5860
  sid,
5531
- segments.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
5861
+ segments2.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
5532
5862
  ])
5533
5863
  )
5534
5864
  };
@@ -5538,7 +5868,22 @@ function narrationIsland(narration, scenes) {
5538
5868
  ${json}
5539
5869
  </script>`;
5540
5870
  }
5541
- function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
5871
+ function playerEmbed(beat, source) {
5872
+ if (beat.archetype !== "claim-figure") return void 0;
5873
+ const fig = source.figures.find((f) => f.id === beat.params.figureId);
5874
+ if (fig?.kind !== "clip" || fig.href === void 0) return void 0;
5875
+ const url2 = embedUrl(fig.href);
5876
+ return url2 === void 0 ? void 0 : { url: url2, title: fig.caption };
5877
+ }
5878
+ function videoIsland(clips) {
5879
+ if (Object.keys(clips).length === 0) return "";
5880
+ const json = JSON.stringify({ scenes: clips }, null, 2).replace(/</g, "\\u003c");
5881
+ return `
5882
+ <script type="application/decksmith-video+json">
5883
+ ${json}
5884
+ </script>`;
5885
+ }
5886
+ function emitDeckPage(storyboard, format, slides, runtimeJs, narration, video) {
5542
5887
  return `<!doctype html>
5543
5888
  <html lang="${esc(storyboard.lang)}">
5544
5889
  <head>
@@ -5557,7 +5902,7 @@ function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
5557
5902
  width="${format.width}"
5558
5903
  height="${format.height}"
5559
5904
  ></hyperframes-player>
5560
- ${emitIsland(slides)}${narration}
5905
+ ${emitIsland(slides)}${narration}${video}
5561
5906
  <script>
5562
5907
  ${closeSafe(runtimeJs)}
5563
5908
  </script>
@@ -5628,91 +5973,621 @@ function rnd(n3) {
5628
5973
  }
5629
5974
 
5630
5975
  // src/images/illustrate.ts
5631
- import { createHash as createHash4 } from "node:crypto";
5632
- import { mkdir as mkdir3, readFile as readFile5, rename, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
5976
+ import { createHash as createHash5 } from "node:crypto";
5977
+ import { mkdir as mkdir3, readFile as readFile6, rename, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
5633
5978
  import { join as join5 } from "node:path";
5634
5979
 
5635
5980
  // src/source/assets.ts
5636
- import { createHash as createHash2 } from "node:crypto";
5637
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
5638
- import { extname, join as join2 } from "node:path";
5639
- async function fetchFigures(source, dir) {
5640
- await mkdir2(dir, { recursive: true });
5641
- const figures = [];
5642
- for (const figure of source.figures) {
5643
- const name = assetName(figure.id, figure.src);
5644
- const bytes = await cache(join2(dir, name), figure.src);
5645
- figures.push({ ...figure, src: name, ...imageSize(bytes) });
5646
- }
5647
- return sourceSchema.parse({ ...source, figures });
5648
- }
5649
- function assetName(id2, src) {
5650
- const ext = extname(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
5651
- return `${id2}-${createHash2("sha256").update(src).digest("hex").slice(0, 8)}${ext || ".img"}`;
5652
- }
5653
- async function cache(path2, src) {
5654
- const hit = await readFile2(path2).catch(() => null);
5655
- if (hit) return hit;
5656
- let bytes;
5657
- if (/^https?:/i.test(src)) {
5658
- const res = await fetch(src);
5659
- if (!res.ok) throw new Error(`${src}: HTTP ${res.status}`);
5660
- bytes = Buffer.from(await res.arrayBuffer());
5661
- } else {
5662
- bytes = await readFile2(src);
5981
+ import { createHash as createHash3 } from "node:crypto";
5982
+ import { mkdir as mkdir2, readdir, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
5983
+ import { extname as extname2, join as join2 } from "node:path";
5984
+ import { z as z2 } from "zod";
5985
+
5986
+ // src/net/fetch.ts
5987
+ import { lookup as resolveHost } from "node:dns/promises";
5988
+ import { request as httpRequest } from "node:http";
5989
+ import { request as httpsRequest } from "node:https";
5990
+ import { isIP } from "node:net";
5991
+ import { brotliDecompressSync, gunzipSync } from "node:zlib";
5992
+
5993
+ // src/version.ts
5994
+ import { createRequire } from "node:module";
5995
+ var VERSION = createRequire(import.meta.url)("../package.json").version;
5996
+
5997
+ // src/net/fetch.ts
5998
+ var BLOCKED_V4 = [
5999
+ // "This network". Already in the original list as /^0\./, which is 0.0.0.0/8.
6000
+ { re: /^0\./, why: "the unspecified network (0.0.0.0/8)" },
6001
+ { re: /^10\./, why: "a private address (10.0.0.0/8)" },
6002
+ // NEW: carrier-grade NAT. A mobile or datacentre network hands these out, and
6003
+ // on a host that has one, the whole /10 is the neighbourhood.
6004
+ {
6005
+ re: /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
6006
+ why: "carrier-grade NAT space (100.64.0.0/10)"
6007
+ },
6008
+ { re: /^127\./, why: "loopback (127.0.0.0/8)" },
6009
+ { re: /^169\.254\./, why: "link-local, where the cloud metadata service lives (169.254.0.0/16)" },
6010
+ { re: /^172\.(1[6-9]|2\d|3[01])\./, why: "a private address (172.16.0.0/12)" },
6011
+ // NEW: IETF protocol assignments — 192.0.0.8, the NAT64 well-known prefix and
6012
+ // friends. Not routable on the public internet, so a URL pointing here is
6013
+ // pointing at something local.
6014
+ { re: /^192\.0\.0\./, why: "IETF protocol assignment space (192.0.0.0/24)" },
6015
+ { re: /^192\.168\./, why: "a private address (192.168.0.0/16)" },
6016
+ // NEW: benchmarking. Reserved for test equipment, and some networks route it
6017
+ // internally precisely because it will never collide with anything real.
6018
+ { re: /^198\.1[89]\./, why: "benchmarking space (198.18.0.0/15)" }
6019
+ ];
6020
+ var USER_AGENT = `DeckSmith/${VERSION} (+https://github.com/ca1773130n/DeckSmith)`;
6021
+ function isBlockedAddress(ip) {
6022
+ const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
6023
+ const kind = isIP(bare);
6024
+ if (kind === 0) return `not an IP address (${ip})`;
6025
+ if (kind === 4) {
6026
+ for (const { re, why: why3 } of BLOCKED_V4) if (re.test(bare)) return why3;
6027
+ return null;
5663
6028
  }
5664
- await writeFile2(path2, bytes);
5665
- return bytes;
5666
- }
5667
- function imageSize(b) {
5668
- if (b.length >= 24 && b.readUInt32BE(0) === 2303741511)
5669
- return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
5670
- if (b.length >= 10 && b.toString("latin1", 0, 4) === "GIF8")
5671
- return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
5672
- if (b.length >= 4 && b.readUInt16BE(0) === 65496) return jpegSize(b);
5673
- throw new Error("unrecognised image header (expected PNG, JPEG or GIF)");
6029
+ const groups = expandV6(bare);
6030
+ if (groups === null) return `not an IP address (${ip})`;
6031
+ if (groups.every((n3) => n3 === 0)) return "the unspecified address (::)";
6032
+ if (groups.slice(0, 7).every((n3) => n3 === 0) && groups[7] === 1) return "IPv6 loopback (::1)";
6033
+ const embedded = groups.slice(0, 5).every((n3) => n3 === 0) && (groups[5] === 65535 || groups[5] === 0);
6034
+ if (embedded) {
6035
+ const hi = groups[6] ?? 0;
6036
+ const lo = groups[7] ?? 0;
6037
+ return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
6038
+ }
6039
+ const top = groups[0] ?? 0;
6040
+ if ((top & 65472) === 65152) return "IPv6 link-local (fe80::/10)";
6041
+ if ((top & 65024) === 64512) return "IPv6 unique-local (fc00::/7)";
6042
+ return null;
5674
6043
  }
5675
- function jpegSize(b) {
5676
- let i = 2;
5677
- while (i + 9 < b.length) {
5678
- if (b[i] !== 255) {
5679
- i++;
5680
- continue;
6044
+ function expandV6(v6) {
6045
+ let text2 = v6;
6046
+ const quad = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text2);
6047
+ if (quad?.[1]) {
6048
+ const [a = 0, b = 0, c = 0, d = 0] = quad[1].split(".").map(Number);
6049
+ const head = text2.slice(0, text2.length - quad[1].length);
6050
+ text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
6051
+ }
6052
+ const halves = text2.split("::");
6053
+ if (halves.length > 2) return null;
6054
+ const left = halves[0] ? halves[0].split(":") : [];
6055
+ const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
6056
+ const fill = 8 - left.length - (right?.length ?? 0);
6057
+ if (right !== null && fill < 0) return null;
6058
+ const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
6059
+ if (parts.length !== 8) return null;
6060
+ const groups = parts.map((p) => Number.parseInt(p, 16));
6061
+ return groups.every((n3) => Number.isInteger(n3) && n3 >= 0 && n3 <= 65535) ? groups : null;
6062
+ }
6063
+ function isLoopback(ip) {
6064
+ const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
6065
+ return /^127\./.test(bare) || bare === "::1" || /^::ffff:127\./.test(bare);
6066
+ }
6067
+ var MAX_REDIRECTS = 5;
6068
+ var WEB_PORTS = /* @__PURE__ */ new Set([80, 443]);
6069
+ var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
6070
+ var CREDENTIALS = ["authorization", "cookie"];
6071
+ async function fetchGuarded(url2, opts) {
6072
+ const signal = AbortSignal.timeout(opts.timeoutMs);
6073
+ let target;
6074
+ try {
6075
+ target = new URL(url2);
6076
+ } catch {
6077
+ throw new Error(`refusing ${url2}: not a URL. Give an absolute http:// or https:// URL.`);
6078
+ }
6079
+ let headers = lowercased(opts.headers);
6080
+ for (let hop = 0; ; hop++) {
6081
+ if (hop > MAX_REDIRECTS) {
6082
+ throw new Error(
6083
+ `refusing ${url2}: more than ${MAX_REDIRECTS} redirects, last to ${target.href}. Link the final URL directly.`
6084
+ );
5681
6085
  }
5682
- const marker = b[i + 1];
5683
- if (marker === void 0) break;
5684
- if (marker === 255) {
5685
- i++;
6086
+ const res = await send(url2, target, headers, signal, opts);
6087
+ const status = res.statusCode ?? 0;
6088
+ if (REDIRECTS.has(status)) {
6089
+ const location = res.headers.location;
6090
+ res.destroy();
6091
+ if (location === void 0) {
6092
+ throw new Error(`refusing ${url2}: HTTP ${status} from ${target.href} with no Location.`);
6093
+ }
6094
+ let next;
6095
+ try {
6096
+ next = new URL(location, target);
6097
+ } catch {
6098
+ throw new Error(
6099
+ `refusing ${url2}: HTTP ${status} to an unparseable Location (${location}).`
6100
+ );
6101
+ }
6102
+ const sameOrigin = next.protocol === target.protocol && next.hostname === target.hostname && next.port === target.port;
6103
+ if (!sameOrigin) headers = withoutCredentials(headers);
6104
+ target = next;
5686
6105
  continue;
5687
6106
  }
5688
- if (marker === 1 || marker >= 208 && marker <= 217) {
5689
- i += 2;
5690
- continue;
6107
+ if (status < 200 || status >= 300) {
6108
+ res.destroy();
6109
+ throw new Error(`${target.href}: HTTP ${status}`);
5691
6110
  }
5692
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
5693
- return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
5694
- i += 2 + b.readUInt16BE(i + 2);
6111
+ const contentType = (res.headers["content-type"] ?? "").trim();
6112
+ const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
6113
+ if (opts.accept && media.match(opts.accept) === null) {
6114
+ res.destroy();
6115
+ throw new Error(
6116
+ `refusing ${url2}: ${target.href} served ${media || "no Content-Type"}, which does not match ${opts.accept}. Link the file itself, not a page about it.`
6117
+ );
6118
+ }
6119
+ const raw2 = await readCapped(res, opts.maxBytes, url2, signal, opts.timeoutMs);
6120
+ const bytes = decode(raw2, res.headers["content-encoding"], opts.maxBytes, url2);
6121
+ return { bytes, contentType, url: target.href };
5695
6122
  }
5696
- throw new Error("JPEG has no SOF segment");
5697
6123
  }
5698
-
5699
- // src/images/providers.ts
5700
- import { createHash as createHash3 } from "node:crypto";
5701
- import { mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
5702
- import { tmpdir as tmpdir2 } from "node:os";
5703
- import { join as join4, resolve } from "node:path";
5704
- import { z as z3 } from "zod";
5705
-
5706
- // src/plan/codex.ts
5707
- import { spawn } from "node:child_process";
5708
- import { mkdtemp, readFile as readFile3, rm, writeFile as writeFile3 } from "node:fs/promises";
5709
- import { tmpdir } from "node:os";
5710
- import { join as join3 } from "node:path";
5711
- import { z as z2 } from "zod";
5712
-
5713
- // src/plan/arc.ts
5714
- var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
5715
- function requiredRoles(beatCount) {
6124
+ async function send(url2, target, headers, signal, opts) {
6125
+ const secure = target.protocol === "https:";
6126
+ if (!secure && target.protocol !== "http:") {
6127
+ throw new Error(
6128
+ `refusing ${url2}: ${target.protocol}// is not http or https (${target.href}). Only web URLs are followed.`
6129
+ );
6130
+ }
6131
+ const port = Number(target.port || (secure ? 443 : 80));
6132
+ if (!WEB_PORTS.has(port) && opts.allowLoopback !== true) {
6133
+ throw new Error(
6134
+ `refusing ${url2}: port ${port} on ${target.hostname} \u2014 only 80 and 443 are fetched. A URL naming another port is naming a service, not a page.`
6135
+ );
6136
+ }
6137
+ const host = target.hostname.replace(/^\[|\]$/g, "");
6138
+ let addresses;
6139
+ try {
6140
+ addresses = await Promise.race([resolveHost(host, { all: true }), rejectsOnAbort(signal)]);
6141
+ } catch {
6142
+ if (signal.aborted) throw timedOut(url2, opts.timeoutMs);
6143
+ throw new Error(`refusing ${url2}: ${host} does not resolve.`);
6144
+ }
6145
+ const pinned = addresses[0];
6146
+ if (pinned === void 0) throw new Error(`refusing ${url2}: ${host} does not resolve.`);
6147
+ for (const { address } of addresses) {
6148
+ const why3 = isBlockedAddress(address);
6149
+ if (why3 !== null && !(opts.allowLoopback === true && isLoopback(address))) {
6150
+ throw new Error(
6151
+ `refusing ${url2}: ${host} resolves to ${address}, which is ${why3}. Host the file somewhere this server can reach from the public internet.`
6152
+ );
6153
+ }
6154
+ }
6155
+ const options = {
6156
+ hostname: host,
6157
+ port,
6158
+ path: `${target.pathname}${target.search}`,
6159
+ method: "GET",
6160
+ // IDENTIFY OURSELVES, AND ACCEPT HTML.
6161
+ //
6162
+ // Measured against the first real page this was ever pointed at: Wikipedia
6163
+ // answers a request with no `user-agent` with a bare 403, and so do a great
6164
+ // many sites behind a CDN. A fetcher that will not say who it is looks
6165
+ // exactly like a scraper worth blocking, and the failure arrives as an
6166
+ // HTTP status with nothing in it to explain itself. Both headers are
6167
+ // DEFAULTS rather than overrides — a caller that sets either wins, because
6168
+ // the spread below them is the caller's.
6169
+ headers: {
6170
+ "user-agent": USER_AGENT,
6171
+ accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5",
6172
+ ...headers,
6173
+ "accept-encoding": "gzip, br"
6174
+ },
6175
+ signal,
6176
+ // THE PIN. Without this line every check above is advisory: `dns.lookup`
6177
+ // would run a second time inside the connect and could answer differently.
6178
+ lookup: pin(pinned.address, pinned.family),
6179
+ // No connection pool. This is a one-shot against a stranger's host, and a
6180
+ // pooled socket outlives the call that validated it — nothing should be able
6181
+ // to inherit a connection this module opened.
6182
+ agent: false,
6183
+ // Say out loud which name the certificate has to match. Node derives this
6184
+ // from `hostname` today, but the pin means the socket is opened to a bare
6185
+ // address, and a future refactor that stops setting `hostname` would turn
6186
+ // verification off silently rather than fail.
6187
+ ...secure && isIP(host) === 0 ? { servername: host } : {}
6188
+ };
6189
+ try {
6190
+ return await new Promise((resolve6, reject) => {
6191
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve6);
6192
+ req.on("error", reject);
6193
+ req.end();
6194
+ });
6195
+ } catch (err) {
6196
+ if (signal.aborted) throw timedOut(url2, opts.timeoutMs);
6197
+ throw new Error(`refusing ${url2}: ${target.href} could not be reached (${message(err)}).`);
6198
+ }
6199
+ }
6200
+ function pin(address, family) {
6201
+ return (_hostname, options, callback) => {
6202
+ if (options.all) callback(null, [{ address, family }]);
6203
+ else callback(null, address, family);
6204
+ };
6205
+ }
6206
+ async function readCapped(res, maxBytes, url2, signal, timeoutMs) {
6207
+ const chunks = [];
6208
+ let total = 0;
6209
+ let oversize = false;
6210
+ try {
6211
+ for await (const chunk of res) {
6212
+ total += chunk.length;
6213
+ if (total > maxBytes) {
6214
+ oversize = true;
6215
+ res.destroy();
6216
+ break;
6217
+ }
6218
+ chunks.push(chunk);
6219
+ }
6220
+ } catch (err) {
6221
+ if (signal.aborted) throw timedOut(url2, timeoutMs);
6222
+ throw new Error(`refusing ${url2}: the body stopped arriving (${message(err)}).`);
6223
+ }
6224
+ if (oversize) {
6225
+ throw new Error(
6226
+ `refusing ${url2}: body is larger than ${maxBytes} bytes. Link a smaller file, or raise maxBytes if this one is expected.`
6227
+ );
6228
+ }
6229
+ return Buffer.concat(chunks);
6230
+ }
6231
+ function decode(raw2, header, maxBytes, url2) {
6232
+ const encoding = (header ?? "").trim().toLowerCase();
6233
+ if (encoding === "" || encoding === "identity") return raw2;
6234
+ try {
6235
+ if (encoding === "gzip" || encoding === "x-gzip") {
6236
+ return gunzipSync(raw2, { maxOutputLength: maxBytes });
6237
+ }
6238
+ if (encoding === "br") return brotliDecompressSync(raw2, { maxOutputLength: maxBytes });
6239
+ } catch (err) {
6240
+ throw new Error(
6241
+ `refusing ${url2}: ${encoding} body does not decompress within ${maxBytes} bytes (${message(err)}). Serve it uncompressed, or raise maxBytes.`
6242
+ );
6243
+ }
6244
+ throw new Error(
6245
+ `refusing ${url2}: Content-Encoding "${encoding}" was never offered \u2014 this request asked for gzip or br. Serve one of those, or no encoding.`
6246
+ );
6247
+ }
6248
+ function lowercased(headers) {
6249
+ const out = {};
6250
+ for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
6251
+ return out;
6252
+ }
6253
+ function withoutCredentials(headers) {
6254
+ const out = { ...headers };
6255
+ for (const name of CREDENTIALS) delete out[name];
6256
+ return out;
6257
+ }
6258
+ function rejectsOnAbort(signal) {
6259
+ return new Promise((_, reject) => {
6260
+ if (signal.aborted) reject(signal.reason);
6261
+ else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
6262
+ });
6263
+ }
6264
+ function timedOut(url2, timeoutMs) {
6265
+ return new Error(
6266
+ `refusing ${url2}: nothing completed within ${timeoutMs}ms. Raise timeoutMs, or use a URL that answers faster.`
6267
+ );
6268
+ }
6269
+ function message(err) {
6270
+ return err instanceof Error ? err.message : String(err);
6271
+ }
6272
+
6273
+ // src/source/assets.ts
6274
+ async function fetchFigures(source, dir, warnings) {
6275
+ const drops = warnings ?? [];
6276
+ await mkdir2(dir, { recursive: true });
6277
+ const cached = await readdir(dir).catch(() => []);
6278
+ const figures = [];
6279
+ for (const figure of source.figures) {
6280
+ if (figure.kind === "clip") {
6281
+ figures.push(figure);
6282
+ continue;
6283
+ }
6284
+ try {
6285
+ figures.push(figureSchema.parse(await localize(figure, dir, cached)));
6286
+ } catch (err) {
6287
+ drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6288
+ }
6289
+ }
6290
+ if (!warnings) for (const drop of drops) console.warn(`decksmith: ${drop}`);
6291
+ const parsed = sourceSchema.safeParse({ ...source, figures });
6292
+ if (!parsed.success) {
6293
+ throw new Error(
6294
+ `source "${source.id}" no longer parses after localising its figures: ${reason(parsed.error)}. Every figure kept was checked on its own, so the fault is in the document around them, not in an image.`
6295
+ );
6296
+ }
6297
+ return parsed.data;
6298
+ }
6299
+ async function localize(figure, dir, cached) {
6300
+ const stem = assetStem(figure.id, figure.src);
6301
+ const hit = cached.find((name) => name.startsWith(`${stem}.`));
6302
+ const bytes = hit ? await readFile3(join2(dir, hit)) : await load(figure.src);
6303
+ const size3 = imageSize(bytes);
6304
+ if (hit) return { ...figure, src: hit, ...size3 };
6305
+ const src = `${stem}${assetExt(bytes, figure.src)}`;
6306
+ await writeFile2(join2(dir, src), bytes);
6307
+ cached.push(src);
6308
+ return { ...figure, src, ...size3 };
6309
+ }
6310
+ function assetStem(id2, src) {
6311
+ const readable = id2.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+/, "") || "figure";
6312
+ return `${readable}-${createHash3("sha256").update(src).digest("hex").slice(0, 8)}`;
6313
+ }
6314
+ var EXT = {
6315
+ png: ".png",
6316
+ jpeg: ".jpg",
6317
+ gif: ".gif",
6318
+ webp: ".webp",
6319
+ avif: ".avif",
6320
+ svg: ".svg"
6321
+ };
6322
+ function assetExt(bytes, src) {
6323
+ const format = sniffFormat(bytes);
6324
+ if (format) return EXT[format];
6325
+ const fromUrl = extname2(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
6326
+ return fromUrl || ".img";
6327
+ }
6328
+ var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
6329
+ var FIGURE_TIMEOUT_MS = 2e4;
6330
+ async function load(src) {
6331
+ if (!/^https?:/i.test(src)) return readFile3(src);
6332
+ const got = await fetchGuarded(src, {
6333
+ maxBytes: FIGURE_MAX_BYTES,
6334
+ timeoutMs: FIGURE_TIMEOUT_MS
6335
+ });
6336
+ return got.bytes;
6337
+ }
6338
+ function reason(err) {
6339
+ if (err instanceof z2.ZodError)
6340
+ return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
6341
+ return err instanceof Error ? err.message : String(err);
6342
+ }
6343
+ var SVG_WINDOW = 64 * 1024;
6344
+ function sniffFormat(b) {
6345
+ if (b.length >= 8 && b.readUInt32BE(0) === 2303741511 && b.readUInt32BE(4) === 218765834)
6346
+ return "png";
6347
+ if (b.length >= 6 && b.toString("latin1", 0, 4) === "GIF8") return "gif";
6348
+ if (b.length >= 2 && b.readUInt16BE(0) === 65496) return "jpeg";
6349
+ if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP")
6350
+ return "webp";
6351
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp" && isAvifBrand(b)) return "avif";
6352
+ if (isSvg(b)) return "svg";
6353
+ return void 0;
6354
+ }
6355
+ function isAvifBrand(b) {
6356
+ const declared = b.readUInt32BE(0);
6357
+ const end = Math.min(declared >= 16 ? declared : b.length, b.length);
6358
+ const brand = (at) => b.toString("latin1", at, at + 4);
6359
+ if (brand(8) === "avif" || brand(8) === "avis") return true;
6360
+ for (let i = 16; i + 4 <= end; i += 4)
6361
+ if (brand(i) === "avif" || brand(i) === "avis") return true;
6362
+ return false;
6363
+ }
6364
+ function isSvg(b) {
6365
+ const text2 = b.toString("utf8", 0, Math.min(b.length, SVG_WINDOW)).replace(/^\uFEFF/, "");
6366
+ let i = 0;
6367
+ for (; ; ) {
6368
+ while (i < text2.length && /\s/.test(text2.charAt(i))) i++;
6369
+ const comment = text2.startsWith("<!--", i);
6370
+ if (text2.startsWith("<?", i) || comment || /^<!doctype\s+svg\b/i.test(text2.slice(i, i + 20))) {
6371
+ const end = comment ? text2.indexOf("-->", i) + 3 : text2.indexOf(">", i) + 1;
6372
+ if (end <= 0) return false;
6373
+ i = end;
6374
+ continue;
6375
+ }
6376
+ return /^<svg[\s/>]/i.test(text2.slice(i, i + 5));
6377
+ }
6378
+ }
6379
+ function imageSize(b) {
6380
+ const format = sniffFormat(b);
6381
+ const raw2 = measure2(b, format);
6382
+ const width = Math.round(raw2.width);
6383
+ const height = Math.round(raw2.height);
6384
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
6385
+ throw new Error(`${format ?? "image"} header claims ${raw2.width}x${raw2.height}, not a size`);
6386
+ return { width, height };
6387
+ }
6388
+ function measure2(b, format) {
6389
+ switch (format) {
6390
+ case "png":
6391
+ return pngSize(b);
6392
+ case "gif":
6393
+ return gifSize(b);
6394
+ case "jpeg":
6395
+ return jpegSize(b);
6396
+ case "webp":
6397
+ return webpSize(b);
6398
+ case "avif":
6399
+ return avifSize(b);
6400
+ case "svg":
6401
+ return svgFigureSize(b);
6402
+ default:
6403
+ throw new Error(
6404
+ `unrecognised image header (expected PNG, JPEG, GIF, WebP, AVIF or SVG; got ${describe(b)})`
6405
+ );
6406
+ }
6407
+ }
6408
+ function describe(b) {
6409
+ if (b.length === 0) return "an empty file";
6410
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp")
6411
+ return `an ISO-BMFF file branded "${b.toString("latin1", 8, 12)}" \u2014 HEIC and its relatives are not drawn by the renderer`;
6412
+ const head = b.toString("latin1", 0, Math.min(b.length, 64));
6413
+ if (/^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(head))
6414
+ return "an HTML page \u2014 the URL answered with a page, not a picture";
6415
+ if (head.startsWith("%PDF")) return "a PDF";
6416
+ const hex = [...b.subarray(0, 8)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
6417
+ return `${b.length} bytes beginning ${hex}`;
6418
+ }
6419
+ function pngSize(b) {
6420
+ if (b.length < 24) throw new Error(`PNG is truncated: ${b.length} bytes, IHDR ends at 24`);
6421
+ if (b.toString("latin1", 12, 16) !== "IHDR") throw new Error("PNG does not open with IHDR");
6422
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
6423
+ }
6424
+ function gifSize(b) {
6425
+ if (b.length < 10)
6426
+ throw new Error(`GIF is truncated: ${b.length} bytes, the screen descriptor ends at 10`);
6427
+ return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
6428
+ }
6429
+ function jpegSize(b) {
6430
+ let i = 2;
6431
+ while (i + 9 < b.length) {
6432
+ if (b[i] !== 255) {
6433
+ i++;
6434
+ continue;
6435
+ }
6436
+ const marker = b[i + 1];
6437
+ if (marker === void 0) break;
6438
+ if (marker === 255) {
6439
+ i++;
6440
+ continue;
6441
+ }
6442
+ if (marker === 1 || marker >= 208 && marker <= 217) {
6443
+ i += 2;
6444
+ continue;
6445
+ }
6446
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
6447
+ return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
6448
+ i += 2 + b.readUInt16BE(i + 2);
6449
+ }
6450
+ throw new Error("JPEG has no SOF segment");
6451
+ }
6452
+ function webpSize(b) {
6453
+ const chunk = b.length >= 16 ? b.toString("latin1", 12, 16) : "";
6454
+ if (chunk === "VP8 ") {
6455
+ if (b.length < 30) throw new Error(`WebP VP8 chunk is truncated: ${b.length} bytes, needs 30`);
6456
+ if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42)
6457
+ throw new Error("WebP VP8 chunk has no keyframe sync code");
6458
+ return { width: b.readUInt16LE(26) & 16383, height: b.readUInt16LE(28) & 16383 };
6459
+ }
6460
+ if (chunk === "VP8L") {
6461
+ if (b.length < 25) throw new Error(`WebP VP8L chunk is truncated: ${b.length} bytes, needs 25`);
6462
+ if (b[20] !== 47) throw new Error("WebP VP8L chunk has no 0x2f signature");
6463
+ const bits = b.readUInt32LE(21);
6464
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
6465
+ }
6466
+ if (chunk === "VP8X") {
6467
+ if (b.length < 30) throw new Error(`WebP VP8X chunk is truncated: ${b.length} bytes, needs 30`);
6468
+ return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
6469
+ }
6470
+ throw new Error(`WebP opens with chunk "${chunk}", not one of VP8 , VP8L, VP8X`);
6471
+ }
6472
+ function avifSize(b) {
6473
+ const meta = find(b, 0, b.length, "meta");
6474
+ if (!meta) throw new Error("AVIF has no meta box (truncated, or not an image item)");
6475
+ const iprp = find(b, meta.start + 4, meta.end, "iprp");
6476
+ const ipco = iprp && find(b, iprp.start, iprp.end, "ipco");
6477
+ if (!ipco) throw new Error("AVIF has no ipco box (no item properties to read a size from)");
6478
+ let best = { width: 0, height: 0 };
6479
+ for (const box of boxes(b, ipco.start, ipco.end)) {
6480
+ if (box.type !== "ispe" || box.end - box.start < 12) continue;
6481
+ const found = { width: b.readUInt32BE(box.start + 4), height: b.readUInt32BE(box.start + 8) };
6482
+ if (found.width * found.height > best.width * best.height) best = found;
6483
+ }
6484
+ if (best.width < 1 || best.height < 1) throw new Error("AVIF has no usable ispe property");
6485
+ return cleanAperture(b, ipco, best) ?? best;
6486
+ }
6487
+ function cleanAperture(b, ipco, ispe) {
6488
+ for (const box of boxes(b, ipco.start, ipco.end)) {
6489
+ if (box.type !== "clap" || box.end - box.start < 16) continue;
6490
+ const at = (offset) => b.readUInt32BE(box.start + offset);
6491
+ if (at(4) === 0 || at(12) === 0) continue;
6492
+ const width = Math.round(at(0) / at(4));
6493
+ const height = Math.round(at(8) / at(12));
6494
+ if (width > ispe.width || height > ispe.height) continue;
6495
+ if (width * 2 < ispe.width || height * 2 < ispe.height) continue;
6496
+ return { width, height };
6497
+ }
6498
+ return void 0;
6499
+ }
6500
+ function find(b, from, to, type) {
6501
+ for (const box of boxes(b, from, to)) if (box.type === type) return box;
6502
+ return void 0;
6503
+ }
6504
+ function* boxes(b, from, to) {
6505
+ let i = from;
6506
+ while (i + 8 <= to) {
6507
+ let size3 = b.readUInt32BE(i);
6508
+ const type = b.toString("latin1", i + 4, i + 8);
6509
+ let start = i + 8;
6510
+ if (size3 === 1) {
6511
+ if (i + 16 > to) return;
6512
+ const large = b.readBigUInt64BE(i + 8);
6513
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return;
6514
+ size3 = Number(large);
6515
+ start = i + 16;
6516
+ } else if (size3 === 0) {
6517
+ size3 = to - i;
6518
+ }
6519
+ if (size3 < start - i || i + size3 > to) return;
6520
+ yield { type, start, end: i + size3 };
6521
+ i += size3;
6522
+ }
6523
+ }
6524
+ function svgFigureSize(b) {
6525
+ const moving = /<(?:animate|animateTransform|animateMotion|set|script)\b|@keyframes\b/i.exec(
6526
+ b.toString("utf8")
6527
+ );
6528
+ if (moving)
6529
+ throw new Error(
6530
+ `SVG carries "${moving[0]}": animation is drawn against the capture clock, so the render would differ every run. Convert the figure to PNG and point at that.`
6531
+ );
6532
+ return svgSize(b);
6533
+ }
6534
+ function svgSize(bytes) {
6535
+ const text2 = bytes.toString("utf8", 0, Math.min(bytes.length, SVG_WINDOW));
6536
+ const tag = /<svg\b[^>]*>/i.exec(text2)?.[0];
6537
+ if (!tag) throw new Error(`SVG root element is not closed within ${SVG_WINDOW} bytes`);
6538
+ const width = cssPixels(attrOf(tag, "width"));
6539
+ const height = cssPixels(attrOf(tag, "height"));
6540
+ if (width !== void 0 && height !== void 0) return { width, height };
6541
+ const box = attrOf(tag, "viewBox")?.trim().split(/[\s,]+/).map(Number);
6542
+ const w = box?.[2];
6543
+ const h = box?.[3];
6544
+ if (box?.length === 4 && w !== void 0 && h !== void 0 && w > 0 && h > 0)
6545
+ return { width: w, height: h };
6546
+ throw new Error("SVG declares no absolute width and height, and no usable viewBox");
6547
+ }
6548
+ function attrOf(tag, name) {
6549
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
6550
+ return m?.[1] ?? m?.[2];
6551
+ }
6552
+ var UNIT = {
6553
+ "": 1,
6554
+ px: 1,
6555
+ pt: 96 / 72,
6556
+ pc: 16,
6557
+ in: 96,
6558
+ cm: 96 / 2.54,
6559
+ mm: 96 / 25.4,
6560
+ q: 96 / 101.6,
6561
+ em: 16,
6562
+ rem: 16
6563
+ };
6564
+ function cssPixels(value) {
6565
+ if (value === void 0) return void 0;
6566
+ const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)\s*$/i.exec(value);
6567
+ if (!m?.[1]) return void 0;
6568
+ const scale = UNIT[(m[2] ?? "").toLowerCase()];
6569
+ if (scale === void 0) return void 0;
6570
+ const px = Number(m[1]) * scale;
6571
+ return px > 0 ? px : void 0;
6572
+ }
6573
+
6574
+ // src/images/providers.ts
6575
+ import { createHash as createHash4 } from "node:crypto";
6576
+ import { mkdtemp as mkdtemp2, readFile as readFile5, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
6577
+ import { tmpdir as tmpdir2 } from "node:os";
6578
+ import { join as join4, resolve } from "node:path";
6579
+ import { z as z4 } from "zod";
6580
+
6581
+ // src/plan/codex.ts
6582
+ import { spawn } from "node:child_process";
6583
+ import { mkdtemp, readFile as readFile4, rm, writeFile as writeFile3 } from "node:fs/promises";
6584
+ import { tmpdir } from "node:os";
6585
+ import { join as join3 } from "node:path";
6586
+ import { z as z3 } from "zod";
6587
+
6588
+ // src/plan/arc.ts
6589
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
6590
+ function requiredRoles(beatCount) {
5716
6591
  if (beatCount >= 8) return ARC_ROLES;
5717
6592
  if (beatCount >= 5) return ["limitations", "conclusion"];
5718
6593
  return [];
@@ -6521,9 +7396,28 @@ function renderSource(source) {
6521
7396
  } else {
6522
7397
  const n3 = source.figures.length;
6523
7398
  out.push(`${n3 === 1 ? "1 figure" : `${n3} figures`} in this document.`);
7399
+ if (source.figures.some((f) => f.kind === "clip")) {
7400
+ out.push(
7401
+ "",
7402
+ "A CLIP is a figure whose asset is video. Cite it exactly as you cite any other",
7403
+ "figure \u2014 `figureId` on the beat, `[figure id]` in evidence \u2014 and pick it for the",
7404
+ "same reason: it is the picture that carries the point.",
7405
+ "WHAT IT COSTS, and it decides whether a beat is worth spending on one: the deck",
7406
+ "holds a clip PAUSED AND MUTED, so a viewer clicking through sees one frame until",
7407
+ "they press play, and the rendered video shows that same frame unless the clip is",
7408
+ "a file this deck actually holds. A clip listed as watchable only at a link is a",
7409
+ "page whose video we could not download, and its still is all any format will ever",
7410
+ "show. So write the beat for the FRAME: claim what the picture states, and never",
7411
+ "narrate a motion the still does not."
7412
+ );
7413
+ }
6524
7414
  const headings = new Map(source.sections.map((s) => [s.id, s.heading]));
6525
7415
  for (const f of source.figures) {
6526
- out.push("", `[figure ${f.id}] ${f.width}x${f.height} \u2014 ${f.caption}`);
7416
+ const size3 = f.kind === "clip" ? `CLIP ${f.width}x${f.height}${f.seconds === void 0 ? "" : `, ${Math.round(f.seconds * 10) / 10}s`}` : `${f.width}x${f.height}`;
7417
+ out.push("", `[figure ${f.id}] ${size3} \u2014 ${f.caption}`);
7418
+ if (f.kind === "clip" && f.href) {
7419
+ out.push(` watchable only at ${f.href} \u2014 the deck shows its still, never the video`);
7420
+ }
6527
7421
  const heading = f.sectionId === void 0 ? void 0 : headings.get(f.sectionId);
6528
7422
  if (f.sectionId !== void 0) {
6529
7423
  out.push(` under: [section ${f.sectionId}]${heading ? ` ${heading}` : ""}`);
@@ -6671,7 +7565,7 @@ function assertInsideResolves(storyboard, source) {
6671
7565
  let parts;
6672
7566
  try {
6673
7567
  const sid = `s${i}`;
6674
- const scene = emitScene(previous, { source, format, theme: ink, sid });
7568
+ const scene = emitScene(previous, { source, format, theme: ink, sid, start: 0 });
6675
7569
  drawn = enterableIds(sid, scene.html).map((id2) => id2.replace(`${sid}-`, ""));
6676
7570
  parts = scene.parts;
6677
7571
  } catch {
@@ -6774,7 +7668,7 @@ function hideFromPlanner(node, hidden) {
6774
7668
  }
6775
7669
  function schemaFor(prefs) {
6776
7670
  return hideFromPlanner(
6777
- forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
7671
+ forStructuredOutput(z3.toJSONSchema(storyboardSchema, { io: "input" })),
6778
7672
  plannerInvisible(prefs)
6779
7673
  );
6780
7674
  }
@@ -6793,7 +7687,7 @@ async function codexPlanner(source, opts = {}) {
6793
7687
  ...opts.model === void 0 ? {} : { model: opts.model },
6794
7688
  timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
6795
7689
  });
6796
- const raw2 = await readFile3(outPath, "utf8").catch(() => "");
7690
+ const raw2 = await readFile4(outPath, "utf8").catch(() => "");
6797
7691
  if (raw2.trim() === "") {
6798
7692
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
6799
7693
  }
@@ -6866,7 +7760,7 @@ function codexCommand(args) {
6866
7760
  }
6867
7761
  function runCodex(args) {
6868
7762
  const { argv, env } = codexCommand(args);
6869
- return new Promise((resolve5, reject) => {
7763
+ return new Promise((resolve6, reject) => {
6870
7764
  const child = spawn("codex", argv, {
6871
7765
  stdio: ["pipe", "ignore", "pipe"],
6872
7766
  ...env === void 0 ? {} : { env }
@@ -6889,7 +7783,7 @@ function runCodex(args) {
6889
7783
  });
6890
7784
  child.on("close", (code) => {
6891
7785
  clearTimeout(timer);
6892
- if (code === 0) return resolve5();
7786
+ if (code === 0) return resolve6();
6893
7787
  reject(
6894
7788
  new Error(`codex exec exited ${code}.
6895
7789
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -6922,11 +7816,11 @@ var DEFAULT_BASE_URL = "https://api.openai.com/v1";
6922
7816
  var DEFAULT_MODEL = "gpt-image-2";
6923
7817
  var REQUEST_TIMEOUT_MS = 12e4;
6924
7818
  var MAX_BYTES = 16 * 1024 * 1024;
6925
- var generatedSchema = z3.object({
6926
- data: z3.array(z3.object({ b64_json: z3.string().optional(), url: z3.string().optional() })).optional()
7819
+ var generatedSchema = z4.object({
7820
+ data: z4.array(z4.object({ b64_json: z4.string().optional(), url: z4.string().optional() })).optional()
6927
7821
  });
6928
- var failedSchema = z3.object({
6929
- error: z3.object({ code: z3.string().nullish(), type: z3.string().nullish() }).nullish()
7822
+ var failedSchema = z4.object({
7823
+ error: z4.object({ code: z4.string().nullish(), type: z4.string().nullish() }).nullish()
6930
7824
  });
6931
7825
  function openaiImages(opts) {
6932
7826
  const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -6951,8 +7845,8 @@ function openaiImages(opts) {
6951
7845
  }),
6952
7846
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
6953
7847
  });
6954
- const body = await readCapped(res);
6955
- if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason(body)}`);
7848
+ const body = await readCapped2(res);
7849
+ if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason2(body)}`);
6956
7850
  const first = generatedSchema.safeParse(parseJson(body)).data?.data?.[0];
6957
7851
  if (first?.b64_json) return raster(Buffer.from(first.b64_json, "base64"), "openai images");
6958
7852
  if (!first?.url) throw new Error("openai images: answer carried neither b64_json nor url");
@@ -6965,14 +7859,14 @@ function openaiImages(opts) {
6965
7859
  redirect: "error",
6966
7860
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
6967
7861
  });
6968
- const bytes = await readCapped(picture);
7862
+ const bytes = await readCapped2(picture);
6969
7863
  if (!picture.ok)
6970
7864
  throw new Error(`openai images: HTTP ${picture.status} fetching the picture`);
6971
7865
  return raster(bytes, "openai images");
6972
7866
  }
6973
7867
  };
6974
7868
  }
6975
- function reason(body) {
7869
+ function reason2(body) {
6976
7870
  const error = failedSchema.safeParse(parseJson(body)).data?.error;
6977
7871
  const code = error?.code ?? error?.type;
6978
7872
  return code ? ` (${code})` : "";
@@ -6984,7 +7878,7 @@ function parseJson(body) {
6984
7878
  return void 0;
6985
7879
  }
6986
7880
  }
6987
- async function readCapped(res) {
7881
+ async function readCapped2(res) {
6988
7882
  const over = `openai images: answer is over ${MAX_BYTES >> 20} MB`;
6989
7883
  if (Number(res.headers.get("content-length")) > MAX_BYTES) throw new Error(over);
6990
7884
  const reader = res.body?.getReader();
@@ -7014,10 +7908,10 @@ var ANSWER_SCHEMA = {
7014
7908
  required: ["ok", "file", "reason"],
7015
7909
  additionalProperties: false
7016
7910
  };
7017
- var answerSchema = z3.object({
7018
- ok: z3.boolean(),
7019
- file: z3.string().nullish(),
7020
- reason: z3.string().nullish()
7911
+ var answerSchema = z4.object({
7912
+ ok: z4.boolean(),
7913
+ file: z4.string().nullish(),
7914
+ reason: z4.string().nullish()
7021
7915
  });
7022
7916
  function codexImages(opts = {}) {
7023
7917
  const run4 = opts.run ?? runCodex;
@@ -7043,7 +7937,7 @@ function codexImages(opts = {}) {
7043
7937
  cwd: dir,
7044
7938
  sandbox: "workspace-write"
7045
7939
  });
7046
- const raw2 = await readFile4(outPath, "utf8").catch(() => "");
7940
+ const raw2 = await readFile5(outPath, "utf8").catch(() => "");
7047
7941
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
7048
7942
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
7049
7943
  if (!answer.data.ok) {
@@ -7054,7 +7948,7 @@ function codexImages(opts = {}) {
7054
7948
  const candidates2 = [join4(dir, "picture.png")];
7055
7949
  if (answer.data.file) candidates2.push(resolve(dir, answer.data.file));
7056
7950
  for (const path2 of candidates2) {
7057
- const bytes = await readFile4(path2).catch(() => null);
7951
+ const bytes = await readFile5(path2).catch(() => null);
7058
7952
  if (bytes) return raster(bytes, "codex");
7059
7953
  }
7060
7954
  throw new Error("codex said ok but wrote no picture.png");
@@ -7094,7 +7988,7 @@ function mulberry32(seed) {
7094
7988
  }
7095
7989
  function drawSvg(req) {
7096
7990
  const { width: W, height: H } = SIZE[req.aspect];
7097
- const seed = createHash3("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
7991
+ const seed = createHash4("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
7098
7992
  const rand = mulberry32(seed.readUInt32BE(0));
7099
7993
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
7100
7994
  const count = 6 + Math.floor(rand() * 5);
@@ -7125,11 +8019,6 @@ function drawSvg(req) {
7125
8019
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join("")}</svg>
7126
8020
  `;
7127
8021
  }
7128
- function svgSize(bytes) {
7129
- const m = /viewBox="0 0 (\d+) (\d+)"/.exec(bytes.toString("utf8", 0, 200));
7130
- if (!m) throw new Error("svg has no viewBox");
7131
- return { width: Number(m[1]), height: Number(m[2]) };
7132
- }
7133
8022
  function toolSvg() {
7134
8023
  return {
7135
8024
  id: "svg",
@@ -7173,7 +8062,7 @@ function imageChain(images, backend) {
7173
8062
  }
7174
8063
 
7175
8064
  // src/images/illustrate.ts
7176
- var EXT = {
8065
+ var EXT2 = {
7177
8066
  "image/png": ".png",
7178
8067
  "image/jpeg": ".jpg",
7179
8068
  "image/svg+xml": ".svg"
@@ -7211,6 +8100,9 @@ async function illustrate(storyboard, source, opts) {
7211
8100
  const picture = await draw(provider, req, name, opts.assetsDir);
7212
8101
  const figure = {
7213
8102
  id: slot.figureId,
8103
+ // An illustration is always a still: every provider draws a picture,
8104
+ // and there is no rung that returns a video.
8105
+ kind: "image",
7214
8106
  src: picture.src,
7215
8107
  caption: slot.brief.caption,
7216
8108
  width: picture.width,
@@ -7229,8 +8121,8 @@ async function illustrate(storyboard, source, opts) {
7229
8121
  } catch (err) {
7230
8122
  const after = live[j + 1];
7231
8123
  if (!after) throw err;
7232
- const why2 = err instanceof Error ? err.message : String(err);
7233
- step2(`illustrate: ${slot.label} via ${provider.id} failed (${why2}); trying ${after.id}`);
8124
+ const why3 = err instanceof Error ? err.message : String(err);
8125
+ step2(`illustrate: ${slot.label} via ${provider.id} failed (${why3}); trying ${after.id}`);
7234
8126
  dropped.add(provider.id);
7235
8127
  }
7236
8128
  }
@@ -7280,17 +8172,17 @@ function slots(storyboard, known) {
7280
8172
  return out;
7281
8173
  }
7282
8174
  function cacheKey(providerId, req) {
7283
- return createHash4("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
8175
+ return createHash5("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
7284
8176
  }
7285
8177
  async function draw(provider, req, name, dir) {
7286
- for (const ext of Object.values(EXT)) {
8178
+ for (const ext of Object.values(EXT2)) {
7287
8179
  const src2 = `${name}${ext}`;
7288
- const bytes = await readFile5(join5(dir, src2)).catch(() => null);
8180
+ const bytes = await readFile6(join5(dir, src2)).catch(() => null);
7289
8181
  if (bytes) return { src: src2, ...sizeOf(bytes, ext), cached: true };
7290
8182
  }
7291
8183
  await provider.check();
7292
8184
  const img = await provider.generate(req);
7293
- const src = `${name}${EXT[img.mime]}`;
8185
+ const src = `${name}${EXT2[img.mime]}`;
7294
8186
  const tmp = join5(dir, `.${name}.tmp`);
7295
8187
  try {
7296
8188
  await writeFile5(tmp, img.bytes);
@@ -7306,8 +8198,8 @@ function sizeOf(bytes, ext) {
7306
8198
 
7307
8199
  // src/narrate/tts.ts
7308
8200
  import { spawn as spawn2 } from "node:child_process";
7309
- import { createHash as createHash5 } from "node:crypto";
7310
- import { mkdir as mkdir4, readFile as readFile6, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
8201
+ import { createHash as createHash6 } from "node:crypto";
8202
+ import { mkdir as mkdir4, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
7311
8203
  import { homedir } from "node:os";
7312
8204
  import { join as join6 } from "node:path";
7313
8205
  var MISSING = [
@@ -7341,18 +8233,18 @@ async function answersHelp(argv) {
7341
8233
  }
7342
8234
  var resolved = null;
7343
8235
  function resolveEdgeTts(can = answersHelp) {
7344
- if (can !== answersHelp) return find(can);
7345
- resolved ??= find(can);
8236
+ if (can !== answersHelp) return find2(can);
8237
+ resolved ??= find2(can);
7346
8238
  return resolved;
7347
8239
  }
7348
- async function find(can) {
8240
+ async function find2(can) {
7349
8241
  for (const argv of candidates()) {
7350
8242
  if (await can(argv)) return argv;
7351
8243
  }
7352
8244
  throw new Error(MISSING);
7353
8245
  }
7354
8246
  function runArgv(cmd, args) {
7355
- return new Promise((resolve5) => {
8247
+ return new Promise((resolve6) => {
7356
8248
  const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
7357
8249
  let stdout = "";
7358
8250
  let stderr = "";
@@ -7362,8 +8254,8 @@ function runArgv(cmd, args) {
7362
8254
  child.stderr.on("data", (b) => {
7363
8255
  stderr += b.toString();
7364
8256
  });
7365
- child.on("error", (e) => resolve5({ code: -1, stderr: String(e), stdout }));
7366
- child.on("close", (code) => resolve5({ code: code ?? -1, stderr, stdout }));
8257
+ child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
8258
+ child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
7367
8259
  });
7368
8260
  }
7369
8261
  var edgeTts = {
@@ -7430,7 +8322,7 @@ function edgeProvider(runner = edgeTts) {
7430
8322
  async speak(req) {
7431
8323
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
7432
8324
  await runner.speak({ ...req, subs });
7433
- const raw2 = await readFile6(subs, "utf8").catch(() => "");
8325
+ const raw2 = await readFile7(subs, "utf8").catch(() => "");
7434
8326
  await rm4(subs, { force: true });
7435
8327
  const cues = parseCues(raw2);
7436
8328
  const measured = await runner.measure(req.audio);
@@ -7440,7 +8332,7 @@ function edgeProvider(runner = edgeTts) {
7440
8332
  };
7441
8333
  }
7442
8334
  function cacheKey2(text2, voice, rate, pitch) {
7443
- return createHash5("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
8335
+ return createHash6("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
7444
8336
  }
7445
8337
  async function synthesize(text2, opts) {
7446
8338
  const rate = opts.rate ?? "+0%";
@@ -7467,7 +8359,7 @@ async function synthesize(text2, opts) {
7467
8359
  }
7468
8360
  async function readSidecar(path2) {
7469
8361
  try {
7470
- const parsed = JSON.parse(await readFile6(path2, "utf8"));
8362
+ const parsed = JSON.parse(await readFile7(path2, "utf8"));
7471
8363
  const s = parsed;
7472
8364
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
7473
8365
  } catch {
@@ -7520,7 +8412,7 @@ function stopCount(holds) {
7520
8412
  return Math.max(1, usable.size);
7521
8413
  }
7522
8414
  function stopsFor(beat, source, format, sid = "s1") {
7523
- const ctx = { source, format, theme: ink, sid };
8415
+ const ctx = { source, format, theme: ink, sid, start: 0 };
7524
8416
  try {
7525
8417
  return stopCount(emitScene(beat, ctx).holds);
7526
8418
  } catch {
@@ -7559,7 +8451,7 @@ async function narrate(storyboard, source, prefs, opts) {
7559
8451
  for (const [i, beat] of storyboard.beats.entries()) {
7560
8452
  const text2 = beat.narration?.trim();
7561
8453
  if (!text2) continue;
7562
- const segments = [];
8454
+ const segments2 = [];
7563
8455
  const stops = Math.min(stopsFor(beat, source, format, `s${i + 1}`), paced.speakingStops);
7564
8456
  const plan = planSegments(text2, stops);
7565
8457
  for (const [stop, line2] of plan.entries()) {
@@ -7571,7 +8463,7 @@ async function narrate(storyboard, source, prefs, opts) {
7571
8463
  dir: opts.dir,
7572
8464
  runner: opts.runner
7573
8465
  });
7574
- segments.push({
8466
+ segments2.push({
7575
8467
  stop,
7576
8468
  text: line2,
7577
8469
  // Content-addressed and flat, so the path is the filename and the deck
@@ -7581,209 +8473,58 @@ async function narrate(storyboard, source, prefs, opts) {
7581
8473
  cues: speech.cues
7582
8474
  });
7583
8475
  }
7584
- if (segments.length > 0) beats[beat.id] = segments;
8476
+ if (segments2.length > 0) beats[beat.id] = segments2;
7585
8477
  }
7586
8478
  return { voice, beats };
7587
8479
  }
7588
8480
 
7589
- // src/pack/media.ts
7590
- import { createHash as createHash6 } from "node:crypto";
7591
- import { readFile as readFile7 } from "node:fs/promises";
7592
- import { extname as extname2 } from "node:path";
7593
- var PLAYERS = [
7594
- "youtube.com",
7595
- "youtube-nocookie.com",
7596
- "youtu.be",
7597
- "vimeo.com",
7598
- "dailymotion.com",
7599
- "dai.ly",
7600
- "twitch.tv",
7601
- "loom.com",
7602
- "wistia.com",
7603
- "wistia.net",
7604
- "streamable.com",
7605
- "bilibili.com",
7606
- "soundcloud.com",
7607
- "tiktok.com"
7608
- ];
7609
- var FILE_EXT = /* @__PURE__ */ new Set([
8481
+ // src/pack/pack.ts
8482
+ import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
8483
+ import { dirname, extname as extname3 } from "node:path";
8484
+ import { unzipSync, zipSync } from "fflate";
8485
+ var MTIME = Date.UTC(1980, 0, 2, 12);
8486
+ var STORED = /* @__PURE__ */ new Set([
7610
8487
  ".png",
7611
8488
  ".jpg",
7612
8489
  ".jpeg",
7613
8490
  ".gif",
7614
8491
  ".webp",
7615
8492
  ".avif",
7616
- ".svg",
8493
+ ".mp3",
8494
+ ".m4a",
7617
8495
  ".mp4",
7618
8496
  ".webm",
7619
8497
  ".mov",
7620
- ".m4v",
7621
- ".mp3",
7622
- ".m4a",
7623
- ".wav",
7624
8498
  ".ogg",
7625
8499
  ".opus",
7626
- ".pdf"
8500
+ ".woff",
8501
+ ".woff2"
7627
8502
  ]);
7628
- function isEmbed(url) {
7629
- const host = hostOf(url);
7630
- return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
7631
- }
7632
- function policyFor(url, prefer) {
7633
- if (isEmbed(url)) return "embed";
7634
- const host = hostOf(url);
7635
- if (host === null || /^data:/i.test(url)) return "bake";
7636
- if (prefer === "link") return "link";
7637
- return FILE_EXT.has(extOf(url)) ? "bake" : "link";
7638
- }
7639
- async function planMedia(assets, fetcher = fetchAsset) {
7640
- const plan = {
7641
- media: [],
7642
- files: {},
7643
- bakedCount: 0,
7644
- bakedBytes: 0,
7645
- demoted: [],
7646
- promoted: []
8503
+ async function writePack(pack2, files, out) {
8504
+ const manifest = packSchema.parse(pack2);
8505
+ for (const m of manifest.media) {
8506
+ if (m.policy !== "bake") continue;
8507
+ if (!m.path) throw new Error(`media "${m.id}" is baked but has no path`);
8508
+ if (!(m.path in files)) throw new Error(`media "${m.id}" is baked but ${m.path} was not given`);
8509
+ }
8510
+ const entries = {
8511
+ "deck.json": new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}
8512
+ `)
7647
8513
  };
7648
- for (const asset of assets) {
7649
- const policy = policyFor(asset.url, asset.prefer);
7650
- if (policy !== "bake") {
7651
- if (asset.prefer === "bake") plan.demoted.push(asset.id);
7652
- plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
7653
- continue;
7654
- }
7655
- if (asset.prefer === "link") plan.promoted.push(asset.id);
7656
- const got = await fetcher(asset.url);
7657
- const mime = clean(got.mime) ?? asset.mime;
7658
- if (mime === "text/html") {
7659
- plan.demoted.push(asset.id);
7660
- plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
7661
- continue;
7662
- }
7663
- const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
7664
- plan.files[path2] = got.bytes;
7665
- plan.bakedCount += 1;
7666
- plan.bakedBytes += got.bytes.length;
7667
- plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
8514
+ for (const path2 of Object.keys(files).sort()) {
8515
+ const bytes = files[path2];
8516
+ if (!bytes) continue;
8517
+ if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
8518
+ check(path2);
8519
+ entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
7668
8520
  }
7669
- return plan;
7670
- }
7671
- function mediaSummary(plan) {
7672
- const linked = plan.media.filter((m) => m.policy === "link").length;
7673
- const embedded = plan.media.filter((m) => m.policy === "embed").length;
7674
- const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
7675
- if (linked) parts.push(`${linked} linked`);
7676
- if (embedded) parts.push(`${embedded} embedded`);
7677
- if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
7678
- return parts.join(", ");
7679
- }
7680
- function bakedName(id2, url, mime) {
7681
- const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
7682
- const hash = createHash6("sha256").update(url).digest("hex").slice(0, 8);
7683
- return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
8521
+ const zip = zipSync(entries, { mtime: MTIME });
8522
+ await mkdir5(dirname(out), { recursive: true });
8523
+ await writeFile7(out, zip);
8524
+ return zip.length;
7684
8525
  }
7685
- function extOf(url) {
7686
- const path2 = hostOf(url) === null ? url : new URL(url).pathname;
7687
- const ext = extname2(path2).toLowerCase();
7688
- return FILE_EXT.has(ext) ? ext : "";
7689
- }
7690
- var MIME_EXT = {
7691
- "image/png": ".png",
7692
- "image/jpeg": ".jpg",
7693
- "image/gif": ".gif",
7694
- "image/webp": ".webp",
7695
- "image/avif": ".avif",
7696
- "image/svg+xml": ".svg",
7697
- "video/mp4": ".mp4",
7698
- "video/webm": ".webm",
7699
- "audio/mpeg": ".mp3",
7700
- "application/pdf": ".pdf"
7701
- };
7702
- function extFor(mime) {
7703
- return (mime && MIME_EXT[mime]) ?? ".bin";
7704
- }
7705
- function hostOf(url) {
7706
- try {
7707
- const u = new URL(url);
7708
- if (u.protocol === "file:" || u.protocol === "data:") return null;
7709
- return u.hostname.toLowerCase().replace(/^www\./, "");
7710
- } catch {
7711
- return null;
7712
- }
7713
- }
7714
- function clean(mime) {
7715
- return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
7716
- }
7717
- function size(bytes) {
7718
- if (bytes < 1024) return `${bytes} B`;
7719
- const units = ["KB", "MB", "GB"];
7720
- let n3 = bytes / 1024;
7721
- let i = 0;
7722
- while (n3 >= 1024 && i < units.length - 1) {
7723
- n3 /= 1024;
7724
- i += 1;
7725
- }
7726
- return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
7727
- }
7728
- async function fetchAsset(url) {
7729
- if (/^(https?|data):/i.test(url)) {
7730
- const res = await fetch(url);
7731
- if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
7732
- return {
7733
- bytes: new Uint8Array(await res.arrayBuffer()),
7734
- mime: res.headers.get("content-type") ?? void 0
7735
- };
7736
- }
7737
- return { bytes: new Uint8Array(await readFile7(url)) };
7738
- }
7739
-
7740
- // src/pack/pack.ts
7741
- import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
7742
- import { dirname, extname as extname3 } from "node:path";
7743
- import { unzipSync, zipSync } from "fflate";
7744
- var MTIME = Date.UTC(1980, 0, 2, 12);
7745
- var STORED = /* @__PURE__ */ new Set([
7746
- ".png",
7747
- ".jpg",
7748
- ".jpeg",
7749
- ".gif",
7750
- ".webp",
7751
- ".avif",
7752
- ".mp3",
7753
- ".m4a",
7754
- ".mp4",
7755
- ".webm",
7756
- ".mov",
7757
- ".ogg",
7758
- ".opus",
7759
- ".woff",
7760
- ".woff2"
7761
- ]);
7762
- async function writePack(pack2, files, out) {
7763
- const manifest = packSchema.parse(pack2);
7764
- for (const m of manifest.media) {
7765
- if (m.policy !== "bake") continue;
7766
- if (!m.path) throw new Error(`media "${m.id}" is baked but has no path`);
7767
- if (!(m.path in files)) throw new Error(`media "${m.id}" is baked but ${m.path} was not given`);
7768
- }
7769
- const entries = {
7770
- "deck.json": new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}
7771
- `)
7772
- };
7773
- for (const path2 of Object.keys(files).sort()) {
7774
- const bytes = files[path2];
7775
- if (!bytes) continue;
7776
- if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
7777
- check(path2);
7778
- entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
7779
- }
7780
- const zip = zipSync(entries, { mtime: MTIME });
7781
- await mkdir5(dirname(out), { recursive: true });
7782
- await writeFile7(out, zip);
7783
- return zip.length;
7784
- }
7785
- async function readPack(path2) {
7786
- return openPack(new Uint8Array(await readFile8(path2)), path2);
8526
+ async function readPack(path2) {
8527
+ return openPack(new Uint8Array(await readFile8(path2)), path2);
7787
8528
  }
7788
8529
  function openPack(bytes, label = "pack") {
7789
8530
  if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
@@ -7936,7 +8677,7 @@ function number(flag, value) {
7936
8677
 
7937
8678
  // src/render/capture.ts
7938
8679
  import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile8 } from "node:fs/promises";
7939
- import { createRequire } from "node:module";
8680
+ import { createRequire as createRequire2 } from "node:module";
7940
8681
  import { homedir as homedir2 } from "node:os";
7941
8682
  import { join as join8 } from "node:path";
7942
8683
  import { pathToFileURL } from "node:url";
@@ -7945,15 +8686,15 @@ async function chromePath(need = "open the deck with") {
7945
8686
  if (explicit) return explicit;
7946
8687
  const { getInstalledBrowsers } = await import("@puppeteer/browsers");
7947
8688
  const cacheDir = process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer");
7948
- const installed = await getInstalledBrowsers({ cacheDir }).catch(() => []);
7949
- const found = installed.find((b) => b.browser === "chrome-headless-shell") ?? installed.find((b) => b.browser === "chrome");
8689
+ const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
8690
+ const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
7950
8691
  if (found) return found.executablePath;
7951
8692
  throw new Error(
7952
8693
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
7953
8694
  );
7954
8695
  }
7955
8696
  function runtimePath() {
7956
- return createRequire(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
8697
+ return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
7957
8698
  }
7958
8699
  function renderSeek(t2) {
7959
8700
  const player = window.__player;
@@ -8128,7 +8869,7 @@ function readFragments(html) {
8128
8869
  return out;
8129
8870
  }
8130
8871
  function holdsFor(beat, source, format, theme, sid, speed) {
8131
- const ctx = { source, format, theme, sid };
8872
+ const ctx = { source, format, theme, sid, start: 0 };
8132
8873
  const { scene, open } = stageScene(emitScene(beat, ctx), speed);
8133
8874
  return {
8134
8875
  holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
@@ -8152,15 +8893,15 @@ function assertHoldsAgree(scenes, fragments) {
8152
8893
  function place(scenes, spoken) {
8153
8894
  const out = [];
8154
8895
  for (const scene of scenes) {
8155
- const segments = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
8156
- if (segments.length === 0) continue;
8896
+ const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
8897
+ if (segments2.length === 0) continue;
8157
8898
  if (scene.holds.length === 0) {
8158
8899
  throw new Error(
8159
8900
  `${scene.id}: narrated but has no hold to speak at. Re-run \`narrate\` for this format.`
8160
8901
  );
8161
8902
  }
8162
- const { starts } = speechPlan(scene.open, scene.holds, segments);
8163
- for (const [i, segment] of segments.entries()) {
8903
+ const { starts } = speechPlan(scene.open, scene.holds, segments2);
8904
+ for (const [i, segment] of segments2.entries()) {
8164
8905
  const index = Math.min(segment.stop, scene.holds.length - 1);
8165
8906
  out.push({
8166
8907
  id: `${scene.id}.${segment.stop}`,
@@ -8176,9 +8917,9 @@ function place(scenes, spoken) {
8176
8917
  }
8177
8918
  return out;
8178
8919
  }
8179
- function assertFits(scenes, segments) {
8920
+ function assertFits(scenes, segments2) {
8180
8921
  for (const scene of scenes) {
8181
- const mine = segments.filter((s) => s.scene === scene.id);
8922
+ const mine = segments2.filter((s) => s.scene === scene.id);
8182
8923
  if (mine.length === 0) continue;
8183
8924
  const spoken = mine.reduce((sum, s) => sum + s.duration, 0);
8184
8925
  const need = scene.open + spoken;
@@ -8222,8 +8963,8 @@ function planTiming(input) {
8222
8963
  const staging = holdsFor(beat, source, format, theme, scene.id, speed);
8223
8964
  scene.holds = staging.holds;
8224
8965
  scene.open = staging.open;
8225
- const segments2 = narration?.beats[beat.id];
8226
- if (segments2?.length) spoken[scene.id] = segments2;
8966
+ const segments3 = narration?.beats[beat.id];
8967
+ if (segments3?.length) spoken[scene.id] = segments3;
8227
8968
  });
8228
8969
  const fragments = readFragments(composition);
8229
8970
  if (fragments) {
@@ -8232,18 +8973,18 @@ function planTiming(input) {
8232
8973
  fragments
8233
8974
  );
8234
8975
  }
8235
- const segments = place(scenes, spoken);
8236
- assertFits(scenes, segments);
8976
+ const segments2 = place(scenes, spoken);
8977
+ assertFits(scenes, segments2);
8237
8978
  return {
8238
8979
  version: 1,
8239
8980
  width: format.width,
8240
8981
  height: format.height,
8241
8982
  duration: readDuration(composition),
8242
8983
  lang: storyboard.lang,
8243
- audioDir: segments.length > 0 ? narration?.dir ?? "" : "",
8244
- voice: segments.length > 0 ? narration?.voice ?? "" : "",
8984
+ audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
8985
+ voice: segments2.length > 0 ? narration?.voice ?? "" : "",
8245
8986
  scenes,
8246
- segments
8987
+ segments: segments2
8247
8988
  };
8248
8989
  }
8249
8990
  function framePlan(timing, fps) {
@@ -8255,12 +8996,12 @@ function framePlan(timing, fps) {
8255
8996
  for (const scene of timing.scenes) {
8256
8997
  const first = f(scene.start);
8257
8998
  const last = f(scene.start + scene.duration);
8258
- const segments = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
8259
- if (segments.length === 0) {
8999
+ const segments2 = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
9000
+ if (segments2.length === 0) {
8260
9001
  pieces.push({ from: first, motion: last - first, freeze: 0 });
8261
9002
  continue;
8262
9003
  }
8263
- const tail3 = segments[segments.length - 1];
9004
+ const tail3 = segments2[segments2.length - 1];
8264
9005
  const silent = f(tail3.start + tail3.duration);
8265
9006
  if (silent > last) {
8266
9007
  throw new Error(
@@ -8268,7 +9009,7 @@ function framePlan(timing, fps) {
8268
9009
  );
8269
9010
  }
8270
9011
  pieces.push({ from: first, motion: last - first, freeze: 0 });
8271
- for (const segment of segments) {
9012
+ for (const segment of segments2) {
8272
9013
  const at = f(segment.start);
8273
9014
  audio.push({
8274
9015
  id: segment.id,
@@ -8395,7 +9136,7 @@ ${bands}
8395
9136
  </div>
8396
9137
  `;
8397
9138
  }
8398
- function measure2(count) {
9139
+ function measure3(count) {
8399
9140
  const doc = document;
8400
9141
  const probe2 = doc.getElementById("c0");
8401
9142
  if (probe2) {
@@ -8446,7 +9187,7 @@ async function renderCaptions(cues, style, deck, work) {
8446
9187
  await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
8447
9188
  await tab.goto(pathToFileURL2(page).href, { waitUntil: "load" });
8448
9189
  await tab.evaluate(() => document.fonts.ready);
8449
- const rects = await tab.evaluate(measure2, cues.length);
9190
+ const rects = await tab.evaluate(measure3, cues.length);
8450
9191
  const box = union(rects);
8451
9192
  const files = [];
8452
9193
  for (const [i, cue] of cues.entries()) {
@@ -8510,7 +9251,7 @@ ${tail3}`);
8510
9251
  }
8511
9252
  }
8512
9253
  function runLive(file, args) {
8513
- return new Promise((resolve5, reject) => {
9254
+ return new Promise((resolve6, reject) => {
8514
9255
  const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
8515
9256
  child.on("error", (err) => {
8516
9257
  reject(
@@ -8518,7 +9259,7 @@ function runLive(file, args) {
8518
9259
  );
8519
9260
  });
8520
9261
  child.on("close", (code, signal) => {
8521
- if (code === 0) resolve5();
9262
+ if (code === 0) resolve6();
8522
9263
  else if (signal) {
8523
9264
  reject(
8524
9265
  new Error(
@@ -8711,148 +9452,1416 @@ async function render(opts) {
8711
9452
  "ffmpeg",
8712
9453
  respeedArgs(out, playback, tempoChain(playback), shot.fps, muxed.hasAudio, fast)
8713
9454
  );
8714
- await rename2(fast, out);
9455
+ await rename2(fast, out);
9456
+ }
9457
+ const final = await probe(out);
9458
+ if (plan.audio.length > 0 && !final.hasAudio) {
9459
+ throw new Error(`${out} came out with no audio stream. The mux silently dropped it.`);
9460
+ }
9461
+ return {
9462
+ out,
9463
+ ...srt ? { srt: srtPath } : {},
9464
+ seconds: final.seconds,
9465
+ frames: final.frames,
9466
+ segments: plan.audio.length,
9467
+ burned: burn,
9468
+ playback,
9469
+ // The cues as written, not `plan.cues` — at `playback > 1` these are the
9470
+ // scaled ones, so the number the caller prints is the rate on the file it
9471
+ // just got rather than the rate on a timeline that no longer exists.
9472
+ captionCps: p95CueRate(cues)
9473
+ };
9474
+ } finally {
9475
+ if (!opts.keep) await rm5(work, { recursive: true, force: true });
9476
+ }
9477
+ }
9478
+ async function readTiming(deck) {
9479
+ const path2 = join10(deck, TIMING_FILE);
9480
+ const text2 = await readFile11(path2, "utf8").catch(() => {
9481
+ throw new Error(
9482
+ `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
9483
+ );
9484
+ });
9485
+ const timing = JSON.parse(text2);
9486
+ if (timing.version !== 1) throw new Error(`${path2} is version ${timing.version}; expected 1.`);
9487
+ return timing;
9488
+ }
9489
+ function assertCapture(timing, shot) {
9490
+ if (shot.width !== timing.width || shot.height !== timing.height) {
9491
+ throw new Error(
9492
+ `the capture is ${shot.width}\xD7${shot.height} but ${TIMING_FILE} describes a ${timing.width}\xD7${timing.height} deck.`
9493
+ );
9494
+ }
9495
+ if (Math.abs(shot.seconds - timing.duration) > 0.5) {
9496
+ throw new Error(
9497
+ `the capture is ${shot.seconds.toFixed(2)}s but ${TIMING_FILE} describes a ${timing.duration.toFixed(2)}s deck. One of them is stale.`
9498
+ );
9499
+ }
9500
+ }
9501
+ function scaleCue(cue, factor) {
9502
+ const at = (t2) => Math.round(t2 / factor * 1e3) / 1e3;
9503
+ return { ...cue, start: at(cue.start), end: at(cue.end) };
9504
+ }
9505
+ async function capture(deck, out, opts, log) {
9506
+ const args = ["hyperframes", "render", deck, "-o", out];
9507
+ if (opts.fps) args.push("--fps", String(opts.fps));
9508
+ if (opts.quality) args.push("--quality", opts.quality);
9509
+ if (opts.workers) args.push("--workers", opts.workers);
9510
+ args.push("--protocol-timeout", String(opts.protocolTimeoutMs ?? 9e5));
9511
+ log("render: capturing the composition \u2014 this is the long part");
9512
+ await runLive("npx", args);
9513
+ return out;
9514
+ }
9515
+ async function retime(raw2, plan, work, log) {
9516
+ if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
9517
+ const list = [];
9518
+ for (const [i, piece] of plan.pieces.entries()) {
9519
+ const file = join10(work, `p${String(i).padStart(4, "0")}.ts`);
9520
+ await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
9521
+ list.push(file);
9522
+ if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
9523
+ log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
9524
+ }
9525
+ }
9526
+ const listFile = join10(work, "pieces.txt");
9527
+ await writeFile10(
9528
+ listFile,
9529
+ `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
9530
+ `
9531
+ );
9532
+ const joined = join10(work, "retimed.mp4");
9533
+ await runTool("ffmpeg", [
9534
+ "-y",
9535
+ "-hide_banner",
9536
+ "-loglevel",
9537
+ "error",
9538
+ "-f",
9539
+ "concat",
9540
+ "-safe",
9541
+ "0",
9542
+ "-i",
9543
+ listFile,
9544
+ "-c",
9545
+ "copy",
9546
+ "-movflags",
9547
+ "+faststart",
9548
+ joined
9549
+ ]);
9550
+ return joined;
9551
+ }
9552
+ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
9553
+ const inputs = plan.audio.map((a) => ({
9554
+ file: join10(deck, timing.audioDir, a.audio),
9555
+ delayMs: a.delayMs
9556
+ }));
9557
+ const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
9558
+ const graph = [];
9559
+ const band = burnCues ? await renderCaptions(
9560
+ burnCues,
9561
+ burnStyle(timing.width, timing.height, familyFor(timing.lang) ?? "Arial"),
9562
+ deck,
9563
+ work
9564
+ ) : void 0;
9565
+ if (band) {
9566
+ log(`render: drew ${band.files.length} caption band(s), ${band.width}\xD7${band.height}px`);
9567
+ graph.push(overlayGraph(burnCues, band));
9568
+ }
9569
+ if (band) args.push(...overlayInputs(band));
9570
+ for (const input of inputs) args.push("-i", input.file);
9571
+ if (inputs.length > 0) {
9572
+ graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
9573
+ }
9574
+ if (graph.length > 0) {
9575
+ const script = join10(work, "mux.filter");
9576
+ await writeFile10(script, `${graph.join(";\n")}
9577
+ `);
9578
+ args.push("-filter_complex_script", script);
9579
+ }
9580
+ args.push("-map", burnCues ? "[vout]" : "0:v");
9581
+ if (inputs.length > 0) args.push("-map", "[aout]", "-c:a", "aac", "-b:a", "192k");
9582
+ if (burnCues) {
9583
+ args.push("-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p");
9584
+ } else {
9585
+ args.push("-c:v", "copy");
9586
+ }
9587
+ args.push("-movflags", "+faststart", out);
9588
+ log(
9589
+ `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
9590
+ );
9591
+ await mkdir8(dirname3(out), { recursive: true });
9592
+ await runTool("ffmpeg", args, { cwd: work });
9593
+ }
9594
+
9595
+ // src/source/harvest.ts
9596
+ import { createHash as createHash7 } from "node:crypto";
9597
+ import { copyFile, mkdir as mkdir9, rm as rm7, stat, writeFile as writeFile11 } from "node:fs/promises";
9598
+ import { basename as basename3, join as join11, resolve as resolve4 } from "node:path";
9599
+
9600
+ // src/source/readability.ts
9601
+ var CONTENT_MARKER = "data-ds-content";
9602
+ function readContentRegion() {
9603
+ const MARKER = "data-ds-content";
9604
+ const MIN_TEXT = 250;
9605
+ const MAX_ANCESTORS = 5;
9606
+ const SIBLING_FRACTION = 0.2;
9607
+ const MIN_SIBLING_SCORE = 10;
9608
+ const MIN_PARAGRAPH = 25;
9609
+ const UNLIKELY = /-ad-|ai2html|banner|breadcrumb|combx|comment|community|consent|cookie|disqus|extra|footer|gdpr|legends|masthead|menu|modal|newsletter|paywall|popup|promo|related|remark|replies|rss|share|shoutbox|sidebar|skyscraper|social|sponsor|subscribe|supplemental|pagination|pager|toolbar|yom-remote/i;
9610
+ const MAYBE = /and|article|body|column|content|main|shadow|story/i;
9611
+ const POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|post|story|text|blog/i;
9612
+ const NEGATIVE = /-ad-|banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i;
9613
+ const CHROME_ROLES = /* @__PURE__ */ new Set([
9614
+ "alert",
9615
+ "alertdialog",
9616
+ "banner",
9617
+ "complementary",
9618
+ "contentinfo",
9619
+ "dialog",
9620
+ "menu",
9621
+ "menubar",
9622
+ "navigation",
9623
+ "search",
9624
+ "toolbar",
9625
+ "tooltip"
9626
+ ]);
9627
+ const CHROME_TAGS = /* @__PURE__ */ new Set([
9628
+ "nav",
9629
+ "aside",
9630
+ "footer",
9631
+ "script",
9632
+ "style",
9633
+ "noscript",
9634
+ "form",
9635
+ "svg",
9636
+ "canvas",
9637
+ "template",
9638
+ "button",
9639
+ "select",
9640
+ "textarea"
9641
+ ]);
9642
+ const SCORED = "p, td, pre, section, h2, h3, h4, h5, h6";
9643
+ function textOf2(node) {
9644
+ return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
9645
+ }
9646
+ function labelOf(el) {
9647
+ return `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`;
9648
+ }
9649
+ function nameOf(el) {
9650
+ const id2 = el.getAttribute("id");
9651
+ const cls = (el.getAttribute("class") ?? "").trim().split(/\s+/)[0];
9652
+ return el.tagName.toLowerCase() + (id2 ? `#${id2}` : "") + (cls ? `.${cls}` : "");
9653
+ }
9654
+ function pathOf(el) {
9655
+ const parts = [];
9656
+ let node = el;
9657
+ while (node && parts.length < 3) {
9658
+ parts.unshift(nameOf(node));
9659
+ node = node.parentElement;
9660
+ }
9661
+ return parts.join(" > ");
9662
+ }
9663
+ function round7(n3) {
9664
+ return Math.round(n3 * 1e3) / 1e3;
9665
+ }
9666
+ function linkDensity(el) {
9667
+ const total = textOf2(el).length;
9668
+ if (total === 0) return 0;
9669
+ let inLinks = 0;
9670
+ for (const a of el.querySelectorAll("a")) {
9671
+ const href = a.getAttribute("href") ?? "";
9672
+ inLinks += textOf2(a).length * (href.startsWith("#") ? 0.3 : 1);
9673
+ }
9674
+ return inLinks / total;
9675
+ }
9676
+ function classWeight(el) {
9677
+ let weight = 0;
9678
+ for (const label of [el.getAttribute("class") ?? "", el.getAttribute("id") ?? ""]) {
9679
+ if (label === "") continue;
9680
+ if (NEGATIVE.test(label)) weight -= 25;
9681
+ if (POSITIVE.test(label)) weight += 25;
9682
+ }
9683
+ return weight;
9684
+ }
9685
+ function tagBonus(tag) {
9686
+ if (tag === "div") return 5;
9687
+ if (tag === "pre" || tag === "td" || tag === "blockquote") return 3;
9688
+ if (tag === "th" || /^h[1-6]$/.test(tag)) return -5;
9689
+ if (/^(address|ol|ul|dl|dd|dt|li|form)$/.test(tag)) return -3;
9690
+ return 0;
9691
+ }
9692
+ const scores = /* @__PURE__ */ new Map();
9693
+ function scoreOf(el) {
9694
+ const at = scores.get(el);
9695
+ if (at !== void 0) return at;
9696
+ const start = tagBonus(el.tagName.toLowerCase()) + classWeight(el);
9697
+ scores.set(el, start);
9698
+ return start;
9699
+ }
9700
+ const removed = [];
9701
+ function strip(el) {
9702
+ const parent = el.parentElement;
9703
+ if (!parent) return;
9704
+ removed.push({ node: el, parent, next: el.nextSibling });
9705
+ el.remove();
9706
+ }
9707
+ function restore() {
9708
+ for (let i = removed.length - 1; i >= 0; i--) {
9709
+ const at = removed[i];
9710
+ if (at) at.parent.insertBefore(at.node, at.next);
9711
+ }
9712
+ removed.length = 0;
9713
+ }
9714
+ function declined(reason3, candidates2) {
9715
+ restore();
9716
+ return {
9717
+ marked: false,
9718
+ reason: reason3,
9719
+ candidates: candidates2,
9720
+ merged: 0,
9721
+ stripped: 0,
9722
+ text: 0,
9723
+ mediaDropped: 0
9724
+ };
9725
+ }
9726
+ try {
9727
+ if (!document.body) {
9728
+ return declined("the document has no <body>, so there is no region to choose", []);
9729
+ }
9730
+ const mediaBefore = document.querySelectorAll("video, iframe").length;
9731
+ for (const el of [...document.body.querySelectorAll("*")]) {
9732
+ if (!el.isConnected) continue;
9733
+ const tag = el.tagName.toLowerCase();
9734
+ if (CHROME_TAGS.has(tag)) {
9735
+ strip(el);
9736
+ continue;
9737
+ }
9738
+ if (tag === "header" && el.parentElement?.closest("article, main, section") == null) {
9739
+ strip(el);
9740
+ continue;
9741
+ }
9742
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
9743
+ strip(el);
9744
+ continue;
9745
+ }
9746
+ if (CHROME_ROLES.has((el.getAttribute("role") ?? "").toLowerCase())) {
9747
+ strip(el);
9748
+ continue;
9749
+ }
9750
+ const label = labelOf(el);
9751
+ if (UNLIKELY.test(label) && !MAYBE.test(label) && !el.querySelector("main, article")) {
9752
+ strip(el);
9753
+ }
9754
+ }
9755
+ for (const el of document.body.querySelectorAll(SCORED)) {
9756
+ const text3 = textOf2(el);
9757
+ if (text3.length < MIN_PARAGRAPH) continue;
9758
+ const base = 1 + (text3.split(",").length - 1) + Math.min(Math.floor(text3.length / 100), 3);
9759
+ let node = el.parentElement;
9760
+ let level = 0;
9761
+ while (node && node !== document.documentElement && level < MAX_ANCESTORS) {
9762
+ const divider = level === 0 ? 1 : level === 1 ? 2 : level * 3;
9763
+ scores.set(node, scoreOf(node) + base / divider);
9764
+ node = node.parentElement;
9765
+ level++;
9766
+ }
9767
+ }
9768
+ const ranked = [];
9769
+ for (const [el, content] of scores) {
9770
+ const density = linkDensity(el);
9771
+ ranked.push({
9772
+ el,
9773
+ report: {
9774
+ path: pathOf(el),
9775
+ content: round7(content),
9776
+ linkDensity: round7(density),
9777
+ // THE WHOLE POINT OF THE PASS. A rail of related headlines and an
9778
+ // article of the same length hold the same number of characters; only
9779
+ // this term separates them, and it is why the discount is a
9780
+ // multiplier rather than a subtraction — a candidate that is entirely
9781
+ // links scores zero however long it is.
9782
+ score: round7(content * (1 - density)),
9783
+ text: textOf2(el).length
9784
+ }
9785
+ });
9786
+ }
9787
+ ranked.sort((a, b) => b.report.score - a.report.score);
9788
+ const report2 = ranked.slice(0, 5).map((r) => r.report);
9789
+ let top = ranked[0];
9790
+ if (!top) {
9791
+ return declined(
9792
+ `nothing scored: the page has no paragraph of ${MIN_PARAGRAPH} characters or more outside its chrome. Save the page and ingest the file if a script builds its body.`,
9793
+ report2
9794
+ );
9795
+ }
9796
+ const byElement = new Map(ranked.map((r) => [r.el, r]));
9797
+ const floor = top.report.score / 3;
9798
+ let last = top.report.score;
9799
+ let up = top.el.parentElement;
9800
+ while (up && up !== document.body && up !== document.documentElement) {
9801
+ const here = byElement.get(up);
9802
+ if (here) {
9803
+ if (here.report.score < floor) break;
9804
+ if (here.report.score > last) {
9805
+ top = here;
9806
+ break;
9807
+ }
9808
+ last = here.report.score;
9809
+ }
9810
+ up = up.parentElement;
9811
+ }
9812
+ if (top.el === document.body) {
9813
+ const text3 = textOf2(document.body).length;
9814
+ if (text3 < MIN_TEXT) {
9815
+ return declined(
9816
+ `the page holds ${text3} characters of text outside its chrome, under the ${MIN_TEXT} this pass needs to be sure of a region; left it for the simpler heuristic`,
9817
+ report2
9818
+ );
9819
+ }
9820
+ document.body.setAttribute(MARKER, "");
9821
+ return {
9822
+ marked: true,
9823
+ reason: `chose <body>: nothing narrower than the page itself scored, ${text3} characters`,
9824
+ candidates: report2,
9825
+ merged: 0,
9826
+ stripped: removed.length,
9827
+ text: text3,
9828
+ mediaDropped: mediaBefore - document.body.querySelectorAll("video, iframe").length
9829
+ };
9830
+ }
9831
+ const parent = top.el.parentElement;
9832
+ if (!parent) {
9833
+ return declined("the winning candidate is not in the document any more", report2);
9834
+ }
9835
+ const threshold = Math.max(MIN_SIBLING_SCORE, top.report.score * SIBLING_FRACTION);
9836
+ const topClass = top.el.getAttribute("class") ?? "";
9837
+ const keep = [];
9838
+ for (const sib of [...parent.children]) {
9839
+ if (sib === top.el) {
9840
+ keep.push(sib);
9841
+ continue;
9842
+ }
9843
+ const twin = topClass !== "" && sib.getAttribute("class") === topClass;
9844
+ const bonus = twin ? top.report.score * SIBLING_FRACTION : 0;
9845
+ const here = byElement.get(sib);
9846
+ if (here && here.report.score + bonus >= threshold) {
9847
+ keep.push(sib);
9848
+ continue;
9849
+ }
9850
+ if (sib.tagName.toLowerCase() === "p") {
9851
+ const text3 = textOf2(sib);
9852
+ const density = linkDensity(sib);
9853
+ const long = text3.length > 80 && density < 0.25;
9854
+ const sentence = text3.length > 0 && text3.length <= 80 && density === 0 && /\.( |$)/.test(text3);
9855
+ if (long || sentence) keep.push(sib);
9856
+ }
9857
+ }
9858
+ const text2 = keep.reduce((n3, el) => n3 + textOf2(el).length, 0);
9859
+ if (text2 < MIN_TEXT) {
9860
+ return declined(
9861
+ `the best region (${top.report.path}) holds ${text2} characters, under the ${MIN_TEXT} this pass needs to be sure of it; left the page for the simpler heuristic`,
9862
+ report2
9863
+ );
9864
+ }
9865
+ let region;
9866
+ if (keep.length === 1 && keep[0]) {
9867
+ region = keep[0];
9868
+ } else {
9869
+ const box = document.createElement("div");
9870
+ for (const el of keep) box.appendChild(el);
9871
+ document.body.appendChild(box);
9872
+ region = box;
9873
+ }
9874
+ region.setAttribute(MARKER, "");
9875
+ return {
9876
+ marked: true,
9877
+ reason: `chose ${top.report.path} \u2014 score ${top.report.score}, link density ${top.report.linkDensity}, ${textOf2(region).length} characters` + (keep.length > 1 ? `, with ${keep.length - 1} sibling(s) merged in` : ""),
9878
+ candidates: report2,
9879
+ merged: keep.length - 1,
9880
+ stripped: removed.length,
9881
+ text: textOf2(region).length,
9882
+ mediaDropped: mediaBefore - region.querySelectorAll("video, iframe").length
9883
+ };
9884
+ } catch (e) {
9885
+ return declined(
9886
+ `the readability pass failed inside the page (${e instanceof Error ? e.message : String(e)}); the page was left as it was found and the simpler heuristic still applies`,
9887
+ []
9888
+ );
9889
+ }
9890
+ }
9891
+
9892
+ // src/source/transcode.ts
9893
+ import { readFile as readFile12, rm as rm6 } from "node:fs/promises";
9894
+ import { basename as basename2 } from "node:path";
9895
+ var CLIP_EDGE_PX = 1280;
9896
+ var MAX_CLIP_SECONDS = 60;
9897
+ var TIMEOUT_MS = 6e5;
9898
+ function fitBox(width, height, maxEdgePx) {
9899
+ const scale = Math.min(1, maxEdgePx / width, maxEdgePx / height);
9900
+ return { width: even(width * scale), height: even(height * scale) };
9901
+ }
9902
+ function even(n3) {
9903
+ return Math.max(2, Math.floor(Math.round(n3) / 2) * 2);
9904
+ }
9905
+ function transcodeArgs(input, out, plan) {
9906
+ return [
9907
+ "-y",
9908
+ "-hide_banner",
9909
+ "-loglevel",
9910
+ "error",
9911
+ "-i",
9912
+ input,
9913
+ ...plan.seconds === void 0 ? [] : ["-t", plan.seconds.toFixed(3)],
9914
+ "-an",
9915
+ "-map_metadata",
9916
+ "-1",
9917
+ "-vf",
9918
+ `scale=${plan.width}:${plan.height}`,
9919
+ "-c:v",
9920
+ "libvpx-vp9",
9921
+ "-crf",
9922
+ "32",
9923
+ "-b:v",
9924
+ "0",
9925
+ "-deadline",
9926
+ "good",
9927
+ "-cpu-used",
9928
+ "4",
9929
+ "-row-mt",
9930
+ "1",
9931
+ "-pix_fmt",
9932
+ "yuv420p",
9933
+ "-fflags",
9934
+ "+bitexact",
9935
+ "-flags:v",
9936
+ "+bitexact",
9937
+ out
9938
+ ];
9939
+ }
9940
+ var probed = /* @__PURE__ */ new Map();
9941
+ function installed(file) {
9942
+ const asked = probed.get(file);
9943
+ if (asked !== void 0) return asked;
9944
+ const answer = runTool(file, ["-version"], { timeoutMs: 5e3 }).then(
9945
+ () => true,
9946
+ () => false
9947
+ );
9948
+ probed.set(file, answer);
9949
+ return answer;
9950
+ }
9951
+ async function transcode(input, out, opts = {}) {
9952
+ const file = opts.ffmpeg ?? "ffmpeg";
9953
+ const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
9954
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
9955
+ const name = basename2(input);
9956
+ const source = videoSize(await readFile12(input));
9957
+ const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
9958
+ const kept = (why3) => ({
9959
+ path: input,
9960
+ width: source.width,
9961
+ height: source.height,
9962
+ seconds: source.seconds,
9963
+ transcoded: false,
9964
+ warnings: [`${name} was shipped as the page served it: ${why3}`]
9965
+ });
9966
+ if (!await installed(file)) {
9967
+ return kept(
9968
+ `${file} is not installed, or not on PATH, so it could not be shrunk to ${box.width}x${box.height} VP9 \u2014 \`brew install ffmpeg\` / \`apt install ffmpeg\` and re-ingest to spend fewer bytes and a shorter render on it.`
9969
+ );
9970
+ }
9971
+ const trim = source.seconds === void 0 || source.seconds > maxSeconds;
9972
+ const started = Date.now();
9973
+ try {
9974
+ await runTool(
9975
+ file,
9976
+ transcodeArgs(input, out, { ...box, ...trim ? { seconds: maxSeconds } : {} }),
9977
+ { timeoutMs }
9978
+ );
9979
+ } catch (err) {
9980
+ await rm6(out, { force: true });
9981
+ const over = Date.now() - started >= timeoutMs;
9982
+ return kept(
9983
+ over ? `${file} ran past its ${(timeoutMs / 1e3).toFixed(0)}s budget and was stopped (raise timeoutMs, or cap the clip harder with maxSeconds).` : `${file} failed \u2014 ${err instanceof Error ? err.message : String(err)}`
9984
+ );
9985
+ }
9986
+ let measured;
9987
+ try {
9988
+ measured = videoSize(await readFile12(out));
9989
+ } catch (err) {
9990
+ await rm6(out, { force: true });
9991
+ return kept(
9992
+ `the webm ${file} wrote could not be measured \u2014 ${err instanceof Error ? err.message : String(err)}`
9993
+ );
9994
+ }
9995
+ const warnings = [];
9996
+ if (trim && measured.seconds !== void 0 && measured.seconds >= maxSeconds - 0.05) {
9997
+ warnings.push(
9998
+ `${name} was trimmed to the ${maxSeconds}s cap (maxSeconds) from ${source.seconds === void 0 ? "an unstated length" : `${source.seconds.toFixed(1)}s`}: a clip is pre-decoded to one still per output frame, so everything past the cap is bytes and decode that no beat is long enough to reach.`
9999
+ );
10000
+ }
10001
+ return {
10002
+ path: out,
10003
+ width: measured.width,
10004
+ height: measured.height,
10005
+ seconds: measured.seconds,
10006
+ transcoded: true,
10007
+ warnings
10008
+ };
10009
+ }
10010
+
10011
+ // src/source/harvest.ts
10012
+ var HTML_MAX_BYTES = 8 * 1024 * 1024;
10013
+ var ASSET_MAX_BYTES = 32 * 1024 * 1024;
10014
+ var TIMEOUT_MS2 = 2e4;
10015
+ var MAX_ASSETS = 40;
10016
+ var MAX_CLIPS = 4;
10017
+ var MAX_TOTAL_BYTES = 96 * 1024 * 1024;
10018
+ var MAX_WALL_MS = 18e4;
10019
+ var MIN_FIGURE_PX = 64;
10020
+ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
10021
+ async function harvest(url2, dir, opts = {}) {
10022
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
10023
+ const warnings = [];
10024
+ const assets = resolve4(dir);
10025
+ await mkdir9(assets, { recursive: true });
10026
+ const page = await fetchGuarded(url2, {
10027
+ maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
10028
+ timeoutMs,
10029
+ accept: HTML_TYPES,
10030
+ ...opts.allowLoopback === true ? { allowLoopback: true } : {}
10031
+ });
10032
+ const html = decodeHtml(page.bytes, page.contentType, url2, warnings);
10033
+ const { seen, pick } = await readInBrowser(html, timeoutMs);
10034
+ if (!pick.marked) {
10035
+ warnings.push(
10036
+ `the article could not be scored out of this page \u2014 ${pick.reason}. The densest container was taken instead: read the section count below, and if it is the whole page rather than the piece, save the article and ingest the file.`
10037
+ );
10038
+ } else if (pick.mediaDropped > 0) {
10039
+ warnings.push(
10040
+ `${pick.mediaDropped} video or embed(s) sat outside the article region and were dropped with the page's chrome. If one of them was the video the piece is about, ingest the saved page instead, where the region is the whole document.`
10041
+ );
10042
+ }
10043
+ const base = absolute(seen.base, page.url) ?? page.url;
10044
+ const local = await localise(seen, base, assets, opts, timeoutMs, warnings);
10045
+ return {
10046
+ markdown: toMarkdown(titled(seen.title, local.blocks)),
10047
+ assets: local.assets,
10048
+ clips: local.clips,
10049
+ warnings,
10050
+ title: seen.title
10051
+ };
10052
+ }
10053
+ function titled(title2, blocks) {
10054
+ const first = blocks[0];
10055
+ const already = first?.kind === "heading" && first.depth === 1 && first.text.trim() === title2.trim();
10056
+ return title2 && !already ? [{ kind: "heading", depth: 1, text: title2 }, ...blocks] : [...blocks];
10057
+ }
10058
+ function decodeHtml(bytes, contentType, url2, warnings) {
10059
+ const declared = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
10060
+ const head = bytes.toString("latin1", 0, Math.min(bytes.length, 4096));
10061
+ const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
10062
+ const label = declared ?? meta;
10063
+ if (label === void 0) return bytes.toString("utf8");
10064
+ try {
10065
+ return new TextDecoder(label).decode(bytes);
10066
+ } catch {
10067
+ warnings.push(
10068
+ `${url2} declares charset "${label}", which this Node cannot decode; read as UTF-8 instead. Non-ASCII text may be wrong \u2014 serve the page as UTF-8, or save it and ingest the file.`
10069
+ );
10070
+ return bytes.toString("utf8");
10071
+ }
10072
+ }
10073
+ async function readInBrowser(html, timeoutMs) {
10074
+ const { default: puppeteer } = await import("puppeteer-core");
10075
+ const browser = await puppeteer.launch({
10076
+ executablePath: await chromePath("read the page with"),
10077
+ headless: true,
10078
+ // Chrome's own background traffic — variations, safe browsing, first-run
10079
+ // pings — never goes through page interception, so it is switched off here
10080
+ // rather than assumed absent. It is not an SSRF path, but "the browser makes
10081
+ // no requests" should be true of the whole process, not just of the tab.
10082
+ args: [
10083
+ "--disable-background-networking",
10084
+ "--disable-extensions",
10085
+ "--no-default-browser-check",
10086
+ "--no-first-run"
10087
+ ]
10088
+ });
10089
+ try {
10090
+ const page = await browser.newPage();
10091
+ await page.setRequestInterception(true);
10092
+ page.on("request", (request) => {
10093
+ request.abort().catch(() => {
10094
+ });
10095
+ });
10096
+ await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
10097
+ const pick = await page.evaluate(readContentRegion);
10098
+ return { seen: await page.evaluate(readDom, CONTENT_MARKER), pick };
10099
+ } finally {
10100
+ await browser.close().catch(() => {
10101
+ });
10102
+ }
10103
+ }
10104
+ function readDom(marker) {
10105
+ const SKIP = /* @__PURE__ */ new Set([
10106
+ "nav",
10107
+ "aside",
10108
+ "footer",
10109
+ "script",
10110
+ "style",
10111
+ "noscript",
10112
+ "form",
10113
+ "svg",
10114
+ "canvas",
10115
+ "template",
10116
+ "button",
10117
+ "select",
10118
+ "textarea"
10119
+ ]);
10120
+ const BLOCKY = /* @__PURE__ */ new Set([
10121
+ "p",
10122
+ "div",
10123
+ "section",
10124
+ "article",
10125
+ "main",
10126
+ "ul",
10127
+ "ol",
10128
+ "table",
10129
+ "figure",
10130
+ "blockquote",
10131
+ "pre",
10132
+ "h1",
10133
+ "h2",
10134
+ "h3",
10135
+ "h4",
10136
+ "h5",
10137
+ "h6"
10138
+ ]);
10139
+ const PLAYER = /(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|(?:player\.)?vimeo\.com\/|dailymotion\.com\/video\/|\.(?:mp4|webm|m4v|mov)(?:[?#]|$))/i;
10140
+ function words2(node) {
10141
+ return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
10142
+ }
10143
+ function skipped(el) {
10144
+ return SKIP.has(el.tagName.toLowerCase()) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
10145
+ }
10146
+ function pickSrc(el) {
10147
+ const candidates2 = (el.getAttribute("srcset") ?? "").split(",").map((part) => part.trim().split(/\s+/)).filter((bits) => (bits[0] ?? "") !== "").map((bits) => ({ url: bits[0] ?? "", w: Number.parseInt(bits[1] ?? "", 10) || 0 }));
10148
+ let widest2 = candidates2[0];
10149
+ for (const candidate of candidates2) if (candidate.w >= (widest2?.w ?? -1)) widest2 = candidate;
10150
+ return widest2?.url ?? el.getAttribute("src") ?? el.getAttribute("data-src") ?? "";
10151
+ }
10152
+ function imageOf(el, caption) {
10153
+ const src = pickSrc(el);
10154
+ if (!src) return void 0;
10155
+ return { kind: "image", src, alt: words2(el.getAttributeNode("alt")), caption };
10156
+ }
10157
+ function videoOf(el, caption) {
10158
+ const source = el.querySelector("source");
10159
+ return {
10160
+ kind: "video",
10161
+ src: el.getAttribute("src") ?? source?.getAttribute("src") ?? "",
10162
+ poster: el.getAttribute("poster") ?? "",
10163
+ href: "",
10164
+ caption
10165
+ };
10166
+ }
10167
+ function figureOf(el, out) {
10168
+ const caption = words2(el.querySelector("figcaption"));
10169
+ const video = el.querySelector("video");
10170
+ if (video) {
10171
+ out.push(videoOf(video, caption));
10172
+ return;
10173
+ }
10174
+ const image2 = el.querySelector("img");
10175
+ const block2 = image2 ? imageOf(image2, caption) : void 0;
10176
+ if (block2) {
10177
+ out.push(block2);
10178
+ return;
10179
+ }
10180
+ const text2 = words2(el);
10181
+ if (text2) out.push({ kind: "paragraph", text: text2 });
10182
+ }
10183
+ function paragraphOf(el, out) {
10184
+ const solid = [...el.childNodes].filter(
10185
+ (n3) => n3.nodeType !== Node.TEXT_NODE || (n3.textContent ?? "").trim() !== ""
10186
+ );
10187
+ const only = solid.length === 1 ? solid[0] : void 0;
10188
+ if (only instanceof HTMLAnchorElement && PLAYER.test(only.getAttribute("href") ?? "")) {
10189
+ const href = only.getAttribute("href") ?? "";
10190
+ out.push({ kind: "video", src: "", poster: "", href, caption: words2(only) });
10191
+ return;
10192
+ }
10193
+ let buffer = "";
10194
+ const flush = () => {
10195
+ const text2 = buffer.replace(/\s+/g, " ").trim();
10196
+ if (text2) out.push({ kind: "paragraph", text: text2 });
10197
+ buffer = "";
10198
+ };
10199
+ const scan = (node) => {
10200
+ if (node.nodeType === Node.TEXT_NODE) {
10201
+ buffer += node.textContent ?? "";
10202
+ return;
10203
+ }
10204
+ if (!(node instanceof Element)) return;
10205
+ if (skipped(node)) return;
10206
+ const tag = node.tagName.toLowerCase();
10207
+ if (tag === "br") {
10208
+ buffer += " ";
10209
+ return;
10210
+ }
10211
+ if (tag === "img" || tag === "video" || BLOCKY.has(tag)) {
10212
+ flush();
10213
+ block(node, out);
10214
+ return;
10215
+ }
10216
+ for (const kid of node.childNodes) scan(kid);
10217
+ };
10218
+ for (const kid of el.childNodes) scan(kid);
10219
+ flush();
10220
+ }
10221
+ function listOf(el) {
10222
+ return {
10223
+ kind: "list",
10224
+ ordered: el.tagName === "OL",
10225
+ // A nested list flattens into its parent item, exactly as `blockText` in
10226
+ // src/source/markdown.ts already flattens one on the way back out.
10227
+ items: [...el.children].filter((li) => li.tagName === "LI").map(words2).filter((text2) => text2 !== "")
10228
+ };
10229
+ }
10230
+ function tableOf(el) {
10231
+ const rows = [...el.querySelectorAll("tr")].map(
10232
+ (tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH").map(words2)
10233
+ );
10234
+ const [head, ...body] = rows;
10235
+ return head ? { kind: "table", columns: head, rows: body } : void 0;
10236
+ }
10237
+ function block(el, out) {
10238
+ if (skipped(el)) return;
10239
+ const tag = el.tagName.toLowerCase();
10240
+ if (/^h[1-6]$/.test(tag)) {
10241
+ const text2 = words2(el);
10242
+ if (text2) out.push({ kind: "heading", depth: Number(tag[1]), text: text2 });
10243
+ return;
10244
+ }
10245
+ if (tag === "p" || tag === "blockquote") {
10246
+ paragraphOf(el, out);
10247
+ return;
10248
+ }
10249
+ if (tag === "ul" || tag === "ol") {
10250
+ const list = listOf(el);
10251
+ if (list.kind === "list" && list.items.length > 0) out.push(list);
10252
+ return;
10253
+ }
10254
+ if (tag === "table") {
10255
+ const table = tableOf(el);
10256
+ if (table) out.push(table);
10257
+ return;
10258
+ }
10259
+ if (tag === "figure") {
10260
+ figureOf(el, out);
10261
+ return;
10262
+ }
10263
+ if (tag === "img") {
10264
+ const image2 = imageOf(el, "");
10265
+ if (image2) out.push(image2);
10266
+ return;
10267
+ }
10268
+ if (tag === "video") {
10269
+ out.push(videoOf(el, ""));
10270
+ return;
10271
+ }
10272
+ if (tag === "iframe") {
10273
+ const href = el.getAttribute("src") ?? "";
10274
+ if (PLAYER.test(href)) {
10275
+ out.push({
10276
+ kind: "video",
10277
+ src: "",
10278
+ poster: "",
10279
+ href,
10280
+ caption: words2(el.getAttributeNode("title"))
10281
+ });
10282
+ }
10283
+ return;
10284
+ }
10285
+ if (tag === "pre") {
10286
+ const text2 = (el.textContent ?? "").replace(/\s+$/, "");
10287
+ if (text2) out.push({ kind: "code", text: text2 });
10288
+ return;
10289
+ }
10290
+ const direct = [...el.childNodes].some(
10291
+ (n3) => n3.nodeType === Node.TEXT_NODE && (n3.textContent ?? "").trim() !== ""
10292
+ );
10293
+ if (direct) {
10294
+ paragraphOf(el, out);
10295
+ return;
10296
+ }
10297
+ walk(el, out);
10298
+ }
10299
+ function walk(el, out) {
10300
+ for (const kid of el.children) block(kid, out);
10301
+ }
10302
+ function score(el) {
10303
+ let text2 = 0;
10304
+ for (const p of el.querySelectorAll("p, li, td, h1, h2, h3, h4, h5, h6")) {
10305
+ text2 += (p.textContent ?? "").trim().length;
10306
+ }
10307
+ let links = 0;
10308
+ for (const a of el.querySelectorAll("a")) links += (a.textContent ?? "").trim().length;
10309
+ return text2 - links;
10310
+ }
10311
+ function pickRoot() {
10312
+ const marked = document.querySelector(`[${marker}]`);
10313
+ if (marked) return marked;
10314
+ const main = document.querySelector("main");
10315
+ if (main && score(main) > 0) return main;
10316
+ const article = document.querySelector("article");
10317
+ if (article && score(article) > 0) return article;
10318
+ let best = document.body;
10319
+ let top = score(document.body);
10320
+ for (const el of document.body.querySelectorAll("div, section, td")) {
10321
+ const here = score(el);
10322
+ if (here >= top) {
10323
+ top = here;
10324
+ best = el;
10325
+ }
10326
+ }
10327
+ return best;
10328
+ }
10329
+ const root = pickRoot();
10330
+ const blocks = [];
10331
+ walk(root, blocks);
10332
+ const heading = words2(root.querySelector("h1")) || words2(document.querySelector("h1"));
10333
+ const og = document.querySelector('meta[property="og:image"], meta[name="og:image"]') ?? document.querySelector('meta[property="og:image:url"], meta[name="twitter:image"]');
10334
+ return {
10335
+ title: heading || (document.title ?? "").replace(/\s+/g, " ").trim(),
10336
+ base: document.querySelector("base[href]")?.getAttribute("href") ?? "",
10337
+ ogImage: og?.getAttribute("content") ?? "",
10338
+ blocks
10339
+ };
10340
+ }
10341
+ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
10342
+ const maxAssets = opts.maxAssets ?? MAX_ASSETS;
10343
+ const maxClips = opts.maxClips ?? MAX_CLIPS;
10344
+ const maxBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
10345
+ const wallMs = opts.maxWallMs ?? MAX_WALL_MS;
10346
+ const deadline = Date.now() + wallMs;
10347
+ const out = [];
10348
+ const assets = [];
10349
+ const clips = [];
10350
+ let spent = 0;
10351
+ const done = /* @__PURE__ */ new Map();
10352
+ const ref = (got) => opts.refs === "relative" ? basename3(got.path) : got.path;
10353
+ const overdrawn = () => {
10354
+ if (Date.now() >= deadline) {
10355
+ return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
10356
+ }
10357
+ if (spent >= maxBytes) {
10358
+ return `the harvest has downloaded ${mb(spent)} of its ${mb(maxBytes)} \u2014 raise maxTotalBytes`;
10359
+ }
10360
+ return null;
10361
+ };
10362
+ const bytesOf = async (url2) => {
10363
+ const got = await fetchGuarded(url2, {
10364
+ maxBytes: opts.maxAssetBytes ?? ASSET_MAX_BYTES,
10365
+ timeoutMs,
10366
+ ...opts.allowLoopback === true ? { allowLoopback: true } : {}
10367
+ });
10368
+ spent += got.bytes.length;
10369
+ return got.bytes;
10370
+ };
10371
+ const grab = async (raw2, what) => {
10372
+ const url2 = absolute(raw2, base);
10373
+ if (url2 === null) {
10374
+ warnings.push(
10375
+ `${what} was left out: "${raw2.slice(0, 120)}" is not an http(s) URL. Only web URLs are fetched; a data: or blob: source has to be saved by hand.`
10376
+ );
10377
+ return null;
10378
+ }
10379
+ const already = done.get(url2);
10380
+ if (already !== void 0) return already;
10381
+ if (assets.length >= maxAssets) {
10382
+ done.set(url2, null);
10383
+ warnings.push(
10384
+ `${what} was left out: ${url2} \u2014 already at ${maxAssets} assets. Raise maxAssets if the page really has that many figures.`
10385
+ );
10386
+ return null;
8715
10387
  }
8716
- const final = await probe(out);
8717
- if (plan.audio.length > 0 && !final.hasAudio) {
8718
- throw new Error(`${out} came out with no audio stream. The mux silently dropped it.`);
10388
+ const capped = overdrawn();
10389
+ if (capped !== null) {
10390
+ done.set(url2, null);
10391
+ warnings.push(`${what} was left out: ${url2} \u2014 ${capped} if the page is worth the wait.`);
10392
+ return null;
10393
+ }
10394
+ let got = null;
10395
+ try {
10396
+ const bytes = await bytesOf(url2);
10397
+ const size3 = imageSize(bytes);
10398
+ if (size3.width < MIN_FIGURE_PX || size3.height < MIN_FIGURE_PX) {
10399
+ throw new Error(
10400
+ `it is ${size3.width}x${size3.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
10401
+ );
10402
+ }
10403
+ const path2 = join11(dir, assetName(url2, extFor2(sniffFormat(bytes))));
10404
+ await writeFile11(path2, bytes);
10405
+ assets.push(path2);
10406
+ got = { path: path2, width: size3.width, height: size3.height };
10407
+ } catch (err) {
10408
+ got = null;
10409
+ warnings.push(`${what} was left out: ${url2} \u2014 ${why2(err)}`);
10410
+ }
10411
+ done.set(url2, got);
10412
+ return got;
10413
+ };
10414
+ const shrink = async (url2, path2, measured, what) => {
10415
+ if (opts.transcode === false) return { path: path2, ...measured };
10416
+ const out2 = join11(dir, assetName(url2, ".vp9.webm"));
10417
+ let small;
10418
+ try {
10419
+ small = await transcode(path2, out2, {
10420
+ ...opts.maxClipSeconds === void 0 ? {} : { maxSeconds: opts.maxClipSeconds }
10421
+ });
10422
+ } catch (err) {
10423
+ warnings.push(`${what} was shipped as the page served it: ${why2(err)}`);
10424
+ return { path: path2, ...measured };
10425
+ }
10426
+ for (const w of small.warnings) warnings.push(`${what} \u2014 ${w}`);
10427
+ if (small.transcoded) {
10428
+ const [before, after] = await Promise.all([sizeOf2(path2), sizeOf2(small.path)]);
10429
+ if (before > 0 && after > before) {
10430
+ warnings.push(
10431
+ `${what} \u2014 ${basename3(small.path)} came back LARGER than the page's own file (${mb(before)} \u2192 ${mb(after)}) at ${small.width}x${small.height}. The encode is sized for the render, which pre-decodes every clip to one still per output frame, so it still costs less to render; pass --no-transcode if the deck's size is what matters here.`
10432
+ );
10433
+ }
10434
+ await rm7(path2, { force: true });
8719
10435
  }
8720
10436
  return {
8721
- out,
8722
- ...srt ? { srt: srtPath } : {},
8723
- seconds: final.seconds,
8724
- frames: final.frames,
8725
- segments: plan.audio.length,
8726
- burned: burn,
8727
- playback,
8728
- // The cues as written, not `plan.cues` — at `playback > 1` these are the
8729
- // scaled ones, so the number the caller prints is the rate on the file it
8730
- // just got rather than the rate on a timeline that no longer exists.
8731
- captionCps: p95CueRate(cues)
10437
+ path: small.path,
10438
+ width: small.width,
10439
+ height: small.height,
10440
+ ...small.seconds === void 0 ? {} : { seconds: small.seconds }
8732
10441
  };
8733
- } finally {
8734
- if (!opts.keep) await rm5(work, { recursive: true, force: true });
10442
+ };
10443
+ const grabVideo = async (url2, what) => {
10444
+ if (clips.length >= maxClips) {
10445
+ warnings.push(
10446
+ maxClips === 0 ? `${what} is a link only: this harvest downloads no videos at all (maxClips is 0).` : `${what} is a link only: already holding ${maxClips} clips. Raise maxClips if the page really is that many videos.`
10447
+ );
10448
+ return null;
10449
+ }
10450
+ const capped = overdrawn();
10451
+ if (capped !== null) {
10452
+ warnings.push(`${what} is a link only: ${capped} if the video is worth the wait.`);
10453
+ return null;
10454
+ }
10455
+ try {
10456
+ const bytes = await bytesOf(url2);
10457
+ const measured = videoSize(bytes);
10458
+ const path2 = join11(dir, assetName(url2, `.${measured.container}`));
10459
+ await writeFile11(path2, bytes);
10460
+ return await shrink(url2, path2, measured, what);
10461
+ } catch (err) {
10462
+ warnings.push(`${what} was not downloaded: ${url2} \u2014 ${why2(err)}`);
10463
+ return null;
10464
+ }
10465
+ };
10466
+ let ogSpent = false;
10467
+ const stillFor = async (poster, named, needed) => {
10468
+ if (poster) return grab(poster, `the poster of ${named}`);
10469
+ if (needed && seen.ogImage && !ogSpent) {
10470
+ ogSpent = true;
10471
+ return grab(seen.ogImage, `the page's og:image, taken as the still of ${named}`);
10472
+ }
10473
+ return null;
10474
+ };
10475
+ for (const item of seen.blocks) {
10476
+ if (item.kind === "image") {
10477
+ const named = item.caption || item.alt;
10478
+ const got = await grab(item.src, named ? `the image "${named}"` : "an image");
10479
+ if (got) out.push({ ...item, src: ref(got) });
10480
+ continue;
10481
+ }
10482
+ if (item.kind === "video") {
10483
+ const named = item.caption ? `the video "${item.caption}"` : "a video";
10484
+ const file = absolute(item.src, base);
10485
+ const page = absolute(item.href, base);
10486
+ const subject = file ?? page;
10487
+ const watch = page ?? file;
10488
+ const policy = subject === null ? null : policyFor(subject, "bake");
10489
+ const held = policy === "bake" && subject !== null ? await grabVideo(subject, named) : null;
10490
+ const still = await stillFor(item.poster, named, held === null);
10491
+ if (held !== null) {
10492
+ clips.push({
10493
+ file: held.path,
10494
+ poster: still?.path ?? "",
10495
+ href: "",
10496
+ width: held.width,
10497
+ height: held.height,
10498
+ ...held.seconds === void 0 ? {} : { seconds: held.seconds },
10499
+ caption: item.caption
10500
+ });
10501
+ out.push({ ...item, src: "", poster: still ? ref(still) : "", href: "" });
10502
+ continue;
10503
+ }
10504
+ if (still !== null && watch !== null) {
10505
+ clips.push({
10506
+ file: "",
10507
+ poster: still.path,
10508
+ href: watch,
10509
+ width: still.width,
10510
+ height: still.height,
10511
+ caption: item.caption
10512
+ });
10513
+ warnings.push(
10514
+ `${named} is a link only: ${unheld(policy)}. The still is what the deck shows, and a viewer goes to ${watch}.`
10515
+ );
10516
+ out.push({ ...item, src: "", poster: ref(still), href: watch });
10517
+ continue;
10518
+ }
10519
+ if (still !== null) {
10520
+ warnings.push(
10521
+ `${named} is a still only: the page names no source for it, so there is nothing to play.`
10522
+ );
10523
+ out.push({ ...item, src: "", poster: ref(still), href: "" });
10524
+ continue;
10525
+ }
10526
+ if (watch === null) {
10527
+ warnings.push(`${named} was left out: it names neither a poster image nor a URL.`);
10528
+ continue;
10529
+ }
10530
+ warnings.push(
10531
+ `${named} is a link only and the page gave it no still: no poster attribute, no og:image left to spend. Save a frame by hand and add it to source.json as a clip figure.`
10532
+ );
10533
+ out.push({ ...item, src: "", poster: "", href: watch });
10534
+ continue;
10535
+ }
10536
+ out.push(item);
8735
10537
  }
10538
+ return { blocks: out, assets, clips };
8736
10539
  }
8737
- async function readTiming(deck) {
8738
- const path2 = join10(deck, TIMING_FILE);
8739
- const text2 = await readFile11(path2, "utf8").catch(() => {
8740
- throw new Error(
8741
- `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
8742
- );
8743
- });
8744
- const timing = JSON.parse(text2);
8745
- if (timing.version !== 1) throw new Error(`${path2} is version ${timing.version}; expected 1.`);
8746
- return timing;
10540
+ function unheld(policy) {
10541
+ if (policy === "embed") return "it is a player page, which is never downloaded";
10542
+ if (policy === "link") {
10543
+ return "its URL does not end in a video extension, so what came back could as easily be a page";
10544
+ }
10545
+ return "the file could not be held";
8747
10546
  }
8748
- function assertCapture(timing, shot) {
8749
- if (shot.width !== timing.width || shot.height !== timing.height) {
10547
+ async function sizeOf2(path2) {
10548
+ return await stat(path2).then(
10549
+ (s) => s.size,
10550
+ () => 0
10551
+ );
10552
+ }
10553
+ function mb(bytes) {
10554
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
10555
+ }
10556
+ function absolute(raw2, base) {
10557
+ const trimmed = raw2.trim();
10558
+ if (!trimmed) return null;
10559
+ try {
10560
+ const url2 = new URL(trimmed, base);
10561
+ return url2.protocol === "http:" || url2.protocol === "https:" ? url2.href : null;
10562
+ } catch {
10563
+ return null;
10564
+ }
10565
+ }
10566
+ function assetName(url2, ext) {
10567
+ const last = new URL(url2).pathname.split("/").pop() ?? "";
10568
+ const stem = last.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "figure";
10569
+ const hash = createHash7("sha256").update(url2).digest("hex").slice(0, 8);
10570
+ return `${stem}-${hash}${ext}`;
10571
+ }
10572
+ function extFor2(format) {
10573
+ if (format === void 0) return ".img";
10574
+ return format === "jpeg" ? ".jpg" : `.${format}`;
10575
+ }
10576
+ function why2(err) {
10577
+ return err instanceof Error ? err.message : String(err);
10578
+ }
10579
+ function sniffVideo(b) {
10580
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp") return "mp4";
10581
+ if (b.length >= 4 && b.readUInt32BE(0) === 440786851) return "webm";
10582
+ return void 0;
10583
+ }
10584
+ function videoSize(b) {
10585
+ const container = sniffVideo(b);
10586
+ if (container === void 0) {
8750
10587
  throw new Error(
8751
- `the capture is ${shot.width}\xD7${shot.height} but ${TIMING_FILE} describes a ${timing.width}\xD7${timing.height} deck.`
10588
+ "its first bytes are neither an ISO base media (mp4) header nor an EBML (webm) one, so it is not a video this can measure \u2014 which is what an interstitial or a login page looks like"
8752
10589
  );
8753
10590
  }
8754
- if (Math.abs(shot.seconds - timing.duration) > 0.5) {
10591
+ const measured = container === "mp4" ? mp4Size(b) : webmSize(b);
10592
+ if (measured.width < MIN_FIGURE_PX || measured.height < MIN_FIGURE_PX) {
8755
10593
  throw new Error(
8756
- `the capture is ${shot.seconds.toFixed(2)}s but ${TIMING_FILE} describes a ${timing.duration.toFixed(2)}s deck. One of them is stale.`
10594
+ `it measures ${measured.width}x${measured.height}, under ${MIN_FIGURE_PX}px \u2014 a slide cannot use one`
8757
10595
  );
8758
10596
  }
10597
+ return { ...measured, container };
8759
10598
  }
8760
- function scaleCue(cue, factor) {
8761
- const at = (t2) => Math.round(t2 / factor * 1e3) / 1e3;
8762
- return { ...cue, start: at(cue.start), end: at(cue.end) };
8763
- }
8764
- async function capture(deck, out, opts, log) {
8765
- const args = ["hyperframes", "render", deck, "-o", out];
8766
- if (opts.fps) args.push("--fps", String(opts.fps));
8767
- if (opts.quality) args.push("--quality", opts.quality);
8768
- if (opts.workers) args.push("--workers", opts.workers);
8769
- args.push("--protocol-timeout", String(opts.protocolTimeoutMs ?? 9e5));
8770
- log("render: capturing the composition \u2014 this is the long part");
8771
- await runLive("npx", args);
10599
+ function boxes2(b, from, to) {
10600
+ const out = [];
10601
+ let at = from;
10602
+ while (at + 8 <= to) {
10603
+ let size3 = b.readUInt32BE(at);
10604
+ const type = b.toString("latin1", at + 4, at + 8);
10605
+ let body = at + 8;
10606
+ if (size3 === 1) {
10607
+ if (body + 8 > to) return out;
10608
+ size3 = Number(b.readBigUInt64BE(body));
10609
+ body += 8;
10610
+ } else if (size3 === 0) {
10611
+ size3 = to - at;
10612
+ }
10613
+ const end = at + size3;
10614
+ if (size3 < body - at || end > to) return out;
10615
+ out.push({ type, body, end });
10616
+ at = end;
10617
+ }
8772
10618
  return out;
8773
10619
  }
8774
- async function retime(raw2, plan, work, log) {
8775
- if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
8776
- const list = [];
8777
- for (const [i, piece] of plan.pieces.entries()) {
8778
- const file = join10(work, `p${String(i).padStart(4, "0")}.ts`);
8779
- await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
8780
- list.push(file);
8781
- if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
8782
- log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
10620
+ function mp4Size(b) {
10621
+ const moov = boxes2(b, 0, b.length).find((box) => box.type === "moov");
10622
+ if (moov === void 0) {
10623
+ throw new Error(
10624
+ "it has no moov box \u2014 the file is truncated, or it is a fragmented stream whose header never arrived"
10625
+ );
10626
+ }
10627
+ let seconds;
10628
+ let size3;
10629
+ for (const box of boxes2(b, moov.body, moov.end)) {
10630
+ if (box.type === "mvhd") seconds = mvhdSeconds(b, box.body);
10631
+ if (box.type !== "trak") continue;
10632
+ for (const inner of boxes2(b, box.body, box.end)) {
10633
+ if (inner.type === "tkhd" && size3 === void 0) size3 = tkhdSize(b, inner.body);
8783
10634
  }
8784
10635
  }
8785
- const listFile = join10(work, "pieces.txt");
8786
- await writeFile10(
8787
- listFile,
8788
- `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
8789
- `
8790
- );
8791
- const joined = join10(work, "retimed.mp4");
8792
- await runTool("ffmpeg", [
8793
- "-y",
8794
- "-hide_banner",
8795
- "-loglevel",
8796
- "error",
8797
- "-f",
8798
- "concat",
8799
- "-safe",
8800
- "0",
8801
- "-i",
8802
- listFile,
8803
- "-c",
8804
- "copy",
8805
- "-movflags",
8806
- "+faststart",
8807
- joined
8808
- ]);
8809
- return joined;
8810
- }
8811
- async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
8812
- const inputs = plan.audio.map((a) => ({
8813
- file: join10(deck, timing.audioDir, a.audio),
8814
- delayMs: a.delayMs
8815
- }));
8816
- const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
8817
- const graph = [];
8818
- const band = burnCues ? await renderCaptions(
8819
- burnCues,
8820
- burnStyle(timing.width, timing.height, familyFor(timing.lang) ?? "Arial"),
8821
- deck,
8822
- work
8823
- ) : void 0;
8824
- if (band) {
8825
- log(`render: drew ${band.files.length} caption band(s), ${band.width}\xD7${band.height}px`);
8826
- graph.push(overlayGraph(burnCues, band));
10636
+ if (size3 === void 0) {
10637
+ throw new Error(
10638
+ "no track in it declares a width and a height, so there is no picture to place"
10639
+ );
8827
10640
  }
8828
- if (band) args.push(...overlayInputs(band));
8829
- for (const input of inputs) args.push("-i", input.file);
8830
- if (inputs.length > 0) {
8831
- graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
10641
+ return { ...size3, ...seconds === void 0 ? {} : { seconds } };
10642
+ }
10643
+ function tkhdSize(b, body) {
10644
+ const matrix = body + (b[body] === 1 ? 52 : 40);
10645
+ const at = matrix + 36;
10646
+ if (at + 8 > b.length) return void 0;
10647
+ const width = Math.round(b.readUInt32BE(at) / 65536);
10648
+ const height = Math.round(b.readUInt32BE(at + 4) / 65536);
10649
+ if (width <= 0 || height <= 0) return void 0;
10650
+ const quarterTurn = b.readInt32BE(matrix) === 0 && b.readInt32BE(matrix + 16) === 0;
10651
+ return quarterTurn ? { width: height, height: width } : { width, height };
10652
+ }
10653
+ function mvhdSeconds(b, body) {
10654
+ const long = b[body] === 1;
10655
+ const at = body + (long ? 20 : 12);
10656
+ if (at + (long ? 12 : 8) > b.length) return void 0;
10657
+ const timescale = b.readUInt32BE(at);
10658
+ const ticks = long ? Number(b.readBigUInt64BE(at + 4)) : b.readUInt32BE(at + 4);
10659
+ if (timescale <= 0 || ticks <= 0 || ticks === 4294967295) return void 0;
10660
+ return sane(ticks / timescale);
10661
+ }
10662
+ var EBML_SEGMENT = 408125543;
10663
+ var EBML_INFO = 357149030;
10664
+ var EBML_TRACKS = 374648427;
10665
+ var EBML_TRACK_ENTRY = 174;
10666
+ var EBML_VIDEO = 224;
10667
+ var EBML_PIXEL_WIDTH = 176;
10668
+ var EBML_PIXEL_HEIGHT = 186;
10669
+ var EBML_DISPLAY_WIDTH = 21680;
10670
+ var EBML_DISPLAY_HEIGHT = 21690;
10671
+ var EBML_TIMECODE_SCALE = 2807729;
10672
+ var EBML_DURATION = 17545;
10673
+ function webmSize(b) {
10674
+ let pixel = { width: 0, height: 0 };
10675
+ let display = { width: 0, height: 0 };
10676
+ let scale = 1e6;
10677
+ let ticks;
10678
+ const masters = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_INFO, EBML_TRACKS, EBML_TRACK_ENTRY, EBML_VIDEO]);
10679
+ const scan = (from, to, depth) => {
10680
+ let at = from;
10681
+ while (at < to) {
10682
+ const el = ebml(b, at, to);
10683
+ if (el === void 0 || el.end <= at) return;
10684
+ if (masters.has(el.id)) {
10685
+ if (depth < 6) scan(el.body, el.end, depth + 1);
10686
+ } else if (el.id === EBML_PIXEL_WIDTH && pixel.width === 0) {
10687
+ pixel = { ...pixel, width: uint(b, el) };
10688
+ } else if (el.id === EBML_PIXEL_HEIGHT && pixel.height === 0) {
10689
+ pixel = { ...pixel, height: uint(b, el) };
10690
+ } else if (el.id === EBML_DISPLAY_WIDTH && display.width === 0) {
10691
+ display = { ...display, width: uint(b, el) };
10692
+ } else if (el.id === EBML_DISPLAY_HEIGHT && display.height === 0) {
10693
+ display = { ...display, height: uint(b, el) };
10694
+ } else if (el.id === EBML_TIMECODE_SCALE) {
10695
+ scale = uint(b, el) || scale;
10696
+ } else if (el.id === EBML_DURATION) {
10697
+ ticks = float(b, el);
10698
+ }
10699
+ at = el.end;
10700
+ }
10701
+ };
10702
+ scan(0, b.length, 0);
10703
+ const width = display.width || pixel.width;
10704
+ const height = display.height || pixel.height;
10705
+ if (width <= 0 || height <= 0) {
10706
+ throw new Error(
10707
+ "no track in it declares PixelWidth and PixelHeight, so there is no picture to place"
10708
+ );
8832
10709
  }
8833
- if (graph.length > 0) {
8834
- const script = join10(work, "mux.filter");
8835
- await writeFile10(script, `${graph.join(";\n")}
8836
- `);
8837
- args.push("-filter_complex_script", script);
10710
+ const seconds = ticks === void 0 ? void 0 : sane(ticks * scale / 1e9);
10711
+ return { width, height, ...seconds === void 0 ? {} : { seconds } };
10712
+ }
10713
+ function ebml(b, at, to) {
10714
+ const idLen = vintLen(b[at]);
10715
+ if (idLen === 0 || at + idLen > to) return void 0;
10716
+ let id2 = 0;
10717
+ for (let i = 0; i < idLen; i += 1) id2 = id2 * 256 + (b[at + i] ?? 0);
10718
+ let p = at + idLen;
10719
+ const sizeLen = vintLen(b[p]);
10720
+ if (sizeLen === 0 || p + sizeLen > to) return void 0;
10721
+ const first = b[p] ?? 0;
10722
+ const mask = 255 >> sizeLen;
10723
+ let size3 = first & mask;
10724
+ let unknown = size3 === mask;
10725
+ for (let i = 1; i < sizeLen; i += 1) {
10726
+ const byte = b[p + i] ?? 0;
10727
+ size3 = size3 * 256 + byte;
10728
+ unknown = unknown && byte === 255;
10729
+ }
10730
+ p += sizeLen;
10731
+ return { id: id2, body: p, end: unknown ? to : Math.min(p + size3, to) };
10732
+ }
10733
+ function vintLen(first) {
10734
+ if (first === void 0 || first === 0) return 0;
10735
+ let len = 1;
10736
+ for (let mask = 128; (first & mask) === 0; mask >>= 1) len += 1;
10737
+ return len;
10738
+ }
10739
+ function uint(b, el) {
10740
+ let value = 0;
10741
+ for (let at = el.body; at < el.end && at - el.body < 8; at += 1)
10742
+ value = value * 256 + (b[at] ?? 0);
10743
+ return value;
10744
+ }
10745
+ function float(b, el) {
10746
+ const width = el.end - el.body;
10747
+ if (width === 4) return b.readFloatBE(el.body);
10748
+ if (width === 8) return b.readDoubleBE(el.body);
10749
+ return void 0;
10750
+ }
10751
+ function sane(seconds) {
10752
+ return Number.isFinite(seconds) && seconds > 0 && seconds < 86400 ? seconds : void 0;
10753
+ }
10754
+ async function attachClips(source, clips, dir) {
10755
+ if (clips.length === 0) return source;
10756
+ const assets = resolve4(dir);
10757
+ await mkdir9(assets, { recursive: true });
10758
+ const figures = [...source.figures];
10759
+ for (const clip3 of clips) {
10760
+ const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
10761
+ const poster = clip3.poster ? await adopt(clip3.poster, assets) : void 0;
10762
+ if (src === void 0 && poster === void 0) continue;
10763
+ const at = clip3.poster === "" ? -1 : figures.findIndex((f) => f.src === clip3.poster || f.src === basename3(clip3.poster));
10764
+ const kept = at === -1 ? void 0 : figures[at];
10765
+ const figure = figureSchema.parse({
10766
+ id: kept?.id ?? freeId(figures),
10767
+ kind: "clip",
10768
+ // The file when we hold it, and the still when we do not: `claim-figure`
10769
+ // draws `poster` for a clip that carries an `href` and plays `src` for one
10770
+ // that does not, and `src` is required either way.
10771
+ src: src ?? poster,
10772
+ caption: kept?.caption || clip3.caption,
10773
+ width: clip3.width,
10774
+ height: clip3.height,
10775
+ ...kept?.sectionId === void 0 ? {} : { sectionId: kept.sectionId },
10776
+ ...kept?.mention === void 0 ? {} : { mention: kept.mention },
10777
+ ...poster === void 0 ? {} : { poster },
10778
+ ...clip3.seconds === void 0 ? {} : { seconds: clip3.seconds },
10779
+ ...clip3.href === "" ? {} : { href: clip3.href }
10780
+ });
10781
+ if (at === -1) figures.push(figure);
10782
+ else figures[at] = figure;
8838
10783
  }
8839
- args.push("-map", burnCues ? "[vout]" : "0:v");
8840
- if (inputs.length > 0) args.push("-map", "[aout]", "-c:a", "aac", "-b:a", "192k");
8841
- if (burnCues) {
8842
- args.push("-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p");
8843
- } else {
8844
- args.push("-c:v", "copy");
10784
+ return { ...source, figures };
10785
+ }
10786
+ async function adopt(file, dir) {
10787
+ const name = basename3(file);
10788
+ await copyFile(file, join11(dir, name));
10789
+ return name;
10790
+ }
10791
+ function freeId(figures) {
10792
+ const taken = new Set(figures.map((f) => f.id));
10793
+ let n3 = figures.length + 1;
10794
+ while (taken.has(`fig${n3}`)) n3 += 1;
10795
+ return `fig${n3}`;
10796
+ }
10797
+ function toMarkdown(blocks) {
10798
+ const out = [];
10799
+ for (const item of blocks) {
10800
+ switch (item.kind) {
10801
+ case "heading":
10802
+ out.push(`${"#".repeat(Math.min(Math.max(item.depth, 1), 6))} ${inline(item.text)}`);
10803
+ break;
10804
+ case "paragraph":
10805
+ out.push(inline(item.text));
10806
+ break;
10807
+ case "code":
10808
+ out.push(fenced(item.text));
10809
+ break;
10810
+ case "list":
10811
+ out.push(
10812
+ item.items.map((li, n3) => `${item.ordered ? `${n3 + 1}.` : "-"} ${inline(li)}`).join("\n")
10813
+ );
10814
+ break;
10815
+ case "table":
10816
+ out.push(pipes(item.columns, item.rows));
10817
+ break;
10818
+ case "image":
10819
+ out.push(`![${inline(item.alt)}](${destination(item.src)})`);
10820
+ if (item.caption || item.alt) out.push(`*${inline(item.caption || item.alt)}*`);
10821
+ break;
10822
+ case "video": {
10823
+ if (item.poster) {
10824
+ out.push(`![${inline(item.caption)}](${destination(item.poster)})`);
10825
+ if (item.caption) out.push(`*${inline(item.caption)}*`);
10826
+ } else if (item.caption) {
10827
+ out.push(inline(item.caption));
10828
+ }
10829
+ const link = item.href || item.src;
10830
+ if (/^https?:/i.test(link)) out.push(`Video: <${link}>`);
10831
+ break;
10832
+ }
10833
+ }
8845
10834
  }
8846
- args.push("-movflags", "+faststart", out);
8847
- log(
8848
- `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
8849
- );
8850
- await mkdir8(dirname3(out), { recursive: true });
8851
- await runTool("ffmpeg", args, { cwd: work });
10835
+ return `${out.filter((block) => block !== "").join("\n\n")}
10836
+ `;
10837
+ }
10838
+ function inline(text2) {
10839
+ return text2.replace(/\s+/g, " ").trim().replace(/([\\`*_[\]<>])/g, "\\$1").replace(/^(#{1,6}\s|[-+]\s)/, "\\$&").replace(/^(\d{1,9})([.)]\s)/, "$1\\$2");
10840
+ }
10841
+ function destination(path2) {
10842
+ if (/[<>]/.test(path2)) {
10843
+ throw new Error(
10844
+ `cannot reference ${path2} from markdown: a path containing < or > has no spelling as a link destination. Harvest into a directory without them.`
10845
+ );
10846
+ }
10847
+ return /[\s()]/.test(path2) ? `<${path2}>` : path2;
10848
+ }
10849
+ function pipes(columns2, rows) {
10850
+ const width = Math.max(columns2.length, ...rows.map((r) => r.length), 1);
10851
+ const cells = (row) => `| ${Array.from({ length: width }, (_, i) => inline(row[i] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`;
10852
+ const rule = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`;
10853
+ return [cells(columns2), rule, ...rows.map(cells)].join("\n");
10854
+ }
10855
+ function fenced(code) {
10856
+ const runs = [...code.matchAll(/`+/g)].map((m) => m[0].length + 1);
10857
+ const fence = "`".repeat(Math.max(3, ...runs));
10858
+ return `${fence}
10859
+ ${code}
10860
+ ${fence}`;
8852
10861
  }
8853
10862
 
8854
10863
  // src/source/markdown.ts
8855
- import { createHash as createHash7 } from "node:crypto";
10864
+ import { createHash as createHash8 } from "node:crypto";
8856
10865
  import remarkGfm from "remark-gfm";
8857
10866
  import remarkMath from "remark-math";
8858
10867
  import remarkParse from "remark-parse";
@@ -8904,6 +10913,9 @@ function parseMarkdown(md, opts = {}) {
8904
10913
  for (const img of images) {
8905
10914
  const figure = {
8906
10915
  id: `fig${figures.length + 1}`,
10916
+ // Markdown carries no video: an image node is an image. A clip only
10917
+ // ever enters through `harvest`, which sets this itself.
10918
+ kind: "image",
8907
10919
  src: img.url,
8908
10920
  caption: caption ?? img.alt ?? "",
8909
10921
  // 1x1 until assets.ts reads the actual bytes. The schema has no "unknown",
@@ -8939,7 +10951,7 @@ function parseMarkdown(md, opts = {}) {
8939
10951
  if (mention) p.figure.mention = mention;
8940
10952
  }
8941
10953
  return sourceSchema.parse({
8942
- id: opts.id ?? createHash7("sha256").update(md).digest("hex").slice(0, 12),
10954
+ id: opts.id ?? createHash8("sha256").update(md).digest("hex").slice(0, 12),
8943
10955
  title: title2 || "Untitled",
8944
10956
  lang: opts.lang ?? sniffLang(md),
8945
10957
  sections,
@@ -8948,10 +10960,14 @@ function parseMarkdown(md, opts = {}) {
8948
10960
  tables
8949
10961
  });
8950
10962
  }
10963
+ var SCRIPT_SHARE = 0.02;
8951
10964
  function sniffLang(md) {
8952
- if (/[가-힣]/.test(md)) return "ko";
8953
- if (/[぀-ヿ]/.test(md)) return "ja";
8954
- if (/[一-鿿]/.test(md)) return "zh";
10965
+ const total = md.replace(/\s+/g, "").length;
10966
+ if (total === 0) return "en";
10967
+ const share2 = (re) => (md.match(re)?.length ?? 0) / total;
10968
+ if (share2(/[가-힣]/g) >= SCRIPT_SHARE) return "ko";
10969
+ if (share2(/[぀-ヿ]/g) >= SCRIPT_SHARE) return "ja";
10970
+ if (share2(/[一-鿿]/g) >= SCRIPT_SHARE) return "zh";
8955
10971
  return "en";
8956
10972
  }
8957
10973
  function onlyImages(kids) {
@@ -8972,7 +10988,11 @@ function mentionOf(caption, prose, at, opened) {
8972
10988
  const found = before ?? prose.find((p) => p.at > at && name.test(p.text));
8973
10989
  if (found) return found.text;
8974
10990
  }
8975
- return prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
10991
+ const near = prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
10992
+ return near !== void 0 && isSentence(near) ? near : void 0;
10993
+ }
10994
+ function isSentence(text2) {
10995
+ return (text2.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3;
8976
10996
  }
8977
10997
  function figureName(caption) {
8978
10998
  const match = /^\s*(fig(?:ure)?\.?|그림|図|图)\s*([0-9]+)/i.exec(caption);
@@ -9033,8 +11053,8 @@ function textOf(node) {
9033
11053
  }
9034
11054
 
9035
11055
  // src/verify/index.ts
9036
- import { readdir as readdir2, readFile as readFile15 } from "node:fs/promises";
9037
- import { join as join14, relative, sep } from "node:path";
11056
+ import { readdir as readdir3, readFile as readFile16 } from "node:fs/promises";
11057
+ import { join as join15, relative, sep } from "node:path";
9038
11058
 
9039
11059
  // src/verify/budget.ts
9040
11060
  function readCanvas(html) {
@@ -9105,12 +11125,12 @@ function remedy(html, over, maxSeconds, profiles, storyboard, emitted) {
9105
11125
  const kept = emitted ?? storyboard.beats.filter((b) => b.weight >= (profile?.minWeight ?? 0));
9106
11126
  if (windows.length !== kept.length) return generic;
9107
11127
  const seconds = Object.fromEntries(kept.map((b, i) => [b.id, windows[i] ?? 0]));
9108
- const budget2 = {
11128
+ const budget3 = {
9109
11129
  minWeight: profile?.minWeight ?? 0,
9110
11130
  maxSeconds,
9111
11131
  ...profile && { id: profile.id }
9112
11132
  };
9113
- const cut = selectBeats(storyboard, budget2, seconds);
11133
+ const cut = selectBeats(storyboard, budget3, seconds);
9114
11134
  const casualties = cut.dropped.filter((d) => d.rule !== "below_min_weight");
9115
11135
  const selection = cut.fits ? `A budgeted cut keeps ${cut.kept.length} of ${kept.length} beats in ${clock2(cut.seconds)}, dropping ${casualties.length}. ` + casualties.map((d) => `${d.beat.id} (${d.beat.archetype}): ${d.reason}`).join(" ") + (cut.dangling.length > 0 ? ` Check the wording first: ${cut.dangling.map((d) => d.reason).join(" ")}` : "") + " " : `No cut of these beats fits ${clock2(maxSeconds)} \u2014 the narration itself has to get shorter. `;
9116
11136
  const byWeight = kept.map((beat, i) => ({ beat, seconds: windows[i] ?? 0 })).sort((a, b) => a.beat.weight - b.beat.weight);
@@ -9141,8 +11161,8 @@ function clock2(seconds) {
9141
11161
 
9142
11162
  // src/verify/check.ts
9143
11163
  import { execFile as execFile2 } from "node:child_process";
9144
- import { readFile as readFile12 } from "node:fs/promises";
9145
- import { join as join11 } from "node:path";
11164
+ import { readFile as readFile13 } from "node:fs/promises";
11165
+ import { join as join12 } from "node:path";
9146
11166
  import { promisify as promisify2 } from "node:util";
9147
11167
  var run2 = promisify2(execFile2);
9148
11168
  var DEFAULT_TIMEOUT_MS3 = 24e4;
@@ -9172,7 +11192,7 @@ async function check2(dir, opts = {}) {
9172
11192
  async function readComposition(dir) {
9173
11193
  let html;
9174
11194
  try {
9175
- html = await readFile12(join11(dir, "index.html"), "utf8");
11195
+ html = await readFile13(join12(dir, "index.html"), "utf8");
9176
11196
  } catch {
9177
11197
  return { transit: [], duration: 0 };
9178
11198
  }
@@ -9253,11 +11273,11 @@ function regrade({ f, time, selector }, transit = []) {
9253
11273
  (w) => time > w.t0 && time < w.t1 && (named === void 0 || named === w.sid)
9254
11274
  );
9255
11275
  if (hit) {
9256
- const why2 = named === hit.sid ? `no stop of ${hit.sid} is inside this window` : "the element names no scene, so this is excused on time alone";
11276
+ const why3 = named === hit.sid ? `no stop of ${hit.sid} is inside this window` : "the element names no scene, so this is excused on time alone";
9257
11277
  return {
9258
11278
  ...f,
9259
11279
  severity: "info",
9260
- message: `${f.message} \u2014 accepted: mid-camera-move, ${why2}.`
11280
+ message: `${f.message} \u2014 accepted: mid-camera-move, ${why3}.`
9261
11281
  };
9262
11282
  }
9263
11283
  return { ...f, severity: "error" };
@@ -9270,7 +11290,7 @@ function regrade({ f, time, selector }, transit = []) {
9270
11290
  var OFF_CANVAS = /* @__PURE__ */ new Set(["canvas_overflow", "panel_out_of_canvas", "text_occluded"]);
9271
11291
  function toFinding(gate2, f) {
9272
11292
  const severity = typeof f.severity === "string" && SEVERITIES.has(f.severity) ? f.severity : "warning";
9273
- const message = str(f.message) ?? "(no message)";
11293
+ const message2 = str(f.message) ?? "(no message)";
9274
11294
  const time = typeof f.time === "number" && f.time > 0 ? f.time : void 0;
9275
11295
  const at = time !== void 0 ? `t=${time}s` : void 0;
9276
11296
  const where2 = [str(f.selector) ?? str(f.containerSelector), at].filter(Boolean).join(" ");
@@ -9281,7 +11301,7 @@ function toFinding(gate2, f) {
9281
11301
  rule: str(f.code) ?? "unknown",
9282
11302
  // "Text extends outside the composition canvas." is unactionable without the
9283
11303
  // element it happened to, and Finding has nowhere else to put it.
9284
- message: where2 ? `${message} [${where2}]` : message
11304
+ message: where2 ? `${message2} [${where2}]` : message2
9285
11305
  },
9286
11306
  time,
9287
11307
  selector: str(f.selector) ?? str(f.containerSelector)
@@ -9295,8 +11315,8 @@ function staticGuardFindings(stderr) {
9295
11315
  message: line2.slice("[StaticGuard]".length).trim()
9296
11316
  }));
9297
11317
  }
9298
- function tooling(rule, message) {
9299
- return { severity: "error", gate: "check", rule, message };
11318
+ function tooling(rule, message2) {
11319
+ return { severity: "error", gate: "check", rule, message: message2 };
9300
11320
  }
9301
11321
  function readJson(stdout) {
9302
11322
  const first = stdout.indexOf("{");
@@ -9320,8 +11340,8 @@ function tail(s) {
9320
11340
  }
9321
11341
 
9322
11342
  // src/verify/fidelity.ts
9323
- import { readFile as readFile13 } from "node:fs/promises";
9324
- import { join as join12 } from "node:path";
11343
+ import { readFile as readFile14 } from "node:fs/promises";
11344
+ import { join as join13 } from "node:path";
9325
11345
 
9326
11346
  // src/verify/typefloor.ts
9327
11347
  var TYPE_FLOOR_PX = 40;
@@ -9375,7 +11395,7 @@ function svgZones(html) {
9375
11395
  return zones;
9376
11396
  }
9377
11397
  function userUnit(zones, at) {
9378
- return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
11398
+ return zones.filter((z5) => at >= z5.start && at < z5.end).reduce((unit, z5) => unit * z5.unit, 1);
9379
11399
  }
9380
11400
  function where(html, at) {
9381
11401
  const open = html.lastIndexOf("<", at);
@@ -9755,21 +11775,21 @@ function captionBottom(sid, selector, fallbackPx) {
9755
11775
  async function fidelity(dir, opts = {}) {
9756
11776
  const started = Date.now();
9757
11777
  const floor = opts.floor ?? INK_FLOOR;
9758
- const notMeasured = (why2) => ({
11778
+ const notMeasured = (why3) => ({
9759
11779
  stops: [],
9760
11780
  findings: [
9761
11781
  {
9762
11782
  severity: "warning",
9763
11783
  gate: "fidelity",
9764
11784
  rule: "not_measured",
9765
- message: `did not check whether each stop draws anything: ${why2}`
11785
+ message: `did not check whether each stop draws anything: ${why3}`
9766
11786
  }
9767
11787
  ],
9768
11788
  elapsedMs: Date.now() - started
9769
11789
  });
9770
11790
  const stops = opts.stops ?? readStops(
9771
- await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => null),
9772
- await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => null)
11791
+ await readFile14(join13(dir, TIMING_FILE), "utf8").catch(() => null),
11792
+ await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => null)
9773
11793
  );
9774
11794
  if (stops.length === 0) return notMeasured("the deck declares no stops");
9775
11795
  let deck = null;
@@ -9826,10 +11846,10 @@ async function fidelity(dir, opts = {}) {
9826
11846
 
9827
11847
  // src/verify/drift.ts
9828
11848
  import { execFile as execFile3 } from "node:child_process";
9829
- import { createHash as createHash8 } from "node:crypto";
9830
- import { mkdtemp as mkdtemp3, readdir, readFile as readFile14, rm as rm6 } from "node:fs/promises";
11849
+ import { createHash as createHash9 } from "node:crypto";
11850
+ import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile15, rm as rm8 } from "node:fs/promises";
9831
11851
  import { tmpdir as tmpdir3 } from "node:os";
9832
- import { join as join13 } from "node:path";
11852
+ import { join as join14 } from "node:path";
9833
11853
  import { promisify as promisify3 } from "node:util";
9834
11854
  var run3 = promisify3(execFile3);
9835
11855
  var FLOOR_DB = 40;
@@ -9868,13 +11888,13 @@ function measureMotion(hashes, scenes, seconds) {
9868
11888
  async function drift(dir, opts = {}) {
9869
11889
  const mode = opts.mode ?? "psnr";
9870
11890
  const floorDb = opts.floorDb ?? FLOOR_DB;
9871
- const work = opts.workDir ?? await mkdtemp3(join13(tmpdir3(), "decksmith-drift-"));
9872
- const a = join13(work, "a");
9873
- const b = join13(work, "b");
11891
+ const work = opts.workDir ?? await mkdtemp3(join14(tmpdir3(), "decksmith-drift-"));
11892
+ const a = join14(work, "a");
11893
+ const b = join14(work, "b");
9874
11894
  const report2 = await compare(dir, a, b, mode, floorDb, opts);
9875
11895
  const keep = opts.keep || !report2.passed;
9876
11896
  if (keep) report2.kept = { a, b };
9877
- else if (!opts.workDir) await rm6(work, { recursive: true, force: true });
11897
+ else if (!opts.workDir) await rm8(work, { recursive: true, force: true });
9878
11898
  return report2;
9879
11899
  }
9880
11900
  async function compare(dir, a, b, mode, floorDb, opts) {
@@ -9889,11 +11909,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
9889
11909
  [a, "first"],
9890
11910
  [b, "second"]
9891
11911
  ].entries()) {
9892
- const why2 = await render2(dir, out, { ...opts, workers: counts[i] });
9893
- if (why2)
11912
+ const why3 = await render2(dir, out, { ...opts, workers: counts[i] });
11913
+ if (why3)
9894
11914
  return {
9895
11915
  passed: false,
9896
- findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${why2}`)],
11916
+ findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${why3}`)],
9897
11917
  ...empty
9898
11918
  };
9899
11919
  }
@@ -9925,11 +11945,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
9925
11945
  const differing = [];
9926
11946
  const ha = [];
9927
11947
  for (const [i, name] of fa.entries()) {
9928
- const one = await sha(join13(a, name));
11948
+ const one = await sha(join14(a, name));
9929
11949
  ha.push(one);
9930
- if (one !== await sha(join13(b, name))) differing.push(i + 1);
11950
+ if (one !== await sha(join14(b, name))) differing.push(i + 1);
9931
11951
  }
9932
- const html = await readFile14(join13(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11952
+ const html = await readFile15(join14(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
9933
11953
  const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
9934
11954
  let worst;
9935
11955
  if (differing.length > 0) {
@@ -10040,11 +12060,11 @@ async function render2(dir, out, opts) {
10040
12060
  }
10041
12061
  }
10042
12062
  async function frames(dir) {
10043
- const names = await readdir(dir).catch(() => []);
12063
+ const names = await readdir2(dir).catch(() => []);
10044
12064
  return names.filter((n3) => n3.endsWith(".png")).sort();
10045
12065
  }
10046
12066
  async function sha(file) {
10047
- return createHash8("sha256").update(await readFile14(file)).digest("hex");
12067
+ return createHash9("sha256").update(await readFile15(file)).digest("hex");
10048
12068
  }
10049
12069
  var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
10050
12070
  function framePattern(name) {
@@ -10077,11 +12097,11 @@ async function psnr(a, b, firstFrame) {
10077
12097
  "-start_number",
10078
12098
  String(seq.start),
10079
12099
  "-i",
10080
- join13(a, seq.pattern),
12100
+ join14(a, seq.pattern),
10081
12101
  "-start_number",
10082
12102
  String(seq.start),
10083
12103
  "-i",
10084
- join13(b, seq.pattern),
12104
+ join14(b, seq.pattern),
10085
12105
  "-lavfi",
10086
12106
  "psnr=stats_file=-",
10087
12107
  "-f",
@@ -10123,11 +12143,11 @@ function moved(motion) {
10123
12143
  const rest = motion.unmeasured.length > 0 ? ` ${motion.unmeasured.length} scene(s) had too few frames of their own to judge: ${motion.unmeasured.join(", ")}.` : "";
10124
12144
  return ` All ${measured} measurable scene(s) moved within their own window.${rest}`;
10125
12145
  }
10126
- function finding(rule, message) {
10127
- return { severity: "error", gate: "drift", rule, message };
12146
+ function finding(rule, message2) {
12147
+ return { severity: "error", gate: "drift", rule, message: message2 };
10128
12148
  }
10129
- function note(message) {
10130
- return { severity: "info", gate: "drift", rule: "stable", message };
12149
+ function note(message2) {
12150
+ return { severity: "info", gate: "drift", rule: "stable", message: message2 };
10131
12151
  }
10132
12152
  function tail2(s) {
10133
12153
  return s.trim().split("\n").slice(-3).join(" / ").slice(0, 400);
@@ -10135,24 +12155,34 @@ function tail2(s) {
10135
12155
 
10136
12156
  // src/verify/index.ts
10137
12157
  var NONDETERMINISM = [
10138
- [/\bMath\.random\s*\(/, "math_random"],
10139
- [/\bDate\.now\s*\(/, "date_now"],
10140
- [/\bnew\s+Date\s*\(\s*\)/, "date_now"],
10141
- [/\bperformance\.now\s*\(/, "performance_now"],
10142
- [/\bfetch\s*\(/, "runtime_fetch"],
10143
- [/\bXMLHttpRequest\b/, "runtime_fetch"]
12158
+ [/\bMath\.random\s*\(/, "math_random", "calls"],
12159
+ [/\bDate\.now\s*\(/, "date_now", "calls"],
12160
+ [/\bnew\s+Date\s*\(\s*\)/, "date_now", "calls"],
12161
+ [/\bperformance\.now\s*\(/, "performance_now", "calls"],
12162
+ [/\bfetch\s*\(/, "runtime_fetch", "calls"],
12163
+ [/\bXMLHttpRequest\b/, "runtime_fetch", "calls"],
12164
+ // The TAG, matched with a word boundary so `<iframes>` and the word "iframe"
12165
+ // in a comment or a `createElement("iframe")` in the wrapper's own runtime are
12166
+ // not it. `deck.html` is never scanned at all (`readCompositions`), which is
12167
+ // what keeps the player's own frame out of this.
12168
+ [
12169
+ /<iframe\b/i,
12170
+ "third_party_iframe",
12171
+ "embeds",
12172
+ "Bake what the frame was showing into the deck instead: a still as a figure, or a clip claim-figure can play."
12173
+ ]
10144
12174
  ];
10145
12175
  async function verify(dir, opts = {}, storyboard, kept, source) {
10146
12176
  const html = await readCompositions(dir);
10147
12177
  const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
10148
12178
  const narration = scanNarration(
10149
- await readFile15(join14(dir, DECK_PAGE), "utf8").catch(() => ""),
12179
+ await readFile16(join15(dir, DECK_PAGE), "utf8").catch(() => ""),
10150
12180
  await listFiles(dir)
10151
12181
  );
10152
- const budget2 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
12182
+ const budget3 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
10153
12183
  const type = html.flatMap(([file, text2]) => scanTypeFloor(text2, file));
10154
12184
  const lead = kept ? await readTiming2(dir).then((t2) => t2 ? scanNarrationLead(kept, t2) : []) : [];
10155
- const ours = [...determinism, ...narration, ...budget2, ...type, ...lead];
12185
+ const ours = [...determinism, ...narration, ...budget3, ...type, ...lead];
10156
12186
  const stops = await declaredStops(dir);
10157
12187
  const [verdict, frames2] = await Promise.all([
10158
12188
  check2(dir, { ...opts, at: stops.map((s) => s.t) }),
@@ -10183,8 +12213,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
10183
12213
  }
10184
12214
  async function declaredStops(dir) {
10185
12215
  return readStops(
10186
- await readFile15(join14(dir, TIMING_FILE), "utf8").catch(() => null),
10187
- await readFile15(join14(dir, DECK_PAGE), "utf8").catch(() => null)
12216
+ await readFile16(join15(dir, TIMING_FILE), "utf8").catch(() => null),
12217
+ await readFile16(join15(dir, DECK_PAGE), "utf8").catch(() => null)
10188
12218
  );
10189
12219
  }
10190
12220
  var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
@@ -10207,8 +12237,8 @@ function scanNarration(page, files) {
10207
12237
  const dir = typeof parsed.dir === "string" && parsed.dir ? `${parsed.dir}/` : "";
10208
12238
  const scenes = parsed.scenes ?? {};
10209
12239
  const missing = /* @__PURE__ */ new Set();
10210
- for (const segments of Object.values(scenes)) {
10211
- for (const s of segments ?? []) {
12240
+ for (const segments2 of Object.values(scenes)) {
12241
+ for (const s of segments2 ?? []) {
10212
12242
  if (typeof s?.audio !== "string" || /^[a-z][a-z0-9+.-]*:|^\//i.test(s.audio)) continue;
10213
12243
  if (!files.has(`${dir}${s.audio}`)) missing.add(s.audio);
10214
12244
  }
@@ -10224,7 +12254,7 @@ function scanNarration(page, files) {
10224
12254
  ];
10225
12255
  }
10226
12256
  async function readTiming2(dir) {
10227
- const raw2 = await readFile15(join14(dir, TIMING_FILE), "utf8").catch(() => "");
12257
+ const raw2 = await readFile16(join15(dir, TIMING_FILE), "utf8").catch(() => "");
10228
12258
  if (!raw2) return void 0;
10229
12259
  try {
10230
12260
  const parsed = JSON.parse(raw2);
@@ -10234,9 +12264,9 @@ async function readTiming2(dir) {
10234
12264
  }
10235
12265
  }
10236
12266
  async function listFiles(dir) {
10237
- const entries = await readdir2(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12267
+ const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
10238
12268
  return new Set(
10239
- entries.filter((e) => e.isFile()).map((e) => relative(dir, join14(e.parentPath, e.name)).split(sep).join("/"))
12269
+ entries.filter((e) => e.isFile()).map((e) => relative(dir, join15(e.parentPath, e.name)).split(sep).join("/"))
10240
12270
  );
10241
12271
  }
10242
12272
  var INSTEAD = {
@@ -10362,20 +12392,20 @@ function scanBeatCount(storyboard, prefs) {
10362
12392
  ];
10363
12393
  }
10364
12394
  function scanPaperArc(storyboard, prefs) {
10365
- return arcProblems(storyboard, prefs).map((message) => ({
12395
+ return arcProblems(storyboard, prefs).map((message2) => ({
10366
12396
  severity: "warning",
10367
12397
  gate: "storyboard",
10368
12398
  rule: "paper_arc",
10369
- message
12399
+ message: message2
10370
12400
  }));
10371
12401
  }
10372
12402
  function scanNarrationDrift(storyboard, narration) {
10373
12403
  const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
10374
12404
  const stale = [];
10375
12405
  for (const beat of storyboard.beats) {
10376
- const segments = narration.beats[beat.id];
10377
- if (!segments?.length || !flat(beat.narration)) continue;
10378
- if (flat(segments.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
12406
+ const segments2 = narration.beats[beat.id];
12407
+ if (!segments2?.length || !flat(beat.narration)) continue;
12408
+ if (flat(segments2.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
10379
12409
  }
10380
12410
  if (!stale.length) return [];
10381
12411
  return [
@@ -10420,8 +12450,8 @@ function scanNarrationLead(beats, timing) {
10420
12450
  }
10421
12451
  return findings;
10422
12452
  }
10423
- function firstMention(segments, label) {
10424
- for (const segment of segments) {
12453
+ function firstMention(segments2, label) {
12454
+ for (const segment of segments2) {
10425
12455
  for (const cue of segment.cues) {
10426
12456
  const words2 = cue.text.split(/\s+/);
10427
12457
  let at = 0;
@@ -10479,32 +12509,28 @@ var STOP = /* @__PURE__ */ new Set([
10479
12509
  function scanDeterminism(html, file) {
10480
12510
  const findings = [];
10481
12511
  const lines = html.split("\n");
10482
- for (const [pattern, rule] of NONDETERMINISM) {
12512
+ for (const [pattern, rule, verb, remedy2] of NONDETERMINISM) {
10483
12513
  const i = lines.findIndex((line2) => pattern.test(line2));
10484
12514
  if (i < 0) continue;
10485
12515
  findings.push({
10486
12516
  severity: "error",
10487
12517
  gate: "determinism",
10488
12518
  rule,
10489
- message: `${file}:${i + 1} calls \`${lines[i]?.match(pattern)?.[0]}\` at render time, so two renders of this deck will not be identical.`
12519
+ message: `${file}:${i + 1} ${verb} \`${lines[i]?.match(pattern)?.[0]}\` at render time, so two renders of this deck will not be identical.` + (remedy2 ? ` ${remedy2}` : "")
10490
12520
  });
10491
12521
  }
10492
12522
  return findings;
10493
12523
  }
10494
12524
  async function readCompositions(dir) {
10495
- const entries = await readdir2(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12525
+ const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
10496
12526
  const files = entries.filter(
10497
12527
  (e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
10498
- ).map((e) => join14(e.parentPath, e.name));
12528
+ ).map((e) => join15(e.parentPath, e.name));
10499
12529
  return Promise.all(
10500
- files.map(async (f) => [relative(dir, f), await readFile15(f, "utf8")])
12530
+ files.map(async (f) => [relative(dir, f), await readFile16(f, "utf8")])
10501
12531
  );
10502
12532
  }
10503
12533
 
10504
- // src/version.ts
10505
- import { createRequire as createRequire2 } from "node:module";
10506
- var VERSION = createRequire2(import.meta.url)("../package.json").version;
10507
-
10508
12534
  // src/cli.ts
10509
12535
  var AUDIO_DIR = "audio";
10510
12536
  var NARRATION_FILE = "narration.json";
@@ -10512,7 +12538,7 @@ var DEFAULTS = prefsSchema.parse({});
10512
12538
  async function deckRuntime() {
10513
12539
  const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
10514
12540
  try {
10515
- return await readFile16(path2, "utf8");
12541
+ return await readFile17(path2, "utf8");
10516
12542
  } catch {
10517
12543
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
10518
12544
  }
@@ -10520,40 +12546,40 @@ async function deckRuntime() {
10520
12546
  function playerBundle() {
10521
12547
  const require2 = createRequire3(import.meta.url);
10522
12548
  try {
10523
- return join15(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12549
+ return join16(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
10524
12550
  } catch {
10525
12551
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
10526
12552
  }
10527
12553
  }
10528
12554
  async function vendorKatex(out) {
10529
12555
  const require2 = createRequire3(import.meta.url);
10530
- const dist = join15(dirname4(require2.resolve("katex/package.json")), "dist");
10531
- const css = await readFile16(join15(dist, "katex.min.css"), "utf8");
10532
- await mkdir9(join15(out, "katex/fonts"), { recursive: true });
10533
- for (const file of await readdir3(join15(dist, "fonts"))) {
12556
+ const dist = join16(dirname4(require2.resolve("katex/package.json")), "dist");
12557
+ const css = await readFile17(join16(dist, "katex.min.css"), "utf8");
12558
+ await mkdir10(join16(out, "katex/fonts"), { recursive: true });
12559
+ for (const file of await readdir4(join16(dist, "fonts"))) {
10534
12560
  if (file.endsWith(".woff2"))
10535
- await cp(join15(dist, "fonts", file), join15(out, "katex/fonts", file));
12561
+ await cp(join16(dist, "fonts", file), join16(out, "katex/fonts", file));
10536
12562
  }
10537
12563
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
10538
12564
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
10539
12565
  return kept ? `src:${kept}` : whole;
10540
12566
  });
10541
- await writeFile11(join15(out, "katex/katex.min.css"), woff2Only);
12567
+ await writeFile12(join16(out, "katex/katex.min.css"), woff2Only);
10542
12568
  }
10543
12569
  async function vendorScripts(out) {
10544
12570
  const require2 = createRequire3(import.meta.url);
10545
- await mkdir9(join15(out, "vendor"), { recursive: true });
12571
+ await mkdir10(join16(out, "vendor"), { recursive: true });
10546
12572
  for (const [pkg, rel, name] of [
10547
12573
  ["gsap/package.json", "dist/gsap.min.js", "gsap.min.js"],
10548
12574
  ["gsap/package.json", "dist/DrawSVGPlugin.min.js", "DrawSVGPlugin.min.js"],
10549
12575
  ["katex/package.json", "dist/katex.min.js", "katex.min.js"]
10550
12576
  ]) {
10551
- const from = join15(dirname4(require2.resolve(pkg)), rel);
10552
- await cp(from, join15(out, "vendor", name));
12577
+ const from = join16(dirname4(require2.resolve(pkg)), rel);
12578
+ await cp(from, join16(out, "vendor", name));
10553
12579
  }
10554
12580
  await cp(
10555
12581
  fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
10556
- join15(out, "vendor", "ds-morph.js")
12582
+ join16(out, "vendor", "ds-morph.js")
10557
12583
  );
10558
12584
  }
10559
12585
  var HYPERFRAMES_JSON = `${JSON.stringify(
@@ -10619,22 +12645,78 @@ function flags(o) {
10619
12645
  function stated(prefs, key) {
10620
12646
  return prefs[key] === DEFAULTS[key] ? void 0 : prefs[key];
10621
12647
  }
12648
+ function url(input) {
12649
+ return /^https?:\/\//i.test(input);
12650
+ }
12651
+ function budget2(o) {
12652
+ const maxAssets = positive(o.maxAssets, "--max-assets");
12653
+ const maxClips = positive(o.maxClips, "--max-clips");
12654
+ const maxTotalBytes = positive(o.maxBytes, "--max-bytes");
12655
+ const seconds = positive(o.maxSeconds, "--max-seconds");
12656
+ const maxClipSeconds = positive(o.maxClipSeconds, "--max-clip-seconds");
12657
+ return {
12658
+ ...maxAssets === void 0 ? {} : { maxAssets },
12659
+ ...maxClips === void 0 ? {} : { maxClips },
12660
+ ...maxTotalBytes === void 0 ? {} : { maxTotalBytes },
12661
+ ...seconds === void 0 ? {} : { maxWallMs: seconds * 1e3 },
12662
+ ...maxClipSeconds === void 0 ? {} : { maxClipSeconds },
12663
+ // Only the negative travels. Commander fills `--no-` flags in as `true`, and
12664
+ // passing that through would state a default in this file that belongs to
12665
+ // `harvest` — which is also what the server and the MCP call.
12666
+ ...o.transcode === false ? { transcode: false } : {}
12667
+ };
12668
+ }
12669
+ function positive(value, flag) {
12670
+ if (value === void 0) return void 0;
12671
+ const n3 = Number(value);
12672
+ if (!Number.isFinite(n3) || n3 <= 0) {
12673
+ throw new Error(`${flag} takes a positive number; got "${value}".`);
12674
+ }
12675
+ return n3;
12676
+ }
10622
12677
  var program = new Command();
10623
12678
  program.name("decksmith").description("Turn a source document into an animated explanation deck.").version(VERSION);
10624
- program.command("ingest").description("Parse a document into source.json, localising its figures and font.").argument("<input>", "source document (markdown)").requiredOption("-o, --out <file>", "where to write source.json").option("--lang <bcp47>", "override the sniffed language").action(async (input, o) => {
10625
- const md = await readFile16(resolve4(input), "utf8").catch(() => {
10626
- throw new Error(`Cannot read ${input}.`);
10627
- });
10628
- const parsed = parseMarkdown(md, { lang: o.lang });
10629
- const assets = join15(dirname4(resolve4(o.out)), "assets");
10630
- step(
10631
- `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations`
10632
- );
10633
- const source = await fetchFigures(parsed, assets);
10634
- const bundle = await bundleFont(source.lang, glyphs(source), join15(assets, "fonts"));
10635
- if (bundle) step(`ingest: bundled ${bundle.family} for ${source.lang}`);
10636
- await writeJson(o.out, source);
10637
- step(`ingest: wrote ${o.out}`);
12679
+ program.command("ingest").description("Parse a document or a web page into source.json, localising its figures and font.").argument("<input>", "the document: a markdown file, or an http(s) URL to read the page at").requiredOption("-o, --out <file>", "where to write source.json").option("--lang <bcp47>", "override the sniffed language").option("--max-assets <n>", "URL only: figures to download (maxAssets; default 40)").option("--max-clips <n>", "URL only: videos to download as clips (maxClips; default 4)").option("--max-bytes <n>", "URL only: bytes to download in total (maxTotalBytes; default 96 MB)").option(
12680
+ "--max-seconds <n>",
12681
+ "URL only: wall clock for the whole harvest (maxWallMs; default 180)"
12682
+ ).option(
12683
+ "--max-clip-seconds <n>",
12684
+ "URL only: seconds of each clip kept, the rest trimmed (maxClipSeconds; default 60)"
12685
+ ).option("--no-transcode", "URL only: ship each clip as the page served it, unshrunk").action(async (input, o) => {
12686
+ const assets = join16(dirname4(resolve5(o.out)), "assets");
12687
+ const scratch = url(input) ? await mkdtemp4(join16(tmpdir4(), "decksmith-harvest-")) : void 0;
12688
+ try {
12689
+ let md;
12690
+ let clips = [];
12691
+ if (scratch === void 0) {
12692
+ md = await readFile17(resolve5(input), "utf8").catch(() => {
12693
+ throw new Error(`Cannot read ${input}.`);
12694
+ });
12695
+ } else {
12696
+ step(`ingest: reading ${input} \u2014 this opens a browser and takes a moment`);
12697
+ const page = await harvest(input, scratch, budget2(o));
12698
+ for (const warning of page.warnings) step(`ingest: ${warning}`);
12699
+ step(
12700
+ `ingest: harvested "${page.title}" \u2014 ${page.assets.length} images, ${page.clips.length} clips`
12701
+ );
12702
+ md = page.markdown;
12703
+ clips = page.clips;
12704
+ }
12705
+ const parsed = parseMarkdown(md, { lang: o.lang });
12706
+ step(
12707
+ `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations`
12708
+ );
12709
+ const withClips = await attachClips(parsed, clips, assets);
12710
+ const dropped = [];
12711
+ const source = await fetchFigures(withClips, assets, dropped);
12712
+ for (const why3 of dropped) step(`ingest: ${why3}`);
12713
+ const bundle = await bundleFont(source.lang, glyphs(source), join16(assets, "fonts"));
12714
+ if (bundle) step(`ingest: bundled ${bundle.family} for ${source.lang}`);
12715
+ await writeJson(o.out, source);
12716
+ step(`ingest: wrote ${o.out}`);
12717
+ } finally {
12718
+ if (scratch !== void 0) await rm9(scratch, { recursive: true, force: true });
12719
+ }
10638
12720
  });
10639
12721
  imageFlags(
10640
12722
  lookFlags(
@@ -10689,7 +12771,7 @@ imageFlags(
10689
12771
  program.command("illustrate").description("Draw the pictures the plan asked for, and point the storyboard at them.").argument("<storyboard>", "storyboard.json carrying illustration briefs").requiredOption("--source <file>", "source.json the storyboard was planned from")
10690
12772
  ).action(async (sbPath, o) => {
10691
12773
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
10692
- const sourcePath = resolve4(String(o.source));
12774
+ const sourcePath = resolve5(String(o.source));
10693
12775
  const source = await readValidated(sourcePath, sourceSchema, "source");
10694
12776
  assertRefsResolve(storyboard, source, { pending: "allow" });
10695
12777
  const prefs = await loadPrefs(prefsFromFlags({ ...flags(o), images: true }));
@@ -10703,7 +12785,7 @@ imageFlags(
10703
12785
  );
10704
12786
  const drawn = await illustrate(storyboard, source, {
10705
12787
  prefs,
10706
- assetsDir: join15(dirname4(sourcePath), "assets"),
12788
+ assetsDir: join16(dirname4(sourcePath), "assets"),
10707
12789
  onStep: step
10708
12790
  });
10709
12791
  for (const p of drawn.illustrated) {
@@ -10728,18 +12810,18 @@ voiceFlags(
10728
12810
  const format = pickFormat(String(o.format), o.width, o.height);
10729
12811
  const chosen = await loadPrefs(prefsFromFlags(flags(o)));
10730
12812
  const prefs = stated(chosen, "lang") ? chosen : { ...chosen, lang: storyboard.lang };
10731
- const dir = resolve4(String(o.out));
12813
+ const dir = resolve5(String(o.out));
10732
12814
  const speaking = storyboard.beats.filter((b) => b.narration?.trim()).length;
10733
12815
  if (speaking === 0) {
10734
12816
  throw new Error(`No beat in ${sbPath} has a "narration" field, so there is nothing to speak.`);
10735
12817
  }
10736
12818
  step(`narrate: ${speaking} of ${storyboard.beats.length} beats have narration`);
10737
12819
  const narration = await narrate(storyboard, source, prefs, { dir, format });
10738
- await writeJson(join15(dir, NARRATION_FILE), narration);
10739
- const segments = Object.values(narration.beats).flat();
10740
- const seconds = segments.reduce((sum, s) => sum + s.seconds, 0);
12820
+ await writeJson(join16(dir, NARRATION_FILE), narration);
12821
+ const segments2 = Object.values(narration.beats).flat();
12822
+ const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
10741
12823
  step(
10742
- `narrate: ${segments.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join15(dir, NARRATION_FILE)}`
12824
+ `narrate: ${segments2.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join16(dir, NARRATION_FILE)}`
10743
12825
  );
10744
12826
  });
10745
12827
  lookFlags(
@@ -10754,8 +12836,8 @@ lookFlags(
10754
12836
  const format = withMinWeight(pickFormat(o.format, o.width, o.height), o.minWeight);
10755
12837
  const prefs = await loadPrefs(prefsFromFlags(flags(o)), process.cwd(), source);
10756
12838
  const theme = stated(prefs, "theme") ?? storyboard.theme;
10757
- const out = resolve4(o.out);
10758
- await mkdir9(out, { recursive: true });
12839
+ const out = resolve5(o.out);
12840
+ await mkdir10(out, { recursive: true });
10759
12841
  const found = await findNarration(sbPath, o.narration);
10760
12842
  const narration = found ? await loadNarration(found) : void 0;
10761
12843
  if (narration) {
@@ -10781,8 +12863,8 @@ lookFlags(
10781
12863
  // keeps finding by eye.
10782
12864
  onBeatError: (id2, err) => step(`build: left out ${id2} \u2014 ${err.message}`)
10783
12865
  });
10784
- await writeFile11(join15(out, "index.html"), deck.composition);
10785
- await writeFile11(join15(out, "hyperframes.json"), HYPERFRAMES_JSON);
12866
+ await writeFile12(join16(out, "index.html"), deck.composition);
12867
+ await writeFile12(join16(out, "hyperframes.json"), HYPERFRAMES_JSON);
10786
12868
  await writeTiming(out, {
10787
12869
  storyboard,
10788
12870
  source,
@@ -10798,36 +12880,36 @@ lookFlags(
10798
12880
  ...narration ? { narration } : {}
10799
12881
  });
10800
12882
  if (deck.page) {
10801
- await writeFile11(join15(out, DECK_PAGE), deck.page);
10802
- await cp(playerBundle(), join15(out, PLAYER_FILE));
12883
+ await writeFile12(join16(out, DECK_PAGE), deck.page);
12884
+ await cp(playerBundle(), join16(out, PLAYER_FILE));
10803
12885
  }
10804
12886
  await vendorKatex(out);
10805
12887
  await vendorScripts(out);
10806
- await copyAssets(dirname4(resolve4(o.source)), out, source.figures);
12888
+ await copyAssets(dirname4(resolve5(o.source)), out, source.figures);
10807
12889
  if (found && narration) await copyAudio(dirname4(found), narration, out);
10808
12890
  const look = [theme, paced.speed === 1 ? "" : `${paced.speed}\xD7 speed`].filter(Boolean).join(", ");
10809
12891
  const { cut } = deck;
10810
12892
  const floor = cut.dropped.filter((d) => d.rule === "below_min_weight").length;
10811
12893
  const of = cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
10812
12894
  step(
10813
- `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join15(out, "index.html")}`
12895
+ `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join16(out, "index.html")}`
10814
12896
  );
10815
12897
  for (const f of scanPaperArc({ ...storyboard, beats: cut.kept }, prefs))
10816
12898
  step(`build: ${f.message}`);
10817
12899
  reportCut(cut);
10818
- if (deck.page) step(`build: navigable deck \u2192 ${join15(out, DECK_PAGE)}`);
12900
+ if (deck.page) step(`build: navigable deck \u2192 ${join16(out, DECK_PAGE)}`);
10819
12901
  await gate(out, false, storyboard, cut.kept, o.fidelity !== false, source);
10820
12902
  }
10821
12903
  );
10822
12904
  program.command("verify").description("Re-run the gates on a built deck.").argument("<dir>", "a built deck directory").option("--snapshots", "also write the contrast-pass PNGs to <dir>/snapshots", false).option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser").action(async (dir, o) => {
10823
- await gate(resolve4(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
12905
+ await gate(resolve5(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
10824
12906
  });
10825
12907
  program.command("frames").description("Write PNGs of a built deck through the capture path the renderer uses.").argument("<dir>", "a built deck directory").option(
10826
12908
  "--at <seconds...>",
10827
12909
  "absolute times to photograph; defaults to every hold the deck declares"
10828
12910
  ).option("--out <dir>", "where to write the PNGs (default: <dir>/frames)").action(async (dir, o) => {
10829
- const deck = resolve4(dir);
10830
- const out = resolve4(o.out ?? join15(deck, "frames"));
12911
+ const deck = resolve5(dir);
12912
+ const out = resolve5(o.out ?? join16(deck, "frames"));
10831
12913
  let times;
10832
12914
  if (o.at?.length) {
10833
12915
  times = o.at.map((raw2) => {
@@ -10837,8 +12919,8 @@ program.command("frames").description("Write PNGs of a built deck through the ca
10837
12919
  });
10838
12920
  } else {
10839
12921
  times = readStops(
10840
- await readFile16(join15(deck, TIMING_FILE), "utf8").catch(() => null),
10841
- await readFile16(join15(deck, DECK_PAGE), "utf8").catch(() => null)
12922
+ await readFile17(join16(deck, TIMING_FILE), "utf8").catch(() => null),
12923
+ await readFile17(join16(deck, DECK_PAGE), "utf8").catch(() => null)
10842
12924
  ).map((s) => s.t);
10843
12925
  if (times.length === 0)
10844
12926
  throw new Error(
@@ -10852,7 +12934,7 @@ program.command("frames").description("Write PNGs of a built deck through the ca
10852
12934
  program.command("drift").description("Render a built deck twice and compare, frame by frame. Minutes, not seconds.").argument("<dir>", "a built deck directory").option("--identical", "require every frame byte-identical \u2014 image-free decks only", false).option("--floor <dB>", "per-frame PSNR floor", String(FLOOR_DB)).option("--keep", "keep both frame directories even when they agree").option("--workers <n>", "pin both renders to one worker count (default: 1 vs 3)").action(
10853
12935
  async (dir, o) => {
10854
12936
  step("drift: rendering twice, several minutes a render");
10855
- const verdict = await drift(resolve4(dir), {
12937
+ const verdict = await drift(resolve5(dir), {
10856
12938
  mode: o.identical ? "identical" : "psnr",
10857
12939
  floorDb: Number(o.floor),
10858
12940
  ...o.keep ? { keep: true } : {},
@@ -10879,8 +12961,8 @@ program.command("render").description("Render a built deck to a finished video:
10879
12961
  throw new Error(`Unknown --subtitles "${o.subtitles}". Use sidecar, burn or none.`);
10880
12962
  }
10881
12963
  const result = await render({
10882
- deck: resolve4(dir),
10883
- out: resolve4(o.out),
12964
+ deck: resolve5(dir),
12965
+ out: resolve5(o.out),
10884
12966
  subtitles: o.subtitles,
10885
12967
  protocolTimeoutMs: Number(o.protocolTimeout),
10886
12968
  log: step,
@@ -10908,7 +12990,7 @@ voiceFlags(
10908
12990
  )
10909
12991
  ).action(async (sbPath, o) => {
10910
12992
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
10911
- const sourcePath = resolve4(String(o.source));
12993
+ const sourcePath = resolve5(String(o.source));
10912
12994
  const source = await readValidated(sourcePath, sourceSchema, "source");
10913
12995
  assertRefsResolve(storyboard, source);
10914
12996
  if (o.bake && o.link) throw new Error("Choose --bake or --link, not both.");
@@ -10938,7 +13020,7 @@ voiceFlags(
10938
13020
  ...narration ? { narration } : {},
10939
13021
  media: plan.media
10940
13022
  };
10941
- const out = resolve4(String(o.out));
13023
+ const out = resolve5(String(o.out));
10942
13024
  const bytes = await writePack(pack2, files, out);
10943
13025
  if (plan.demoted.length) {
10944
13026
  step(`pack: kept as links, not bakeable \u2014 ${plan.demoted.join(", ")}`);
@@ -10952,19 +13034,19 @@ voiceFlags(
10952
13034
  step(`pack: ${size2(bytes)} \u2192 ${out}`);
10953
13035
  });
10954
13036
  program.command("unpack").description("Open a .deck back into the files build reads.").argument("<file>", "a .deck archive").requiredOption("-o, --out <dir>", "directory to open it into").action(async (file, o) => {
10955
- const { pack: pack2, files } = await readPack(resolve4(file));
10956
- const out = resolve4(o.out);
10957
- await writeJson(join15(out, "source.json"), pack2.source);
10958
- await writeJson(join15(out, "storyboard.json"), pack2.storyboard);
10959
- await writeJson(join15(out, "decksmith.config.json"), pack2.prefs);
10960
- if (pack2.narration) await writeJson(join15(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
13037
+ const { pack: pack2, files } = await readPack(resolve5(file));
13038
+ const out = resolve5(o.out);
13039
+ await writeJson(join16(out, "source.json"), pack2.source);
13040
+ await writeJson(join16(out, "storyboard.json"), pack2.storyboard);
13041
+ await writeJson(join16(out, "decksmith.config.json"), pack2.prefs);
13042
+ if (pack2.narration) await writeJson(join16(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
10961
13043
  const figures = new Map(pack2.source.figures.map((f) => [f.id, f.src]));
10962
13044
  for (const [path2, bytes] of Object.entries(files)) {
10963
13045
  const media = pack2.media.find((m) => m.path === path2);
10964
13046
  const src = media && figures.get(media.id);
10965
- const to = src ? join15("assets", src) : path2;
10966
- await mkdir9(dirname4(join15(out, to)), { recursive: true });
10967
- await writeFile11(join15(out, to), bytes);
13047
+ const to = src ? join16("assets", src) : path2;
13048
+ await mkdir10(dirname4(join16(out, to)), { recursive: true });
13049
+ await writeFile12(join16(out, to), bytes);
10968
13050
  }
10969
13051
  const linked = pack2.media.filter((m) => m.policy !== "bake");
10970
13052
  step(`unpack: "${pack2.title}", ${pack2.storyboard.beats.length} beats \u2192 ${out}`);
@@ -10974,7 +13056,7 @@ program.command("unpack").description("Open a .deck back into the files build re
10974
13056
  );
10975
13057
  }
10976
13058
  step(
10977
- `unpack: decksmith build ${join15(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join15(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
13059
+ `unpack: decksmith build ${join16(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join16(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
10978
13060
  );
10979
13061
  });
10980
13062
  program.parseAsync(process.argv).catch((err) => {
@@ -10995,7 +13077,7 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
10995
13077
  );
10996
13078
  const verdict = await verify(dir, { snapshots, fidelity: fidelity2 }, storyboard, kept, source);
10997
13079
  process.stdout.write(report(verdict));
10998
- if (snapshots) step(`verify: snapshots in ${join15(dir, "snapshots")}`);
13080
+ if (snapshots) step(`verify: snapshots in ${join16(dir, "snapshots")}`);
10999
13081
  if (!verdict.passed) process.exitCode = 1;
11000
13082
  }
11001
13083
  function report(v) {
@@ -11012,13 +13094,13 @@ function report(v) {
11012
13094
  async function findNarration(sbPath, flag) {
11013
13095
  if (flag === false) return void 0;
11014
13096
  if (typeof flag === "string") {
11015
- const path2 = resolve4(flag);
11016
- if (!await stat(path2).catch(() => null)) throw new Error(`Cannot read narration ${flag}.`);
13097
+ const path2 = resolve5(flag);
13098
+ if (!await stat2(path2).catch(() => null)) throw new Error(`Cannot read narration ${flag}.`);
11017
13099
  return path2;
11018
13100
  }
11019
- const beside = dirname4(resolve4(sbPath));
11020
- for (const candidate of [join15(beside, AUDIO_DIR, NARRATION_FILE), join15(beside, NARRATION_FILE)]) {
11021
- if (await stat(candidate).catch(() => null)) {
13101
+ const beside = dirname4(resolve5(sbPath));
13102
+ for (const candidate of [join16(beside, AUDIO_DIR, NARRATION_FILE), join16(beside, NARRATION_FILE)]) {
13103
+ if (await stat2(candidate).catch(() => null)) {
11022
13104
  step(`narration: using ${candidate}`);
11023
13105
  return candidate;
11024
13106
  }
@@ -11037,11 +13119,11 @@ function audioNames(narration) {
11037
13119
  ].sort();
11038
13120
  }
11039
13121
  async function copyAudio(from, narration, out) {
11040
- const dir = join15(out, AUDIO_DIR);
11041
- await mkdir9(dir, { recursive: true });
13122
+ const dir = join16(out, AUDIO_DIR);
13123
+ await mkdir10(dir, { recursive: true });
11042
13124
  const names = audioNames(narration);
11043
13125
  for (const name of names) {
11044
- await cp(join15(from, name), join15(dir, name)).catch(() => {
13126
+ await cp(join16(from, name), join16(dir, name)).catch(() => {
11045
13127
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
11046
13128
  });
11047
13129
  }
@@ -11050,7 +13132,7 @@ async function copyAudio(from, narration, out) {
11050
13132
  async function audioFiles(from, narration) {
11051
13133
  const files = {};
11052
13134
  for (const name of audioNames(narration)) {
11053
- const bytes = await readFile16(join15(from, name)).catch(() => {
13135
+ const bytes = await readFile17(join16(from, name)).catch(() => {
11054
13136
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
11055
13137
  });
11056
13138
  files[`${AUDIO_DIR}/${name}`] = new Uint8Array(bytes);
@@ -11072,9 +13154,9 @@ function wrapScript(text2, width) {
11072
13154
  async function writeTiming(out, input) {
11073
13155
  try {
11074
13156
  const timing = planTiming(input);
11075
- await writeJson(join15(out, TIMING_FILE), timing);
13157
+ await writeJson(join16(out, TIMING_FILE), timing);
11076
13158
  step(
11077
- `build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join15(out, TIMING_FILE)}`
13159
+ `build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join16(out, TIMING_FILE)}`
11078
13160
  );
11079
13161
  } catch (err) {
11080
13162
  step(
@@ -11083,10 +13165,10 @@ async function writeTiming(out, input) {
11083
13165
  }
11084
13166
  }
11085
13167
  function figureAssets(source, sourcePath, prefer) {
11086
- const assets = join15(dirname4(sourcePath), "assets");
13168
+ const assets = join16(dirname4(sourcePath), "assets");
11087
13169
  return source.figures.map((f) => ({
11088
13170
  id: f.id,
11089
- url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join15(assets, f.src),
13171
+ url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join16(assets, f.src),
11090
13172
  prefer
11091
13173
  }));
11092
13174
  }
@@ -11115,25 +13197,27 @@ function withMinWeight(format, raw2) {
11115
13197
  return { ...format, minWeight };
11116
13198
  }
11117
13199
  async function copyAssets(sourceDir, out, figures) {
11118
- const from = join15(sourceDir, "assets");
11119
- if (!await stat(from).catch(() => null)) {
13200
+ const from = join16(sourceDir, "assets");
13201
+ if (!await stat2(from).catch(() => null)) {
11120
13202
  step(`build: no assets/ beside source.json, skipping`);
11121
13203
  return;
11122
13204
  }
11123
- const wanted = new Set(figures.map((f) => f.src.replace(/^\.?\//, "")));
11124
- await mkdir9(join15(out, "assets"), { recursive: true });
13205
+ const wanted = new Set(
13206
+ figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
13207
+ );
13208
+ await mkdir10(join16(out, "assets"), { recursive: true });
11125
13209
  let copied = 0;
11126
13210
  for (const name of wanted) {
11127
- const src = resolve4(join15(from, name));
11128
- if (!src.startsWith(`${resolve4(from)}/`)) continue;
11129
- if (!await stat(src).catch(() => null)) continue;
11130
- await mkdir9(dirname4(join15(out, "assets", name)), { recursive: true });
11131
- await cp(src, join15(out, "assets", name));
13211
+ const src = resolve5(join16(from, name));
13212
+ if (!src.startsWith(`${resolve5(from)}/`)) continue;
13213
+ if (!await stat2(src).catch(() => null)) continue;
13214
+ await mkdir10(dirname4(join16(out, "assets", name)), { recursive: true });
13215
+ await cp(src, join16(out, "assets", name));
11132
13216
  copied++;
11133
13217
  }
11134
- const fonts = join15(from, "fonts");
11135
- if (await stat(fonts).catch(() => null)) {
11136
- await cp(fonts, join15(out, "assets", "fonts"), { recursive: true });
13218
+ const fonts = join16(from, "fonts");
13219
+ if (await stat2(fonts).catch(() => null)) {
13220
+ await cp(fonts, join16(out, "assets", "fonts"), { recursive: true });
11137
13221
  }
11138
13222
  step(`build: copied ${copied} referenced figure(s)`);
11139
13223
  }
@@ -11142,7 +13226,7 @@ async function refreshFont(storyboard, source, out) {
11142
13226
  const bundle = await bundleFont(
11143
13227
  storyboard.lang,
11144
13228
  glyphs(source) + glyphs(storyboard),
11145
- join15(out, "assets", "fonts")
13229
+ join16(out, "assets", "fonts")
11146
13230
  );
11147
13231
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
11148
13232
  return bundle?.css;
@@ -11157,7 +13241,7 @@ function glyphs(value) {
11157
13241
  return JSON.stringify(value);
11158
13242
  }
11159
13243
  async function readValidated(file, schema, label) {
11160
- const text2 = await readFile16(resolve4(file), "utf8").catch(() => {
13244
+ const text2 = await readFile17(resolve5(file), "utf8").catch(() => {
11161
13245
  throw new Error(`Cannot read ${label} file ${file}.`);
11162
13246
  });
11163
13247
  let json;
@@ -11175,9 +13259,9 @@ ${issues}`);
11175
13259
  return parsed.data;
11176
13260
  }
11177
13261
  async function writeJson(file, value) {
11178
- const path2 = resolve4(file);
11179
- await mkdir9(dirname4(path2), { recursive: true });
11180
- await writeFile11(path2, `${JSON.stringify(value, null, 2)}
13262
+ const path2 = resolve5(file);
13263
+ await mkdir10(dirname4(path2), { recursive: true });
13264
+ await writeFile12(path2, `${JSON.stringify(value, null, 2)}
11181
13265
  `);
11182
13266
  }
11183
13267
  function size2(bytes) {
@@ -11185,7 +13269,7 @@ function size2(bytes) {
11185
13269
  const n3 = bytes / 1024;
11186
13270
  return n3 < 1024 ? `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} KB` : `${(n3 / 1024).toFixed(1)} MB`;
11187
13271
  }
11188
- function step(message) {
11189
- process.stderr.write(`${message}
13272
+ function step(message2) {
13273
+ process.stderr.write(`${message2}
11190
13274
  `);
11191
13275
  }