@jokerized/decksmith 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -7,22 +7,70 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { z as z6 } from "zod";
9
9
 
10
- // src/version.ts
10
+ // src/tmpdir.ts
11
+ import { realpathSync } from "node:fs";
11
12
  import { createRequire } from "node:module";
12
- var VERSION = createRequire(import.meta.url)("../package.json").version;
13
+ import { tmpdir } from "node:os";
14
+ import { basename, dirname, join, resolve, sep } from "node:path";
15
+ var cached;
16
+ function real(dir) {
17
+ let at = resolve(dir);
18
+ const tail = [];
19
+ for (; ; ) {
20
+ try {
21
+ return join(realpathSync(at), ...tail);
22
+ } catch {
23
+ const up = dirname(at);
24
+ if (up === at) return resolve(dir);
25
+ tail.unshift(basename(at));
26
+ at = up;
27
+ }
28
+ }
29
+ }
30
+ function packageRoot() {
31
+ cached ??= real(dirname(createRequire(import.meta.url).resolve("../package.json")));
32
+ return cached;
33
+ }
34
+ function insideRoot(dir) {
35
+ const root2 = packageRoot();
36
+ const at = real(dir);
37
+ return at === root2 || at.startsWith(root2 + sep);
38
+ }
39
+ function guardTmpdir() {
40
+ const before = tmpdir();
41
+ if (!insideRoot(before)) return;
42
+ delete process.env.TMPDIR;
43
+ const after = tmpdir();
44
+ if (insideRoot(after)) {
45
+ throw new Error(
46
+ `tmpdir: ${after} is still inside ${packageRoot()}. Set TMPDIR to a real temp directory.`
47
+ );
48
+ }
49
+ const worker = process.env.VITEST_WORKER_ID;
50
+ if (worker === void 0 || worker === "1") {
51
+ process.stderr.write(
52
+ `tmpdir: TMPDIR pointed at ${before}, inside the source tree, so every mkdtemp would land in the repo. Using ${after}. Fix the environment \u2014 this only stops the mess.
53
+ `
54
+ );
55
+ }
56
+ }
57
+
58
+ // src/version.ts
59
+ import { createRequire as createRequire2 } from "node:module";
60
+ var VERSION = createRequire2(import.meta.url)("../package.json").version;
13
61
 
14
62
  // src/mcp/tools.ts
15
63
  import { randomBytes } from "node:crypto";
16
64
  import { mkdir as mkdir11, mkdtemp as mkdtemp3, readFile as readFile13, rm as rm8 } from "node:fs/promises";
17
- import { tmpdir as tmpdir3 } from "node:os";
18
- import { basename as basename4, isAbsolute as isAbsolute2, join as join13, resolve as resolve6, sep as sep2 } from "node:path";
65
+ import { tmpdir as tmpdir4 } from "node:os";
66
+ import { basename as basename5, isAbsolute as isAbsolute2, join as join14, resolve as resolve7, sep as sep3 } from "node:path";
19
67
  import { zipSync as zipSync2 } from "fflate";
20
68
  import { z as z5 } from "zod";
21
69
 
22
70
  // src/index.ts
23
71
  import { cp, mkdir as mkdir9, readdir as readdir2, readFile as readFile11, stat as stat2, writeFile as writeFile11 } from "node:fs/promises";
24
- import { createRequire as createRequire2 } from "node:module";
25
- import { dirname as dirname3, join as join11, resolve as resolve4 } from "node:path";
72
+ import { createRequire as createRequire3 } from "node:module";
73
+ import { dirname as dirname4, join as join12, resolve as resolve5 } from "node:path";
26
74
  import { fileURLToPath } from "node:url";
27
75
 
28
76
  // src/pack/media.ts
@@ -415,15 +463,53 @@ var dataTableParamsSchema = z.object({
415
463
  path: ["highlight"],
416
464
  message: "every highlight must name a row that params.rows draws"
417
465
  });
466
+ var chartPointSchema = z.object({ x: z.string(), y: z.number() });
418
467
  var lineChartParamsSchema = z.object({
419
468
  eyebrow: z.string().optional(),
420
469
  headline: z.string(),
421
470
  xLabel: z.string(),
422
471
  yLabel: z.string(),
423
- points: z.array(z.object({ x: z.string(), y: z.number() })).min(2),
472
+ points: z.array(chartPointSchema).min(2),
424
473
  /** Inter-point annotations, e.g. per-step deltas. One fewer than `points`. */
425
474
  deltas: z.array(z.string()).optional(),
426
- readout: z.string().optional()
475
+ readout: z.string().optional(),
476
+ /**
477
+ * THE SAME MEASUREMENT UNDER A SECOND CONDITION — a baseline the main series
478
+ * is to be read against, where the point is the change in the SHAPE of the
479
+ * curve rather than two numbers.
480
+ *
481
+ * Drawn first and alone; then the curve lifts off it and reshapes into
482
+ * `points`, leaving this one behind as a ghost. `label` names the ghost and
483
+ * is drawn wherever the chart has room for it.
484
+ *
485
+ * KEEP IT SHORT — two or three words. A label wider than the plot is refused
486
+ * outright, and that refusal reaches `onBeatError` and costs the whole beat.
487
+ * One that fits but cannot be placed clear of the axis names, the tick and
488
+ * category labels, the values, the deltas and both curves is DROPPED
489
+ * instead, silently: an unnamed ghost is still legibly the fainter, earlier
490
+ * curve, where a name printed through a number is a defect in both of them.
491
+ * Nothing warns about that one — see the placement note in
492
+ * `src/emit/archetypes/line-chart.ts`.
493
+ */
494
+ compare: z.object({ label: z.string(), points: z.array(chartPointSchema).min(2) }).optional()
495
+ }).superRefine((p, ctx) => {
496
+ if (!p.compare) return;
497
+ if (p.compare.points.length !== p.points.length) {
498
+ ctx.addIssue({
499
+ code: z.ZodIssueCode.custom,
500
+ path: ["compare", "points"],
501
+ message: `compare has ${p.compare.points.length} points against ${p.points.length}; a comparison is over the same x values`
502
+ });
503
+ return;
504
+ }
505
+ const off = p.compare.points.findIndex((c, i) => c.x !== p.points[i]?.x);
506
+ if (off >= 0) {
507
+ ctx.addIssue({
508
+ code: z.ZodIssueCode.custom,
509
+ path: ["compare", "points", off, "x"],
510
+ message: `compare point ${off} is "${p.compare.points[off]?.x}" where points has "${p.points[off]?.x}"`
511
+ });
512
+ }
427
513
  });
