@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/index.js CHANGED
@@ -1,9 +1,243 @@
1
1
  // src/index.ts
2
- import { cp, mkdir as mkdir9, readdir as readdir3, readFile as readFile16, stat, writeFile as writeFile11 } from "node:fs/promises";
3
- import { createRequire as createRequire2 } from "node:module";
4
- import { dirname as dirname4, join as join15, resolve as resolve4 } from "node:path";
2
+ import { cp, mkdir as mkdir10, readdir as readdir4, readFile as readFile17, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
3
+ import { createRequire as createRequire3 } from "node:module";
4
+ import { dirname as dirname4, join as join16, resolve as resolve5 } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
+ // src/pack/media.ts
8
+ import { createHash } from "node:crypto";
9
+ import { readFile } from "node:fs/promises";
10
+ import { extname } from "node:path";
11
+ var PLAYERS = [
12
+ "youtube.com",
13
+ "youtube-nocookie.com",
14
+ "youtu.be",
15
+ "vimeo.com",
16
+ "dailymotion.com",
17
+ "dai.ly",
18
+ "twitch.tv",
19
+ "loom.com",
20
+ "wistia.com",
21
+ "wistia.net",
22
+ "streamable.com",
23
+ "bilibili.com",
24
+ "soundcloud.com",
25
+ "tiktok.com"
26
+ ];
27
+ var FILE_EXT = /* @__PURE__ */ new Set([
28
+ ".png",
29
+ ".jpg",
30
+ ".jpeg",
31
+ ".gif",
32
+ ".webp",
33
+ ".avif",
34
+ ".svg",
35
+ ".mp4",
36
+ ".webm",
37
+ ".mov",
38
+ ".m4v",
39
+ ".mp3",
40
+ ".m4a",
41
+ ".wav",
42
+ ".ogg",
43
+ ".opus",
44
+ ".pdf"
45
+ ]);
46
+ function isEmbed(url) {
47
+ const host = hostOf(url);
48
+ return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
49
+ }
50
+ function segments(u) {
51
+ return u.pathname.split("/").filter(Boolean);
52
+ }
53
+ function token(raw2) {
54
+ return raw2 && /^[A-Za-z0-9_-]{1,64}$/.test(raw2) ? raw2 : void 0;
55
+ }
56
+ var YOUTUBE_EMBED = "https://www.youtube-nocookie.com";
57
+ var youtube = (id2) => id2 === void 0 ? void 0 : `${YOUTUBE_EMBED}/embed/${id2}`;
58
+ var YOUTUBE = {
59
+ origin: YOUTUBE_EMBED,
60
+ embed: (u) => {
61
+ const seg = segments(u);
62
+ const path2 = seg[0];
63
+ return youtube(
64
+ token(
65
+ path2 === "watch" ? u.searchParams.get("v") : path2 === "embed" || path2 === "shorts" || path2 === "live" || path2 === "v" ? seg[1] : void 0
66
+ )
67
+ );
68
+ }
69
+ };
70
+ var YOUTU_BE = {
71
+ origin: YOUTUBE_EMBED,
72
+ embed: (u) => youtube(token(segments(u)[0]))
73
+ };
74
+ var VIMEO_EMBED = "https://player.vimeo.com";
75
+ var VIMEO = {
76
+ origin: VIMEO_EMBED,
77
+ embed: (u) => {
78
+ const seg = segments(u);
79
+ const [head, next] = seg[0] === "video" ? [seg[1], seg[2]] : [seg[0], seg[1]];
80
+ if (head === void 0 || !/^\d+$/.test(head)) return void 0;
81
+ const hash = token(next ?? u.searchParams.get("h"));
82
+ return `${VIMEO_EMBED}/video/${head}?${hash ? `h=${hash}&` : ""}dnt=1`;
83
+ }
84
+ };
85
+ var DAILYMOTION_EMBED = "https://www.dailymotion.com";
86
+ var dailymotion = (id2) => id2 === void 0 ? void 0 : `${DAILYMOTION_EMBED}/embed/video/${id2}`;
87
+ var DAILYMOTION = {
88
+ origin: DAILYMOTION_EMBED,
89
+ embed: (u) => {
90
+ const seg = segments(u);
91
+ return dailymotion(
92
+ token(seg[0] === "video" ? seg[1] : seg[0] === "embed" ? seg[2] : void 0)
93
+ );
94
+ }
95
+ };
96
+ var DAI_LY = {
97
+ origin: DAILYMOTION_EMBED,
98
+ embed: (u) => dailymotion(token(segments(u)[0]))
99
+ };
100
+ var LOOM = {
101
+ origin: "https://www.loom.com",
102
+ embed: (u) => {
103
+ const seg = segments(u);
104
+ const id2 = token(seg[0] === "share" || seg[0] === "embed" ? seg[1] : void 0);
105
+ return id2 === void 0 ? void 0 : `https://www.loom.com/embed/${id2}`;
106
+ }
107
+ };
108
+ var EMBEDS = {
109
+ "youtube.com": YOUTUBE,
110
+ "youtube-nocookie.com": YOUTUBE,
111
+ "youtu.be": YOUTU_BE,
112
+ "vimeo.com": VIMEO,
113
+ "dailymotion.com": DAILYMOTION,
114
+ "dai.ly": DAI_LY,
115
+ "loom.com": LOOM
116
+ };
117
+ var EMBED_ORIGINS = [
118
+ ...new Set(Object.values(EMBEDS).map((e) => e.origin))
119
+ ];
120
+ function embedUrl(url) {
121
+ const host = hostOf(url);
122
+ if (host === null) return void 0;
123
+ let u;
124
+ try {
125
+ u = new URL(url);
126
+ } catch {
127
+ return void 0;
128
+ }
129
+ if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
130
+ const rule = Object.entries(EMBEDS).find(([h]) => host === h || host.endsWith(`.${h}`))?.[1];
131
+ return rule?.embed(u);
132
+ }
133
+ function policyFor(url, prefer) {
134
+ if (isEmbed(url)) return "embed";
135
+ const host = hostOf(url);
136
+ if (host === null || /^data:/i.test(url)) return "bake";
137
+ if (prefer === "link") return "link";
138
+ return FILE_EXT.has(extOf(url)) ? "bake" : "link";
139
+ }
140
+ async function planMedia(assets, fetcher = fetchAsset) {
141
+ const plan = {
142
+ media: [],
143
+ files: {},
144
+ bakedCount: 0,
145
+ bakedBytes: 0,
146
+ demoted: [],
147
+ promoted: []
148
+ };
149
+ for (const asset of assets) {
150
+ const policy = policyFor(asset.url, asset.prefer);
151
+ if (policy !== "bake") {
152
+ if (asset.prefer === "bake") plan.demoted.push(asset.id);
153
+ plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
154
+ continue;
155
+ }
156
+ if (asset.prefer === "link") plan.promoted.push(asset.id);
157
+ const got = await fetcher(asset.url);
158
+ const mime = clean(got.mime) ?? asset.mime;
159
+ if (mime === "text/html") {
160
+ plan.demoted.push(asset.id);
161
+ plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
162
+ continue;
163
+ }
164
+ const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
165
+ plan.files[path2] = got.bytes;
166
+ plan.bakedCount += 1;
167
+ plan.bakedBytes += got.bytes.length;
168
+ plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
169
+ }
170
+ return plan;
171
+ }
172
+ function mediaSummary(plan) {
173
+ const linked = plan.media.filter((m) => m.policy === "link").length;
174
+ const embedded = plan.media.filter((m) => m.policy === "embed").length;
175
+ const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
176
+ if (linked) parts.push(`${linked} linked`);
177
+ if (embedded) parts.push(`${embedded} embedded`);
178
+ if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
179
+ return parts.join(", ");
180
+ }
181
+ function bakedName(id2, url, mime) {
182
+ const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
183
+ const hash = createHash("sha256").update(url).digest("hex").slice(0, 8);
184
+ return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
185
+ }
186
+ function extOf(url) {
187
+ const path2 = hostOf(url) === null ? url : new URL(url).pathname;
188
+ const ext = extname(path2).toLowerCase();
189
+ return FILE_EXT.has(ext) ? ext : "";
190
+ }
191
+ var MIME_EXT = {
192
+ "image/png": ".png",
193
+ "image/jpeg": ".jpg",
194
+ "image/gif": ".gif",
195
+ "image/webp": ".webp",
196
+ "image/avif": ".avif",
197
+ "image/svg+xml": ".svg",
198
+ "video/mp4": ".mp4",
199
+ "video/webm": ".webm",
200
+ "audio/mpeg": ".mp3",
201
+ "application/pdf": ".pdf"
202
+ };
203
+ function extFor(mime) {
204
+ return (mime && MIME_EXT[mime]) ?? ".bin";
205
+ }
206
+ function hostOf(url) {
207
+ try {
208
+ const u = new URL(url);
209
+ if (u.protocol === "file:" || u.protocol === "data:") return null;
210
+ return u.hostname.toLowerCase().replace(/^www\./, "");
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+ function clean(mime) {
216
+ return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
217
+ }
218
+ function size(bytes) {
219
+ if (bytes < 1024) return `${bytes} B`;
220
+ const units = ["KB", "MB", "GB"];
221
+ let n3 = bytes / 1024;
222
+ let i = 0;
223
+ while (n3 >= 1024 && i < units.length - 1) {
224
+ n3 /= 1024;
225
+ i += 1;
226
+ }
227
+ return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
228
+ }
229
+ async function fetchAsset(url) {
230
+ if (/^(https?|data):/i.test(url)) {
231
+ const res = await fetch(url);
232
+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
233
+ return {
234
+ bytes: new Uint8Array(await res.arrayBuffer()),
235
+ mime: res.headers.get("content-type") ?? void 0
236
+ };
237
+ }
238
+ return { bytes: new Uint8Array(await readFile(url)) };
239
+ }
240
+
7
241
  // src/types.ts
8
242
  import { z } from "zod";
9
243
  var refSchema = z.object({
@@ -12,9 +246,34 @@ var refSchema = z.object({
12
246
  });
13
247
  var figureSchema = z.object({
14
248
  id: z.string(),
249
+ /**
250
+ * WHAT THE ASSET IS. A CLIP IS A FIGURE.
251
+ *
252
+ * A harvested page hands back stills and video in one pass, and the obvious
253
+ * shape for the video — a fifth `Source` array and a fifth `refSchema` kind —
254
+ * costs every layer that already knows what a figure is: the inventory
255
+ * `renderSource` prints, `assertRefsResolve`, the archetypes that take a
256
+ * `figureId`, and the pack. All of them would need a second word for "the
257
+ * thing this beat points at", and `refSchema` is a CLOSED enum of four kinds
258
+ * that a fifth entry would put into every stored plan's schema. A clip is a
259
+ * rectangle with intrinsic pixels and a caption that a beat points at, which
260
+ * is what a figure is; what differs is one branch at emit.
261
+ *
262
+ * DEFAULTED rather than required, and that is the whole point of the field
263
+ * being an enum with a default: every `source.json` written before clips
264
+ * existed parses unchanged and comes back an `image`, which is what it has
265
+ * always been.
266
+ */
267
+ kind: z.enum(["image", "clip"]).default("image"),
15
268
  /** Path relative to the deck's asset directory. */
16
269
  src: z.string(),
17
270
  caption: z.string(),
271
+ /**
272
+ * The intrinsic pixel size layout keys off — for a clip, the VIDEO's own
273
+ * dimensions, not the poster's. Every fit, crop and leader-line fraction
274
+ * downstream is expressed against this box, so a clip whose poster was
275
+ * letterboxed to another shape would put every annotation in the wrong place.
276
+ */
18
277
  width: z.int().positive(),
19
278
  height: z.int().positive(),
20
279
  /**
@@ -32,7 +291,26 @@ var figureSchema = z.object({
32
291
  */
33
292
  sectionId: z.string().optional(),
34
293
  /** The sentence or paragraph that refers to it, verbatim from the document. */
35
- mention: z.string().optional()
294
+ mention: z.string().optional(),
295
+ // THE THREE FIELDS ONLY A CLIP USES. All optional, for the same reason `kind`
296
+ // is defaulted: an image carries none of them, and a source written before
297
+ // clips existed parses into exactly the object it always did.
298
+ /**
299
+ * The local still that represents the clip — what the deck shows before
300
+ * anyone presses play, and what stands in wherever the video cannot run at
301
+ * all. A clip whose video could not be downloaded is this still and nothing
302
+ * else, so it is the picture the beat is really planned around.
303
+ */
304
+ poster: z.string().optional(),
305
+ /** How long the clip runs. Seconds, as measured off the file, never guessed. */
306
+ seconds: z.number().positive().optional(),
307
+ /**
308
+ * The page the video lives ON, when the video itself is not a file we can
309
+ * fetch — a player page, an embed, anything whose terms or DRM make the bytes
310
+ * unavailable. Then `poster` is all the deck can show, and this is where a
311
+ * viewer goes to watch the thing. Absent for a clip we hold the file for.
312
+ */
313
+ href: z.string().optional()
36
314
  });
37
315
  var equationSchema = z.object({
38
316
  id: z.string(),
@@ -908,8 +1186,8 @@ function clock(seconds) {
908
1186
  }
909
1187
 
910
1188
  // src/source/fonts.ts
911
- import { createHash } from "node:crypto";
912
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1189
+ import { createHash as createHash2 } from "node:crypto";
1190
+ import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
913
1191
  import { join } from "node:path";
914
1192
  function familyFor(lang) {
915
1193
  const tag = lang.toLowerCase();
@@ -923,11 +1201,11 @@ async function bundleFont(lang, glyphs, dir) {
923
1201
  const family = familyFor(lang);
924
1202
  if (!family) return null;
925
1203
  const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
926
- const stamp = `/* decksmith ${createHash("sha256").update(`${family}
1204
+ const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
927
1205
  ${text2}`).digest("hex").slice(0, 16)} */`;
928
1206
  await mkdir(dir, { recursive: true });
929
1207
  const cssPath = join(dir, "fonts.css");
930
- const cached = await readFile(cssPath, "utf8").catch(() => "");
1208
+ const cached = await readFile2(cssPath, "utf8").catch(() => "");
931
1209
  if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
932
1210
  const res = await fetch(
933
1211
  `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
@@ -1809,11 +2087,11 @@ function unwidow(text2, width, size2, weight = 700, face = "latin") {
1809
2087
  function wrapTokens(tokens, size2, width, weight, face) {
1810
2088
  const lines = [];
1811
2089
  let line2 = "";
1812
- for (const token of tokens) {
1813
- const candidate = line2 ? `${line2} ${token}` : token;
2090
+ for (const token2 of tokens) {
2091
+ const candidate = line2 ? `${line2} ${token2}` : token2;
1814
2092
  if (line2 && textWidth(candidate, size2, weight, 0, false, face) > width) {
1815
2093
  lines.push(line2);
1816
- line2 = token;
2094
+ line2 = token2;
1817
2095
  continue;
1818
2096
  }
1819
2097
  line2 = candidate;
@@ -2163,6 +2441,11 @@ var annotatedFigure = (beat, ctx) => {
2163
2441
  `annotated-figure ${beat.id}: no figure "${p.figureId}" in source ${ctx.source.id}`
2164
2442
  );
2165
2443
  }
2444
+ if (fig.kind === "clip") {
2445
+ throw new Error(
2446
+ `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`
2447
+ );
2448
+ }
2166
2449
  const crop = p.crop;
2167
2450
  const view = crop ? { width: fig.width * crop.w, height: fig.height * crop.h } : { width: fig.width, height: fig.height };
2168
2451
  const clamp3 = (v) => Math.min(1, Math.max(0, v));
@@ -2181,7 +2464,7 @@ var annotatedFigure = (beat, ctx) => {
2181
2464
  }
2182
2465
  const plan = planFigure(STAGE_W, notes, view, budget2, isPortrait(ctx.format), face);
2183
2466
  const stageH = plan.height;
2184
- const plate = {
2467
+ const plate2 = {
2185
2468
  x: plan.img.x - PLATE,
2186
2469
  y: plan.img.y - PLATE,
2187
2470
  w: plan.img.w + 2 * PLATE,
@@ -2335,7 +2618,7 @@ var annotatedFigure = (beat, ctx) => {
2335
2618
  // Per-scene geometry: the overlay is only correct because the image box is
2336
2619
  // stated rather than negotiated with the layout engine.
2337
2620
  `#${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}`,
2621
+ `#${sid}-plate{left:${n(plate2.x)}px;top:${n(plate2.y)}px;width:${n(plate2.w)}px;height:${n(plate2.h)}px}`,
2339
2622
  // `overflow:visible` because a dot sitting on the figure's own edge puts its
2340
2623
  // halo outside the viewBox, and a clipped halo reads as a rendering fault.
2341
2624
  `#${sid}-ov{position:absolute;left:0;top:0;overflow:visible}`,
@@ -2791,6 +3074,31 @@ var CLAIM_LH = 1.5;
2791
3074
  var CLAIM_RULE = 6 + 32;
2792
3075
  var BESIDE_COL = 560;
2793
3076
  var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
3077
+ function plate(fig, sid, beatId, start) {
3078
+ const img = (src) => ({
3079
+ html: `<img src="assets/${esc(src)}" alt="${esc(fig.caption)}" />`,
3080
+ el: "img"
3081
+ });
3082
+ if (fig.kind !== "clip") return img(fig.src);
3083
+ if (fig.href !== void 0) {
3084
+ if (fig.poster === void 0) {
3085
+ throw new Error(
3086
+ `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`
3087
+ );
3088
+ }
3089
+ return img(fig.poster);
3090
+ }
3091
+ if (start === void 0) {
3092
+ throw new Error(
3093
+ `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`
3094
+ );
3095
+ }
3096
+ const poster = fig.poster === void 0 ? "" : ` poster="assets/${esc(fig.poster)}"`;
3097
+ return {
3098
+ html: `<video id="${sid}-v" src="assets/${esc(fig.src)}"${poster} data-start="${start}" preload="auto" playsinline muted></video>`,
3099
+ el: "video"
3100
+ };
3101
+ }
2794
3102
  var claimFigure = (beat, ctx) => {
2795
3103
  const { sid, theme } = ctx;
2796
3104
  const p = beat.params;
@@ -2830,7 +3138,8 @@ var claimFigure = (beat, ctx) => {
2830
3138
  );
2831
3139
  }
2832
3140
  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>`;
3141
+ const held = plate(fig, sid, beat.id, ctx.start);
3142
+ const figure = `<div class="figwrap" id="${sid}-f">${held.html}</div>`;
2834
3143
  const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
2835
3144
  const body = tall ? `<div class="cf-stack">${claim}
2836
3145
  <div>${figure}
@@ -2898,12 +3207,22 @@ ${body}`,
2898
3207
  // Height-capped rather than width-driven: a square figure in the beside
2899
3208
  // layout would otherwise be ~970px tall and run off the canvas. The cap is
2900
3209
  // the measured remainder, not a constant — see `figMax`.
2901
- `.figwrap img{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
3210
+ //
3211
+ // NAMED FOR THE TAG THAT IS ACTUALLY THERE rather than written for both.
3212
+ // A rule listing `img, video` would move the bytes of every deck we have
3213
+ // ever built to describe an element almost none of them contain, and a
3214
+ // rule naming only `img` over a clip is a cap that silently does not
3215
+ // apply — the video would render at its natural 1920x1080 and run off the
3216
+ // canvas, which is invariant-5 territory that no gate reads.
3217
+ `.figwrap ${held.el}{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
2902
3218
  `.caption{font-size:${BODY_SIZE}px;line-height:${BODY_LH};color:${theme.dim};margin-top:16px}`,
2903
3219
  // The image, not its wrapper: the wrapper's entrance already writes
2904
3220
  // `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)
3221
+ // 16px padding absorbs — the swell can never reach the canvas edge. Same
3222
+ // reason as the cap above for naming the tag: a drift rule aimed at `img`
3223
+ // over a `<video>` is one ambient rule that animates nothing, and the
3224
+ // slide reads as dead rather than as held.
3225
+ ambient(sid, `-f ${held.el}`, DRIFT)
2907
3226
  ].join("\n")
2908
3227
  };
2909
3228
  };
@@ -3023,8 +3342,8 @@ var dataTable = (beat, ctx) => {
3023
3342
  p.highlight.forEach((h, i) => {
3024
3343
  const index = shown.findIndex((row) => row[0] === h.row);
3025
3344
  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}`);
3345
+ 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}`;
3346
+ throw new Error(`data-table ${beat.id}: ${why3}`);
3028
3347
  }
3029
3348
  const at = settled + 0.3 + i * step;
3030
3349
  tl.push(
@@ -3339,14 +3658,14 @@ var MORPH_SECONDS = 1.6;
3339
3658
  var equationMorph = (beat, ctx) => {
3340
3659
  const { sid, theme } = ctx;
3341
3660
  const p = beat.params;
3342
- const find2 = (id2) => {
3661
+ const find3 = (id2) => {
3343
3662
  const eq = ctx.source.equations.find((e) => e.id === id2);
3344
3663
  if (!eq)
3345
3664
  throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3346
3665
  return eq;
3347
3666
  };
3348
- const a = find2(p.fromId);
3349
- const b = find2(p.toId);
3667
+ const a = find3(p.fromId);
3668
+ const b = find3(p.toId);
3350
3669
  const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3351
3670
  if (both.length === 0) {
3352
3671
  throw new Error(
@@ -3556,10 +3875,10 @@ var grid = (beat, ctx) => {
3556
3875
  h: r.h * y0.w + (r.h - 1) * f.gap + 2 * bleed
3557
3876
  };
3558
3877
  };
3559
- const boxes = p.regions.map(boxOf);
3878
+ const boxes3 = p.regions.map(boxOf);
3560
3879
  const lx = W - gutter + 40;
3561
3880
  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 };
3881
+ const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3563
3882
  const half = lines(r) * LABEL * LH / 2;
3564
3883
  return { i, half, y: b.y + b.h / 2, from: { x: b.x + b.w, y: b.y + b.h / 2 } };
3565
3884
  }).sort((a, b) => a.y - b.y || a.i - b.i);
@@ -3577,7 +3896,7 @@ var grid = (beat, ctx) => {
3577
3896
  const parts = {};
3578
3897
  p.regions.forEach((r, i) => {
3579
3898
  parts[`rgn${i}`] = r.label;
3580
- const b = boxes[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3899
+ const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3581
3900
  const tone2 = theme.tones[r.tone];
3582
3901
  rects.push(
3583
3902
  `<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 +4004,7 @@ var grid = (beat, ctx) => {
3685
4004
  tl.push(...dimCells);
3686
4005
  p.regions.forEach((_, i) => {
3687
4006
  const at = first + i * step;
3688
- const b = boxes[i] ?? { x: 0, y: 0, w: 0, h: 0 };
4007
+ const b = boxes3[i] ?? { x: 0, y: 0, w: 0, h: 0 };
3689
4008
  tl.push(
3690
4009
  tween(
3691
4010
  `#${id(sid, "rgn", i)}`,
@@ -4085,12 +4404,12 @@ function balance(label, k, face) {
4085
4404
  if (cur.length > 0) lines.push(cur.join(" "));
4086
4405
  return lines;
4087
4406
  }
4088
- function centre(boxes, i) {
4089
- const b = boxes[i];
4407
+ function centre(boxes3, i) {
4408
+ const b = boxes3[i];
4090
4409
  return b ? b.x + b.w / 2 : 0;
4091
4410
  }
4092
- function loopLabelWidth(boxes, from, to, stageW) {
4093
- const mid = (centre(boxes, from) + centre(boxes, to)) / 2;
4411
+ function loopLabelWidth(boxes3, from, to, stageW) {
4412
+ const mid = (centre(boxes3, from) + centre(boxes3, to)) / 2;
4094
4413
  return Math.max(NOTE * 6, Math.min(stageW - 2 * M - 80, 2 * Math.min(mid, stageW - mid) - 40));
4095
4414
  }
4096
4415
  function widest(lines, face) {
@@ -4123,11 +4442,11 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
4123
4442
  let fit = solve(stages, capped, stageW, face);
4124
4443
  if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
4125
4444
  const size2 = Math.max(MIN_FONT, Math.floor(fit.size));
4126
- const boxes = fit.boxes;
4127
- const innerW = Math.max(size2, (boxes[0]?.w ?? width) - 2 * PAD_X_EM * size2);
4445
+ const boxes3 = fit.boxes;
4446
+ const innerW = Math.max(size2, (boxes3[0]?.w ?? width) - 2 * PAD_X_EM * size2);
4128
4447
  const labelLines = stages.map((s) => wrap(s.label, size2, innerW, 600, 0, face));
4129
4448
  const noteLines = stages.map((s) => s.note ? wrap(s.note, NOTE, innerW, 400, 0, face) : []);
4130
- const boxW = boxes[0]?.w ?? width;
4449
+ const boxW = boxes3[0]?.w ?? width;
4131
4450
  const need = Math.max(
4132
4451
  MIN_BOX_H,
4133
4452
  Math.min(MAX_BOX_H, boxW * BOX_ASPECT),
@@ -4137,15 +4456,15 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
4137
4456
  return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
4138
4457
  })
4139
4458
  );
4140
- const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes, loop.from, loop.to, stageW), 500, 0, face) : [];
4459
+ const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes3, loop.from, loop.to, stageW), 500, 0, face) : [];
4141
4460
  const below = loop ? LOOP_TOP + LOOP_LABEL_TOP + loopLines.length * NOTE * NOTE_LH + LOOP_BOTTOM : M;
4142
4461
  const boxH = need;
4143
4462
  return {
4144
4463
  size: size2,
4145
- boxes,
4464
+ boxes: boxes3,
4146
4465
  boxH,
4147
4466
  boxX: M,
4148
- boxW: boxes[0]?.w ?? width,
4467
+ boxW: boxes3[0]?.w ?? width,
4149
4468
  vertical: false,
4150
4469
  innerW,
4151
4470
  labelLines,
@@ -4473,6 +4792,11 @@ var splitCompare = (beat, ctx) => {
4473
4792
  if (fig.width <= 0 || fig.height <= 0) {
4474
4793
  throw new Error(`split-compare ${beat.id}: figure "${fig.id}" has no usable dimensions`);
4475
4794
  }
4795
+ if (fig.kind === "clip") {
4796
+ throw new Error(
4797
+ `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`
4798
+ );
4799
+ }
4476
4800
  return fig;
4477
4801
  });
4478
4802
  sides.forEach((side, i) => {
@@ -4771,9 +5095,9 @@ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
4771
5095
  function stackLayout(p, format, face = "latin") {
4772
5096
  const stacked = solve2(p, format, false, face);
4773
5097
  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;
5098
+ const inline2 = solve2(p, format, true, face);
5099
+ if (inline2.fits) return inline2;
5100
+ return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4777
5101
  }
4778
5102
  function labelWeight(i, count) {
4779
5103
  return i === count - 1 ? 700 : 600;
@@ -4782,7 +5106,7 @@ function floorFor(p, format) {
4782
5106
  if (!p.tilt) return MIN_FONT;
4783
5107
  return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4784
5108
  }
4785
- function solve2(p, format, inline, face) {
5109
+ function solve2(p, format, inline2, face) {
4786
5110
  const width = contentW(format);
4787
5111
  const boxH = contentH(format);
4788
5112
  const floor = floorFor(p, format);
@@ -4791,15 +5115,15 @@ function solve2(p, format, inline, face) {
4791
5115
  const riseMax = RISE_MAX[k];
4792
5116
  const syMax = SY_MAX[k];
4793
5117
  const tMax = T_MAX[k];
4794
- const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
5118
+ const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4795
5119
  const want = Math.max(
4796
5120
  ...p.layers.map(
4797
5121
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
4798
5122
  )
4799
5123
  );
4800
- const colCap = inline ? width * 0.56 : width * 0.5;
5124
+ const colCap = inline2 ? width * 0.56 : width * 0.5;
4801
5125
  const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
4802
- const wide = !inline || Math.ceil(want) + 12 <= colCap;
5126
+ const wide = !inline2 || Math.ceil(want) + 12 <= colCap;
4803
5127
  const labelX = width - colW;
4804
5128
  const labelRoom = Math.min(
4805
5129
  ...p.layers.map(
@@ -4814,14 +5138,14 @@ function solve2(p, format, inline, face) {
4814
5138
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4815
5139
  // Inline notes stay on one line by contract — the schema calls a note "one
4816
5140
  // 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),
5141
+ note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4818
5142
  noteW: nw,
4819
5143
  labelMaxW
4820
5144
  };
4821
5145
  });
4822
5146
  const blockH = Math.max(
4823
5147
  ...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)
5148
+ (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
5149
  )
4826
5150
  );
4827
5151
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4843,7 +5167,7 @@ function solve2(p, format, inline, face) {
4843
5167
  fits: room >= blockH + 10 && height <= free && wide,
4844
5168
  floor,
4845
5169
  wide,
4846
- inline,
5170
+ inline: inline2,
4847
5171
  width,
4848
5172
  height,
4849
5173
  avail,
@@ -5247,7 +5571,8 @@ function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
5247
5571
  format,
5248
5572
  laid.slides,
5249
5573
  runtimeJs,
5250
- narrationIsland(opts.narration, laid.spoken)
5574
+ narrationIsland(opts.narration, laid.spoken),
5575
+ videoIsland(laid.embeds)
5251
5576
  )
5252
5577
  };
5253
5578
  }
@@ -5258,17 +5583,20 @@ function planCut(storyboard, source, format, opts = {}) {
5258
5583
  const seconds = {};
5259
5584
  const undrawable = /* @__PURE__ */ new Set();
5260
5585
  floor.forEach((beat, i) => {
5261
- const segments = opts.narration?.beats[beat.id];
5586
+ const segments2 = opts.narration?.beats[beat.id];
5262
5587
  let scene;
5263
5588
  try {
5264
- ({ scene } = stageScene(emitScene(beat, { source, format, theme, sid: `s${i + 1}` }), speed));
5589
+ ({ scene } = stageScene(
5590
+ emitScene(beat, { source, format, theme, sid: `s${i + 1}`, start: 0 }),
5591
+ speed
5592
+ ));
5265
5593
  } catch (err) {
5266
5594
  if (!opts.onBeatError) throw err;
5267
5595
  opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
5268
5596
  undrawable.add(beat.id);
5269
5597
  return;
5270
5598
  }
5271
- seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments);
5599
+ seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments2);
5272
5600
  });
5273
5601
  if (floor.length > 0 && undrawable.size === floor.length) {
5274
5602
  throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
@@ -5301,31 +5629,29 @@ function layout(storyboard, source, format, opts = {}) {
5301
5629
  const scenes = [];
5302
5630
  const slides = [];
5303
5631
  const spoken = {};
5632
+ const embeds = {};
5304
5633
  const entered = enteredParts(beats);
5634
+ let at = 0;
5305
5635
  const cuts = beats.map((beat, i) => {
5306
5636
  const sid = `s${i + 1}`;
5307
- const ctx = { source, format, theme, sid };
5308
- const segments = opts.narration?.beats[beat.id];
5637
+ const ctx = { source, format, theme, sid, start: rnd(at) };
5638
+ const segments2 = opts.narration?.beats[beat.id];
5309
5639
  const { scene } = stageScene(emitScene(beat, ctx), speed);
5310
- const seconds = beatSeconds(beat.seconds * speed, scene, segments);
5640
+ const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
5311
5641
  const inside = entered[i];
5312
5642
  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
- };
5643
+ const duration = dive ? seconds + diveTail(dive) : seconds;
5644
+ const start = at;
5645
+ at += duration;
5646
+ return { beat, sid, scene, segments: segments2, inside, dive, duration, start };
5322
5647
  });
5323
- let start = 0;
5324
5648
  let builds = false;
5325
5649
  const plugins = /* @__PURE__ */ new Set();
5326
5650
  cuts.forEach((cut2, i) => {
5327
- const { beat, sid, dive, inside, duration } = cut2;
5651
+ const { beat, sid, dive, inside, duration, start } = cut2;
5328
5652
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
5653
+ const embed = playerEmbed(beat, source);
5654
+ if (embed) embeds[sid] = embed;
5329
5655
  const next = cuts[i + 1];
5330
5656
  const over = next ? rnd(Math.min(HANDOFF_SECONDS * speed, next.duration)) : 0;
5331
5657
  let scene = cut2.scene;
@@ -5355,7 +5681,6 @@ function layout(storyboard, source, format, opts = {}) {
5355
5681
  notes: beat.narration ?? beat.intent,
5356
5682
  holds: scene.holds
5357
5683
  });
5358
- start += duration;
5359
5684
  });
5360
5685
  return {
5361
5686
  family,
@@ -5367,7 +5692,11 @@ function layout(storyboard, source, format, opts = {}) {
5367
5692
  scenes,
5368
5693
  slides,
5369
5694
  spoken,
5370
- total: start,
5695
+ embeds,
5696
+ // The clock after the last scene: the sum the map above finished with, which
5697
+ // is the number the second pass used to arrive at by re-adding the same
5698
+ // durations in the same order.
5699
+ total: at,
5371
5700
  cut,
5372
5701
  builds,
5373
5702
  plugins
@@ -5418,23 +5747,23 @@ function openSeconds(scene) {
5418
5747
  const first = scene.holds.filter((h) => Number.isFinite(h) && h > 0).sort((a, b) => a - b)[0];
5419
5748
  return rnd(first ?? 0);
5420
5749
  }
5421
- function beatSeconds(authored, scene, segments) {
5422
- if (!segments?.length) return authored;
5750
+ function beatSeconds(authored, scene, segments2) {
5751
+ if (!segments2?.length) return authored;
5423
5752
  const lastHold = scene.holds.reduce((a, b) => Math.max(a, b), 0);
5424
5753
  const usable = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
5425
5754
  (a, b) => a - b
5426
5755
  );
5427
- const ends = speechPlan(openSeconds(scene), usable, segments).end;
5756
+ const ends = speechPlan(openSeconds(scene), usable, segments2).end;
5428
5757
  return Math.max(authored, lastHold + SETTLE_SECONDS, ends + SETTLE_SECONDS);
5429
5758
  }
5430
5759
  function stageScene(scene, speed) {
5431
5760
  const paced = pace(scene, speed);
5432
5761
  return { scene: paced, open: openSeconds(paced) };
5433
5762
  }
5434
- function speechPlan(open, holds, segments) {
5763
+ function speechPlan(open, holds, segments2) {
5435
5764
  const starts = [];
5436
5765
  let at = open;
5437
- for (const [i, segment] of segments.entries()) {
5766
+ for (const [i, segment] of segments2.entries()) {
5438
5767
  const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
5439
5768
  if (i > 0) at = Math.max(at, hold);
5440
5769
  starts.push(at);
@@ -5529,9 +5858,9 @@ function narrationIsland(narration, scenes) {
5529
5858
  voice: narration.voice,
5530
5859
  dir: narration.dir,
5531
5860
  scenes: Object.fromEntries(
5532
- Object.entries(scenes).map(([sid, segments]) => [
5861
+ Object.entries(scenes).map(([sid, segments2]) => [
5533
5862
  sid,
5534
- segments.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
5863
+ segments2.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
5535
5864
  ])
5536
5865
  )
5537
5866
  };
@@ -5541,7 +5870,22 @@ function narrationIsland(narration, scenes) {
5541
5870
  ${json}
5542
5871
  </script>`;
5543
5872
  }
5544
- function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
5873
+ function playerEmbed(beat, source) {
5874
+ if (beat.archetype !== "claim-figure") return void 0;
5875
+ const fig = source.figures.find((f) => f.id === beat.params.figureId);
5876
+ if (fig?.kind !== "clip" || fig.href === void 0) return void 0;
5877
+ const url = embedUrl(fig.href);
5878
+ return url === void 0 ? void 0 : { url, title: fig.caption };
5879
+ }
5880
+ function videoIsland(clips) {
5881
+ if (Object.keys(clips).length === 0) return "";
5882
+ const json = JSON.stringify({ scenes: clips }, null, 2).replace(/</g, "\\u003c");
5883
+ return `
5884
+ <script type="application/decksmith-video+json">
5885
+ ${json}
5886
+ </script>`;
5887
+ }
5888
+ function emitDeckPage(storyboard, format, slides, runtimeJs, narration, video) {
5545
5889
  return `<!doctype html>
5546
5890
  <html lang="${esc(storyboard.lang)}">
5547
5891
  <head>
@@ -5560,7 +5904,7 @@ function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
5560
5904
  width="${format.width}"
5561
5905
  height="${format.height}"
5562
5906
  ></hyperframes-player>
5563
- ${emitIsland(slides)}${narration}
5907
+ ${emitIsland(slides)}${narration}${video}
5564
5908
  <script>
5565
5909
  ${closeSafe(runtimeJs)}
5566
5910
  </script>
@@ -5712,7 +6056,7 @@ function readFragments(html) {
5712
6056
  return out;
5713
6057
  }
5714
6058
  function holdsFor(beat, source, format, theme, sid, speed) {
5715
- const ctx = { source, format, theme, sid };
6059
+ const ctx = { source, format, theme, sid, start: 0 };
5716
6060
  const { scene, open } = stageScene(emitScene(beat, ctx), speed);
5717
6061
  return {
5718
6062
  holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
@@ -5736,15 +6080,15 @@ function assertHoldsAgree(scenes, fragments) {
5736
6080
  function place(scenes, spoken) {
5737
6081
  const out = [];
5738
6082
  for (const scene of scenes) {
5739
- const segments = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
5740
- if (segments.length === 0) continue;
6083
+ const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
6084
+ if (segments2.length === 0) continue;
5741
6085
  if (scene.holds.length === 0) {
5742
6086
  throw new Error(
5743
6087
  `${scene.id}: narrated but has no hold to speak at. Re-run \`narrate\` for this format.`
5744
6088
  );
5745
6089
  }
5746
- const { starts } = speechPlan(scene.open, scene.holds, segments);
5747
- for (const [i, segment] of segments.entries()) {
6090
+ const { starts } = speechPlan(scene.open, scene.holds, segments2);
6091
+ for (const [i, segment] of segments2.entries()) {
5748
6092
  const index = Math.min(segment.stop, scene.holds.length - 1);
5749
6093
  out.push({
5750
6094
  id: `${scene.id}.${segment.stop}`,
@@ -5760,9 +6104,9 @@ function place(scenes, spoken) {
5760
6104
  }
5761
6105
  return out;
5762
6106
  }
5763
- function assertFits(scenes, segments) {
6107
+ function assertFits(scenes, segments2) {
5764
6108
  for (const scene of scenes) {
5765
- const mine = segments.filter((s) => s.scene === scene.id);
6109
+ const mine = segments2.filter((s) => s.scene === scene.id);
5766
6110
  if (mine.length === 0) continue;
5767
6111
  const spoken = mine.reduce((sum, s) => sum + s.duration, 0);
5768
6112
  const need = scene.open + spoken;
@@ -5806,8 +6150,8 @@ function planTiming(input) {
5806
6150
  const staging = holdsFor(beat, source, format, theme, scene.id, speed);
5807
6151
  scene.holds = staging.holds;
5808
6152
  scene.open = staging.open;
5809
- const segments2 = narration?.beats[beat.id];
5810
- if (segments2?.length) spoken[scene.id] = segments2;
6153
+ const segments3 = narration?.beats[beat.id];
6154
+ if (segments3?.length) spoken[scene.id] = segments3;
5811
6155
  });
5812
6156
  const fragments = readFragments(composition);
5813
6157
  if (fragments) {
@@ -5816,18 +6160,18 @@ function planTiming(input) {
5816
6160
  fragments
5817
6161
  );
5818
6162
  }
5819
- const segments = place(scenes, spoken);
5820
- assertFits(scenes, segments);
6163
+ const segments2 = place(scenes, spoken);
6164
+ assertFits(scenes, segments2);
5821
6165
  return {
5822
6166
  version: 1,
5823
6167
  width: format.width,
5824
6168
  height: format.height,
5825
6169
  duration: readDuration(composition),
5826
6170
  lang: storyboard.lang,
5827
- audioDir: segments.length > 0 ? narration?.dir ?? "" : "",
5828
- voice: segments.length > 0 ? narration?.voice ?? "" : "",
6171
+ audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
6172
+ voice: segments2.length > 0 ? narration?.voice ?? "" : "",
5829
6173
  scenes,
5830
- segments
6174
+ segments: segments2
5831
6175
  };
5832
6176
  }
5833
6177
  function framePlan(timing, fps) {
@@ -5839,12 +6183,12 @@ function framePlan(timing, fps) {
5839
6183
  for (const scene of timing.scenes) {
5840
6184
  const first = f(scene.start);
5841
6185
  const last = f(scene.start + scene.duration);
5842
- const segments = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
5843
- if (segments.length === 0) {
6186
+ const segments2 = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
6187
+ if (segments2.length === 0) {
5844
6188
  pieces.push({ from: first, motion: last - first, freeze: 0 });
5845
6189
  continue;
5846
6190
  }
5847
- const tail3 = segments[segments.length - 1];
6191
+ const tail3 = segments2[segments2.length - 1];
5848
6192
  const silent = f(tail3.start + tail3.duration);
5849
6193
  if (silent > last) {
5850
6194
  throw new Error(
@@ -5852,7 +6196,7 @@ function framePlan(timing, fps) {
5852
6196
  );
5853
6197
  }
5854
6198
  pieces.push({ from: first, motion: last - first, freeze: 0 });
5855
- for (const segment of segments) {
6199
+ for (const segment of segments2) {
5856
6200
  const at = f(segment.start);
5857
6201
  audio.push({
5858
6202
  id: segment.id,
@@ -5925,7 +6269,7 @@ function round5(n3) {
5925
6269
  }
5926
6270
 
5927
6271
  // src/source/markdown.ts
5928
- import { createHash as createHash2 } from "node:crypto";
6272
+ import { createHash as createHash3 } from "node:crypto";
5929
6273
  import remarkGfm from "remark-gfm";
5930
6274
  import remarkMath from "remark-math";
5931
6275
  import remarkParse from "remark-parse";
@@ -5977,6 +6321,9 @@ function parseMarkdown(md, opts = {}) {
5977
6321
  for (const img of images) {
5978
6322
  const figure = {
5979
6323
  id: `fig${figures.length + 1}`,
6324
+ // Markdown carries no video: an image node is an image. A clip only
6325
+ // ever enters through `harvest`, which sets this itself.
6326
+ kind: "image",
5980
6327
  src: img.url,
5981
6328
  caption: caption ?? img.alt ?? "",
5982
6329
  // 1x1 until assets.ts reads the actual bytes. The schema has no "unknown",
@@ -6012,7 +6359,7 @@ function parseMarkdown(md, opts = {}) {
6012
6359
  if (mention) p.figure.mention = mention;
6013
6360
  }
6014
6361
  return sourceSchema.parse({
6015
- id: opts.id ?? createHash2("sha256").update(md).digest("hex").slice(0, 12),
6362
+ id: opts.id ?? createHash3("sha256").update(md).digest("hex").slice(0, 12),
6016
6363
  title: title2 || "Untitled",
6017
6364
  lang: opts.lang ?? sniffLang(md),
6018
6365
  sections,
@@ -6021,10 +6368,14 @@ function parseMarkdown(md, opts = {}) {
6021
6368
  tables
6022
6369
  });
6023
6370
  }
6371
+ var SCRIPT_SHARE = 0.02;
6024
6372
  function sniffLang(md) {
6025
- if (/[가-힣]/.test(md)) return "ko";
6026
- if (/[぀-ヿ]/.test(md)) return "ja";
6027
- if (/[一-鿿]/.test(md)) return "zh";
6373
+ const total = md.replace(/\s+/g, "").length;
6374
+ if (total === 0) return "en";
6375
+ const share2 = (re) => (md.match(re)?.length ?? 0) / total;
6376
+ if (share2(/[가-힣]/g) >= SCRIPT_SHARE) return "ko";
6377
+ if (share2(/[぀-ヿ]/g) >= SCRIPT_SHARE) return "ja";
6378
+ if (share2(/[一-鿿]/g) >= SCRIPT_SHARE) return "zh";
6028
6379
  return "en";
6029
6380
  }
6030
6381
  function onlyImages(kids) {
@@ -6045,7 +6396,11 @@ function mentionOf(caption, prose, at, opened) {
6045
6396
  const found = before ?? prose.find((p) => p.at > at && name.test(p.text));
6046
6397
  if (found) return found.text;
6047
6398
  }
6048
- return prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
6399
+ const near = prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
6400
+ return near !== void 0 && isSentence(near) ? near : void 0;
6401
+ }
6402
+ function isSentence(text2) {
6403
+ return (text2.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3;
6049
6404
  }
6050
6405
  function figureName(caption) {
6051
6406
  const match = /^\s*(fig(?:ure)?\.?|그림|図|图)\s*([0-9]+)/i.exec(caption);
@@ -6105,76 +6460,2119 @@ function textOf(node) {
6105
6460
  }
6106
6461
  }
6107
6462
 
6108
- // src/source/assets.ts
6109
- import { createHash as createHash3 } from "node:crypto";
6110
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
6111
- import { extname, join as join2 } from "node:path";
6112
- async function fetchFigures(source, dir) {
6113
- await mkdir2(dir, { recursive: true });
6114
- const figures = [];
6115
- for (const figure of source.figures) {
6116
- const name = assetName(figure.id, figure.src);
6117
- const bytes = await cache(join2(dir, name), figure.src);
6118
- figures.push({ ...figure, src: name, ...imageSize(bytes) });
6119
- }
6120
- return sourceSchema.parse({ ...source, figures });
6121
- }
6122
- function assetName(id2, src) {
6123
- const ext = extname(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
6124
- return `${id2}-${createHash3("sha256").update(src).digest("hex").slice(0, 8)}${ext || ".img"}`;
6125
- }
6126
- async function cache(path2, src) {
6127
- const hit = await readFile2(path2).catch(() => null);
6128
- if (hit) return hit;
6129
- let bytes;
6130
- if (/^https?:/i.test(src)) {
6131
- const res = await fetch(src);
6132
- if (!res.ok) throw new Error(`${src}: HTTP ${res.status}`);
6133
- bytes = Buffer.from(await res.arrayBuffer());
6134
- } else {
6135
- bytes = await readFile2(src);
6463
+ // src/source/harvest.ts
6464
+ import { createHash as createHash5 } from "node:crypto";
6465
+ import { copyFile, mkdir as mkdir4, rm as rm2, stat, writeFile as writeFile4 } from "node:fs/promises";
6466
+ import { basename as basename2, join as join4, resolve } from "node:path";
6467
+
6468
+ // src/net/fetch.ts
6469
+ import { lookup as resolveHost } from "node:dns/promises";
6470
+ import { request as httpRequest } from "node:http";
6471
+ import { request as httpsRequest } from "node:https";
6472
+ import { isIP } from "node:net";
6473
+ import { brotliDecompressSync, gunzipSync } from "node:zlib";
6474
+
6475
+ // src/version.ts
6476
+ import { createRequire } from "node:module";
6477
+ var VERSION = createRequire(import.meta.url)("../package.json").version;
6478
+
6479
+ // src/net/fetch.ts
6480
+ var BLOCKED_V4 = [
6481
+ // "This network". Already in the original list as /^0\./, which is 0.0.0.0/8.
6482
+ { re: /^0\./, why: "the unspecified network (0.0.0.0/8)" },
6483
+ { re: /^10\./, why: "a private address (10.0.0.0/8)" },
6484
+ // NEW: carrier-grade NAT. A mobile or datacentre network hands these out, and
6485
+ // on a host that has one, the whole /10 is the neighbourhood.
6486
+ {
6487
+ re: /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
6488
+ why: "carrier-grade NAT space (100.64.0.0/10)"
6489
+ },
6490
+ { re: /^127\./, why: "loopback (127.0.0.0/8)" },
6491
+ { re: /^169\.254\./, why: "link-local, where the cloud metadata service lives (169.254.0.0/16)" },
6492
+ { re: /^172\.(1[6-9]|2\d|3[01])\./, why: "a private address (172.16.0.0/12)" },
6493
+ // NEW: IETF protocol assignments — 192.0.0.8, the NAT64 well-known prefix and
6494
+ // friends. Not routable on the public internet, so a URL pointing here is
6495
+ // pointing at something local.
6496
+ { re: /^192\.0\.0\./, why: "IETF protocol assignment space (192.0.0.0/24)" },
6497
+ { re: /^192\.168\./, why: "a private address (192.168.0.0/16)" },
6498
+ // NEW: benchmarking. Reserved for test equipment, and some networks route it
6499
+ // internally precisely because it will never collide with anything real.
6500
+ { re: /^198\.1[89]\./, why: "benchmarking space (198.18.0.0/15)" }
6501
+ ];
6502
+ var USER_AGENT = `DeckSmith/${VERSION} (+https://github.com/ca1773130n/DeckSmith)`;
6503
+ function isBlockedAddress(ip) {
6504
+ const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
6505
+ const kind = isIP(bare);
6506
+ if (kind === 0) return `not an IP address (${ip})`;
6507
+ if (kind === 4) {
6508
+ for (const { re, why: why3 } of BLOCKED_V4) if (re.test(bare)) return why3;
6509
+ return null;
6136
6510
  }
6137
- await writeFile2(path2, bytes);
6138
- return bytes;
6139
- }
6140
- function imageSize(b) {
6141
- if (b.length >= 24 && b.readUInt32BE(0) === 2303741511)
6142
- return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
6143
- if (b.length >= 10 && b.toString("latin1", 0, 4) === "GIF8")
6144
- return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
6145
- if (b.length >= 4 && b.readUInt16BE(0) === 65496) return jpegSize(b);
6146
- throw new Error("unrecognised image header (expected PNG, JPEG or GIF)");
6511
+ const groups = expandV6(bare);
6512
+ if (groups === null) return `not an IP address (${ip})`;
6513
+ if (groups.every((n3) => n3 === 0)) return "the unspecified address (::)";
6514
+ if (groups.slice(0, 7).every((n3) => n3 === 0) && groups[7] === 1) return "IPv6 loopback (::1)";
6515
+ const embedded = groups.slice(0, 5).every((n3) => n3 === 0) && (groups[5] === 65535 || groups[5] === 0);
6516
+ if (embedded) {
6517
+ const hi = groups[6] ?? 0;
6518
+ const lo = groups[7] ?? 0;
6519
+ return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
6520
+ }
6521
+ const top = groups[0] ?? 0;
6522
+ if ((top & 65472) === 65152) return "IPv6 link-local (fe80::/10)";
6523
+ if ((top & 65024) === 64512) return "IPv6 unique-local (fc00::/7)";
6524
+ return null;
6147
6525
  }
6148
- function jpegSize(b) {
6149
- let i = 2;
6150
- while (i + 9 < b.length) {
6151
- if (b[i] !== 255) {
6152
- i++;
6153
- continue;
6526
+ function expandV6(v6) {
6527
+ let text2 = v6;
6528
+ const quad = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text2);
6529
+ if (quad?.[1]) {
6530
+ const [a = 0, b = 0, c = 0, d = 0] = quad[1].split(".").map(Number);
6531
+ const head = text2.slice(0, text2.length - quad[1].length);
6532
+ text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
6533
+ }
6534
+ const halves = text2.split("::");
6535
+ if (halves.length > 2) return null;
6536
+ const left = halves[0] ? halves[0].split(":") : [];
6537
+ const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
6538
+ const fill = 8 - left.length - (right?.length ?? 0);
6539
+ if (right !== null && fill < 0) return null;
6540
+ const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
6541
+ if (parts.length !== 8) return null;
6542
+ const groups = parts.map((p) => Number.parseInt(p, 16));
6543
+ return groups.every((n3) => Number.isInteger(n3) && n3 >= 0 && n3 <= 65535) ? groups : null;
6544
+ }
6545
+ function isLoopback(ip) {
6546
+ const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
6547
+ return /^127\./.test(bare) || bare === "::1" || /^::ffff:127\./.test(bare);
6548
+ }
6549
+ var MAX_REDIRECTS = 5;
6550
+ var WEB_PORTS = /* @__PURE__ */ new Set([80, 443]);
6551
+ var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
6552
+ var CREDENTIALS = ["authorization", "cookie"];
6553
+ async function fetchGuarded(url, opts) {
6554
+ const signal = AbortSignal.timeout(opts.timeoutMs);
6555
+ let target;
6556
+ try {
6557
+ target = new URL(url);
6558
+ } catch {
6559
+ throw new Error(`refusing ${url}: not a URL. Give an absolute http:// or https:// URL.`);
6560
+ }
6561
+ let headers = lowercased(opts.headers);
6562
+ for (let hop = 0; ; hop++) {
6563
+ if (hop > MAX_REDIRECTS) {
6564
+ throw new Error(
6565
+ `refusing ${url}: more than ${MAX_REDIRECTS} redirects, last to ${target.href}. Link the final URL directly.`
6566
+ );
6154
6567
  }
6155
- const marker = b[i + 1];
6156
- if (marker === void 0) break;
6157
- if (marker === 255) {
6158
- i++;
6568
+ const res = await send(url, target, headers, signal, opts);
6569
+ const status = res.statusCode ?? 0;
6570
+ if (REDIRECTS.has(status)) {
6571
+ const location = res.headers.location;
6572
+ res.destroy();
6573
+ if (location === void 0) {
6574
+ throw new Error(`refusing ${url}: HTTP ${status} from ${target.href} with no Location.`);
6575
+ }
6576
+ let next;
6577
+ try {
6578
+ next = new URL(location, target);
6579
+ } catch {
6580
+ throw new Error(
6581
+ `refusing ${url}: HTTP ${status} to an unparseable Location (${location}).`
6582
+ );
6583
+ }
6584
+ const sameOrigin = next.protocol === target.protocol && next.hostname === target.hostname && next.port === target.port;
6585
+ if (!sameOrigin) headers = withoutCredentials(headers);
6586
+ target = next;
6159
6587
  continue;
6160
6588
  }
6161
- if (marker === 1 || marker >= 208 && marker <= 217) {
6162
- i += 2;
6163
- continue;
6589
+ if (status < 200 || status >= 300) {
6590
+ res.destroy();
6591
+ throw new Error(`${target.href}: HTTP ${status}`);
6164
6592
  }
6165
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
6166
- return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
6167
- i += 2 + b.readUInt16BE(i + 2);
6168
- }
6169
- throw new Error("JPEG has no SOF segment");
6593
+ const contentType = (res.headers["content-type"] ?? "").trim();
6594
+ const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
6595
+ if (opts.accept && media.match(opts.accept) === null) {
6596
+ res.destroy();
6597
+ throw new Error(
6598
+ `refusing ${url}: ${target.href} served ${media || "no Content-Type"}, which does not match ${opts.accept}. Link the file itself, not a page about it.`
6599
+ );
6600
+ }
6601
+ const raw2 = await readCapped(res, opts.maxBytes, url, signal, opts.timeoutMs);
6602
+ const bytes = decode(raw2, res.headers["content-encoding"], opts.maxBytes, url);
6603
+ return { bytes, contentType, url: target.href };
6604
+ }
6605
+ }
6606
+ async function send(url, target, headers, signal, opts) {
6607
+ const secure = target.protocol === "https:";
6608
+ if (!secure && target.protocol !== "http:") {
6609
+ throw new Error(
6610
+ `refusing ${url}: ${target.protocol}// is not http or https (${target.href}). Only web URLs are followed.`
6611
+ );
6612
+ }
6613
+ const port = Number(target.port || (secure ? 443 : 80));
6614
+ if (!WEB_PORTS.has(port) && opts.allowLoopback !== true) {
6615
+ throw new Error(
6616
+ `refusing ${url}: port ${port} on ${target.hostname} \u2014 only 80 and 443 are fetched. A URL naming another port is naming a service, not a page.`
6617
+ );
6618
+ }
6619
+ const host = target.hostname.replace(/^\[|\]$/g, "");
6620
+ let addresses;
6621
+ try {
6622
+ addresses = await Promise.race([resolveHost(host, { all: true }), rejectsOnAbort(signal)]);
6623
+ } catch {
6624
+ if (signal.aborted) throw timedOut(url, opts.timeoutMs);
6625
+ throw new Error(`refusing ${url}: ${host} does not resolve.`);
6626
+ }
6627
+ const pinned = addresses[0];
6628
+ if (pinned === void 0) throw new Error(`refusing ${url}: ${host} does not resolve.`);
6629
+ for (const { address } of addresses) {
6630
+ const why3 = isBlockedAddress(address);
6631
+ if (why3 !== null && !(opts.allowLoopback === true && isLoopback(address))) {
6632
+ throw new Error(
6633
+ `refusing ${url}: ${host} resolves to ${address}, which is ${why3}. Host the file somewhere this server can reach from the public internet.`
6634
+ );
6635
+ }
6636
+ }
6637
+ const options = {
6638
+ hostname: host,
6639
+ port,
6640
+ path: `${target.pathname}${target.search}`,
6641
+ method: "GET",
6642
+ // IDENTIFY OURSELVES, AND ACCEPT HTML.
6643
+ //
6644
+ // Measured against the first real page this was ever pointed at: Wikipedia
6645
+ // answers a request with no `user-agent` with a bare 403, and so do a great
6646
+ // many sites behind a CDN. A fetcher that will not say who it is looks
6647
+ // exactly like a scraper worth blocking, and the failure arrives as an
6648
+ // HTTP status with nothing in it to explain itself. Both headers are
6649
+ // DEFAULTS rather than overrides — a caller that sets either wins, because
6650
+ // the spread below them is the caller's.
6651
+ headers: {
6652
+ "user-agent": USER_AGENT,
6653
+ accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5",
6654
+ ...headers,
6655
+ "accept-encoding": "gzip, br"
6656
+ },
6657
+ signal,
6658
+ // THE PIN. Without this line every check above is advisory: `dns.lookup`
6659
+ // would run a second time inside the connect and could answer differently.
6660
+ lookup: pin(pinned.address, pinned.family),
6661
+ // No connection pool. This is a one-shot against a stranger's host, and a
6662
+ // pooled socket outlives the call that validated it — nothing should be able
6663
+ // to inherit a connection this module opened.
6664
+ agent: false,
6665
+ // Say out loud which name the certificate has to match. Node derives this
6666
+ // from `hostname` today, but the pin means the socket is opened to a bare
6667
+ // address, and a future refactor that stops setting `hostname` would turn
6668
+ // verification off silently rather than fail.
6669
+ ...secure && isIP(host) === 0 ? { servername: host } : {}
6670
+ };
6671
+ try {
6672
+ return await new Promise((resolve6, reject) => {
6673
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve6);
6674
+ req.on("error", reject);
6675
+ req.end();
6676
+ });
6677
+ } catch (err) {
6678
+ if (signal.aborted) throw timedOut(url, opts.timeoutMs);
6679
+ throw new Error(`refusing ${url}: ${target.href} could not be reached (${message(err)}).`);
6680
+ }
6681
+ }
6682
+ function pin(address, family) {
6683
+ return (_hostname, options, callback) => {
6684
+ if (options.all) callback(null, [{ address, family }]);
6685
+ else callback(null, address, family);
6686
+ };
6687
+ }
6688
+ async function readCapped(res, maxBytes, url, signal, timeoutMs) {
6689
+ const chunks = [];
6690
+ let total = 0;
6691
+ let oversize = false;
6692
+ try {
6693
+ for await (const chunk of res) {
6694
+ total += chunk.length;
6695
+ if (total > maxBytes) {
6696
+ oversize = true;
6697
+ res.destroy();
6698
+ break;
6699
+ }
6700
+ chunks.push(chunk);
6701
+ }
6702
+ } catch (err) {
6703
+ if (signal.aborted) throw timedOut(url, timeoutMs);
6704
+ throw new Error(`refusing ${url}: the body stopped arriving (${message(err)}).`);
6705
+ }
6706
+ if (oversize) {
6707
+ throw new Error(
6708
+ `refusing ${url}: body is larger than ${maxBytes} bytes. Link a smaller file, or raise maxBytes if this one is expected.`
6709
+ );
6710
+ }
6711
+ return Buffer.concat(chunks);
6712
+ }
6713
+ function decode(raw2, header, maxBytes, url) {
6714
+ const encoding = (header ?? "").trim().toLowerCase();
6715
+ if (encoding === "" || encoding === "identity") return raw2;
6716
+ try {
6717
+ if (encoding === "gzip" || encoding === "x-gzip") {
6718
+ return gunzipSync(raw2, { maxOutputLength: maxBytes });
6719
+ }
6720
+ if (encoding === "br") return brotliDecompressSync(raw2, { maxOutputLength: maxBytes });
6721
+ } catch (err) {
6722
+ throw new Error(
6723
+ `refusing ${url}: ${encoding} body does not decompress within ${maxBytes} bytes (${message(err)}). Serve it uncompressed, or raise maxBytes.`
6724
+ );
6725
+ }
6726
+ throw new Error(
6727
+ `refusing ${url}: Content-Encoding "${encoding}" was never offered \u2014 this request asked for gzip or br. Serve one of those, or no encoding.`
6728
+ );
6729
+ }
6730
+ function lowercased(headers) {
6731
+ const out = {};
6732
+ for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
6733
+ return out;
6734
+ }
6735
+ function withoutCredentials(headers) {
6736
+ const out = { ...headers };
6737
+ for (const name of CREDENTIALS) delete out[name];
6738
+ return out;
6739
+ }
6740
+ function rejectsOnAbort(signal) {
6741
+ return new Promise((_, reject) => {
6742
+ if (signal.aborted) reject(signal.reason);
6743
+ else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
6744
+ });
6745
+ }
6746
+ function timedOut(url, timeoutMs) {
6747
+ return new Error(
6748
+ `refusing ${url}: nothing completed within ${timeoutMs}ms. Raise timeoutMs, or use a URL that answers faster.`
6749
+ );
6750
+ }
6751
+ function message(err) {
6752
+ return err instanceof Error ? err.message : String(err);
6753
+ }
6754
+
6755
+ // src/render/capture.ts
6756
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
6757
+ import { createRequire as createRequire2 } from "node:module";
6758
+ import { homedir } from "node:os";
6759
+ import { join as join2 } from "node:path";
6760
+ import { pathToFileURL } from "node:url";
6761
+ async function chromePath(need = "open the deck with") {
6762
+ const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
6763
+ if (explicit) return explicit;
6764
+ const { getInstalledBrowsers } = await import("@puppeteer/browsers");
6765
+ const cacheDir = process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer");
6766
+ const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
6767
+ const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
6768
+ if (found) return found.executablePath;
6769
+ throw new Error(
6770
+ `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
6771
+ );
6772
+ }
6773
+ function runtimePath() {
6774
+ return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
6775
+ }
6776
+ function renderSeek(t2) {
6777
+ const player = window.__player;
6778
+ player.renderSeek(t2, { suppressEvents: true });
6779
+ }
6780
+ async function openDeck(dir, opts = {}) {
6781
+ const index = join2(dir, "index.html");
6782
+ const html = await readFile3(index, "utf8").catch(() => null);
6783
+ if (html === null) throw new Error(`no index.html in ${dir}`);
6784
+ const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
6785
+ const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
6786
+ if (!width || !height) throw new Error("index.html declares no canvas size");
6787
+ const timeout = opts.timeoutMs ?? 6e4;
6788
+ const { default: puppeteer } = await import("puppeteer-core");
6789
+ const browser = await puppeteer.launch({
6790
+ executablePath: await chromePath(),
6791
+ headless: true,
6792
+ // A retina host would otherwise hand back a 2x frame, whose clip is not the
6793
+ // renderer's.
6794
+ args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
6795
+ });
6796
+ try {
6797
+ const page = await browser.newPage();
6798
+ await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
6799
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
6800
+ await page.goto(pathToFileURL(index).href, { waitUntil: "load", timeout });
6801
+ await page.addScriptTag({ path: runtimePath() });
6802
+ await page.waitForFunction(
6803
+ // The composition registers a paused timeline per scene and a spanning
6804
+ // `main` that carries no motion, so seeking `main` directly moves nothing.
6805
+ // Only the runtime knows the per-scene offsets; wait for it.
6806
+ "typeof window.__player?.renderSeek === 'function'",
6807
+ { timeout }
6808
+ );
6809
+ await page.evaluate(() => document.fonts.ready);
6810
+ const cdp = await page.createCDPSession();
6811
+ return {
6812
+ width,
6813
+ height,
6814
+ page,
6815
+ seek: (t2) => page.evaluate(renderSeek, t2),
6816
+ shoot: async () => {
6817
+ const shot = await cdp.send("Page.captureScreenshot", {
6818
+ format: "png",
6819
+ fromSurface: true,
6820
+ captureBeyondViewport: false,
6821
+ clip: { x: 0, y: 0, width, height, scale: 1 }
6822
+ });
6823
+ return Buffer.from(shot.data, "base64");
6824
+ },
6825
+ close: () => browser.close()
6826
+ };
6827
+ } catch (err) {
6828
+ await browser.close().catch(() => {
6829
+ });
6830
+ throw err;
6831
+ }
6832
+ }
6833
+
6834
+ // src/source/assets.ts
6835
+ import { createHash as createHash4 } from "node:crypto";
6836
+ import { mkdir as mkdir3, readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
6837
+ import { extname as extname2, join as join3 } from "node:path";
6838
+ import { z as z2 } from "zod";
6839
+ async function fetchFigures(source, dir, warnings) {
6840
+ const drops = warnings ?? [];
6841
+ await mkdir3(dir, { recursive: true });
6842
+ const cached = await readdir(dir).catch(() => []);
6843
+ const figures = [];
6844
+ for (const figure of source.figures) {
6845
+ if (figure.kind === "clip") {
6846
+ figures.push(figure);
6847
+ continue;
6848
+ }
6849
+ try {
6850
+ figures.push(figureSchema.parse(await localize(figure, dir, cached)));
6851
+ } catch (err) {
6852
+ drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6853
+ }
6854
+ }
6855
+ if (!warnings) for (const drop of drops) console.warn(`decksmith: ${drop}`);
6856
+ const parsed = sourceSchema.safeParse({ ...source, figures });
6857
+ if (!parsed.success) {
6858
+ throw new Error(
6859
+ `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.`
6860
+ );
6861
+ }
6862
+ return parsed.data;
6863
+ }
6864
+ async function localize(figure, dir, cached) {
6865
+ const stem = assetStem(figure.id, figure.src);
6866
+ const hit = cached.find((name) => name.startsWith(`${stem}.`));
6867
+ const bytes = hit ? await readFile4(join3(dir, hit)) : await load(figure.src);
6868
+ const size2 = imageSize(bytes);
6869
+ if (hit) return { ...figure, src: hit, ...size2 };
6870
+ const src = `${stem}${assetExt(bytes, figure.src)}`;
6871
+ await writeFile3(join3(dir, src), bytes);
6872
+ cached.push(src);
6873
+ return { ...figure, src, ...size2 };
6874
+ }
6875
+ function assetStem(id2, src) {
6876
+ const readable = id2.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+/, "") || "figure";
6877
+ return `${readable}-${createHash4("sha256").update(src).digest("hex").slice(0, 8)}`;
6878
+ }
6879
+ var EXT = {
6880
+ png: ".png",
6881
+ jpeg: ".jpg",
6882
+ gif: ".gif",
6883
+ webp: ".webp",
6884
+ avif: ".avif",
6885
+ svg: ".svg"
6886
+ };
6887
+ function assetExt(bytes, src) {
6888
+ const format = sniffFormat(bytes);
6889
+ if (format) return EXT[format];
6890
+ const fromUrl = extname2(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
6891
+ return fromUrl || ".img";
6892
+ }
6893
+ var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
6894
+ var FIGURE_TIMEOUT_MS = 2e4;
6895
+ async function load(src) {
6896
+ if (!/^https?:/i.test(src)) return readFile4(src);
6897
+ const got = await fetchGuarded(src, {
6898
+ maxBytes: FIGURE_MAX_BYTES,
6899
+ timeoutMs: FIGURE_TIMEOUT_MS
6900
+ });
6901
+ return got.bytes;
6902
+ }
6903
+ function reason(err) {
6904
+ if (err instanceof z2.ZodError)
6905
+ return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
6906
+ return err instanceof Error ? err.message : String(err);
6907
+ }
6908
+ var SVG_WINDOW = 64 * 1024;
6909
+ function sniffFormat(b) {
6910
+ if (b.length >= 8 && b.readUInt32BE(0) === 2303741511 && b.readUInt32BE(4) === 218765834)
6911
+ return "png";
6912
+ if (b.length >= 6 && b.toString("latin1", 0, 4) === "GIF8") return "gif";
6913
+ if (b.length >= 2 && b.readUInt16BE(0) === 65496) return "jpeg";
6914
+ if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP")
6915
+ return "webp";
6916
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp" && isAvifBrand(b)) return "avif";
6917
+ if (isSvg(b)) return "svg";
6918
+ return void 0;
6919
+ }
6920
+ function isAvifBrand(b) {
6921
+ const declared = b.readUInt32BE(0);
6922
+ const end = Math.min(declared >= 16 ? declared : b.length, b.length);
6923
+ const brand = (at) => b.toString("latin1", at, at + 4);
6924
+ if (brand(8) === "avif" || brand(8) === "avis") return true;
6925
+ for (let i = 16; i + 4 <= end; i += 4)
6926
+ if (brand(i) === "avif" || brand(i) === "avis") return true;
6927
+ return false;
6928
+ }
6929
+ function isSvg(b) {
6930
+ const text2 = b.toString("utf8", 0, Math.min(b.length, SVG_WINDOW)).replace(/^\uFEFF/, "");
6931
+ let i = 0;
6932
+ for (; ; ) {
6933
+ while (i < text2.length && /\s/.test(text2.charAt(i))) i++;
6934
+ const comment = text2.startsWith("<!--", i);
6935
+ if (text2.startsWith("<?", i) || comment || /^<!doctype\s+svg\b/i.test(text2.slice(i, i + 20))) {
6936
+ const end = comment ? text2.indexOf("-->", i) + 3 : text2.indexOf(">", i) + 1;
6937
+ if (end <= 0) return false;
6938
+ i = end;
6939
+ continue;
6940
+ }
6941
+ return /^<svg[\s/>]/i.test(text2.slice(i, i + 5));
6942
+ }
6943
+ }
6944
+ function imageSize(b) {
6945
+ const format = sniffFormat(b);
6946
+ const raw2 = measure2(b, format);
6947
+ const width = Math.round(raw2.width);
6948
+ const height = Math.round(raw2.height);
6949
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
6950
+ throw new Error(`${format ?? "image"} header claims ${raw2.width}x${raw2.height}, not a size`);
6951
+ return { width, height };
6952
+ }
6953
+ function measure2(b, format) {
6954
+ switch (format) {
6955
+ case "png":
6956
+ return pngSize(b);
6957
+ case "gif":
6958
+ return gifSize(b);
6959
+ case "jpeg":
6960
+ return jpegSize(b);
6961
+ case "webp":
6962
+ return webpSize(b);
6963
+ case "avif":
6964
+ return avifSize(b);
6965
+ case "svg":
6966
+ return svgFigureSize(b);
6967
+ default:
6968
+ throw new Error(
6969
+ `unrecognised image header (expected PNG, JPEG, GIF, WebP, AVIF or SVG; got ${describe(b)})`
6970
+ );
6971
+ }
6972
+ }
6973
+ function describe(b) {
6974
+ if (b.length === 0) return "an empty file";
6975
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp")
6976
+ return `an ISO-BMFF file branded "${b.toString("latin1", 8, 12)}" \u2014 HEIC and its relatives are not drawn by the renderer`;
6977
+ const head = b.toString("latin1", 0, Math.min(b.length, 64));
6978
+ if (/^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(head))
6979
+ return "an HTML page \u2014 the URL answered with a page, not a picture";
6980
+ if (head.startsWith("%PDF")) return "a PDF";
6981
+ const hex = [...b.subarray(0, 8)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
6982
+ return `${b.length} bytes beginning ${hex}`;
6983
+ }
6984
+ function pngSize(b) {
6985
+ if (b.length < 24) throw new Error(`PNG is truncated: ${b.length} bytes, IHDR ends at 24`);
6986
+ if (b.toString("latin1", 12, 16) !== "IHDR") throw new Error("PNG does not open with IHDR");
6987
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
6988
+ }
6989
+ function gifSize(b) {
6990
+ if (b.length < 10)
6991
+ throw new Error(`GIF is truncated: ${b.length} bytes, the screen descriptor ends at 10`);
6992
+ return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
6993
+ }
6994
+ function jpegSize(b) {
6995
+ let i = 2;
6996
+ while (i + 9 < b.length) {
6997
+ if (b[i] !== 255) {
6998
+ i++;
6999
+ continue;
7000
+ }
7001
+ const marker = b[i + 1];
7002
+ if (marker === void 0) break;
7003
+ if (marker === 255) {
7004
+ i++;
7005
+ continue;
7006
+ }
7007
+ if (marker === 1 || marker >= 208 && marker <= 217) {
7008
+ i += 2;
7009
+ continue;
7010
+ }
7011
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
7012
+ return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
7013
+ i += 2 + b.readUInt16BE(i + 2);
7014
+ }
7015
+ throw new Error("JPEG has no SOF segment");
7016
+ }
7017
+ function webpSize(b) {
7018
+ const chunk = b.length >= 16 ? b.toString("latin1", 12, 16) : "";
7019
+ if (chunk === "VP8 ") {
7020
+ if (b.length < 30) throw new Error(`WebP VP8 chunk is truncated: ${b.length} bytes, needs 30`);
7021
+ if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42)
7022
+ throw new Error("WebP VP8 chunk has no keyframe sync code");
7023
+ return { width: b.readUInt16LE(26) & 16383, height: b.readUInt16LE(28) & 16383 };
7024
+ }
7025
+ if (chunk === "VP8L") {
7026
+ if (b.length < 25) throw new Error(`WebP VP8L chunk is truncated: ${b.length} bytes, needs 25`);
7027
+ if (b[20] !== 47) throw new Error("WebP VP8L chunk has no 0x2f signature");
7028
+ const bits = b.readUInt32LE(21);
7029
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
7030
+ }
7031
+ if (chunk === "VP8X") {
7032
+ if (b.length < 30) throw new Error(`WebP VP8X chunk is truncated: ${b.length} bytes, needs 30`);
7033
+ return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
7034
+ }
7035
+ throw new Error(`WebP opens with chunk "${chunk}", not one of VP8 , VP8L, VP8X`);
7036
+ }
7037
+ function avifSize(b) {
7038
+ const meta = find(b, 0, b.length, "meta");
7039
+ if (!meta) throw new Error("AVIF has no meta box (truncated, or not an image item)");
7040
+ const iprp = find(b, meta.start + 4, meta.end, "iprp");
7041
+ const ipco = iprp && find(b, iprp.start, iprp.end, "ipco");
7042
+ if (!ipco) throw new Error("AVIF has no ipco box (no item properties to read a size from)");
7043
+ let best = { width: 0, height: 0 };
7044
+ for (const box of boxes(b, ipco.start, ipco.end)) {
7045
+ if (box.type !== "ispe" || box.end - box.start < 12) continue;
7046
+ const found = { width: b.readUInt32BE(box.start + 4), height: b.readUInt32BE(box.start + 8) };
7047
+ if (found.width * found.height > best.width * best.height) best = found;
7048
+ }
7049
+ if (best.width < 1 || best.height < 1) throw new Error("AVIF has no usable ispe property");
7050
+ return cleanAperture(b, ipco, best) ?? best;
7051
+ }
7052
+ function cleanAperture(b, ipco, ispe) {
7053
+ for (const box of boxes(b, ipco.start, ipco.end)) {
7054
+ if (box.type !== "clap" || box.end - box.start < 16) continue;
7055
+ const at = (offset) => b.readUInt32BE(box.start + offset);
7056
+ if (at(4) === 0 || at(12) === 0) continue;
7057
+ const width = Math.round(at(0) / at(4));
7058
+ const height = Math.round(at(8) / at(12));
7059
+ if (width > ispe.width || height > ispe.height) continue;
7060
+ if (width * 2 < ispe.width || height * 2 < ispe.height) continue;
7061
+ return { width, height };
7062
+ }
7063
+ return void 0;
7064
+ }
7065
+ function find(b, from, to, type) {
7066
+ for (const box of boxes(b, from, to)) if (box.type === type) return box;
7067
+ return void 0;
7068
+ }
7069
+ function* boxes(b, from, to) {
7070
+ let i = from;
7071
+ while (i + 8 <= to) {
7072
+ let size2 = b.readUInt32BE(i);
7073
+ const type = b.toString("latin1", i + 4, i + 8);
7074
+ let start = i + 8;
7075
+ if (size2 === 1) {
7076
+ if (i + 16 > to) return;
7077
+ const large = b.readBigUInt64BE(i + 8);
7078
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return;
7079
+ size2 = Number(large);
7080
+ start = i + 16;
7081
+ } else if (size2 === 0) {
7082
+ size2 = to - i;
7083
+ }
7084
+ if (size2 < start - i || i + size2 > to) return;
7085
+ yield { type, start, end: i + size2 };
7086
+ i += size2;
7087
+ }
7088
+ }
7089
+ function svgFigureSize(b) {
7090
+ const moving = /<(?:animate|animateTransform|animateMotion|set|script)\b|@keyframes\b/i.exec(
7091
+ b.toString("utf8")
7092
+ );
7093
+ if (moving)
7094
+ throw new Error(
7095
+ `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.`
7096
+ );
7097
+ return svgSize(b);
7098
+ }
7099
+ function svgSize(bytes) {
7100
+ const text2 = bytes.toString("utf8", 0, Math.min(bytes.length, SVG_WINDOW));
7101
+ const tag = /<svg\b[^>]*>/i.exec(text2)?.[0];
7102
+ if (!tag) throw new Error(`SVG root element is not closed within ${SVG_WINDOW} bytes`);
7103
+ const width = cssPixels(attrOf(tag, "width"));
7104
+ const height = cssPixels(attrOf(tag, "height"));
7105
+ if (width !== void 0 && height !== void 0) return { width, height };
7106
+ const box = attrOf(tag, "viewBox")?.trim().split(/[\s,]+/).map(Number);
7107
+ const w = box?.[2];
7108
+ const h = box?.[3];
7109
+ if (box?.length === 4 && w !== void 0 && h !== void 0 && w > 0 && h > 0)
7110
+ return { width: w, height: h };
7111
+ throw new Error("SVG declares no absolute width and height, and no usable viewBox");
7112
+ }
7113
+ function attrOf(tag, name) {
7114
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
7115
+ return m?.[1] ?? m?.[2];
7116
+ }
7117
+ var UNIT = {
7118
+ "": 1,
7119
+ px: 1,
7120
+ pt: 96 / 72,
7121
+ pc: 16,
7122
+ in: 96,
7123
+ cm: 96 / 2.54,
7124
+ mm: 96 / 25.4,
7125
+ q: 96 / 101.6,
7126
+ em: 16,
7127
+ rem: 16
7128
+ };
7129
+ function cssPixels(value) {
7130
+ if (value === void 0) return void 0;
7131
+ const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)\s*$/i.exec(value);
7132
+ if (!m?.[1]) return void 0;
7133
+ const scale = UNIT[(m[2] ?? "").toLowerCase()];
7134
+ if (scale === void 0) return void 0;
7135
+ const px = Number(m[1]) * scale;
7136
+ return px > 0 ? px : void 0;
7137
+ }
7138
+
7139
+ // src/source/readability.ts
7140
+ var CONTENT_MARKER = "data-ds-content";
7141
+ function readContentRegion() {
7142
+ const MARKER = "data-ds-content";
7143
+ const MIN_TEXT = 250;
7144
+ const MAX_ANCESTORS = 5;
7145
+ const SIBLING_FRACTION = 0.2;
7146
+ const MIN_SIBLING_SCORE = 10;
7147
+ const MIN_PARAGRAPH = 25;
7148
+ 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;
7149
+ const MAYBE = /and|article|body|column|content|main|shadow|story/i;
7150
+ const POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|post|story|text|blog/i;
7151
+ 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;
7152
+ const CHROME_ROLES = /* @__PURE__ */ new Set([
7153
+ "alert",
7154
+ "alertdialog",
7155
+ "banner",
7156
+ "complementary",
7157
+ "contentinfo",
7158
+ "dialog",
7159
+ "menu",
7160
+ "menubar",
7161
+ "navigation",
7162
+ "search",
7163
+ "toolbar",
7164
+ "tooltip"
7165
+ ]);
7166
+ const CHROME_TAGS = /* @__PURE__ */ new Set([
7167
+ "nav",
7168
+ "aside",
7169
+ "footer",
7170
+ "script",
7171
+ "style",
7172
+ "noscript",
7173
+ "form",
7174
+ "svg",
7175
+ "canvas",
7176
+ "template",
7177
+ "button",
7178
+ "select",
7179
+ "textarea"
7180
+ ]);
7181
+ const SCORED = "p, td, pre, section, h2, h3, h4, h5, h6";
7182
+ function textOf2(node) {
7183
+ return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
7184
+ }
7185
+ function labelOf(el) {
7186
+ return `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`;
7187
+ }
7188
+ function nameOf(el) {
7189
+ const id2 = el.getAttribute("id");
7190
+ const cls = (el.getAttribute("class") ?? "").trim().split(/\s+/)[0];
7191
+ return el.tagName.toLowerCase() + (id2 ? `#${id2}` : "") + (cls ? `.${cls}` : "");
7192
+ }
7193
+ function pathOf(el) {
7194
+ const parts = [];
7195
+ let node = el;
7196
+ while (node && parts.length < 3) {
7197
+ parts.unshift(nameOf(node));
7198
+ node = node.parentElement;
7199
+ }
7200
+ return parts.join(" > ");
7201
+ }
7202
+ function round7(n3) {
7203
+ return Math.round(n3 * 1e3) / 1e3;
7204
+ }
7205
+ function linkDensity(el) {
7206
+ const total = textOf2(el).length;
7207
+ if (total === 0) return 0;
7208
+ let inLinks = 0;
7209
+ for (const a of el.querySelectorAll("a")) {
7210
+ const href = a.getAttribute("href") ?? "";
7211
+ inLinks += textOf2(a).length * (href.startsWith("#") ? 0.3 : 1);
7212
+ }
7213
+ return inLinks / total;
7214
+ }
7215
+ function classWeight(el) {
7216
+ let weight = 0;
7217
+ for (const label of [el.getAttribute("class") ?? "", el.getAttribute("id") ?? ""]) {
7218
+ if (label === "") continue;
7219
+ if (NEGATIVE.test(label)) weight -= 25;
7220
+ if (POSITIVE.test(label)) weight += 25;
7221
+ }
7222
+ return weight;
7223
+ }
7224
+ function tagBonus(tag) {
7225
+ if (tag === "div") return 5;
7226
+ if (tag === "pre" || tag === "td" || tag === "blockquote") return 3;
7227
+ if (tag === "th" || /^h[1-6]$/.test(tag)) return -5;
7228
+ if (/^(address|ol|ul|dl|dd|dt|li|form)$/.test(tag)) return -3;
7229
+ return 0;
7230
+ }
7231
+ const scores = /* @__PURE__ */ new Map();
7232
+ function scoreOf(el) {
7233
+ const at = scores.get(el);
7234
+ if (at !== void 0) return at;
7235
+ const start = tagBonus(el.tagName.toLowerCase()) + classWeight(el);
7236
+ scores.set(el, start);
7237
+ return start;
7238
+ }
7239
+ const removed = [];
7240
+ function strip(el) {
7241
+ const parent = el.parentElement;
7242
+ if (!parent) return;
7243
+ removed.push({ node: el, parent, next: el.nextSibling });
7244
+ el.remove();
7245
+ }
7246
+ function restore() {
7247
+ for (let i = removed.length - 1; i >= 0; i--) {
7248
+ const at = removed[i];
7249
+ if (at) at.parent.insertBefore(at.node, at.next);
7250
+ }
7251
+ removed.length = 0;
7252
+ }
7253
+ function declined(reason3, candidates2) {
7254
+ restore();
7255
+ return {
7256
+ marked: false,
7257
+ reason: reason3,
7258
+ candidates: candidates2,
7259
+ merged: 0,
7260
+ stripped: 0,
7261
+ text: 0,
7262
+ mediaDropped: 0
7263
+ };
7264
+ }
7265
+ try {
7266
+ if (!document.body) {
7267
+ return declined("the document has no <body>, so there is no region to choose", []);
7268
+ }
7269
+ const mediaBefore = document.querySelectorAll("video, iframe").length;
7270
+ for (const el of [...document.body.querySelectorAll("*")]) {
7271
+ if (!el.isConnected) continue;
7272
+ const tag = el.tagName.toLowerCase();
7273
+ if (CHROME_TAGS.has(tag)) {
7274
+ strip(el);
7275
+ continue;
7276
+ }
7277
+ if (tag === "header" && el.parentElement?.closest("article, main, section") == null) {
7278
+ strip(el);
7279
+ continue;
7280
+ }
7281
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
7282
+ strip(el);
7283
+ continue;
7284
+ }
7285
+ if (CHROME_ROLES.has((el.getAttribute("role") ?? "").toLowerCase())) {
7286
+ strip(el);
7287
+ continue;
7288
+ }
7289
+ const label = labelOf(el);
7290
+ if (UNLIKELY.test(label) && !MAYBE.test(label) && !el.querySelector("main, article")) {
7291
+ strip(el);
7292
+ }
7293
+ }
7294
+ for (const el of document.body.querySelectorAll(SCORED)) {
7295
+ const text3 = textOf2(el);
7296
+ if (text3.length < MIN_PARAGRAPH) continue;
7297
+ const base = 1 + (text3.split(",").length - 1) + Math.min(Math.floor(text3.length / 100), 3);
7298
+ let node = el.parentElement;
7299
+ let level = 0;
7300
+ while (node && node !== document.documentElement && level < MAX_ANCESTORS) {
7301
+ const divider = level === 0 ? 1 : level === 1 ? 2 : level * 3;
7302
+ scores.set(node, scoreOf(node) + base / divider);
7303
+ node = node.parentElement;
7304
+ level++;
7305
+ }
7306
+ }
7307
+ const ranked = [];
7308
+ for (const [el, content] of scores) {
7309
+ const density = linkDensity(el);
7310
+ ranked.push({
7311
+ el,
7312
+ report: {
7313
+ path: pathOf(el),
7314
+ content: round7(content),
7315
+ linkDensity: round7(density),
7316
+ // THE WHOLE POINT OF THE PASS. A rail of related headlines and an
7317
+ // article of the same length hold the same number of characters; only
7318
+ // this term separates them, and it is why the discount is a
7319
+ // multiplier rather than a subtraction — a candidate that is entirely
7320
+ // links scores zero however long it is.
7321
+ score: round7(content * (1 - density)),
7322
+ text: textOf2(el).length
7323
+ }
7324
+ });
7325
+ }
7326
+ ranked.sort((a, b) => b.report.score - a.report.score);
7327
+ const report = ranked.slice(0, 5).map((r) => r.report);
7328
+ let top = ranked[0];
7329
+ if (!top) {
7330
+ return declined(
7331
+ `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.`,
7332
+ report
7333
+ );
7334
+ }
7335
+ const byElement = new Map(ranked.map((r) => [r.el, r]));
7336
+ const floor = top.report.score / 3;
7337
+ let last = top.report.score;
7338
+ let up = top.el.parentElement;
7339
+ while (up && up !== document.body && up !== document.documentElement) {
7340
+ const here = byElement.get(up);
7341
+ if (here) {
7342
+ if (here.report.score < floor) break;
7343
+ if (here.report.score > last) {
7344
+ top = here;
7345
+ break;
7346
+ }
7347
+ last = here.report.score;
7348
+ }
7349
+ up = up.parentElement;
7350
+ }
7351
+ if (top.el === document.body) {
7352
+ const text3 = textOf2(document.body).length;
7353
+ if (text3 < MIN_TEXT) {
7354
+ return declined(
7355
+ `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`,
7356
+ report
7357
+ );
7358
+ }
7359
+ document.body.setAttribute(MARKER, "");
7360
+ return {
7361
+ marked: true,
7362
+ reason: `chose <body>: nothing narrower than the page itself scored, ${text3} characters`,
7363
+ candidates: report,
7364
+ merged: 0,
7365
+ stripped: removed.length,
7366
+ text: text3,
7367
+ mediaDropped: mediaBefore - document.body.querySelectorAll("video, iframe").length
7368
+ };
7369
+ }
7370
+ const parent = top.el.parentElement;
7371
+ if (!parent) {
7372
+ return declined("the winning candidate is not in the document any more", report);
7373
+ }
7374
+ const threshold = Math.max(MIN_SIBLING_SCORE, top.report.score * SIBLING_FRACTION);
7375
+ const topClass = top.el.getAttribute("class") ?? "";
7376
+ const keep = [];
7377
+ for (const sib of [...parent.children]) {
7378
+ if (sib === top.el) {
7379
+ keep.push(sib);
7380
+ continue;
7381
+ }
7382
+ const twin = topClass !== "" && sib.getAttribute("class") === topClass;
7383
+ const bonus = twin ? top.report.score * SIBLING_FRACTION : 0;
7384
+ const here = byElement.get(sib);
7385
+ if (here && here.report.score + bonus >= threshold) {
7386
+ keep.push(sib);
7387
+ continue;
7388
+ }
7389
+ if (sib.tagName.toLowerCase() === "p") {
7390
+ const text3 = textOf2(sib);
7391
+ const density = linkDensity(sib);
7392
+ const long = text3.length > 80 && density < 0.25;
7393
+ const sentence = text3.length > 0 && text3.length <= 80 && density === 0 && /\.( |$)/.test(text3);
7394
+ if (long || sentence) keep.push(sib);
7395
+ }
7396
+ }
7397
+ const text2 = keep.reduce((n3, el) => n3 + textOf2(el).length, 0);
7398
+ if (text2 < MIN_TEXT) {
7399
+ return declined(
7400
+ `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`,
7401
+ report
7402
+ );
7403
+ }
7404
+ let region;
7405
+ if (keep.length === 1 && keep[0]) {
7406
+ region = keep[0];
7407
+ } else {
7408
+ const box = document.createElement("div");
7409
+ for (const el of keep) box.appendChild(el);
7410
+ document.body.appendChild(box);
7411
+ region = box;
7412
+ }
7413
+ region.setAttribute(MARKER, "");
7414
+ return {
7415
+ marked: true,
7416
+ 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` : ""),
7417
+ candidates: report,
7418
+ merged: keep.length - 1,
7419
+ stripped: removed.length,
7420
+ text: textOf2(region).length,
7421
+ mediaDropped: mediaBefore - region.querySelectorAll("video, iframe").length
7422
+ };
7423
+ } catch (e) {
7424
+ return declined(
7425
+ `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`,
7426
+ []
7427
+ );
7428
+ }
7429
+ }
7430
+
7431
+ // src/source/transcode.ts
7432
+ import { readFile as readFile5, rm } from "node:fs/promises";
7433
+ import { basename } from "node:path";
7434
+
7435
+ // src/render/ffmpeg.ts
7436
+ import { execFile, spawn } from "node:child_process";
7437
+ import { promisify } from "node:util";
7438
+ var run = promisify(execFile);
7439
+ var DEFAULT_TIMEOUT_MS = 36e5;
7440
+ async function runTool(file, args, opts = {}) {
7441
+ try {
7442
+ return await run(file, args, {
7443
+ cwd: opts.cwd,
7444
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
7445
+ maxBuffer: 64 << 20
7446
+ });
7447
+ } catch (err) {
7448
+ const e = err;
7449
+ if (e.code === "ENOENT") {
7450
+ throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
7451
+ }
7452
+ const tail3 = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
7453
+ throw new Error(`${file} failed:
7454
+ ${tail3}`);
7455
+ }
7456
+ }
7457
+ function runLive(file, args) {
7458
+ return new Promise((resolve6, reject) => {
7459
+ const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7460
+ child.on("error", (err) => {
7461
+ reject(
7462
+ err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
7463
+ );
7464
+ });
7465
+ child.on("close", (code, signal) => {
7466
+ if (code === 0) resolve6();
7467
+ else if (signal) {
7468
+ reject(
7469
+ new Error(
7470
+ `${file} was killed by ${signal}. On a machine under memory pressure this is the OS reclaiming the browser; free memory, or lower --workers, and run again.`
7471
+ )
7472
+ );
7473
+ } else reject(new Error(`${file} exited ${code}.`));
7474
+ });
7475
+ });
7476
+ }
7477
+ async function probe(path2) {
7478
+ const { stdout } = await runTool("ffprobe", [
7479
+ "-v",
7480
+ "error",
7481
+ "-show_entries",
7482
+ "stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
7483
+ "-of",
7484
+ "json",
7485
+ path2
7486
+ ]);
7487
+ const json = JSON.parse(stdout);
7488
+ const streams = json.streams ?? [];
7489
+ const video = streams.find((s) => s.codec_type === "video");
7490
+ if (!video) throw new Error(`${path2} has no video stream.`);
7491
+ const [num, den] = (video.r_frame_rate ?? "30/1").split("/");
7492
+ const fps = Number(num) / (Number(den) || 1);
7493
+ const seconds = Number(json.format?.duration ?? 0);
7494
+ const frames2 = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
7495
+ return {
7496
+ width: video.width ?? 0,
7497
+ height: video.height ?? 0,
7498
+ fps,
7499
+ frames: frames2,
7500
+ seconds,
7501
+ hasAudio: streams.some((s) => s.codec_type === "audio")
7502
+ };
7503
+ }
7504
+ function encoderArgs(fps) {
7505
+ return [
7506
+ "-c:v",
7507
+ "libx264",
7508
+ "-preset",
7509
+ "veryfast",
7510
+ "-crf",
7511
+ "16",
7512
+ "-pix_fmt",
7513
+ "yuv420p",
7514
+ "-g",
7515
+ "12",
7516
+ "-r",
7517
+ String(fps),
7518
+ "-fps_mode",
7519
+ "cfr"
7520
+ ];
7521
+ }
7522
+ function pieceFilter(motion, freeze) {
7523
+ const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
7524
+ if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
7525
+ return chain.join(",");
7526
+ }
7527
+ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
7528
+ return [
7529
+ "-y",
7530
+ "-hide_banner",
7531
+ "-loglevel",
7532
+ "error",
7533
+ // Half a frame in, so a time that is exactly on a boundary cannot round to
7534
+ // the frame before it.
7535
+ "-ss",
7536
+ ((fromFrame + 0.5) / fps).toFixed(6),
7537
+ "-i",
7538
+ source,
7539
+ "-an",
7540
+ "-vf",
7541
+ pieceFilter(motion, freeze),
7542
+ "-frames:v",
7543
+ String(motion + freeze),
7544
+ ...encoderArgs(fps),
7545
+ "-f",
7546
+ "mpegts",
7547
+ out
7548
+ ];
7549
+ }
7550
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
7551
+ function audioGraph(inputs, seconds, first = 1) {
7552
+ const lines = inputs.map(
7553
+ (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
7554
+ );
7555
+ const labels = inputs.map((_, i) => `[d${i}]`).join("");
7556
+ lines.push(
7557
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
7558
+ );
7559
+ return lines.join(";\n");
7560
+ }
7561
+ function respeedArgs(source, factor, chain, fps, hasAudio, out) {
7562
+ const video = `[0:v]setpts=PTS/${factor}[v]`;
7563
+ const audio = hasAudio ? `;[0:a]${chain.map((t2) => `atempo=${t2}`).join(",")}[a]` : "";
7564
+ return [
7565
+ "-y",
7566
+ "-hide_banner",
7567
+ "-loglevel",
7568
+ "error",
7569
+ "-i",
7570
+ source,
7571
+ "-filter_complex",
7572
+ `${video}${audio}`,
7573
+ "-map",
7574
+ "[v]",
7575
+ ...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
7576
+ ...encoderArgs(fps),
7577
+ out
7578
+ ];
7579
+ }
7580
+ function burnStyle(width, height, font = "Arial") {
7581
+ return {
7582
+ width,
7583
+ height,
7584
+ font,
7585
+ // MEASURED, not guessed. `splitCue` caps a cue at 84 characters and `wrap`
7586
+ // breaks it near the middle, so the longer of the two lines runs to about
7587
+ // 46 characters in real caption prose. Bold Arial advances 0.485em per
7588
+ // character on that prose (measured in a browser over the demo's own
7589
+ // narration), so 46 characters at F px is 22.3F wide, and the usable width
7590
+ // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
7591
+ // "4% of the width" looked like on paper — overflows to a THIRD line, and a
7592
+ // three-line band covers the bottom of the slide.
7593
+ fontSize: Math.round(width * 0.037),
7594
+ // Clear of the play button, the progress bar and the handle every vertical
7595
+ // player draws across the bottom eighth of the frame.
7596
+ marginV: Math.round(height * 0.09),
7597
+ marginX: Math.round(width * 0.04)
7598
+ };
7599
+ }
7600
+
7601
+ // src/source/transcode.ts
7602
+ var CLIP_EDGE_PX = 1280;
7603
+ var MAX_CLIP_SECONDS = 60;
7604
+ var TIMEOUT_MS = 6e5;
7605
+ function fitBox(width, height, maxEdgePx) {
7606
+ const scale = Math.min(1, maxEdgePx / width, maxEdgePx / height);
7607
+ return { width: even(width * scale), height: even(height * scale) };
7608
+ }
7609
+ function even(n3) {
7610
+ return Math.max(2, Math.floor(Math.round(n3) / 2) * 2);
7611
+ }
7612
+ function transcodeArgs(input, out, plan) {
7613
+ return [
7614
+ "-y",
7615
+ "-hide_banner",
7616
+ "-loglevel",
7617
+ "error",
7618
+ "-i",
7619
+ input,
7620
+ ...plan.seconds === void 0 ? [] : ["-t", plan.seconds.toFixed(3)],
7621
+ "-an",
7622
+ "-map_metadata",
7623
+ "-1",
7624
+ "-vf",
7625
+ `scale=${plan.width}:${plan.height}`,
7626
+ "-c:v",
7627
+ "libvpx-vp9",
7628
+ "-crf",
7629
+ "32",
7630
+ "-b:v",
7631
+ "0",
7632
+ "-deadline",
7633
+ "good",
7634
+ "-cpu-used",
7635
+ "4",
7636
+ "-row-mt",
7637
+ "1",
7638
+ "-pix_fmt",
7639
+ "yuv420p",
7640
+ "-fflags",
7641
+ "+bitexact",
7642
+ "-flags:v",
7643
+ "+bitexact",
7644
+ out
7645
+ ];
7646
+ }
7647
+ var probed = /* @__PURE__ */ new Map();
7648
+ function installed(file) {
7649
+ const asked = probed.get(file);
7650
+ if (asked !== void 0) return asked;
7651
+ const answer = runTool(file, ["-version"], { timeoutMs: 5e3 }).then(
7652
+ () => true,
7653
+ () => false
7654
+ );
7655
+ probed.set(file, answer);
7656
+ return answer;
7657
+ }
7658
+ async function transcode(input, out, opts = {}) {
7659
+ const file = opts.ffmpeg ?? "ffmpeg";
7660
+ const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
7661
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
7662
+ const name = basename(input);
7663
+ const source = videoSize(await readFile5(input));
7664
+ const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
7665
+ const kept = (why3) => ({
7666
+ path: input,
7667
+ width: source.width,
7668
+ height: source.height,
7669
+ seconds: source.seconds,
7670
+ transcoded: false,
7671
+ warnings: [`${name} was shipped as the page served it: ${why3}`]
7672
+ });
7673
+ if (!await installed(file)) {
7674
+ return kept(
7675
+ `${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.`
7676
+ );
7677
+ }
7678
+ const trim = source.seconds === void 0 || source.seconds > maxSeconds;
7679
+ const started = Date.now();
7680
+ try {
7681
+ await runTool(
7682
+ file,
7683
+ transcodeArgs(input, out, { ...box, ...trim ? { seconds: maxSeconds } : {} }),
7684
+ { timeoutMs }
7685
+ );
7686
+ } catch (err) {
7687
+ await rm(out, { force: true });
7688
+ const over = Date.now() - started >= timeoutMs;
7689
+ return kept(
7690
+ 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)}`
7691
+ );
7692
+ }
7693
+ let measured;
7694
+ try {
7695
+ measured = videoSize(await readFile5(out));
7696
+ } catch (err) {
7697
+ await rm(out, { force: true });
7698
+ return kept(
7699
+ `the webm ${file} wrote could not be measured \u2014 ${err instanceof Error ? err.message : String(err)}`
7700
+ );
7701
+ }
7702
+ const warnings = [];
7703
+ if (trim && measured.seconds !== void 0 && measured.seconds >= maxSeconds - 0.05) {
7704
+ warnings.push(
7705
+ `${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.`
7706
+ );
7707
+ }
7708
+ return {
7709
+ path: out,
7710
+ width: measured.width,
7711
+ height: measured.height,
7712
+ seconds: measured.seconds,
7713
+ transcoded: true,
7714
+ warnings
7715
+ };
7716
+ }
7717
+
7718
+ // src/source/harvest.ts
7719
+ var HTML_MAX_BYTES = 8 * 1024 * 1024;
7720
+ var ASSET_MAX_BYTES = 32 * 1024 * 1024;
7721
+ var TIMEOUT_MS2 = 2e4;
7722
+ var MAX_ASSETS = 40;
7723
+ var MAX_CLIPS = 4;
7724
+ var MAX_TOTAL_BYTES = 96 * 1024 * 1024;
7725
+ var MAX_WALL_MS = 18e4;
7726
+ var MIN_FIGURE_PX = 64;
7727
+ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
7728
+ async function harvest(url, dir, opts = {}) {
7729
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
7730
+ const warnings = [];
7731
+ const assets = resolve(dir);
7732
+ await mkdir4(assets, { recursive: true });
7733
+ const page = await fetchGuarded(url, {
7734
+ maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
7735
+ timeoutMs,
7736
+ accept: HTML_TYPES,
7737
+ ...opts.allowLoopback === true ? { allowLoopback: true } : {}
7738
+ });
7739
+ const html = decodeHtml(page.bytes, page.contentType, url, warnings);
7740
+ const { seen, pick } = await readInBrowser(html, timeoutMs);
7741
+ if (!pick.marked) {
7742
+ warnings.push(
7743
+ `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.`
7744
+ );
7745
+ } else if (pick.mediaDropped > 0) {
7746
+ warnings.push(
7747
+ `${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.`
7748
+ );
7749
+ }
7750
+ const base = absolute(seen.base, page.url) ?? page.url;
7751
+ const local = await localise(seen, base, assets, opts, timeoutMs, warnings);
7752
+ return {
7753
+ markdown: toMarkdown(titled(seen.title, local.blocks)),
7754
+ assets: local.assets,
7755
+ clips: local.clips,
7756
+ warnings,
7757
+ title: seen.title
7758
+ };
7759
+ }
7760
+ function titled(title2, blocks) {
7761
+ const first = blocks[0];
7762
+ const already = first?.kind === "heading" && first.depth === 1 && first.text.trim() === title2.trim();
7763
+ return title2 && !already ? [{ kind: "heading", depth: 1, text: title2 }, ...blocks] : [...blocks];
7764
+ }
7765
+ function decodeHtml(bytes, contentType, url, warnings) {
7766
+ const declared = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
7767
+ const head = bytes.toString("latin1", 0, Math.min(bytes.length, 4096));
7768
+ const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
7769
+ const label = declared ?? meta;
7770
+ if (label === void 0) return bytes.toString("utf8");
7771
+ try {
7772
+ return new TextDecoder(label).decode(bytes);
7773
+ } catch {
7774
+ warnings.push(
7775
+ `${url} 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.`
7776
+ );
7777
+ return bytes.toString("utf8");
7778
+ }
7779
+ }
7780
+ async function readInBrowser(html, timeoutMs) {
7781
+ const { default: puppeteer } = await import("puppeteer-core");
7782
+ const browser = await puppeteer.launch({
7783
+ executablePath: await chromePath("read the page with"),
7784
+ headless: true,
7785
+ // Chrome's own background traffic — variations, safe browsing, first-run
7786
+ // pings — never goes through page interception, so it is switched off here
7787
+ // rather than assumed absent. It is not an SSRF path, but "the browser makes
7788
+ // no requests" should be true of the whole process, not just of the tab.
7789
+ args: [
7790
+ "--disable-background-networking",
7791
+ "--disable-extensions",
7792
+ "--no-default-browser-check",
7793
+ "--no-first-run"
7794
+ ]
7795
+ });
7796
+ try {
7797
+ const page = await browser.newPage();
7798
+ await page.setRequestInterception(true);
7799
+ page.on("request", (request) => {
7800
+ request.abort().catch(() => {
7801
+ });
7802
+ });
7803
+ await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
7804
+ const pick = await page.evaluate(readContentRegion);
7805
+ return { seen: await page.evaluate(readDom, CONTENT_MARKER), pick };
7806
+ } finally {
7807
+ await browser.close().catch(() => {
7808
+ });
7809
+ }
7810
+ }
7811
+ function readDom(marker) {
7812
+ const SKIP = /* @__PURE__ */ new Set([
7813
+ "nav",
7814
+ "aside",
7815
+ "footer",
7816
+ "script",
7817
+ "style",
7818
+ "noscript",
7819
+ "form",
7820
+ "svg",
7821
+ "canvas",
7822
+ "template",
7823
+ "button",
7824
+ "select",
7825
+ "textarea"
7826
+ ]);
7827
+ const BLOCKY = /* @__PURE__ */ new Set([
7828
+ "p",
7829
+ "div",
7830
+ "section",
7831
+ "article",
7832
+ "main",
7833
+ "ul",
7834
+ "ol",
7835
+ "table",
7836
+ "figure",
7837
+ "blockquote",
7838
+ "pre",
7839
+ "h1",
7840
+ "h2",
7841
+ "h3",
7842
+ "h4",
7843
+ "h5",
7844
+ "h6"
7845
+ ]);
7846
+ const PLAYER = /(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|(?:player\.)?vimeo\.com\/|dailymotion\.com\/video\/|\.(?:mp4|webm|m4v|mov)(?:[?#]|$))/i;
7847
+ function words2(node) {
7848
+ return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
7849
+ }
7850
+ function skipped(el) {
7851
+ return SKIP.has(el.tagName.toLowerCase()) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
7852
+ }
7853
+ function pickSrc(el) {
7854
+ 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 }));
7855
+ let widest2 = candidates2[0];
7856
+ for (const candidate of candidates2) if (candidate.w >= (widest2?.w ?? -1)) widest2 = candidate;
7857
+ return widest2?.url ?? el.getAttribute("src") ?? el.getAttribute("data-src") ?? "";
7858
+ }
7859
+ function imageOf(el, caption) {
7860
+ const src = pickSrc(el);
7861
+ if (!src) return void 0;
7862
+ return { kind: "image", src, alt: words2(el.getAttributeNode("alt")), caption };
7863
+ }
7864
+ function videoOf(el, caption) {
7865
+ const source = el.querySelector("source");
7866
+ return {
7867
+ kind: "video",
7868
+ src: el.getAttribute("src") ?? source?.getAttribute("src") ?? "",
7869
+ poster: el.getAttribute("poster") ?? "",
7870
+ href: "",
7871
+ caption
7872
+ };
7873
+ }
7874
+ function figureOf(el, out) {
7875
+ const caption = words2(el.querySelector("figcaption"));
7876
+ const video = el.querySelector("video");
7877
+ if (video) {
7878
+ out.push(videoOf(video, caption));
7879
+ return;
7880
+ }
7881
+ const image2 = el.querySelector("img");
7882
+ const block2 = image2 ? imageOf(image2, caption) : void 0;
7883
+ if (block2) {
7884
+ out.push(block2);
7885
+ return;
7886
+ }
7887
+ const text2 = words2(el);
7888
+ if (text2) out.push({ kind: "paragraph", text: text2 });
7889
+ }
7890
+ function paragraphOf(el, out) {
7891
+ const solid = [...el.childNodes].filter(
7892
+ (n3) => n3.nodeType !== Node.TEXT_NODE || (n3.textContent ?? "").trim() !== ""
7893
+ );
7894
+ const only = solid.length === 1 ? solid[0] : void 0;
7895
+ if (only instanceof HTMLAnchorElement && PLAYER.test(only.getAttribute("href") ?? "")) {
7896
+ const href = only.getAttribute("href") ?? "";
7897
+ out.push({ kind: "video", src: "", poster: "", href, caption: words2(only) });
7898
+ return;
7899
+ }
7900
+ let buffer = "";
7901
+ const flush = () => {
7902
+ const text2 = buffer.replace(/\s+/g, " ").trim();
7903
+ if (text2) out.push({ kind: "paragraph", text: text2 });
7904
+ buffer = "";
7905
+ };
7906
+ const scan = (node) => {
7907
+ if (node.nodeType === Node.TEXT_NODE) {
7908
+ buffer += node.textContent ?? "";
7909
+ return;
7910
+ }
7911
+ if (!(node instanceof Element)) return;
7912
+ if (skipped(node)) return;
7913
+ const tag = node.tagName.toLowerCase();
7914
+ if (tag === "br") {
7915
+ buffer += " ";
7916
+ return;
7917
+ }
7918
+ if (tag === "img" || tag === "video" || BLOCKY.has(tag)) {
7919
+ flush();
7920
+ block(node, out);
7921
+ return;
7922
+ }
7923
+ for (const kid of node.childNodes) scan(kid);
7924
+ };
7925
+ for (const kid of el.childNodes) scan(kid);
7926
+ flush();
7927
+ }
7928
+ function listOf(el) {
7929
+ return {
7930
+ kind: "list",
7931
+ ordered: el.tagName === "OL",
7932
+ // A nested list flattens into its parent item, exactly as `blockText` in
7933
+ // src/source/markdown.ts already flattens one on the way back out.
7934
+ items: [...el.children].filter((li) => li.tagName === "LI").map(words2).filter((text2) => text2 !== "")
7935
+ };
7936
+ }
7937
+ function tableOf(el) {
7938
+ const rows = [...el.querySelectorAll("tr")].map(
7939
+ (tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH").map(words2)
7940
+ );
7941
+ const [head, ...body] = rows;
7942
+ return head ? { kind: "table", columns: head, rows: body } : void 0;
7943
+ }
7944
+ function block(el, out) {
7945
+ if (skipped(el)) return;
7946
+ const tag = el.tagName.toLowerCase();
7947
+ if (/^h[1-6]$/.test(tag)) {
7948
+ const text2 = words2(el);
7949
+ if (text2) out.push({ kind: "heading", depth: Number(tag[1]), text: text2 });
7950
+ return;
7951
+ }
7952
+ if (tag === "p" || tag === "blockquote") {
7953
+ paragraphOf(el, out);
7954
+ return;
7955
+ }
7956
+ if (tag === "ul" || tag === "ol") {
7957
+ const list = listOf(el);
7958
+ if (list.kind === "list" && list.items.length > 0) out.push(list);
7959
+ return;
7960
+ }
7961
+ if (tag === "table") {
7962
+ const table = tableOf(el);
7963
+ if (table) out.push(table);
7964
+ return;
7965
+ }
7966
+ if (tag === "figure") {
7967
+ figureOf(el, out);
7968
+ return;
7969
+ }
7970
+ if (tag === "img") {
7971
+ const image2 = imageOf(el, "");
7972
+ if (image2) out.push(image2);
7973
+ return;
7974
+ }
7975
+ if (tag === "video") {
7976
+ out.push(videoOf(el, ""));
7977
+ return;
7978
+ }
7979
+ if (tag === "iframe") {
7980
+ const href = el.getAttribute("src") ?? "";
7981
+ if (PLAYER.test(href)) {
7982
+ out.push({
7983
+ kind: "video",
7984
+ src: "",
7985
+ poster: "",
7986
+ href,
7987
+ caption: words2(el.getAttributeNode("title"))
7988
+ });
7989
+ }
7990
+ return;
7991
+ }
7992
+ if (tag === "pre") {
7993
+ const text2 = (el.textContent ?? "").replace(/\s+$/, "");
7994
+ if (text2) out.push({ kind: "code", text: text2 });
7995
+ return;
7996
+ }
7997
+ const direct = [...el.childNodes].some(
7998
+ (n3) => n3.nodeType === Node.TEXT_NODE && (n3.textContent ?? "").trim() !== ""
7999
+ );
8000
+ if (direct) {
8001
+ paragraphOf(el, out);
8002
+ return;
8003
+ }
8004
+ walk(el, out);
8005
+ }
8006
+ function walk(el, out) {
8007
+ for (const kid of el.children) block(kid, out);
8008
+ }
8009
+ function score(el) {
8010
+ let text2 = 0;
8011
+ for (const p of el.querySelectorAll("p, li, td, h1, h2, h3, h4, h5, h6")) {
8012
+ text2 += (p.textContent ?? "").trim().length;
8013
+ }
8014
+ let links = 0;
8015
+ for (const a of el.querySelectorAll("a")) links += (a.textContent ?? "").trim().length;
8016
+ return text2 - links;
8017
+ }
8018
+ function pickRoot() {
8019
+ const marked = document.querySelector(`[${marker}]`);
8020
+ if (marked) return marked;
8021
+ const main = document.querySelector("main");
8022
+ if (main && score(main) > 0) return main;
8023
+ const article = document.querySelector("article");
8024
+ if (article && score(article) > 0) return article;
8025
+ let best = document.body;
8026
+ let top = score(document.body);
8027
+ for (const el of document.body.querySelectorAll("div, section, td")) {
8028
+ const here = score(el);
8029
+ if (here >= top) {
8030
+ top = here;
8031
+ best = el;
8032
+ }
8033
+ }
8034
+ return best;
8035
+ }
8036
+ const root = pickRoot();
8037
+ const blocks = [];
8038
+ walk(root, blocks);
8039
+ const heading = words2(root.querySelector("h1")) || words2(document.querySelector("h1"));
8040
+ const og = document.querySelector('meta[property="og:image"], meta[name="og:image"]') ?? document.querySelector('meta[property="og:image:url"], meta[name="twitter:image"]');
8041
+ return {
8042
+ title: heading || (document.title ?? "").replace(/\s+/g, " ").trim(),
8043
+ base: document.querySelector("base[href]")?.getAttribute("href") ?? "",
8044
+ ogImage: og?.getAttribute("content") ?? "",
8045
+ blocks
8046
+ };
8047
+ }
8048
+ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8049
+ const maxAssets = opts.maxAssets ?? MAX_ASSETS;
8050
+ const maxClips = opts.maxClips ?? MAX_CLIPS;
8051
+ const maxBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
8052
+ const wallMs = opts.maxWallMs ?? MAX_WALL_MS;
8053
+ const deadline = Date.now() + wallMs;
8054
+ const out = [];
8055
+ const assets = [];
8056
+ const clips = [];
8057
+ let spent = 0;
8058
+ const done = /* @__PURE__ */ new Map();
8059
+ const ref = (got) => opts.refs === "relative" ? basename2(got.path) : got.path;
8060
+ const overdrawn = () => {
8061
+ if (Date.now() >= deadline) {
8062
+ return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
8063
+ }
8064
+ if (spent >= maxBytes) {
8065
+ return `the harvest has downloaded ${mb(spent)} of its ${mb(maxBytes)} \u2014 raise maxTotalBytes`;
8066
+ }
8067
+ return null;
8068
+ };
8069
+ const bytesOf = async (url) => {
8070
+ const got = await fetchGuarded(url, {
8071
+ maxBytes: opts.maxAssetBytes ?? ASSET_MAX_BYTES,
8072
+ timeoutMs,
8073
+ ...opts.allowLoopback === true ? { allowLoopback: true } : {}
8074
+ });
8075
+ spent += got.bytes.length;
8076
+ return got.bytes;
8077
+ };
8078
+ const grab = async (raw2, what) => {
8079
+ const url = absolute(raw2, base);
8080
+ if (url === null) {
8081
+ warnings.push(
8082
+ `${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.`
8083
+ );
8084
+ return null;
8085
+ }
8086
+ const already = done.get(url);
8087
+ if (already !== void 0) return already;
8088
+ if (assets.length >= maxAssets) {
8089
+ done.set(url, null);
8090
+ warnings.push(
8091
+ `${what} was left out: ${url} \u2014 already at ${maxAssets} assets. Raise maxAssets if the page really has that many figures.`
8092
+ );
8093
+ return null;
8094
+ }
8095
+ const capped = overdrawn();
8096
+ if (capped !== null) {
8097
+ done.set(url, null);
8098
+ warnings.push(`${what} was left out: ${url} \u2014 ${capped} if the page is worth the wait.`);
8099
+ return null;
8100
+ }
8101
+ let got = null;
8102
+ try {
8103
+ const bytes = await bytesOf(url);
8104
+ const size2 = imageSize(bytes);
8105
+ if (size2.width < MIN_FIGURE_PX || size2.height < MIN_FIGURE_PX) {
8106
+ throw new Error(
8107
+ `it is ${size2.width}x${size2.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
8108
+ );
8109
+ }
8110
+ const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
8111
+ await writeFile4(path2, bytes);
8112
+ assets.push(path2);
8113
+ got = { path: path2, width: size2.width, height: size2.height };
8114
+ } catch (err) {
8115
+ got = null;
8116
+ warnings.push(`${what} was left out: ${url} \u2014 ${why(err)}`);
8117
+ }
8118
+ done.set(url, got);
8119
+ return got;
8120
+ };
8121
+ const shrink = async (url, path2, measured, what) => {
8122
+ if (opts.transcode === false) return { path: path2, ...measured };
8123
+ const out2 = join4(dir, assetName(url, ".vp9.webm"));
8124
+ let small;
8125
+ try {
8126
+ small = await transcode(path2, out2, {
8127
+ ...opts.maxClipSeconds === void 0 ? {} : { maxSeconds: opts.maxClipSeconds }
8128
+ });
8129
+ } catch (err) {
8130
+ warnings.push(`${what} was shipped as the page served it: ${why(err)}`);
8131
+ return { path: path2, ...measured };
8132
+ }
8133
+ for (const w of small.warnings) warnings.push(`${what} \u2014 ${w}`);
8134
+ if (small.transcoded) {
8135
+ const [before, after] = await Promise.all([sizeOf(path2), sizeOf(small.path)]);
8136
+ if (before > 0 && after > before) {
8137
+ warnings.push(
8138
+ `${what} \u2014 ${basename2(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.`
8139
+ );
8140
+ }
8141
+ await rm2(path2, { force: true });
8142
+ }
8143
+ return {
8144
+ path: small.path,
8145
+ width: small.width,
8146
+ height: small.height,
8147
+ ...small.seconds === void 0 ? {} : { seconds: small.seconds }
8148
+ };
8149
+ };
8150
+ const grabVideo = async (url, what) => {
8151
+ if (clips.length >= maxClips) {
8152
+ warnings.push(
8153
+ 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.`
8154
+ );
8155
+ return null;
8156
+ }
8157
+ const capped = overdrawn();
8158
+ if (capped !== null) {
8159
+ warnings.push(`${what} is a link only: ${capped} if the video is worth the wait.`);
8160
+ return null;
8161
+ }
8162
+ try {
8163
+ const bytes = await bytesOf(url);
8164
+ const measured = videoSize(bytes);
8165
+ const path2 = join4(dir, assetName(url, `.${measured.container}`));
8166
+ await writeFile4(path2, bytes);
8167
+ return await shrink(url, path2, measured, what);
8168
+ } catch (err) {
8169
+ warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
8170
+ return null;
8171
+ }
8172
+ };
8173
+ let ogSpent = false;
8174
+ const stillFor = async (poster, named, needed) => {
8175
+ if (poster) return grab(poster, `the poster of ${named}`);
8176
+ if (needed && seen.ogImage && !ogSpent) {
8177
+ ogSpent = true;
8178
+ return grab(seen.ogImage, `the page's og:image, taken as the still of ${named}`);
8179
+ }
8180
+ return null;
8181
+ };
8182
+ for (const item of seen.blocks) {
8183
+ if (item.kind === "image") {
8184
+ const named = item.caption || item.alt;
8185
+ const got = await grab(item.src, named ? `the image "${named}"` : "an image");
8186
+ if (got) out.push({ ...item, src: ref(got) });
8187
+ continue;
8188
+ }
8189
+ if (item.kind === "video") {
8190
+ const named = item.caption ? `the video "${item.caption}"` : "a video";
8191
+ const file = absolute(item.src, base);
8192
+ const page = absolute(item.href, base);
8193
+ const subject = file ?? page;
8194
+ const watch = page ?? file;
8195
+ const policy = subject === null ? null : policyFor(subject, "bake");
8196
+ const held = policy === "bake" && subject !== null ? await grabVideo(subject, named) : null;
8197
+ const still = await stillFor(item.poster, named, held === null);
8198
+ if (held !== null) {
8199
+ clips.push({
8200
+ file: held.path,
8201
+ poster: still?.path ?? "",
8202
+ href: "",
8203
+ width: held.width,
8204
+ height: held.height,
8205
+ ...held.seconds === void 0 ? {} : { seconds: held.seconds },
8206
+ caption: item.caption
8207
+ });
8208
+ out.push({ ...item, src: "", poster: still ? ref(still) : "", href: "" });
8209
+ continue;
8210
+ }
8211
+ if (still !== null && watch !== null) {
8212
+ clips.push({
8213
+ file: "",
8214
+ poster: still.path,
8215
+ href: watch,
8216
+ width: still.width,
8217
+ height: still.height,
8218
+ caption: item.caption
8219
+ });
8220
+ warnings.push(
8221
+ `${named} is a link only: ${unheld(policy)}. The still is what the deck shows, and a viewer goes to ${watch}.`
8222
+ );
8223
+ out.push({ ...item, src: "", poster: ref(still), href: watch });
8224
+ continue;
8225
+ }
8226
+ if (still !== null) {
8227
+ warnings.push(
8228
+ `${named} is a still only: the page names no source for it, so there is nothing to play.`
8229
+ );
8230
+ out.push({ ...item, src: "", poster: ref(still), href: "" });
8231
+ continue;
8232
+ }
8233
+ if (watch === null) {
8234
+ warnings.push(`${named} was left out: it names neither a poster image nor a URL.`);
8235
+ continue;
8236
+ }
8237
+ warnings.push(
8238
+ `${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.`
8239
+ );
8240
+ out.push({ ...item, src: "", poster: "", href: watch });
8241
+ continue;
8242
+ }
8243
+ out.push(item);
8244
+ }
8245
+ return { blocks: out, assets, clips };
8246
+ }
8247
+ function unheld(policy) {
8248
+ if (policy === "embed") return "it is a player page, which is never downloaded";
8249
+ if (policy === "link") {
8250
+ return "its URL does not end in a video extension, so what came back could as easily be a page";
8251
+ }
8252
+ return "the file could not be held";
8253
+ }
8254
+ async function sizeOf(path2) {
8255
+ return await stat(path2).then(
8256
+ (s) => s.size,
8257
+ () => 0
8258
+ );
8259
+ }
8260
+ function mb(bytes) {
8261
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8262
+ }
8263
+ function absolute(raw2, base) {
8264
+ const trimmed = raw2.trim();
8265
+ if (!trimmed) return null;
8266
+ try {
8267
+ const url = new URL(trimmed, base);
8268
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
8269
+ } catch {
8270
+ return null;
8271
+ }
8272
+ }
8273
+ function assetName(url, ext) {
8274
+ const last = new URL(url).pathname.split("/").pop() ?? "";
8275
+ const stem = last.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "figure";
8276
+ const hash = createHash5("sha256").update(url).digest("hex").slice(0, 8);
8277
+ return `${stem}-${hash}${ext}`;
8278
+ }
8279
+ function extFor2(format) {
8280
+ if (format === void 0) return ".img";
8281
+ return format === "jpeg" ? ".jpg" : `.${format}`;
8282
+ }
8283
+ function why(err) {
8284
+ return err instanceof Error ? err.message : String(err);
8285
+ }
8286
+ function sniffVideo(b) {
8287
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp") return "mp4";
8288
+ if (b.length >= 4 && b.readUInt32BE(0) === 440786851) return "webm";
8289
+ return void 0;
8290
+ }
8291
+ function videoSize(b) {
8292
+ const container = sniffVideo(b);
8293
+ if (container === void 0) {
8294
+ throw new Error(
8295
+ "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"
8296
+ );
8297
+ }
8298
+ const measured = container === "mp4" ? mp4Size(b) : webmSize(b);
8299
+ if (measured.width < MIN_FIGURE_PX || measured.height < MIN_FIGURE_PX) {
8300
+ throw new Error(
8301
+ `it measures ${measured.width}x${measured.height}, under ${MIN_FIGURE_PX}px \u2014 a slide cannot use one`
8302
+ );
8303
+ }
8304
+ return { ...measured, container };
8305
+ }
8306
+ function boxes2(b, from, to) {
8307
+ const out = [];
8308
+ let at = from;
8309
+ while (at + 8 <= to) {
8310
+ let size2 = b.readUInt32BE(at);
8311
+ const type = b.toString("latin1", at + 4, at + 8);
8312
+ let body = at + 8;
8313
+ if (size2 === 1) {
8314
+ if (body + 8 > to) return out;
8315
+ size2 = Number(b.readBigUInt64BE(body));
8316
+ body += 8;
8317
+ } else if (size2 === 0) {
8318
+ size2 = to - at;
8319
+ }
8320
+ const end = at + size2;
8321
+ if (size2 < body - at || end > to) return out;
8322
+ out.push({ type, body, end });
8323
+ at = end;
8324
+ }
8325
+ return out;
8326
+ }
8327
+ function mp4Size(b) {
8328
+ const moov = boxes2(b, 0, b.length).find((box) => box.type === "moov");
8329
+ if (moov === void 0) {
8330
+ throw new Error(
8331
+ "it has no moov box \u2014 the file is truncated, or it is a fragmented stream whose header never arrived"
8332
+ );
8333
+ }
8334
+ let seconds;
8335
+ let size2;
8336
+ for (const box of boxes2(b, moov.body, moov.end)) {
8337
+ if (box.type === "mvhd") seconds = mvhdSeconds(b, box.body);
8338
+ if (box.type !== "trak") continue;
8339
+ for (const inner of boxes2(b, box.body, box.end)) {
8340
+ if (inner.type === "tkhd" && size2 === void 0) size2 = tkhdSize(b, inner.body);
8341
+ }
8342
+ }
8343
+ if (size2 === void 0) {
8344
+ throw new Error(
8345
+ "no track in it declares a width and a height, so there is no picture to place"
8346
+ );
8347
+ }
8348
+ return { ...size2, ...seconds === void 0 ? {} : { seconds } };
8349
+ }
8350
+ function tkhdSize(b, body) {
8351
+ const matrix = body + (b[body] === 1 ? 52 : 40);
8352
+ const at = matrix + 36;
8353
+ if (at + 8 > b.length) return void 0;
8354
+ const width = Math.round(b.readUInt32BE(at) / 65536);
8355
+ const height = Math.round(b.readUInt32BE(at + 4) / 65536);
8356
+ if (width <= 0 || height <= 0) return void 0;
8357
+ const quarterTurn = b.readInt32BE(matrix) === 0 && b.readInt32BE(matrix + 16) === 0;
8358
+ return quarterTurn ? { width: height, height: width } : { width, height };
8359
+ }
8360
+ function mvhdSeconds(b, body) {
8361
+ const long = b[body] === 1;
8362
+ const at = body + (long ? 20 : 12);
8363
+ if (at + (long ? 12 : 8) > b.length) return void 0;
8364
+ const timescale = b.readUInt32BE(at);
8365
+ const ticks = long ? Number(b.readBigUInt64BE(at + 4)) : b.readUInt32BE(at + 4);
8366
+ if (timescale <= 0 || ticks <= 0 || ticks === 4294967295) return void 0;
8367
+ return sane(ticks / timescale);
8368
+ }
8369
+ var EBML_SEGMENT = 408125543;
8370
+ var EBML_INFO = 357149030;
8371
+ var EBML_TRACKS = 374648427;
8372
+ var EBML_TRACK_ENTRY = 174;
8373
+ var EBML_VIDEO = 224;
8374
+ var EBML_PIXEL_WIDTH = 176;
8375
+ var EBML_PIXEL_HEIGHT = 186;
8376
+ var EBML_DISPLAY_WIDTH = 21680;
8377
+ var EBML_DISPLAY_HEIGHT = 21690;
8378
+ var EBML_TIMECODE_SCALE = 2807729;
8379
+ var EBML_DURATION = 17545;
8380
+ function webmSize(b) {
8381
+ let pixel = { width: 0, height: 0 };
8382
+ let display = { width: 0, height: 0 };
8383
+ let scale = 1e6;
8384
+ let ticks;
8385
+ const masters = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_INFO, EBML_TRACKS, EBML_TRACK_ENTRY, EBML_VIDEO]);
8386
+ const scan = (from, to, depth) => {
8387
+ let at = from;
8388
+ while (at < to) {
8389
+ const el = ebml(b, at, to);
8390
+ if (el === void 0 || el.end <= at) return;
8391
+ if (masters.has(el.id)) {
8392
+ if (depth < 6) scan(el.body, el.end, depth + 1);
8393
+ } else if (el.id === EBML_PIXEL_WIDTH && pixel.width === 0) {
8394
+ pixel = { ...pixel, width: uint(b, el) };
8395
+ } else if (el.id === EBML_PIXEL_HEIGHT && pixel.height === 0) {
8396
+ pixel = { ...pixel, height: uint(b, el) };
8397
+ } else if (el.id === EBML_DISPLAY_WIDTH && display.width === 0) {
8398
+ display = { ...display, width: uint(b, el) };
8399
+ } else if (el.id === EBML_DISPLAY_HEIGHT && display.height === 0) {
8400
+ display = { ...display, height: uint(b, el) };
8401
+ } else if (el.id === EBML_TIMECODE_SCALE) {
8402
+ scale = uint(b, el) || scale;
8403
+ } else if (el.id === EBML_DURATION) {
8404
+ ticks = float(b, el);
8405
+ }
8406
+ at = el.end;
8407
+ }
8408
+ };
8409
+ scan(0, b.length, 0);
8410
+ const width = display.width || pixel.width;
8411
+ const height = display.height || pixel.height;
8412
+ if (width <= 0 || height <= 0) {
8413
+ throw new Error(
8414
+ "no track in it declares PixelWidth and PixelHeight, so there is no picture to place"
8415
+ );
8416
+ }
8417
+ const seconds = ticks === void 0 ? void 0 : sane(ticks * scale / 1e9);
8418
+ return { width, height, ...seconds === void 0 ? {} : { seconds } };
8419
+ }
8420
+ function ebml(b, at, to) {
8421
+ const idLen = vintLen(b[at]);
8422
+ if (idLen === 0 || at + idLen > to) return void 0;
8423
+ let id2 = 0;
8424
+ for (let i = 0; i < idLen; i += 1) id2 = id2 * 256 + (b[at + i] ?? 0);
8425
+ let p = at + idLen;
8426
+ const sizeLen = vintLen(b[p]);
8427
+ if (sizeLen === 0 || p + sizeLen > to) return void 0;
8428
+ const first = b[p] ?? 0;
8429
+ const mask = 255 >> sizeLen;
8430
+ let size2 = first & mask;
8431
+ let unknown = size2 === mask;
8432
+ for (let i = 1; i < sizeLen; i += 1) {
8433
+ const byte = b[p + i] ?? 0;
8434
+ size2 = size2 * 256 + byte;
8435
+ unknown = unknown && byte === 255;
8436
+ }
8437
+ p += sizeLen;
8438
+ return { id: id2, body: p, end: unknown ? to : Math.min(p + size2, to) };
8439
+ }
8440
+ function vintLen(first) {
8441
+ if (first === void 0 || first === 0) return 0;
8442
+ let len = 1;
8443
+ for (let mask = 128; (first & mask) === 0; mask >>= 1) len += 1;
8444
+ return len;
8445
+ }
8446
+ function uint(b, el) {
8447
+ let value = 0;
8448
+ for (let at = el.body; at < el.end && at - el.body < 8; at += 1)
8449
+ value = value * 256 + (b[at] ?? 0);
8450
+ return value;
8451
+ }
8452
+ function float(b, el) {
8453
+ const width = el.end - el.body;
8454
+ if (width === 4) return b.readFloatBE(el.body);
8455
+ if (width === 8) return b.readDoubleBE(el.body);
8456
+ return void 0;
8457
+ }
8458
+ function sane(seconds) {
8459
+ return Number.isFinite(seconds) && seconds > 0 && seconds < 86400 ? seconds : void 0;
8460
+ }
8461
+ async function attachClips(source, clips, dir) {
8462
+ if (clips.length === 0) return source;
8463
+ const assets = resolve(dir);
8464
+ await mkdir4(assets, { recursive: true });
8465
+ const figures = [...source.figures];
8466
+ for (const clip3 of clips) {
8467
+ const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
8468
+ const poster = clip3.poster ? await adopt(clip3.poster, assets) : void 0;
8469
+ if (src === void 0 && poster === void 0) continue;
8470
+ const at = clip3.poster === "" ? -1 : figures.findIndex((f) => f.src === clip3.poster || f.src === basename2(clip3.poster));
8471
+ const kept = at === -1 ? void 0 : figures[at];
8472
+ const figure = figureSchema.parse({
8473
+ id: kept?.id ?? freeId(figures),
8474
+ kind: "clip",
8475
+ // The file when we hold it, and the still when we do not: `claim-figure`
8476
+ // draws `poster` for a clip that carries an `href` and plays `src` for one
8477
+ // that does not, and `src` is required either way.
8478
+ src: src ?? poster,
8479
+ caption: kept?.caption || clip3.caption,
8480
+ width: clip3.width,
8481
+ height: clip3.height,
8482
+ ...kept?.sectionId === void 0 ? {} : { sectionId: kept.sectionId },
8483
+ ...kept?.mention === void 0 ? {} : { mention: kept.mention },
8484
+ ...poster === void 0 ? {} : { poster },
8485
+ ...clip3.seconds === void 0 ? {} : { seconds: clip3.seconds },
8486
+ ...clip3.href === "" ? {} : { href: clip3.href }
8487
+ });
8488
+ if (at === -1) figures.push(figure);
8489
+ else figures[at] = figure;
8490
+ }
8491
+ return { ...source, figures };
8492
+ }
8493
+ async function adopt(file, dir) {
8494
+ const name = basename2(file);
8495
+ await copyFile(file, join4(dir, name));
8496
+ return name;
8497
+ }
8498
+ function freeId(figures) {
8499
+ const taken = new Set(figures.map((f) => f.id));
8500
+ let n3 = figures.length + 1;
8501
+ while (taken.has(`fig${n3}`)) n3 += 1;
8502
+ return `fig${n3}`;
8503
+ }
8504
+ function toMarkdown(blocks) {
8505
+ const out = [];
8506
+ for (const item of blocks) {
8507
+ switch (item.kind) {
8508
+ case "heading":
8509
+ out.push(`${"#".repeat(Math.min(Math.max(item.depth, 1), 6))} ${inline(item.text)}`);
8510
+ break;
8511
+ case "paragraph":
8512
+ out.push(inline(item.text));
8513
+ break;
8514
+ case "code":
8515
+ out.push(fenced(item.text));
8516
+ break;
8517
+ case "list":
8518
+ out.push(
8519
+ item.items.map((li, n3) => `${item.ordered ? `${n3 + 1}.` : "-"} ${inline(li)}`).join("\n")
8520
+ );
8521
+ break;
8522
+ case "table":
8523
+ out.push(pipes(item.columns, item.rows));
8524
+ break;
8525
+ case "image":
8526
+ out.push(`![${inline(item.alt)}](${destination(item.src)})`);
8527
+ if (item.caption || item.alt) out.push(`*${inline(item.caption || item.alt)}*`);
8528
+ break;
8529
+ case "video": {
8530
+ if (item.poster) {
8531
+ out.push(`![${inline(item.caption)}](${destination(item.poster)})`);
8532
+ if (item.caption) out.push(`*${inline(item.caption)}*`);
8533
+ } else if (item.caption) {
8534
+ out.push(inline(item.caption));
8535
+ }
8536
+ const link = item.href || item.src;
8537
+ if (/^https?:/i.test(link)) out.push(`Video: <${link}>`);
8538
+ break;
8539
+ }
8540
+ }
8541
+ }
8542
+ return `${out.filter((block) => block !== "").join("\n\n")}
8543
+ `;
8544
+ }
8545
+ function inline(text2) {
8546
+ return text2.replace(/\s+/g, " ").trim().replace(/([\\`*_[\]<>])/g, "\\$1").replace(/^(#{1,6}\s|[-+]\s)/, "\\$&").replace(/^(\d{1,9})([.)]\s)/, "$1\\$2");
8547
+ }
8548
+ function destination(path2) {
8549
+ if (/[<>]/.test(path2)) {
8550
+ throw new Error(
8551
+ `cannot reference ${path2} from markdown: a path containing < or > has no spelling as a link destination. Harvest into a directory without them.`
8552
+ );
8553
+ }
8554
+ return /[\s()]/.test(path2) ? `<${path2}>` : path2;
8555
+ }
8556
+ function pipes(columns2, rows) {
8557
+ const width = Math.max(columns2.length, ...rows.map((r) => r.length), 1);
8558
+ const cells = (row) => `| ${Array.from({ length: width }, (_, i) => inline(row[i] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`;
8559
+ const rule = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`;
8560
+ return [cells(columns2), rule, ...rows.map(cells)].join("\n");
8561
+ }
8562
+ function fenced(code) {
8563
+ const runs = [...code.matchAll(/`+/g)].map((m) => m[0].length + 1);
8564
+ const fence = "`".repeat(Math.max(3, ...runs));
8565
+ return `${fence}
8566
+ ${code}
8567
+ ${fence}`;
6170
8568
  }
6171
8569
 
6172
8570
  // src/plan/codex.ts
6173
- import { spawn } from "node:child_process";
6174
- import { mkdtemp, readFile as readFile3, rm, writeFile as writeFile3 } from "node:fs/promises";
8571
+ import { spawn as spawn2 } from "node:child_process";
8572
+ import { mkdtemp, readFile as readFile6, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
6175
8573
  import { tmpdir } from "node:os";
6176
- import { join as join3 } from "node:path";
6177
- import { z as z2 } from "zod";
8574
+ import { join as join5 } from "node:path";
8575
+ import { z as z3 } from "zod";
6178
8576
 
6179
8577
  // src/plan/arc.ts
6180
8578
  var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
@@ -6987,9 +9385,28 @@ function renderSource(source) {
6987
9385
  } else {
6988
9386
  const n3 = source.figures.length;
6989
9387
  out.push(`${n3 === 1 ? "1 figure" : `${n3} figures`} in this document.`);
9388
+ if (source.figures.some((f) => f.kind === "clip")) {
9389
+ out.push(
9390
+ "",
9391
+ "A CLIP is a figure whose asset is video. Cite it exactly as you cite any other",
9392
+ "figure \u2014 `figureId` on the beat, `[figure id]` in evidence \u2014 and pick it for the",
9393
+ "same reason: it is the picture that carries the point.",
9394
+ "WHAT IT COSTS, and it decides whether a beat is worth spending on one: the deck",
9395
+ "holds a clip PAUSED AND MUTED, so a viewer clicking through sees one frame until",
9396
+ "they press play, and the rendered video shows that same frame unless the clip is",
9397
+ "a file this deck actually holds. A clip listed as watchable only at a link is a",
9398
+ "page whose video we could not download, and its still is all any format will ever",
9399
+ "show. So write the beat for the FRAME: claim what the picture states, and never",
9400
+ "narrate a motion the still does not."
9401
+ );
9402
+ }
6990
9403
  const headings = new Map(source.sections.map((s) => [s.id, s.heading]));
6991
9404
  for (const f of source.figures) {
6992
- out.push("", `[figure ${f.id}] ${f.width}x${f.height} \u2014 ${f.caption}`);
9405
+ const size2 = 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}`;
9406
+ out.push("", `[figure ${f.id}] ${size2} \u2014 ${f.caption}`);
9407
+ if (f.kind === "clip" && f.href) {
9408
+ out.push(` watchable only at ${f.href} \u2014 the deck shows its still, never the video`);
9409
+ }
6993
9410
  const heading = f.sectionId === void 0 ? void 0 : headings.get(f.sectionId);
6994
9411
  if (f.sectionId !== void 0) {
6995
9412
  out.push(` under: [section ${f.sectionId}]${heading ? ` ${heading}` : ""}`);
@@ -7137,7 +9554,7 @@ function assertInsideResolves(storyboard, source) {
7137
9554
  let parts;
7138
9555
  try {
7139
9556
  const sid = `s${i}`;
7140
- const scene = emitScene(previous, { source, format, theme: ink, sid });
9557
+ const scene = emitScene(previous, { source, format, theme: ink, sid, start: 0 });
7141
9558
  drawn = enterableIds(sid, scene.html).map((id2) => id2.replace(`${sid}-`, ""));
7142
9559
  parts = scene.parts;
7143
9560
  } catch {
@@ -7165,7 +9582,7 @@ function assertInsideResolves(storyboard, source) {
7165
9582
  }
7166
9583
 
7167
9584
  // src/plan/codex.ts
7168
- var DEFAULT_TIMEOUT_MS = 10 * 6e4;
9585
+ var DEFAULT_TIMEOUT_MS2 = 10 * 6e4;
7169
9586
  var UNSUPPORTED = /* @__PURE__ */ new Set([
7170
9587
  "$schema",
7171
9588
  "default",
@@ -7240,26 +9657,26 @@ function hideFromPlanner(node, hidden) {
7240
9657
  }
7241
9658
  function schemaFor(prefs) {
7242
9659
  return hideFromPlanner(
7243
- forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
9660
+ forStructuredOutput(z3.toJSONSchema(storyboardSchema, { io: "input" })),
7244
9661
  plannerInvisible(prefs)
7245
9662
  );
7246
9663
  }
7247
9664
  var SCHEMA = schemaFor({ genre: "general" });
7248
9665
  async function codexPlanner(source, opts = {}) {
7249
9666
  const prefs = opts.prefs ?? prefsSchema.parse({});
7250
- const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
9667
+ const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
7251
9668
  try {
7252
- const schemaPath = join3(dir, "storyboard.schema.json");
7253
- const outPath = join3(dir, "storyboard.json");
7254
- await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
9669
+ const schemaPath = join5(dir, "storyboard.schema.json");
9670
+ const outPath = join5(dir, "storyboard.json");
9671
+ await writeFile5(schemaPath, JSON.stringify(schemaFor(prefs)));
7255
9672
  await (opts.run ?? runCodex)({
7256
9673
  prompt: buildPrompt(source, prefs),
7257
9674
  schemaPath,
7258
9675
  outPath,
7259
9676
  ...opts.model === void 0 ? {} : { model: opts.model },
7260
- timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
9677
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
7261
9678
  });
7262
- const raw2 = await readFile3(outPath, "utf8").catch(() => "");
9679
+ const raw2 = await readFile6(outPath, "utf8").catch(() => "");
7263
9680
  if (raw2.trim() === "") {
7264
9681
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
7265
9682
  }
@@ -7290,7 +9707,7 @@ ${issues}`);
7290
9707
  assertRefsResolve(result.data, source, { pending: prefs.images.enabled ? "allow" : "refuse" });
7291
9708
  return result.data;
7292
9709
  } finally {
7293
- await rm(dir, { recursive: true, force: true });
9710
+ await rm3(dir, { recursive: true, force: true });
7294
9711
  }
7295
9712
  }
7296
9713
  function buildPrompt(source, prefs) {
@@ -7332,8 +9749,8 @@ function codexCommand(args) {
7332
9749
  }
7333
9750
  function runCodex(args) {
7334
9751
  const { argv, env } = codexCommand(args);
7335
- return new Promise((resolve5, reject) => {
7336
- const child = spawn("codex", argv, {
9752
+ return new Promise((resolve6, reject) => {
9753
+ const child = spawn2("codex", argv, {
7337
9754
  stdio: ["pipe", "ignore", "pipe"],
7338
9755
  ...env === void 0 ? {} : { env }
7339
9756
  });
@@ -7355,7 +9772,7 @@ function runCodex(args) {
7355
9772
  });
7356
9773
  child.on("close", (code) => {
7357
9774
  clearTimeout(timer);
7358
- if (code === 0) return resolve5();
9775
+ if (code === 0) return resolve6();
7359
9776
  reject(
7360
9777
  new Error(`codex exec exited ${code}.
7361
9778
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -7366,16 +9783,16 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
7366
9783
  }
7367
9784
 
7368
9785
  // src/images/illustrate.ts
7369
- import { createHash as createHash5 } from "node:crypto";
7370
- import { mkdir as mkdir3, readFile as readFile5, rename, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
7371
- import { join as join5 } from "node:path";
9786
+ import { createHash as createHash7 } from "node:crypto";
9787
+ import { mkdir as mkdir5, readFile as readFile8, rename, rm as rm5, writeFile as writeFile7 } from "node:fs/promises";
9788
+ import { join as join7 } from "node:path";
7372
9789
 
7373
9790
  // src/images/providers.ts
7374
- import { createHash as createHash4 } from "node:crypto";
7375
- import { mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
9791
+ import { createHash as createHash6 } from "node:crypto";
9792
+ import { mkdtemp as mkdtemp2, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
7376
9793
  import { tmpdir as tmpdir2 } from "node:os";
7377
- import { join as join4, resolve } from "node:path";
7378
- import { z as z3 } from "zod";
9794
+ import { join as join6, resolve as resolve2 } from "node:path";
9795
+ import { z as z4 } from "zod";
7379
9796
  var SIZE = {
7380
9797
  landscape: { width: 1536, height: 1024 },
7381
9798
  square: { width: 1024, height: 1024 },
@@ -7398,11 +9815,11 @@ var DEFAULT_BASE_URL = "https://api.openai.com/v1";
7398
9815
  var DEFAULT_MODEL = "gpt-image-2";
7399
9816
  var REQUEST_TIMEOUT_MS = 12e4;
7400
9817
  var MAX_BYTES = 16 * 1024 * 1024;
7401
- var generatedSchema = z3.object({
7402
- data: z3.array(z3.object({ b64_json: z3.string().optional(), url: z3.string().optional() })).optional()
9818
+ var generatedSchema = z4.object({
9819
+ data: z4.array(z4.object({ b64_json: z4.string().optional(), url: z4.string().optional() })).optional()
7403
9820
  });
7404
- var failedSchema = z3.object({
7405
- error: z3.object({ code: z3.string().nullish(), type: z3.string().nullish() }).nullish()
9821
+ var failedSchema = z4.object({
9822
+ error: z4.object({ code: z4.string().nullish(), type: z4.string().nullish() }).nullish()
7406
9823
  });
7407
9824
  function openaiImages(opts) {
7408
9825
  const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -7427,8 +9844,8 @@ function openaiImages(opts) {
7427
9844
  }),
7428
9845
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7429
9846
  });
7430
- const body = await readCapped(res);
7431
- if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason(body)}`);
9847
+ const body = await readCapped2(res);
9848
+ if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason2(body)}`);
7432
9849
  const first = generatedSchema.safeParse(parseJson(body)).data?.data?.[0];
7433
9850
  if (first?.b64_json) return raster(Buffer.from(first.b64_json, "base64"), "openai images");
7434
9851
  if (!first?.url) throw new Error("openai images: answer carried neither b64_json nor url");
@@ -7441,14 +9858,14 @@ function openaiImages(opts) {
7441
9858
  redirect: "error",
7442
9859
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7443
9860
  });
7444
- const bytes = await readCapped(picture);
9861
+ const bytes = await readCapped2(picture);
7445
9862
  if (!picture.ok)
7446
9863
  throw new Error(`openai images: HTTP ${picture.status} fetching the picture`);
7447
9864
  return raster(bytes, "openai images");
7448
9865
  }
7449
9866
  };
7450
9867
  }
7451
- function reason(body) {
9868
+ function reason2(body) {
7452
9869
  const error = failedSchema.safeParse(parseJson(body)).data?.error;
7453
9870
  const code = error?.code ?? error?.type;
7454
9871
  return code ? ` (${code})` : "";
@@ -7460,7 +9877,7 @@ function parseJson(body) {
7460
9877
  return void 0;
7461
9878
  }
7462
9879
  }
7463
- async function readCapped(res) {
9880
+ async function readCapped2(res) {
7464
9881
  const over = `openai images: answer is over ${MAX_BYTES >> 20} MB`;
7465
9882
  if (Number(res.headers.get("content-length")) > MAX_BYTES) throw new Error(over);
7466
9883
  const reader = res.body?.getReader();
@@ -7490,10 +9907,10 @@ var ANSWER_SCHEMA = {
7490
9907
  required: ["ok", "file", "reason"],
7491
9908
  additionalProperties: false
7492
9909
  };
7493
- var answerSchema = z3.object({
7494
- ok: z3.boolean(),
7495
- file: z3.string().nullish(),
7496
- reason: z3.string().nullish()
9910
+ var answerSchema = z4.object({
9911
+ ok: z4.boolean(),
9912
+ file: z4.string().nullish(),
9913
+ reason: z4.string().nullish()
7497
9914
  });
7498
9915
  function codexImages(opts = {}) {
7499
9916
  const run4 = opts.run ?? runCodex;
@@ -7506,11 +9923,11 @@ function codexImages(opts = {}) {
7506
9923
  async check() {
7507
9924
  },
7508
9925
  async generate(req) {
7509
- const dir = await mkdtemp2(join4(tmpdir2(), "decksmith-image-"));
9926
+ const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
7510
9927
  try {
7511
- const schemaPath = join4(dir, "answer.schema.json");
7512
- const outPath = join4(dir, "answer.json");
7513
- await writeFile4(schemaPath, JSON.stringify(ANSWER_SCHEMA));
9928
+ const schemaPath = join6(dir, "answer.schema.json");
9929
+ const outPath = join6(dir, "answer.json");
9930
+ await writeFile6(schemaPath, JSON.stringify(ANSWER_SCHEMA));
7514
9931
  await run4({
7515
9932
  prompt: codexPrompt(req),
7516
9933
  schemaPath,
@@ -7519,7 +9936,7 @@ function codexImages(opts = {}) {
7519
9936
  cwd: dir,
7520
9937
  sandbox: "workspace-write"
7521
9938
  });
7522
- const raw2 = await readFile4(outPath, "utf8").catch(() => "");
9939
+ const raw2 = await readFile7(outPath, "utf8").catch(() => "");
7523
9940
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
7524
9941
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
7525
9942
  if (!answer.data.ok) {
@@ -7527,15 +9944,15 @@ function codexImages(opts = {}) {
7527
9944
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
7528
9945
  );
7529
9946
  }
7530
- const candidates2 = [join4(dir, "picture.png")];
7531
- if (answer.data.file) candidates2.push(resolve(dir, answer.data.file));
9947
+ const candidates2 = [join6(dir, "picture.png")];
9948
+ if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
7532
9949
  for (const path2 of candidates2) {
7533
- const bytes = await readFile4(path2).catch(() => null);
9950
+ const bytes = await readFile7(path2).catch(() => null);
7534
9951
  if (bytes) return raster(bytes, "codex");
7535
9952
  }
7536
9953
  throw new Error("codex said ok but wrote no picture.png");
7537
9954
  } finally {
7538
- await rm2(dir, { recursive: true, force: true });
9955
+ await rm4(dir, { recursive: true, force: true });
7539
9956
  }
7540
9957
  }
7541
9958
  };
@@ -7570,7 +9987,7 @@ function mulberry32(seed) {
7570
9987
  }
7571
9988
  function drawSvg(req) {
7572
9989
  const { width: W, height: H } = SIZE[req.aspect];
7573
- const seed = createHash4("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
9990
+ const seed = createHash6("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
7574
9991
  const rand = mulberry32(seed.readUInt32BE(0));
7575
9992
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
7576
9993
  const count = 6 + Math.floor(rand() * 5);
@@ -7601,11 +10018,6 @@ function drawSvg(req) {
7601
10018
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join("")}</svg>
7602
10019
  `;
7603
10020
  }
7604
- function svgSize(bytes) {
7605
- const m = /viewBox="0 0 (\d+) (\d+)"/.exec(bytes.toString("utf8", 0, 200));
7606
- if (!m) throw new Error("svg has no viewBox");
7607
- return { width: Number(m[1]), height: Number(m[2]) };
7608
- }
7609
10021
  function toolSvg() {
7610
10022
  return {
7611
10023
  id: "svg",
@@ -7649,7 +10061,7 @@ function imageChain(images, backend) {
7649
10061
  }
7650
10062
 
7651
10063
  // src/images/illustrate.ts
7652
- var EXT = {
10064
+ var EXT2 = {
7653
10065
  "image/png": ".png",
7654
10066
  "image/jpeg": ".jpg",
7655
10067
  "image/svg+xml": ".svg"
@@ -7667,7 +10079,7 @@ async function illustrate(storyboard, source, opts) {
7667
10079
  const tool = last?.id === "svg" ? last : toolSvg();
7668
10080
  if (tool !== last) chain.push(tool);
7669
10081
  const dropped = /* @__PURE__ */ new Set();
7670
- if (pending.length > 0) await mkdir3(opts.assetsDir, { recursive: true });
10082
+ if (pending.length > 0) await mkdir5(opts.assetsDir, { recursive: true });
7671
10083
  for (const [i, slot] of pending.entries()) {
7672
10084
  let rungs = chain;
7673
10085
  if (i >= images.max) {
@@ -7687,6 +10099,9 @@ async function illustrate(storyboard, source, opts) {
7687
10099
  const picture = await draw(provider, req, name, opts.assetsDir);
7688
10100
  const figure = {
7689
10101
  id: slot.figureId,
10102
+ // An illustration is always a still: every provider draws a picture,
10103
+ // and there is no rung that returns a video.
10104
+ kind: "image",
7690
10105
  src: picture.src,
7691
10106
  caption: slot.brief.caption,
7692
10107
  width: picture.width,
@@ -7705,8 +10120,8 @@ async function illustrate(storyboard, source, opts) {
7705
10120
  } catch (err) {
7706
10121
  const after = live[j + 1];
7707
10122
  if (!after) throw err;
7708
- const why2 = err instanceof Error ? err.message : String(err);
7709
- step(`illustrate: ${slot.label} via ${provider.id} failed (${why2}); trying ${after.id}`);
10123
+ const why3 = err instanceof Error ? err.message : String(err);
10124
+ step(`illustrate: ${slot.label} via ${provider.id} failed (${why3}); trying ${after.id}`);
7710
10125
  dropped.add(provider.id);
7711
10126
  }
7712
10127
  }
@@ -7756,36 +10171,36 @@ function slots(storyboard, known) {
7756
10171
  return out;
7757
10172
  }
7758
10173
  function cacheKey(providerId, req) {
7759
- return createHash5("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
10174
+ return createHash7("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
7760
10175
  }
7761
10176
  async function draw(provider, req, name, dir) {
7762
- for (const ext of Object.values(EXT)) {
10177
+ for (const ext of Object.values(EXT2)) {
7763
10178
  const src2 = `${name}${ext}`;
7764
- const bytes = await readFile5(join5(dir, src2)).catch(() => null);
7765
- if (bytes) return { src: src2, ...sizeOf(bytes, ext), cached: true };
10179
+ const bytes = await readFile8(join7(dir, src2)).catch(() => null);
10180
+ if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
7766
10181
  }
7767
10182
  await provider.check();
7768
10183
  const img = await provider.generate(req);
7769
- const src = `${name}${EXT[img.mime]}`;
7770
- const tmp = join5(dir, `.${name}.tmp`);
10184
+ const src = `${name}${EXT2[img.mime]}`;
10185
+ const tmp = join7(dir, `.${name}.tmp`);
7771
10186
  try {
7772
- await writeFile5(tmp, img.bytes);
7773
- await rename(tmp, join5(dir, src));
10187
+ await writeFile7(tmp, img.bytes);
10188
+ await rename(tmp, join7(dir, src));
7774
10189
  } finally {
7775
- await rm3(tmp, { force: true });
10190
+ await rm5(tmp, { force: true });
7776
10191
  }
7777
10192
  return { src, width: img.width, height: img.height, cached: false };
7778
10193
  }
7779
- function sizeOf(bytes, ext) {
10194
+ function sizeOf2(bytes, ext) {
7780
10195
  return ext === ".svg" ? svgSize(bytes) : imageSize(bytes);
7781
10196
  }
7782
10197
 
7783
10198
  // src/narrate/tts.ts
7784
- import { spawn as spawn2 } from "node:child_process";
7785
- import { createHash as createHash6 } from "node:crypto";
7786
- import { mkdir as mkdir4, readFile as readFile6, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
7787
- import { homedir } from "node:os";
7788
- import { join as join6 } from "node:path";
10199
+ import { spawn as spawn3 } from "node:child_process";
10200
+ import { createHash as createHash8 } from "node:crypto";
10201
+ import { mkdir as mkdir6, readFile as readFile9, rm as rm6, writeFile as writeFile8 } from "node:fs/promises";
10202
+ import { homedir as homedir2 } from "node:os";
10203
+ import { join as join8 } from "node:path";
7789
10204
  var MISSING = [
7790
10205
  "edge-tts is not installed, so narration cannot be synthesised.",
7791
10206
  "",
@@ -7796,14 +10211,14 @@ var MISSING = [
7796
10211
  ].join("\n");
7797
10212
  function candidates() {
7798
10213
  const env = process.env.DECKSMITH_EDGE_TTS?.trim();
7799
- const home = homedir();
10214
+ const home = homedir2();
7800
10215
  return [
7801
10216
  ...env ? [[env]] : [],
7802
10217
  ["edge-tts"],
7803
10218
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
7804
10219
  // default, and both are where this actually lands in practice.
7805
- [join6(home, "Library", "Python", "3.9", "bin", "edge-tts")],
7806
- [join6(home, ".local", "bin", "edge-tts")],
10220
+ [join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10221
+ [join8(home, ".local", "bin", "edge-tts")],
7807
10222
  // Last resort: the module is installed even though its console script is
7808
10223
  // nowhere findable, which is the normal state of a pip --user install.
7809
10224
  ["python3", "-m", "edge_tts"]
@@ -7817,19 +10232,19 @@ async function answersHelp(argv) {
7817
10232
  }
7818
10233
  var resolved = null;
7819
10234
  function resolveEdgeTts(can = answersHelp) {
7820
- if (can !== answersHelp) return find(can);
7821
- resolved ??= find(can);
10235
+ if (can !== answersHelp) return find2(can);
10236
+ resolved ??= find2(can);
7822
10237
  return resolved;
7823
10238
  }
7824
- async function find(can) {
10239
+ async function find2(can) {
7825
10240
  for (const argv of candidates()) {
7826
10241
  if (await can(argv)) return argv;
7827
10242
  }
7828
10243
  throw new Error(MISSING);
7829
10244
  }
7830
10245
  function runArgv(cmd, args) {
7831
- return new Promise((resolve5) => {
7832
- const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10246
+ return new Promise((resolve6) => {
10247
+ const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
7833
10248
  let stdout = "";
7834
10249
  let stderr = "";
7835
10250
  child.stdout.on("data", (b) => {
@@ -7838,8 +10253,8 @@ function runArgv(cmd, args) {
7838
10253
  child.stderr.on("data", (b) => {
7839
10254
  stderr += b.toString();
7840
10255
  });
7841
- child.on("error", (e) => resolve5({ code: -1, stderr: String(e), stdout }));
7842
- child.on("close", (code) => resolve5({ code: code ?? -1, stderr, stdout }));
10256
+ child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
10257
+ child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
7843
10258
  });
7844
10259
  }
7845
10260
  var edgeTts = {
@@ -7906,8 +10321,8 @@ function edgeProvider(runner = edgeTts) {
7906
10321
  async speak(req) {
7907
10322
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
7908
10323
  await runner.speak({ ...req, subs });
7909
- const raw2 = await readFile6(subs, "utf8").catch(() => "");
7910
- await rm4(subs, { force: true });
10324
+ const raw2 = await readFile9(subs, "utf8").catch(() => "");
10325
+ await rm6(subs, { force: true });
7911
10326
  const cues = parseCues(raw2);
7912
10327
  const measured = await runner.measure(req.audio);
7913
10328
  const lastCue = cues.length > 0 ? cues[cues.length - 1].end : 0;
@@ -7928,7 +10343,7 @@ function resolveProvider(name = process.env.DECKSMITH_TTS ?? "edge-tts") {
7928
10343
  return make();
7929
10344
  }
7930
10345
  function cacheKey2(text2, voice, rate, pitch) {
7931
- return createHash6("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
10346
+ return createHash8("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
7932
10347
  }
7933
10348
  async function synthesize(text2, opts) {
7934
10349
  const rate = opts.rate ?? "+0%";
@@ -7936,11 +10351,11 @@ async function synthesize(text2, opts) {
7936
10351
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
7937
10352
  const key = cacheKey2(text2, opts.voice, rate, pitch);
7938
10353
  const file = `${key}.mp3`;
7939
- const audio = join6(opts.dir, file);
7940
- const sidecar = join6(opts.dir, `${key}.json`);
10354
+ const audio = join8(opts.dir, file);
10355
+ const sidecar = join8(opts.dir, `${key}.json`);
7941
10356
  const cached = await readSidecar(sidecar);
7942
10357
  if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
7943
- await mkdir4(opts.dir, { recursive: true });
10358
+ await mkdir6(opts.dir, { recursive: true });
7944
10359
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
7945
10360
  const { cues, seconds } = spoken;
7946
10361
  const clamped = cues.map((c) => ({
@@ -7949,13 +10364,13 @@ async function synthesize(text2, opts) {
7949
10364
  text: c.text
7950
10365
  }));
7951
10366
  const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
7952
- await writeFile6(sidecar, `${JSON.stringify(body, null, 2)}
10367
+ await writeFile8(sidecar, `${JSON.stringify(body, null, 2)}
7953
10368
  `);
7954
10369
  return { audio, file, seconds, cues: clamped };
7955
10370
  }
7956
10371
  async function readSidecar(path2) {
7957
10372
  try {
7958
- const parsed = JSON.parse(await readFile6(path2, "utf8"));
10373
+ const parsed = JSON.parse(await readFile9(path2, "utf8"));
7959
10374
  const s = parsed;
7960
10375
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
7961
10376
  } catch {
@@ -8011,7 +10426,7 @@ function stopCount(holds) {
8011
10426
  return Math.max(1, usable.size);
8012
10427
  }
8013
10428
  function stopsFor(beat, source, format, sid = "s1") {
8014
- const ctx = { source, format, theme: ink, sid };
10429
+ const ctx = { source, format, theme: ink, sid, start: 0 };
8015
10430
  try {
8016
10431
  return stopCount(emitScene(beat, ctx).holds);
8017
10432
  } catch {
@@ -8050,7 +10465,7 @@ async function narrate(storyboard, source, prefs, opts) {
8050
10465
  for (const [i, beat] of storyboard.beats.entries()) {
8051
10466
  const text2 = beat.narration?.trim();
8052
10467
  if (!text2) continue;
8053
- const segments = [];
10468
+ const segments2 = [];
8054
10469
  const stops = Math.min(stopsFor(beat, source, format, `s${i + 1}`), paced.speakingStops);
8055
10470
  const plan = planSegments(text2, stops);
8056
10471
  for (const [stop, line2] of plan.entries()) {
@@ -8062,7 +10477,7 @@ async function narrate(storyboard, source, prefs, opts) {
8062
10477
  dir: opts.dir,
8063
10478
  runner: opts.runner
8064
10479
  });
8065
- segments.push({
10480
+ segments2.push({
8066
10481
  stop,
8067
10482
  text: line2,
8068
10483
  // Content-addressed and flat, so the path is the filename and the deck
@@ -8072,14 +10487,14 @@ async function narrate(storyboard, source, prefs, opts) {
8072
10487
  cues: speech.cues
8073
10488
  });
8074
10489
  }
8075
- if (segments.length > 0) beats[beat.id] = segments;
10490
+ if (segments2.length > 0) beats[beat.id] = segments2;
8076
10491
  }
8077
10492
  return { voice, beats };
8078
10493
  }
8079
10494
 
8080
10495
  // src/verify/index.ts
8081
- import { readdir as readdir2, readFile as readFile11 } from "node:fs/promises";
8082
- import { join as join11, relative, sep } from "node:path";
10496
+ import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
10497
+ import { join as join12, relative, sep } from "node:path";
8083
10498
 
8084
10499
  // src/verify/budget.ts
8085
10500
  function readCanvas(html) {
@@ -8185,24 +10600,24 @@ function clock2(seconds) {
8185
10600
  }
8186
10601
 
8187
10602
  // src/verify/check.ts
8188
- import { execFile } from "node:child_process";
8189
- import { readFile as readFile7 } from "node:fs/promises";
8190
- import { join as join7 } from "node:path";
8191
- import { promisify } from "node:util";
8192
- var run = promisify(execFile);
8193
- var DEFAULT_TIMEOUT_MS2 = 24e4;
10603
+ import { execFile as execFile2 } from "node:child_process";
10604
+ import { readFile as readFile10 } from "node:fs/promises";
10605
+ import { join as join9 } from "node:path";
10606
+ import { promisify as promisify2 } from "node:util";
10607
+ var run2 = promisify2(execFile2);
10608
+ var DEFAULT_TIMEOUT_MS3 = 24e4;
8194
10609
  var DEFAULT_SAMPLES = 9;
8195
10610
  var GATES = ["lint", "runtime", "layout", "motion", "contrast"];
8196
10611
  var SEVERITIES = /* @__PURE__ */ new Set(["error", "warning", "info"]);
8197
10612
  async function check(dir, opts = {}) {
8198
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
10613
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
8199
10614
  const args = ["hyperframes", "check", "--json", dir];
8200
10615
  if (opts.snapshots) args.push("--snapshots");
8201
10616
  const started = Date.now();
8202
10617
  const { transit, duration } = await readComposition(dir);
8203
10618
  if (opts.at?.length) args.push(`--at=${sampleTimes(opts.at, duration).join(",")}`);
8204
10619
  try {
8205
- const { stdout, stderr } = await run("npx", args, { timeout: timeoutMs, maxBuffer: 32 << 20 });
10620
+ const { stdout, stderr } = await run2("npx", args, { timeout: timeoutMs, maxBuffer: 32 << 20 });
8206
10621
  return interpret(stdout, stderr, { timeoutMs, elapsed: Date.now() - started, transit });
8207
10622
  } catch (err) {
8208
10623
  const e = err;
@@ -8217,7 +10632,7 @@ async function check(dir, opts = {}) {
8217
10632
  async function readComposition(dir) {
8218
10633
  let html;
8219
10634
  try {
8220
- html = await readFile7(join7(dir, "index.html"), "utf8");
10635
+ html = await readFile10(join9(dir, "index.html"), "utf8");
8221
10636
  } catch {
8222
10637
  return { transit: [], duration: 0 };
8223
10638
  }
@@ -8301,11 +10716,11 @@ function regrade({ f, time, selector }, transit = []) {
8301
10716
  (w) => time > w.t0 && time < w.t1 && (named === void 0 || named === w.sid)
8302
10717
  );
8303
10718
  if (hit) {
8304
- 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";
10719
+ 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";
8305
10720
  return {
8306
10721
  ...f,
8307
10722
  severity: "info",
8308
- message: `${f.message} \u2014 accepted: mid-camera-move, ${why2}.`
10723
+ message: `${f.message} \u2014 accepted: mid-camera-move, ${why3}.`
8309
10724
  };
8310
10725
  }
8311
10726
  return { ...f, severity: "error" };
@@ -8318,7 +10733,7 @@ function regrade({ f, time, selector }, transit = []) {
8318
10733
  var OFF_CANVAS = /* @__PURE__ */ new Set(["canvas_overflow", "panel_out_of_canvas", "text_occluded"]);
8319
10734
  function toFinding(gate, f) {
8320
10735
  const severity = typeof f.severity === "string" && SEVERITIES.has(f.severity) ? f.severity : "warning";
8321
- const message = str(f.message) ?? "(no message)";
10736
+ const message2 = str(f.message) ?? "(no message)";
8322
10737
  const time = typeof f.time === "number" && f.time > 0 ? f.time : void 0;
8323
10738
  const at = time !== void 0 ? `t=${time}s` : void 0;
8324
10739
  const where2 = [str(f.selector) ?? str(f.containerSelector), at].filter(Boolean).join(" ");
@@ -8329,7 +10744,7 @@ function toFinding(gate, f) {
8329
10744
  rule: str(f.code) ?? "unknown",
8330
10745
  // "Text extends outside the composition canvas." is unactionable without the
8331
10746
  // element it happened to, and Finding has nowhere else to put it.
8332
- message: where2 ? `${message} [${where2}]` : message
10747
+ message: where2 ? `${message2} [${where2}]` : message2
8333
10748
  },
8334
10749
  time,
8335
10750
  selector: str(f.selector) ?? str(f.containerSelector)
@@ -8343,8 +10758,8 @@ function staticGuardFindings(stderr) {
8343
10758
  message: line2.slice("[StaticGuard]".length).trim()
8344
10759
  }));
8345
10760
  }
8346
- function tooling(rule, message) {
8347
- return { severity: "error", gate: "check", rule, message };
10761
+ function tooling(rule, message2) {
10762
+ return { severity: "error", gate: "check", rule, message: message2 };
8348
10763
  }
8349
10764
  function readJson(stdout) {
8350
10765
  const first = stdout.indexOf("{");
@@ -8368,87 +10783,8 @@ function tail(s) {
8368
10783
  }
8369
10784
 
8370
10785
  // src/verify/fidelity.ts
8371
- import { readFile as readFile9 } from "node:fs/promises";
8372
- import { join as join9 } from "node:path";
8373
-
8374
- // src/render/capture.ts
8375
- import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
8376
- import { createRequire } from "node:module";
8377
- import { homedir as homedir2 } from "node:os";
8378
- import { join as join8 } from "node:path";
8379
- import { pathToFileURL } from "node:url";
8380
- async function chromePath(need = "open the deck with") {
8381
- const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
8382
- if (explicit) return explicit;
8383
- const { getInstalledBrowsers } = await import("@puppeteer/browsers");
8384
- const cacheDir = process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer");
8385
- const installed = await getInstalledBrowsers({ cacheDir }).catch(() => []);
8386
- const found = installed.find((b) => b.browser === "chrome-headless-shell") ?? installed.find((b) => b.browser === "chrome");
8387
- if (found) return found.executablePath;
8388
- throw new Error(
8389
- `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
8390
- );
8391
- }
8392
- function runtimePath() {
8393
- return createRequire(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
8394
- }
8395
- function renderSeek(t2) {
8396
- const player = window.__player;
8397
- player.renderSeek(t2, { suppressEvents: true });
8398
- }
8399
- async function openDeck(dir, opts = {}) {
8400
- const index = join8(dir, "index.html");
8401
- const html = await readFile8(index, "utf8").catch(() => null);
8402
- if (html === null) throw new Error(`no index.html in ${dir}`);
8403
- const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
8404
- const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
8405
- if (!width || !height) throw new Error("index.html declares no canvas size");
8406
- const timeout = opts.timeoutMs ?? 6e4;
8407
- const { default: puppeteer } = await import("puppeteer-core");
8408
- const browser = await puppeteer.launch({
8409
- executablePath: await chromePath(),
8410
- headless: true,
8411
- // A retina host would otherwise hand back a 2x frame, whose clip is not the
8412
- // renderer's.
8413
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
8414
- });
8415
- try {
8416
- const page = await browser.newPage();
8417
- await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
8418
- await page.setViewport({ width, height, deviceScaleFactor: 1 });
8419
- await page.goto(pathToFileURL(index).href, { waitUntil: "load", timeout });
8420
- await page.addScriptTag({ path: runtimePath() });
8421
- await page.waitForFunction(
8422
- // The composition registers a paused timeline per scene and a spanning
8423
- // `main` that carries no motion, so seeking `main` directly moves nothing.
8424
- // Only the runtime knows the per-scene offsets; wait for it.
8425
- "typeof window.__player?.renderSeek === 'function'",
8426
- { timeout }
8427
- );
8428
- await page.evaluate(() => document.fonts.ready);
8429
- const cdp = await page.createCDPSession();
8430
- return {
8431
- width,
8432
- height,
8433
- page,
8434
- seek: (t2) => page.evaluate(renderSeek, t2),
8435
- shoot: async () => {
8436
- const shot = await cdp.send("Page.captureScreenshot", {
8437
- format: "png",
8438
- fromSurface: true,
8439
- captureBeyondViewport: false,
8440
- clip: { x: 0, y: 0, width, height, scale: 1 }
8441
- });
8442
- return Buffer.from(shot.data, "base64");
8443
- },
8444
- close: () => browser.close()
8445
- };
8446
- } catch (err) {
8447
- await browser.close().catch(() => {
8448
- });
8449
- throw err;
8450
- }
8451
- }
10786
+ import { readFile as readFile11 } from "node:fs/promises";
10787
+ import { join as join10 } from "node:path";
8452
10788
 
8453
10789
  // src/verify/typefloor.ts
8454
10790
  var TYPE_FLOOR_PX = 40;
@@ -8502,7 +10838,7 @@ function svgZones(html) {
8502
10838
  return zones;
8503
10839
  }
8504
10840
  function userUnit(zones, at) {
8505
- return zones.filter((z4) => at >= z4.start && at < z4.end).reduce((unit, z4) => unit * z4.unit, 1);
10841
+ return zones.filter((z5) => at >= z5.start && at < z5.end).reduce((unit, z5) => unit * z5.unit, 1);
8506
10842
  }
8507
10843
  function where(html, at) {
8508
10844
  const open = html.lastIndexOf("<", at);
@@ -8882,21 +11218,21 @@ function captionBottom(sid, selector, fallbackPx) {
8882
11218
  async function fidelity(dir, opts = {}) {
8883
11219
  const started = Date.now();
8884
11220
  const floor = opts.floor ?? INK_FLOOR;
8885
- const notMeasured = (why2) => ({
11221
+ const notMeasured = (why3) => ({
8886
11222
  stops: [],
8887
11223
  findings: [
8888
11224
  {
8889
11225
  severity: "warning",
8890
11226
  gate: "fidelity",
8891
11227
  rule: "not_measured",
8892
- message: `did not check whether each stop draws anything: ${why2}`
11228
+ message: `did not check whether each stop draws anything: ${why3}`
8893
11229
  }
8894
11230
  ],
8895
11231
  elapsedMs: Date.now() - started
8896
11232
  });
8897
11233
  const stops = opts.stops ?? readStops(
8898
- await readFile9(join9(dir, TIMING_FILE), "utf8").catch(() => null),
8899
- await readFile9(join9(dir, DECK_PAGE), "utf8").catch(() => null)
11234
+ await readFile11(join10(dir, TIMING_FILE), "utf8").catch(() => null),
11235
+ await readFile11(join10(dir, DECK_PAGE), "utf8").catch(() => null)
8900
11236
  );
8901
11237
  if (stops.length === 0) return notMeasured("the deck declares no stops");
8902
11238
  let deck = null;
@@ -8952,13 +11288,13 @@ async function fidelity(dir, opts = {}) {
8952
11288
  }
8953
11289
 
8954
11290
  // src/verify/drift.ts
8955
- import { execFile as execFile2 } from "node:child_process";
8956
- import { createHash as createHash7 } from "node:crypto";
8957
- import { mkdtemp as mkdtemp3, readdir, readFile as readFile10, rm as rm5 } from "node:fs/promises";
11291
+ import { execFile as execFile3 } from "node:child_process";
11292
+ import { createHash as createHash9 } from "node:crypto";
11293
+ import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile12, rm as rm7 } from "node:fs/promises";
8958
11294
  import { tmpdir as tmpdir3 } from "node:os";
8959
- import { join as join10 } from "node:path";
8960
- import { promisify as promisify2 } from "node:util";
8961
- var run2 = promisify2(execFile2);
11295
+ import { join as join11 } from "node:path";
11296
+ import { promisify as promisify3 } from "node:util";
11297
+ var run3 = promisify3(execFile3);
8962
11298
  var FLOOR_DB = 40;
8963
11299
  var COMPOSITION_PAGE = "index.html";
8964
11300
  var DEFAULT_RENDER_TIMEOUT_MS = 18e5;
@@ -8995,13 +11331,13 @@ function measureMotion(hashes, scenes, seconds) {
8995
11331
  async function drift(dir, opts = {}) {
8996
11332
  const mode = opts.mode ?? "psnr";
8997
11333
  const floorDb = opts.floorDb ?? FLOOR_DB;
8998
- const work = opts.workDir ?? await mkdtemp3(join10(tmpdir3(), "decksmith-drift-"));
8999
- const a = join10(work, "a");
9000
- const b = join10(work, "b");
11334
+ const work = opts.workDir ?? await mkdtemp3(join11(tmpdir3(), "decksmith-drift-"));
11335
+ const a = join11(work, "a");
11336
+ const b = join11(work, "b");
9001
11337
  const report = await compare(dir, a, b, mode, floorDb, opts);
9002
11338
  const keep = opts.keep || !report.passed;
9003
11339
  if (keep) report.kept = { a, b };
9004
- else if (!opts.workDir) await rm5(work, { recursive: true, force: true });
11340
+ else if (!opts.workDir) await rm7(work, { recursive: true, force: true });
9005
11341
  return report;
9006
11342
  }
9007
11343
  async function compare(dir, a, b, mode, floorDb, opts) {
@@ -9016,11 +11352,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
9016
11352
  [a, "first"],
9017
11353
  [b, "second"]
9018
11354
  ].entries()) {
9019
- const why2 = await render(dir, out, { ...opts, workers: counts[i] });
9020
- if (why2)
11355
+ const why3 = await render(dir, out, { ...opts, workers: counts[i] });
11356
+ if (why3)
9021
11357
  return {
9022
11358
  passed: false,
9023
- findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${why2}`)],
11359
+ findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${why3}`)],
9024
11360
  ...empty
9025
11361
  };
9026
11362
  }
@@ -9052,11 +11388,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
9052
11388
  const differing = [];
9053
11389
  const ha = [];
9054
11390
  for (const [i, name] of fa.entries()) {
9055
- const one = await sha(join10(a, name));
11391
+ const one = await sha(join11(a, name));
9056
11392
  ha.push(one);
9057
- if (one !== await sha(join10(b, name))) differing.push(i + 1);
11393
+ if (one !== await sha(join11(b, name))) differing.push(i + 1);
9058
11394
  }
9059
- const html = await readFile10(join10(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11395
+ const html = await readFile12(join11(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
9060
11396
  const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
9061
11397
  let worst;
9062
11398
  if (differing.length > 0) {
@@ -9156,7 +11492,7 @@ async function render(dir, out, opts) {
9156
11492
  ];
9157
11493
  if (opts.workers !== void 0) args.push("-w", String(opts.workers));
9158
11494
  try {
9159
- await run2("npx", args, {
11495
+ await run3("npx", args, {
9160
11496
  timeout: opts.timeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS,
9161
11497
  maxBuffer: 32 << 20
9162
11498
  });
@@ -9167,11 +11503,11 @@ async function render(dir, out, opts) {
9167
11503
  }
9168
11504
  }
9169
11505
  async function frames(dir) {
9170
- const names = await readdir(dir).catch(() => []);
11506
+ const names = await readdir2(dir).catch(() => []);
9171
11507
  return names.filter((n3) => n3.endsWith(".png")).sort();
9172
11508
  }
9173
11509
  async function sha(file) {
9174
- return createHash7("sha256").update(await readFile10(file)).digest("hex");
11510
+ return createHash9("sha256").update(await readFile12(file)).digest("hex");
9175
11511
  }
9176
11512
  var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
9177
11513
  function framePattern(name) {
@@ -9195,7 +11531,7 @@ async function psnr(a, b, firstFrame) {
9195
11531
  if (!seq) return { error: `Cannot read a frame-number pattern out of "${firstFrame}".` };
9196
11532
  let stdout;
9197
11533
  try {
9198
- ({ stdout } = await run2(
11534
+ ({ stdout } = await run3(
9199
11535
  "ffmpeg",
9200
11536
  [
9201
11537
  "-hide_banner",
@@ -9204,11 +11540,11 @@ async function psnr(a, b, firstFrame) {
9204
11540
  "-start_number",
9205
11541
  String(seq.start),
9206
11542
  "-i",
9207
- join10(a, seq.pattern),
11543
+ join11(a, seq.pattern),
9208
11544
  "-start_number",
9209
11545
  String(seq.start),
9210
11546
  "-i",
9211
- join10(b, seq.pattern),
11547
+ join11(b, seq.pattern),
9212
11548
  "-lavfi",
9213
11549
  "psnr=stats_file=-",
9214
11550
  "-f",
@@ -9250,11 +11586,11 @@ function moved(motion) {
9250
11586
  const rest = motion.unmeasured.length > 0 ? ` ${motion.unmeasured.length} scene(s) had too few frames of their own to judge: ${motion.unmeasured.join(", ")}.` : "";
9251
11587
  return ` All ${measured} measurable scene(s) moved within their own window.${rest}`;
9252
11588
  }
9253
- function finding(rule, message) {
9254
- return { severity: "error", gate: "drift", rule, message };
11589
+ function finding(rule, message2) {
11590
+ return { severity: "error", gate: "drift", rule, message: message2 };
9255
11591
  }
9256
- function note(message) {
9257
- return { severity: "info", gate: "drift", rule: "stable", message };
11592
+ function note(message2) {
11593
+ return { severity: "info", gate: "drift", rule: "stable", message: message2 };
9258
11594
  }
9259
11595
  function tail2(s) {
9260
11596
  return s.trim().split("\n").slice(-3).join(" / ").slice(0, 400);
@@ -9262,18 +11598,28 @@ function tail2(s) {
9262
11598
 
9263
11599
  // src/verify/index.ts
9264
11600
  var NONDETERMINISM = [
9265
- [/\bMath\.random\s*\(/, "math_random"],
9266
- [/\bDate\.now\s*\(/, "date_now"],
9267
- [/\bnew\s+Date\s*\(\s*\)/, "date_now"],
9268
- [/\bperformance\.now\s*\(/, "performance_now"],
9269
- [/\bfetch\s*\(/, "runtime_fetch"],
9270
- [/\bXMLHttpRequest\b/, "runtime_fetch"]
11601
+ [/\bMath\.random\s*\(/, "math_random", "calls"],
11602
+ [/\bDate\.now\s*\(/, "date_now", "calls"],
11603
+ [/\bnew\s+Date\s*\(\s*\)/, "date_now", "calls"],
11604
+ [/\bperformance\.now\s*\(/, "performance_now", "calls"],
11605
+ [/\bfetch\s*\(/, "runtime_fetch", "calls"],
11606
+ [/\bXMLHttpRequest\b/, "runtime_fetch", "calls"],
11607
+ // The TAG, matched with a word boundary so `<iframes>` and the word "iframe"
11608
+ // in a comment or a `createElement("iframe")` in the wrapper's own runtime are
11609
+ // not it. `deck.html` is never scanned at all (`readCompositions`), which is
11610
+ // what keeps the player's own frame out of this.
11611
+ [
11612
+ /<iframe\b/i,
11613
+ "third_party_iframe",
11614
+ "embeds",
11615
+ "Bake what the frame was showing into the deck instead: a still as a figure, or a clip claim-figure can play."
11616
+ ]
9271
11617
  ];
9272
11618
  async function verify(dir, opts = {}, storyboard, kept, source) {
9273
11619
  const html = await readCompositions(dir);
9274
11620
  const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
9275
11621
  const narration = scanNarration(
9276
- await readFile11(join11(dir, DECK_PAGE), "utf8").catch(() => ""),
11622
+ await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => ""),
9277
11623
  await listFiles(dir)
9278
11624
  );
9279
11625
  const budget2 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
@@ -9310,8 +11656,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
9310
11656
  }
9311
11657
  async function declaredStops(dir) {
9312
11658
  return readStops(
9313
- await readFile11(join11(dir, TIMING_FILE), "utf8").catch(() => null),
9314
- await readFile11(join11(dir, DECK_PAGE), "utf8").catch(() => null)
11659
+ await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => null),
11660
+ await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => null)
9315
11661
  );
9316
11662
  }
9317
11663
  var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
@@ -9334,8 +11680,8 @@ function scanNarration(page, files) {
9334
11680
  const dir = typeof parsed.dir === "string" && parsed.dir ? `${parsed.dir}/` : "";
9335
11681
  const scenes = parsed.scenes ?? {};
9336
11682
  const missing = /* @__PURE__ */ new Set();
9337
- for (const segments of Object.values(scenes)) {
9338
- for (const s of segments ?? []) {
11683
+ for (const segments2 of Object.values(scenes)) {
11684
+ for (const s of segments2 ?? []) {
9339
11685
  if (typeof s?.audio !== "string" || /^[a-z][a-z0-9+.-]*:|^\//i.test(s.audio)) continue;
9340
11686
  if (!files.has(`${dir}${s.audio}`)) missing.add(s.audio);
9341
11687
  }
@@ -9351,7 +11697,7 @@ function scanNarration(page, files) {
9351
11697
  ];
9352
11698
  }
9353
11699
  async function readTiming(dir) {
9354
- const raw2 = await readFile11(join11(dir, TIMING_FILE), "utf8").catch(() => "");
11700
+ const raw2 = await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => "");
9355
11701
  if (!raw2) return void 0;
9356
11702
  try {
9357
11703
  const parsed = JSON.parse(raw2);
@@ -9361,9 +11707,9 @@ async function readTiming(dir) {
9361
11707
  }
9362
11708
  }
9363
11709
  async function listFiles(dir) {
9364
- const entries = await readdir2(dir, { recursive: true, withFileTypes: true }).catch(() => []);
11710
+ const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
9365
11711
  return new Set(
9366
- entries.filter((e) => e.isFile()).map((e) => relative(dir, join11(e.parentPath, e.name)).split(sep).join("/"))
11712
+ entries.filter((e) => e.isFile()).map((e) => relative(dir, join12(e.parentPath, e.name)).split(sep).join("/"))
9367
11713
  );
9368
11714
  }
9369
11715
  var INSTEAD = {
@@ -9489,20 +11835,20 @@ function scanBeatCount(storyboard, prefs) {
9489
11835
  ];
9490
11836
  }
9491
11837
  function scanPaperArc(storyboard, prefs) {
9492
- return arcProblems(storyboard, prefs).map((message) => ({
11838
+ return arcProblems(storyboard, prefs).map((message2) => ({
9493
11839
  severity: "warning",
9494
11840
  gate: "storyboard",
9495
11841
  rule: "paper_arc",
9496
- message
11842
+ message: message2
9497
11843
  }));
9498
11844
  }
9499
11845
  function scanNarrationDrift(storyboard, narration) {
9500
11846
  const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
9501
11847
  const stale = [];
9502
11848
  for (const beat of storyboard.beats) {
9503
- const segments = narration.beats[beat.id];
9504
- if (!segments?.length || !flat(beat.narration)) continue;
9505
- if (flat(segments.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
11849
+ const segments2 = narration.beats[beat.id];
11850
+ if (!segments2?.length || !flat(beat.narration)) continue;
11851
+ if (flat(segments2.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
9506
11852
  }
9507
11853
  if (!stale.length) return [];
9508
11854
  return [
@@ -9547,8 +11893,8 @@ function scanNarrationLead(beats, timing) {
9547
11893
  }
9548
11894
  return findings;
9549
11895
  }
9550
- function firstMention(segments, label) {
9551
- for (const segment of segments) {
11896
+ function firstMention(segments2, label) {
11897
+ for (const segment of segments2) {
9552
11898
  for (const cue of segment.cues) {
9553
11899
  const words2 = cue.text.split(/\s+/);
9554
11900
  let at = 0;
@@ -9606,35 +11952,35 @@ var STOP = /* @__PURE__ */ new Set([
9606
11952
  function scanDeterminism(html, file) {
9607
11953
  const findings = [];
9608
11954
  const lines = html.split("\n");
9609
- for (const [pattern, rule] of NONDETERMINISM) {
11955
+ for (const [pattern, rule, verb, remedy2] of NONDETERMINISM) {
9610
11956
  const i = lines.findIndex((line2) => pattern.test(line2));
9611
11957
  if (i < 0) continue;
9612
11958
  findings.push({
9613
11959
  severity: "error",
9614
11960
  gate: "determinism",
9615
11961
  rule,
9616
- message: `${file}:${i + 1} calls \`${lines[i]?.match(pattern)?.[0]}\` at render time, so two renders of this deck will not be identical.`
11962
+ 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}` : "")
9617
11963
  });
9618
11964
  }
9619
11965
  return findings;
9620
11966
  }
9621
11967
  async function readCompositions(dir) {
9622
- const entries = await readdir2(dir, { recursive: true, withFileTypes: true }).catch(() => []);
11968
+ const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
9623
11969
  const files = entries.filter(
9624
11970
  (e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
9625
- ).map((e) => join11(e.parentPath, e.name));
11971
+ ).map((e) => join12(e.parentPath, e.name));
9626
11972
  return Promise.all(
9627
- files.map(async (f) => [relative(dir, f), await readFile11(f, "utf8")])
11973
+ files.map(async (f) => [relative(dir, f), await readFile13(f, "utf8")])
9628
11974
  );
9629
11975
  }
9630
11976
 
9631
11977
  // src/render/render.ts
9632
- import { mkdir as mkdir7, readFile as readFile12, rename as rename2, rm as rm6, writeFile as writeFile9 } from "node:fs/promises";
9633
- import { basename, dirname, join as join13, resolve as resolve2 } from "node:path";
11978
+ import { mkdir as mkdir8, readFile as readFile14, rename as rename2, rm as rm8, writeFile as writeFile10 } from "node:fs/promises";
11979
+ import { basename as basename3, dirname, join as join14, resolve as resolve3 } from "node:path";
9634
11980
 
9635
11981
  // src/render/captions.ts
9636
- import { mkdir as mkdir6, writeFile as writeFile8 } from "node:fs/promises";
9637
- import { join as join12 } from "node:path";
11982
+ import { mkdir as mkdir7, writeFile as writeFile9 } from "node:fs/promises";
11983
+ import { join as join13 } from "node:path";
9638
11984
  import { pathToFileURL as pathToFileURL2 } from "node:url";
9639
11985
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
9640
11986
  function cssString(value) {
@@ -9690,7 +12036,7 @@ ${bands}
9690
12036
  </div>
9691
12037
  `;
9692
12038
  }
9693
- function measure2(count) {
12039
+ function measure3(count) {
9694
12040
  const doc = document;
9695
12041
  const probe2 = doc.getElementById("c0");
9696
12042
  if (probe2) {
@@ -9710,255 +12056,89 @@ function measure2(count) {
9710
12056
  const r = el.querySelector("span").getBoundingClientRect();
9711
12057
  el.classList.remove("on");
9712
12058
  rects.push({ x: r.x, y: r.y, w: r.width, h: r.height });
9713
- }
9714
- return rects;
9715
- }
9716
- var CAPTION_NEED = "draw the captions with (`render` needs one for the capture too)";
9717
- async function captionBlocker() {
9718
- try {
9719
- await import("puppeteer-core");
9720
- await chromePath(CAPTION_NEED);
9721
- return null;
9722
- } catch (err) {
9723
- return err instanceof Error ? err.message : String(err);
9724
- }
9725
- }
9726
- async function renderCaptions(cues, style, deck, work) {
9727
- if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
9728
- const fontCss = join12(deck, DECK_FONT_CSS);
9729
- const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
9730
- const page = join12(work, "captions.html");
9731
- await mkdir6(work, { recursive: true });
9732
- await writeFile8(page, captionPage(cues, style, href));
9733
- const { default: puppeteer } = await import("puppeteer-core");
9734
- const browser = await puppeteer.launch({
9735
- executablePath: await chromePath(CAPTION_NEED),
9736
- headless: true,
9737
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
9738
- });
9739
- try {
9740
- const tab = await browser.newPage();
9741
- await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
9742
- await tab.goto(pathToFileURL2(page).href, { waitUntil: "load" });
9743
- await tab.evaluate(() => document.fonts.ready);
9744
- const rects = await tab.evaluate(measure2, cues.length);
9745
- const box = union(rects);
9746
- const files = [];
9747
- for (const [i, cue] of cues.entries()) {
9748
- void cue;
9749
- const name = `cap${String(i).padStart(4, "0")}.png`;
9750
- await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
9751
- await tab.screenshot({
9752
- path: join12(work, name),
9753
- type: "png",
9754
- omitBackground: true,
9755
- clip: { x: box.x, y: box.y, width: box.width, height: box.height }
9756
- });
9757
- await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
9758
- files.push(name);
9759
- }
9760
- return { files, ...box };
9761
- } finally {
9762
- await browser.close();
9763
- }
9764
- }
9765
- function union(rects) {
9766
- const left = Math.floor(Math.min(...rects.map((r) => r.x)));
9767
- const top = Math.floor(Math.min(...rects.map((r) => r.y)));
9768
- const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
9769
- const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
9770
- return { x: left, y: top, width: right - left, height: bottom - top };
9771
- }
9772
- var t3 = (seconds) => seconds.toFixed(3);
9773
- function overlayGraph(cues, band, input = "0:v", output = "vout") {
9774
- return cues.map((cue, i) => {
9775
- const from = i === 0 ? `[${input}]` : `[v${i}]`;
9776
- const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
9777
- const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
9778
- return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
9779
- }).join(";\n");
9780
- }
9781
- function overlayInputs(band) {
9782
- return band.files.flatMap((file) => ["-i", file]);
9783
- }
9784
-
9785
- // src/render/ffmpeg.ts
9786
- import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
9787
- import { promisify as promisify3 } from "node:util";
9788
- var run3 = promisify3(execFile3);
9789
- var DEFAULT_TIMEOUT_MS3 = 36e5;
9790
- async function runTool(file, args, opts = {}) {
9791
- try {
9792
- return await run3(file, args, {
9793
- cwd: opts.cwd,
9794
- timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3,
9795
- maxBuffer: 64 << 20
9796
- });
9797
- } catch (err) {
9798
- const e = err;
9799
- if (e.code === "ENOENT") {
9800
- throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
9801
- }
9802
- const tail3 = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
9803
- throw new Error(`${file} failed:
9804
- ${tail3}`);
9805
- }
9806
- }
9807
- function runLive(file, args) {
9808
- return new Promise((resolve5, reject) => {
9809
- const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
9810
- child.on("error", (err) => {
9811
- reject(
9812
- err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
9813
- );
9814
- });
9815
- child.on("close", (code, signal) => {
9816
- if (code === 0) resolve5();
9817
- else if (signal) {
9818
- reject(
9819
- new Error(
9820
- `${file} was killed by ${signal}. On a machine under memory pressure this is the OS reclaiming the browser; free memory, or lower --workers, and run again.`
9821
- )
9822
- );
9823
- } else reject(new Error(`${file} exited ${code}.`));
9824
- });
9825
- });
9826
- }
9827
- async function probe(path2) {
9828
- const { stdout } = await runTool("ffprobe", [
9829
- "-v",
9830
- "error",
9831
- "-show_entries",
9832
- "stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
9833
- "-of",
9834
- "json",
9835
- path2
9836
- ]);
9837
- const json = JSON.parse(stdout);
9838
- const streams = json.streams ?? [];
9839
- const video = streams.find((s) => s.codec_type === "video");
9840
- if (!video) throw new Error(`${path2} has no video stream.`);
9841
- const [num, den] = (video.r_frame_rate ?? "30/1").split("/");
9842
- const fps = Number(num) / (Number(den) || 1);
9843
- const seconds = Number(json.format?.duration ?? 0);
9844
- const frames2 = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
9845
- return {
9846
- width: video.width ?? 0,
9847
- height: video.height ?? 0,
9848
- fps,
9849
- frames: frames2,
9850
- seconds,
9851
- hasAudio: streams.some((s) => s.codec_type === "audio")
9852
- };
9853
- }
9854
- function encoderArgs(fps) {
9855
- return [
9856
- "-c:v",
9857
- "libx264",
9858
- "-preset",
9859
- "veryfast",
9860
- "-crf",
9861
- "16",
9862
- "-pix_fmt",
9863
- "yuv420p",
9864
- "-g",
9865
- "12",
9866
- "-r",
9867
- String(fps),
9868
- "-fps_mode",
9869
- "cfr"
9870
- ];
9871
- }
9872
- function pieceFilter(motion, freeze) {
9873
- const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
9874
- if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
9875
- return chain.join(",");
9876
- }
9877
- function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
9878
- return [
9879
- "-y",
9880
- "-hide_banner",
9881
- "-loglevel",
9882
- "error",
9883
- // Half a frame in, so a time that is exactly on a boundary cannot round to
9884
- // the frame before it.
9885
- "-ss",
9886
- ((fromFrame + 0.5) / fps).toFixed(6),
9887
- "-i",
9888
- source,
9889
- "-an",
9890
- "-vf",
9891
- pieceFilter(motion, freeze),
9892
- "-frames:v",
9893
- String(motion + freeze),
9894
- ...encoderArgs(fps),
9895
- "-f",
9896
- "mpegts",
9897
- out
9898
- ];
12059
+ }
12060
+ return rects;
9899
12061
  }
9900
- var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
9901
- function audioGraph(inputs, seconds, first = 1) {
9902
- const lines = inputs.map(
9903
- (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
9904
- );
9905
- const labels = inputs.map((_, i) => `[d${i}]`).join("");
9906
- lines.push(
9907
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
9908
- );
9909
- return lines.join(";\n");
12062
+ var CAPTION_NEED = "draw the captions with (`render` needs one for the capture too)";
12063
+ async function captionBlocker() {
12064
+ try {
12065
+ await import("puppeteer-core");
12066
+ await chromePath(CAPTION_NEED);
12067
+ return null;
12068
+ } catch (err) {
12069
+ return err instanceof Error ? err.message : String(err);
12070
+ }
9910
12071
  }
9911
- function respeedArgs(source, factor, chain, fps, hasAudio, out) {
9912
- const video = `[0:v]setpts=PTS/${factor}[v]`;
9913
- const audio = hasAudio ? `;[0:a]${chain.map((t2) => `atempo=${t2}`).join(",")}[a]` : "";
9914
- return [
9915
- "-y",
9916
- "-hide_banner",
9917
- "-loglevel",
9918
- "error",
9919
- "-i",
9920
- source,
9921
- "-filter_complex",
9922
- `${video}${audio}`,
9923
- "-map",
9924
- "[v]",
9925
- ...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
9926
- ...encoderArgs(fps),
9927
- out
9928
- ];
12072
+ async function renderCaptions(cues, style, deck, work) {
12073
+ if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
12074
+ const fontCss = join13(deck, DECK_FONT_CSS);
12075
+ const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
12076
+ const page = join13(work, "captions.html");
12077
+ await mkdir7(work, { recursive: true });
12078
+ await writeFile9(page, captionPage(cues, style, href));
12079
+ const { default: puppeteer } = await import("puppeteer-core");
12080
+ const browser = await puppeteer.launch({
12081
+ executablePath: await chromePath(CAPTION_NEED),
12082
+ headless: true,
12083
+ args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
12084
+ });
12085
+ try {
12086
+ const tab = await browser.newPage();
12087
+ await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
12088
+ await tab.goto(pathToFileURL2(page).href, { waitUntil: "load" });
12089
+ await tab.evaluate(() => document.fonts.ready);
12090
+ const rects = await tab.evaluate(measure3, cues.length);
12091
+ const box = union(rects);
12092
+ const files = [];
12093
+ for (const [i, cue] of cues.entries()) {
12094
+ void cue;
12095
+ const name = `cap${String(i).padStart(4, "0")}.png`;
12096
+ await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
12097
+ await tab.screenshot({
12098
+ path: join13(work, name),
12099
+ type: "png",
12100
+ omitBackground: true,
12101
+ clip: { x: box.x, y: box.y, width: box.width, height: box.height }
12102
+ });
12103
+ await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
12104
+ files.push(name);
12105
+ }
12106
+ return { files, ...box };
12107
+ } finally {
12108
+ await browser.close();
12109
+ }
9929
12110
  }
9930
- function burnStyle(width, height, font = "Arial") {
9931
- return {
9932
- width,
9933
- height,
9934
- font,
9935
- // MEASURED, not guessed. `splitCue` caps a cue at 84 characters and `wrap`
9936
- // breaks it near the middle, so the longer of the two lines runs to about
9937
- // 46 characters in real caption prose. Bold Arial advances 0.485em per
9938
- // character on that prose (measured in a browser over the demo's own
9939
- // narration), so 46 characters at F px is 22.3F wide, and the usable width
9940
- // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
9941
- // "4% of the width" looked like on paper overflows to a THIRD line, and a
9942
- // three-line band covers the bottom of the slide.
9943
- fontSize: Math.round(width * 0.037),
9944
- // Clear of the play button, the progress bar and the handle every vertical
9945
- // player draws across the bottom eighth of the frame.
9946
- marginV: Math.round(height * 0.09),
9947
- marginX: Math.round(width * 0.04)
9948
- };
12111
+ function union(rects) {
12112
+ const left = Math.floor(Math.min(...rects.map((r) => r.x)));
12113
+ const top = Math.floor(Math.min(...rects.map((r) => r.y)));
12114
+ const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
12115
+ const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
12116
+ return { x: left, y: top, width: right - left, height: bottom - top };
12117
+ }
12118
+ var t3 = (seconds) => seconds.toFixed(3);
12119
+ function overlayGraph(cues, band, input = "0:v", output = "vout") {
12120
+ return cues.map((cue, i) => {
12121
+ const from = i === 0 ? `[${input}]` : `[v${i}]`;
12122
+ const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
12123
+ const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
12124
+ return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
12125
+ }).join(";\n");
12126
+ }
12127
+ function overlayInputs(band) {
12128
+ return band.files.flatMap((file) => ["-i", file]);
9949
12129
  }
9950
12130
 
9951
12131
  // src/render/render.ts
9952
- var SRT_NAME = (out) => `${basename(out).replace(/\.[^.]+$/, "")}.srt`;
12132
+ var SRT_NAME = (out) => `${basename3(out).replace(/\.[^.]+$/, "")}.srt`;
9953
12133
  function subtitlePlan(mode) {
9954
12134
  return { sidecar: mode !== "none", burn: mode === "burn" };
9955
12135
  }
9956
12136
  async function render2(opts) {
9957
12137
  const log = opts.log ?? (() => {
9958
12138
  });
9959
- const deck = resolve2(opts.deck);
9960
- const out = resolve2(opts.out);
9961
- const work = join13(dirname(out), `.${basename(out)}.parts`);
12139
+ const deck = resolve3(opts.deck);
12140
+ const out = resolve3(opts.out);
12141
+ const work = join14(dirname(out), `.${basename3(out)}.parts`);
9962
12142
  const timing = await readTiming2(deck);
9963
12143
  if (opts.targetSeconds && !opts.allowFastPlayback) {
9964
12144
  const refusal = playbackRefusal(
@@ -9974,9 +12154,9 @@ async function render2(opts) {
9974
12154
  if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
9975
12155
  }
9976
12156
  const burnable = plan0.burn;
9977
- await mkdir7(work, { recursive: true });
12157
+ await mkdir8(work, { recursive: true });
9978
12158
  try {
9979
- const raw2 = opts.video ? resolve2(opts.video) : await capture(deck, join13(work, "raw.mp4"), opts, log);
12159
+ const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join14(work, "raw.mp4"), opts, log);
9980
12160
  const shot = await probe(raw2);
9981
12161
  log(
9982
12162
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -9989,18 +12169,18 @@ async function render2(opts) {
9989
12169
  );
9990
12170
  }
9991
12171
  const retimed = await retime(raw2, plan, work, log);
9992
- const srtPath = join13(dirname(out), SRT_NAME(out));
12172
+ const srtPath = join14(dirname(out), SRT_NAME(out));
9993
12173
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
9994
12174
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
9995
12175
  const srt = plan0.sidecar ? toSrt(cues) : "";
9996
- if (srt) await writeFile9(srtPath, srt);
12176
+ if (srt) await writeFile10(srtPath, srt);
9997
12177
  const burn = burnable && srt.length > 0;
9998
12178
  await mux(retimed, timing, plan, deck, out, work, burn ? plan.cues : void 0, shot.fps, log);
9999
12179
  if (playback > 1) {
10000
12180
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
10001
12181
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
10002
12182
  if (warning) log(`render: ${warning}`);
10003
- const fast = join13(work, `fast.${basename(out)}`);
12183
+ const fast = join14(work, `fast.${basename3(out)}`);
10004
12184
  const muxed = await probe(out);
10005
12185
  await runTool(
10006
12186
  "ffmpeg",
@@ -10026,12 +12206,12 @@ async function render2(opts) {
10026
12206
  captionCps: p95CueRate(cues)
10027
12207
  };
10028
12208
  } finally {
10029
- if (!opts.keep) await rm6(work, { recursive: true, force: true });
12209
+ if (!opts.keep) await rm8(work, { recursive: true, force: true });
10030
12210
  }
10031
12211
  }
10032
12212
  async function readTiming2(deck) {
10033
- const path2 = join13(deck, TIMING_FILE);
10034
- const text2 = await readFile12(path2, "utf8").catch(() => {
12213
+ const path2 = join14(deck, TIMING_FILE);
12214
+ const text2 = await readFile14(path2, "utf8").catch(() => {
10035
12215
  throw new Error(
10036
12216
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
10037
12217
  );
@@ -10070,20 +12250,20 @@ async function retime(raw2, plan, work, log) {
10070
12250
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
10071
12251
  const list = [];
10072
12252
  for (const [i, piece] of plan.pieces.entries()) {
10073
- const file = join13(work, `p${String(i).padStart(4, "0")}.ts`);
12253
+ const file = join14(work, `p${String(i).padStart(4, "0")}.ts`);
10074
12254
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
10075
12255
  list.push(file);
10076
12256
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
10077
12257
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
10078
12258
  }
10079
12259
  }
10080
- const listFile = join13(work, "pieces.txt");
10081
- await writeFile9(
12260
+ const listFile = join14(work, "pieces.txt");
12261
+ await writeFile10(
10082
12262
  listFile,
10083
12263
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
10084
12264
  `
10085
12265
  );
10086
- const joined = join13(work, "retimed.mp4");
12266
+ const joined = join14(work, "retimed.mp4");
10087
12267
  await runTool("ffmpeg", [
10088
12268
  "-y",
10089
12269
  "-hide_banner",
@@ -10105,7 +12285,7 @@ async function retime(raw2, plan, work, log) {
10105
12285
  }
10106
12286
  async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
10107
12287
  const inputs = plan.audio.map((a) => ({
10108
- file: join13(deck, timing.audioDir, a.audio),
12288
+ file: join14(deck, timing.audioDir, a.audio),
10109
12289
  delayMs: a.delayMs
10110
12290
  }));
10111
12291
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -10126,8 +12306,8 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
10126
12306
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
10127
12307
  }
10128
12308
  if (graph.length > 0) {
10129
- const script = join13(work, "mux.filter");
10130
- await writeFile9(script, `${graph.join(";\n")}
12309
+ const script = join14(work, "mux.filter");
12310
+ await writeFile10(script, `${graph.join(";\n")}
10131
12311
  `);
10132
12312
  args.push("-filter_complex_script", script);
10133
12313
  }
@@ -10142,13 +12322,13 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
10142
12322
  log(
10143
12323
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
10144
12324
  );
10145
- await mkdir7(dirname(out), { recursive: true });
12325
+ await mkdir8(dirname(out), { recursive: true });
10146
12326
  await runTool("ffmpeg", args, { cwd: work });
10147
12327
  }
10148
12328
 
10149
12329
  // src/pack/pack.ts
10150
- import { mkdir as mkdir8, readFile as readFile13, writeFile as writeFile10 } from "node:fs/promises";
10151
- import { dirname as dirname2, extname as extname2 } from "node:path";
12330
+ import { mkdir as mkdir9, readFile as readFile15, writeFile as writeFile11 } from "node:fs/promises";
12331
+ import { dirname as dirname2, extname as extname3 } from "node:path";
10152
12332
  import { unzipSync, zipSync } from "fflate";
10153
12333
  var MTIME = Date.UTC(1980, 0, 2, 12);
10154
12334
  var STORED = /* @__PURE__ */ new Set([
@@ -10184,15 +12364,15 @@ async function writePack(pack2, files, out) {
10184
12364
  if (!bytes) continue;
10185
12365
  if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
10186
12366
  check2(path2);
10187
- entries[path2] = STORED.has(extname2(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
12367
+ entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
10188
12368
  }
10189
12369
  const zip = zipSync(entries, { mtime: MTIME });
10190
- await mkdir8(dirname2(out), { recursive: true });
10191
- await writeFile10(out, zip);
12370
+ await mkdir9(dirname2(out), { recursive: true });
12371
+ await writeFile11(out, zip);
10192
12372
  return zip.length;
10193
12373
  }
10194
12374
  async function readPack(path2) {
10195
- return openPack(new Uint8Array(await readFile13(path2)), path2);
12375
+ return openPack(new Uint8Array(await readFile15(path2)), path2);
10196
12376
  }
10197
12377
  function openPack(bytes, label = "pack") {
10198
12378
  if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
@@ -10218,7 +12398,7 @@ function openPack(bytes, label = "pack") {
10218
12398
  );
10219
12399
  const parsed = packSchema.safeParse(json);
10220
12400
  if (!parsed.success)
10221
- throw new Error(`${label}: deck.json is not a valid pack \u2014 ${why(parsed.error)}`);
12401
+ throw new Error(`${label}: deck.json is not a valid pack \u2014 ${why2(parsed.error)}`);
10222
12402
  const files = {};
10223
12403
  for (const [name, content] of Object.entries(raw2)) {
10224
12404
  if (name === "deck.json" || name.endsWith("/")) continue;
@@ -10231,164 +12411,13 @@ function check2(label, path2 = label) {
10231
12411
  if (path2.startsWith("/") || /^[a-z]:/i.test(path2) || path2.split("/").includes(".."))
10232
12412
  throw new Error(`${label}: unsafe path in pack`);
10233
12413
  }
10234
- function why(error) {
12414
+ function why2(error) {
10235
12415
  return error.issues.slice(0, 3).map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
10236
12416
  }
10237
12417
 
10238
- // src/pack/media.ts
10239
- import { createHash as createHash8 } from "node:crypto";
10240
- import { readFile as readFile14 } from "node:fs/promises";
10241
- import { extname as extname3 } from "node:path";
10242
- var PLAYERS = [
10243
- "youtube.com",
10244
- "youtube-nocookie.com",
10245
- "youtu.be",
10246
- "vimeo.com",
10247
- "dailymotion.com",
10248
- "dai.ly",
10249
- "twitch.tv",
10250
- "loom.com",
10251
- "wistia.com",
10252
- "wistia.net",
10253
- "streamable.com",
10254
- "bilibili.com",
10255
- "soundcloud.com",
10256
- "tiktok.com"
10257
- ];
10258
- var FILE_EXT = /* @__PURE__ */ new Set([
10259
- ".png",
10260
- ".jpg",
10261
- ".jpeg",
10262
- ".gif",
10263
- ".webp",
10264
- ".avif",
10265
- ".svg",
10266
- ".mp4",
10267
- ".webm",
10268
- ".mov",
10269
- ".m4v",
10270
- ".mp3",
10271
- ".m4a",
10272
- ".wav",
10273
- ".ogg",
10274
- ".opus",
10275
- ".pdf"
10276
- ]);
10277
- function isEmbed(url) {
10278
- const host = hostOf(url);
10279
- return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
10280
- }
10281
- function policyFor(url, prefer) {
10282
- if (isEmbed(url)) return "embed";
10283
- const host = hostOf(url);
10284
- if (host === null || /^data:/i.test(url)) return "bake";
10285
- if (prefer === "link") return "link";
10286
- return FILE_EXT.has(extOf(url)) ? "bake" : "link";
10287
- }
10288
- async function planMedia(assets, fetcher = fetchAsset) {
10289
- const plan = {
10290
- media: [],
10291
- files: {},
10292
- bakedCount: 0,
10293
- bakedBytes: 0,
10294
- demoted: [],
10295
- promoted: []
10296
- };
10297
- for (const asset of assets) {
10298
- const policy = policyFor(asset.url, asset.prefer);
10299
- if (policy !== "bake") {
10300
- if (asset.prefer === "bake") plan.demoted.push(asset.id);
10301
- plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
10302
- continue;
10303
- }
10304
- if (asset.prefer === "link") plan.promoted.push(asset.id);
10305
- const got = await fetcher(asset.url);
10306
- const mime = clean(got.mime) ?? asset.mime;
10307
- if (mime === "text/html") {
10308
- plan.demoted.push(asset.id);
10309
- plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
10310
- continue;
10311
- }
10312
- const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
10313
- plan.files[path2] = got.bytes;
10314
- plan.bakedCount += 1;
10315
- plan.bakedBytes += got.bytes.length;
10316
- plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
10317
- }
10318
- return plan;
10319
- }
10320
- function mediaSummary(plan) {
10321
- const linked = plan.media.filter((m) => m.policy === "link").length;
10322
- const embedded = plan.media.filter((m) => m.policy === "embed").length;
10323
- const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
10324
- if (linked) parts.push(`${linked} linked`);
10325
- if (embedded) parts.push(`${embedded} embedded`);
10326
- if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
10327
- return parts.join(", ");
10328
- }
10329
- function bakedName(id2, url, mime) {
10330
- const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
10331
- const hash = createHash8("sha256").update(url).digest("hex").slice(0, 8);
10332
- return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
10333
- }
10334
- function extOf(url) {
10335
- const path2 = hostOf(url) === null ? url : new URL(url).pathname;
10336
- const ext = extname3(path2).toLowerCase();
10337
- return FILE_EXT.has(ext) ? ext : "";
10338
- }
10339
- var MIME_EXT = {
10340
- "image/png": ".png",
10341
- "image/jpeg": ".jpg",
10342
- "image/gif": ".gif",
10343
- "image/webp": ".webp",
10344
- "image/avif": ".avif",
10345
- "image/svg+xml": ".svg",
10346
- "video/mp4": ".mp4",
10347
- "video/webm": ".webm",
10348
- "audio/mpeg": ".mp3",
10349
- "application/pdf": ".pdf"
10350
- };
10351
- function extFor(mime) {
10352
- return (mime && MIME_EXT[mime]) ?? ".bin";
10353
- }
10354
- function hostOf(url) {
10355
- try {
10356
- const u = new URL(url);
10357
- if (u.protocol === "file:" || u.protocol === "data:") return null;
10358
- return u.hostname.toLowerCase().replace(/^www\./, "");
10359
- } catch {
10360
- return null;
10361
- }
10362
- }
10363
- function clean(mime) {
10364
- return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
10365
- }
10366
- function size(bytes) {
10367
- if (bytes < 1024) return `${bytes} B`;
10368
- const units = ["KB", "MB", "GB"];
10369
- let n3 = bytes / 1024;
10370
- let i = 0;
10371
- while (n3 >= 1024 && i < units.length - 1) {
10372
- n3 /= 1024;
10373
- i += 1;
10374
- }
10375
- return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
10376
- }
10377
- async function fetchAsset(url) {
10378
- if (/^(https?|data):/i.test(url)) {
10379
- const res = await fetch(url);
10380
- if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
10381
- return {
10382
- bytes: new Uint8Array(await res.arrayBuffer()),
10383
- mime: res.headers.get("content-type") ?? void 0
10384
- };
10385
- }
10386
- return { bytes: new Uint8Array(await readFile14(url)) };
10387
- }
10388
-
10389
12418
  // src/prefs.ts
10390
- import { readFile as readFile15 } from "node:fs/promises";
10391
- import { dirname as dirname3, join as join14, resolve as resolve3 } from "node:path";
12419
+ import { readFile as readFile16 } from "node:fs/promises";
12420
+ import { dirname as dirname3, join as join15, resolve as resolve4 } from "node:path";
10392
12421
  var CONFIG_FILE = "decksmith.config.json";
10393
12422
  var PREF_KEYS = Object.keys(prefsSchema.shape);
10394
12423
  var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
@@ -10405,10 +12434,10 @@ async function loadPrefs(overrides = {}, cwd = process.cwd(), source) {
10405
12434
  return parsed;
10406
12435
  }
10407
12436
  async function findConfig(from) {
10408
- let dir = resolve3(from);
12437
+ let dir = resolve4(from);
10409
12438
  for (; ; ) {
10410
- const path2 = join14(dir, CONFIG_FILE);
10411
- const text2 = await readFile15(path2, "utf8").catch(() => void 0);
12439
+ const path2 = join15(dir, CONFIG_FILE);
12440
+ const text2 = await readFile16(path2, "utf8").catch(() => void 0);
10412
12441
  if (text2 !== void 0) {
10413
12442
  try {
10414
12443
  return { path: path2, value: JSON.parse(text2) };
@@ -10466,8 +12495,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10466
12495
  if (!format) throw new Error("no deck-16x9 format");
10467
12496
  const step = opts.onStep ?? (() => {
10468
12497
  });
10469
- const out = resolve4(outDir);
10470
- await mkdir9(out, { recursive: true });
12498
+ const out = resolve5(outDir);
12499
+ await mkdir10(out, { recursive: true });
10471
12500
  const speed = opts.speed ?? 1;
10472
12501
  const fontCss = await refreshFont(storyboard, source, out, step);
10473
12502
  const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
@@ -10479,8 +12508,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10479
12508
  });
10480
12509
  const files = [];
10481
12510
  const write = async (name, text2) => {
10482
- await writeFile11(join15(out, name), text2);
10483
- files.push(join15(out, name));
12511
+ await writeFile12(join16(out, name), text2);
12512
+ files.push(join16(out, name));
10484
12513
  };
10485
12514
  await write("index.html", deck.composition);
10486
12515
  await write("hyperframes.json", HYPERFRAMES_JSON);
@@ -10509,8 +12538,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10509
12538
  }
10510
12539
  if (deck.page) {
10511
12540
  await write(DECK_PAGE, deck.page);
10512
- await cp(playerBundle(), join15(out, PLAYER_FILE));
10513
- files.push(join15(out, PLAYER_FILE));
12541
+ await cp(playerBundle(), join16(out, PLAYER_FILE));
12542
+ files.push(join16(out, PLAYER_FILE));
10514
12543
  }
10515
12544
  files.push(...await vendorKatex(out));
10516
12545
  if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
@@ -10519,7 +12548,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10519
12548
  }
10520
12549
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
10521
12550
  step(
10522
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join15(out, "index.html")}`
12551
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join16(out, "index.html")}`
10523
12552
  );
10524
12553
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
10525
12554
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -10537,65 +12566,65 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
10537
12566
  async function deckRuntime() {
10538
12567
  const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
10539
12568
  try {
10540
- return await readFile16(path2, "utf8");
12569
+ return await readFile17(path2, "utf8");
10541
12570
  } catch {
10542
12571
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
10543
12572
  }
10544
12573
  }
10545
12574
  function playerBundle() {
10546
- const require2 = createRequire2(import.meta.url);
12575
+ const require2 = createRequire3(import.meta.url);
10547
12576
  try {
10548
- return join15(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12577
+ return join16(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
10549
12578
  } catch {
10550
12579
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
10551
12580
  }
10552
12581
  }
10553
12582
  async function vendorKatex(out) {
10554
- const require2 = createRequire2(import.meta.url);
10555
- const dist = join15(dirname4(require2.resolve("katex/package.json")), "dist");
10556
- const css = await readFile16(join15(dist, "katex.min.css"), "utf8");
12583
+ const require2 = createRequire3(import.meta.url);
12584
+ const dist = join16(dirname4(require2.resolve("katex/package.json")), "dist");
12585
+ const css = await readFile17(join16(dist, "katex.min.css"), "utf8");
10557
12586
  const written = [];
10558
- await mkdir9(join15(out, "katex/fonts"), { recursive: true });
10559
- for (const file of await readdir3(join15(dist, "fonts"))) {
12587
+ await mkdir10(join16(out, "katex/fonts"), { recursive: true });
12588
+ for (const file of await readdir4(join16(dist, "fonts"))) {
10560
12589
  if (!file.endsWith(".woff2")) continue;
10561
- await cp(join15(dist, "fonts", file), join15(out, "katex/fonts", file));
10562
- written.push(join15(out, "katex/fonts", file));
12590
+ await cp(join16(dist, "fonts", file), join16(out, "katex/fonts", file));
12591
+ written.push(join16(out, "katex/fonts", file));
10563
12592
  }
10564
12593
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
10565
12594
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
10566
12595
  return kept ? `src:${kept}` : whole;
10567
12596
  });
10568
- await writeFile11(join15(out, "katex/katex.min.css"), woff2Only);
10569
- written.push(join15(out, "katex/katex.min.css"));
12597
+ await writeFile12(join16(out, "katex/katex.min.css"), woff2Only);
12598
+ written.push(join16(out, "katex/katex.min.css"));
10570
12599
  return written;
10571
12600
  }
10572
12601
  async function copyAssets(sourceDir, out) {
10573
- const from = join15(resolve4(sourceDir), "assets");
10574
- if (!await stat(from).catch(() => null)) return [];
10575
- await cp(from, join15(out, "assets"), { recursive: true });
10576
- return [join15(out, "assets")];
12602
+ const from = join16(resolve5(sourceDir), "assets");
12603
+ if (!await stat2(from).catch(() => null)) return [];
12604
+ await cp(from, join16(out, "assets"), { recursive: true });
12605
+ return [join16(out, "assets")];
10577
12606
  }
10578
12607
  async function copyAudio(from, narration, out) {
10579
- const dir = join15(out, narration.dir);
10580
- await mkdir9(dir, { recursive: true });
12608
+ const dir = join16(out, narration.dir);
12609
+ await mkdir10(dir, { recursive: true });
10581
12610
  const names = [
10582
12611
  ...new Set(
10583
12612
  Object.values(narration.beats).flat().map((s) => s.audio)
10584
12613
  )
10585
12614
  ].sort();
10586
12615
  for (const name of names) {
10587
- await cp(join15(resolve4(from), name), join15(dir, name)).catch(() => {
12616
+ await cp(join16(resolve5(from), name), join16(dir, name)).catch(() => {
10588
12617
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
10589
12618
  });
10590
12619
  }
10591
- return names.map((n3) => join15(dir, n3));
12620
+ return names.map((n3) => join16(dir, n3));
10592
12621
  }
10593
12622
  async function refreshFont(storyboard, source, out, step) {
10594
12623
  try {
10595
12624
  const bundle = await bundleFont(
10596
12625
  storyboard.lang,
10597
12626
  JSON.stringify(source) + JSON.stringify(storyboard),
10598
- join15(out, "assets", "fonts")
12627
+ join16(out, "assets", "fonts")
10599
12628
  );
10600
12629
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
10601
12630
  return bundle?.css;
@@ -10636,6 +12665,7 @@ export {
10636
12665
  annotatedFigureParamsSchema,
10637
12666
  assertInsideResolves,
10638
12667
  assertRefsResolve,
12668
+ attachClips,
10639
12669
  barCompareParamsSchema,
10640
12670
  beatRoleSchema,
10641
12671
  beatSchema,
@@ -10659,15 +12689,18 @@ export {
10659
12689
  equationSchema,
10660
12690
  equationWalkParamsSchema,
10661
12691
  fetchFigures,
12692
+ fetchGuarded,
10662
12693
  figureSchema,
10663
12694
  framePlan,
10664
12695
  gradeOverprint,
10665
12696
  gridParamsSchema,
12697
+ harvest,
10666
12698
  hasIllustrations,
10667
12699
  illustrate,
10668
12700
  illustrationSchema,
10669
12701
  imageChain,
10670
12702
  insideSchema,
12703
+ isBlockedAddress,
10671
12704
  isCustom,
10672
12705
  lineChartParamsSchema,
10673
12706
  loadPrefs,
@@ -10724,6 +12757,7 @@ export {
10724
12757
  tempoChain,
10725
12758
  termSchema,
10726
12759
  titleParamsSchema,
12760
+ toMarkdown,
10727
12761
  toSrt,
10728
12762
  verify,
10729
12763
  warnSecondsFor,