@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/mcp.js CHANGED
@@ -5,7 +5,7 @@ import { homedir as homedir3 } from "node:os";
5
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
- import { z as z5 } from "zod";
8
+ import { z as z6 } from "zod";
9
9
 
10
10
  // src/version.ts
11
11
  import { createRequire } from "node:module";
@@ -13,17 +13,232 @@ var VERSION = createRequire(import.meta.url)("../package.json").version;
13
13
 
14
14
  // src/mcp/tools.ts
15
15
  import { randomBytes } from "node:crypto";
16
- import { mkdir as mkdir10, readFile as readFile12 } from "node:fs/promises";
16
+ import { mkdir as mkdir11, mkdtemp as mkdtemp3, readFile as readFile13, rm as rm8 } from "node:fs/promises";
17
17
  import { tmpdir as tmpdir3 } from "node:os";
18
- import { isAbsolute, join as join12, resolve as resolve5, sep as sep2 } from "node:path";
19
- import { z as z4 } from "zod";
18
+ import { basename as basename4, isAbsolute as isAbsolute2, join as join13, resolve as resolve6, sep as sep2 } from "node:path";
19
+ import { zipSync as zipSync2 } from "fflate";
20
+ import { z as z5 } from "zod";
20
21
 
21
22
  // src/index.ts
22
- import { cp, mkdir as mkdir8, readdir, readFile as readFile10, stat, writeFile as writeFile10 } from "node:fs/promises";
23
+ import { cp, mkdir as mkdir9, readdir as readdir2, readFile as readFile11, stat as stat2, writeFile as writeFile11 } from "node:fs/promises";
23
24
  import { createRequire as createRequire2 } from "node:module";
24
- import { dirname as dirname3, join as join10, resolve as resolve3 } from "node:path";
25
+ import { dirname as dirname3, join as join11, resolve as resolve4 } from "node:path";
25
26
  import { fileURLToPath } from "node:url";
26
27
 