428
514
  var calloutParamsSchema = z.object({
429
515
  eyebrow: z.string().optional(),
@@ -1171,7 +1257,7 @@ function clock(seconds) {
1171
1257
  // src/source/fonts.ts
1172
1258
  import { createHash as createHash2 } from "node:crypto";
1173
1259
  import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
1174
- import { join } from "node:path";
1260
+ import { join as join2 } from "node:path";
1175
1261
  function familyFor(lang) {
1176
1262
  const tag = lang.toLowerCase();
1177
1263
  if (tag.startsWith("ko")) return "Noto Sans KR";
@@ -1187,9 +1273,9 @@ async function bundleFont(lang, glyphs, dir) {
1187
1273
  const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
1188
1274
  ${text2}`).digest("hex").slice(0, 16)} */`;
1189
1275
  await mkdir(dir, { recursive: true });
1190
- const cssPath = join(dir, "fonts.css");
1191
- const cached = await readFile2(cssPath, "utf8").catch(() => "");
1192
- if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
1276
+ const cssPath = join2(dir, "fonts.css");
1277
+ const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1278
+ if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1193
1279
  const res = await fetch(
1194
1280
  `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
1195
1281
  { headers: { "User-Agent": UA } }
@@ -1203,7 +1289,7 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
1203
1289
  const font = await fetch(url);
1204
1290
  if (!font.ok) throw new Error(`${url}: HTTP ${font.status}`);
1205
1291
  const name = `${slug}-${i}.woff2`;
1206
- await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
1292
+ await writeFile(join2(dir, name), Buffer.from(await font.arrayBuffer()));
1207
1293
  css = css.replaceAll(url, name);
1208
1294
  files.push(name);
1209
1295
  }
@@ -1402,6 +1488,21 @@ function nv(v) {
1402
1488
  }
1403
1489
  var DRAW_FROM = { drawSVG: "0%" };
1404
1490
  var DRAW_TO = { drawSVG: "100%" };
1491
+ var SHAPE_INDEX = 0;
1492
+ function reshape(target, to, at, seconds, first) {
1493
+ if (!(seconds > 0)) throw new Error(`reshape ${target}: ${seconds}s is no time to reshape in`);
1494
+ return fromTo(
1495
+ target,
1496
+ { morphSVG: { shape: target, shapeIndex: SHAPE_INDEX } },
1497
+ {
1498
+ morphSVG: { shape: to, shapeIndex: SHAPE_INDEX },
1499
+ duration: sec(seconds),
1500
+ ease: "power2.inOut",
1501
+ ...first ? {} : { immediateRender: false }
1502
+ },
1503
+ sec(at)
1504
+ );
1505
+ }
1405
1506
  function travel(target, route, at, seconds) {
1406
1507
  if (!(seconds > 0)) throw new Error(`travel ${target}: ${seconds}s is no time to travel in`);
1407
1508
  const legs = [];
@@ -4083,6 +4184,12 @@ var CHART_GAP = 60;
4083
4184
  var READOUT_W = 460;
4084
4185
  var READOUT_LH = 1.35;
4085
4186
  var TALL_ASPECT = 1.15;
4187
+ var RESHAPE_SECONDS = 1.2;
4188
+ var GHOST_OPACITY = 0.28;
4189
+ var DRAW_SECONDS = 1.8;
4190
+ var DRAW_FLOOR = 1;
4191
+ var SEPARATE = 0.5;
4192
+ var STEP_FLOOR = 0.15;
4086
4193
  function chartScale(values) {
4087
4194
  const lo = Math.min(...values);
4088
4195
  const hi = Math.max(...values);
@@ -4113,7 +4220,8 @@ var lineChart = (beat, ctx) => {
4113
4220
  const { sid, theme } = ctx;
4114
4221
  const p = beat.params;
4115
4222
  const face = faceOf(theme.fontStack);
4116
- const scale = chartScale(p.points.map((pt) => pt.y));
4223
+ const cmp = p.compare;
4224
+ const scale = chartScale([...p.points, ...cmp?.points ?? []].map((pt) => pt.y));
4117
4225
  const box = contentW(ctx.format);
4118
4226
  const tall = isPortrait(ctx.format);
4119
4227
  const width = tall ? box : box - (p.readout ? CHART_GAP + READOUT_W : 0);
@@ -4156,11 +4264,15 @@ var lineChart = (beat, ctx) => {
4156
4264
  const LABEL_SIZE3 = 40;
4157
4265
  const runW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
4158
4266
  const catW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
4267
+ const nameW = (s) => textWidth(s, LABEL_SIZE3, 500, 0, false, face);
4268
+ const ghostW = (s) => textWidth(s, LABEL_SIZE3, 600, 0, false, face);
4159
4269
  const shownX = fitIndices((i) => catW(p.points[i]?.x ?? ""), false);
4160
4270
  const xLabels = p.points.map(
4161
4271
  (pt, i) => shownX.has(i) ? `<text x="${n2(x(i))}" y="${H - PAD2.b + 56}">${esc(pt.x)}</text>` : ""
4162
4272
  ).join("");
4163
- const path2 = p.points.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
4273
+ const pathOf = (pts) => pts.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
4274
+ const path2 = pathOf(p.points);
4275
+ const basePath = cmp ? pathOf(cmp.points) : "";
4164
4276
  const dots = p.points.map(
4165
4277
  (pt, i) => `<circle class="dot" cx="${n2(x(i))}" cy="${n2(y(pt.y))}" r="${i === p.points.length - 1 ? 11 : 9}" fill="${i === p.points.length - 1 ? theme.tones.b : theme.accent}" />`
4166
4278
  ).join("");
@@ -4196,6 +4308,75 @@ var lineChart = (beat, ctx) => {
4196
4308
  }).join("");
4197
4309
  const first = { x: x(0), y: y(p.points[0]?.y ?? 0) };
4198
4310
  const ring = p.points.length > 1 ? `<circle id="${sid}-ring" cx="${n2(first.x)}" cy="${n2(first.y)}" r="20" fill="none" stroke="${theme.tones.b}" stroke-width="5" opacity="0" />` : "";
4311
+ const lineTag = (part, d, extra = "") => `<path class="chartline" id="${sid}-${part}" d="${d}" fill="none" stroke="${theme.accent}"${extra} />`;
4312
+ const chartlines = cmp ? [
4313
+ lineTag("base", basePath),
4314
+ lineTag("line", basePath, ' opacity="0"'),
4315
+ `<path id="${sid}-target" d="${path2}" fill="none" stroke="none" />`
4316
+ ].join("\n ") : lineTag("line", path2);
4317
+ const curveBand = (pts, x0, x1) => {
4318
+ const ys = [];
4319
+ const py = (i) => y(pts[i]?.y ?? 0);
4320
+ for (let i = 0; i < pts.length; i++) {
4321
+ if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
4322
+ }
4323
+ for (let i = 0; i + 1 < pts.length; i++) {
4324
+ const [ax, bx] = [x(i), x(i + 1)];
4325
+ for (const edge of [x0, x1]) {
4326
+ if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
4327
+ ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
4328
+ }
4329
+ }
4330
+ }
4331
+ return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
4332
+ };
4333
+ const fixedBoxes = [
4334
+ // `.axname` is the one run here set at weight 500 rather than 400, and
4335
+ // under-charging a width is the unrecoverable direction (see `padR`).
4336
+ // No `text-anchor` on the y half, so it runs rightwards from x=0.
4337
+ { x: 0, y: 42 - LABEL_SIZE3, w: nameW(p.yLabel), h: LABEL_SIZE3 },
4338
+ {
4339
+ x: PAD2.l + plotW / 2 - nameW(p.xLabel) / 2,
4340
+ y: H - 16 - LABEL_SIZE3,
4341
+ w: nameW(p.xLabel),
4342
+ h: LABEL_SIZE3
4343
+ },
4344
+ ...ticks.map((v) => {
4345
+ const w = runW(v.toFixed(scale.decimals));
4346
+ return { x: PAD2.l - 22 - w, y: y(v) + 13 - LABEL_SIZE3, w, h: LABEL_SIZE3 };
4347
+ }),
4348
+ ...p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => shownX.has(i)).map(({ pt, i }) => labelBox(x(i), H - PAD2.b + 56, pt.x))
4349
+ ];
4350
+ const lastI = p.points.length - 1;
4351
+ const ghost = (() => {
4352
+ if (!cmp) return void 0;
4353
+ const w = ghostW(cmp.label);
4354
+ if (w > plotW) {
4355
+ throw new Error(
4356
+ `line-chart ${beat.id}: the compare label "${cmp.label}" is ${Math.ceil(w)}px against the ${Math.floor(plotW)}px of plot it is set in. Shorten it.`
4357
+ );
4358
+ }
4359
+ const atEnd = (i, start) => {
4360
+ const cy = y(cmp.points[i]?.y ?? 0);
4361
+ const away = Math.max(44, cy - 26);
4362
+ const toward = Math.min(cy + 52, H - PAD2.b + 4);
4363
+ const sides = cy < y(p.points[i]?.y ?? 0) ? [away, toward] : [toward, away];
4364
+ return sides.map((by) => ({ i, start, by }));
4365
+ };
4366
+ const drawnDeltas = deltasFit ? deltaBoxes : [];
4367
+ return [...atEnd(lastI, false), ...atEnd(0, true)].find((c) => {
4368
+ const box2 = { x: c.start ? x(c.i) : x(c.i) - w, y: c.by - LABEL_SIZE3, w, h: LABEL_SIZE3 };
4369
+ if (fixedBoxes.some((f) => overlaps(box2, f))) return false;
4370
+ if (valueBoxes.some((v) => overlaps(box2, v))) return false;
4371
+ if (drawnDeltas.some((d) => d !== null && overlaps(box2, d))) return false;
4372
+ return ![p.points, cmp.points].some((series) => {
4373
+ const [top, bottom] = curveBand(series, box2.x, box2.x + box2.w);
4374
+ return Math.min(box2.y + box2.h, bottom) - Math.max(box2.y, top) > 8;
4375
+ });
4376
+ });
4377
+ })();
4378
+ const ghostLabel = cmp && ghost ? `
4379
+ <text class="ghostlab" id="${sid}-ghostlab" text-anchor="${ghost.start ? "start" : "end"}" x="${n2(x(ghost.i))}" y="${n2(ghost.by)}">${esc(cmp.label)}</text>` : "";
4199
4380
  const readout = p.readout ? `
4200
4381
  <div class="readout" id="${sid}-read">${esc(p.readout)}</div>` : "";
4201
4382
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -4208,7 +4389,7 @@ var lineChart = (beat, ctx) => {
4208
4389
  the svg and the layout gate reports container_overflow. -->
4209
4390
  <text class="axname" x="0" y="42">${esc(p.yLabel)}</text>
4210
4391
  <text class="axname" x="${n2(PAD2.l + plotW / 2)}" y="${H - 16}" text-anchor="middle">${esc(p.xLabel)}</text>
4211
- <path class="chartline" id="${sid}-line" d="${path2}" fill="none" stroke="${theme.accent}" />
4392
+ ${chartlines}${ghostLabel}
4212
4393
  <g>${dots}</g>
4213
4394
  ${ring}
4214
4395
  <g class="ptlab" text-anchor="middle">${values}</g>
@@ -4216,10 +4397,52 @@ var lineChart = (beat, ctx) => {
4216
4397
  </svg>${readout}
4217
4398
  </div>`;
4218
4399
  const draw2 = 0.8;
4219
- const step = Math.min(0.45, 1.8 / p.points.length);
4400
+ const count = p.points.length;
4401
+ const idealStep = Math.min(0.45, DRAW_SECONDS / count);
4402
+ const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
4403
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
4404
+ const tailAfter = (s) => Math.max(
4405
+ s * count + 0.4,
4406
+ // the ring: its walk, then the 0.4s it takes to leave
4407
+ 0.3 + s * (count - 1),
4408
+ // the dots
4409
+ 0.5 + s * (count - 1),
4410
+ ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
4411
+ );
4412
+ const room = beat.seconds - spine;
4413
+ let drawCents = Math.round(DRAW_SECONDS * 100);
4414
+ let stepCents = Math.floor(idealStep * 100);
4415
+ if (cmp) {
4416
+ while (drawCents / 100 + tailAfter(stepCents / 100) > room) {
4417
+ if (drawCents > Math.round(DRAW_FLOOR * 100)) drawCents--;
4418
+ else if (stepCents > Math.round(STEP_FLOOR * 100)) stepCents--;
4419
+ else break;
4420
+ }
4421
+ }
4422
+ const drawFor = cmp ? drawCents / 100 : DRAW_SECONDS;
4423
+ const step = cmp ? stepCents / 100 : idealStep;
4424
+ const lift2 = sec(draw2 + drawFor);
4425
+ const settled = cmp ? sec(lift2 + SEPARATE + RESHAPE_SECONDS) : draw2;
4220
4426
  const tl = [
4221
4427
  ...chromeIn(sid, p.eyebrow !== void 0),
4222
- tween(`#${sid}-line`, DRAW_FROM, { ...DRAW_TO, duration: 1.8, ease: "none" }, draw2),
4428
+ tween(
4429
+ `#${sid}-${cmp ? "base" : "line"}`,
4430
+ DRAW_FROM,
4431
+ { ...DRAW_TO, duration: drawFor, ease: "none" },
4432
+ draw2
4433
+ )
4434
+ ];
4435
+ if (cmp) {
4436
+ tl.push(
4437
+ ...ghost ? [tween(`#${sid}-ghostlab`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, draw2 + 0.4)] : [],
4438
+ // The copy lifts off — same geometry, so the half-second reads as one line
4439
+ // separating from itself rather than as a second line arriving.
4440
+ tween(`#${sid}-line`, { opacity: 0 }, { opacity: 1, duration: SEPARATE }, lift2),
4441
+ tween(`#${sid}-base`, { opacity: 1 }, { opacity: GHOST_OPACITY, duration: SEPARATE }, lift2),
4442
+ reshape(`#${sid}-line`, `#${sid}-target`, lift2 + SEPARATE, RESHAPE_SECONDS, true)
4443
+ );
4444
+ }
4445
+ tl.push(
4223
4446
  tween(
4224
4447
  `#${sid} .dot`,
4225
4448
  // Origin in both halves, or GSAP's smoothOrigin compensates the change with
@@ -4227,37 +4450,43 @@ var lineChart = (beat, ctx) => {
4227
4450
  // polyline they mark, inside the frame and so invisible to every gate.
4228
4451
  { opacity: 0, scale: 0, transformOrigin: "center" },
4229
4452
  { opacity: 1, scale: 1, transformOrigin: "center", duration: 0.3, stagger: step },
4230
- draw2
4453
+ settled
4231
4454
  ),
4232
- tween(`#${sid} .pv`, { opacity: 0 }, { opacity: 1, duration: 0.3, stagger: step }, draw2 + 0.2)
4233
- ];
4455
+ tween(
4456
+ `#${sid} .pv`,
4457
+ { opacity: 0 },
4458
+ { opacity: 1, duration: 0.3, stagger: step },
4459
+ settled + 0.2
4460
+ )
4461
+ );
4234
4462
  if (deltas) {
4235
4463
  tl.push(
4236
4464
  tween(
4237
4465
  `#${sid} .dv`,
4238
4466
  { opacity: 0, y: -10 },
4239
4467
  { opacity: 1, y: 0, duration: 0.35, stagger: step },
4240
- draw2 + 0.6
4468
+ settled + 0.6
4241
4469
  )
4242
4470
  );
4243
4471
  }
4244
- if (p.points.length > 1) {
4472
+ const walk = cmp ? sec(step * count) : DRAW_SECONDS;
4473
+ if (count > 1) {
4245
4474
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
4246
4475
  tl.push(
4247
- tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, draw2),
4248
- ...travel(`#${sid}-ring`, route, draw2, 1.8),
4476
+ tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
4477
+ ...travel(`#${sid}-ring`, route, settled, walk),
4249
4478
  // And it leaves once the curve is whole: a marker parked on the last
4250
4479
  // point for the rest of the beat reads as a defect, not as emphasis.
4251
4480
  tween(
4252
4481
  `#${sid}-ring`,
4253
4482
  { opacity: 1 },
4254
4483
  { opacity: 0, duration: 0.4, immediateRender: false },
4255
- draw2 + 1.8
4484
+ settled + walk
4256
4485
  )
4257
4486
  );
4258
4487
  }
4259
- const drawn = draw2 + step * p.points.length + 0.4;
4260
- const holds = [drawn];
4488
+ const drawn = settled + (cmp ? tailAfter(step) : step * count + 0.4);
4489
+ const holds = cmp ? [lift2, drawn] : [drawn];
4261
4490
  if (p.readout) {
4262
4491
  tl.push(
4263
4492
  // It enters from wherever it sits: from the right when it is beside the
@@ -4266,9 +4495,22 @@ var lineChart = (beat, ctx) => {
4266
4495
  );
4267
4496
  holds.push(drawn + 0.8);
4268
4497
  }
4498
+ const end = holds[holds.length - 1] ?? 0;
4499
+ const need = sec(end + 0.15);
4500
+ if (cmp && need > beat.seconds + 1e-9) {
4501
+ return {
4502
+ ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
4503
+ warnings: [
4504
+ `line-chart ${beat.id}: a comparison against "${cmp.label}" over ${count} points needs ${need}s and the beat is ${beat.seconds}s, so the chart was drawn without it. Lengthen the beat to keep the comparison.`
4505
+ ]
4506
+ };
4507
+ }
4269
4508
  return {
4270
4509
  html,
4271
4510
  tl,
4511
+ // Named only when a beat actually reshapes, which is what keeps MorphSVG's
4512
+ // 21,195 bytes off every deck that does not. See `PLUGINS` in composition.ts.
4513
+ ...cmp ? { plugins: ["morphSVG"] } : {},
4272
4514
  holds: holdsWithin(holds, beat.seconds),
4273
4515
  css: [
4274
4516
  chromeCss(theme),
@@ -4293,6 +4535,14 @@ var lineChart = (beat, ctx) => {
4293
4535
  `.axname{font-size:40px;fill:${theme.muted};font-weight:500}`,
4294
4536
  `.ptlab{font-size:40px;fill:${theme.fg};font-weight:600}`,
4295
4537
  `.delta{font-size:40px;fill:${theme.tones.b};font-weight:600}`,
4538
+ // Only when a ghost was actually named — not merely when there is a ghost,
4539
+ // since the label is dropped where neither side of the baseline is clear.
4540
+ // An unconditional rule would move the stylesheet bytes of every line chart
4541
+ // ever built for a part they do not draw, which is the whole thing
4542
+ // `Scene.plugins` is careful about one level up. `muted`, not `dim`: it
4543
+ // names a series, so it is read, and the curve it names is the thing that
4544
+ // has been faded, not its label.
4545
+ ...ghost ? [`.ghostlab{font-size:40px;fill:${theme.muted};font-weight:600}`] : [],
4296
4546
  // 1.7 set the two lines of a wrapped readout 68px apart, which reads as two
4297
4547
  // unrelated fragments rather than one sentence. 1.35 keeps it a paragraph.
4298
4548
  `.readout{font-size:${BODY_SIZE}px;line-height:${READOUT_LH};color:${theme.muted};max-width:${READOUT_W}px}`,
@@ -5538,7 +5788,10 @@ function round4(n3) {
5538
5788
  // src/emit/composition.ts
5539
5789
  var GSAP_SRC = "./vendor/gsap.min.js";
5540
5790
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5541
- var MORPH_SRC = "./vendor/ds-morph.js";
5791
+ var PLUGINS = {
5792
+ dsMorph: { src: "./vendor/ds-morph.js", global: "DSMorphPlugin" },
5793
+ morphSVG: { src: "./vendor/MorphSVGPlugin.min.js", global: "MorphSVGPlugin" }
5794
+ };
5542
5795
  var KATEX_JS = "./vendor/katex.min.js";
5543
5796
  var KATEX_CSS = "./katex/katex.min.css";
5544
5797
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5646,7 +5899,15 @@ function layout(storyboard, source, format, opts = {}) {
5646
5899
  }
5647
5900
  if (scene.css) archetypeCss.add(scene.css.trim());
5648
5901
  if (scene.measure?.length) builds = true;
5649
- for (const p of scene.plugins ?? []) plugins.add(p);
5902
+ for (const w of cut2.scene.warnings ?? []) opts.onBeatWarning?.(beat.id, w);
5903
+ for (const p of scene.plugins ?? []) {
5904
+ if (!Object.hasOwn(PLUGINS, p)) {
5905
+ throw new Error(
5906
+ `${beat.archetype} ${beat.id}: no vendored plugin named "${p}" \u2014 Scene.plugins takes ${Object.keys(PLUGINS).join(" or ")}`
5907
+ );
5908
+ }
5909
+ plugins.add(p);
5910
+ }
5650
5911
  scenes.push(
5651
5912
  sceneHtml(
5652
5913
  sid,
@@ -5763,9 +6024,11 @@ function renderComposition(storyboard, format, laid) {
5763
6024
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5764
6025
  const island = format.navigable ? `
5765
6026
  ${emitIsland(slides)}` : "";
5766
- const morph = laid.plugins.has("dsMorph") ? `
5767
- <script src="${MORPH_SRC}"></script>
5768
- <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
6027
+ const plugins = Object.entries(PLUGINS).filter(([name]) => laid.plugins.has(name)).map(
6028
+ ([, p]) => `
6029
+ <script src="${p.src}"></script>
6030
+ <script>gsap.registerPlugin(${p.global});</script>`
6031
+ ).join("");
5769
6032
  return `<!doctype html>
5770
6033
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5771
6034
  <head>
@@ -5774,7 +6037,7 @@ ${emitIsland(slides)}` : "";
5774
6037
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5775
6038
  <script src="${GSAP_SRC}"></script>
5776
6039
  <script src="${DRAWSVG_SRC}"></script>
5777
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
6040
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${plugins}
5778
6041
  <link rel="stylesheet" href="${KATEX_CSS}" />
5779
6042
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5780
6043
  <style>
@@ -6443,7 +6706,7 @@ function textOf(node) {
6443
6706
  // src/source/harvest.ts
6444
6707
  import { createHash as createHash5 } from "node:crypto";
6445
6708
  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";
6709
+ import { basename as basename3, join as join5, resolve as resolve2 } from "node:path";
6447
6710
 
6448
6711
  // src/net/fetch.ts
6449
6712
  import { lookup as resolveHost } from "node:dns/promises";
@@ -6643,8 +6906,8 @@ async function send(url, target, headers, signal, opts) {
6643
6906
  ...secure && isIP(host2) === 0 ? { servername: host2 } : {}
6644
6907
  };
6645
6908
  try {
6646
- return await new Promise((resolve7, reject) => {
6647
- const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
6909
+ return await new Promise((resolve8, reject) => {
6910
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve8);
6648
6911
  req.on("error", reject);
6649
6912
  req.end();
6650
6913
  });
@@ -6728,29 +6991,37 @@ function message(err) {
6728
6991
 
6729
6992
  // src/render/capture.ts
6730
6993
  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;
6994
+ import { join as join3 } from "node:path";
6995
+ async function resolveChrome(need = "open the deck with") {
6996
+ const env2 = process.env.HYPERFRAMES_BROWSER_PATH || process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
6997
+ if (env2) return { path: env2, source: "env" };
6735
6998
  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;
6999
+ const pick = async (cacheDir) => {
7000
+ const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
7001
+ const ordered = [...installed2].sort((a, b) => b.buildId.localeCompare(a.buildId, "en"));
7002
+ return ordered.find((b) => b.browser === "chrome-headless-shell") ?? ordered.find((b) => b.browser === "chrome");
7003
+ };
7004
+ const hf = await pick(join3(homedir(), ".cache", "hyperframes", "chrome"));
7005
+ if (hf) return { path: hf.executablePath, source: "hyperframes" };
7006
+ const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join3(homedir(), ".cache", "puppeteer"));
7007
+ if (pup) return { path: pup.executablePath, source: "puppeteer" };
6740
7008
  throw new Error(
6741
7009
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
6742
7010
  );
6743
7011
  }
7012
+ async function chromePath(need = "open the deck with") {
7013
+ return (await resolveChrome(need)).path;
7014
+ }
6744
7015
 
6745
7016
  // src/source/assets.ts
6746
7017
  import { createHash as createHash4 } from "node:crypto";
6747
7018
  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";
7019
+ import { extname as extname2, join as join4 } from "node:path";
6749
7020
  import { z as z2 } from "zod";
6750
7021
  async function fetchFigures(source, dir, warnings) {
6751
7022
  const drops = warnings ?? [];
6752
7023
  await mkdir2(dir, { recursive: true });
6753
- const cached = await readdir(dir).catch(() => []);
7024
+ const cached2 = await readdir(dir).catch(() => []);
6754
7025
  const figures = [];
6755
7026
  for (const figure of source.figures) {
6756
7027
  if (figure.kind === "clip") {
@@ -6758,7 +7029,7 @@ async function fetchFigures(source, dir, warnings) {
6758
7029
  continue;
6759
7030
  }
6760
7031
  try {
6761
- figures.push(figureSchema.parse(await localize(figure, dir, cached)));
7032
+ figures.push(figureSchema.parse(await localize(figure, dir, cached2)));
6762
7033
  } catch (err) {
6763
7034
  drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6764
7035
  }
@@ -6772,15 +7043,15 @@ async function fetchFigures(source, dir, warnings) {
6772
7043
  }
6773
7044
  return parsed.data;
6774
7045
  }
6775
- async function localize(figure, dir, cached) {
7046
+ async function localize(figure, dir, cached2) {
6776
7047
  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);
7048
+ const hit = cached2.find((name) => name.startsWith(`${stem}.`));
7049
+ const bytes = hit ? await readFile3(join4(dir, hit)) : await load(figure.src);
6779
7050
  const size = imageSize(bytes);
6780
7051
  if (hit) return { ...figure, src: hit, ...size };
6781
7052
  const src = `${stem}${assetExt(bytes, figure.src)}`;
6782
- await writeFile2(join3(dir, src), bytes);
6783
- cached.push(src);
7053
+ await writeFile2(join4(dir, src), bytes);
7054
+ cached2.push(src);
6784
7055
  return { ...figure, src, ...size };
6785
7056
  }
6786
7057
  function assetStem(id2, src) {
@@ -7341,7 +7612,7 @@ function readContentRegion() {
7341
7612
 
7342
7613
  // src/source/transcode.ts
7343
7614
  import { readFile as readFile4, rm } from "node:fs/promises";
7344
- import { basename } from "node:path";
7615
+ import { basename as basename2 } from "node:path";
7345
7616
 
7346
7617
  // src/render/ffmpeg.ts
7347
7618
  import { execFile, spawn } from "node:child_process";
@@ -7366,7 +7637,7 @@ ${tail}`);
7366
7637
  }
7367
7638
  }
7368
7639
  function runLive(file, args) {
7369
- return new Promise((resolve7, reject) => {
7640
+ return new Promise((resolve8, reject) => {
7370
7641
  const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7371
7642
  child.on("error", (err) => {
7372
7643
  reject(
@@ -7374,7 +7645,7 @@ function runLive(file, args) {
7374
7645
  );
7375
7646
  });
7376
7647
  child.on("close", (code, signal) => {
7377
- if (code === 0) resolve7();
7648
+ if (code === 0) resolve8();
7378
7649
  else if (signal) {
7379
7650
  reject(
7380
7651
  new Error(
@@ -7570,7 +7841,7 @@ async function transcode(input, out, opts = {}) {
7570
7841
  const file = opts.ffmpeg ?? "ffmpeg";
7571
7842
  const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
7572
7843
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
7573
- const name = basename(input);
7844
+ const name = basename2(input);
7574
7845
  const source = videoSize(await readFile4(input));
7575
7846
  const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
7576
7847
  const kept = (why2) => ({
@@ -7639,7 +7910,7 @@ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
7639
7910
  async function harvest(url, dir, opts = {}) {
7640
7911
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
7641
7912
  const warnings = [];
7642
- const assets = resolve(dir);
7913
+ const assets = resolve2(dir);
7643
7914
  await mkdir3(assets, { recursive: true });
7644
7915
  const page = await fetchGuarded(url, {
7645
7916
  maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
@@ -7701,7 +7972,11 @@ async function readInBrowser(html, timeoutMs) {
7701
7972
  "--disable-background-networking",
7702
7973
  "--disable-extensions",
7703
7974
  "--no-default-browser-check",
7704
- "--no-first-run"
7975
+ "--no-first-run",
7976
+ // The floor under interception — see the note above. Not a hardening
7977
+ // nicety: without it the open-proxy property holds only as fast as CDP
7978
+ // happens to be that run.
7979
+ "--host-resolver-rules=MAP * ~NOTFOUND"
7705
7980
  ]
7706
7981
  });
7707
7982
  try {
@@ -7967,7 +8242,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
7967
8242
  const clips = [];
7968
8243
  let spent = 0;
7969
8244
  const done = /* @__PURE__ */ new Map();
7970
- const ref = (got) => opts.refs === "relative" ? basename2(got.path) : got.path;
8245
+ const ref = (got) => opts.refs === "relative" ? basename3(got.path) : got.path;
7971
8246
  const overdrawn = () => {
7972
8247
  if (Date.now() >= deadline) {
7973
8248
  return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
@@ -8018,7 +8293,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8018
8293
  `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
8294
  );
8020
8295
  }
8021
- const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
8296
+ const path2 = join5(dir, assetName(url, extFor2(sniffFormat(bytes))));
8022
8297
  await writeFile3(path2, bytes);
8023
8298
  assets.push(path2);
8024
8299
  got = { path: path2, width: size.width, height: size.height };
@@ -8031,7 +8306,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8031
8306
  };
8032
8307
  const shrink = async (url, path2, measured, what) => {
8033
8308
  if (opts.transcode === false) return { path: path2, ...measured };
8034
- const out2 = join4(dir, assetName(url, ".vp9.webm"));
8309
+ const out2 = join5(dir, assetName(url, ".vp9.webm"));
8035
8310
  let small;
8036
8311
  try {
8037
8312
  small = await transcode(path2, out2, {
@@ -8046,7 +8321,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8046
8321
  const [before, after] = await Promise.all([sizeOf(path2), sizeOf(small.path)]);
8047
8322
  if (before > 0 && after > before) {
8048
8323
  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.`
8324
+ `${what} \u2014 ${basename3(small.path)} came back LARGER than the page's own file (${mb(before)} \u2192 ${mb(after)}) at ${small.width}x${small.height}. The encode is sized for the render, which pre-decodes every clip to one still per output frame, so it still costs less to render; pass --no-transcode if the deck's size is what matters here.`
8050
8325
  );
8051
8326
  }
8052
8327
  await rm2(path2, { force: true });
@@ -8073,7 +8348,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8073
8348
  try {
8074
8349
  const bytes = await bytesOf(url);
8075
8350
  const measured = videoSize(bytes);
8076
- const path2 = join4(dir, assetName(url, `.${measured.container}`));
8351
+ const path2 = join5(dir, assetName(url, `.${measured.container}`));
8077
8352
  await writeFile3(path2, bytes);
8078
8353
  return await shrink(url, path2, measured, what);
8079
8354
  } catch (err) {
@@ -8438,8 +8713,8 @@ ${fence}`;
8438
8713
  // src/plan/codex.ts
8439
8714
  import { spawn as spawn2 } from "node:child_process";
8440
8715
  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";
8716
+ import { tmpdir as tmpdir2 } from "node:os";
8717
+ import { join as join6 } from "node:path";
8443
8718
  import { z as z3 } from "zod";
8444
8719
 
8445
8720
  // src/plan/arc.ts
@@ -8651,7 +8926,10 @@ var REVEALS = {
8651
8926
  "equation-walk": "one per term",
8652
8927
  "equation-morph": "2",
8653
8928
  "data-table": "one per highlighted row, plus 1",
8654
- "line-chart": "1",
8929
+ // "long enough" is not a hedge: below the emitter's own floor the comparison
8930
+ // is dropped and the chart stops twice becoming a chart that stops once. See
8931
+ // the line-chart entry under THE THIRTEEN ARCHETYPES for what that costs.
8932
+ "line-chart": "1, or 2 when compare is given and the beat is long enough for it",
8655
8933
  callout: "one per panel",
8656
8934
  pipeline: "one per stage",
8657
8935
  "annotated-figure": "one per note, plus 1",
@@ -8774,6 +9052,29 @@ DRAWING ARCHETYPES \u2014 reach here first
8774
9052
  scale, over training. Points come from the source's numbers;
8775
9053
  deltas, if given, are the steps between consecutive points, so
8776
9054
  there is always one fewer.
9055
+ \`compare\` adds a SECOND CONDITION over the same axis \u2014 the tell
9056
+ there is THE SAME QUANTITY MEASURED TWO WAYS, a baseline and the
9057
+ result, where what the source is claiming is the change in the
9058
+ SHAPE of the curve and not two numbers. Its \`label\` names the
9059
+ baseline ("without pretraining", "FP32") and is drawn on the
9060
+ slide where the chart has room for it. Keep it SHORT \u2014 two or
9061
+ three words. A label wider than the plot is refused and the
9062
+ whole slide goes with it, and one that cannot be placed clear of
9063
+ the axis names, the values and both curves is dropped without a
9064
+ word, leaving the baseline unnamed. A \`readout\` takes the plot
9065
+ down to about two thirds of its width, so a beat that has one
9066
+ has room for a shorter label still.
9067
+ \`compare.points\` must carry the SAME x values, in the
9068
+ same order, as \`points\`; the baseline is drawn first, then the
9069
+ curve reshapes into \`points\` and leaves the baseline behind. Do
9070
+ not use it for two unrelated series that happen to share an
9071
+ axis \u2014 nothing reshapes into something it is not a version of.
9072
+ A compare beat also costs TIME \u2014 the baseline draws, is held,
9073
+ then reshapes \u2014 so give it \`seconds\` of 7, or 8 with a
9074
+ \`readout\`. Give it too few and the emitter draws the chart
9075
+ without the comparison rather than stopping on a half-drawn one:
9076
+ the slide is still there, but the point about the baseline is
9077
+ gone.
8777
9078
 
8778
9079
  DESCRIBING ARCHETYPES \u2014 the fallbacks
8779
9080
 
@@ -9431,10 +9732,10 @@ function schemaFor(prefs) {
9431
9732
  var SCHEMA = schemaFor({ genre: "general" });
9432
9733
  async function codexPlanner(source, opts = {}) {
9433
9734
  const prefs = opts.prefs ?? prefsSchema.parse({});
9434
- const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
9735
+ const dir = await mkdtemp(join6(tmpdir2(), "decksmith-plan-"));
9435
9736
  try {
9436
- const schemaPath = join5(dir, "storyboard.schema.json");
9437
- const outPath = join5(dir, "storyboard.json");
9737
+ const schemaPath = join6(dir, "storyboard.schema.json");
9738
+ const outPath = join6(dir, "storyboard.json");
9438
9739
  await writeFile4(schemaPath, JSON.stringify(schemaFor(prefs)));
9439
9740
  await (opts.run ?? runCodex)({
9440
9741
  prompt: buildPrompt(source, prefs),
@@ -9516,7 +9817,7 @@ function codexCommand(args) {
9516
9817
  }
9517
9818
  function runCodex(args) {
9518
9819
  const { argv, env: env2 } = codexCommand(args);
9519
- return new Promise((resolve7, reject) => {
9820
+ return new Promise((resolve8, reject) => {
9520
9821
  const child = spawn2("codex", argv, {
9521
9822
  stdio: ["pipe", "ignore", "pipe"],
9522
9823
  ...env2 === void 0 ? {} : { env: env2 }
@@ -9539,7 +9840,7 @@ function runCodex(args) {
9539
9840
  });
9540
9841
  child.on("close", (code) => {
9541
9842
  clearTimeout(timer);
9542
- if (code === 0) return resolve7();
9843
+ if (code === 0) return resolve8();
9543
9844
  reject(
9544
9845
  new Error(`codex exec exited ${code}.
9545
9846
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -9552,13 +9853,13 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
9552
9853
  // src/images/illustrate.ts
9553
9854
  import { createHash as createHash7 } from "node:crypto";
9554
9855
  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";
9856
+ import { join as join8 } from "node:path";
9556
9857
 
9557
9858
  // src/images/providers.ts
9558
9859
  import { createHash as createHash6 } from "node:crypto";
9559
9860
  import { mkdtemp as mkdtemp2, readFile as readFile6, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
9560
- import { tmpdir as tmpdir2 } from "node:os";
9561
- import { join as join6, resolve as resolve2 } from "node:path";
9861
+ import { tmpdir as tmpdir3 } from "node:os";
9862
+ import { join as join7, resolve as resolve3 } from "node:path";
9562
9863
  import { z as z4 } from "zod";
9563
9864
  var SIZE = {
9564
9865
  landscape: { width: 1536, height: 1024 },
@@ -9690,10 +9991,10 @@ function codexImages(opts = {}) {
9690
9991
  async check() {
9691
9992
  },
9692
9993
  async generate(req) {
9693
- const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
9994
+ const dir = await mkdtemp2(join7(tmpdir3(), "decksmith-image-"));
9694
9995
  try {
9695
- const schemaPath = join6(dir, "answer.schema.json");
9696
- const outPath = join6(dir, "answer.json");
9996
+ const schemaPath = join7(dir, "answer.schema.json");
9997
+ const outPath = join7(dir, "answer.json");
9697
9998
  await writeFile5(schemaPath, JSON.stringify(ANSWER_SCHEMA));
9698
9999
  await run5({
9699
10000
  prompt: codexPrompt(req),
@@ -9711,8 +10012,8 @@ function codexImages(opts = {}) {
9711
10012
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
9712
10013
  );
9713
10014
  }
9714
- const candidates2 = [join6(dir, "picture.png")];
9715
- if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
10015
+ const candidates2 = [join7(dir, "picture.png")];
10016
+ if (answer.data.file) candidates2.push(resolve3(dir, answer.data.file));
9716
10017
  for (const path2 of candidates2) {
9717
10018
  const bytes = await readFile6(path2).catch(() => null);
9718
10019
  if (bytes) return raster(bytes, "codex");
@@ -9943,16 +10244,16 @@ function cacheKey(providerId, req) {
9943
10244
  async function draw(provider, req, name, dir) {
9944
10245
  for (const ext of Object.values(EXT2)) {
9945
10246
  const src2 = `${name}${ext}`;
9946
- const bytes = await readFile7(join7(dir, src2)).catch(() => null);
10247
+ const bytes = await readFile7(join8(dir, src2)).catch(() => null);
9947
10248
  if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
9948
10249
  }
9949
10250
  await provider.check();
9950
10251
  const img = await provider.generate(req);
9951
10252
  const src = `${name}${EXT2[img.mime]}`;
9952
- const tmp = join7(dir, `.${name}.tmp`);
10253
+ const tmp = join8(dir, `.${name}.tmp`);
9953
10254
  try {
9954
10255
  await writeFile6(tmp, img.bytes);
9955
- await rename(tmp, join7(dir, src));
10256
+ await rename(tmp, join8(dir, src));
9956
10257
  } finally {
9957
10258
  await rm5(tmp, { force: true });
9958
10259
  }
@@ -9967,7 +10268,7 @@ import { spawn as spawn3 } from "node:child_process";
9967
10268
  import { createHash as createHash8 } from "node:crypto";
9968
10269
  import { mkdir as mkdir5, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
9969
10270
  import { homedir as homedir2 } from "node:os";
9970
- import { join as join8 } from "node:path";
10271
+ import { join as join9 } from "node:path";
9971
10272
  var MISSING = [
9972
10273
  "edge-tts is not installed, so narration cannot be synthesised.",
9973
10274
  "",
@@ -9984,8 +10285,8 @@ function candidates() {
9984
10285
  ["edge-tts"],
9985
10286
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
9986
10287
  // default, and both are where this actually lands in practice.
9987
- [join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
9988
- [join8(home, ".local", "bin", "edge-tts")],
10288
+ [join9(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10289
+ [join9(home, ".local", "bin", "edge-tts")],
9989
10290
  // Last resort: the module is installed even though its console script is
9990
10291
  // nowhere findable, which is the normal state of a pip --user install.
9991
10292
  ["python3", "-m", "edge_tts"]
@@ -10010,7 +10311,7 @@ async function find2(can) {
10010
10311
  throw new Error(MISSING);
10011
10312
  }
10012
10313
  function runArgv(cmd, args) {
10013
- return new Promise((resolve7) => {
10314
+ return new Promise((resolve8) => {
10014
10315
  const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10015
10316
  let stdout = "";
10016
10317
  let stderr = "";
@@ -10020,8 +10321,8 @@ function runArgv(cmd, args) {
10020
10321
  child.stderr.on("data", (b) => {
10021
10322
  stderr += b.toString();
10022
10323
  });
10023
- child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
10024
- child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
10324
+ child.on("error", (e) => resolve8({ code: -1, stderr: String(e), stdout }));
10325
+ child.on("close", (code) => resolve8({ code: code ?? -1, stderr, stdout }));
10025
10326
  });
10026
10327
  }
10027
10328
  var edgeTts = {
@@ -10106,10 +10407,10 @@ async function synthesize(text2, opts) {
10106
10407
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
10107
10408
  const key = cacheKey2(text2, opts.voice, rate, pitch);
10108
10409
  const file = `${key}.mp3`;
10109
- const audio = join8(opts.dir, file);
10110
- const sidecar = join8(opts.dir, `${key}.json`);
10111
- const cached = await readSidecar(sidecar);
10112
- if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
10410
+ const audio = join9(opts.dir, file);
10411
+ const sidecar = join9(opts.dir, `${key}.json`);
10412
+ const cached2 = await readSidecar(sidecar);
10413
+ if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
10113
10414
  await mkdir5(opts.dir, { recursive: true });
10114
10415
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
10115
10416
  const { cues, seconds } = spoken;
@@ -10273,11 +10574,11 @@ function scanBeatCount(storyboard, prefs) {
10273
10574
 
10274
10575
  // src/render/render.ts
10275
10576
  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";
10577
+ import { basename as basename4, dirname as dirname2, join as join11, resolve as resolve4 } from "node:path";
10277
10578
 
10278
10579
  // src/render/captions.ts
10279
10580
  import { mkdir as mkdir6, writeFile as writeFile8 } from "node:fs/promises";
10280
- import { join as join9 } from "node:path";
10581
+ import { join as join10 } from "node:path";
10281
10582
  import { pathToFileURL } from "node:url";
10282
10583
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
10283
10584
  function cssString(value) {
@@ -10368,9 +10669,9 @@ async function captionBlocker() {
10368
10669
  }
10369
10670
  async function renderCaptions(cues, style, deck, work2) {
10370
10671
  if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
10371
- const fontCss = join9(deck, DECK_FONT_CSS);
10672
+ const fontCss = join10(deck, DECK_FONT_CSS);
10372
10673
  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");
10674
+ const page = join10(work2, "captions.html");
10374
10675
  await mkdir6(work2, { recursive: true });
10375
10676
  await writeFile8(page, captionPage(cues, style, href));
10376
10677
  const { default: puppeteer } = await import("puppeteer-core");
@@ -10392,7 +10693,7 @@ async function renderCaptions(cues, style, deck, work2) {
10392
10693
  const name = `cap${String(i).padStart(4, "0")}.png`;
10393
10694
  await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
10394
10695
  await tab.screenshot({
10395
- path: join9(work2, name),
10696
+ path: join10(work2, name),
10396
10697
  type: "png",
10397
10698
  omitBackground: true,
10398
10699
  clip: { x: box.x, y: box.y, width: box.width, height: box.height }
@@ -10426,16 +10727,16 @@ function overlayInputs(band) {
10426
10727
  }
10427
10728
 
10428
10729
  // src/render/render.ts
10429
- var SRT_NAME = (out) => `${basename3(out).replace(/\.[^.]+$/, "")}.srt`;
10730
+ var SRT_NAME = (out) => `${basename4(out).replace(/\.[^.]+$/, "")}.srt`;
10430
10731
  function subtitlePlan(mode) {
10431
10732
  return { sidecar: mode !== "none", burn: mode === "burn" };
10432
10733
  }
10433
10734
  async function render(opts) {
10434
10735
  const log = opts.log ?? (() => {
10435
10736
  });
10436
- const deck = resolve3(opts.deck);
10437
- const out = resolve3(opts.out);
10438
- const work2 = join10(dirname(out), `.${basename3(out)}.parts`);
10737
+ const deck = resolve4(opts.deck);
10738
+ const out = resolve4(opts.out);
10739
+ const work2 = join11(dirname2(out), `.${basename4(out)}.parts`);
10439
10740
  const timing = await readTiming(deck);
10440
10741
  if (opts.targetSeconds && !opts.allowFastPlayback) {
10441
10742
  const refusal = playbackRefusal(
@@ -10453,7 +10754,7 @@ async function render(opts) {
10453
10754
  const burnable = plan0.burn;
10454
10755
  await mkdir7(work2, { recursive: true });
10455
10756
  try {
10456
- const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join10(work2, "raw.mp4"), opts, log);
10757
+ const raw2 = opts.video ? resolve4(opts.video) : await capture(deck, join11(work2, "raw.mp4"), opts, log);
10457
10758
  const shot = await probe(raw2);
10458
10759
  log(
10459
10760
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -10466,7 +10767,7 @@ async function render(opts) {
10466
10767
  );
10467
10768
  }
10468
10769
  const retimed = await retime(raw2, plan, work2, log);
10469
- const srtPath = join10(dirname(out), SRT_NAME(out));
10770
+ const srtPath = join11(dirname2(out), SRT_NAME(out));
10470
10771
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
10471
10772
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
10472
10773
  const srt = plan0.sidecar ? toSrt(cues) : "";
@@ -10477,7 +10778,7 @@ async function render(opts) {
10477
10778
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
10478
10779
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
10479
10780
  if (warning) log(`render: ${warning}`);
10480
- const fast = join10(work2, `fast.${basename3(out)}`);
10781
+ const fast = join11(work2, `fast.${basename4(out)}`);
10481
10782
  const muxed = await probe(out);
10482
10783
  await runTool(
10483
10784
  "ffmpeg",
@@ -10507,7 +10808,7 @@ async function render(opts) {
10507
10808
  }
10508
10809
  }
10509
10810
  async function readTiming(deck) {
10510
- const path2 = join10(deck, TIMING_FILE);
10811
+ const path2 = join11(deck, TIMING_FILE);
10511
10812
  const text2 = await readFile9(path2, "utf8").catch(() => {
10512
10813
  throw new Error(
10513
10814
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
@@ -10547,20 +10848,20 @@ async function retime(raw2, plan, work2, log) {
10547
10848
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
10548
10849
  const list = [];
10549
10850
  for (const [i, piece] of plan.pieces.entries()) {
10550
- const file = join10(work2, `p${String(i).padStart(4, "0")}.ts`);
10851
+ const file = join11(work2, `p${String(i).padStart(4, "0")}.ts`);
10551
10852
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
10552
10853
  list.push(file);
10553
10854
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
10554
10855
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
10555
10856
  }
10556
10857
  }
10557
- const listFile = join10(work2, "pieces.txt");
10858
+ const listFile = join11(work2, "pieces.txt");
10558
10859
  await writeFile9(
10559
10860
  listFile,
10560
10861
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
10561
10862
  `
10562
10863
  );
10563
- const joined = join10(work2, "retimed.mp4");
10864
+ const joined = join11(work2, "retimed.mp4");
10564
10865
  await runTool("ffmpeg", [
10565
10866
  "-y",
10566
10867
  "-hide_banner",
@@ -10582,7 +10883,7 @@ async function retime(raw2, plan, work2, log) {
10582
10883
  }
10583
10884
  async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
10584
10885
  const inputs = plan.audio.map((a) => ({
10585
- file: join10(deck, timing.audioDir, a.audio),
10886
+ file: join11(deck, timing.audioDir, a.audio),
10586
10887
  delayMs: a.delayMs
10587
10888
  }));
10588
10889
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -10603,7 +10904,7 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
10603
10904
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
10604
10905
  }
10605
10906
  if (graph.length > 0) {
10606
- const script = join10(work2, "mux.filter");
10907
+ const script = join11(work2, "mux.filter");
10607
10908
  await writeFile9(script, `${graph.join(";\n")}
10608
10909
  `);
10609
10910
  args.push("-filter_complex_script", script);
@@ -10619,13 +10920,13 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
10619
10920
  log(
10620
10921
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
10621
10922
  );
10622
- await mkdir7(dirname(out), { recursive: true });
10923
+ await mkdir7(dirname2(out), { recursive: true });
10623
10924
  await runTool("ffmpeg", args, { cwd: work2 });
10624
10925
  }
10625
10926
 
10626
10927
  // src/pack/pack.ts
10627
10928
  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";
10929
+ import { dirname as dirname3, extname as extname3 } from "node:path";
10629
10930
  import { unzipSync, zipSync } from "fflate";
10630
10931
  var MTIME = Date.UTC(1980, 0, 2, 12);
10631
10932
  var STORED = /* @__PURE__ */ new Set([
@@ -10664,7 +10965,7 @@ async function writePack(pack3, files, out) {
10664
10965
  entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
10665
10966
  }
10666
10967
  const zip = zipSync(entries, { mtime: MTIME });
10667
- await mkdir8(dirname2(out), { recursive: true });
10968
+ await mkdir8(dirname3(out), { recursive: true });
10668
10969
  await writeFile10(out, zip);
10669
10970
  return zip.length;
10670
10971
  }
@@ -10684,7 +10985,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10684
10985
  if (!format) throw new Error("no deck-16x9 format");
10685
10986
  const step = opts.onStep ?? (() => {
10686
10987
  });
10687
- const out = resolve4(outDir);
10988
+ const out = resolve5(outDir);
10688
10989
  await mkdir9(out, { recursive: true });
10689
10990
  const speed = opts.speed ?? 1;
10690
10991
  const fontCss = await refreshFont(storyboard, source, out, step);
@@ -10693,12 +10994,13 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10693
10994
  ...opts.theme ? { theme: opts.theme } : {},
10694
10995
  ...opts.narration ? { narration: opts.narration } : {},
10695
10996
  ...opts.onBeatError ? { onBeatError: opts.onBeatError } : {},
10997
+ ...opts.onBeatWarning ? { onBeatWarning: opts.onBeatWarning } : {},
10696
10998
  ...fontCss ? { fontCss } : {}
10697
10999
  });
10698
11000
  const files = [];
10699
11001
  const write = async (name, text2) => {
10700
- await writeFile11(join11(out, name), text2);
10701
- files.push(join11(out, name));
11002
+ await writeFile11(join12(out, name), text2);
11003
+ files.push(join12(out, name));
10702
11004
  };
10703
11005
  await write("index.html", deck.composition);
10704
11006
  await write("hyperframes.json", HYPERFRAMES_JSON);
@@ -10727,8 +11029,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10727
11029
  }
10728
11030
  if (deck.page) {
10729
11031
  await write(DECK_PAGE, deck.page);
10730
- await cp(playerBundle(), join11(out, PLAYER_FILE));
10731
- files.push(join11(out, PLAYER_FILE));
11032
+ await cp(playerBundle(), join12(out, PLAYER_FILE));
11033
+ files.push(join12(out, PLAYER_FILE));
10732
11034
  }
10733
11035
  files.push(...await vendorKatex(out));
10734
11036
  if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
@@ -10737,7 +11039,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10737
11039
  }
10738
11040
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
10739
11041
  step(
10740
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join11(out, "index.html")}`
11042
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join12(out, "index.html")}`
10741
11043
  );
10742
11044
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
10743
11045
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -10761,40 +11063,40 @@ async function deckRuntime() {
10761
11063
  }
10762
11064
  }
10763
11065
  function playerBundle() {
10764
- const require2 = createRequire2(import.meta.url);
11066
+ const require2 = createRequire3(import.meta.url);
10765
11067
  try {
10766
- return join11(dirname3(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
11068
+ return join12(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
10767
11069
  } catch {
10768
11070
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
10769
11071
  }
10770
11072
  }
10771
11073
  async function vendorKatex(out) {
10772
- const require2 = createRequire2(import.meta.url);
10773
- const dist = join11(dirname3(require2.resolve("katex/package.json")), "dist");
10774
- const css = await readFile11(join11(dist, "katex.min.css"), "utf8");
11074
+ const require2 = createRequire3(import.meta.url);
11075
+ const dist = join12(dirname4(require2.resolve("katex/package.json")), "dist");
11076
+ const css = await readFile11(join12(dist, "katex.min.css"), "utf8");
10775
11077
  const written = [];
10776
- await mkdir9(join11(out, "katex/fonts"), { recursive: true });
10777
- for (const file of await readdir2(join11(dist, "fonts"))) {
11078
+ await mkdir9(join12(out, "katex/fonts"), { recursive: true });
11079
+ for (const file of await readdir2(join12(dist, "fonts"))) {
10778
11080
  if (!file.endsWith(".woff2")) continue;
10779
- await cp(join11(dist, "fonts", file), join11(out, "katex/fonts", file));
10780
- written.push(join11(out, "katex/fonts", file));
11081
+ await cp(join12(dist, "fonts", file), join12(out, "katex/fonts", file));
11082
+ written.push(join12(out, "katex/fonts", file));
10781
11083
  }
10782
11084
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
10783
11085
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
10784
11086
  return kept ? `src:${kept}` : whole;
10785
11087
  });
10786
- await writeFile11(join11(out, "katex/katex.min.css"), woff2Only);
10787
- written.push(join11(out, "katex/katex.min.css"));
11088
+ await writeFile11(join12(out, "katex/katex.min.css"), woff2Only);
11089
+ written.push(join12(out, "katex/katex.min.css"));
10788
11090
  return written;
10789
11091
  }
10790
11092
  async function copyAssets(sourceDir, out) {
10791
- const from = join11(resolve4(sourceDir), "assets");
11093
+ const from = join12(resolve5(sourceDir), "assets");
10792
11094
  if (!await stat2(from).catch(() => null)) return [];
10793
- await cp(from, join11(out, "assets"), { recursive: true });
10794
- return [join11(out, "assets")];
11095
+ await cp(from, join12(out, "assets"), { recursive: true });
11096
+ return [join12(out, "assets")];
10795
11097
  }
10796
11098
  async function copyAudio(from, narration, out) {
10797
- const dir = join11(out, narration.dir);
11099
+ const dir = join12(out, narration.dir);
10798
11100
  await mkdir9(dir, { recursive: true });
10799
11101
  const names = [
10800
11102
  ...new Set(
@@ -10802,18 +11104,18 @@ async function copyAudio(from, narration, out) {
10802
11104
  )
10803
11105
  ].sort();
10804
11106
  for (const name of names) {
10805
- await cp(join11(resolve4(from), name), join11(dir, name)).catch(() => {
11107
+ await cp(join12(resolve5(from), name), join12(dir, name)).catch(() => {
10806
11108
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
10807
11109
  });
10808
11110
  }
10809
- return names.map((n3) => join11(dir, n3));
11111
+ return names.map((n3) => join12(dir, n3));
10810
11112
  }
10811
11113
  async function refreshFont(storyboard, source, out, step) {
10812
11114
  try {
10813
11115
  const bundle = await bundleFont(
10814
11116
  storyboard.lang,
10815
11117
  JSON.stringify(source) + JSON.stringify(storyboard),
10816
- join11(out, "assets", "fonts")
11118
+ join12(out, "assets", "fonts")
10817
11119
  );
10818
11120
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
10819
11121
  return bundle?.css;
@@ -10826,7 +11128,7 @@ async function refreshFont(storyboard, source, out, step) {
10826
11128
  }
10827
11129
 
10828
11130
  // src/server/upload.ts
10829
- import { posix, sep } from "node:path";
11131
+ import { posix, sep as sep2 } from "node:path";
10830
11132
  import { unzipSync as unzipSync2 } from "fflate";
10831
11133
  var MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
10832
11134
  var ZIP_LIMITS = {
@@ -10863,8 +11165,8 @@ function safeEntryPath(name) {
10863
11165
  if (path2.length > 512 || kept.some((s) => s.length > 200)) return null;
10864
11166
  return path2;
10865
11167
  }
10866
- function insideRoot(root2, joined) {
10867
- return joined === root2 || joined.startsWith(root2.endsWith(sep) ? root2 : root2 + sep);
11168
+ function insideRoot2(root2, joined) {
11169
+ return joined === root2 || joined.startsWith(root2.endsWith(sep2) ? root2 : root2 + sep2);
10868
11170
  }
10869
11171
  function readZip(bytes, limits = ZIP_LIMITS) {
10870
11172
  const seen = [];
@@ -11169,7 +11471,7 @@ function int(name, raw2) {
11169
11471
 
11170
11472
  // src/server/pipeline.ts
11171
11473
  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";
11474
+ import { dirname as dirname5, isAbsolute, join as join13, relative, resolve as resolve6 } from "node:path";
11173
11475
  var AUDIO_DIR = "audio";
11174
11476
  var NARRATION_FILE = "narration.json";
11175
11477
  var MAX_REMOTE_FIGURES = 40;
@@ -11192,10 +11494,10 @@ function stagesFor(options) {
11192
11494
  async function runPipeline(job, input) {
11193
11495
  const { options } = input;
11194
11496
  const dirs = {
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")
11497
+ upload: join13(job.dir, "upload"),
11498
+ src: join13(job.dir, "src"),
11499
+ audio: join13(job.dir, AUDIO_DIR),
11500
+ deck: join13(job.dir, "deck")
11199
11501
  };
11200
11502
  const warnings = [...options.warnings];
11201
11503
  const url = (rel) => `/d/${job.id}/${rel}`;
@@ -11219,21 +11521,21 @@ async function runPipeline(job, input) {
11219
11521
  theme: options.stated.theme ? prefs.theme : planned.theme
11220
11522
  };
11221
11523
  assertRefsResolve(storyboard, source, { pending: options.images ? "allow" : "refuse" });
11222
- await writeJson(join12(job.dir, "storyboard.json"), storyboard);
11524
+ await writeJson(join13(job.dir, "storyboard.json"), storyboard);
11223
11525
  job.done("plan", `${storyboard.beats.length} beats`);
11224
11526
  for (const f of scanBeatCount(storyboard, prefs)) warnings.push(f.message);
11225
11527
  if (options.images) {
11226
11528
  job.begin("illustrate");
11227
11529
  const drawn = await illustrate(storyboard, source, {
11228
11530
  prefs,
11229
- assetsDir: join12(dirs.src, "assets"),
11531
+ assetsDir: join13(dirs.src, "assets"),
11230
11532
  ...input.imageChain ? { chain: input.imageChain } : {},
11231
11533
  onStep: (line2) => job.log(line2)
11232
11534
  });
11233
11535
  storyboard = drawn.storyboard;
11234
11536
  source = drawn.source;
11235
- await writeJson(join12(dirs.src, "source.json"), source);
11236
- await writeJson(join12(job.dir, "storyboard.json"), storyboard);
11537
+ await writeJson(join13(dirs.src, "source.json"), source);
11538
+ await writeJson(join13(job.dir, "storyboard.json"), storyboard);
11237
11539
  for (const p of drawn.illustrated) {
11238
11540
  job.log(
11239
11541
  `illustrate: ${p.beatId} \u2192 assets/${p.src} via ${p.provider}${p.cached ? " (cached)" : ""}`
@@ -11260,7 +11562,7 @@ async function runPipeline(job, input) {
11260
11562
  dir: dirs.audio,
11261
11563
  format: options.format
11262
11564
  });
11263
- await writeJson(join12(dirs.audio, NARRATION_FILE), spoken);
11565
+ await writeJson(join13(dirs.audio, NARRATION_FILE), spoken);
11264
11566
  narration = { voice: spoken.voice, dir: AUDIO_DIR, beats: spoken.beats };
11265
11567
  const segments2 = Object.values(spoken.beats).flat();
11266
11568
  const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
@@ -11286,6 +11588,13 @@ async function runPipeline(job, input) {
11286
11588
  onBeatError: (id2, err) => {
11287
11589
  warnings.push(`slide ${id2} was left out: ${err.message}`);
11288
11590
  job.log(`build: dropped ${id2} \u2014 ${err.message}`);
11591
+ },
11592
+ // A beat that IS in the deck, drawn with one of its parts dropped so it
11593
+ // would fit. The slide looks finished, so the job's warnings are the only
11594
+ // place the person who asked for it finds out otherwise.
11595
+ onBeatWarning: (id2, warning) => {
11596
+ warnings.push(`slide ${id2} was drawn short: ${warning}`);
11597
+ job.log(`build: kept ${id2} \u2014 ${warning}`);
11289
11598
  }
11290
11599
  });
11291
11600
  for (const d of built.cut.dropped) warnings.push(`cut ${d.beat.id} \u2014 ${d.reason}`);
@@ -11307,7 +11616,7 @@ async function runPipeline(job, input) {
11307
11616
  job.log("render: capturing frames \u2014 two minutes for a four-minute video");
11308
11617
  const out = await render({
11309
11618
  deck: dirs.deck,
11310
- out: join12(dirs.deck, "video.mp4"),
11619
+ out: join13(dirs.deck, "video.mp4"),
11311
11620
  subtitles: "sidecar",
11312
11621
  log: (line2) => job.log(line2),
11313
11622
  // The last of the three length levers, and the only one that acts on a
@@ -11357,7 +11666,7 @@ async function runPipeline(job, input) {
11357
11666
  }
11358
11667
  async function ingest(job, input, dirs, warnings) {
11359
11668
  await mkdir10(dirs.upload, { recursive: true });
11360
- const root2 = resolve5(dirs.upload);
11669
+ const root2 = resolve6(dirs.upload);
11361
11670
  let docPath;
11362
11671
  const upload = input.upload;
11363
11672
  const bothOrNeither = new UploadError(
@@ -11367,12 +11676,12 @@ async function ingest(job, input, dirs, warnings) {
11367
11676
  );
11368
11677
  if (input.url !== void 0) {
11369
11678
  if (upload !== void 0) throw bothOrNeither;
11370
- const harvested = await harvestBounded(input.url, join12(root2, "assets"), {
11679
+ const harvested = await harvestBounded(input.url, join13(root2, "assets"), {
11371
11680
  ...HARVEST_LIMITS,
11372
11681
  ...input.harvest
11373
11682
  });
11374
11683
  warnings.push(...harvested.warnings);
11375
- docPath = join12(root2, "document.md");
11684
+ docPath = join13(root2, "document.md");
11376
11685
  await writeFile12(docPath, harvested.markdown);
11377
11686
  job.log(
11378
11687
  `ingest: harvested ${input.url} \u2014 "${harvested.title}", ${harvested.assets.length} asset(s)`
@@ -11389,13 +11698,13 @@ async function ingest(job, input, dirs, warnings) {
11389
11698
  const { files, warnings: zipWarnings } = readZip(upload.bytes);
11390
11699
  warnings.push(...zipWarnings);
11391
11700
  for (const [rel, bytes] of Object.entries(files)) {
11392
- const to = resolve5(join12(root2, rel));
11393
- if (!insideRoot(root2, to))
11701
+ const to = resolve6(join13(root2, rel));
11702
+ if (!insideRoot2(root2, to))
11394
11703
  throw new UploadError(`Refusing to write ${rel}.`, "Re-zip from inside the folder.");
11395
- await mkdir10(dirname4(to), { recursive: true });
11704
+ await mkdir10(dirname5(to), { recursive: true });
11396
11705
  await writeFile12(to, bytes);
11397
11706
  }
11398
- docPath = join12(root2, pickMarkdown(files));
11707
+ docPath = join13(root2, pickMarkdown(files));
11399
11708
  job.log(
11400
11709
  `ingest: unpacked ${Object.keys(files).length} file(s), reading ${pickMarkdown(files)}`
11401
11710
  );
@@ -11407,7 +11716,7 @@ async function ingest(job, input, dirs, warnings) {
11407
11716
  `DeckSmith reads ${MARKDOWN_EXTS.join(", ")} or a .zip containing one. Export the document to markdown first.`
11408
11717
  );
11409
11718
  }
11410
- docPath = join12(root2, "document.md");
11719
+ docPath = join13(root2, "document.md");
11411
11720
  await writeFile12(docPath, upload.bytes);
11412
11721
  }
11413
11722
  const text2 = await readFile12(docPath, "utf8");
@@ -11430,8 +11739,8 @@ async function ingest(job, input, dirs, warnings) {
11430
11739
  `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations, ${parsed.tables.length} tables`
11431
11740
  );
11432
11741
  const guarded = await guardFigures(parsed, root2, input.fetchRemoteFigures, warnings);
11433
- const source = await fetchFigures(guarded, join12(resolve5(dirs.src), "assets"), warnings);
11434
- await writeJson(join12(dirs.src, "source.json"), source);
11742
+ const source = await fetchFigures(guarded, join13(resolve6(dirs.src), "assets"), warnings);
11743
+ await writeJson(join13(dirs.src, "source.json"), source);
11435
11744
  return source;
11436
11745
  }
11437
11746
  async function harvestBounded(url, dir, opts) {
@@ -11507,8 +11816,8 @@ async function guardFigures(source, root2, allowRemote, warnings) {
11507
11816
  warnings.push(`figure ${figure.id} was left out: "${scheme}:" figures are not read`);
11508
11817
  continue;
11509
11818
  }
11510
- const abs = isAbsolute(src) ? resolve5(src) : resolve5(join12(root2, src.replace(/^\.?\//, "")));
11511
- if (!insideRoot(root2, abs)) {
11819
+ const abs = isAbsolute(src) ? resolve6(src) : resolve6(join13(root2, src.replace(/^\.?\//, "")));
11820
+ if (!insideRoot2(root2, abs)) {
11512
11821
  warnings.push(`figure ${figure.id} was left out: "${src}" points outside the upload`);
11513
11822
  continue;
11514
11823
  }
@@ -11522,10 +11831,10 @@ async function guardFigures(source, root2, allowRemote, warnings) {
11522
11831
  }
11523
11832
  async function pack2(job, ctx) {
11524
11833
  try {
11525
- const assets = join12(resolve5(ctx.dirs.src), "assets");
11834
+ const assets = join13(resolve6(ctx.dirs.src), "assets");
11526
11835
  const requests = ctx.source.figures.map((f) => ({
11527
11836
  id: f.id,
11528
- url: join12(assets, f.src),
11837
+ url: join13(assets, f.src),
11529
11838
  prefer: "bake"
11530
11839
  }));
11531
11840
  const plan = await planMedia(requests, async (url) => ({
@@ -11534,7 +11843,7 @@ async function pack2(job, ctx) {
11534
11843
  const files = { ...plan.files };
11535
11844
  if (ctx.narration) {
11536
11845
  for (const name of audioNames(ctx.narration)) {
11537
- files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile12(join12(ctx.dirs.audio, name)));
11846
+ files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile12(join13(ctx.dirs.audio, name)));
11538
11847
  }
11539
11848
  }
11540
11849
  const container = {
@@ -11551,10 +11860,10 @@ async function pack2(job, ctx) {
11551
11860
  },
11552
11861
  source: ctx.source,
11553
11862
  storyboard: ctx.storyboard,
11554
- ...ctx.narration ? { narration: await readJson(join12(ctx.dirs.audio, NARRATION_FILE)) } : {},
11863
+ ...ctx.narration ? { narration: await readJson(join13(ctx.dirs.audio, NARRATION_FILE)) } : {},
11555
11864
  media: plan.media
11556
11865
  };
11557
- const bytes = await writePack(container, files, join12(ctx.dirs.deck, "deck.deck"));
11866
+ const bytes = await writePack(container, files, join13(ctx.dirs.deck, "deck.deck"));
11558
11867
  job.log(`pack: ${Math.round(bytes / 1024)} KB \u2192 deck.deck`);
11559
11868
  return ctx.url("deck.deck");
11560
11869
  } catch (err) {
@@ -11577,7 +11886,7 @@ function host(src) {
11577
11886
  }
11578
11887
  }
11579
11888
  async function writeJson(path2, value) {
11580
- await mkdir10(dirname4(path2), { recursive: true });
11889
+ await mkdir10(dirname5(path2), { recursive: true });
11581
11890
  await writeFile12(path2, `${JSON.stringify(value, null, 2)}
11582
11891
  `);
11583
11892
  }
@@ -11989,13 +12298,13 @@ var PAGE_BUDGET = {
11989
12298
  };
11990
12299
  async function readPage(url, work2) {
11991
12300
  await mkdir11(work2, { recursive: true });
11992
- const dir = await mkdtemp3(join13(work2, "harvest-"));
12301
+ const dir = await mkdtemp3(join14(work2, "harvest-"));
11993
12302
  try {
11994
12303
  const page = await harvest(url, dir, PAGE_BUDGET);
11995
12304
  const files = {
11996
12305
  "document.md": new TextEncoder().encode(page.markdown)
11997
12306
  };
11998
- for (const asset of page.assets) files[basename4(asset)] = await readFile13(asset);
12307
+ for (const asset of page.assets) files[basename5(asset)] = await readFile13(asset);
11999
12308
  const warnings = [...page.warnings];
12000
12309
  if (page.clips.length > 0) {
12001
12310
  warnings.push(
@@ -12029,16 +12338,16 @@ var statusSchema = z5.object({
12029
12338
  var DEFAULT_WAIT = 45;
12030
12339
  function deckTools(opts) {
12031
12340
  const queue = new Queue({ maxQueued: 8 });
12032
- const root2 = resolve6(opts.root);
12033
- let cached;
12341
+ const root2 = resolve7(opts.root);
12342
+ let cached2;
12034
12343
  const found = async () => {
12035
- cached ??= await (opts.probe ?? prereqs)();
12036
- return cached;
12344
+ cached2 ??= await (opts.probe ?? prereqs)();
12345
+ return cached2;
12037
12346
  };
12038
- const insideRoot2 = (p) => {
12347
+ const insideRoot3 = (p) => {
12039
12348
  if (!isAbsolute2(p)) throw new Error(`document_path must be absolute; got "${p}".`);
12040
- const full = resolve6(p);
12041
- if (full !== root2 && !full.startsWith(root2 + sep2)) {
12349
+ const full = resolve7(p);
12350
+ if (full !== root2 && !full.startsWith(root2 + sep3)) {
12042
12351
  throw new Error(
12043
12352
  `document_path is outside the server's root. It may only read files under ${root2}.`
12044
12353
  );
@@ -12099,7 +12408,7 @@ function deckTools(opts) {
12099
12408
  }
12100
12409
  const settings = input.settings ?? {};
12101
12410
  const options = parseOptions(fieldsFor(settings));
12102
- const file = input.document_path ? insideRoot2(input.document_path) : void 0;
12411
+ const file = input.document_path ? insideRoot3(input.document_path) : void 0;
12103
12412
  const missing = missingFor(await found(), options);
12104
12413
  if (missing.length) {
12105
12414
  throw new Error(
@@ -12115,7 +12424,7 @@ function deckTools(opts) {
12115
12424
  }
12116
12425
  }
12117
12426
  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";
12427
+ const filename = page ? "page.zip" : input.document_path ? input.document_path.split(sep3).pop() : "document.md";
12119
12428
  const bytes = page ? page.zip : file ? await readFile13(file) : new TextEncoder().encode(input.document_text);
12120
12429
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
12121
12430
  throw new Error(
@@ -12123,7 +12432,7 @@ function deckTools(opts) {
12123
12432
  );
12124
12433
  }
12125
12434
  const id2 = randomBytes(16).toString("base64url");
12126
- const dir = join13(opts.work, id2);
12435
+ const dir = join14(opts.work, id2);
12127
12436
  await mkdir11(dir, { recursive: true });
12128
12437
  queue.submit({
12129
12438
  id: id2,
@@ -12151,7 +12460,7 @@ function deckTools(opts) {
12151
12460
  };
12152
12461
  }
12153
12462
  function dirOf(work2, id2) {
12154
- return join13(work2, id2);
12463
+ return join14(work2, id2);
12155
12464
  }
12156
12465
  function waitFor(queue, id2, seconds) {
12157
12466
  const now = queue.view(id2);
@@ -12183,9 +12492,9 @@ function report(view, dir) {
12183
12492
  // (src/server/pipeline.ts). This said `src/storyboard.json` for one run, which
12184
12493
  // is the shape of the CLI's output directory and not the server's, and an
12185
12494
  // agent told to read it would have found nothing there.
12186
- storyboard_path: join13(dir, "storyboard.json"),
12495
+ storyboard_path: join14(dir, "storyboard.json"),
12187
12496
  ...done && view.result ? {
12188
- deck_path: join13(dir, "deck"),
12497
+ deck_path: join14(dir, "deck"),
12189
12498
  slides: view.result.slides,
12190
12499
  duration_seconds: view.result.duration,
12191
12500
  warnings: view.result.warnings
@@ -12194,7 +12503,7 @@ function report(view, dir) {
12194
12503
  };
12195
12504
  }
12196
12505
  function defaultWork() {
12197
- return join13(tmpdir3(), "decksmith-mcp");
12506
+ return join14(tmpdir4(), "decksmith-mcp");
12198
12507
  }
12199
12508
 
12200
12509
  // src/mcp/main.ts
@@ -12204,6 +12513,7 @@ for (const level of ["log", "info", "debug", "warn", "trace"]) {
12204
12513
  `);
12205
12514
  };
12206
12515
  }
12516
+ guardTmpdir();
12207
12517
  var env = process.env;
12208
12518
  var root = env.DECKSMITH_MCP_ROOT ?? homedir3();
12209
12519
  var work = env.DECKSMITH_MCP_WORK ?? defaultWork();