28
+ // src/pack/media.ts
29
+ import { createHash } from "node:crypto";
30
+ import { readFile } from "node:fs/promises";
31
+ import { extname } from "node:path";
32
+ var PLAYERS = [
33
+ "youtube.com",
34
+ "youtube-nocookie.com",
35
+ "youtu.be",
36
+ "vimeo.com",
37
+ "dailymotion.com",
38
+ "dai.ly",
39
+ "twitch.tv",
40
+ "loom.com",
41
+ "wistia.com",
42
+ "wistia.net",
43
+ "streamable.com",
44
+ "bilibili.com",
45
+ "soundcloud.com",
46
+ "tiktok.com"
47
+ ];
48
+ var FILE_EXT = /* @__PURE__ */ new Set([
49
+ ".png",
50
+ ".jpg",
51
+ ".jpeg",
52
+ ".gif",
53
+ ".webp",
54
+ ".avif",
55
+ ".svg",
56
+ ".mp4",
57
+ ".webm",
58
+ ".mov",
59
+ ".m4v",
60
+ ".mp3",
61
+ ".m4a",
62
+ ".wav",
63
+ ".ogg",
64
+ ".opus",
65
+ ".pdf"
66
+ ]);
67
+ function isEmbed(url) {
68
+ const host2 = hostOf(url);
69
+ return host2 !== null && PLAYERS.some((p) => host2 === p || host2.endsWith(`.${p}`));
70
+ }
71
+ function segments(u) {
72
+ return u.pathname.split("/").filter(Boolean);
73
+ }
74
+ function token(raw2) {
75
+ return raw2 && /^[A-Za-z0-9_-]{1,64}$/.test(raw2) ? raw2 : void 0;
76
+ }
77
+ var YOUTUBE_EMBED = "https://www.youtube-nocookie.com";
78
+ var youtube = (id2) => id2 === void 0 ? void 0 : `${YOUTUBE_EMBED}/embed/${id2}`;
79
+ var YOUTUBE = {
80
+ origin: YOUTUBE_EMBED,
81
+ embed: (u) => {
82
+ const seg = segments(u);
83
+ const path2 = seg[0];
84
+ return youtube(
85
+ token(
86
+ path2 === "watch" ? u.searchParams.get("v") : path2 === "embed" || path2 === "shorts" || path2 === "live" || path2 === "v" ? seg[1] : void 0
87
+ )
88
+ );
89
+ }
90
+ };
91
+ var YOUTU_BE = {
92
+ origin: YOUTUBE_EMBED,
93
+ embed: (u) => youtube(token(segments(u)[0]))
94
+ };
95
+ var VIMEO_EMBED = "https://player.vimeo.com";
96
+ var VIMEO = {
97
+ origin: VIMEO_EMBED,
98
+ embed: (u) => {
99
+ const seg = segments(u);
100
+ const [head, next] = seg[0] === "video" ? [seg[1], seg[2]] : [seg[0], seg[1]];
101
+ if (head === void 0 || !/^\d+$/.test(head)) return void 0;
102
+ const hash = token(next ?? u.searchParams.get("h"));
103
+ return `${VIMEO_EMBED}/video/${head}?${hash ? `h=${hash}&` : ""}dnt=1`;
104
+ }
105
+ };
106
+ var DAILYMOTION_EMBED = "https://www.dailymotion.com";
107
+ var dailymotion = (id2) => id2 === void 0 ? void 0 : `${DAILYMOTION_EMBED}/embed/video/${id2}`;
108
+ var DAILYMOTION = {
109
+ origin: DAILYMOTION_EMBED,
110
+ embed: (u) => {
111
+ const seg = segments(u);
112
+ return dailymotion(
113
+ token(seg[0] === "video" ? seg[1] : seg[0] === "embed" ? seg[2] : void 0)
114
+ );
115
+ }
116
+ };
117
+ var DAI_LY = {
118
+ origin: DAILYMOTION_EMBED,
119
+ embed: (u) => dailymotion(token(segments(u)[0]))
120
+ };
121
+ var LOOM = {
122
+ origin: "https://www.loom.com",
123
+ embed: (u) => {
124
+ const seg = segments(u);
125
+ const id2 = token(seg[0] === "share" || seg[0] === "embed" ? seg[1] : void 0);
126
+ return id2 === void 0 ? void 0 : `https://www.loom.com/embed/${id2}`;
127
+ }
128
+ };
129
+ var EMBEDS = {
130
+ "youtube.com": YOUTUBE,
131
+ "youtube-nocookie.com": YOUTUBE,
132
+ "youtu.be": YOUTU_BE,
133
+ "vimeo.com": VIMEO,
134
+ "dailymotion.com": DAILYMOTION,
135
+ "dai.ly": DAI_LY,
136
+ "loom.com": LOOM
137
+ };
138
+ var EMBED_ORIGINS = [
139
+ ...new Set(Object.values(EMBEDS).map((e) => e.origin))
140
+ ];
141
+ function embedUrl(url) {
142
+ const host2 = hostOf(url);
143
+ if (host2 === null) return void 0;
144
+ let u;
145
+ try {
146
+ u = new URL(url);
147
+ } catch {
148
+ return void 0;
149
+ }
150
+ if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
151
+ const rule = Object.entries(EMBEDS).find(([h]) => host2 === h || host2.endsWith(`.${h}`))?.[1];
152
+ return rule?.embed(u);
153
+ }
154
+ function policyFor(url, prefer) {
155
+ if (isEmbed(url)) return "embed";
156
+ const host2 = hostOf(url);
157
+ if (host2 === null || /^data:/i.test(url)) return "bake";
158
+ if (prefer === "link") return "link";
159
+ return FILE_EXT.has(extOf(url)) ? "bake" : "link";
160
+ }
161
+ async function planMedia(assets, fetcher = fetchAsset) {
162
+ const plan = {
163
+ media: [],
164
+ files: {},
165
+ bakedCount: 0,
166
+ bakedBytes: 0,
167
+ demoted: [],
168
+ promoted: []
169
+ };
170
+ for (const asset of assets) {
171
+ const policy = policyFor(asset.url, asset.prefer);
172
+ if (policy !== "bake") {
173
+ if (asset.prefer === "bake") plan.demoted.push(asset.id);
174
+ plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
175
+ continue;
176
+ }
177
+ if (asset.prefer === "link") plan.promoted.push(asset.id);
178
+ const got = await fetcher(asset.url);
179
+ const mime = clean(got.mime) ?? asset.mime;
180
+ if (mime === "text/html") {
181
+ plan.demoted.push(asset.id);
182
+ plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
183
+ continue;
184
+ }
185
+ const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
186
+ plan.files[path2] = got.bytes;
187
+ plan.bakedCount += 1;
188
+ plan.bakedBytes += got.bytes.length;
189
+ plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
190
+ }
191
+ return plan;
192
+ }
193
+ function bakedName(id2, url, mime) {
194
+ const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
195
+ const hash = createHash("sha256").update(url).digest("hex").slice(0, 8);
196
+ return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
197
+ }
198
+ function extOf(url) {
199
+ const path2 = hostOf(url) === null ? url : new URL(url).pathname;
200
+ const ext = extname(path2).toLowerCase();
201
+ return FILE_EXT.has(ext) ? ext : "";
202
+ }
203
+ var MIME_EXT = {
204
+ "image/png": ".png",
205
+ "image/jpeg": ".jpg",
206
+ "image/gif": ".gif",
207
+ "image/webp": ".webp",
208
+ "image/avif": ".avif",
209
+ "image/svg+xml": ".svg",
210
+ "video/mp4": ".mp4",
211
+ "video/webm": ".webm",
212
+ "audio/mpeg": ".mp3",
213
+ "application/pdf": ".pdf"
214
+ };
215
+ function extFor(mime) {
216
+ return (mime && MIME_EXT[mime]) ?? ".bin";
217
+ }
218
+ function hostOf(url) {
219
+ try {
220
+ const u = new URL(url);
221
+ if (u.protocol === "file:" || u.protocol === "data:") return null;
222
+ return u.hostname.toLowerCase().replace(/^www\./, "");
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+ function clean(mime) {
228
+ return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
229
+ }
230
+ async function fetchAsset(url) {
231
+ if (/^(https?|data):/i.test(url)) {
232
+ const res = await fetch(url);
233
+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
234
+ return {
235
+ bytes: new Uint8Array(await res.arrayBuffer()),
236
+ mime: res.headers.get("content-type") ?? void 0
237
+ };
238
+ }
239
+ return { bytes: new Uint8Array(await readFile(url)) };
240
+ }
241
+
27
242
  // src/types.ts
28
243
  import { z } from "zod";
29
244
  var refSchema = z.object({
@@ -32,9 +247,34 @@ var refSchema = z.object({
32
247
  });
33
248
  var figureSchema = z.object({
34
249
  id: z.string(),
250
+ /**
251
+ * WHAT THE ASSET IS. A CLIP IS A FIGURE.
252
+ *
253
+ * A harvested page hands back stills and video in one pass, and the obvious
254
+ * shape for the video — a fifth `Source` array and a fifth `refSchema` kind —
255
+ * costs every layer that already knows what a figure is: the inventory
256
+ * `renderSource` prints, `assertRefsResolve`, the archetypes that take a
257
+ * `figureId`, and the pack. All of them would need a second word for "the
258
+ * thing this beat points at", and `refSchema` is a CLOSED enum of four kinds
259
+ * that a fifth entry would put into every stored plan's schema. A clip is a
260
+ * rectangle with intrinsic pixels and a caption that a beat points at, which
261
+ * is what a figure is; what differs is one branch at emit.
262
+ *
263
+ * DEFAULTED rather than required, and that is the whole point of the field
264
+ * being an enum with a default: every `source.json` written before clips
265
+ * existed parses unchanged and comes back an `image`, which is what it has
266
+ * always been.
267
+ */
268
+ kind: z.enum(["image", "clip"]).default("image"),
35
269
  /** Path relative to the deck's asset directory. */
36
270
  src: z.string(),
37
271
  caption: z.string(),
272
+ /**
273
+ * The intrinsic pixel size layout keys off — for a clip, the VIDEO's own
274
+ * dimensions, not the poster's. Every fit, crop and leader-line fraction
275
+ * downstream is expressed against this box, so a clip whose poster was
276
+ * letterboxed to another shape would put every annotation in the wrong place.
277
+ */
38
278
  width: z.int().positive(),
39
279
  height: z.int().positive(),
40
280
  /**
@@ -52,7 +292,26 @@ var figureSchema = z.object({
52
292
  */
53
293
  sectionId: z.string().optional(),
54
294
  /** The sentence or paragraph that refers to it, verbatim from the document. */
55
- mention: z.string().optional()
295
+ mention: z.string().optional(),
296
+ // THE THREE FIELDS ONLY A CLIP USES. All optional, for the same reason `kind`
297
+ // is defaulted: an image carries none of them, and a source written before
298
+ // clips existed parses into exactly the object it always did.
299
+ /**
300
+ * The local still that represents the clip — what the deck shows before
301
+ * anyone presses play, and what stands in wherever the video cannot run at
302
+ * all. A clip whose video could not be downloaded is this still and nothing
303
+ * else, so it is the picture the beat is really planned around.
304
+ */
305
+ poster: z.string().optional(),
306
+ /** How long the clip runs. Seconds, as measured off the file, never guessed. */
307
+ seconds: z.number().positive().optional(),
308
+ /**
309
+ * The page the video lives ON, when the video itself is not a file we can
310
+ * fetch — a player page, an embed, anything whose terms or DRM make the bytes
311
+ * unavailable. Then `poster` is all the deck can show, and this is where a
312
+ * viewer goes to watch the thing. Absent for a clip we hold the file for.
313
+ */
314
+ href: z.string().optional()
56
315
  });
57
316
  var equationSchema = z.object({
58
317
  id: z.string(),
@@ -910,8 +1169,8 @@ function clock(seconds) {
910
1169
  }
911
1170
 
912
1171
  // src/source/fonts.ts
913
- import { createHash } from "node:crypto";
914
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1172
+ import { createHash as createHash2 } from "node:crypto";
1173
+ import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
915
1174
  import { join } from "node:path";
916
1175
  function familyFor(lang) {
917
1176
  const tag = lang.toLowerCase();
@@ -925,11 +1184,11 @@ async function bundleFont(lang, glyphs, dir) {
925
1184
  const family = familyFor(lang);
926
1185
  if (!family) return null;
927
1186
  const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
928
- const stamp = `/* decksmith ${createHash("sha256").update(`${family}
1187
+ const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
929
1188
  ${text2}`).digest("hex").slice(0, 16)} */`;
930
1189
  await mkdir(dir, { recursive: true });
931
1190
  const cssPath = join(dir, "fonts.css");
932
- const cached = await readFile(cssPath, "utf8").catch(() => "");
1191
+ const cached = await readFile2(cssPath, "utf8").catch(() => "");
933
1192
  if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
934
1193
  const res = await fetch(
935
1194
  `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
@@ -1811,11 +2070,11 @@ function unwidow(text2, width, size, weight = 700, face = "latin") {
1811
2070
  function wrapTokens(tokens, size, width, weight, face) {
1812
2071
  const lines = [];
1813
2072
  let line2 = "";
1814
- for (const token of tokens) {
1815
- const candidate = line2 ? `${line2} ${token}` : token;
2073
+ for (const token2 of tokens) {
2074
+ const candidate = line2 ? `${line2} ${token2}` : token2;
1816
2075
  if (line2 && textWidth(candidate, size, weight, 0, false, face) > width) {
1817
2076
  lines.push(line2);
1818
- line2 = token;
2077
+ line2 = token2;
1819
2078
  continue;
1820
2079
  }
1821
2080
  line2 = candidate;
@@ -2165,6 +2424,11 @@ var annotatedFigure = (beat, ctx) => {
2165
2424
  `annotated-figure ${beat.id}: no figure "${p.figureId}" in source ${ctx.source.id}`
2166
2425
  );
2167
2426
  }
2427
+ if (fig.kind === "clip") {
2428
+ throw new Error(
2429
+ `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`
2430
+ );
2431
+ }
2168
2432
  const crop = p.crop;
2169
2433
  const view = crop ? { width: fig.width * crop.w, height: fig.height * crop.h } : { width: fig.width, height: fig.height };
2170
2434
  const clamp3 = (v) => Math.min(1, Math.max(0, v));
@@ -2183,7 +2447,7 @@ var annotatedFigure = (beat, ctx) => {
2183
2447
  }
2184
2448
  const plan = planFigure(STAGE_W, notes, view, budget2, isPortrait(ctx.format), face);
2185
2449
  const stageH = plan.height;
2186
- const plate = {
2450
+ const plate2 = {
2187
2451
  x: plan.img.x - PLATE,
2188
2452
  y: plan.img.y - PLATE,
2189
2453
  w: plan.img.w + 2 * PLATE,
@@ -2337,7 +2601,7 @@ var annotatedFigure = (beat, ctx) => {
2337
2601
  // Per-scene geometry: the overlay is only correct because the image box is
2338
2602
  // stated rather than negotiated with the layout engine.
2339
2603
  `#${sid}-stage{width:${STAGE_W}px;height:${n(stageH)}px;margin-top:${STAGE_GAP}px}`,
2340
- `#${sid}-plate{left:${n(plate.x)}px;top:${n(plate.y)}px;width:${n(plate.w)}px;height:${n(plate.h)}px}`,
2604
+ `#${sid}-plate{left:${n(plate2.x)}px;top:${n(plate2.y)}px;width:${n(plate2.w)}px;height:${n(plate2.h)}px}`,
2341
2605
  // `overflow:visible` because a dot sitting on the figure's own edge puts its
2342
2606
  // halo outside the viewBox, and a clipped halo reads as a rendering fault.
2343
2607
  `#${sid}-ov{position:absolute;left:0;top:0;overflow:visible}`,
@@ -2793,6 +3057,31 @@ var CLAIM_LH = 1.5;
2793
3057
  var CLAIM_RULE = 6 + 32;
2794
3058
  var BESIDE_COL = 560;
2795
3059
  var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
3060
+ function plate(fig, sid, beatId, start) {
3061
+ const img = (src) => ({
3062
+ html: `<img src="assets/${esc(src)}" alt="${esc(fig.caption)}" />`,
3063
+ el: "img"
3064
+ });
3065
+ if (fig.kind !== "clip") return img(fig.src);
3066
+ if (fig.href !== void 0) {
3067
+ if (fig.poster === void 0) {
3068
+ throw new Error(
3069
+ `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`
3070
+ );
3071
+ }
3072
+ return img(fig.poster);
3073
+ }
3074
+ if (start === void 0) {
3075
+ throw new Error(
3076
+ `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`
3077
+ );
3078
+ }
3079
+ const poster = fig.poster === void 0 ? "" : ` poster="assets/${esc(fig.poster)}"`;
3080
+ return {
3081
+ html: `<video id="${sid}-v" src="assets/${esc(fig.src)}"${poster} data-start="${start}" preload="auto" playsinline muted></video>`,
3082
+ el: "video"
3083
+ };
3084
+ }
2796
3085
  var claimFigure = (beat, ctx) => {
2797
3086
  const { sid, theme } = ctx;
2798
3087
  const p = beat.params;
@@ -2832,7 +3121,8 @@ var claimFigure = (beat, ctx) => {
2832
3121
  );
2833
3122
  }
2834
3123
  const claim = `<div class="claim" id="${sid}-c">${words(p.claim)}</div>`;
2835
- const figure = `<div class="figwrap" id="${sid}-f"><img src="assets/${esc(fig.src)}" alt="${esc(fig.caption)}" /></div>`;
3124
+ const held = plate(fig, sid, beat.id, ctx.start);
3125
+ const figure = `<div class="figwrap" id="${sid}-f">${held.html}</div>`;
2836
3126
  const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
2837
3127
  const body = tall ? `<div class="cf-stack">${claim}
2838
3128
  <div>${figure}
@@ -2900,12 +3190,22 @@ ${body}`,
2900
3190
  // Height-capped rather than width-driven: a square figure in the beside
2901
3191
  // layout would otherwise be ~970px tall and run off the canvas. The cap is
2902
3192
  // the measured remainder, not a constant — see `figMax`.
2903
- `.figwrap img{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
3193
+ //
3194
+ // NAMED FOR THE TAG THAT IS ACTUALLY THERE rather than written for both.
3195
+ // A rule listing `img, video` would move the bytes of every deck we have
3196
+ // ever built to describe an element almost none of them contain, and a
3197
+ // rule naming only `img` over a clip is a cap that silently does not
3198
+ // apply — the video would render at its natural 1920x1080 and run off the
3199
+ // canvas, which is invariant-5 territory that no gate reads.
3200
+ `.figwrap ${held.el}{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
2904
3201
  `.caption{font-size:${BODY_SIZE}px;line-height:${BODY_LH};color:${theme.dim};margin-top:16px}`,
2905
3202
  // The image, not its wrapper: the wrapper's entrance already writes
2906
3203
  // `transform`. 1.2% of the 550px cap is 3.3px a side, which the wrapper's
2907
- // 16px padding absorbs — the swell can never reach the canvas edge.
2908
- ambient(sid, "-f img", DRIFT)
3204
+ // 16px padding absorbs — the swell can never reach the canvas edge. Same
3205
+ // reason as the cap above for naming the tag: a drift rule aimed at `img`
3206
+ // over a `<video>` is one ambient rule that animates nothing, and the
3207
+ // slide reads as dead rather than as held.
3208
+ ambient(sid, `-f ${held.el}`, DRIFT)
2909
3209
  ].join("\n")
2910
3210
  };
2911
3211
  };
@@ -3025,8 +3325,8 @@ var dataTable = (beat, ctx) => {
3025
3325
  p.highlight.forEach((h, i) => {
3026
3326
  const index = shown.findIndex((row) => row[0] === h.row);
3027
3327
  if (index < 0) {
3028
- const why = 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}`;
3029
- throw new Error(`data-table ${beat.id}: ${why}`);
3328
+ 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}`;
3329
+ throw new Error(`data-table ${beat.id}: ${why2}`);
3030
3330
  }
3031
3331
  const at = settled + 0.3 + i * step;
3032
3332
  tl.push(
@@ -3341,14 +3641,14 @@ var MORPH_SECONDS = 1.6;
3341
3641
  var equationMorph = (beat, ctx) => {
3342
3642
  const { sid, theme } = ctx;
3343
3643
  const p = beat.params;
3344
- const find2 = (id2) => {
3644
+ const find3 = (id2) => {
3345
3645
  const eq = ctx.source.equations.find((e) => e.id === id2);
3346
3646
  if (!eq)
3347
3647
  throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3348
3648
  return eq;
3349
3649
  };
3350
- const a = find2(p.fromId);
3351
- const b = find2(p.toId);
3650
+ const a = find3(p.fromId);
3651
+ const b = find3(p.toId);
3352
3652
  const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3353
3653
  if (both.length === 0) {
3354
3654
  throw new Error(
@@ -3558,10 +3858,10 @@ var grid = (beat, ctx) => {
3558
3858
  h: r.h * y0.w + (r.h - 1) * f.gap + 2 * bleed
3559
3859
  };
3560
3860
  };
3561
- const boxes = p.regions.map(boxOf);
3861
+ const boxes3 = p.regions.map(boxOf);
3562
3862
  const lx = W - gutter + 40;
3563
3863
  const outside = p.regions.map((r, i) => ({ r, i })).filter(({ i }) => inGutter[i]).map(({ r, i }) => {
3564
- const b = boxes[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3864
+ const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3565
3865
  const half = lines(r) * LABEL * LH / 2;
3566
3866
  return { i, half, y: b.y + b.h / 2, from: { x: b.x + b.w, y: b.y + b.h / 2 } };
3567
3867
  }).sort((a, b) => a.y - b.y || a.i - b.i);
@@ -3579,7 +3879,7 @@ var grid = (beat, ctx) => {
3579
3879
  const parts = {};
3580
3880
  p.regions.forEach((r, i) => {
3581
3881
  parts[`rgn${i}`] = r.label;
3582
- const b = boxes[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3882
+ const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
3583
3883
  const tone2 = theme.tones[r.tone];
3584
3884
  rects.push(
3585
3885
  `<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, {
@@ -3687,7 +3987,7 @@ var grid = (beat, ctx) => {
3687
3987
  tl.push(...dimCells);
3688
3988
  p.regions.forEach((_, i) => {
3689
3989
  const at = first + i * step;
3690
- const b = boxes[i] ?? { x: 0, y: 0, w: 0, h: 0 };
3990
+ const b = boxes3[i] ?? { x: 0, y: 0, w: 0, h: 0 };
3691
3991
  tl.push(
3692
3992
  tween(
3693
3993
  `#${id(sid, "rgn", i)}`,
@@ -4087,12 +4387,12 @@ function balance(label, k, face) {
4087
4387
  if (cur.length > 0) lines.push(cur.join(" "));
4088
4388
  return lines;
4089
4389
  }
4090
- function centre(boxes, i) {
4091
- const b = boxes[i];
4390
+ function centre(boxes3, i) {
4391
+ const b = boxes3[i];
4092
4392
  return b ? b.x + b.w / 2 : 0;
4093
4393
  }
4094
- function loopLabelWidth(boxes, from, to, stageW) {
4095
- const mid = (centre(boxes, from) + centre(boxes, to)) / 2;
4394
+ function loopLabelWidth(boxes3, from, to, stageW) {
4395
+ const mid = (centre(boxes3, from) + centre(boxes3, to)) / 2;
4096
4396
  return Math.max(NOTE * 6, Math.min(stageW - 2 * M - 80, 2 * Math.min(mid, stageW - mid) - 40));
4097
4397
  }
4098
4398
  function widest(lines, face) {
@@ -4125,11 +4425,11 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
4125
4425
  let fit = solve(stages, capped, stageW, face);
4126
4426
  if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
4127
4427
  const size = Math.max(MIN_FONT, Math.floor(fit.size));
4128
- const boxes = fit.boxes;
4129
- const innerW = Math.max(size, (boxes[0]?.w ?? width) - 2 * PAD_X_EM * size);
4428
+ const boxes3 = fit.boxes;
4429
+ const innerW = Math.max(size, (boxes3[0]?.w ?? width) - 2 * PAD_X_EM * size);
4130
4430
  const labelLines = stages.map((s) => wrap(s.label, size, innerW, 600, 0, face));
4131
4431
  const noteLines = stages.map((s) => s.note ? wrap(s.note, NOTE, innerW, 400, 0, face) : []);
4132
- const boxW = boxes[0]?.w ?? width;
4432
+ const boxW = boxes3[0]?.w ?? width;
4133
4433
  const need = Math.max(
4134
4434
  MIN_BOX_H,
4135
4435
  Math.min(MAX_BOX_H, boxW * BOX_ASPECT),
@@ -4139,15 +4439,15 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
4139
4439
  return Math.ceil(2 * PAD_Y2 + label + (note > 0 ? NOTE_TOP + note * NOTE * NOTE_LH : 0));
4140
4440
  })
4141
4441
  );
4142
- const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes, loop.from, loop.to, stageW), 500, 0, face) : [];
4442
+ const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes3, loop.from, loop.to, stageW), 500, 0, face) : [];
4143
4443
  const below = loop ? LOOP_TOP + LOOP_LABEL_TOP + loopLines.length * NOTE * NOTE_LH + LOOP_BOTTOM : M;
4144
4444
  const boxH = need;
4145
4445
  return {
4146
4446
  size,
4147
- boxes,
4447
+ boxes: boxes3,
4148
4448
  boxH,
4149
4449
  boxX: M,
4150
- boxW: boxes[0]?.w ?? width,
4450
+ boxW: boxes3[0]?.w ?? width,
4151
4451
  vertical: false,
4152
4452
  innerW,
4153
4453
  labelLines,
@@ -4475,6 +4775,11 @@ var splitCompare = (beat, ctx) => {
4475
4775
  if (fig.width <= 0 || fig.height <= 0) {
4476
4776
  throw new Error(`split-compare ${beat.id}: figure "${fig.id}" has no usable dimensions`);
4477
4777
  }
4778
+ if (fig.kind === "clip") {
4779
+ throw new Error(
4780
+ `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`
4781
+ );
4782
+ }
4478
4783
  return fig;
4479
4784
  });
4480
4785
  sides.forEach((side, i) => {
@@ -4773,9 +5078,9 @@ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
4773
5078
  function stackLayout(p, format, face = "latin") {
4774
5079
  const stacked = solve2(p, format, false, face);
4775
5080
  if (stacked.fits || !p.layers.some((l) => l.note)) return stacked;
4776
- const inline = solve2(p, format, true, face);
4777
- if (inline.fits) return inline;
4778
- return inline.wide && inline.blockH < stacked.blockH ? inline : stacked;
5081
+ const inline2 = solve2(p, format, true, face);
5082
+ if (inline2.fits) return inline2;
5083
+ return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4779
5084
  }
4780
5085
  function labelWeight(i, count) {
4781
5086
  return i === count - 1 ? 700 : 600;
@@ -4784,7 +5089,7 @@ function floorFor(p, format) {
4784
5089
  if (!p.tilt) return MIN_FONT;
4785
5090
  return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
4786
5091
  }
4787
- function solve2(p, format, inline, face) {
5092
+ function solve2(p, format, inline2, face) {
4788
5093
  const width = contentW(format);
4789
5094
  const boxH = contentH(format);
4790
5095
  const floor = floorFor(p, format);
@@ -4793,15 +5098,15 @@ function solve2(p, format, inline, face) {
4793
5098
  const riseMax = RISE_MAX[k];
4794
5099
  const syMax = SY_MAX[k];
4795
5100
  const tMax = T_MAX[k];
4796
- const noteW = (l) => inline && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
5101
+ const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4797
5102
  const want = Math.max(
4798
5103
  ...p.layers.map(
4799
5104
  (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
4800
5105
  )
4801
5106
  );
4802
- const colCap = inline ? width * 0.56 : width * 0.5;
5107
+ const colCap = inline2 ? width * 0.56 : width * 0.5;
4803
5108
  const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
4804
- const wide = !inline || Math.ceil(want) + 12 <= colCap;
5109
+ const wide = !inline2 || Math.ceil(want) + 12 <= colCap;
4805
5110
  const labelX = width - colW;
4806
5111
  const labelRoom = Math.min(
4807
5112
  ...p.layers.map(
@@ -4816,14 +5121,14 @@ function solve2(p, format, inline, face) {
4816
5121
  label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4817
5122
  // Inline notes stay on one line by contract — the schema calls a note "one
4818
5123
  // short line" — and wrapping one would put its second line under the label.
4819
- note: l.note === void 0 ? [] : inline ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
5124
+ note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
4820
5125
  noteW: nw,
4821
5126
  labelMaxW
4822
5127
  };
4823
5128
  });
4824
5129
  const blockH = Math.max(
4825
5130
  ...lines.map(
4826
- (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)
5131
+ (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)
4827
5132
  )
4828
5133
  );
4829
5134
  const pad = Math.max(EDGE2, blockH / 2);
@@ -4845,7 +5150,7 @@ function solve2(p, format, inline, face) {
4845
5150
  fits: room >= blockH + 10 && height <= free && wide,
4846
5151
  floor,
4847
5152
  wide,
4848
- inline,
5153
+ inline: inline2,
4849
5154
  width,
4850
5155
  height,
4851
5156
  avail,
@@ -5249,7 +5554,8 @@ function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
5249
5554
  format,
5250
5555
  laid.slides,
5251
5556
  runtimeJs,
5252
- narrationIsland(opts.narration, laid.spoken)
5557
+ narrationIsland(opts.narration, laid.spoken),
5558
+ videoIsland(laid.embeds)
5253
5559
  )
5254
5560
  };
5255
5561
  }
@@ -5260,17 +5566,20 @@ function planCut(storyboard, source, format, opts = {}) {
5260
5566
  const seconds = {};
5261
5567
  const undrawable = /* @__PURE__ */ new Set();
5262
5568
  floor.forEach((beat, i) => {
5263
- const segments = opts.narration?.beats[beat.id];
5569
+ const segments2 = opts.narration?.beats[beat.id];
5264
5570
  let scene;
5265
5571
  try {
5266
- ({ scene } = stageScene(emitScene(beat, { source, format, theme, sid: `s${i + 1}` }), speed));
5572
+ ({ scene } = stageScene(
5573
+ emitScene(beat, { source, format, theme, sid: `s${i + 1}`, start: 0 }),
5574
+ speed
5575
+ ));
5267
5576
  } catch (err) {
5268
5577
  if (!opts.onBeatError) throw err;
5269
5578
  opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
5270
5579
  undrawable.add(beat.id);
5271
5580
  return;
5272
5581
  }
5273
- seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments);
5582
+ seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments2);
5274
5583
  });
5275
5584
  if (floor.length > 0 && undrawable.size === floor.length) {
5276
5585
  throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
@@ -5303,31 +5612,29 @@ function layout(storyboard, source, format, opts = {}) {
5303
5612
  const scenes = [];
5304
5613
  const slides = [];
5305
5614
  const spoken = {};
5615
+ const embeds = {};
5306
5616
  const entered = enteredParts(beats);
5617
+ let at = 0;
5307
5618
  const cuts = beats.map((beat, i) => {
5308
5619
  const sid = `s${i + 1}`;
5309
- const ctx = { source, format, theme, sid };
5310
- const segments = opts.narration?.beats[beat.id];
5620
+ const ctx = { source, format, theme, sid, start: rnd(at) };
5621
+ const segments2 = opts.narration?.beats[beat.id];
5311
5622
  const { scene } = stageScene(emitScene(beat, ctx), speed);
5312
- const seconds = beatSeconds(beat.seconds * speed, scene, segments);
5623
+ const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
5313
5624
  const inside = entered[i];
5314
5625
  const dive = inside ? { t0: rnd(seconds), dur: rnd(MOVE_SECONDS * speed), fade: rnd(FADE_SECONDS * speed) } : void 0;
5315
- return {
5316
- beat,
5317
- sid,
5318
- scene,
5319
- segments,
5320
- inside,
5321
- dive,
5322
- duration: dive ? seconds + diveTail(dive) : seconds
5323
- };
5626
+ const duration = dive ? seconds + diveTail(dive) : seconds;
5627
+ const start = at;
5628
+ at += duration;
5629
+ return { beat, sid, scene, segments: segments2, inside, dive, duration, start };
5324
5630
  });
5325
- let start = 0;
5326
5631
  let builds = false;
5327
5632
  const plugins = /* @__PURE__ */ new Set();
5328
5633
  cuts.forEach((cut2, i) => {
5329
- const { beat, sid, dive, inside, duration } = cut2;
5634
+ const { beat, sid, dive, inside, duration, start } = cut2;
5330
5635
  if (cut2.segments?.length) spoken[sid] = cut2.segments;
5636
+ const embed = playerEmbed(beat, source);
5637
+ if (embed) embeds[sid] = embed;
5331
5638
  const next = cuts[i + 1];
5332
5639
  const over = next ? rnd(Math.min(HANDOFF_SECONDS * speed, next.duration)) : 0;
5333
5640
  let scene = cut2.scene;
@@ -5357,7 +5664,6 @@ function layout(storyboard, source, format, opts = {}) {
5357
5664
  notes: beat.narration ?? beat.intent,
5358
5665
  holds: scene.holds
5359
5666
  });
5360
- start += duration;
5361
5667
  });
5362
5668
  return {
5363
5669
  family,
@@ -5369,7 +5675,11 @@ function layout(storyboard, source, format, opts = {}) {
5369
5675
  scenes,
5370
5676
  slides,
5371
5677
  spoken,
5372
- total: start,
5678
+ embeds,
5679
+ // The clock after the last scene: the sum the map above finished with, which
5680
+ // is the number the second pass used to arrive at by re-adding the same
5681
+ // durations in the same order.
5682
+ total: at,
5373
5683
  cut,
5374
5684
  builds,
5375
5685
  plugins
@@ -5420,23 +5730,23 @@ function openSeconds(scene) {
5420
5730
  const first = scene.holds.filter((h) => Number.isFinite(h) && h > 0).sort((a, b) => a - b)[0];
5421
5731
  return rnd(first ?? 0);
5422
5732
  }
5423
- function beatSeconds(authored, scene, segments) {
5424
- if (!segments?.length) return authored;
5733
+ function beatSeconds(authored, scene, segments2) {
5734
+ if (!segments2?.length) return authored;
5425
5735
  const lastHold = scene.holds.reduce((a, b) => Math.max(a, b), 0);
5426
5736
  const usable = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
5427
5737
  (a, b) => a - b
5428
5738
  );
5429
- const ends = speechPlan(openSeconds(scene), usable, segments).end;
5739
+ const ends = speechPlan(openSeconds(scene), usable, segments2).end;
5430
5740
  return Math.max(authored, lastHold + SETTLE_SECONDS, ends + SETTLE_SECONDS);
5431
5741
  }
5432
5742
  function stageScene(scene, speed) {
5433
5743
  const paced = pace(scene, speed);
5434
5744
  return { scene: paced, open: openSeconds(paced) };
5435
5745
  }
5436
- function speechPlan(open, holds, segments) {
5746
+ function speechPlan(open, holds, segments2) {
5437
5747
  const starts = [];
5438
5748
  let at = open;
5439
- for (const [i, segment] of segments.entries()) {
5749
+ for (const [i, segment] of segments2.entries()) {
5440
5750
  const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
5441
5751
  if (i > 0) at = Math.max(at, hold);
5442
5752
  starts.push(at);
@@ -5528,9 +5838,9 @@ function narrationIsland(narration, scenes) {
5528
5838
  voice: narration.voice,
5529
5839
  dir: narration.dir,
5530
5840
  scenes: Object.fromEntries(
5531
- Object.entries(scenes).map(([sid, segments]) => [
5841
+ Object.entries(scenes).map(([sid, segments2]) => [
5532
5842
  sid,
5533
- segments.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
5843
+ segments2.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
5534
5844
  ])
5535
5845
  )
5536
5846
  };
@@ -5540,7 +5850,22 @@ function narrationIsland(narration, scenes) {
5540
5850
  ${json}
5541
5851
  </script>`;
5542
5852
  }
5543
- function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
5853
+ function playerEmbed(beat, source) {
5854
+ if (beat.archetype !== "claim-figure") return void 0;
5855
+ const fig = source.figures.find((f) => f.id === beat.params.figureId);
5856
+ if (fig?.kind !== "clip" || fig.href === void 0) return void 0;
5857
+ const url = embedUrl(fig.href);
5858
+ return url === void 0 ? void 0 : { url, title: fig.caption };
5859
+ }
5860
+ function videoIsland(clips) {
5861
+ if (Object.keys(clips).length === 0) return "";
5862
+ const json = JSON.stringify({ scenes: clips }, null, 2).replace(/</g, "\\u003c");
5863
+ return `
5864
+ <script type="application/decksmith-video+json">
5865
+ ${json}
5866
+ </script>`;
5867
+ }
5868
+ function emitDeckPage(storyboard, format, slides, runtimeJs, narration, video) {
5544
5869
  return `<!doctype html>
5545
5870
  <html lang="${esc(storyboard.lang)}">
5546
5871
  <head>
@@ -5559,7 +5884,7 @@ function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
5559
5884
  width="${format.width}"
5560
5885
  height="${format.height}"
5561
5886
  ></hyperframes-player>
5562
- ${emitIsland(slides)}${narration}
5887
+ ${emitIsland(slides)}${narration}${video}
5563
5888
  <script>
5564
5889
  ${closeSafe(runtimeJs)}
5565
5890
  </script>
@@ -5711,7 +6036,7 @@ function readFragments(html) {
5711
6036
  return out;
5712
6037
  }
5713
6038
  function holdsFor(beat, source, format, theme, sid, speed) {
5714
- const ctx = { source, format, theme, sid };
6039
+ const ctx = { source, format, theme, sid, start: 0 };
5715
6040
  const { scene, open } = stageScene(emitScene(beat, ctx), speed);
5716
6041
  return {
5717
6042
  holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
@@ -5735,15 +6060,15 @@ function assertHoldsAgree(scenes, fragments) {
5735
6060
  function place(scenes, spoken) {
5736
6061
  const out = [];
5737
6062
  for (const scene of scenes) {
5738
- const segments = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
5739
- if (segments.length === 0) continue;
6063
+ const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
6064
+ if (segments2.length === 0) continue;
5740
6065
  if (scene.holds.length === 0) {
5741
6066
  throw new Error(
5742
6067
  `${scene.id}: narrated but has no hold to speak at. Re-run \`narrate\` for this format.`
5743
6068
  );
5744
6069
  }
5745
- const { starts } = speechPlan(scene.open, scene.holds, segments);
5746
- for (const [i, segment] of segments.entries()) {
6070
+ const { starts } = speechPlan(scene.open, scene.holds, segments2);
6071
+ for (const [i, segment] of segments2.entries()) {
5747
6072
  const index = Math.min(segment.stop, scene.holds.length - 1);
5748
6073
  out.push({
5749
6074
  id: `${scene.id}.${segment.stop}`,
@@ -5759,9 +6084,9 @@ function place(scenes, spoken) {
5759
6084
  }
5760
6085
  return out;
5761
6086
  }
5762
- function assertFits(scenes, segments) {
6087
+ function assertFits(scenes, segments2) {
5763
6088
  for (const scene of scenes) {
5764
- const mine = segments.filter((s) => s.scene === scene.id);
6089
+ const mine = segments2.filter((s) => s.scene === scene.id);
5765
6090
  if (mine.length === 0) continue;
5766
6091
  const spoken = mine.reduce((sum, s) => sum + s.duration, 0);
5767
6092
  const need = scene.open + spoken;
@@ -5805,8 +6130,8 @@ function planTiming(input) {
5805
6130
  const staging = holdsFor(beat, source, format, theme, scene.id, speed);
5806
6131
  scene.holds = staging.holds;
5807
6132
  scene.open = staging.open;
5808
- const segments2 = narration?.beats[beat.id];
5809
- if (segments2?.length) spoken[scene.id] = segments2;
6133
+ const segments3 = narration?.beats[beat.id];
6134
+ if (segments3?.length) spoken[scene.id] = segments3;
5810
6135
  });
5811
6136
  const fragments = readFragments(composition);
5812
6137
  if (fragments) {
@@ -5815,18 +6140,18 @@ function planTiming(input) {
5815
6140
  fragments
5816
6141
  );
5817
6142
  }
5818
- const segments = place(scenes, spoken);
5819
- assertFits(scenes, segments);
6143
+ const segments2 = place(scenes, spoken);
6144
+ assertFits(scenes, segments2);
5820
6145
  return {
5821
6146
  version: 1,
5822
6147
  width: format.width,
5823
6148
  height: format.height,
5824
6149
  duration: readDuration(composition),
5825
6150
  lang: storyboard.lang,
5826
- audioDir: segments.length > 0 ? narration?.dir ?? "" : "",
5827
- voice: segments.length > 0 ? narration?.voice ?? "" : "",
6151
+ audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
6152
+ voice: segments2.length > 0 ? narration?.voice ?? "" : "",
5828
6153
  scenes,
5829
- segments
6154
+ segments: segments2
5830
6155
  };
5831
6156
  }
5832
6157
  function framePlan(timing, fps) {
@@ -5838,12 +6163,12 @@ function framePlan(timing, fps) {
5838
6163
  for (const scene of timing.scenes) {
5839
6164
  const first = f(scene.start);
5840
6165
  const last = f(scene.start + scene.duration);
5841
- const segments = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
5842
- if (segments.length === 0) {
6166
+ const segments2 = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
6167
+ if (segments2.length === 0) {
5843
6168
  pieces.push({ from: first, motion: last - first, freeze: 0 });
5844
6169
  continue;
5845
6170
  }
5846
- const tail = segments[segments.length - 1];
6171
+ const tail = segments2[segments2.length - 1];
5847
6172
  const silent = f(tail.start + tail.duration);
5848
6173
  if (silent > last) {
5849
6174
  throw new Error(
@@ -5851,7 +6176,7 @@ function framePlan(timing, fps) {
5851
6176
  );
5852
6177
  }
5853
6178
  pieces.push({ from: first, motion: last - first, freeze: 0 });
5854
- for (const segment of segments) {
6179
+ for (const segment of segments2) {
5855
6180
  const at = f(segment.start);
5856
6181
  audio.push({
5857
6182
  id: segment.id,
@@ -5924,7 +6249,7 @@ function round5(n3) {
5924
6249
  }
5925
6250
 
5926
6251
  // src/source/markdown.ts
5927
- import { createHash as createHash2 } from "node:crypto";
6252
+ import { createHash as createHash3 } from "node:crypto";
5928
6253
  import remarkGfm from "remark-gfm";
5929
6254
  import remarkMath from "remark-math";
5930
6255
  import remarkParse from "remark-parse";
@@ -5976,6 +6301,9 @@ function parseMarkdown(md, opts = {}) {
5976
6301
  for (const img of images) {
5977
6302
  const figure = {
5978
6303
  id: `fig${figures.length + 1}`,
6304
+ // Markdown carries no video: an image node is an image. A clip only
6305
+ // ever enters through `harvest`, which sets this itself.
6306
+ kind: "image",
5979
6307
  src: img.url,
5980
6308
  caption: caption ?? img.alt ?? "",
5981
6309
  // 1x1 until assets.ts reads the actual bytes. The schema has no "unknown",
@@ -6011,7 +6339,7 @@ function parseMarkdown(md, opts = {}) {
6011
6339
  if (mention) p.figure.mention = mention;
6012
6340
  }
6013
6341
  return sourceSchema.parse({
6014
- id: opts.id ?? createHash2("sha256").update(md).digest("hex").slice(0, 12),
6342
+ id: opts.id ?? createHash3("sha256").update(md).digest("hex").slice(0, 12),
6015
6343
  title: title2 || "Untitled",
6016
6344
  lang: opts.lang ?? sniffLang(md),
6017
6345
  sections,
@@ -6020,10 +6348,14 @@ function parseMarkdown(md, opts = {}) {
6020
6348
  tables
6021
6349
  });
6022
6350
  }
6351
+ var SCRIPT_SHARE = 0.02;
6023
6352
  function sniffLang(md) {
6024
- if (/[가-힣]/.test(md)) return "ko";
6025
- if (/[぀-ヿ]/.test(md)) return "ja";
6026
- if (/[一-鿿]/.test(md)) return "zh";
6353
+ const total = md.replace(/\s+/g, "").length;
6354
+ if (total === 0) return "en";
6355
+ const share2 = (re) => (md.match(re)?.length ?? 0) / total;
6356
+ if (share2(/[가-힣]/g) >= SCRIPT_SHARE) return "ko";
6357
+ if (share2(/[぀-ヿ]/g) >= SCRIPT_SHARE) return "ja";
6358
+ if (share2(/[一-鿿]/g) >= SCRIPT_SHARE) return "zh";
6027
6359
  return "en";
6028
6360
  }
6029
6361
  function onlyImages(kids) {
@@ -6044,7 +6376,11 @@ function mentionOf(caption, prose, at, opened) {
6044
6376
  const found = before ?? prose.find((p) => p.at > at && name.test(p.text));
6045
6377
  if (found) return found.text;
6046
6378
  }
6047
- return prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
6379
+ const near = prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
6380
+ return near !== void 0 && isSentence(near) ? near : void 0;
6381
+ }
6382
+ function isSentence(text2) {
6383
+ return (text2.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3;
6048
6384
  }
6049
6385
  function figureName(caption) {
6050
6386
  const match = /^\s*(fig(?:ure)?\.?|그림|図|图)\s*([0-9]+)/i.exec(caption);
@@ -6104,91 +6440,2022 @@ function textOf(node) {
6104
6440
  }
6105
6441
  }
6106
6442
 
6107
- // src/source/assets.ts
6108
- import { createHash as createHash3 } from "node:crypto";
6109
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
6110
- import { extname, join as join2 } from "node:path";
6111
- async function fetchFigures(source, dir) {
6112
- await mkdir2(dir, { recursive: true });
6113
- const figures = [];
6114
- for (const figure of source.figures) {
6115
- const name = assetName(figure.id, figure.src);
6116
- const bytes = await cache(join2(dir, name), figure.src);
6117
- figures.push({ ...figure, src: name, ...imageSize(bytes) });
6118
- }
6119
- return sourceSchema.parse({ ...source, figures });
6120
- }
6121
- function assetName(id2, src) {
6122
- const ext = extname(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
6123
- return `${id2}-${createHash3("sha256").update(src).digest("hex").slice(0, 8)}${ext || ".img"}`;
6124
- }
6125
- async function cache(path2, src) {
6126
- const hit = await readFile2(path2).catch(() => null);
6127
- if (hit) return hit;
6128
- let bytes;
6129
- if (/^https?:/i.test(src)) {
6130
- const res = await fetch(src);
6131
- if (!res.ok) throw new Error(`${src}: HTTP ${res.status}`);
6132
- bytes = Buffer.from(await res.arrayBuffer());
6133
- } else {
6134
- bytes = await readFile2(src);
6443
+ // src/source/harvest.ts
6444
+ import { createHash as createHash5 } from "node:crypto";
6445
+ import { copyFile, mkdir as mkdir3, rm as rm2, stat, writeFile as writeFile3 } from "node:fs/promises";
6446
+ import { basename as basename2, join as join4, resolve } from "node:path";
6447
+
6448
+ // src/net/fetch.ts
6449
+ import { lookup as resolveHost } from "node:dns/promises";
6450
+ import { request as httpRequest } from "node:http";
6451
+ import { request as httpsRequest } from "node:https";
6452
+ import { isIP } from "node:net";
6453
+ import { brotliDecompressSync, gunzipSync } from "node:zlib";
6454
+ var BLOCKED_V4 = [
6455
+ // "This network". Already in the original list as /^0\./, which is 0.0.0.0/8.
6456
+ { re: /^0\./, why: "the unspecified network (0.0.0.0/8)" },
6457
+ { re: /^10\./, why: "a private address (10.0.0.0/8)" },
6458
+ // NEW: carrier-grade NAT. A mobile or datacentre network hands these out, and
6459
+ // on a host that has one, the whole /10 is the neighbourhood.
6460
+ {
6461
+ re: /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
6462
+ why: "carrier-grade NAT space (100.64.0.0/10)"
6463
+ },
6464
+ { re: /^127\./, why: "loopback (127.0.0.0/8)" },
6465
+ { re: /^169\.254\./, why: "link-local, where the cloud metadata service lives (169.254.0.0/16)" },
6466
+ { re: /^172\.(1[6-9]|2\d|3[01])\./, why: "a private address (172.16.0.0/12)" },
6467
+ // NEW: IETF protocol assignments — 192.0.0.8, the NAT64 well-known prefix and
6468
+ // friends. Not routable on the public internet, so a URL pointing here is
6469
+ // pointing at something local.
6470
+ { re: /^192\.0\.0\./, why: "IETF protocol assignment space (192.0.0.0/24)" },
6471
+ { re: /^192\.168\./, why: "a private address (192.168.0.0/16)" },
6472
+ // NEW: benchmarking. Reserved for test equipment, and some networks route it
6473
+ // internally precisely because it will never collide with anything real.
6474
+ { re: /^198\.1[89]\./, why: "benchmarking space (198.18.0.0/15)" }
6475
+ ];
6476
+ var USER_AGENT = `DeckSmith/${VERSION} (+https://github.com/ca1773130n/DeckSmith)`;
6477
+ function isBlockedAddress(ip) {
6478
+ const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
6479
+ const kind = isIP(bare);
6480
+ if (kind === 0) return `not an IP address (${ip})`;
6481
+ if (kind === 4) {
6482
+ for (const { re, why: why2 } of BLOCKED_V4) if (re.test(bare)) return why2;
6483
+ return null;
6135
6484
  }
6136
- await writeFile2(path2, bytes);
6137
- return bytes;
6138
- }
6139
- function imageSize(b) {
6140
- if (b.length >= 24 && b.readUInt32BE(0) === 2303741511)
6141
- return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
6142
- if (b.length >= 10 && b.toString("latin1", 0, 4) === "GIF8")
6143
- return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
6144
- if (b.length >= 4 && b.readUInt16BE(0) === 65496) return jpegSize(b);
6145
- throw new Error("unrecognised image header (expected PNG, JPEG or GIF)");
6485
+ const groups = expandV6(bare);
6486
+ if (groups === null) return `not an IP address (${ip})`;
6487
+ if (groups.every((n3) => n3 === 0)) return "the unspecified address (::)";
6488
+ if (groups.slice(0, 7).every((n3) => n3 === 0) && groups[7] === 1) return "IPv6 loopback (::1)";
6489
+ const embedded = groups.slice(0, 5).every((n3) => n3 === 0) && (groups[5] === 65535 || groups[5] === 0);
6490
+ if (embedded) {
6491
+ const hi = groups[6] ?? 0;
6492
+ const lo = groups[7] ?? 0;
6493
+ return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
6494
+ }
6495
+ const top = groups[0] ?? 0;
6496
+ if ((top & 65472) === 65152) return "IPv6 link-local (fe80::/10)";
6497
+ if ((top & 65024) === 64512) return "IPv6 unique-local (fc00::/7)";
6498
+ return null;
6146
6499
  }
6147
- function jpegSize(b) {
6148
- let i = 2;
6149
- while (i + 9 < b.length) {
6150
- if (b[i] !== 255) {
6151
- i++;
6152
- continue;
6500
+ function expandV6(v6) {
6501
+ let text2 = v6;
6502
+ const quad = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text2);
6503
+ if (quad?.[1]) {
6504
+ const [a = 0, b = 0, c = 0, d = 0] = quad[1].split(".").map(Number);
6505
+ const head = text2.slice(0, text2.length - quad[1].length);
6506
+ text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
6507
+ }
6508
+ const halves = text2.split("::");
6509
+ if (halves.length > 2) return null;
6510
+ const left = halves[0] ? halves[0].split(":") : [];
6511
+ const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
6512
+ const fill = 8 - left.length - (right?.length ?? 0);
6513
+ if (right !== null && fill < 0) return null;
6514
+ const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
6515
+ if (parts.length !== 8) return null;
6516
+ const groups = parts.map((p) => Number.parseInt(p, 16));
6517
+ return groups.every((n3) => Number.isInteger(n3) && n3 >= 0 && n3 <= 65535) ? groups : null;
6518
+ }
6519
+ function isLoopback(ip) {
6520
+ const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
6521
+ return /^127\./.test(bare) || bare === "::1" || /^::ffff:127\./.test(bare);
6522
+ }
6523
+ var MAX_REDIRECTS = 5;
6524
+ var WEB_PORTS = /* @__PURE__ */ new Set([80, 443]);
6525
+ var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
6526
+ var CREDENTIALS = ["authorization", "cookie"];
6527
+ async function fetchGuarded(url, opts) {
6528
+ const signal = AbortSignal.timeout(opts.timeoutMs);
6529
+ let target;
6530
+ try {
6531
+ target = new URL(url);
6532
+ } catch {
6533
+ throw new Error(`refusing ${url}: not a URL. Give an absolute http:// or https:// URL.`);
6534
+ }
6535
+ let headers = lowercased(opts.headers);
6536
+ for (let hop = 0; ; hop++) {
6537
+ if (hop > MAX_REDIRECTS) {
6538
+ throw new Error(
6539
+ `refusing ${url}: more than ${MAX_REDIRECTS} redirects, last to ${target.href}. Link the final URL directly.`
6540
+ );
6153
6541
  }
6154
- const marker = b[i + 1];
6155
- if (marker === void 0) break;
6156
- if (marker === 255) {
6157
- i++;
6542
+ const res = await send(url, target, headers, signal, opts);
6543
+ const status = res.statusCode ?? 0;
6544
+ if (REDIRECTS.has(status)) {
6545
+ const location = res.headers.location;
6546
+ res.destroy();
6547
+ if (location === void 0) {
6548
+ throw new Error(`refusing ${url}: HTTP ${status} from ${target.href} with no Location.`);
6549
+ }
6550
+ let next;
6551
+ try {
6552
+ next = new URL(location, target);
6553
+ } catch {
6554
+ throw new Error(
6555
+ `refusing ${url}: HTTP ${status} to an unparseable Location (${location}).`
6556
+ );
6557
+ }
6558
+ const sameOrigin = next.protocol === target.protocol && next.hostname === target.hostname && next.port === target.port;
6559
+ if (!sameOrigin) headers = withoutCredentials(headers);
6560
+ target = next;
6158
6561
  continue;
6159
6562
  }
6160
- if (marker === 1 || marker >= 208 && marker <= 217) {
6161
- i += 2;
6162
- continue;
6563
+ if (status < 200 || status >= 300) {
6564
+ res.destroy();
6565
+ throw new Error(`${target.href}: HTTP ${status}`);
6163
6566
  }
6164
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
6165
- return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
6166
- i += 2 + b.readUInt16BE(i + 2);
6567
+ const contentType = (res.headers["content-type"] ?? "").trim();
6568
+ const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
6569
+ if (opts.accept && media.match(opts.accept) === null) {
6570
+ res.destroy();
6571
+ throw new Error(
6572
+ `refusing ${url}: ${target.href} served ${media || "no Content-Type"}, which does not match ${opts.accept}. Link the file itself, not a page about it.`
6573
+ );
6574
+ }
6575
+ const raw2 = await readCapped(res, opts.maxBytes, url, signal, opts.timeoutMs);
6576
+ const bytes = decode(raw2, res.headers["content-encoding"], opts.maxBytes, url);
6577
+ return { bytes, contentType, url: target.href };
6167
6578
  }
6168
- throw new Error("JPEG has no SOF segment");
6169
- }
6170
-
6171
- // src/plan/codex.ts
6172
- import { spawn } from "node:child_process";
6173
- import { mkdtemp, readFile as readFile3, rm, writeFile as writeFile3 } from "node:fs/promises";
6174
- import { tmpdir } from "node:os";
6175
- import { join as join3 } from "node:path";
6176
- import { z as z2 } from "zod";
6177
-
6178
- // src/plan/arc.ts
6179
- var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
6180
- function requiredRoles(beatCount) {
6181
- if (beatCount >= 8) return ARC_ROLES;
6182
- if (beatCount >= 5) return ["limitations", "conclusion"];
6183
- return [];
6184
- }
6185
- function paperArcRequested(prefs) {
6186
- return prefs.genre === "paper";
6187
6579
  }
6188
-
6189
- // src/plan/duration.ts
6190
- var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
6191
- var LAST_HOLD_SECONDS = 4.2;
6580
+ async function send(url, target, headers, signal, opts) {
6581
+ const secure = target.protocol === "https:";
6582
+ if (!secure && target.protocol !== "http:") {
6583
+ throw new Error(
6584
+ `refusing ${url}: ${target.protocol}// is not http or https (${target.href}). Only web URLs are followed.`
6585
+ );
6586
+ }
6587
+ const port = Number(target.port || (secure ? 443 : 80));
6588
+ if (!WEB_PORTS.has(port) && opts.allowLoopback !== true) {
6589
+ throw new Error(
6590
+ `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.`
6591
+ );
6592
+ }
6593
+ const host2 = target.hostname.replace(/^\[|\]$/g, "");
6594
+ let addresses;
6595
+ try {
6596
+ addresses = await Promise.race([resolveHost(host2, { all: true }), rejectsOnAbort(signal)]);
6597
+ } catch {
6598
+ if (signal.aborted) throw timedOut(url, opts.timeoutMs);
6599
+ throw new Error(`refusing ${url}: ${host2} does not resolve.`);
6600
+ }
6601
+ const pinned = addresses[0];
6602
+ if (pinned === void 0) throw new Error(`refusing ${url}: ${host2} does not resolve.`);
6603
+ for (const { address } of addresses) {
6604
+ const why2 = isBlockedAddress(address);
6605
+ if (why2 !== null && !(opts.allowLoopback === true && isLoopback(address))) {
6606
+ throw new Error(
6607
+ `refusing ${url}: ${host2} resolves to ${address}, which is ${why2}. Host the file somewhere this server can reach from the public internet.`
6608
+ );
6609
+ }
6610
+ }
6611
+ const options = {
6612
+ hostname: host2,
6613
+ port,
6614
+ path: `${target.pathname}${target.search}`,
6615
+ method: "GET",
6616
+ // IDENTIFY OURSELVES, AND ACCEPT HTML.
6617
+ //
6618
+ // Measured against the first real page this was ever pointed at: Wikipedia
6619
+ // answers a request with no `user-agent` with a bare 403, and so do a great
6620
+ // many sites behind a CDN. A fetcher that will not say who it is looks
6621
+ // exactly like a scraper worth blocking, and the failure arrives as an
6622
+ // HTTP status with nothing in it to explain itself. Both headers are
6623
+ // DEFAULTS rather than overrides — a caller that sets either wins, because
6624
+ // the spread below them is the caller's.
6625
+ headers: {
6626
+ "user-agent": USER_AGENT,
6627
+ accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5",
6628
+ ...headers,
6629
+ "accept-encoding": "gzip, br"
6630
+ },
6631
+ signal,
6632
+ // THE PIN. Without this line every check above is advisory: `dns.lookup`
6633
+ // would run a second time inside the connect and could answer differently.
6634
+ lookup: pin(pinned.address, pinned.family),
6635
+ // No connection pool. This is a one-shot against a stranger's host, and a
6636
+ // pooled socket outlives the call that validated it — nothing should be able
6637
+ // to inherit a connection this module opened.
6638
+ agent: false,
6639
+ // Say out loud which name the certificate has to match. Node derives this
6640
+ // from `hostname` today, but the pin means the socket is opened to a bare
6641
+ // address, and a future refactor that stops setting `hostname` would turn
6642
+ // verification off silently rather than fail.
6643
+ ...secure && isIP(host2) === 0 ? { servername: host2 } : {}
6644
+ };
6645
+ try {
6646
+ return await new Promise((resolve7, reject) => {
6647
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
6648
+ req.on("error", reject);
6649
+ req.end();
6650
+ });
6651
+ } catch (err) {
6652
+ if (signal.aborted) throw timedOut(url, opts.timeoutMs);
6653
+ throw new Error(`refusing ${url}: ${target.href} could not be reached (${message(err)}).`);
6654
+ }
6655
+ }
6656
+ function pin(address, family) {
6657
+ return (_hostname, options, callback) => {
6658
+ if (options.all) callback(null, [{ address, family }]);
6659
+ else callback(null, address, family);
6660
+ };
6661
+ }
6662
+ async function readCapped(res, maxBytes, url, signal, timeoutMs) {
6663
+ const chunks = [];
6664
+ let total = 0;
6665
+ let oversize = false;
6666
+ try {
6667
+ for await (const chunk of res) {
6668
+ total += chunk.length;
6669
+ if (total > maxBytes) {
6670
+ oversize = true;
6671
+ res.destroy();
6672
+ break;
6673
+ }
6674
+ chunks.push(chunk);
6675
+ }
6676
+ } catch (err) {
6677
+ if (signal.aborted) throw timedOut(url, timeoutMs);
6678
+ throw new Error(`refusing ${url}: the body stopped arriving (${message(err)}).`);
6679
+ }
6680
+ if (oversize) {
6681
+ throw new Error(
6682
+ `refusing ${url}: body is larger than ${maxBytes} bytes. Link a smaller file, or raise maxBytes if this one is expected.`
6683
+ );
6684
+ }
6685
+ return Buffer.concat(chunks);
6686
+ }
6687
+ function decode(raw2, header, maxBytes, url) {
6688
+ const encoding = (header ?? "").trim().toLowerCase();
6689
+ if (encoding === "" || encoding === "identity") return raw2;
6690
+ try {
6691
+ if (encoding === "gzip" || encoding === "x-gzip") {
6692
+ return gunzipSync(raw2, { maxOutputLength: maxBytes });
6693
+ }
6694
+ if (encoding === "br") return brotliDecompressSync(raw2, { maxOutputLength: maxBytes });
6695
+ } catch (err) {
6696
+ throw new Error(
6697
+ `refusing ${url}: ${encoding} body does not decompress within ${maxBytes} bytes (${message(err)}). Serve it uncompressed, or raise maxBytes.`
6698
+ );
6699
+ }
6700
+ throw new Error(
6701
+ `refusing ${url}: Content-Encoding "${encoding}" was never offered \u2014 this request asked for gzip or br. Serve one of those, or no encoding.`
6702
+ );
6703
+ }
6704
+ function lowercased(headers) {
6705
+ const out = {};
6706
+ for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
6707
+ return out;
6708
+ }
6709
+ function withoutCredentials(headers) {
6710
+ const out = { ...headers };
6711
+ for (const name of CREDENTIALS) delete out[name];
6712
+ return out;
6713
+ }
6714
+ function rejectsOnAbort(signal) {
6715
+ return new Promise((_, reject) => {
6716
+ if (signal.aborted) reject(signal.reason);
6717
+ else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
6718
+ });
6719
+ }
6720
+ function timedOut(url, timeoutMs) {
6721
+ return new Error(
6722
+ `refusing ${url}: nothing completed within ${timeoutMs}ms. Raise timeoutMs, or use a URL that answers faster.`
6723
+ );
6724
+ }
6725
+ function message(err) {
6726
+ return err instanceof Error ? err.message : String(err);
6727
+ }
6728
+
6729
+ // src/render/capture.ts
6730
+ import { homedir } from "node:os";
6731
+ import { join as join2 } from "node:path";
6732
+ async function chromePath(need = "open the deck with") {
6733
+ const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
6734
+ if (explicit) return explicit;
6735
+ const { getInstalledBrowsers } = await import("@puppeteer/browsers");
6736
+ const cacheDir = process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer");
6737
+ const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
6738
+ const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
6739
+ if (found) return found.executablePath;
6740
+ throw new Error(
6741
+ `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
6742
+ );
6743
+ }
6744
+
6745
+ // src/source/assets.ts
6746
+ import { createHash as createHash4 } from "node:crypto";
6747
+ import { mkdir as mkdir2, readdir, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
6748
+ import { extname as extname2, join as join3 } from "node:path";
6749
+ import { z as z2 } from "zod";
6750
+ async function fetchFigures(source, dir, warnings) {
6751
+ const drops = warnings ?? [];
6752
+ await mkdir2(dir, { recursive: true });
6753
+ const cached = await readdir(dir).catch(() => []);
6754
+ const figures = [];
6755
+ for (const figure of source.figures) {
6756
+ if (figure.kind === "clip") {
6757
+ figures.push(figure);
6758
+ continue;
6759
+ }
6760
+ try {
6761
+ figures.push(figureSchema.parse(await localize(figure, dir, cached)));
6762
+ } catch (err) {
6763
+ drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6764
+ }
6765
+ }
6766
+ if (!warnings) for (const drop of drops) console.warn(`decksmith: ${drop}`);
6767
+ const parsed = sourceSchema.safeParse({ ...source, figures });
6768
+ if (!parsed.success) {
6769
+ throw new Error(
6770
+ `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.`
6771
+ );
6772
+ }
6773
+ return parsed.data;
6774
+ }
6775
+ async function localize(figure, dir, cached) {
6776
+ const stem = assetStem(figure.id, figure.src);
6777
+ const hit = cached.find((name) => name.startsWith(`${stem}.`));
6778
+ const bytes = hit ? await readFile3(join3(dir, hit)) : await load(figure.src);
6779
+ const size = imageSize(bytes);
6780
+ if (hit) return { ...figure, src: hit, ...size };
6781
+ const src = `${stem}${assetExt(bytes, figure.src)}`;
6782
+ await writeFile2(join3(dir, src), bytes);
6783
+ cached.push(src);
6784
+ return { ...figure, src, ...size };
6785
+ }
6786
+ function assetStem(id2, src) {
6787
+ const readable = id2.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+/, "") || "figure";
6788
+ return `${readable}-${createHash4("sha256").update(src).digest("hex").slice(0, 8)}`;
6789
+ }
6790
+ var EXT = {
6791
+ png: ".png",
6792
+ jpeg: ".jpg",
6793
+ gif: ".gif",
6794
+ webp: ".webp",
6795
+ avif: ".avif",
6796
+ svg: ".svg"
6797
+ };
6798
+ function assetExt(bytes, src) {
6799
+ const format = sniffFormat(bytes);
6800
+ if (format) return EXT[format];
6801
+ const fromUrl = extname2(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
6802
+ return fromUrl || ".img";
6803
+ }
6804
+ var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
6805
+ var FIGURE_TIMEOUT_MS = 2e4;
6806
+ async function load(src) {
6807
+ if (!/^https?:/i.test(src)) return readFile3(src);
6808
+ const got = await fetchGuarded(src, {
6809
+ maxBytes: FIGURE_MAX_BYTES,
6810
+ timeoutMs: FIGURE_TIMEOUT_MS
6811
+ });
6812
+ return got.bytes;
6813
+ }
6814
+ function reason(err) {
6815
+ if (err instanceof z2.ZodError)
6816
+ return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
6817
+ return err instanceof Error ? err.message : String(err);
6818
+ }
6819
+ var SVG_WINDOW = 64 * 1024;
6820
+ function sniffFormat(b) {
6821
+ if (b.length >= 8 && b.readUInt32BE(0) === 2303741511 && b.readUInt32BE(4) === 218765834)
6822
+ return "png";
6823
+ if (b.length >= 6 && b.toString("latin1", 0, 4) === "GIF8") return "gif";
6824
+ if (b.length >= 2 && b.readUInt16BE(0) === 65496) return "jpeg";
6825
+ if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP")
6826
+ return "webp";
6827
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp" && isAvifBrand(b)) return "avif";
6828
+ if (isSvg(b)) return "svg";
6829
+ return void 0;
6830
+ }
6831
+ function isAvifBrand(b) {
6832
+ const declared = b.readUInt32BE(0);
6833
+ const end = Math.min(declared >= 16 ? declared : b.length, b.length);
6834
+ const brand = (at) => b.toString("latin1", at, at + 4);
6835
+ if (brand(8) === "avif" || brand(8) === "avis") return true;
6836
+ for (let i = 16; i + 4 <= end; i += 4)
6837
+ if (brand(i) === "avif" || brand(i) === "avis") return true;
6838
+ return false;
6839
+ }
6840
+ function isSvg(b) {
6841
+ const text2 = b.toString("utf8", 0, Math.min(b.length, SVG_WINDOW)).replace(/^\uFEFF/, "");
6842
+ let i = 0;
6843
+ for (; ; ) {
6844
+ while (i < text2.length && /\s/.test(text2.charAt(i))) i++;
6845
+ const comment = text2.startsWith("<!--", i);
6846
+ if (text2.startsWith("<?", i) || comment || /^<!doctype\s+svg\b/i.test(text2.slice(i, i + 20))) {
6847
+ const end = comment ? text2.indexOf("-->", i) + 3 : text2.indexOf(">", i) + 1;
6848
+ if (end <= 0) return false;
6849
+ i = end;
6850
+ continue;
6851
+ }
6852
+ return /^<svg[\s/>]/i.test(text2.slice(i, i + 5));
6853
+ }
6854
+ }
6855
+ function imageSize(b) {
6856
+ const format = sniffFormat(b);
6857
+ const raw2 = measure2(b, format);
6858
+ const width = Math.round(raw2.width);
6859
+ const height = Math.round(raw2.height);
6860
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
6861
+ throw new Error(`${format ?? "image"} header claims ${raw2.width}x${raw2.height}, not a size`);
6862
+ return { width, height };
6863
+ }
6864
+ function measure2(b, format) {
6865
+ switch (format) {
6866
+ case "png":
6867
+ return pngSize(b);
6868
+ case "gif":
6869
+ return gifSize(b);
6870
+ case "jpeg":
6871
+ return jpegSize(b);
6872
+ case "webp":
6873
+ return webpSize(b);
6874
+ case "avif":
6875
+ return avifSize(b);
6876
+ case "svg":
6877
+ return svgFigureSize(b);
6878
+ default:
6879
+ throw new Error(
6880
+ `unrecognised image header (expected PNG, JPEG, GIF, WebP, AVIF or SVG; got ${describe(b)})`
6881
+ );
6882
+ }
6883
+ }
6884
+ function describe(b) {
6885
+ if (b.length === 0) return "an empty file";
6886
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp")
6887
+ return `an ISO-BMFF file branded "${b.toString("latin1", 8, 12)}" \u2014 HEIC and its relatives are not drawn by the renderer`;
6888
+ const head = b.toString("latin1", 0, Math.min(b.length, 64));
6889
+ if (/^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(head))
6890
+ return "an HTML page \u2014 the URL answered with a page, not a picture";
6891
+ if (head.startsWith("%PDF")) return "a PDF";
6892
+ const hex = [...b.subarray(0, 8)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
6893
+ return `${b.length} bytes beginning ${hex}`;
6894
+ }
6895
+ function pngSize(b) {
6896
+ if (b.length < 24) throw new Error(`PNG is truncated: ${b.length} bytes, IHDR ends at 24`);
6897
+ if (b.toString("latin1", 12, 16) !== "IHDR") throw new Error("PNG does not open with IHDR");
6898
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
6899
+ }
6900
+ function gifSize(b) {
6901
+ if (b.length < 10)
6902
+ throw new Error(`GIF is truncated: ${b.length} bytes, the screen descriptor ends at 10`);
6903
+ return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
6904
+ }
6905
+ function jpegSize(b) {
6906
+ let i = 2;
6907
+ while (i + 9 < b.length) {
6908
+ if (b[i] !== 255) {
6909
+ i++;
6910
+ continue;
6911
+ }
6912
+ const marker = b[i + 1];
6913
+ if (marker === void 0) break;
6914
+ if (marker === 255) {
6915
+ i++;
6916
+ continue;
6917
+ }
6918
+ if (marker === 1 || marker >= 208 && marker <= 217) {
6919
+ i += 2;
6920
+ continue;
6921
+ }
6922
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
6923
+ return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
6924
+ i += 2 + b.readUInt16BE(i + 2);
6925
+ }
6926
+ throw new Error("JPEG has no SOF segment");
6927
+ }
6928
+ function webpSize(b) {
6929
+ const chunk = b.length >= 16 ? b.toString("latin1", 12, 16) : "";
6930
+ if (chunk === "VP8 ") {
6931
+ if (b.length < 30) throw new Error(`WebP VP8 chunk is truncated: ${b.length} bytes, needs 30`);
6932
+ if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42)
6933
+ throw new Error("WebP VP8 chunk has no keyframe sync code");
6934
+ return { width: b.readUInt16LE(26) & 16383, height: b.readUInt16LE(28) & 16383 };
6935
+ }
6936
+ if (chunk === "VP8L") {
6937
+ if (b.length < 25) throw new Error(`WebP VP8L chunk is truncated: ${b.length} bytes, needs 25`);
6938
+ if (b[20] !== 47) throw new Error("WebP VP8L chunk has no 0x2f signature");
6939
+ const bits = b.readUInt32LE(21);
6940
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
6941
+ }
6942
+ if (chunk === "VP8X") {
6943
+ if (b.length < 30) throw new Error(`WebP VP8X chunk is truncated: ${b.length} bytes, needs 30`);
6944
+ return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
6945
+ }
6946
+ throw new Error(`WebP opens with chunk "${chunk}", not one of VP8 , VP8L, VP8X`);
6947
+ }
6948
+ function avifSize(b) {
6949
+ const meta = find(b, 0, b.length, "meta");
6950
+ if (!meta) throw new Error("AVIF has no meta box (truncated, or not an image item)");
6951
+ const iprp = find(b, meta.start + 4, meta.end, "iprp");
6952
+ const ipco = iprp && find(b, iprp.start, iprp.end, "ipco");
6953
+ if (!ipco) throw new Error("AVIF has no ipco box (no item properties to read a size from)");
6954
+ let best = { width: 0, height: 0 };
6955
+ for (const box of boxes(b, ipco.start, ipco.end)) {
6956
+ if (box.type !== "ispe" || box.end - box.start < 12) continue;
6957
+ const found = { width: b.readUInt32BE(box.start + 4), height: b.readUInt32BE(box.start + 8) };
6958
+ if (found.width * found.height > best.width * best.height) best = found;
6959
+ }
6960
+ if (best.width < 1 || best.height < 1) throw new Error("AVIF has no usable ispe property");
6961
+ return cleanAperture(b, ipco, best) ?? best;
6962
+ }
6963
+ function cleanAperture(b, ipco, ispe) {
6964
+ for (const box of boxes(b, ipco.start, ipco.end)) {
6965
+ if (box.type !== "clap" || box.end - box.start < 16) continue;
6966
+ const at = (offset) => b.readUInt32BE(box.start + offset);
6967
+ if (at(4) === 0 || at(12) === 0) continue;
6968
+ const width = Math.round(at(0) / at(4));
6969
+ const height = Math.round(at(8) / at(12));
6970
+ if (width > ispe.width || height > ispe.height) continue;
6971
+ if (width * 2 < ispe.width || height * 2 < ispe.height) continue;
6972
+ return { width, height };
6973
+ }
6974
+ return void 0;
6975
+ }
6976
+ function find(b, from, to, type) {
6977
+ for (const box of boxes(b, from, to)) if (box.type === type) return box;
6978
+ return void 0;
6979
+ }
6980
+ function* boxes(b, from, to) {
6981
+ let i = from;
6982
+ while (i + 8 <= to) {
6983
+ let size = b.readUInt32BE(i);
6984
+ const type = b.toString("latin1", i + 4, i + 8);
6985
+ let start = i + 8;
6986
+ if (size === 1) {
6987
+ if (i + 16 > to) return;
6988
+ const large = b.readBigUInt64BE(i + 8);
6989
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return;
6990
+ size = Number(large);
6991
+ start = i + 16;
6992
+ } else if (size === 0) {
6993
+ size = to - i;
6994
+ }
6995
+ if (size < start - i || i + size > to) return;
6996
+ yield { type, start, end: i + size };
6997
+ i += size;
6998
+ }
6999
+ }
7000
+ function svgFigureSize(b) {
7001
+ const moving = /<(?:animate|animateTransform|animateMotion|set|script)\b|@keyframes\b/i.exec(
7002
+ b.toString("utf8")
7003
+ );
7004
+ if (moving)
7005
+ throw new Error(
7006
+ `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.`
7007
+ );
7008
+ return svgSize(b);
7009
+ }
7010
+ function svgSize(bytes) {
7011
+ const text2 = bytes.toString("utf8", 0, Math.min(bytes.length, SVG_WINDOW));
7012
+ const tag = /<svg\b[^>]*>/i.exec(text2)?.[0];
7013
+ if (!tag) throw new Error(`SVG root element is not closed within ${SVG_WINDOW} bytes`);
7014
+ const width = cssPixels(attrOf(tag, "width"));
7015
+ const height = cssPixels(attrOf(tag, "height"));
7016
+ if (width !== void 0 && height !== void 0) return { width, height };
7017
+ const box = attrOf(tag, "viewBox")?.trim().split(/[\s,]+/).map(Number);
7018
+ const w = box?.[2];
7019
+ const h = box?.[3];
7020
+ if (box?.length === 4 && w !== void 0 && h !== void 0 && w > 0 && h > 0)
7021
+ return { width: w, height: h };
7022
+ throw new Error("SVG declares no absolute width and height, and no usable viewBox");
7023
+ }
7024
+ function attrOf(tag, name) {
7025
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
7026
+ return m?.[1] ?? m?.[2];
7027
+ }
7028
+ var UNIT = {
7029
+ "": 1,
7030
+ px: 1,
7031
+ pt: 96 / 72,
7032
+ pc: 16,
7033
+ in: 96,
7034
+ cm: 96 / 2.54,
7035
+ mm: 96 / 25.4,
7036
+ q: 96 / 101.6,
7037
+ em: 16,
7038
+ rem: 16
7039
+ };
7040
+ function cssPixels(value) {
7041
+ if (value === void 0) return void 0;
7042
+ const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)\s*$/i.exec(value);
7043
+ if (!m?.[1]) return void 0;
7044
+ const scale = UNIT[(m[2] ?? "").toLowerCase()];
7045
+ if (scale === void 0) return void 0;
7046
+ const px = Number(m[1]) * scale;
7047
+ return px > 0 ? px : void 0;
7048
+ }
7049
+
7050
+ // src/source/readability.ts
7051
+ var CONTENT_MARKER = "data-ds-content";
7052
+ function readContentRegion() {
7053
+ const MARKER = "data-ds-content";
7054
+ const MIN_TEXT = 250;
7055
+ const MAX_ANCESTORS = 5;
7056
+ const SIBLING_FRACTION = 0.2;
7057
+ const MIN_SIBLING_SCORE = 10;
7058
+ const MIN_PARAGRAPH = 25;
7059
+ 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;
7060
+ const MAYBE = /and|article|body|column|content|main|shadow|story/i;
7061
+ const POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|post|story|text|blog/i;
7062
+ 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;
7063
+ const CHROME_ROLES = /* @__PURE__ */ new Set([
7064
+ "alert",
7065
+ "alertdialog",
7066
+ "banner",
7067
+ "complementary",
7068
+ "contentinfo",
7069
+ "dialog",
7070
+ "menu",
7071
+ "menubar",
7072
+ "navigation",
7073
+ "search",
7074
+ "toolbar",
7075
+ "tooltip"
7076
+ ]);
7077
+ const CHROME_TAGS = /* @__PURE__ */ new Set([
7078
+ "nav",
7079
+ "aside",
7080
+ "footer",
7081
+ "script",
7082
+ "style",
7083
+ "noscript",
7084
+ "form",
7085
+ "svg",
7086
+ "canvas",
7087
+ "template",
7088
+ "button",
7089
+ "select",
7090
+ "textarea"
7091
+ ]);
7092
+ const SCORED = "p, td, pre, section, h2, h3, h4, h5, h6";
7093
+ function textOf2(node) {
7094
+ return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
7095
+ }
7096
+ function labelOf(el) {
7097
+ return `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`;
7098
+ }
7099
+ function nameOf(el) {
7100
+ const id2 = el.getAttribute("id");
7101
+ const cls = (el.getAttribute("class") ?? "").trim().split(/\s+/)[0];
7102
+ return el.tagName.toLowerCase() + (id2 ? `#${id2}` : "") + (cls ? `.${cls}` : "");
7103
+ }
7104
+ function pathOf(el) {
7105
+ const parts = [];
7106
+ let node = el;
7107
+ while (node && parts.length < 3) {
7108
+ parts.unshift(nameOf(node));
7109
+ node = node.parentElement;
7110
+ }
7111
+ return parts.join(" > ");
7112
+ }
7113
+ function round6(n3) {
7114
+ return Math.round(n3 * 1e3) / 1e3;
7115
+ }
7116
+ function linkDensity(el) {
7117
+ const total = textOf2(el).length;
7118
+ if (total === 0) return 0;
7119
+ let inLinks = 0;
7120
+ for (const a of el.querySelectorAll("a")) {
7121
+ const href = a.getAttribute("href") ?? "";
7122
+ inLinks += textOf2(a).length * (href.startsWith("#") ? 0.3 : 1);
7123
+ }
7124
+ return inLinks / total;
7125
+ }
7126
+ function classWeight(el) {
7127
+ let weight = 0;
7128
+ for (const label of [el.getAttribute("class") ?? "", el.getAttribute("id") ?? ""]) {
7129
+ if (label === "") continue;
7130
+ if (NEGATIVE.test(label)) weight -= 25;
7131
+ if (POSITIVE.test(label)) weight += 25;
7132
+ }
7133
+ return weight;
7134
+ }
7135
+ function tagBonus(tag) {
7136
+ if (tag === "div") return 5;
7137
+ if (tag === "pre" || tag === "td" || tag === "blockquote") return 3;
7138
+ if (tag === "th" || /^h[1-6]$/.test(tag)) return -5;
7139
+ if (/^(address|ol|ul|dl|dd|dt|li|form)$/.test(tag)) return -3;
7140
+ return 0;
7141
+ }
7142
+ const scores = /* @__PURE__ */ new Map();
7143
+ function scoreOf(el) {
7144
+ const at = scores.get(el);
7145
+ if (at !== void 0) return at;
7146
+ const start = tagBonus(el.tagName.toLowerCase()) + classWeight(el);
7147
+ scores.set(el, start);
7148
+ return start;
7149
+ }
7150
+ const removed = [];
7151
+ function strip(el) {
7152
+ const parent = el.parentElement;
7153
+ if (!parent) return;
7154
+ removed.push({ node: el, parent, next: el.nextSibling });
7155
+ el.remove();
7156
+ }
7157
+ function restore() {
7158
+ for (let i = removed.length - 1; i >= 0; i--) {
7159
+ const at = removed[i];
7160
+ if (at) at.parent.insertBefore(at.node, at.next);
7161
+ }
7162
+ removed.length = 0;
7163
+ }
7164
+ function declined(reason3, candidates2) {
7165
+ restore();
7166
+ return {
7167
+ marked: false,
7168
+ reason: reason3,
7169
+ candidates: candidates2,
7170
+ merged: 0,
7171
+ stripped: 0,
7172
+ text: 0,
7173
+ mediaDropped: 0
7174
+ };
7175
+ }
7176
+ try {
7177
+ if (!document.body) {
7178
+ return declined("the document has no <body>, so there is no region to choose", []);
7179
+ }
7180
+ const mediaBefore = document.querySelectorAll("video, iframe").length;
7181
+ for (const el of [...document.body.querySelectorAll("*")]) {
7182
+ if (!el.isConnected) continue;
7183
+ const tag = el.tagName.toLowerCase();
7184
+ if (CHROME_TAGS.has(tag)) {
7185
+ strip(el);
7186
+ continue;
7187
+ }
7188
+ if (tag === "header" && el.parentElement?.closest("article, main, section") == null) {
7189
+ strip(el);
7190
+ continue;
7191
+ }
7192
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
7193
+ strip(el);
7194
+ continue;
7195
+ }
7196
+ if (CHROME_ROLES.has((el.getAttribute("role") ?? "").toLowerCase())) {
7197
+ strip(el);
7198
+ continue;
7199
+ }
7200
+ const label = labelOf(el);
7201
+ if (UNLIKELY.test(label) && !MAYBE.test(label) && !el.querySelector("main, article")) {
7202
+ strip(el);
7203
+ }
7204
+ }
7205
+ for (const el of document.body.querySelectorAll(SCORED)) {
7206
+ const text3 = textOf2(el);
7207
+ if (text3.length < MIN_PARAGRAPH) continue;
7208
+ const base = 1 + (text3.split(",").length - 1) + Math.min(Math.floor(text3.length / 100), 3);
7209
+ let node = el.parentElement;
7210
+ let level = 0;
7211
+ while (node && node !== document.documentElement && level < MAX_ANCESTORS) {
7212
+ const divider = level === 0 ? 1 : level === 1 ? 2 : level * 3;
7213
+ scores.set(node, scoreOf(node) + base / divider);
7214
+ node = node.parentElement;
7215
+ level++;
7216
+ }
7217
+ }
7218
+ const ranked = [];
7219
+ for (const [el, content] of scores) {
7220
+ const density = linkDensity(el);
7221
+ ranked.push({
7222
+ el,
7223
+ report: {
7224
+ path: pathOf(el),
7225
+ content: round6(content),
7226
+ linkDensity: round6(density),
7227
+ // THE WHOLE POINT OF THE PASS. A rail of related headlines and an
7228
+ // article of the same length hold the same number of characters; only
7229
+ // this term separates them, and it is why the discount is a
7230
+ // multiplier rather than a subtraction — a candidate that is entirely
7231
+ // links scores zero however long it is.
7232
+ score: round6(content * (1 - density)),
7233
+ text: textOf2(el).length
7234
+ }
7235
+ });
7236
+ }
7237
+ ranked.sort((a, b) => b.report.score - a.report.score);
7238
+ const report2 = ranked.slice(0, 5).map((r) => r.report);
7239
+ let top = ranked[0];
7240
+ if (!top) {
7241
+ return declined(
7242
+ `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.`,
7243
+ report2
7244
+ );
7245
+ }
7246
+ const byElement = new Map(ranked.map((r) => [r.el, r]));
7247
+ const floor = top.report.score / 3;
7248
+ let last = top.report.score;
7249
+ let up = top.el.parentElement;
7250
+ while (up && up !== document.body && up !== document.documentElement) {
7251
+ const here = byElement.get(up);
7252
+ if (here) {
7253
+ if (here.report.score < floor) break;
7254
+ if (here.report.score > last) {
7255
+ top = here;
7256
+ break;
7257
+ }
7258
+ last = here.report.score;
7259
+ }
7260
+ up = up.parentElement;
7261
+ }
7262
+ if (top.el === document.body) {
7263
+ const text3 = textOf2(document.body).length;
7264
+ if (text3 < MIN_TEXT) {
7265
+ return declined(
7266
+ `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`,
7267
+ report2
7268
+ );
7269
+ }
7270
+ document.body.setAttribute(MARKER, "");
7271
+ return {
7272
+ marked: true,
7273
+ reason: `chose <body>: nothing narrower than the page itself scored, ${text3} characters`,
7274
+ candidates: report2,
7275
+ merged: 0,
7276
+ stripped: removed.length,
7277
+ text: text3,
7278
+ mediaDropped: mediaBefore - document.body.querySelectorAll("video, iframe").length
7279
+ };
7280
+ }
7281
+ const parent = top.el.parentElement;
7282
+ if (!parent) {
7283
+ return declined("the winning candidate is not in the document any more", report2);
7284
+ }
7285
+ const threshold = Math.max(MIN_SIBLING_SCORE, top.report.score * SIBLING_FRACTION);
7286
+ const topClass = top.el.getAttribute("class") ?? "";
7287
+ const keep = [];
7288
+ for (const sib of [...parent.children]) {
7289
+ if (sib === top.el) {
7290
+ keep.push(sib);
7291
+ continue;
7292
+ }
7293
+ const twin = topClass !== "" && sib.getAttribute("class") === topClass;
7294
+ const bonus = twin ? top.report.score * SIBLING_FRACTION : 0;
7295
+ const here = byElement.get(sib);
7296
+ if (here && here.report.score + bonus >= threshold) {
7297
+ keep.push(sib);
7298
+ continue;
7299
+ }
7300
+ if (sib.tagName.toLowerCase() === "p") {
7301
+ const text3 = textOf2(sib);
7302
+ const density = linkDensity(sib);
7303
+ const long = text3.length > 80 && density < 0.25;
7304
+ const sentence = text3.length > 0 && text3.length <= 80 && density === 0 && /\.( |$)/.test(text3);
7305
+ if (long || sentence) keep.push(sib);
7306
+ }
7307
+ }
7308
+ const text2 = keep.reduce((n3, el) => n3 + textOf2(el).length, 0);
7309
+ if (text2 < MIN_TEXT) {
7310
+ return declined(
7311
+ `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`,
7312
+ report2
7313
+ );
7314
+ }
7315
+ let region;
7316
+ if (keep.length === 1 && keep[0]) {
7317
+ region = keep[0];
7318
+ } else {
7319
+ const box = document.createElement("div");
7320
+ for (const el of keep) box.appendChild(el);
7321
+ document.body.appendChild(box);
7322
+ region = box;
7323
+ }
7324
+ region.setAttribute(MARKER, "");
7325
+ return {
7326
+ marked: true,
7327
+ 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` : ""),
7328
+ candidates: report2,
7329
+ merged: keep.length - 1,
7330
+ stripped: removed.length,
7331
+ text: textOf2(region).length,
7332
+ mediaDropped: mediaBefore - region.querySelectorAll("video, iframe").length
7333
+ };
7334
+ } catch (e) {
7335
+ return declined(
7336
+ `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`,
7337
+ []
7338
+ );
7339
+ }
7340
+ }
7341
+
7342
+ // src/source/transcode.ts
7343
+ import { readFile as readFile4, rm } from "node:fs/promises";
7344
+ import { basename } from "node:path";
7345
+
7346
+ // src/render/ffmpeg.ts
7347
+ import { execFile, spawn } from "node:child_process";
7348
+ import { promisify } from "node:util";
7349
+ var run = promisify(execFile);
7350
+ var DEFAULT_TIMEOUT_MS = 36e5;
7351
+ async function runTool(file, args, opts = {}) {
7352
+ try {
7353
+ return await run(file, args, {
7354
+ cwd: opts.cwd,
7355
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
7356
+ maxBuffer: 64 << 20
7357
+ });
7358
+ } catch (err) {
7359
+ const e = err;
7360
+ if (e.code === "ENOENT") {
7361
+ throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
7362
+ }
7363
+ const tail = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
7364
+ throw new Error(`${file} failed:
7365
+ ${tail}`);
7366
+ }
7367
+ }
7368
+ function runLive(file, args) {
7369
+ return new Promise((resolve7, reject) => {
7370
+ const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7371
+ child.on("error", (err) => {
7372
+ reject(
7373
+ err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
7374
+ );
7375
+ });
7376
+ child.on("close", (code, signal) => {
7377
+ if (code === 0) resolve7();
7378
+ else if (signal) {
7379
+ reject(
7380
+ new Error(
7381
+ `${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.`
7382
+ )
7383
+ );
7384
+ } else reject(new Error(`${file} exited ${code}.`));
7385
+ });
7386
+ });
7387
+ }
7388
+ async function probe(path2) {
7389
+ const { stdout } = await runTool("ffprobe", [
7390
+ "-v",
7391
+ "error",
7392
+ "-show_entries",
7393
+ "stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
7394
+ "-of",
7395
+ "json",
7396
+ path2
7397
+ ]);
7398
+ const json = JSON.parse(stdout);
7399
+ const streams = json.streams ?? [];
7400
+ const video = streams.find((s) => s.codec_type === "video");
7401
+ if (!video) throw new Error(`${path2} has no video stream.`);
7402
+ const [num2, den] = (video.r_frame_rate ?? "30/1").split("/");
7403
+ const fps = Number(num2) / (Number(den) || 1);
7404
+ const seconds = Number(json.format?.duration ?? 0);
7405
+ const frames = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
7406
+ return {
7407
+ width: video.width ?? 0,
7408
+ height: video.height ?? 0,
7409
+ fps,
7410
+ frames,
7411
+ seconds,
7412
+ hasAudio: streams.some((s) => s.codec_type === "audio")
7413
+ };
7414
+ }
7415
+ function encoderArgs(fps) {
7416
+ return [
7417
+ "-c:v",
7418
+ "libx264",
7419
+ "-preset",
7420
+ "veryfast",
7421
+ "-crf",
7422
+ "16",
7423
+ "-pix_fmt",
7424
+ "yuv420p",
7425
+ "-g",
7426
+ "12",
7427
+ "-r",
7428
+ String(fps),
7429
+ "-fps_mode",
7430
+ "cfr"
7431
+ ];
7432
+ }
7433
+ function pieceFilter(motion, freeze) {
7434
+ const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
7435
+ if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
7436
+ return chain.join(",");
7437
+ }
7438
+ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
7439
+ return [
7440
+ "-y",
7441
+ "-hide_banner",
7442
+ "-loglevel",
7443
+ "error",
7444
+ // Half a frame in, so a time that is exactly on a boundary cannot round to
7445
+ // the frame before it.
7446
+ "-ss",
7447
+ ((fromFrame + 0.5) / fps).toFixed(6),
7448
+ "-i",
7449
+ source,
7450
+ "-an",
7451
+ "-vf",
7452
+ pieceFilter(motion, freeze),
7453
+ "-frames:v",
7454
+ String(motion + freeze),
7455
+ ...encoderArgs(fps),
7456
+ "-f",
7457
+ "mpegts",
7458
+ out
7459
+ ];
7460
+ }
7461
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
7462
+ function audioGraph(inputs, seconds, first = 1) {
7463
+ const lines = inputs.map(
7464
+ (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
7465
+ );
7466
+ const labels = inputs.map((_, i) => `[d${i}]`).join("");
7467
+ lines.push(
7468
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
7469
+ );
7470
+ return lines.join(";\n");
7471
+ }
7472
+ function respeedArgs(source, factor, chain, fps, hasAudio, out) {
7473
+ const video = `[0:v]setpts=PTS/${factor}[v]`;
7474
+ const audio = hasAudio ? `;[0:a]${chain.map((t2) => `atempo=${t2}`).join(",")}[a]` : "";
7475
+ return [
7476
+ "-y",
7477
+ "-hide_banner",
7478
+ "-loglevel",
7479
+ "error",
7480
+ "-i",
7481
+ source,
7482
+ "-filter_complex",
7483
+ `${video}${audio}`,
7484
+ "-map",
7485
+ "[v]",
7486
+ ...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
7487
+ ...encoderArgs(fps),
7488
+ out
7489
+ ];
7490
+ }
7491
+ function burnStyle(width, height, font = "Arial") {
7492
+ return {
7493
+ width,
7494
+ height,
7495
+ font,
7496
+ // MEASURED, not guessed. `splitCue` caps a cue at 84 characters and `wrap`
7497
+ // breaks it near the middle, so the longer of the two lines runs to about
7498
+ // 46 characters in real caption prose. Bold Arial advances 0.485em per
7499
+ // character on that prose (measured in a browser over the demo's own
7500
+ // narration), so 46 characters at F px is 22.3F wide, and the usable width
7501
+ // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
7502
+ // "4% of the width" looked like on paper — overflows to a THIRD line, and a
7503
+ // three-line band covers the bottom of the slide.
7504
+ fontSize: Math.round(width * 0.037),
7505
+ // Clear of the play button, the progress bar and the handle every vertical
7506
+ // player draws across the bottom eighth of the frame.
7507
+ marginV: Math.round(height * 0.09),
7508
+ marginX: Math.round(width * 0.04)
7509
+ };
7510
+ }
7511
+
7512
+ // src/source/transcode.ts
7513
+ var CLIP_EDGE_PX = 1280;
7514
+ var MAX_CLIP_SECONDS = 60;
7515
+ var TIMEOUT_MS = 6e5;
7516
+ function fitBox(width, height, maxEdgePx) {
7517
+ const scale = Math.min(1, maxEdgePx / width, maxEdgePx / height);
7518
+ return { width: even(width * scale), height: even(height * scale) };
7519
+ }
7520
+ function even(n3) {
7521
+ return Math.max(2, Math.floor(Math.round(n3) / 2) * 2);
7522
+ }
7523
+ function transcodeArgs(input, out, plan) {
7524
+ return [
7525
+ "-y",
7526
+ "-hide_banner",
7527
+ "-loglevel",
7528
+ "error",
7529
+ "-i",
7530
+ input,
7531
+ ...plan.seconds === void 0 ? [] : ["-t", plan.seconds.toFixed(3)],
7532
+ "-an",
7533
+ "-map_metadata",
7534
+ "-1",
7535
+ "-vf",
7536
+ `scale=${plan.width}:${plan.height}`,
7537
+ "-c:v",
7538
+ "libvpx-vp9",
7539
+ "-crf",
7540
+ "32",
7541
+ "-b:v",
7542
+ "0",
7543
+ "-deadline",
7544
+ "good",
7545
+ "-cpu-used",
7546
+ "4",
7547
+ "-row-mt",
7548
+ "1",
7549
+ "-pix_fmt",
7550
+ "yuv420p",
7551
+ "-fflags",
7552
+ "+bitexact",
7553
+ "-flags:v",
7554
+ "+bitexact",
7555
+ out
7556
+ ];
7557
+ }
7558
+ var probed = /* @__PURE__ */ new Map();
7559
+ function installed(file) {
7560
+ const asked = probed.get(file);
7561
+ if (asked !== void 0) return asked;
7562
+ const answer = runTool(file, ["-version"], { timeoutMs: 5e3 }).then(
7563
+ () => true,
7564
+ () => false
7565
+ );
7566
+ probed.set(file, answer);
7567
+ return answer;
7568
+ }
7569
+ async function transcode(input, out, opts = {}) {
7570
+ const file = opts.ffmpeg ?? "ffmpeg";
7571
+ const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
7572
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
7573
+ const name = basename(input);
7574
+ const source = videoSize(await readFile4(input));
7575
+ const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
7576
+ const kept = (why2) => ({
7577
+ path: input,
7578
+ width: source.width,
7579
+ height: source.height,
7580
+ seconds: source.seconds,
7581
+ transcoded: false,
7582
+ warnings: [`${name} was shipped as the page served it: ${why2}`]
7583
+ });
7584
+ if (!await installed(file)) {
7585
+ return kept(
7586
+ `${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.`
7587
+ );
7588
+ }
7589
+ const trim = source.seconds === void 0 || source.seconds > maxSeconds;
7590
+ const started = Date.now();
7591
+ try {
7592
+ await runTool(
7593
+ file,
7594
+ transcodeArgs(input, out, { ...box, ...trim ? { seconds: maxSeconds } : {} }),
7595
+ { timeoutMs }
7596
+ );
7597
+ } catch (err) {
7598
+ await rm(out, { force: true });
7599
+ const over = Date.now() - started >= timeoutMs;
7600
+ return kept(
7601
+ 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)}`
7602
+ );
7603
+ }
7604
+ let measured;
7605
+ try {
7606
+ measured = videoSize(await readFile4(out));
7607
+ } catch (err) {
7608
+ await rm(out, { force: true });
7609
+ return kept(
7610
+ `the webm ${file} wrote could not be measured \u2014 ${err instanceof Error ? err.message : String(err)}`
7611
+ );
7612
+ }
7613
+ const warnings = [];
7614
+ if (trim && measured.seconds !== void 0 && measured.seconds >= maxSeconds - 0.05) {
7615
+ warnings.push(
7616
+ `${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.`
7617
+ );
7618
+ }
7619
+ return {
7620
+ path: out,
7621
+ width: measured.width,
7622
+ height: measured.height,
7623
+ seconds: measured.seconds,
7624
+ transcoded: true,
7625
+ warnings
7626
+ };
7627
+ }
7628
+
7629
+ // src/source/harvest.ts
7630
+ var HTML_MAX_BYTES = 8 * 1024 * 1024;
7631
+ var ASSET_MAX_BYTES = 32 * 1024 * 1024;
7632
+ var TIMEOUT_MS2 = 2e4;
7633
+ var MAX_ASSETS = 40;
7634
+ var MAX_CLIPS = 4;
7635
+ var MAX_TOTAL_BYTES = 96 * 1024 * 1024;
7636
+ var MAX_WALL_MS = 18e4;
7637
+ var MIN_FIGURE_PX = 64;
7638
+ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
7639
+ async function harvest(url, dir, opts = {}) {
7640
+ const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
7641
+ const warnings = [];
7642
+ const assets = resolve(dir);
7643
+ await mkdir3(assets, { recursive: true });
7644
+ const page = await fetchGuarded(url, {
7645
+ maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
7646
+ timeoutMs,
7647
+ accept: HTML_TYPES,
7648
+ ...opts.allowLoopback === true ? { allowLoopback: true } : {}
7649
+ });
7650
+ const html = decodeHtml(page.bytes, page.contentType, url, warnings);
7651
+ const { seen, pick } = await readInBrowser(html, timeoutMs);
7652
+ if (!pick.marked) {
7653
+ warnings.push(
7654
+ `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.`
7655
+ );
7656
+ } else if (pick.mediaDropped > 0) {
7657
+ warnings.push(
7658
+ `${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.`
7659
+ );
7660
+ }
7661
+ const base = absolute(seen.base, page.url) ?? page.url;
7662
+ const local = await localise(seen, base, assets, opts, timeoutMs, warnings);
7663
+ return {
7664
+ markdown: toMarkdown(titled(seen.title, local.blocks)),
7665
+ assets: local.assets,
7666
+ clips: local.clips,
7667
+ warnings,
7668
+ title: seen.title
7669
+ };
7670
+ }
7671
+ function titled(title2, blocks) {
7672
+ const first = blocks[0];
7673
+ const already = first?.kind === "heading" && first.depth === 1 && first.text.trim() === title2.trim();
7674
+ return title2 && !already ? [{ kind: "heading", depth: 1, text: title2 }, ...blocks] : [...blocks];
7675
+ }
7676
+ function decodeHtml(bytes, contentType, url, warnings) {
7677
+ const declared = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
7678
+ const head = bytes.toString("latin1", 0, Math.min(bytes.length, 4096));
7679
+ const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
7680
+ const label = declared ?? meta;
7681
+ if (label === void 0) return bytes.toString("utf8");
7682
+ try {
7683
+ return new TextDecoder(label).decode(bytes);
7684
+ } catch {
7685
+ warnings.push(
7686
+ `${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.`
7687
+ );
7688
+ return bytes.toString("utf8");
7689
+ }
7690
+ }
7691
+ async function readInBrowser(html, timeoutMs) {
7692
+ const { default: puppeteer } = await import("puppeteer-core");
7693
+ const browser = await puppeteer.launch({
7694
+ executablePath: await chromePath("read the page with"),
7695
+ headless: true,
7696
+ // Chrome's own background traffic — variations, safe browsing, first-run
7697
+ // pings — never goes through page interception, so it is switched off here
7698
+ // rather than assumed absent. It is not an SSRF path, but "the browser makes
7699
+ // no requests" should be true of the whole process, not just of the tab.
7700
+ args: [
7701
+ "--disable-background-networking",
7702
+ "--disable-extensions",
7703
+ "--no-default-browser-check",
7704
+ "--no-first-run"
7705
+ ]
7706
+ });
7707
+ try {
7708
+ const page = await browser.newPage();
7709
+ await page.setRequestInterception(true);
7710
+ page.on("request", (request) => {
7711
+ request.abort().catch(() => {
7712
+ });
7713
+ });
7714
+ await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
7715
+ const pick = await page.evaluate(readContentRegion);
7716
+ return { seen: await page.evaluate(readDom, CONTENT_MARKER), pick };
7717
+ } finally {
7718
+ await browser.close().catch(() => {
7719
+ });
7720
+ }
7721
+ }
7722
+ function readDom(marker) {
7723
+ const SKIP = /* @__PURE__ */ new Set([
7724
+ "nav",
7725
+ "aside",
7726
+ "footer",
7727
+ "script",
7728
+ "style",
7729
+ "noscript",
7730
+ "form",
7731
+ "svg",
7732
+ "canvas",
7733
+ "template",
7734
+ "button",
7735
+ "select",
7736
+ "textarea"
7737
+ ]);
7738
+ const BLOCKY = /* @__PURE__ */ new Set([
7739
+ "p",
7740
+ "div",
7741
+ "section",
7742
+ "article",
7743
+ "main",
7744
+ "ul",
7745
+ "ol",
7746
+ "table",
7747
+ "figure",
7748
+ "blockquote",
7749
+ "pre",
7750
+ "h1",
7751
+ "h2",
7752
+ "h3",
7753
+ "h4",
7754
+ "h5",
7755
+ "h6"
7756
+ ]);
7757
+ const PLAYER = /(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|(?:player\.)?vimeo\.com\/|dailymotion\.com\/video\/|\.(?:mp4|webm|m4v|mov)(?:[?#]|$))/i;
7758
+ function words2(node) {
7759
+ return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
7760
+ }
7761
+ function skipped(el) {
7762
+ return SKIP.has(el.tagName.toLowerCase()) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
7763
+ }
7764
+ function pickSrc(el) {
7765
+ 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 }));
7766
+ let widest2 = candidates2[0];
7767
+ for (const candidate of candidates2) if (candidate.w >= (widest2?.w ?? -1)) widest2 = candidate;
7768
+ return widest2?.url ?? el.getAttribute("src") ?? el.getAttribute("data-src") ?? "";
7769
+ }
7770
+ function imageOf(el, caption) {
7771
+ const src = pickSrc(el);
7772
+ if (!src) return void 0;
7773
+ return { kind: "image", src, alt: words2(el.getAttributeNode("alt")), caption };
7774
+ }
7775
+ function videoOf(el, caption) {
7776
+ const source = el.querySelector("source");
7777
+ return {
7778
+ kind: "video",
7779
+ src: el.getAttribute("src") ?? source?.getAttribute("src") ?? "",
7780
+ poster: el.getAttribute("poster") ?? "",
7781
+ href: "",
7782
+ caption
7783
+ };
7784
+ }
7785
+ function figureOf(el, out) {
7786
+ const caption = words2(el.querySelector("figcaption"));
7787
+ const video = el.querySelector("video");
7788
+ if (video) {
7789
+ out.push(videoOf(video, caption));
7790
+ return;
7791
+ }
7792
+ const image2 = el.querySelector("img");
7793
+ const block2 = image2 ? imageOf(image2, caption) : void 0;
7794
+ if (block2) {
7795
+ out.push(block2);
7796
+ return;
7797
+ }
7798
+ const text2 = words2(el);
7799
+ if (text2) out.push({ kind: "paragraph", text: text2 });
7800
+ }
7801
+ function paragraphOf(el, out) {
7802
+ const solid = [...el.childNodes].filter(
7803
+ (n3) => n3.nodeType !== Node.TEXT_NODE || (n3.textContent ?? "").trim() !== ""
7804
+ );
7805
+ const only = solid.length === 1 ? solid[0] : void 0;
7806
+ if (only instanceof HTMLAnchorElement && PLAYER.test(only.getAttribute("href") ?? "")) {
7807
+ const href = only.getAttribute("href") ?? "";
7808
+ out.push({ kind: "video", src: "", poster: "", href, caption: words2(only) });
7809
+ return;
7810
+ }
7811
+ let buffer = "";
7812
+ const flush = () => {
7813
+ const text2 = buffer.replace(/\s+/g, " ").trim();
7814
+ if (text2) out.push({ kind: "paragraph", text: text2 });
7815
+ buffer = "";
7816
+ };
7817
+ const scan = (node) => {
7818
+ if (node.nodeType === Node.TEXT_NODE) {
7819
+ buffer += node.textContent ?? "";
7820
+ return;
7821
+ }
7822
+ if (!(node instanceof Element)) return;
7823
+ if (skipped(node)) return;
7824
+ const tag = node.tagName.toLowerCase();
7825
+ if (tag === "br") {
7826
+ buffer += " ";
7827
+ return;
7828
+ }
7829
+ if (tag === "img" || tag === "video" || BLOCKY.has(tag)) {
7830
+ flush();
7831
+ block(node, out);
7832
+ return;
7833
+ }
7834
+ for (const kid of node.childNodes) scan(kid);
7835
+ };
7836
+ for (const kid of el.childNodes) scan(kid);
7837
+ flush();
7838
+ }
7839
+ function listOf(el) {
7840
+ return {
7841
+ kind: "list",
7842
+ ordered: el.tagName === "OL",
7843
+ // A nested list flattens into its parent item, exactly as `blockText` in
7844
+ // src/source/markdown.ts already flattens one on the way back out.
7845
+ items: [...el.children].filter((li) => li.tagName === "LI").map(words2).filter((text2) => text2 !== "")
7846
+ };
7847
+ }
7848
+ function tableOf(el) {
7849
+ const rows = [...el.querySelectorAll("tr")].map(
7850
+ (tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH").map(words2)
7851
+ );
7852
+ const [head, ...body] = rows;
7853
+ return head ? { kind: "table", columns: head, rows: body } : void 0;
7854
+ }
7855
+ function block(el, out) {
7856
+ if (skipped(el)) return;
7857
+ const tag = el.tagName.toLowerCase();
7858
+ if (/^h[1-6]$/.test(tag)) {
7859
+ const text2 = words2(el);
7860
+ if (text2) out.push({ kind: "heading", depth: Number(tag[1]), text: text2 });
7861
+ return;
7862
+ }
7863
+ if (tag === "p" || tag === "blockquote") {
7864
+ paragraphOf(el, out);
7865
+ return;
7866
+ }
7867
+ if (tag === "ul" || tag === "ol") {
7868
+ const list = listOf(el);
7869
+ if (list.kind === "list" && list.items.length > 0) out.push(list);
7870
+ return;
7871
+ }
7872
+ if (tag === "table") {
7873
+ const table = tableOf(el);
7874
+ if (table) out.push(table);
7875
+ return;
7876
+ }
7877
+ if (tag === "figure") {
7878
+ figureOf(el, out);
7879
+ return;
7880
+ }
7881
+ if (tag === "img") {
7882
+ const image2 = imageOf(el, "");
7883
+ if (image2) out.push(image2);
7884
+ return;
7885
+ }
7886
+ if (tag === "video") {
7887
+ out.push(videoOf(el, ""));
7888
+ return;
7889
+ }
7890
+ if (tag === "iframe") {
7891
+ const href = el.getAttribute("src") ?? "";
7892
+ if (PLAYER.test(href)) {
7893
+ out.push({
7894
+ kind: "video",
7895
+ src: "",
7896
+ poster: "",
7897
+ href,
7898
+ caption: words2(el.getAttributeNode("title"))
7899
+ });
7900
+ }
7901
+ return;
7902
+ }
7903
+ if (tag === "pre") {
7904
+ const text2 = (el.textContent ?? "").replace(/\s+$/, "");
7905
+ if (text2) out.push({ kind: "code", text: text2 });
7906
+ return;
7907
+ }
7908
+ const direct = [...el.childNodes].some(
7909
+ (n3) => n3.nodeType === Node.TEXT_NODE && (n3.textContent ?? "").trim() !== ""
7910
+ );
7911
+ if (direct) {
7912
+ paragraphOf(el, out);
7913
+ return;
7914
+ }
7915
+ walk(el, out);
7916
+ }
7917
+ function walk(el, out) {
7918
+ for (const kid of el.children) block(kid, out);
7919
+ }
7920
+ function score(el) {
7921
+ let text2 = 0;
7922
+ for (const p of el.querySelectorAll("p, li, td, h1, h2, h3, h4, h5, h6")) {
7923
+ text2 += (p.textContent ?? "").trim().length;
7924
+ }
7925
+ let links = 0;
7926
+ for (const a of el.querySelectorAll("a")) links += (a.textContent ?? "").trim().length;
7927
+ return text2 - links;
7928
+ }
7929
+ function pickRoot() {
7930
+ const marked = document.querySelector(`[${marker}]`);
7931
+ if (marked) return marked;
7932
+ const main = document.querySelector("main");
7933
+ if (main && score(main) > 0) return main;
7934
+ const article = document.querySelector("article");
7935
+ if (article && score(article) > 0) return article;
7936
+ let best = document.body;
7937
+ let top = score(document.body);
7938
+ for (const el of document.body.querySelectorAll("div, section, td")) {
7939
+ const here = score(el);
7940
+ if (here >= top) {
7941
+ top = here;
7942
+ best = el;
7943
+ }
7944
+ }
7945
+ return best;
7946
+ }
7947
+ const root2 = pickRoot();
7948
+ const blocks = [];
7949
+ walk(root2, blocks);
7950
+ const heading = words2(root2.querySelector("h1")) || words2(document.querySelector("h1"));
7951
+ const og = document.querySelector('meta[property="og:image"], meta[name="og:image"]') ?? document.querySelector('meta[property="og:image:url"], meta[name="twitter:image"]');
7952
+ return {
7953
+ title: heading || (document.title ?? "").replace(/\s+/g, " ").trim(),
7954
+ base: document.querySelector("base[href]")?.getAttribute("href") ?? "",
7955
+ ogImage: og?.getAttribute("content") ?? "",
7956
+ blocks
7957
+ };
7958
+ }
7959
+ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
7960
+ const maxAssets = opts.maxAssets ?? MAX_ASSETS;
7961
+ const maxClips = opts.maxClips ?? MAX_CLIPS;
7962
+ const maxBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
7963
+ const wallMs = opts.maxWallMs ?? MAX_WALL_MS;
7964
+ const deadline = Date.now() + wallMs;
7965
+ const out = [];
7966
+ const assets = [];
7967
+ const clips = [];
7968
+ let spent = 0;
7969
+ const done = /* @__PURE__ */ new Map();
7970
+ const ref = (got) => opts.refs === "relative" ? basename2(got.path) : got.path;
7971
+ const overdrawn = () => {
7972
+ if (Date.now() >= deadline) {
7973
+ return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
7974
+ }
7975
+ if (spent >= maxBytes) {
7976
+ return `the harvest has downloaded ${mb(spent)} of its ${mb(maxBytes)} \u2014 raise maxTotalBytes`;
7977
+ }
7978
+ return null;
7979
+ };
7980
+ const bytesOf = async (url) => {
7981
+ const got = await fetchGuarded(url, {
7982
+ maxBytes: opts.maxAssetBytes ?? ASSET_MAX_BYTES,
7983
+ timeoutMs,
7984
+ ...opts.allowLoopback === true ? { allowLoopback: true } : {}
7985
+ });
7986
+ spent += got.bytes.length;
7987
+ return got.bytes;
7988
+ };
7989
+ const grab = async (raw2, what) => {
7990
+ const url = absolute(raw2, base);
7991
+ if (url === null) {
7992
+ warnings.push(
7993
+ `${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.`
7994
+ );
7995
+ return null;
7996
+ }
7997
+ const already = done.get(url);
7998
+ if (already !== void 0) return already;
7999
+ if (assets.length >= maxAssets) {
8000
+ done.set(url, null);
8001
+ warnings.push(
8002
+ `${what} was left out: ${url} \u2014 already at ${maxAssets} assets. Raise maxAssets if the page really has that many figures.`
8003
+ );
8004
+ return null;
8005
+ }
8006
+ const capped = overdrawn();
8007
+ if (capped !== null) {
8008
+ done.set(url, null);
8009
+ warnings.push(`${what} was left out: ${url} \u2014 ${capped} if the page is worth the wait.`);
8010
+ return null;
8011
+ }
8012
+ let got = null;
8013
+ try {
8014
+ const bytes = await bytesOf(url);
8015
+ const size = imageSize(bytes);
8016
+ if (size.width < MIN_FIGURE_PX || size.height < MIN_FIGURE_PX) {
8017
+ throw new Error(
8018
+ `it is ${size.width}x${size.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
8019
+ );
8020
+ }
8021
+ const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
8022
+ await writeFile3(path2, bytes);
8023
+ assets.push(path2);
8024
+ got = { path: path2, width: size.width, height: size.height };
8025
+ } catch (err) {
8026
+ got = null;
8027
+ warnings.push(`${what} was left out: ${url} \u2014 ${why(err)}`);
8028
+ }
8029
+ done.set(url, got);
8030
+ return got;
8031
+ };
8032
+ const shrink = async (url, path2, measured, what) => {
8033
+ if (opts.transcode === false) return { path: path2, ...measured };
8034
+ const out2 = join4(dir, assetName(url, ".vp9.webm"));
8035
+ let small;
8036
+ try {
8037
+ small = await transcode(path2, out2, {
8038
+ ...opts.maxClipSeconds === void 0 ? {} : { maxSeconds: opts.maxClipSeconds }
8039
+ });
8040
+ } catch (err) {
8041
+ warnings.push(`${what} was shipped as the page served it: ${why(err)}`);
8042
+ return { path: path2, ...measured };
8043
+ }
8044
+ for (const w of small.warnings) warnings.push(`${what} \u2014 ${w}`);
8045
+ if (small.transcoded) {
8046
+ const [before, after] = await Promise.all([sizeOf(path2), sizeOf(small.path)]);
8047
+ if (before > 0 && after > before) {
8048
+ warnings.push(
8049
+ `${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.`
8050
+ );
8051
+ }
8052
+ await rm2(path2, { force: true });
8053
+ }
8054
+ return {
8055
+ path: small.path,
8056
+ width: small.width,
8057
+ height: small.height,
8058
+ ...small.seconds === void 0 ? {} : { seconds: small.seconds }
8059
+ };
8060
+ };
8061
+ const grabVideo = async (url, what) => {
8062
+ if (clips.length >= maxClips) {
8063
+ warnings.push(
8064
+ 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.`
8065
+ );
8066
+ return null;
8067
+ }
8068
+ const capped = overdrawn();
8069
+ if (capped !== null) {
8070
+ warnings.push(`${what} is a link only: ${capped} if the video is worth the wait.`);
8071
+ return null;
8072
+ }
8073
+ try {
8074
+ const bytes = await bytesOf(url);
8075
+ const measured = videoSize(bytes);
8076
+ const path2 = join4(dir, assetName(url, `.${measured.container}`));
8077
+ await writeFile3(path2, bytes);
8078
+ return await shrink(url, path2, measured, what);
8079
+ } catch (err) {
8080
+ warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
8081
+ return null;
8082
+ }
8083
+ };
8084
+ let ogSpent = false;
8085
+ const stillFor = async (poster, named, needed) => {
8086
+ if (poster) return grab(poster, `the poster of ${named}`);
8087
+ if (needed && seen.ogImage && !ogSpent) {
8088
+ ogSpent = true;
8089
+ return grab(seen.ogImage, `the page's og:image, taken as the still of ${named}`);
8090
+ }
8091
+ return null;
8092
+ };
8093
+ for (const item of seen.blocks) {
8094
+ if (item.kind === "image") {
8095
+ const named = item.caption || item.alt;
8096
+ const got = await grab(item.src, named ? `the image "${named}"` : "an image");
8097
+ if (got) out.push({ ...item, src: ref(got) });
8098
+ continue;
8099
+ }
8100
+ if (item.kind === "video") {
8101
+ const named = item.caption ? `the video "${item.caption}"` : "a video";
8102
+ const file = absolute(item.src, base);
8103
+ const page = absolute(item.href, base);
8104
+ const subject = file ?? page;
8105
+ const watch = page ?? file;
8106
+ const policy = subject === null ? null : policyFor(subject, "bake");
8107
+ const held = policy === "bake" && subject !== null ? await grabVideo(subject, named) : null;
8108
+ const still = await stillFor(item.poster, named, held === null);
8109
+ if (held !== null) {
8110
+ clips.push({
8111
+ file: held.path,
8112
+ poster: still?.path ?? "",
8113
+ href: "",
8114
+ width: held.width,
8115
+ height: held.height,
8116
+ ...held.seconds === void 0 ? {} : { seconds: held.seconds },
8117
+ caption: item.caption
8118
+ });
8119
+ out.push({ ...item, src: "", poster: still ? ref(still) : "", href: "" });
8120
+ continue;
8121
+ }
8122
+ if (still !== null && watch !== null) {
8123
+ clips.push({
8124
+ file: "",
8125
+ poster: still.path,
8126
+ href: watch,
8127
+ width: still.width,
8128
+ height: still.height,
8129
+ caption: item.caption
8130
+ });
8131
+ warnings.push(
8132
+ `${named} is a link only: ${unheld(policy)}. The still is what the deck shows, and a viewer goes to ${watch}.`
8133
+ );
8134
+ out.push({ ...item, src: "", poster: ref(still), href: watch });
8135
+ continue;
8136
+ }
8137
+ if (still !== null) {
8138
+ warnings.push(
8139
+ `${named} is a still only: the page names no source for it, so there is nothing to play.`
8140
+ );
8141
+ out.push({ ...item, src: "", poster: ref(still), href: "" });
8142
+ continue;
8143
+ }
8144
+ if (watch === null) {
8145
+ warnings.push(`${named} was left out: it names neither a poster image nor a URL.`);
8146
+ continue;
8147
+ }
8148
+ warnings.push(
8149
+ `${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.`
8150
+ );
8151
+ out.push({ ...item, src: "", poster: "", href: watch });
8152
+ continue;
8153
+ }
8154
+ out.push(item);
8155
+ }
8156
+ return { blocks: out, assets, clips };
8157
+ }
8158
+ function unheld(policy) {
8159
+ if (policy === "embed") return "it is a player page, which is never downloaded";
8160
+ if (policy === "link") {
8161
+ return "its URL does not end in a video extension, so what came back could as easily be a page";
8162
+ }
8163
+ return "the file could not be held";
8164
+ }
8165
+ async function sizeOf(path2) {
8166
+ return await stat(path2).then(
8167
+ (s) => s.size,
8168
+ () => 0
8169
+ );
8170
+ }
8171
+ function mb(bytes) {
8172
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8173
+ }
8174
+ function absolute(raw2, base) {
8175
+ const trimmed = raw2.trim();
8176
+ if (!trimmed) return null;
8177
+ try {
8178
+ const url = new URL(trimmed, base);
8179
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
8180
+ } catch {
8181
+ return null;
8182
+ }
8183
+ }
8184
+ function assetName(url, ext) {
8185
+ const last = new URL(url).pathname.split("/").pop() ?? "";
8186
+ const stem = last.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "figure";
8187
+ const hash = createHash5("sha256").update(url).digest("hex").slice(0, 8);
8188
+ return `${stem}-${hash}${ext}`;
8189
+ }
8190
+ function extFor2(format) {
8191
+ if (format === void 0) return ".img";
8192
+ return format === "jpeg" ? ".jpg" : `.${format}`;
8193
+ }
8194
+ function why(err) {
8195
+ return err instanceof Error ? err.message : String(err);
8196
+ }
8197
+ function sniffVideo(b) {
8198
+ if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp") return "mp4";
8199
+ if (b.length >= 4 && b.readUInt32BE(0) === 440786851) return "webm";
8200
+ return void 0;
8201
+ }
8202
+ function videoSize(b) {
8203
+ const container = sniffVideo(b);
8204
+ if (container === void 0) {
8205
+ throw new Error(
8206
+ "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"
8207
+ );
8208
+ }
8209
+ const measured = container === "mp4" ? mp4Size(b) : webmSize(b);
8210
+ if (measured.width < MIN_FIGURE_PX || measured.height < MIN_FIGURE_PX) {
8211
+ throw new Error(
8212
+ `it measures ${measured.width}x${measured.height}, under ${MIN_FIGURE_PX}px \u2014 a slide cannot use one`
8213
+ );
8214
+ }
8215
+ return { ...measured, container };
8216
+ }
8217
+ function boxes2(b, from, to) {
8218
+ const out = [];
8219
+ let at = from;
8220
+ while (at + 8 <= to) {
8221
+ let size = b.readUInt32BE(at);
8222
+ const type = b.toString("latin1", at + 4, at + 8);
8223
+ let body = at + 8;
8224
+ if (size === 1) {
8225
+ if (body + 8 > to) return out;
8226
+ size = Number(b.readBigUInt64BE(body));
8227
+ body += 8;
8228
+ } else if (size === 0) {
8229
+ size = to - at;
8230
+ }
8231
+ const end = at + size;
8232
+ if (size < body - at || end > to) return out;
8233
+ out.push({ type, body, end });
8234
+ at = end;
8235
+ }
8236
+ return out;
8237
+ }
8238
+ function mp4Size(b) {
8239
+ const moov = boxes2(b, 0, b.length).find((box) => box.type === "moov");
8240
+ if (moov === void 0) {
8241
+ throw new Error(
8242
+ "it has no moov box \u2014 the file is truncated, or it is a fragmented stream whose header never arrived"
8243
+ );
8244
+ }
8245
+ let seconds;
8246
+ let size;
8247
+ for (const box of boxes2(b, moov.body, moov.end)) {
8248
+ if (box.type === "mvhd") seconds = mvhdSeconds(b, box.body);
8249
+ if (box.type !== "trak") continue;
8250
+ for (const inner of boxes2(b, box.body, box.end)) {
8251
+ if (inner.type === "tkhd" && size === void 0) size = tkhdSize(b, inner.body);
8252
+ }
8253
+ }
8254
+ if (size === void 0) {
8255
+ throw new Error(
8256
+ "no track in it declares a width and a height, so there is no picture to place"
8257
+ );
8258
+ }
8259
+ return { ...size, ...seconds === void 0 ? {} : { seconds } };
8260
+ }
8261
+ function tkhdSize(b, body) {
8262
+ const matrix = body + (b[body] === 1 ? 52 : 40);
8263
+ const at = matrix + 36;
8264
+ if (at + 8 > b.length) return void 0;
8265
+ const width = Math.round(b.readUInt32BE(at) / 65536);
8266
+ const height = Math.round(b.readUInt32BE(at + 4) / 65536);
8267
+ if (width <= 0 || height <= 0) return void 0;
8268
+ const quarterTurn = b.readInt32BE(matrix) === 0 && b.readInt32BE(matrix + 16) === 0;
8269
+ return quarterTurn ? { width: height, height: width } : { width, height };
8270
+ }
8271
+ function mvhdSeconds(b, body) {
8272
+ const long = b[body] === 1;
8273
+ const at = body + (long ? 20 : 12);
8274
+ if (at + (long ? 12 : 8) > b.length) return void 0;
8275
+ const timescale = b.readUInt32BE(at);
8276
+ const ticks = long ? Number(b.readBigUInt64BE(at + 4)) : b.readUInt32BE(at + 4);
8277
+ if (timescale <= 0 || ticks <= 0 || ticks === 4294967295) return void 0;
8278
+ return sane(ticks / timescale);
8279
+ }
8280
+ var EBML_SEGMENT = 408125543;
8281
+ var EBML_INFO = 357149030;
8282
+ var EBML_TRACKS = 374648427;
8283
+ var EBML_TRACK_ENTRY = 174;
8284
+ var EBML_VIDEO = 224;
8285
+ var EBML_PIXEL_WIDTH = 176;
8286
+ var EBML_PIXEL_HEIGHT = 186;
8287
+ var EBML_DISPLAY_WIDTH = 21680;
8288
+ var EBML_DISPLAY_HEIGHT = 21690;
8289
+ var EBML_TIMECODE_SCALE = 2807729;
8290
+ var EBML_DURATION = 17545;
8291
+ function webmSize(b) {
8292
+ let pixel = { width: 0, height: 0 };
8293
+ let display = { width: 0, height: 0 };
8294
+ let scale = 1e6;
8295
+ let ticks;
8296
+ const masters = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_INFO, EBML_TRACKS, EBML_TRACK_ENTRY, EBML_VIDEO]);
8297
+ const scan = (from, to, depth) => {
8298
+ let at = from;
8299
+ while (at < to) {
8300
+ const el = ebml(b, at, to);
8301
+ if (el === void 0 || el.end <= at) return;
8302
+ if (masters.has(el.id)) {
8303
+ if (depth < 6) scan(el.body, el.end, depth + 1);
8304
+ } else if (el.id === EBML_PIXEL_WIDTH && pixel.width === 0) {
8305
+ pixel = { ...pixel, width: uint(b, el) };
8306
+ } else if (el.id === EBML_PIXEL_HEIGHT && pixel.height === 0) {
8307
+ pixel = { ...pixel, height: uint(b, el) };
8308
+ } else if (el.id === EBML_DISPLAY_WIDTH && display.width === 0) {
8309
+ display = { ...display, width: uint(b, el) };
8310
+ } else if (el.id === EBML_DISPLAY_HEIGHT && display.height === 0) {
8311
+ display = { ...display, height: uint(b, el) };
8312
+ } else if (el.id === EBML_TIMECODE_SCALE) {
8313
+ scale = uint(b, el) || scale;
8314
+ } else if (el.id === EBML_DURATION) {
8315
+ ticks = float(b, el);
8316
+ }
8317
+ at = el.end;
8318
+ }
8319
+ };
8320
+ scan(0, b.length, 0);
8321
+ const width = display.width || pixel.width;
8322
+ const height = display.height || pixel.height;
8323
+ if (width <= 0 || height <= 0) {
8324
+ throw new Error(
8325
+ "no track in it declares PixelWidth and PixelHeight, so there is no picture to place"
8326
+ );
8327
+ }
8328
+ const seconds = ticks === void 0 ? void 0 : sane(ticks * scale / 1e9);
8329
+ return { width, height, ...seconds === void 0 ? {} : { seconds } };
8330
+ }
8331
+ function ebml(b, at, to) {
8332
+ const idLen = vintLen(b[at]);
8333
+ if (idLen === 0 || at + idLen > to) return void 0;
8334
+ let id2 = 0;
8335
+ for (let i = 0; i < idLen; i += 1) id2 = id2 * 256 + (b[at + i] ?? 0);
8336
+ let p = at + idLen;
8337
+ const sizeLen = vintLen(b[p]);
8338
+ if (sizeLen === 0 || p + sizeLen > to) return void 0;
8339
+ const first = b[p] ?? 0;
8340
+ const mask = 255 >> sizeLen;
8341
+ let size = first & mask;
8342
+ let unknown = size === mask;
8343
+ for (let i = 1; i < sizeLen; i += 1) {
8344
+ const byte = b[p + i] ?? 0;
8345
+ size = size * 256 + byte;
8346
+ unknown = unknown && byte === 255;
8347
+ }
8348
+ p += sizeLen;
8349
+ return { id: id2, body: p, end: unknown ? to : Math.min(p + size, to) };
8350
+ }
8351
+ function vintLen(first) {
8352
+ if (first === void 0 || first === 0) return 0;
8353
+ let len = 1;
8354
+ for (let mask = 128; (first & mask) === 0; mask >>= 1) len += 1;
8355
+ return len;
8356
+ }
8357
+ function uint(b, el) {
8358
+ let value = 0;
8359
+ for (let at = el.body; at < el.end && at - el.body < 8; at += 1)
8360
+ value = value * 256 + (b[at] ?? 0);
8361
+ return value;
8362
+ }
8363
+ function float(b, el) {
8364
+ const width = el.end - el.body;
8365
+ if (width === 4) return b.readFloatBE(el.body);
8366
+ if (width === 8) return b.readDoubleBE(el.body);
8367
+ return void 0;
8368
+ }
8369
+ function sane(seconds) {
8370
+ return Number.isFinite(seconds) && seconds > 0 && seconds < 86400 ? seconds : void 0;
8371
+ }
8372
+ function toMarkdown(blocks) {
8373
+ const out = [];
8374
+ for (const item of blocks) {
8375
+ switch (item.kind) {
8376
+ case "heading":
8377
+ out.push(`${"#".repeat(Math.min(Math.max(item.depth, 1), 6))} ${inline(item.text)}`);
8378
+ break;
8379
+ case "paragraph":
8380
+ out.push(inline(item.text));
8381
+ break;
8382
+ case "code":
8383
+ out.push(fenced(item.text));
8384
+ break;
8385
+ case "list":
8386
+ out.push(
8387
+ item.items.map((li, n3) => `${item.ordered ? `${n3 + 1}.` : "-"} ${inline(li)}`).join("\n")
8388
+ );
8389
+ break;
8390
+ case "table":
8391
+ out.push(pipes(item.columns, item.rows));
8392
+ break;
8393
+ case "image":
8394
+ out.push(`![${inline(item.alt)}](${destination(item.src)})`);
8395
+ if (item.caption || item.alt) out.push(`*${inline(item.caption || item.alt)}*`);
8396
+ break;
8397
+ case "video": {
8398
+ if (item.poster) {
8399
+ out.push(`![${inline(item.caption)}](${destination(item.poster)})`);
8400
+ if (item.caption) out.push(`*${inline(item.caption)}*`);
8401
+ } else if (item.caption) {
8402
+ out.push(inline(item.caption));
8403
+ }
8404
+ const link = item.href || item.src;
8405
+ if (/^https?:/i.test(link)) out.push(`Video: <${link}>`);
8406
+ break;
8407
+ }
8408
+ }
8409
+ }
8410
+ return `${out.filter((block) => block !== "").join("\n\n")}
8411
+ `;
8412
+ }
8413
+ function inline(text2) {
8414
+ return text2.replace(/\s+/g, " ").trim().replace(/([\\`*_[\]<>])/g, "\\$1").replace(/^(#{1,6}\s|[-+]\s)/, "\\$&").replace(/^(\d{1,9})([.)]\s)/, "$1\\$2");
8415
+ }
8416
+ function destination(path2) {
8417
+ if (/[<>]/.test(path2)) {
8418
+ throw new Error(
8419
+ `cannot reference ${path2} from markdown: a path containing < or > has no spelling as a link destination. Harvest into a directory without them.`
8420
+ );
8421
+ }
8422
+ return /[\s()]/.test(path2) ? `<${path2}>` : path2;
8423
+ }
8424
+ function pipes(columns2, rows) {
8425
+ const width = Math.max(columns2.length, ...rows.map((r) => r.length), 1);
8426
+ const cells = (row) => `| ${Array.from({ length: width }, (_, i) => inline(row[i] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`;
8427
+ const rule = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`;
8428
+ return [cells(columns2), rule, ...rows.map(cells)].join("\n");
8429
+ }
8430
+ function fenced(code) {
8431
+ const runs = [...code.matchAll(/`+/g)].map((m) => m[0].length + 1);
8432
+ const fence = "`".repeat(Math.max(3, ...runs));
8433
+ return `${fence}
8434
+ ${code}
8435
+ ${fence}`;
8436
+ }
8437
+
8438
+ // src/plan/codex.ts
8439
+ import { spawn as spawn2 } from "node:child_process";
8440
+ import { mkdtemp, readFile as readFile5, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
8441
+ import { tmpdir } from "node:os";
8442
+ import { join as join5 } from "node:path";
8443
+ import { z as z3 } from "zod";
8444
+
8445
+ // src/plan/arc.ts
8446
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
8447
+ function requiredRoles(beatCount) {
8448
+ if (beatCount >= 8) return ARC_ROLES;
8449
+ if (beatCount >= 5) return ["limitations", "conclusion"];
8450
+ return [];
8451
+ }
8452
+ function paperArcRequested(prefs) {
8453
+ return prefs.genre === "paper";
8454
+ }
8455
+
8456
+ // src/plan/duration.ts
8457
+ var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
8458
+ var LAST_HOLD_SECONDS = 4.2;
6192
8459
  var STOPS_PER_BEAT = 3.1;
6193
8460
  var MOTION_SHARE = 0.35;
6194
8461
  var MIN_SENTENCE_CHARS = 30;
@@ -6927,9 +9194,28 @@ function renderSource(source) {
6927
9194
  } else {
6928
9195
  const n3 = source.figures.length;
6929
9196
  out.push(`${n3 === 1 ? "1 figure" : `${n3} figures`} in this document.`);
9197
+ if (source.figures.some((f) => f.kind === "clip")) {
9198
+ out.push(
9199
+ "",
9200
+ "A CLIP is a figure whose asset is video. Cite it exactly as you cite any other",
9201
+ "figure \u2014 `figureId` on the beat, `[figure id]` in evidence \u2014 and pick it for the",
9202
+ "same reason: it is the picture that carries the point.",
9203
+ "WHAT IT COSTS, and it decides whether a beat is worth spending on one: the deck",
9204
+ "holds a clip PAUSED AND MUTED, so a viewer clicking through sees one frame until",
9205
+ "they press play, and the rendered video shows that same frame unless the clip is",
9206
+ "a file this deck actually holds. A clip listed as watchable only at a link is a",
9207
+ "page whose video we could not download, and its still is all any format will ever",
9208
+ "show. So write the beat for the FRAME: claim what the picture states, and never",
9209
+ "narrate a motion the still does not."
9210
+ );
9211
+ }
6930
9212
  const headings = new Map(source.sections.map((s) => [s.id, s.heading]));
6931
9213
  for (const f of source.figures) {
6932
- out.push("", `[figure ${f.id}] ${f.width}x${f.height} \u2014 ${f.caption}`);
9214
+ const size = 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}`;
9215
+ out.push("", `[figure ${f.id}] ${size} \u2014 ${f.caption}`);
9216
+ if (f.kind === "clip" && f.href) {
9217
+ out.push(` watchable only at ${f.href} \u2014 the deck shows its still, never the video`);
9218
+ }
6933
9219
  const heading = f.sectionId === void 0 ? void 0 : headings.get(f.sectionId);
6934
9220
  if (f.sectionId !== void 0) {
6935
9221
  out.push(` under: [section ${f.sectionId}]${heading ? ` ${heading}` : ""}`);
@@ -7063,7 +9349,7 @@ function assertRefsResolve(storyboard, source, opts = {}) {
7063
9349
  }
7064
9350
 
7065
9351
  // src/plan/codex.ts
7066
- var DEFAULT_TIMEOUT_MS = 10 * 6e4;
9352
+ var DEFAULT_TIMEOUT_MS2 = 10 * 6e4;
7067
9353
  var UNSUPPORTED = /* @__PURE__ */ new Set([
7068
9354
  "$schema",
7069
9355
  "default",
@@ -7138,26 +9424,26 @@ function hideFromPlanner(node, hidden) {
7138
9424
  }
7139
9425
  function schemaFor(prefs) {
7140
9426
  return hideFromPlanner(
7141
- forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
9427
+ forStructuredOutput(z3.toJSONSchema(storyboardSchema, { io: "input" })),
7142
9428
  plannerInvisible(prefs)
7143
9429
  );
7144
9430
  }
7145
9431
  var SCHEMA = schemaFor({ genre: "general" });
7146
9432
  async function codexPlanner(source, opts = {}) {
7147
9433
  const prefs = opts.prefs ?? prefsSchema.parse({});
7148
- const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
9434
+ const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
7149
9435
  try {
7150
- const schemaPath = join3(dir, "storyboard.schema.json");
7151
- const outPath = join3(dir, "storyboard.json");
7152
- await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
9436
+ const schemaPath = join5(dir, "storyboard.schema.json");
9437
+ const outPath = join5(dir, "storyboard.json");
9438
+ await writeFile4(schemaPath, JSON.stringify(schemaFor(prefs)));
7153
9439
  await (opts.run ?? runCodex)({
7154
9440
  prompt: buildPrompt(source, prefs),
7155
9441
  schemaPath,
7156
9442
  outPath,
7157
9443
  ...opts.model === void 0 ? {} : { model: opts.model },
7158
- timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
9444
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
7159
9445
  });
7160
- const raw2 = await readFile3(outPath, "utf8").catch(() => "");
9446
+ const raw2 = await readFile5(outPath, "utf8").catch(() => "");
7161
9447
  if (raw2.trim() === "") {
7162
9448
  throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
7163
9449
  }
@@ -7188,7 +9474,7 @@ ${issues}`);
7188
9474
  assertRefsResolve(result.data, source, { pending: prefs.images.enabled ? "allow" : "refuse" });
7189
9475
  return result.data;
7190
9476
  } finally {
7191
- await rm(dir, { recursive: true, force: true });
9477
+ await rm3(dir, { recursive: true, force: true });
7192
9478
  }
7193
9479
  }
7194
9480
  function buildPrompt(source, prefs) {
@@ -7230,8 +9516,8 @@ function codexCommand(args) {
7230
9516
  }
7231
9517
  function runCodex(args) {
7232
9518
  const { argv, env: env2 } = codexCommand(args);
7233
- return new Promise((resolve6, reject) => {
7234
- const child = spawn("codex", argv, {
9519
+ return new Promise((resolve7, reject) => {
9520
+ const child = spawn2("codex", argv, {
7235
9521
  stdio: ["pipe", "ignore", "pipe"],
7236
9522
  ...env2 === void 0 ? {} : { env: env2 }
7237
9523
  });
@@ -7253,7 +9539,7 @@ function runCodex(args) {
7253
9539
  });
7254
9540
  child.on("close", (code) => {
7255
9541
  clearTimeout(timer);
7256
- if (code === 0) return resolve6();
9542
+ if (code === 0) return resolve7();
7257
9543
  reject(
7258
9544
  new Error(`codex exec exited ${code}.
7259
9545
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -7264,16 +9550,16 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
7264
9550
  }
7265
9551
 
7266
9552
  // src/images/illustrate.ts
7267
- import { createHash as createHash5 } from "node:crypto";
7268
- import { mkdir as mkdir3, readFile as readFile5, rename, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
7269
- import { join as join5 } from "node:path";
9553
+ import { createHash as createHash7 } from "node:crypto";
9554
+ import { mkdir as mkdir4, readFile as readFile7, rename, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
9555
+ import { join as join7 } from "node:path";
7270
9556
 
7271
9557
  // src/images/providers.ts
7272
- import { createHash as createHash4 } from "node:crypto";
7273
- import { mkdtemp as mkdtemp2, readFile as readFile4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
9558
+ import { createHash as createHash6 } from "node:crypto";
9559
+ import { mkdtemp as mkdtemp2, readFile as readFile6, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
7274
9560
  import { tmpdir as tmpdir2 } from "node:os";
7275
- import { join as join4, resolve } from "node:path";
7276
- import { z as z3 } from "zod";
9561
+ import { join as join6, resolve as resolve2 } from "node:path";
9562
+ import { z as z4 } from "zod";
7277
9563
  var SIZE = {
7278
9564
  landscape: { width: 1536, height: 1024 },
7279
9565
  square: { width: 1024, height: 1024 },
@@ -7296,11 +9582,11 @@ var DEFAULT_BASE_URL = "https://api.openai.com/v1";
7296
9582
  var DEFAULT_MODEL = "gpt-image-2";
7297
9583
  var REQUEST_TIMEOUT_MS = 12e4;
7298
9584
  var MAX_BYTES = 16 * 1024 * 1024;
7299
- var generatedSchema = z3.object({
7300
- data: z3.array(z3.object({ b64_json: z3.string().optional(), url: z3.string().optional() })).optional()
9585
+ var generatedSchema = z4.object({
9586
+ data: z4.array(z4.object({ b64_json: z4.string().optional(), url: z4.string().optional() })).optional()
7301
9587
  });
7302
- var failedSchema = z3.object({
7303
- error: z3.object({ code: z3.string().nullish(), type: z3.string().nullish() }).nullish()
9588
+ var failedSchema = z4.object({
9589
+ error: z4.object({ code: z4.string().nullish(), type: z4.string().nullish() }).nullish()
7304
9590
  });
7305
9591
  function openaiImages(opts) {
7306
9592
  const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -7325,8 +9611,8 @@ function openaiImages(opts) {
7325
9611
  }),
7326
9612
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7327
9613
  });
7328
- const body = await readCapped(res);
7329
- if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason(body)}`);
9614
+ const body = await readCapped2(res);
9615
+ if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason2(body)}`);
7330
9616
  const first = generatedSchema.safeParse(parseJson(body)).data?.data?.[0];
7331
9617
  if (first?.b64_json) return raster(Buffer.from(first.b64_json, "base64"), "openai images");
7332
9618
  if (!first?.url) throw new Error("openai images: answer carried neither b64_json nor url");
@@ -7339,14 +9625,14 @@ function openaiImages(opts) {
7339
9625
  redirect: "error",
7340
9626
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
7341
9627
  });
7342
- const bytes = await readCapped(picture);
9628
+ const bytes = await readCapped2(picture);
7343
9629
  if (!picture.ok)
7344
9630
  throw new Error(`openai images: HTTP ${picture.status} fetching the picture`);
7345
9631
  return raster(bytes, "openai images");
7346
9632
  }
7347
9633
  };
7348
9634
  }
7349
- function reason(body) {
9635
+ function reason2(body) {
7350
9636
  const error = failedSchema.safeParse(parseJson(body)).data?.error;
7351
9637
  const code = error?.code ?? error?.type;
7352
9638
  return code ? ` (${code})` : "";
@@ -7358,7 +9644,7 @@ function parseJson(body) {
7358
9644
  return void 0;
7359
9645
  }
7360
9646
  }
7361
- async function readCapped(res) {
9647
+ async function readCapped2(res) {
7362
9648
  const over = `openai images: answer is over ${MAX_BYTES >> 20} MB`;
7363
9649
  if (Number(res.headers.get("content-length")) > MAX_BYTES) throw new Error(over);
7364
9650
  const reader = res.body?.getReader();
@@ -7388,10 +9674,10 @@ var ANSWER_SCHEMA = {
7388
9674
  required: ["ok", "file", "reason"],
7389
9675
  additionalProperties: false
7390
9676
  };
7391
- var answerSchema = z3.object({
7392
- ok: z3.boolean(),
7393
- file: z3.string().nullish(),
7394
- reason: z3.string().nullish()
9677
+ var answerSchema = z4.object({
9678
+ ok: z4.boolean(),
9679
+ file: z4.string().nullish(),
9680
+ reason: z4.string().nullish()
7395
9681
  });
7396
9682
  function codexImages(opts = {}) {
7397
9683
  const run5 = opts.run ?? runCodex;
@@ -7404,11 +9690,11 @@ function codexImages(opts = {}) {
7404
9690
  async check() {
7405
9691
  },
7406
9692
  async generate(req) {
7407
- const dir = await mkdtemp2(join4(tmpdir2(), "decksmith-image-"));
9693
+ const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
7408
9694
  try {
7409
- const schemaPath = join4(dir, "answer.schema.json");
7410
- const outPath = join4(dir, "answer.json");
7411
- await writeFile4(schemaPath, JSON.stringify(ANSWER_SCHEMA));
9695
+ const schemaPath = join6(dir, "answer.schema.json");
9696
+ const outPath = join6(dir, "answer.json");
9697
+ await writeFile5(schemaPath, JSON.stringify(ANSWER_SCHEMA));
7412
9698
  await run5({
7413
9699
  prompt: codexPrompt(req),
7414
9700
  schemaPath,
@@ -7417,7 +9703,7 @@ function codexImages(opts = {}) {
7417
9703
  cwd: dir,
7418
9704
  sandbox: "workspace-write"
7419
9705
  });
7420
- const raw2 = await readFile4(outPath, "utf8").catch(() => "");
9706
+ const raw2 = await readFile6(outPath, "utf8").catch(() => "");
7421
9707
  const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
7422
9708
  if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
7423
9709
  if (!answer.data.ok) {
@@ -7425,15 +9711,15 @@ function codexImages(opts = {}) {
7425
9711
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
7426
9712
  );
7427
9713
  }
7428
- const candidates2 = [join4(dir, "picture.png")];
7429
- if (answer.data.file) candidates2.push(resolve(dir, answer.data.file));
9714
+ const candidates2 = [join6(dir, "picture.png")];
9715
+ if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
7430
9716
  for (const path2 of candidates2) {
7431
- const bytes = await readFile4(path2).catch(() => null);
9717
+ const bytes = await readFile6(path2).catch(() => null);
7432
9718
  if (bytes) return raster(bytes, "codex");
7433
9719
  }
7434
9720
  throw new Error("codex said ok but wrote no picture.png");
7435
9721
  } finally {
7436
- await rm2(dir, { recursive: true, force: true });
9722
+ await rm4(dir, { recursive: true, force: true });
7437
9723
  }
7438
9724
  }
7439
9725
  };
@@ -7468,7 +9754,7 @@ function mulberry32(seed) {
7468
9754
  }
7469
9755
  function drawSvg(req) {
7470
9756
  const { width: W, height: H } = SIZE[req.aspect];
7471
- const seed = createHash4("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
9757
+ const seed = createHash6("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
7472
9758
  const rand = mulberry32(seed.readUInt32BE(0));
7473
9759
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
7474
9760
  const count = 6 + Math.floor(rand() * 5);
@@ -7499,11 +9785,6 @@ function drawSvg(req) {
7499
9785
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join("")}</svg>
7500
9786
  `;
7501
9787
  }
7502
- function svgSize(bytes) {
7503
- const m = /viewBox="0 0 (\d+) (\d+)"/.exec(bytes.toString("utf8", 0, 200));
7504
- if (!m) throw new Error("svg has no viewBox");
7505
- return { width: Number(m[1]), height: Number(m[2]) };
7506
- }
7507
9788
  function toolSvg() {
7508
9789
  return {
7509
9790
  id: "svg",
@@ -7547,7 +9828,7 @@ function imageChain(images, backend) {
7547
9828
  }
7548
9829
 
7549
9830
  // src/images/illustrate.ts
7550
- var EXT = {
9831
+ var EXT2 = {
7551
9832
  "image/png": ".png",
7552
9833
  "image/jpeg": ".jpg",
7553
9834
  "image/svg+xml": ".svg"
@@ -7565,7 +9846,7 @@ async function illustrate(storyboard, source, opts) {
7565
9846
  const tool = last?.id === "svg" ? last : toolSvg();
7566
9847
  if (tool !== last) chain.push(tool);
7567
9848
  const dropped = /* @__PURE__ */ new Set();
7568
- if (pending.length > 0) await mkdir3(opts.assetsDir, { recursive: true });
9849
+ if (pending.length > 0) await mkdir4(opts.assetsDir, { recursive: true });
7569
9850
  for (const [i, slot] of pending.entries()) {
7570
9851
  let rungs = chain;
7571
9852
  if (i >= images.max) {
@@ -7585,6 +9866,9 @@ async function illustrate(storyboard, source, opts) {
7585
9866
  const picture = await draw(provider, req, name, opts.assetsDir);
7586
9867
  const figure = {
7587
9868
  id: slot.figureId,
9869
+ // An illustration is always a still: every provider draws a picture,
9870
+ // and there is no rung that returns a video.
9871
+ kind: "image",
7588
9872
  src: picture.src,
7589
9873
  caption: slot.brief.caption,
7590
9874
  width: picture.width,
@@ -7603,8 +9887,8 @@ async function illustrate(storyboard, source, opts) {
7603
9887
  } catch (err) {
7604
9888
  const after = live[j + 1];
7605
9889
  if (!after) throw err;
7606
- const why = err instanceof Error ? err.message : String(err);
7607
- step(`illustrate: ${slot.label} via ${provider.id} failed (${why}); trying ${after.id}`);
9890
+ const why2 = err instanceof Error ? err.message : String(err);
9891
+ step(`illustrate: ${slot.label} via ${provider.id} failed (${why2}); trying ${after.id}`);
7608
9892
  dropped.add(provider.id);
7609
9893
  }
7610
9894
  }
@@ -7654,36 +9938,36 @@ function slots(storyboard, known) {
7654
9938
  return out;
7655
9939
  }
7656
9940
  function cacheKey(providerId, req) {
7657
- return createHash5("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
9941
+ return createHash7("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
7658
9942
  }
7659
9943
  async function draw(provider, req, name, dir) {
7660
- for (const ext of Object.values(EXT)) {
9944
+ for (const ext of Object.values(EXT2)) {
7661
9945
  const src2 = `${name}${ext}`;
7662
- const bytes = await readFile5(join5(dir, src2)).catch(() => null);
7663
- if (bytes) return { src: src2, ...sizeOf(bytes, ext), cached: true };
9946
+ const bytes = await readFile7(join7(dir, src2)).catch(() => null);
9947
+ if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
7664
9948
  }
7665
9949
  await provider.check();
7666
9950
  const img = await provider.generate(req);
7667
- const src = `${name}${EXT[img.mime]}`;
7668
- const tmp = join5(dir, `.${name}.tmp`);
9951
+ const src = `${name}${EXT2[img.mime]}`;
9952
+ const tmp = join7(dir, `.${name}.tmp`);
7669
9953
  try {
7670
- await writeFile5(tmp, img.bytes);
7671
- await rename(tmp, join5(dir, src));
9954
+ await writeFile6(tmp, img.bytes);
9955
+ await rename(tmp, join7(dir, src));
7672
9956
  } finally {
7673
- await rm3(tmp, { force: true });
9957
+ await rm5(tmp, { force: true });
7674
9958
  }
7675
9959
  return { src, width: img.width, height: img.height, cached: false };
7676
9960
  }
7677
- function sizeOf(bytes, ext) {
9961
+ function sizeOf2(bytes, ext) {
7678
9962
  return ext === ".svg" ? svgSize(bytes) : imageSize(bytes);
7679
9963
  }
7680
9964
 
7681
9965
  // src/narrate/tts.ts
7682
- import { spawn as spawn2 } from "node:child_process";
7683
- import { createHash as createHash6 } from "node:crypto";
7684
- import { mkdir as mkdir4, readFile as readFile6, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
7685
- import { homedir } from "node:os";
7686
- import { join as join6 } from "node:path";
9966
+ import { spawn as spawn3 } from "node:child_process";
9967
+ import { createHash as createHash8 } from "node:crypto";
9968
+ import { mkdir as mkdir5, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
9969
+ import { homedir as homedir2 } from "node:os";
9970
+ import { join as join8 } from "node:path";
7687
9971
  var MISSING = [
7688
9972
  "edge-tts is not installed, so narration cannot be synthesised.",
7689
9973
  "",
@@ -7694,14 +9978,14 @@ var MISSING = [
7694
9978
  ].join("\n");
7695
9979
  function candidates() {
7696
9980
  const env2 = process.env.DECKSMITH_EDGE_TTS?.trim();
7697
- const home = homedir();
9981
+ const home = homedir2();
7698
9982
  return [
7699
9983
  ...env2 ? [[env2]] : [],
7700
9984
  ["edge-tts"],
7701
9985
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
7702
9986
  // default, and both are where this actually lands in practice.
7703
- [join6(home, "Library", "Python", "3.9", "bin", "edge-tts")],
7704
- [join6(home, ".local", "bin", "edge-tts")],
9987
+ [join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
9988
+ [join8(home, ".local", "bin", "edge-tts")],
7705
9989
  // Last resort: the module is installed even though its console script is
7706
9990
  // nowhere findable, which is the normal state of a pip --user install.
7707
9991
  ["python3", "-m", "edge_tts"]
@@ -7715,19 +9999,19 @@ async function answersHelp(argv) {
7715
9999
  }
7716
10000
  var resolved = null;
7717
10001
  function resolveEdgeTts(can = answersHelp) {
7718
- if (can !== answersHelp) return find(can);
7719
- resolved ??= find(can);
10002
+ if (can !== answersHelp) return find2(can);
10003
+ resolved ??= find2(can);
7720
10004
  return resolved;
7721
10005
  }
7722
- async function find(can) {
10006
+ async function find2(can) {
7723
10007
  for (const argv of candidates()) {
7724
10008
  if (await can(argv)) return argv;
7725
10009
  }
7726
10010
  throw new Error(MISSING);
7727
10011
  }
7728
10012
  function runArgv(cmd, args) {
7729
- return new Promise((resolve6) => {
7730
- const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10013
+ return new Promise((resolve7) => {
10014
+ const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
7731
10015
  let stdout = "";
7732
10016
  let stderr = "";
7733
10017
  child.stdout.on("data", (b) => {
@@ -7736,8 +10020,8 @@ function runArgv(cmd, args) {
7736
10020
  child.stderr.on("data", (b) => {
7737
10021
  stderr += b.toString();
7738
10022
  });
7739
- child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
7740
- child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
10023
+ child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
10024
+ child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
7741
10025
  });
7742
10026
  }
7743
10027
  var edgeTts = {
@@ -7804,8 +10088,8 @@ function edgeProvider(runner = edgeTts) {
7804
10088
  async speak(req) {
7805
10089
  const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
7806
10090
  await runner.speak({ ...req, subs });
7807
- const raw2 = await readFile6(subs, "utf8").catch(() => "");
7808
- await rm4(subs, { force: true });
10091
+ const raw2 = await readFile8(subs, "utf8").catch(() => "");
10092
+ await rm6(subs, { force: true });
7809
10093
  const cues = parseCues(raw2);
7810
10094
  const measured = await runner.measure(req.audio);
7811
10095
  const lastCue = cues.length > 0 ? cues[cues.length - 1].end : 0;
@@ -7814,7 +10098,7 @@ function edgeProvider(runner = edgeTts) {
7814
10098
  };
7815
10099
  }
7816
10100
  function cacheKey2(text2, voice, rate, pitch) {
7817
- return createHash6("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
10101
+ return createHash8("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
7818
10102
  }
7819
10103
  async function synthesize(text2, opts) {
7820
10104
  const rate = opts.rate ?? "+0%";
@@ -7822,11 +10106,11 @@ async function synthesize(text2, opts) {
7822
10106
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
7823
10107
  const key = cacheKey2(text2, opts.voice, rate, pitch);
7824
10108
  const file = `${key}.mp3`;
7825
- const audio = join6(opts.dir, file);
7826
- const sidecar = join6(opts.dir, `${key}.json`);
10109
+ const audio = join8(opts.dir, file);
10110
+ const sidecar = join8(opts.dir, `${key}.json`);
7827
10111
  const cached = await readSidecar(sidecar);
7828
10112
  if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
7829
- await mkdir4(opts.dir, { recursive: true });
10113
+ await mkdir5(opts.dir, { recursive: true });
7830
10114
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
7831
10115
  const { cues, seconds } = spoken;
7832
10116
  const clamped = cues.map((c) => ({
@@ -7835,13 +10119,13 @@ async function synthesize(text2, opts) {
7835
10119
  text: c.text
7836
10120
  }));
7837
10121
  const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
7838
- await writeFile6(sidecar, `${JSON.stringify(body, null, 2)}
10122
+ await writeFile7(sidecar, `${JSON.stringify(body, null, 2)}
7839
10123
  `);
7840
10124
  return { audio, file, seconds, cues: clamped };
7841
10125
  }
7842
10126
  async function readSidecar(path2) {
7843
10127
  try {
7844
- const parsed = JSON.parse(await readFile6(path2, "utf8"));
10128
+ const parsed = JSON.parse(await readFile8(path2, "utf8"));
7845
10129
  const s = parsed;
7846
10130
  return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
7847
10131
  } catch {
@@ -7894,7 +10178,7 @@ function stopCount(holds) {
7894
10178
  return Math.max(1, usable.size);
7895
10179
  }
7896
10180
  function stopsFor(beat, source, format, sid = "s1") {
7897
- const ctx = { source, format, theme: ink, sid };
10181
+ const ctx = { source, format, theme: ink, sid, start: 0 };
7898
10182
  try {
7899
10183
  return stopCount(emitScene(beat, ctx).holds);
7900
10184
  } catch {
@@ -7933,7 +10217,7 @@ async function narrate(storyboard, source, prefs, opts) {
7933
10217
  for (const [i, beat] of storyboard.beats.entries()) {
7934
10218
  const text2 = beat.narration?.trim();
7935
10219
  if (!text2) continue;
7936
- const segments = [];
10220
+ const segments2 = [];
7937
10221
  const stops = Math.min(stopsFor(beat, source, format, `s${i + 1}`), paced.speakingStops);
7938
10222
  const plan = planSegments(text2, stops);
7939
10223
  for (const [stop, line2] of plan.entries()) {
@@ -7945,7 +10229,7 @@ async function narrate(storyboard, source, prefs, opts) {
7945
10229
  dir: opts.dir,
7946
10230
  runner: opts.runner
7947
10231
  });
7948
- segments.push({
10232
+ segments2.push({
7949
10233
  stop,
7950
10234
  text: line2,
7951
10235
  // Content-addressed and flat, so the path is the filename and the deck
@@ -7955,37 +10239,21 @@ async function narrate(storyboard, source, prefs, opts) {
7955
10239
  cues: speech.cues
7956
10240
  });
7957
10241
  }
7958
- if (segments.length > 0) beats[beat.id] = segments;
10242
+ if (segments2.length > 0) beats[beat.id] = segments2;
7959
10243
  }
7960
10244
  return { voice, beats };
7961
10245
  }
7962
10246
 
7963
10247
  // src/verify/check.ts
7964
- import { execFile } from "node:child_process";
7965
- import { promisify } from "node:util";
7966
- var run = promisify(execFile);
7967
-
7968
- // src/render/capture.ts
7969
- import { homedir as homedir2 } from "node:os";
7970
- import { join as join7 } from "node:path";
7971
- async function chromePath(need = "open the deck with") {
7972
- const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
7973
- if (explicit) return explicit;
7974
- const { getInstalledBrowsers } = await import("@puppeteer/browsers");
7975
- const cacheDir = process.env.PUPPETEER_CACHE_DIR || join7(homedir2(), ".cache", "puppeteer");
7976
- const installed = await getInstalledBrowsers({ cacheDir }).catch(() => []);
7977
- const found = installed.find((b) => b.browser === "chrome-headless-shell") ?? installed.find((b) => b.browser === "chrome");
7978
- if (found) return found.executablePath;
7979
- throw new Error(
7980
- `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
7981
- );
7982
- }
7983
-
7984
- // src/verify/drift.ts
7985
10248
  import { execFile as execFile2 } from "node:child_process";
7986
10249
  import { promisify as promisify2 } from "node:util";
7987
10250
  var run2 = promisify2(execFile2);
7988
10251
 
10252
+ // src/verify/drift.ts
10253
+ import { execFile as execFile3 } from "node:child_process";
10254
+ import { promisify as promisify3 } from "node:util";
10255
+ var run3 = promisify3(execFile3);
10256
+
7989
10257
  // src/verify/index.ts
7990
10258
  function scanBeatCount(storyboard, prefs) {
7991
10259
  const got = storyboard.beats.length;
@@ -8004,12 +10272,12 @@ function scanBeatCount(storyboard, prefs) {
8004
10272
  }
8005
10273
 
8006
10274
  // src/render/render.ts
8007
- import { mkdir as mkdir6, readFile as readFile7, rename as rename2, rm as rm5, writeFile as writeFile8 } from "node:fs/promises";
8008
- import { basename, dirname, join as join9, resolve as resolve2 } from "node:path";
10275
+ import { mkdir as mkdir7, readFile as readFile9, rename as rename2, rm as rm7, writeFile as writeFile9 } from "node:fs/promises";
10276
+ import { basename as basename3, dirname, join as join10, resolve as resolve3 } from "node:path";
8009
10277
 
8010
10278
  // src/render/captions.ts
8011
- import { mkdir as mkdir5, writeFile as writeFile7 } from "node:fs/promises";
8012
- import { join as join8 } from "node:path";
10279
+ import { mkdir as mkdir6, writeFile as writeFile8 } from "node:fs/promises";
10280
+ import { join as join9 } from "node:path";
8013
10281
  import { pathToFileURL } from "node:url";
8014
10282
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
8015
10283
  function cssString(value) {
@@ -8065,7 +10333,7 @@ ${bands}
8065
10333
  </div>
8066
10334
  `;
8067
10335
  }
8068
- function measure2(count) {
10336
+ function measure3(count) {
8069
10337
  const doc = document;
8070
10338
  const probe3 = doc.getElementById("c0");
8071
10339
  if (probe3) {
@@ -8093,247 +10361,81 @@ async function captionBlocker() {
8093
10361
  try {
8094
10362
  await import("puppeteer-core");
8095
10363
  await chromePath(CAPTION_NEED);
8096
- return null;
8097
- } catch (err) {
8098
- return err instanceof Error ? err.message : String(err);
8099
- }
8100
- }
8101
- async function renderCaptions(cues, style, deck, work2) {
8102
- if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
8103
- const fontCss = join8(deck, DECK_FONT_CSS);
8104
- const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL(fontCss).href : null;
8105
- const page = join8(work2, "captions.html");
8106
- await mkdir5(work2, { recursive: true });
8107
- await writeFile7(page, captionPage(cues, style, href));
8108
- const { default: puppeteer } = await import("puppeteer-core");
8109
- const browser = await puppeteer.launch({
8110
- executablePath: await chromePath(CAPTION_NEED),
8111
- headless: true,
8112
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
8113
- });
8114
- try {
8115
- const tab = await browser.newPage();
8116
- await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
8117
- await tab.goto(pathToFileURL(page).href, { waitUntil: "load" });
8118
- await tab.evaluate(() => document.fonts.ready);
8119
- const rects = await tab.evaluate(measure2, cues.length);
8120
- const box = union(rects);
8121
- const files = [];
8122
- for (const [i, cue] of cues.entries()) {
8123
- void cue;
8124
- const name = `cap${String(i).padStart(4, "0")}.png`;
8125
- await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
8126
- await tab.screenshot({
8127
- path: join8(work2, name),
8128
- type: "png",
8129
- omitBackground: true,
8130
- clip: { x: box.x, y: box.y, width: box.width, height: box.height }
8131
- });
8132
- await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
8133
- files.push(name);
8134
- }
8135
- return { files, ...box };
8136
- } finally {
8137
- await browser.close();
8138
- }
8139
- }
8140
- function union(rects) {
8141
- const left = Math.floor(Math.min(...rects.map((r) => r.x)));
8142
- const top = Math.floor(Math.min(...rects.map((r) => r.y)));
8143
- const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
8144
- const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
8145
- return { x: left, y: top, width: right - left, height: bottom - top };
8146
- }
8147
- var t3 = (seconds) => seconds.toFixed(3);
8148
- function overlayGraph(cues, band, input = "0:v", output = "vout") {
8149
- return cues.map((cue, i) => {
8150
- const from = i === 0 ? `[${input}]` : `[v${i}]`;
8151
- const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
8152
- const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
8153
- return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
8154
- }).join(";\n");
8155
- }
8156
- function overlayInputs(band) {
8157
- return band.files.flatMap((file) => ["-i", file]);
8158
- }
8159
-
8160
- // src/render/ffmpeg.ts
8161
- import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
8162
- import { promisify as promisify3 } from "node:util";
8163
- var run3 = promisify3(execFile3);
8164
- var DEFAULT_TIMEOUT_MS2 = 36e5;
8165
- async function runTool(file, args, opts = {}) {
8166
- try {
8167
- return await run3(file, args, {
8168
- cwd: opts.cwd,
8169
- timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2,
8170
- maxBuffer: 64 << 20
8171
- });
8172
- } catch (err) {
8173
- const e = err;
8174
- if (e.code === "ENOENT") {
8175
- throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
8176
- }
8177
- const tail = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
8178
- throw new Error(`${file} failed:
8179
- ${tail}`);
8180
- }
8181
- }
8182
- function runLive(file, args) {
8183
- return new Promise((resolve6, reject) => {
8184
- const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
8185
- child.on("error", (err) => {
8186
- reject(
8187
- err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
8188
- );
8189
- });
8190
- child.on("close", (code, signal) => {
8191
- if (code === 0) resolve6();
8192
- else if (signal) {
8193
- reject(
8194
- new Error(
8195
- `${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.`
8196
- )
8197
- );
8198
- } else reject(new Error(`${file} exited ${code}.`));
8199
- });
8200
- });
8201
- }
8202
- async function probe(path2) {
8203
- const { stdout } = await runTool("ffprobe", [
8204
- "-v",
8205
- "error",
8206
- "-show_entries",
8207
- "stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
8208
- "-of",
8209
- "json",
8210
- path2
8211
- ]);
8212
- const json = JSON.parse(stdout);
8213
- const streams = json.streams ?? [];
8214
- const video = streams.find((s) => s.codec_type === "video");
8215
- if (!video) throw new Error(`${path2} has no video stream.`);
8216
- const [num2, den] = (video.r_frame_rate ?? "30/1").split("/");
8217
- const fps = Number(num2) / (Number(den) || 1);
8218
- const seconds = Number(json.format?.duration ?? 0);
8219
- const frames = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
8220
- return {
8221
- width: video.width ?? 0,
8222
- height: video.height ?? 0,
8223
- fps,
8224
- frames,
8225
- seconds,
8226
- hasAudio: streams.some((s) => s.codec_type === "audio")
8227
- };
8228
- }
8229
- function encoderArgs(fps) {
8230
- return [
8231
- "-c:v",
8232
- "libx264",
8233
- "-preset",
8234
- "veryfast",
8235
- "-crf",
8236
- "16",
8237
- "-pix_fmt",
8238
- "yuv420p",
8239
- "-g",
8240
- "12",
8241
- "-r",
8242
- String(fps),
8243
- "-fps_mode",
8244
- "cfr"
8245
- ];
8246
- }
8247
- function pieceFilter(motion, freeze) {
8248
- const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
8249
- if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
8250
- return chain.join(",");
8251
- }
8252
- function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
8253
- return [
8254
- "-y",
8255
- "-hide_banner",
8256
- "-loglevel",
8257
- "error",
8258
- // Half a frame in, so a time that is exactly on a boundary cannot round to
8259
- // the frame before it.
8260
- "-ss",
8261
- ((fromFrame + 0.5) / fps).toFixed(6),
8262
- "-i",
8263
- source,
8264
- "-an",
8265
- "-vf",
8266
- pieceFilter(motion, freeze),
8267
- "-frames:v",
8268
- String(motion + freeze),
8269
- ...encoderArgs(fps),
8270
- "-f",
8271
- "mpegts",
8272
- out
8273
- ];
10364
+ return null;
10365
+ } catch (err) {
10366
+ return err instanceof Error ? err.message : String(err);
10367
+ }
8274
10368
  }
8275
- var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
8276
- function audioGraph(inputs, seconds, first = 1) {
8277
- const lines = inputs.map(
8278
- (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
8279
- );
8280
- const labels = inputs.map((_, i) => `[d${i}]`).join("");
8281
- lines.push(
8282
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
8283
- );
8284
- return lines.join(";\n");
10369
+ async function renderCaptions(cues, style, deck, work2) {
10370
+ if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
10371
+ const fontCss = join9(deck, DECK_FONT_CSS);
10372
+ const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL(fontCss).href : null;
10373
+ const page = join9(work2, "captions.html");
10374
+ await mkdir6(work2, { recursive: true });
10375
+ await writeFile8(page, captionPage(cues, style, href));
10376
+ const { default: puppeteer } = await import("puppeteer-core");
10377
+ const browser = await puppeteer.launch({
10378
+ executablePath: await chromePath(CAPTION_NEED),
10379
+ headless: true,
10380
+ args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
10381
+ });
10382
+ try {
10383
+ const tab = await browser.newPage();
10384
+ await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
10385
+ await tab.goto(pathToFileURL(page).href, { waitUntil: "load" });
10386
+ await tab.evaluate(() => document.fonts.ready);
10387
+ const rects = await tab.evaluate(measure3, cues.length);
10388
+ const box = union(rects);
10389
+ const files = [];
10390
+ for (const [i, cue] of cues.entries()) {
10391
+ void cue;
10392
+ const name = `cap${String(i).padStart(4, "0")}.png`;
10393
+ await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
10394
+ await tab.screenshot({
10395
+ path: join9(work2, name),
10396
+ type: "png",
10397
+ omitBackground: true,
10398
+ clip: { x: box.x, y: box.y, width: box.width, height: box.height }
10399
+ });
10400
+ await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
10401
+ files.push(name);
10402
+ }
10403
+ return { files, ...box };
10404
+ } finally {
10405
+ await browser.close();
10406
+ }
8285
10407
  }
8286
- function respeedArgs(source, factor, chain, fps, hasAudio, out) {
8287
- const video = `[0:v]setpts=PTS/${factor}[v]`;
8288
- const audio = hasAudio ? `;[0:a]${chain.map((t2) => `atempo=${t2}`).join(",")}[a]` : "";
8289
- return [
8290
- "-y",
8291
- "-hide_banner",
8292
- "-loglevel",
8293
- "error",
8294
- "-i",
8295
- source,
8296
- "-filter_complex",
8297
- `${video}${audio}`,
8298
- "-map",
8299
- "[v]",
8300
- ...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
8301
- ...encoderArgs(fps),
8302
- out
8303
- ];
10408
+ function union(rects) {
10409
+ const left = Math.floor(Math.min(...rects.map((r) => r.x)));
10410
+ const top = Math.floor(Math.min(...rects.map((r) => r.y)));
10411
+ const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
10412
+ const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
10413
+ return { x: left, y: top, width: right - left, height: bottom - top };
8304
10414
  }
8305
- function burnStyle(width, height, font = "Arial") {
8306
- return {
8307
- width,
8308
- height,
8309
- font,
8310
- // MEASURED, not guessed. `splitCue` caps a cue at 84 characters and `wrap`
8311
- // breaks it near the middle, so the longer of the two lines runs to about
8312
- // 46 characters in real caption prose. Bold Arial advances 0.485em per
8313
- // character on that prose (measured in a browser over the demo's own
8314
- // narration), so 46 characters at F px is 22.3F wide, and the usable width
8315
- // here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
8316
- // "4% of the width" looked like on paper — overflows to a THIRD line, and a
8317
- // three-line band covers the bottom of the slide.
8318
- fontSize: Math.round(width * 0.037),
8319
- // Clear of the play button, the progress bar and the handle every vertical
8320
- // player draws across the bottom eighth of the frame.
8321
- marginV: Math.round(height * 0.09),
8322
- marginX: Math.round(width * 0.04)
8323
- };
10415
+ var t3 = (seconds) => seconds.toFixed(3);
10416
+ function overlayGraph(cues, band, input = "0:v", output = "vout") {
10417
+ return cues.map((cue, i) => {
10418
+ const from = i === 0 ? `[${input}]` : `[v${i}]`;
10419
+ const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
10420
+ const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
10421
+ return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
10422
+ }).join(";\n");
10423
+ }
10424
+ function overlayInputs(band) {
10425
+ return band.files.flatMap((file) => ["-i", file]);
8324
10426
  }
8325
10427
 
8326
10428
  // src/render/render.ts
8327
- var SRT_NAME = (out) => `${basename(out).replace(/\.[^.]+$/, "")}.srt`;
10429
+ var SRT_NAME = (out) => `${basename3(out).replace(/\.[^.]+$/, "")}.srt`;
8328
10430
  function subtitlePlan(mode) {
8329
10431
  return { sidecar: mode !== "none", burn: mode === "burn" };
8330
10432
  }
8331
10433
  async function render(opts) {
8332
10434
  const log = opts.log ?? (() => {
8333
10435
  });
8334
- const deck = resolve2(opts.deck);
8335
- const out = resolve2(opts.out);
8336
- const work2 = join9(dirname(out), `.${basename(out)}.parts`);
10436
+ const deck = resolve3(opts.deck);
10437
+ const out = resolve3(opts.out);
10438
+ const work2 = join10(dirname(out), `.${basename3(out)}.parts`);
8337
10439
  const timing = await readTiming(deck);
8338
10440
  if (opts.targetSeconds && !opts.allowFastPlayback) {
8339
10441
  const refusal = playbackRefusal(
@@ -8349,9 +10451,9 @@ async function render(opts) {
8349
10451
  if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
8350
10452
  }
8351
10453
  const burnable = plan0.burn;
8352
- await mkdir6(work2, { recursive: true });
10454
+ await mkdir7(work2, { recursive: true });
8353
10455
  try {
8354
- const raw2 = opts.video ? resolve2(opts.video) : await capture(deck, join9(work2, "raw.mp4"), opts, log);
10456
+ const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join10(work2, "raw.mp4"), opts, log);
8355
10457
  const shot = await probe(raw2);
8356
10458
  log(
8357
10459
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -8364,18 +10466,18 @@ async function render(opts) {
8364
10466
  );
8365
10467
  }
8366
10468
  const retimed = await retime(raw2, plan, work2, log);
8367
- const srtPath = join9(dirname(out), SRT_NAME(out));
10469
+ const srtPath = join10(dirname(out), SRT_NAME(out));
8368
10470
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
8369
10471
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
8370
10472
  const srt = plan0.sidecar ? toSrt(cues) : "";
8371
- if (srt) await writeFile8(srtPath, srt);
10473
+ if (srt) await writeFile9(srtPath, srt);
8372
10474
  const burn = burnable && srt.length > 0;
8373
10475
  await mux(retimed, timing, plan, deck, out, work2, burn ? plan.cues : void 0, shot.fps, log);
8374
10476
  if (playback > 1) {
8375
10477
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
8376
10478
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
8377
10479
  if (warning) log(`render: ${warning}`);
8378
- const fast = join9(work2, `fast.${basename(out)}`);
10480
+ const fast = join10(work2, `fast.${basename3(out)}`);
8379
10481
  const muxed = await probe(out);
8380
10482
  await runTool(
8381
10483
  "ffmpeg",
@@ -8401,12 +10503,12 @@ async function render(opts) {
8401
10503
  captionCps: p95CueRate(cues)
8402
10504
  };
8403
10505
  } finally {
8404
- if (!opts.keep) await rm5(work2, { recursive: true, force: true });
10506
+ if (!opts.keep) await rm7(work2, { recursive: true, force: true });
8405
10507
  }
8406
10508
  }
8407
10509
  async function readTiming(deck) {
8408
- const path2 = join9(deck, TIMING_FILE);
8409
- const text2 = await readFile7(path2, "utf8").catch(() => {
10510
+ const path2 = join10(deck, TIMING_FILE);
10511
+ const text2 = await readFile9(path2, "utf8").catch(() => {
8410
10512
  throw new Error(
8411
10513
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
8412
10514
  );
@@ -8445,20 +10547,20 @@ async function retime(raw2, plan, work2, log) {
8445
10547
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
8446
10548
  const list = [];
8447
10549
  for (const [i, piece] of plan.pieces.entries()) {
8448
- const file = join9(work2, `p${String(i).padStart(4, "0")}.ts`);
10550
+ const file = join10(work2, `p${String(i).padStart(4, "0")}.ts`);
8449
10551
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
8450
10552
  list.push(file);
8451
10553
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
8452
10554
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
8453
10555
  }
8454
10556
  }
8455
- const listFile = join9(work2, "pieces.txt");
8456
- await writeFile8(
10557
+ const listFile = join10(work2, "pieces.txt");
10558
+ await writeFile9(
8457
10559
  listFile,
8458
10560
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
8459
10561
  `
8460
10562
  );
8461
- const joined = join9(work2, "retimed.mp4");
10563
+ const joined = join10(work2, "retimed.mp4");
8462
10564
  await runTool("ffmpeg", [
8463
10565
  "-y",
8464
10566
  "-hide_banner",
@@ -8480,7 +10582,7 @@ async function retime(raw2, plan, work2, log) {
8480
10582
  }
8481
10583
  async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
8482
10584
  const inputs = plan.audio.map((a) => ({
8483
- file: join9(deck, timing.audioDir, a.audio),
10585
+ file: join10(deck, timing.audioDir, a.audio),
8484
10586
  delayMs: a.delayMs
8485
10587
  }));
8486
10588
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -8501,8 +10603,8 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
8501
10603
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
8502
10604
  }
8503
10605
  if (graph.length > 0) {
8504
- const script = join9(work2, "mux.filter");
8505
- await writeFile8(script, `${graph.join(";\n")}
10606
+ const script = join10(work2, "mux.filter");
10607
+ await writeFile9(script, `${graph.join(";\n")}
8506
10608
  `);
8507
10609
  args.push("-filter_complex_script", script);
8508
10610
  }
@@ -8517,13 +10619,13 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
8517
10619
  log(
8518
10620
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
8519
10621
  );
8520
- await mkdir6(dirname(out), { recursive: true });
10622
+ await mkdir7(dirname(out), { recursive: true });
8521
10623
  await runTool("ffmpeg", args, { cwd: work2 });
8522
10624
  }
8523
10625
 
8524
10626
  // src/pack/pack.ts
8525
- import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile9 } from "node:fs/promises";
8526
- import { dirname as dirname2, extname as extname2 } from "node:path";
10627
+ import { mkdir as mkdir8, readFile as readFile10, writeFile as writeFile10 } from "node:fs/promises";
10628
+ import { dirname as dirname2, extname as extname3 } from "node:path";
8527
10629
  import { unzipSync, zipSync } from "fflate";
8528
10630
  var MTIME = Date.UTC(1980, 0, 2, 12);
8529
10631
  var STORED = /* @__PURE__ */ new Set([
@@ -8559,11 +10661,11 @@ async function writePack(pack3, files, out) {
8559
10661
  if (!bytes) continue;
8560
10662
  if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
8561
10663
  check2(path2);
8562
- entries[path2] = STORED.has(extname2(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
10664
+ entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
8563
10665
  }
8564
10666
  const zip = zipSync(entries, { mtime: MTIME });
8565
- await mkdir7(dirname2(out), { recursive: true });
8566
- await writeFile9(out, zip);
10667
+ await mkdir8(dirname2(out), { recursive: true });
10668
+ await writeFile10(out, zip);
8567
10669
  return zip.length;
8568
10670
  }
8569
10671
  function check2(label, path2 = label) {
@@ -8571,137 +10673,6 @@ function check2(label, path2 = label) {
8571
10673
  throw new Error(`${label}: unsafe path in pack`);
8572
10674
  }
8573
10675
 
8574
- // src/pack/media.ts
8575
- import { createHash as createHash7 } from "node:crypto";
8576
- import { readFile as readFile9 } from "node:fs/promises";
8577
- import { extname as extname3 } from "node:path";
8578
- var PLAYERS = [
8579
- "youtube.com",
8580
- "youtube-nocookie.com",
8581
- "youtu.be",
8582
- "vimeo.com",
8583
- "dailymotion.com",
8584
- "dai.ly",
8585
- "twitch.tv",
8586
- "loom.com",
8587
- "wistia.com",
8588
- "wistia.net",
8589
- "streamable.com",
8590
- "bilibili.com",
8591
- "soundcloud.com",
8592
- "tiktok.com"
8593
- ];
8594
- var FILE_EXT = /* @__PURE__ */ new Set([
8595
- ".png",
8596
- ".jpg",
8597
- ".jpeg",
8598
- ".gif",
8599
- ".webp",
8600
- ".avif",
8601
- ".svg",
8602
- ".mp4",
8603
- ".webm",
8604
- ".mov",
8605
- ".m4v",
8606
- ".mp3",
8607
- ".m4a",
8608
- ".wav",
8609
- ".ogg",
8610
- ".opus",
8611
- ".pdf"
8612
- ]);
8613
- function isEmbed(url) {
8614
- const host2 = hostOf(url);
8615
- return host2 !== null && PLAYERS.some((p) => host2 === p || host2.endsWith(`.${p}`));
8616
- }
8617
- function policyFor(url, prefer) {
8618
- if (isEmbed(url)) return "embed";
8619
- const host2 = hostOf(url);
8620
- if (host2 === null || /^data:/i.test(url)) return "bake";
8621
- if (prefer === "link") return "link";
8622
- return FILE_EXT.has(extOf(url)) ? "bake" : "link";
8623
- }
8624
- async function planMedia(assets, fetcher = fetchAsset) {
8625
- const plan = {
8626
- media: [],
8627
- files: {},
8628
- bakedCount: 0,
8629
- bakedBytes: 0,
8630
- demoted: [],
8631
- promoted: []
8632
- };
8633
- for (const asset of assets) {
8634
- const policy = policyFor(asset.url, asset.prefer);
8635
- if (policy !== "bake") {
8636
- if (asset.prefer === "bake") plan.demoted.push(asset.id);
8637
- plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
8638
- continue;
8639
- }
8640
- if (asset.prefer === "link") plan.promoted.push(asset.id);
8641
- const got = await fetcher(asset.url);
8642
- const mime = clean(got.mime) ?? asset.mime;
8643
- if (mime === "text/html") {
8644
- plan.demoted.push(asset.id);
8645
- plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
8646
- continue;
8647
- }
8648
- const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
8649
- plan.files[path2] = got.bytes;
8650
- plan.bakedCount += 1;
8651
- plan.bakedBytes += got.bytes.length;
8652
- plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
8653
- }
8654
- return plan;
8655
- }
8656
- function bakedName(id2, url, mime) {
8657
- const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
8658
- const hash = createHash7("sha256").update(url).digest("hex").slice(0, 8);
8659
- return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
8660
- }
8661
- function extOf(url) {
8662
- const path2 = hostOf(url) === null ? url : new URL(url).pathname;
8663
- const ext = extname3(path2).toLowerCase();
8664
- return FILE_EXT.has(ext) ? ext : "";
8665
- }
8666
- var MIME_EXT = {
8667
- "image/png": ".png",
8668
- "image/jpeg": ".jpg",
8669
- "image/gif": ".gif",
8670
- "image/webp": ".webp",
8671
- "image/avif": ".avif",
8672
- "image/svg+xml": ".svg",
8673
- "video/mp4": ".mp4",
8674
- "video/webm": ".webm",
8675
- "audio/mpeg": ".mp3",
8676
- "application/pdf": ".pdf"
8677
- };
8678
- function extFor(mime) {
8679
- return (mime && MIME_EXT[mime]) ?? ".bin";
8680
- }
8681
- function hostOf(url) {
8682
- try {
8683
- const u = new URL(url);
8684
- if (u.protocol === "file:" || u.protocol === "data:") return null;
8685
- return u.hostname.toLowerCase().replace(/^www\./, "");
8686
- } catch {
8687
- return null;
8688
- }
8689
- }
8690
- function clean(mime) {
8691
- return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
8692
- }
8693
- async function fetchAsset(url) {
8694
- if (/^(https?|data):/i.test(url)) {
8695
- const res = await fetch(url);
8696
- if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
8697
- return {
8698
- bytes: new Uint8Array(await res.arrayBuffer()),
8699
- mime: res.headers.get("content-type") ?? void 0
8700
- };
8701
- }
8702
- return { bytes: new Uint8Array(await readFile9(url)) };
8703
- }
8704
-
8705
10676
  // src/prefs.ts
8706
10677
  var PREF_KEYS = Object.keys(prefsSchema.shape);
8707
10678
  var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
@@ -8713,8 +10684,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
8713
10684
  if (!format) throw new Error("no deck-16x9 format");
8714
10685
  const step = opts.onStep ?? (() => {
8715
10686
  });
8716
- const out = resolve3(outDir);
8717
- await mkdir8(out, { recursive: true });
10687
+ const out = resolve4(outDir);
10688
+ await mkdir9(out, { recursive: true });
8718
10689
  const speed = opts.speed ?? 1;
8719
10690
  const fontCss = await refreshFont(storyboard, source, out, step);
8720
10691
  const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
@@ -8726,8 +10697,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
8726
10697
  });
8727
10698
  const files = [];
8728
10699
  const write = async (name, text2) => {
8729
- await writeFile10(join10(out, name), text2);
8730
- files.push(join10(out, name));
10700
+ await writeFile11(join11(out, name), text2);
10701
+ files.push(join11(out, name));
8731
10702
  };
8732
10703
  await write("index.html", deck.composition);
8733
10704
  await write("hyperframes.json", HYPERFRAMES_JSON);
@@ -8756,8 +10727,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
8756
10727
  }
8757
10728
  if (deck.page) {
8758
10729
  await write(DECK_PAGE, deck.page);
8759
- await cp(playerBundle(), join10(out, PLAYER_FILE));
8760
- files.push(join10(out, PLAYER_FILE));
10730
+ await cp(playerBundle(), join11(out, PLAYER_FILE));
10731
+ files.push(join11(out, PLAYER_FILE));
8761
10732
  }
8762
10733
  files.push(...await vendorKatex(out));
8763
10734
  if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
@@ -8766,7 +10737,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
8766
10737
  }
8767
10738
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
8768
10739
  step(
8769
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join10(out, "index.html")}`
10740
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join11(out, "index.html")}`
8770
10741
  );
8771
10742
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
8772
10743
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -8784,7 +10755,7 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
8784
10755
  async function deckRuntime() {
8785
10756
  const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
8786
10757
  try {
8787
- return await readFile10(path2, "utf8");
10758
+ return await readFile11(path2, "utf8");
8788
10759
  } catch {
8789
10760
  throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
8790
10761
  }
@@ -8792,57 +10763,57 @@ async function deckRuntime() {
8792
10763
  function playerBundle() {
8793
10764
  const require2 = createRequire2(import.meta.url);
8794
10765
  try {
8795
- return join10(dirname3(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
10766
+ return join11(dirname3(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
8796
10767
  } catch {
8797
10768
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
8798
10769
  }
8799
10770
  }
8800
10771
  async function vendorKatex(out) {
8801
10772
  const require2 = createRequire2(import.meta.url);
8802
- const dist = join10(dirname3(require2.resolve("katex/package.json")), "dist");
8803
- const css = await readFile10(join10(dist, "katex.min.css"), "utf8");
10773
+ const dist = join11(dirname3(require2.resolve("katex/package.json")), "dist");
10774
+ const css = await readFile11(join11(dist, "katex.min.css"), "utf8");
8804
10775
  const written = [];
8805
- await mkdir8(join10(out, "katex/fonts"), { recursive: true });
8806
- for (const file of await readdir(join10(dist, "fonts"))) {
10776
+ await mkdir9(join11(out, "katex/fonts"), { recursive: true });
10777
+ for (const file of await readdir2(join11(dist, "fonts"))) {
8807
10778
  if (!file.endsWith(".woff2")) continue;
8808
- await cp(join10(dist, "fonts", file), join10(out, "katex/fonts", file));
8809
- written.push(join10(out, "katex/fonts", file));
10779
+ await cp(join11(dist, "fonts", file), join11(out, "katex/fonts", file));
10780
+ written.push(join11(out, "katex/fonts", file));
8810
10781
  }
8811
10782
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
8812
10783
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
8813
10784
  return kept ? `src:${kept}` : whole;
8814
10785
  });
8815
- await writeFile10(join10(out, "katex/katex.min.css"), woff2Only);
8816
- written.push(join10(out, "katex/katex.min.css"));
10786
+ await writeFile11(join11(out, "katex/katex.min.css"), woff2Only);
10787
+ written.push(join11(out, "katex/katex.min.css"));
8817
10788
  return written;
8818
10789
  }
8819
10790
  async function copyAssets(sourceDir, out) {
8820
- const from = join10(resolve3(sourceDir), "assets");
8821
- if (!await stat(from).catch(() => null)) return [];
8822
- await cp(from, join10(out, "assets"), { recursive: true });
8823
- return [join10(out, "assets")];
10791
+ const from = join11(resolve4(sourceDir), "assets");
10792
+ if (!await stat2(from).catch(() => null)) return [];
10793
+ await cp(from, join11(out, "assets"), { recursive: true });
10794
+ return [join11(out, "assets")];
8824
10795
  }
8825
10796
  async function copyAudio(from, narration, out) {
8826
- const dir = join10(out, narration.dir);
8827
- await mkdir8(dir, { recursive: true });
10797
+ const dir = join11(out, narration.dir);
10798
+ await mkdir9(dir, { recursive: true });
8828
10799
  const names = [
8829
10800
  ...new Set(
8830
10801
  Object.values(narration.beats).flat().map((s) => s.audio)
8831
10802
  )
8832
10803
  ].sort();
8833
10804
  for (const name of names) {
8834
- await cp(join10(resolve3(from), name), join10(dir, name)).catch(() => {
10805
+ await cp(join11(resolve4(from), name), join11(dir, name)).catch(() => {
8835
10806
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
8836
10807
  });
8837
10808
  }
8838
- return names.map((n3) => join10(dir, n3));
10809
+ return names.map((n3) => join11(dir, n3));
8839
10810
  }
8840
10811
  async function refreshFont(storyboard, source, out, step) {
8841
10812
  try {
8842
10813
  const bundle = await bundleFont(
8843
10814
  storyboard.lang,
8844
10815
  JSON.stringify(source) + JSON.stringify(storyboard),
8845
- join10(out, "assets", "fonts")
10816
+ join11(out, "assets", "fonts")
8846
10817
  );
8847
10818
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
8848
10819
  return bundle?.css;
@@ -8867,8 +10838,8 @@ var MARKDOWN_EXTS = [".md", ".markdown", ".txt"];
8867
10838
  var UploadError = class extends Error {
8868
10839
  hint;
8869
10840
  status;
8870
- constructor(message, hint, status = 400) {
8871
- super(message);
10841
+ constructor(message2, hint, status = 400) {
10842
+ super(message2);
8872
10843
  this.name = "UploadError";
8873
10844
  this.hint = hint;
8874
10845
  this.status = status;
@@ -8879,10 +10850,10 @@ function looksLikeZip(bytes) {
8879
10850
  }
8880
10851
  function safeEntryPath(name) {
8881
10852
  if (name.includes("\0")) return null;
8882
- const segments = name.replace(/\\/g, "/").split("/");
10853
+ const segments2 = name.replace(/\\/g, "/").split("/");
8883
10854
  if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
8884
10855
  const kept = [];
8885
- for (const segment of segments) {
10856
+ for (const segment of segments2) {
8886
10857
  if (segment === "" || segment === ".") continue;
8887
10858
  if (segment === "..") return null;
8888
10859
  kept.push(segment);
@@ -8925,14 +10896,14 @@ function readZip(bytes, limits = ZIP_LIMITS) {
8925
10896
  }
8926
10897
  if (entry.originalSize > limits.maxEntryBytes) {
8927
10898
  throw new UploadError(
8928
- `"${entry.name}" unpacks to ${mb(entry.originalSize)}, over the ${mb(limits.maxEntryBytes)} per-file limit.`,
10899
+ `"${entry.name}" unpacks to ${mb2(entry.originalSize)}, over the ${mb2(limits.maxEntryBytes)} per-file limit.`,
8929
10900
  "Leave the large file out; a deck reads the document and its figures, not the dataset."
8930
10901
  );
8931
10902
  }
8932
10903
  total += entry.originalSize;
8933
10904
  if (total > limits.maxTotalBytes) {
8934
10905
  throw new UploadError(
8935
- `The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
10906
+ `The archive unpacks to more than ${mb2(limits.maxTotalBytes)}.`,
8936
10907
  "Send only the document and the figures it cites."
8937
10908
  );
8938
10909
  }
@@ -8959,7 +10930,7 @@ function readZip(bytes, limits = ZIP_LIMITS) {
8959
10930
  actual += data.length;
8960
10931
  if (actual > limits.maxTotalBytes) {
8961
10932
  throw new UploadError(
8962
- `The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
10933
+ `The archive unpacks to more than ${mb2(limits.maxTotalBytes)}.`,
8963
10934
  "Send only the document and the figures it cites."
8964
10935
  );
8965
10936
  }
@@ -8992,7 +10963,7 @@ function pickMarkdown(files) {
8992
10963
  function isNoise(name) {
8993
10964
  return name.startsWith("__MACOSX/") || name.includes("/__MACOSX/") || posix.basename(name) === ".DS_Store" || posix.basename(name) === "Thumbs.db";
8994
10965
  }
8995
- function mb(bytes) {
10966
+ function mb2(bytes) {
8996
10967
  return `${Math.round(bytes / 1024 / 1024)} MB`;
8997
10968
  }
8998
10969
 
@@ -9116,8 +11087,8 @@ function withCanvas(preset, fields, warnings) {
9116
11087
  `Send both, or neither and let the "${preset.id}" preset decide.`
9117
11088
  );
9118
11089
  }
9119
- const width = even("width", fields.width, warnings);
9120
- const height = even("height", fields.height, warnings);
11090
+ const width = even2("width", fields.width, warnings);
11091
+ const height = even2("height", fields.height, warnings);
9121
11092
  const problem = canvasProblem(width, height);
9122
11093
  if (problem) {
9123
11094
  throw new UploadError(
@@ -9134,7 +11105,7 @@ function withCanvas(preset, fields, warnings) {
9134
11105
  warnings.push(...canvasWarnings(width, height));
9135
11106
  return resizeFormat(preset, width, height);
9136
11107
  }
9137
- function even(name, raw2, warnings) {
11108
+ function even2(name, raw2, warnings) {
9138
11109
  const n3 = int(name, raw2);
9139
11110
  if (n3 % 2 === 0 || !Number.isFinite(n3)) return n3;
9140
11111
  warnings.push(`${name} ${n3} rounded down to ${n3 - 1}; h264 needs even dimensions`);
@@ -9197,11 +11168,18 @@ function int(name, raw2) {
9197
11168
  }
9198
11169
 
9199
11170
  // src/server/pipeline.ts
9200
- import { mkdir as mkdir9, readFile as readFile11, stat as stat2, writeFile as writeFile11 } from "node:fs/promises";
9201
- import { dirname as dirname4, join as join11, relative, resolve as resolve4 } from "node:path";
11171
+ import { mkdir as mkdir10, readFile as readFile12, stat as stat3, writeFile as writeFile12 } from "node:fs/promises";
11172
+ import { dirname as dirname4, isAbsolute, join as join12, relative, resolve as resolve5 } from "node:path";
9202
11173
  var AUDIO_DIR = "audio";
9203
11174
  var NARRATION_FILE = "narration.json";
9204
11175
  var MAX_REMOTE_FIGURES = 40;
11176
+ var HARVEST_LIMITS = {
11177
+ maxBytes: 4 * 1024 * 1024,
11178
+ maxAssetBytes: 8 * 1024 * 1024,
11179
+ timeoutMs: 12e3,
11180
+ maxAssets: 24
11181
+ };
11182
+ var HARVEST_DEADLINE_MS = 9e4;
9205
11183
  var FEED_FORMATS = /* @__PURE__ */ new Set(["short-9x16", "post-1x1"]);
9206
11184
  function stagesFor(options) {
9207
11185
  const stages = ["ingest", "plan"];
@@ -9214,10 +11192,10 @@ function stagesFor(options) {
9214
11192
  async function runPipeline(job, input) {
9215
11193
  const { options } = input;
9216
11194
  const dirs = {
9217
- upload: join11(job.dir, "upload"),
9218
- src: join11(job.dir, "src"),
9219
- audio: join11(job.dir, AUDIO_DIR),
9220
- deck: join11(job.dir, "deck")
11195
+ upload: join12(job.dir, "upload"),
11196
+ src: join12(job.dir, "src"),
11197
+ audio: join12(job.dir, AUDIO_DIR),
11198
+ deck: join12(job.dir, "deck")
9221
11199
  };
9222
11200
  const warnings = [...options.warnings];
9223
11201
  const url = (rel) => `/d/${job.id}/${rel}`;
@@ -9241,21 +11219,21 @@ async function runPipeline(job, input) {
9241
11219
  theme: options.stated.theme ? prefs.theme : planned.theme
9242
11220
  };
9243
11221
  assertRefsResolve(storyboard, source, { pending: options.images ? "allow" : "refuse" });
9244
- await writeJson(join11(job.dir, "storyboard.json"), storyboard);
11222
+ await writeJson(join12(job.dir, "storyboard.json"), storyboard);
9245
11223
  job.done("plan", `${storyboard.beats.length} beats`);
9246
11224
  for (const f of scanBeatCount(storyboard, prefs)) warnings.push(f.message);
9247
11225
  if (options.images) {
9248
11226
  job.begin("illustrate");
9249
11227
  const drawn = await illustrate(storyboard, source, {
9250
11228
  prefs,
9251
- assetsDir: join11(dirs.src, "assets"),
11229
+ assetsDir: join12(dirs.src, "assets"),
9252
11230
  ...input.imageChain ? { chain: input.imageChain } : {},
9253
11231
  onStep: (line2) => job.log(line2)
9254
11232
  });
9255
11233
  storyboard = drawn.storyboard;
9256
11234
  source = drawn.source;
9257
- await writeJson(join11(dirs.src, "source.json"), source);
9258
- await writeJson(join11(job.dir, "storyboard.json"), storyboard);
11235
+ await writeJson(join12(dirs.src, "source.json"), source);
11236
+ await writeJson(join12(job.dir, "storyboard.json"), storyboard);
9259
11237
  for (const p of drawn.illustrated) {
9260
11238
  job.log(
9261
11239
  `illustrate: ${p.beatId} \u2192 assets/${p.src} via ${p.provider}${p.cached ? " (cached)" : ""}`
@@ -9277,16 +11255,16 @@ async function runPipeline(job, input) {
9277
11255
  warnings.push("the plan had nothing to say aloud, so the deck is silent");
9278
11256
  } else {
9279
11257
  job.log(`narrate: ${speaking} of ${storyboard.beats.length} beats speak`);
9280
- await mkdir9(dirs.audio, { recursive: true });
11258
+ await mkdir10(dirs.audio, { recursive: true });
9281
11259
  const spoken = await narrate(storyboard, source, prefs, {
9282
11260
  dir: dirs.audio,
9283
11261
  format: options.format
9284
11262
  });
9285
- await writeJson(join11(dirs.audio, NARRATION_FILE), spoken);
11263
+ await writeJson(join12(dirs.audio, NARRATION_FILE), spoken);
9286
11264
  narration = { voice: spoken.voice, dir: AUDIO_DIR, beats: spoken.beats };
9287
- const segments = Object.values(spoken.beats).flat();
9288
- const seconds = segments.reduce((sum, s) => sum + s.seconds, 0);
9289
- job.done("narrate", `${segments.length} segments, ${seconds.toFixed(1)}s in ${spoken.voice}`);
11265
+ const segments2 = Object.values(spoken.beats).flat();
11266
+ const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
11267
+ job.done("narrate", `${segments2.length} segments, ${seconds.toFixed(1)}s in ${spoken.voice}`);
9290
11268
  }
9291
11269
  }
9292
11270
  job.begin("build");
@@ -9329,7 +11307,7 @@ async function runPipeline(job, input) {
9329
11307
  job.log("render: capturing frames \u2014 two minutes for a four-minute video");
9330
11308
  const out = await render({
9331
11309
  deck: dirs.deck,
9332
- out: join11(dirs.deck, "video.mp4"),
11310
+ out: join12(dirs.deck, "video.mp4"),
9333
11311
  subtitles: "sidecar",
9334
11312
  log: (line2) => job.log(line2),
9335
11313
  // The last of the three length levers, and the only one that acts on a
@@ -9378,35 +11356,61 @@ async function runPipeline(job, input) {
9378
11356
  };
9379
11357
  }
9380
11358
  async function ingest(job, input, dirs, warnings) {
9381
- await mkdir9(dirs.upload, { recursive: true });
9382
- const root2 = resolve4(dirs.upload);
11359
+ await mkdir10(dirs.upload, { recursive: true });
11360
+ const root2 = resolve5(dirs.upload);
9383
11361
  let docPath;
9384
- if (looksLikeZip(input.upload.bytes)) {
9385
- const { files, warnings: zipWarnings } = readZip(input.upload.bytes);
11362
+ const upload = input.upload;
11363
+ const bothOrNeither = new UploadError(
11364
+ "A job needs exactly one of an upload and a url.",
11365
+ "PipelineInput carries `upload` or `url`; it was handed both, or neither.",
11366
+ 500
11367
+ );
11368
+ if (input.url !== void 0) {
11369
+ if (upload !== void 0) throw bothOrNeither;
11370
+ const harvested = await harvestBounded(input.url, join12(root2, "assets"), {
11371
+ ...HARVEST_LIMITS,
11372
+ ...input.harvest
11373
+ });
11374
+ warnings.push(...harvested.warnings);
11375
+ docPath = join12(root2, "document.md");
11376
+ await writeFile12(docPath, harvested.markdown);
11377
+ job.log(
11378
+ `ingest: harvested ${input.url} \u2014 "${harvested.title}", ${harvested.assets.length} asset(s)`
11379
+ );
11380
+ if (harvested.markdown.trim() === "") {
11381
+ throw new UploadError(
11382
+ `Nothing readable came back from ${input.url}.`,
11383
+ "A page that builds its body from a third-party script harvests to nothing, because the harvester runs none of them \u2014 that trade is written up at the top of src/source/harvest.ts. Save the page as markdown and upload the file instead."
11384
+ );
11385
+ }
11386
+ } else if (upload === void 0) {
11387
+ throw bothOrNeither;
11388
+ } else if (looksLikeZip(upload.bytes)) {
11389
+ const { files, warnings: zipWarnings } = readZip(upload.bytes);
9386
11390
  warnings.push(...zipWarnings);
9387
11391
  for (const [rel, bytes] of Object.entries(files)) {
9388
- const to = resolve4(join11(root2, rel));
11392
+ const to = resolve5(join12(root2, rel));
9389
11393
  if (!insideRoot(root2, to))
9390
11394
  throw new UploadError(`Refusing to write ${rel}.`, "Re-zip from inside the folder.");
9391
- await mkdir9(dirname4(to), { recursive: true });
9392
- await writeFile11(to, bytes);
11395
+ await mkdir10(dirname4(to), { recursive: true });
11396
+ await writeFile12(to, bytes);
9393
11397
  }
9394
- docPath = join11(root2, pickMarkdown(files));
11398
+ docPath = join12(root2, pickMarkdown(files));
9395
11399
  job.log(
9396
11400
  `ingest: unpacked ${Object.keys(files).length} file(s), reading ${pickMarkdown(files)}`
9397
11401
  );
9398
11402
  } else {
9399
- const ext = (input.upload.filename.match(/\.[^.\\/]+$/)?.[0] ?? "").toLowerCase();
11403
+ const ext = (upload.filename.match(/\.[^.\\/]+$/)?.[0] ?? "").toLowerCase();
9400
11404
  if (ext && !MARKDOWN_EXTS.includes(ext)) {
9401
11405
  throw new UploadError(
9402
- `"${input.upload.filename}" is a ${ext} file.`,
11406
+ `"${upload.filename}" is a ${ext} file.`,
9403
11407
  `DeckSmith reads ${MARKDOWN_EXTS.join(", ")} or a .zip containing one. Export the document to markdown first.`
9404
11408
  );
9405
11409
  }
9406
- docPath = join11(root2, "document.md");
9407
- await writeFile11(docPath, input.upload.bytes);
11410
+ docPath = join12(root2, "document.md");
11411
+ await writeFile12(docPath, upload.bytes);
9408
11412
  }
9409
- const text2 = await readFile11(docPath, "utf8");
11413
+ const text2 = await readFile12(docPath, "utf8");
9410
11414
  const parsed = parseMarkdown(text2, {
9411
11415
  lang: input.options.stated.lang ? input.options.prefs.lang : void 0
9412
11416
  });
@@ -9426,18 +11430,30 @@ async function ingest(job, input, dirs, warnings) {
9426
11430
  `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations, ${parsed.tables.length} tables`
9427
11431
  );
9428
11432
  const guarded = await guardFigures(parsed, root2, input.fetchRemoteFigures, warnings);
9429
- const source = await fetchFigures(guarded, join11(resolve4(dirs.src), "assets"));
9430
- await writeJson(join11(dirs.src, "source.json"), source);
11433
+ const source = await fetchFigures(guarded, join12(resolve5(dirs.src), "assets"), warnings);
11434
+ await writeJson(join12(dirs.src, "source.json"), source);
9431
11435
  return source;
9432
11436
  }
9433
- var BLOCKED = [
9434
- /^10\./,
9435
- /^127\./,
9436
- /^169\.254\./,
9437
- /^172\.(1[6-9]|2\d|3[01])\./,
9438
- /^192\.168\./,
9439
- /^0\./
9440
- ];
11437
+ async function harvestBounded(url, dir, opts) {
11438
+ let timer;
11439
+ const deadline = new Promise((_, reject) => {
11440
+ timer = setTimeout(
11441
+ () => reject(
11442
+ new UploadError(
11443
+ `${url} took longer than ${HARVEST_DEADLINE_MS / 1e3}s to read.`,
11444
+ "The page or its images are answering too slowly to build a deck from. Save the page as markdown and upload the file instead.",
11445
+ 504
11446
+ )
11447
+ ),
11448
+ HARVEST_DEADLINE_MS
11449
+ );
11450
+ });
11451
+ try {
11452
+ return await Promise.race([harvest(url, dir, opts), deadline]);
11453
+ } finally {
11454
+ clearTimeout(timer);
11455
+ }
11456
+ }
9441
11457
  async function reachable(url) {
9442
11458
  let host2;
9443
11459
  try {
@@ -9454,11 +11470,8 @@ async function reachable(url) {
9454
11470
  return "does not resolve";
9455
11471
  }
9456
11472
  for (const addr of addrs) {
9457
- if (BLOCKED.some((re) => re.test(addr))) return `resolves to a private address (${addr})`;
9458
- const v6 = addr.toLowerCase();
9459
- if (v6 === "::1" || v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd")) {
9460
- return `resolves to a private address (${addr})`;
9461
- }
11473
+ const why2 = isBlockedAddress(addr);
11474
+ if (why2) return `resolves to a private address (${addr})`;
9462
11475
  }
9463
11476
  return addrs.length === 0 ? "does not resolve" : null;
9464
11477
  }
@@ -9475,17 +11488,18 @@ async function guardFigures(source, root2, allowRemote, warnings) {
9475
11488
  );
9476
11489
  continue;
9477
11490
  }
9478
- if (++remote > MAX_REMOTE_FIGURES) {
11491
+ if (remote >= MAX_REMOTE_FIGURES) {
9479
11492
  warnings.push(
9480
11493
  `figure ${figure.id} was left out: more than ${MAX_REMOTE_FIGURES} remote figures`
9481
11494
  );
9482
11495
  continue;
9483
11496
  }
9484
- const why = await reachable(src);
9485
- if (why) {
9486
- warnings.push(`figure ${figure.id} was left out: ${host(src)} ${why}`);
11497
+ const why2 = await reachable(src);
11498
+ if (why2) {
11499
+ warnings.push(`figure ${figure.id} was left out: ${host(src)} ${why2}`);
9487
11500
  continue;
9488
11501
  }
11502
+ remote++;
9489
11503
  figures.push(figure);
9490
11504
  continue;
9491
11505
  }
@@ -9493,12 +11507,12 @@ async function guardFigures(source, root2, allowRemote, warnings) {
9493
11507
  warnings.push(`figure ${figure.id} was left out: "${scheme}:" figures are not read`);
9494
11508
  continue;
9495
11509
  }
9496
- const abs = resolve4(join11(root2, src.replace(/^\.?\//, "")));
11510
+ const abs = isAbsolute(src) ? resolve5(src) : resolve5(join12(root2, src.replace(/^\.?\//, "")));
9497
11511
  if (!insideRoot(root2, abs)) {
9498
11512
  warnings.push(`figure ${figure.id} was left out: "${src}" points outside the upload`);
9499
11513
  continue;
9500
11514
  }
9501
- if (!(await stat2(abs).catch(() => null))?.isFile()) {
11515
+ if (!(await stat3(abs).catch(() => null))?.isFile()) {
9502
11516
  warnings.push(`figure ${figure.id} was left out: "${src}" is not in the upload`);
9503
11517
  continue;
9504
11518
  }
@@ -9508,19 +11522,19 @@ async function guardFigures(source, root2, allowRemote, warnings) {
9508
11522
  }
9509
11523
  async function pack2(job, ctx) {
9510
11524
  try {
9511
- const assets = join11(resolve4(ctx.dirs.src), "assets");
11525
+ const assets = join12(resolve5(ctx.dirs.src), "assets");
9512
11526
  const requests = ctx.source.figures.map((f) => ({
9513
11527
  id: f.id,
9514
- url: join11(assets, f.src),
11528
+ url: join12(assets, f.src),
9515
11529
  prefer: "bake"
9516
11530
  }));
9517
11531
  const plan = await planMedia(requests, async (url) => ({
9518
- bytes: new Uint8Array(await readFile11(url))
11532
+ bytes: new Uint8Array(await readFile12(url))
9519
11533
  }));
9520
11534
  const files = { ...plan.files };
9521
11535
  if (ctx.narration) {
9522
11536
  for (const name of audioNames(ctx.narration)) {
9523
- files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile11(join11(ctx.dirs.audio, name)));
11537
+ files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile12(join12(ctx.dirs.audio, name)));
9524
11538
  }
9525
11539
  }
9526
11540
  const container = {
@@ -9537,10 +11551,10 @@ async function pack2(job, ctx) {
9537
11551
  },
9538
11552
  source: ctx.source,
9539
11553
  storyboard: ctx.storyboard,
9540
- ...ctx.narration ? { narration: await readJson(join11(ctx.dirs.audio, NARRATION_FILE)) } : {},
11554
+ ...ctx.narration ? { narration: await readJson(join12(ctx.dirs.audio, NARRATION_FILE)) } : {},
9541
11555
  media: plan.media
9542
11556
  };
9543
- const bytes = await writePack(container, files, join11(ctx.dirs.deck, "deck.deck"));
11557
+ const bytes = await writePack(container, files, join12(ctx.dirs.deck, "deck.deck"));
9544
11558
  job.log(`pack: ${Math.round(bytes / 1024)} KB \u2192 deck.deck`);
9545
11559
  return ctx.url("deck.deck");
9546
11560
  } catch (err) {
@@ -9563,12 +11577,12 @@ function host(src) {
9563
11577
  }
9564
11578
  }
9565
11579
  async function writeJson(path2, value) {
9566
- await mkdir9(dirname4(path2), { recursive: true });
9567
- await writeFile11(path2, `${JSON.stringify(value, null, 2)}
11580
+ await mkdir10(dirname4(path2), { recursive: true });
11581
+ await writeFile12(path2, `${JSON.stringify(value, null, 2)}
9568
11582
  `);
9569
11583
  }
9570
11584
  async function readJson(path2) {
9571
- return JSON.parse(await readFile11(path2, "utf8"));
11585
+ return JSON.parse(await readFile12(path2, "utf8"));
9572
11586
  }
9573
11587
 
9574
11588
  // src/server/errors.ts
@@ -9664,9 +11678,9 @@ var HINTS = [
9664
11678
  var FALLBACK = "This one is not a document problem we recognise. The stage list above shows how far it got; re-running an identical upload is worth one try.";
9665
11679
  function explain(err) {
9666
11680
  if (err instanceof UploadError) return { message: err.message, hint: err.hint };
9667
- const message = err instanceof Error ? err.message : String(err);
9668
- const clean2 = message.split("\n at ")[0]?.trim() || "The job failed.";
9669
- return { message: clean2, hint: HINTS.find((h) => h.when.test(message))?.hint ?? FALLBACK };
11681
+ const message2 = err instanceof Error ? err.message : String(err);
11682
+ const clean2 = message2.split("\n at ")[0]?.trim() || "The job failed.";
11683
+ return { message: clean2, hint: HINTS.find((h) => h.when.test(message2))?.hint ?? FALLBACK };
9670
11684
  }
9671
11685
 
9672
11686
  // src/server/queue.ts
@@ -9827,10 +11841,10 @@ var Queue = class {
9827
11841
  if (detail) step.detail = detail;
9828
11842
  this.#emit(job.id);
9829
11843
  },
9830
- skip: (stage, why) => {
11844
+ skip: (stage, why2) => {
9831
11845
  const step = this.#step(job, stage);
9832
11846
  step.state = "skipped";
9833
- step.detail = why;
11847
+ step.detail = why2;
9834
11848
  this.#emit(job.id);
9835
11849
  },
9836
11850
  log: (line2) => this.#note(job, line2)
@@ -9909,37 +11923,37 @@ function missingFor(found, opts) {
9909
11923
  // src/mcp/tools.ts
9910
11924
  var formatIds = Object.keys(FORMATS);
9911
11925
  var themeIds = [...THEME_NAMES];
9912
- var settingsSchema = z4.object({
9913
- format: z4.enum(formatIds).optional().describe(
11926
+ var settingsSchema = z5.object({
11927
+ format: z5.enum(formatIds).optional().describe(
9914
11928
  "deck-16x9 is a navigable slide deck. video-16x9, short-9x16 and post-1x1 are linear and budgeted \u2014 they drop beats that do not fit their length."
9915
11929
  ),
9916
- theme: z4.enum(themeIds).optional().describe("Omit and the storyboard's own theme wins."),
9917
- lang: z4.string().regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$/).optional().describe(
11930
+ theme: z5.enum(themeIds).optional().describe("Omit and the storyboard's own theme wins."),
11931
+ lang: z5.string().regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$/).optional().describe(
9918
11932
  "BCP-47. OMIT THIS unless you want a translation \u2014 absent means the document's own language."
9919
11933
  ),
9920
- tone: z4.enum(["plain", "academic", "conversational", "punchy"]).optional(),
9921
- density: z4.enum(["sparse", "normal", "dense"]).optional().describe("How much text a SLIDE carries. A different axis from narration_density."),
9922
- genre: z4.enum(["general", "paper"]).optional().describe(
11934
+ tone: z5.enum(["plain", "academic", "conversational", "punchy"]).optional(),
11935
+ density: z5.enum(["sparse", "normal", "dense"]).optional().describe("How much text a SLIDE carries. A different axis from narration_density."),
11936
+ genre: z5.enum(["general", "paper"]).optional().describe(
9923
11937
  "DECLARED, never detected. `paper` asks for a research-talk shape \u2014 an introduction and background at the front, a limitations slide and then a conclusion at the end \u2014 and reports where the deck missed it. There is no detector: the documents this tool ingests are analyses OF papers, rewritten in a way that drops the headings a detector would need, so nothing infers this and absent means `general`."
9924
11938
  ),
9925
- duration: z4.number().min(10).max(1800).optional().describe(
11939
+ duration: z5.number().min(10).max(1800).optional().describe(
9926
11940
  "Target seconds. Sets the pace, derives the slide count when you give none, and overrides animation_speed. Call decksmith_estimate_length first \u2014 a short target buys its words by saying less, not by playing faster."
9927
11941
  ),
9928
- slides: z4.int().min(3).max(40).optional().describe(
11942
+ slides: z5.int().min(3).max(40).optional().describe(
9929
11943
  "Target beat count. Derived from duration when omitted. Stating it is obeyed even when it cannot flow, and you are told what it cost."
9930
11944
  ),
9931
- animation_speed: z4.number().min(0.25).max(3).optional().describe("Multiplies every tween. IGNORED when duration is set."),
9932
- narrate: z4.boolean().optional().describe("Speak the deck with edge-tts. Required for video."),
9933
- voice: z4.string().optional().describe('e.g. "en-US-AndrewMultilingualNeural". Omit to have one chosen for lang and tone.'),
9934
- rate: z4.string().regex(/^[+-]\d{1,3}%$/).optional().describe("IGNORED when duration is set."),
9935
- pitch: z4.string().regex(/^[+-]\d{1,3}Hz$/).optional(),
9936
- narration_density: z4.enum(["high", "medium", "low"]).optional().describe(
11945
+ animation_speed: z5.number().min(0.25).max(3).optional().describe("Multiplies every tween. IGNORED when duration is set."),
11946
+ narrate: z5.boolean().optional().describe("Speak the deck with edge-tts. Required for video."),
11947
+ voice: z5.string().optional().describe('e.g. "en-US-AndrewMultilingualNeural". Omit to have one chosen for lang and tone.'),
11948
+ rate: z5.string().regex(/^[+-]\d{1,3}%$/).optional().describe("IGNORED when duration is set."),
11949
+ pitch: z5.string().regex(/^[+-]\d{1,3}Hz$/).optional(),
11950
+ narration_density: z5.enum(["high", "medium", "low"]).optional().describe(
9937
11951
  "How many of a beat's stops SPEAK. `low` is one sentence a beat and is what makes a short target reachable \u2014 the demo has 12 beats but 37 stops, so a 60s target spread over stops is four words each."
9938
11952
  ),
9939
- video: z4.boolean().optional().describe(
11953
+ video: z5.boolean().optional().describe(
9940
11954
  "Also render an mp4. Adds 1-2 minutes, needs ffmpeg and Chrome, and turns narrate on."
9941
11955
  ),
9942
- images: z4.boolean().optional().describe(
11956
+ images: z5.boolean().optional().describe(
9943
11957
  "Let the plan ask for a picture where the document has no figure to show. Each brief is drawn through the configured image backend, else the Codex account's own image tool, else an SVG DeckSmith draws itself \u2014 so it never fails for lack of one. Adds about 30 seconds a picture; decksmith_capabilities says which backend is configured."
9944
11958
  )
9945
11959
  });
@@ -9966,34 +11980,64 @@ function fieldsFor(s) {
9966
11980
  put("images", s.images);
9967
11981
  return f;
9968
11982
  }
9969
- var capabilitiesSchema = z4.object({});
9970
- var estimateSchema = z4.object({
11983
+ var PAGE_BUDGET = {
11984
+ maxAssetBytes: 8 * 1024 * 1024,
11985
+ maxTotalBytes: 16 * 1024 * 1024,
11986
+ maxWallMs: 6e4,
11987
+ maxClips: 0,
11988
+ refs: "relative"
11989
+ };
11990
+ async function readPage(url, work2) {
11991
+ await mkdir11(work2, { recursive: true });
11992
+ const dir = await mkdtemp3(join13(work2, "harvest-"));
11993
+ try {
11994
+ const page = await harvest(url, dir, PAGE_BUDGET);
11995
+ const files = {
11996
+ "document.md": new TextEncoder().encode(page.markdown)
11997
+ };
11998
+ for (const asset of page.assets) files[basename4(asset)] = await readFile13(asset);
11999
+ const warnings = [...page.warnings];
12000
+ if (page.clips.length > 0) {
12001
+ warnings.push(
12002
+ `${page.clips.length} video(s) travel as their still image and a link only: this tool ships the page as a markdown document, which has no way to say kind: "clip". \`decksmith ingest <url>\` keeps them as clips.`
12003
+ );
12004
+ }
12005
+ return { zip: zipSync2(files), warnings };
12006
+ } finally {
12007
+ await rm8(dir, { recursive: true, force: true });
12008
+ }
12009
+ }
12010
+ var capabilitiesSchema = z5.object({});
12011
+ var estimateSchema = z5.object({
9971
12012
  settings: settingsSchema.describe("The same settings you would pass to decksmith_create_deck.")
9972
12013
  });
9973
- var createSchema = z4.object({
9974
- document_path: z4.string().optional().describe("Absolute path to a markdown file. Must sit under the server's root."),
9975
- document_text: z4.string().optional().describe("The markdown itself, if you have no file."),
12014
+ var createSchema = z5.object({
12015
+ document_path: z5.string().optional().describe("Absolute path to a markdown file. Must sit under the server's root."),
12016
+ document_text: z5.string().optional().describe("The markdown itself, if you have no file."),
12017
+ document_url: z5.string().optional().describe(
12018
+ "An http(s) page to read the document out of. The page is fetched through the same guard as every other URL, opened in a browser that is allowed to request nothing, and reduced to its prose, tables, code and figures; the figures are downloaded and measured here. Videos come back as their still image and a link \u2014 the deck cannot play a video it reached this way. Anything the harvest left out is listed in harvest_warnings on the answer."
12019
+ ),
9976
12020
  settings: settingsSchema.optional(),
9977
- wait_seconds: z4.number().min(0).max(300).optional().describe(
12021
+ wait_seconds: z5.number().min(0).max(300).optional().describe(
9978
12022
  "How long to block before answering. Default 45. The job keeps running when this expires \u2014 poll decksmith_job_status with the id."
9979
12023
  )
9980
12024
  });
9981
- var statusSchema = z4.object({
9982
- job_id: z4.string(),
9983
- wait_seconds: z4.number().min(0).max(300).optional().describe("Block up to this long for the job to finish or change stage. Default 45.")
12025
+ var statusSchema = z5.object({
12026
+ job_id: z5.string(),
12027
+ wait_seconds: z5.number().min(0).max(300).optional().describe("Block up to this long for the job to finish or change stage. Default 45.")
9984
12028
  });
9985
12029
  var DEFAULT_WAIT = 45;
9986
12030
  function deckTools(opts) {
9987
12031
  const queue = new Queue({ maxQueued: 8 });
9988
- const root2 = resolve5(opts.root);
12032
+ const root2 = resolve6(opts.root);
9989
12033
  let cached;
9990
12034
  const found = async () => {
9991
12035
  cached ??= await (opts.probe ?? prereqs)();
9992
12036
  return cached;
9993
12037
  };
9994
12038
  const insideRoot2 = (p) => {
9995
- if (!isAbsolute(p)) throw new Error(`document_path must be absolute; got "${p}".`);
9996
- const full = resolve5(p);
12039
+ if (!isAbsolute2(p)) throw new Error(`document_path must be absolute; got "${p}".`);
12040
+ const full = resolve6(p);
9997
12041
  if (full !== root2 && !full.startsWith(root2 + sep2)) {
9998
12042
  throw new Error(
9999
12043
  `document_path is outside the server's root. It may only read files under ${root2}.`
@@ -10049,8 +12093,9 @@ function deckTools(opts) {
10049
12093
  },
10050
12094
  /** Submit a document, wait a bounded slice, report. */
10051
12095
  async create(input) {
10052
- if (!input.document_path === !input.document_text) {
10053
- throw new Error("Pass exactly one of document_path or document_text.");
12096
+ const given = [input.document_path, input.document_text, input.document_url].filter(Boolean);
12097
+ if (given.length !== 1) {
12098
+ throw new Error("Pass exactly one of document_path, document_text or document_url.");
10054
12099
  }
10055
12100
  const settings = input.settings ?? {};
10056
12101
  const options = parseOptions(fieldsFor(settings));
@@ -10069,16 +12114,17 @@ function deckTools(opts) {
10069
12114
  );
10070
12115
  }
10071
12116
  }
10072
- const filename = input.document_path ? input.document_path.split(sep2).pop() : "document.md";
10073
- const bytes = file ? await readFile12(file) : new TextEncoder().encode(input.document_text);
12117
+ const page = input.document_url === void 0 ? void 0 : await readPage(input.document_url, opts.work);
12118
+ const filename = page ? "page.zip" : input.document_path ? input.document_path.split(sep2).pop() : "document.md";
12119
+ const bytes = page ? page.zip : file ? await readFile13(file) : new TextEncoder().encode(input.document_text);
10074
12120
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
10075
12121
  throw new Error(
10076
- `Document is ${(bytes.byteLength / 1e6).toFixed(1)} MB, over the ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB cap.`
12122
+ `Document is ${(bytes.byteLength / 1e6).toFixed(1)} MB, over the ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB cap.` + (page ? " Harvest fewer figures with a smaller maxAssets, or save the page and edit it." : "")
10077
12123
  );
10078
12124
  }
10079
12125
  const id2 = randomBytes(16).toString("base64url");
10080
- const dir = join12(opts.work, id2);
10081
- await mkdir10(dir, { recursive: true });
12126
+ const dir = join13(opts.work, id2);
12127
+ await mkdir11(dir, { recursive: true });
10082
12128
  queue.submit({
10083
12129
  id: id2,
10084
12130
  dir,
@@ -10092,7 +12138,8 @@ function deckTools(opts) {
10092
12138
  fetchRemoteFigures: true
10093
12139
  })
10094
12140
  });
10095
- return this.status({ job_id: id2, wait_seconds: input.wait_seconds });
12141
+ const view = await this.status({ job_id: id2, wait_seconds: input.wait_seconds });
12142
+ return page === void 0 ? view : { ...view, harvest_warnings: page.warnings };
10096
12143
  },
10097
12144
  /** Poll, blocking up to `wait_seconds` for the job to move on. */
10098
12145
  async status(input) {
@@ -10104,7 +12151,7 @@ function deckTools(opts) {
10104
12151
  };
10105
12152
  }
10106
12153
  function dirOf(work2, id2) {
10107
- return join12(work2, id2);
12154
+ return join13(work2, id2);
10108
12155
  }
10109
12156
  function waitFor(queue, id2, seconds) {
10110
12157
  const now = queue.view(id2);
@@ -10136,9 +12183,9 @@ function report(view, dir) {
10136
12183
  // (src/server/pipeline.ts). This said `src/storyboard.json` for one run, which
10137
12184
  // is the shape of the CLI's output directory and not the server's, and an
10138
12185
  // agent told to read it would have found nothing there.
10139
- storyboard_path: join12(dir, "storyboard.json"),
12186
+ storyboard_path: join13(dir, "storyboard.json"),
10140
12187
  ...done && view.result ? {
10141
- deck_path: join12(dir, "deck"),
12188
+ deck_path: join13(dir, "deck"),
10142
12189
  slides: view.result.slides,
10143
12190
  duration_seconds: view.result.duration,
10144
12191
  warnings: view.result.warnings
@@ -10147,7 +12194,7 @@ function report(view, dir) {
10147
12194
  };
10148
12195
  }
10149
12196
  function defaultWork() {
10150
- return join12(tmpdir3(), "decksmith-mcp");
12197
+ return join13(tmpdir3(), "decksmith-mcp");
10151
12198
  }
10152
12199
 
10153
12200
  // src/mcp/main.ts
@@ -10161,7 +12208,7 @@ var env = process.env;
10161
12208
  var root = env.DECKSMITH_MCP_ROOT ?? homedir3();
10162
12209
  var work = env.DECKSMITH_MCP_WORK ?? defaultWork();
10163
12210
  var tools = deckTools({ root, work });
10164
- var schema = (s) => z5.toJSONSchema(s, { io: "input", unrepresentable: "any", target: "draft-2020-12" });
12211
+ var schema = (s) => z6.toJSONSchema(s, { io: "input", unrepresentable: "any", target: "draft-2020-12" });
10165
12212
  var TOOLS = [
10166
12213
  {
10167
12214
  name: "decksmith_capabilities",