@jokerized/decksmith 0.3.2 → 0.4.0

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,12 +6991,12 @@ 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";
6994
+ import { join as join3 } from "node:path";
6732
6995
  async function chromePath(need = "open the deck with") {
6733
6996
  const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
6734
6997
  if (explicit) return explicit;
6735
6998
  const { getInstalledBrowsers } = await import("@puppeteer/browsers");
6736
- const cacheDir = process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer");
6999
+ const cacheDir = process.env.PUPPETEER_CACHE_DIR || join3(homedir(), ".cache", "puppeteer");
6737
7000
  const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
6738
7001
  const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
6739
7002
  if (found) return found.executablePath;
@@ -6745,12 +7008,12 @@ async function chromePath(need = "open the deck with") {
6745
7008
  // src/source/assets.ts
6746
7009
  import { createHash as createHash4 } from "node:crypto";
6747
7010
  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";
7011
+ import { extname as extname2, join as join4 } from "node:path";
6749
7012
  import { z as z2 } from "zod";
6750
7013
  async function fetchFigures(source, dir, warnings) {
6751
7014
  const drops = warnings ?? [];
6752
7015
  await mkdir2(dir, { recursive: true });
6753
- const cached = await readdir(dir).catch(() => []);
7016
+ const cached2 = await readdir(dir).catch(() => []);
6754
7017
  const figures = [];
6755
7018
  for (const figure of source.figures) {
6756
7019
  if (figure.kind === "clip") {
@@ -6758,7 +7021,7 @@ async function fetchFigures(source, dir, warnings) {
6758
7021
  continue;
6759
7022
  }
6760
7023
  try {
6761
- figures.push(figureSchema.parse(await localize(figure, dir, cached)));
7024
+ figures.push(figureSchema.parse(await localize(figure, dir, cached2)));
6762
7025
  } catch (err) {
6763
7026
  drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6764
7027
  }
@@ -6772,15 +7035,15 @@ async function fetchFigures(source, dir, warnings) {
6772
7035
  }
6773
7036
  return parsed.data;
6774
7037
  }
6775
- async function localize(figure, dir, cached) {
7038
+ async function localize(figure, dir, cached2) {
6776
7039
  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);
7040
+ const hit = cached2.find((name) => name.startsWith(`${stem}.`));
7041
+ const bytes = hit ? await readFile3(join4(dir, hit)) : await load(figure.src);
6779
7042
  const size = imageSize(bytes);
6780
7043
  if (hit) return { ...figure, src: hit, ...size };
6781
7044
  const src = `${stem}${assetExt(bytes, figure.src)}`;
6782
- await writeFile2(join3(dir, src), bytes);
6783
- cached.push(src);
7045
+ await writeFile2(join4(dir, src), bytes);
7046
+ cached2.push(src);
6784
7047
  return { ...figure, src, ...size };
6785
7048
  }
6786
7049
  function assetStem(id2, src) {
@@ -7341,7 +7604,7 @@ function readContentRegion() {
7341
7604
 
7342
7605
  // src/source/transcode.ts
7343
7606
  import { readFile as readFile4, rm } from "node:fs/promises";
7344
- import { basename } from "node:path";
7607
+ import { basename as basename2 } from "node:path";
7345
7608
 
7346
7609
  // src/render/ffmpeg.ts
7347
7610
  import { execFile, spawn } from "node:child_process";
@@ -7366,7 +7629,7 @@ ${tail}`);
7366
7629
  }
7367
7630
  }
7368
7631
  function runLive(file, args) {
7369
- return new Promise((resolve7, reject) => {
7632
+ return new Promise((resolve8, reject) => {
7370
7633
  const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
7371
7634
  child.on("error", (err) => {
7372
7635
  reject(
@@ -7374,7 +7637,7 @@ function runLive(file, args) {
7374
7637
  );
7375
7638
  });
7376
7639
  child.on("close", (code, signal) => {
7377
- if (code === 0) resolve7();
7640
+ if (code === 0) resolve8();
7378
7641
  else if (signal) {
7379
7642
  reject(
7380
7643
  new Error(
@@ -7570,7 +7833,7 @@ async function transcode(input, out, opts = {}) {
7570
7833
  const file = opts.ffmpeg ?? "ffmpeg";
7571
7834
  const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
7572
7835
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
7573
- const name = basename(input);
7836
+ const name = basename2(input);
7574
7837
  const source = videoSize(await readFile4(input));
7575
7838
  const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
7576
7839
  const kept = (why2) => ({
@@ -7639,7 +7902,7 @@ var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
7639
7902
  async function harvest(url, dir, opts = {}) {
7640
7903
  const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
7641
7904
  const warnings = [];
7642
- const assets = resolve(dir);
7905
+ const assets = resolve2(dir);
7643
7906
  await mkdir3(assets, { recursive: true });
7644
7907
  const page = await fetchGuarded(url, {
7645
7908
  maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
@@ -7967,7 +8230,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
7967
8230
  const clips = [];
7968
8231
  let spent = 0;
7969
8232
  const done = /* @__PURE__ */ new Map();
7970
- const ref = (got) => opts.refs === "relative" ? basename2(got.path) : got.path;
8233
+ const ref = (got) => opts.refs === "relative" ? basename3(got.path) : got.path;
7971
8234
  const overdrawn = () => {
7972
8235
  if (Date.now() >= deadline) {
7973
8236
  return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
@@ -8018,7 +8281,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8018
8281
  `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
8282
  );
8020
8283
  }
8021
- const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
8284
+ const path2 = join5(dir, assetName(url, extFor2(sniffFormat(bytes))));
8022
8285
  await writeFile3(path2, bytes);
8023
8286
  assets.push(path2);
8024
8287
  got = { path: path2, width: size.width, height: size.height };
@@ -8031,7 +8294,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8031
8294
  };
8032
8295
  const shrink = async (url, path2, measured, what) => {
8033
8296
  if (opts.transcode === false) return { path: path2, ...measured };
8034
- const out2 = join4(dir, assetName(url, ".vp9.webm"));
8297
+ const out2 = join5(dir, assetName(url, ".vp9.webm"));
8035
8298
  let small;
8036
8299
  try {
8037
8300
  small = await transcode(path2, out2, {
@@ -8046,7 +8309,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8046
8309
  const [before, after] = await Promise.all([sizeOf(path2), sizeOf(small.path)]);
8047
8310
  if (before > 0 && after > before) {
8048
8311
  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.`
8312
+ `${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
8313
  );
8051
8314
  }
8052
8315
  await rm2(path2, { force: true });
@@ -8073,7 +8336,7 @@ async function localise(seen, base, dir, opts, timeoutMs, warnings) {
8073
8336
  try {
8074
8337
  const bytes = await bytesOf(url);
8075
8338
  const measured = videoSize(bytes);
8076
- const path2 = join4(dir, assetName(url, `.${measured.container}`));
8339
+ const path2 = join5(dir, assetName(url, `.${measured.container}`));
8077
8340
  await writeFile3(path2, bytes);
8078
8341
  return await shrink(url, path2, measured, what);
8079
8342
  } catch (err) {
@@ -8438,8 +8701,8 @@ ${fence}`;
8438
8701
  // src/plan/codex.ts
8439
8702
  import { spawn as spawn2 } from "node:child_process";
8440
8703
  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";
8704
+ import { tmpdir as tmpdir2 } from "node:os";
8705
+ import { join as join6 } from "node:path";
8443
8706
  import { z as z3 } from "zod";
8444
8707
 
8445
8708
  // src/plan/arc.ts
@@ -8651,7 +8914,10 @@ var REVEALS = {
8651
8914
  "equation-walk": "one per term",
8652
8915
  "equation-morph": "2",
8653
8916
  "data-table": "one per highlighted row, plus 1",
8654
- "line-chart": "1",
8917
+ // "long enough" is not a hedge: below the emitter's own floor the comparison
8918
+ // is dropped and the chart stops twice becoming a chart that stops once. See
8919
+ // the line-chart entry under THE THIRTEEN ARCHETYPES for what that costs.
8920
+ "line-chart": "1, or 2 when compare is given and the beat is long enough for it",
8655
8921
  callout: "one per panel",
8656
8922
  pipeline: "one per stage",
8657
8923
  "annotated-figure": "one per note, plus 1",
@@ -8774,6 +9040,29 @@ DRAWING ARCHETYPES \u2014 reach here first
8774
9040
  scale, over training. Points come from the source's numbers;
8775
9041
  deltas, if given, are the steps between consecutive points, so
8776
9042
  there is always one fewer.
9043
+ \`compare\` adds a SECOND CONDITION over the same axis \u2014 the tell
9044
+ there is THE SAME QUANTITY MEASURED TWO WAYS, a baseline and the
9045
+ result, where what the source is claiming is the change in the
9046
+ SHAPE of the curve and not two numbers. Its \`label\` names the
9047
+ baseline ("without pretraining", "FP32") and is drawn on the
9048
+ slide where the chart has room for it. Keep it SHORT \u2014 two or
9049
+ three words. A label wider than the plot is refused and the
9050
+ whole slide goes with it, and one that cannot be placed clear of
9051
+ the axis names, the values and both curves is dropped without a
9052
+ word, leaving the baseline unnamed. A \`readout\` takes the plot
9053
+ down to about two thirds of its width, so a beat that has one
9054
+ has room for a shorter label still.
9055
+ \`compare.points\` must carry the SAME x values, in the
9056
+ same order, as \`points\`; the baseline is drawn first, then the
9057
+ curve reshapes into \`points\` and leaves the baseline behind. Do
9058
+ not use it for two unrelated series that happen to share an
9059
+ axis \u2014 nothing reshapes into something it is not a version of.
9060
+ A compare beat also costs TIME \u2014 the baseline draws, is held,
9061
+ then reshapes \u2014 so give it \`seconds\` of 7, or 8 with a
9062
+ \`readout\`. Give it too few and the emitter draws the chart
9063
+ without the comparison rather than stopping on a half-drawn one:
9064
+ the slide is still there, but the point about the baseline is
9065
+ gone.
8777
9066
 
8778
9067
  DESCRIBING ARCHETYPES \u2014 the fallbacks
8779
9068
 
@@ -9431,10 +9720,10 @@ function schemaFor(prefs) {
9431
9720
  var SCHEMA = schemaFor({ genre: "general" });
9432
9721
  async function codexPlanner(source, opts = {}) {
9433
9722
  const prefs = opts.prefs ?? prefsSchema.parse({});
9434
- const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
9723
+ const dir = await mkdtemp(join6(tmpdir2(), "decksmith-plan-"));
9435
9724
  try {
9436
- const schemaPath = join5(dir, "storyboard.schema.json");
9437
- const outPath = join5(dir, "storyboard.json");
9725
+ const schemaPath = join6(dir, "storyboard.schema.json");
9726
+ const outPath = join6(dir, "storyboard.json");
9438
9727
  await writeFile4(schemaPath, JSON.stringify(schemaFor(prefs)));
9439
9728
  await (opts.run ?? runCodex)({
9440
9729
  prompt: buildPrompt(source, prefs),
@@ -9516,7 +9805,7 @@ function codexCommand(args) {
9516
9805
  }
9517
9806
  function runCodex(args) {
9518
9807
  const { argv, env: env2 } = codexCommand(args);
9519
- return new Promise((resolve7, reject) => {
9808
+ return new Promise((resolve8, reject) => {
9520
9809
  const child = spawn2("codex", argv, {
9521
9810
  stdio: ["pipe", "ignore", "pipe"],
9522
9811
  ...env2 === void 0 ? {} : { env: env2 }
@@ -9539,7 +9828,7 @@ function runCodex(args) {
9539
9828
  });
9540
9829
  child.on("close", (code) => {
9541
9830
  clearTimeout(timer);
9542
- if (code === 0) return resolve7();
9831
+ if (code === 0) return resolve8();
9543
9832
  reject(
9544
9833
  new Error(`codex exec exited ${code}.
9545
9834
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -9552,13 +9841,13 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
9552
9841
  // src/images/illustrate.ts
9553
9842
  import { createHash as createHash7 } from "node:crypto";
9554
9843
  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";
9844
+ import { join as join8 } from "node:path";
9556
9845
 
9557
9846
  // src/images/providers.ts
9558
9847
  import { createHash as createHash6 } from "node:crypto";
9559
9848
  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";
9849
+ import { tmpdir as tmpdir3 } from "node:os";
9850
+ import { join as join7, resolve as resolve3 } from "node:path";
9562
9851
  import { z as z4 } from "zod";
9563
9852
  var SIZE = {
9564
9853
  landscape: { width: 1536, height: 1024 },
@@ -9690,10 +9979,10 @@ function codexImages(opts = {}) {
9690
9979
  async check() {
9691
9980
  },
9692
9981
  async generate(req) {
9693
- const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
9982
+ const dir = await mkdtemp2(join7(tmpdir3(), "decksmith-image-"));
9694
9983
  try {
9695
- const schemaPath = join6(dir, "answer.schema.json");
9696
- const outPath = join6(dir, "answer.json");
9984
+ const schemaPath = join7(dir, "answer.schema.json");
9985
+ const outPath = join7(dir, "answer.json");
9697
9986
  await writeFile5(schemaPath, JSON.stringify(ANSWER_SCHEMA));
9698
9987
  await run5({
9699
9988
  prompt: codexPrompt(req),
@@ -9711,8 +10000,8 @@ function codexImages(opts = {}) {
9711
10000
  `codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
9712
10001
  );
9713
10002
  }
9714
- const candidates2 = [join6(dir, "picture.png")];
9715
- if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
10003
+ const candidates2 = [join7(dir, "picture.png")];
10004
+ if (answer.data.file) candidates2.push(resolve3(dir, answer.data.file));
9716
10005
  for (const path2 of candidates2) {
9717
10006
  const bytes = await readFile6(path2).catch(() => null);
9718
10007
  if (bytes) return raster(bytes, "codex");
@@ -9943,16 +10232,16 @@ function cacheKey(providerId, req) {
9943
10232
  async function draw(provider, req, name, dir) {
9944
10233
  for (const ext of Object.values(EXT2)) {
9945
10234
  const src2 = `${name}${ext}`;
9946
- const bytes = await readFile7(join7(dir, src2)).catch(() => null);
10235
+ const bytes = await readFile7(join8(dir, src2)).catch(() => null);
9947
10236
  if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
9948
10237
  }
9949
10238
  await provider.check();
9950
10239
  const img = await provider.generate(req);
9951
10240
  const src = `${name}${EXT2[img.mime]}`;
9952
- const tmp = join7(dir, `.${name}.tmp`);
10241
+ const tmp = join8(dir, `.${name}.tmp`);
9953
10242
  try {
9954
10243
  await writeFile6(tmp, img.bytes);
9955
- await rename(tmp, join7(dir, src));
10244
+ await rename(tmp, join8(dir, src));
9956
10245
  } finally {
9957
10246
  await rm5(tmp, { force: true });
9958
10247
  }
@@ -9967,7 +10256,7 @@ import { spawn as spawn3 } from "node:child_process";
9967
10256
  import { createHash as createHash8 } from "node:crypto";
9968
10257
  import { mkdir as mkdir5, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
9969
10258
  import { homedir as homedir2 } from "node:os";
9970
- import { join as join8 } from "node:path";
10259
+ import { join as join9 } from "node:path";
9971
10260
  var MISSING = [
9972
10261
  "edge-tts is not installed, so narration cannot be synthesised.",
9973
10262
  "",
@@ -9984,8 +10273,8 @@ function candidates() {
9984
10273
  ["edge-tts"],
9985
10274
  // pip --user on macOS and on Linux respectively. Neither is on PATH by
9986
10275
  // 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")],
10276
+ [join9(home, "Library", "Python", "3.9", "bin", "edge-tts")],
10277
+ [join9(home, ".local", "bin", "edge-tts")],
9989
10278
  // Last resort: the module is installed even though its console script is
9990
10279
  // nowhere findable, which is the normal state of a pip --user install.
9991
10280
  ["python3", "-m", "edge_tts"]
@@ -10010,7 +10299,7 @@ async function find2(can) {
10010
10299
  throw new Error(MISSING);
10011
10300
  }
10012
10301
  function runArgv(cmd, args) {
10013
- return new Promise((resolve7) => {
10302
+ return new Promise((resolve8) => {
10014
10303
  const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
10015
10304
  let stdout = "";
10016
10305
  let stderr = "";
@@ -10020,8 +10309,8 @@ function runArgv(cmd, args) {
10020
10309
  child.stderr.on("data", (b) => {
10021
10310
  stderr += b.toString();
10022
10311
  });
10023
- child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
10024
- child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
10312
+ child.on("error", (e) => resolve8({ code: -1, stderr: String(e), stdout }));
10313
+ child.on("close", (code) => resolve8({ code: code ?? -1, stderr, stdout }));
10025
10314
  });
10026
10315
  }
10027
10316
  var edgeTts = {
@@ -10106,10 +10395,10 @@ async function synthesize(text2, opts) {
10106
10395
  const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
10107
10396
  const key = cacheKey2(text2, opts.voice, rate, pitch);
10108
10397
  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 };
10398
+ const audio = join9(opts.dir, file);
10399
+ const sidecar = join9(opts.dir, `${key}.json`);
10400
+ const cached2 = await readSidecar(sidecar);
10401
+ if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
10113
10402
  await mkdir5(opts.dir, { recursive: true });
10114
10403
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
10115
10404
  const { cues, seconds } = spoken;
@@ -10273,11 +10562,11 @@ function scanBeatCount(storyboard, prefs) {
10273
10562
 
10274
10563
  // src/render/render.ts
10275
10564
  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";
10565
+ import { basename as basename4, dirname as dirname2, join as join11, resolve as resolve4 } from "node:path";
10277
10566
 
10278
10567
  // src/render/captions.ts
10279
10568
  import { mkdir as mkdir6, writeFile as writeFile8 } from "node:fs/promises";
10280
- import { join as join9 } from "node:path";
10569
+ import { join as join10 } from "node:path";
10281
10570
  import { pathToFileURL } from "node:url";
10282
10571
  var DECK_FONT_CSS = "assets/fonts/fonts.css";
10283
10572
  function cssString(value) {
@@ -10368,9 +10657,9 @@ async function captionBlocker() {
10368
10657
  }
10369
10658
  async function renderCaptions(cues, style, deck, work2) {
10370
10659
  if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
10371
- const fontCss = join9(deck, DECK_FONT_CSS);
10660
+ const fontCss = join10(deck, DECK_FONT_CSS);
10372
10661
  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");
10662
+ const page = join10(work2, "captions.html");
10374
10663
  await mkdir6(work2, { recursive: true });
10375
10664
  await writeFile8(page, captionPage(cues, style, href));
10376
10665
  const { default: puppeteer } = await import("puppeteer-core");
@@ -10392,7 +10681,7 @@ async function renderCaptions(cues, style, deck, work2) {
10392
10681
  const name = `cap${String(i).padStart(4, "0")}.png`;
10393
10682
  await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
10394
10683
  await tab.screenshot({
10395
- path: join9(work2, name),
10684
+ path: join10(work2, name),
10396
10685
  type: "png",
10397
10686
  omitBackground: true,
10398
10687
  clip: { x: box.x, y: box.y, width: box.width, height: box.height }
@@ -10426,16 +10715,16 @@ function overlayInputs(band) {
10426
10715
  }
10427
10716
 
10428
10717
  // src/render/render.ts
10429
- var SRT_NAME = (out) => `${basename3(out).replace(/\.[^.]+$/, "")}.srt`;
10718
+ var SRT_NAME = (out) => `${basename4(out).replace(/\.[^.]+$/, "")}.srt`;
10430
10719
  function subtitlePlan(mode) {
10431
10720
  return { sidecar: mode !== "none", burn: mode === "burn" };
10432
10721
  }
10433
10722
  async function render(opts) {
10434
10723
  const log = opts.log ?? (() => {
10435
10724
  });
10436
- const deck = resolve3(opts.deck);
10437
- const out = resolve3(opts.out);
10438
- const work2 = join10(dirname(out), `.${basename3(out)}.parts`);
10725
+ const deck = resolve4(opts.deck);
10726
+ const out = resolve4(opts.out);
10727
+ const work2 = join11(dirname2(out), `.${basename4(out)}.parts`);
10439
10728
  const timing = await readTiming(deck);
10440
10729
  if (opts.targetSeconds && !opts.allowFastPlayback) {
10441
10730
  const refusal = playbackRefusal(
@@ -10453,7 +10742,7 @@ async function render(opts) {
10453
10742
  const burnable = plan0.burn;
10454
10743
  await mkdir7(work2, { recursive: true });
10455
10744
  try {
10456
- const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join10(work2, "raw.mp4"), opts, log);
10745
+ const raw2 = opts.video ? resolve4(opts.video) : await capture(deck, join11(work2, "raw.mp4"), opts, log);
10457
10746
  const shot = await probe(raw2);
10458
10747
  log(
10459
10748
  `render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
@@ -10466,7 +10755,7 @@ async function render(opts) {
10466
10755
  );
10467
10756
  }
10468
10757
  const retimed = await retime(raw2, plan, work2, log);
10469
- const srtPath = join10(dirname(out), SRT_NAME(out));
10758
+ const srtPath = join11(dirname2(out), SRT_NAME(out));
10470
10759
  const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
10471
10760
  const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
10472
10761
  const srt = plan0.sidecar ? toSrt(cues) : "";
@@ -10477,7 +10766,7 @@ async function render(opts) {
10477
10766
  log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
10478
10767
  const warning = playbackWarning(playback, p95CueRate(plan.cues));
10479
10768
  if (warning) log(`render: ${warning}`);
10480
- const fast = join10(work2, `fast.${basename3(out)}`);
10769
+ const fast = join11(work2, `fast.${basename4(out)}`);
10481
10770
  const muxed = await probe(out);
10482
10771
  await runTool(
10483
10772
  "ffmpeg",
@@ -10507,7 +10796,7 @@ async function render(opts) {
10507
10796
  }
10508
10797
  }
10509
10798
  async function readTiming(deck) {
10510
- const path2 = join10(deck, TIMING_FILE);
10799
+ const path2 = join11(deck, TIMING_FILE);
10511
10800
  const text2 = await readFile9(path2, "utf8").catch(() => {
10512
10801
  throw new Error(
10513
10802
  `${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
@@ -10547,20 +10836,20 @@ async function retime(raw2, plan, work2, log) {
10547
10836
  if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
10548
10837
  const list = [];
10549
10838
  for (const [i, piece] of plan.pieces.entries()) {
10550
- const file = join10(work2, `p${String(i).padStart(4, "0")}.ts`);
10839
+ const file = join11(work2, `p${String(i).padStart(4, "0")}.ts`);
10551
10840
  await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
10552
10841
  list.push(file);
10553
10842
  if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
10554
10843
  log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
10555
10844
  }
10556
10845
  }
10557
- const listFile = join10(work2, "pieces.txt");
10846
+ const listFile = join11(work2, "pieces.txt");
10558
10847
  await writeFile9(
10559
10848
  listFile,
10560
10849
  `${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
10561
10850
  `
10562
10851
  );
10563
- const joined = join10(work2, "retimed.mp4");
10852
+ const joined = join11(work2, "retimed.mp4");
10564
10853
  await runTool("ffmpeg", [
10565
10854
  "-y",
10566
10855
  "-hide_banner",
@@ -10582,7 +10871,7 @@ async function retime(raw2, plan, work2, log) {
10582
10871
  }
10583
10872
  async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
10584
10873
  const inputs = plan.audio.map((a) => ({
10585
- file: join10(deck, timing.audioDir, a.audio),
10874
+ file: join11(deck, timing.audioDir, a.audio),
10586
10875
  delayMs: a.delayMs
10587
10876
  }));
10588
10877
  const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
@@ -10603,7 +10892,7 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
10603
10892
  graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
10604
10893
  }
10605
10894
  if (graph.length > 0) {
10606
- const script = join10(work2, "mux.filter");
10895
+ const script = join11(work2, "mux.filter");
10607
10896
  await writeFile9(script, `${graph.join(";\n")}
10608
10897
  `);
10609
10898
  args.push("-filter_complex_script", script);
@@ -10619,13 +10908,13 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
10619
10908
  log(
10620
10909
  `render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
10621
10910
  );
10622
- await mkdir7(dirname(out), { recursive: true });
10911
+ await mkdir7(dirname2(out), { recursive: true });
10623
10912
  await runTool("ffmpeg", args, { cwd: work2 });
10624
10913
  }
10625
10914
 
10626
10915
  // src/pack/pack.ts
10627
10916
  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";
10917
+ import { dirname as dirname3, extname as extname3 } from "node:path";
10629
10918
  import { unzipSync, zipSync } from "fflate";
10630
10919
  var MTIME = Date.UTC(1980, 0, 2, 12);
10631
10920
  var STORED = /* @__PURE__ */ new Set([
@@ -10664,7 +10953,7 @@ async function writePack(pack3, files, out) {
10664
10953
  entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
10665
10954
  }
10666
10955
  const zip = zipSync(entries, { mtime: MTIME });
10667
- await mkdir8(dirname2(out), { recursive: true });
10956
+ await mkdir8(dirname3(out), { recursive: true });
10668
10957
  await writeFile10(out, zip);
10669
10958
  return zip.length;
10670
10959
  }
@@ -10684,7 +10973,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10684
10973
  if (!format) throw new Error("no deck-16x9 format");
10685
10974
  const step = opts.onStep ?? (() => {
10686
10975
  });
10687
- const out = resolve4(outDir);
10976
+ const out = resolve5(outDir);
10688
10977
  await mkdir9(out, { recursive: true });
10689
10978
  const speed = opts.speed ?? 1;
10690
10979
  const fontCss = await refreshFont(storyboard, source, out, step);
@@ -10693,12 +10982,13 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10693
10982
  ...opts.theme ? { theme: opts.theme } : {},
10694
10983
  ...opts.narration ? { narration: opts.narration } : {},
10695
10984
  ...opts.onBeatError ? { onBeatError: opts.onBeatError } : {},
10985
+ ...opts.onBeatWarning ? { onBeatWarning: opts.onBeatWarning } : {},
10696
10986
  ...fontCss ? { fontCss } : {}
10697
10987
  });
10698
10988
  const files = [];
10699
10989
  const write = async (name, text2) => {
10700
- await writeFile11(join11(out, name), text2);
10701
- files.push(join11(out, name));
10990
+ await writeFile11(join12(out, name), text2);
10991
+ files.push(join12(out, name));
10702
10992
  };
10703
10993
  await write("index.html", deck.composition);
10704
10994
  await write("hyperframes.json", HYPERFRAMES_JSON);
@@ -10727,8 +11017,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10727
11017
  }
10728
11018
  if (deck.page) {
10729
11019
  await write(DECK_PAGE, deck.page);
10730
- await cp(playerBundle(), join11(out, PLAYER_FILE));
10731
- files.push(join11(out, PLAYER_FILE));
11020
+ await cp(playerBundle(), join12(out, PLAYER_FILE));
11021
+ files.push(join12(out, PLAYER_FILE));
10732
11022
  }
10733
11023
  files.push(...await vendorKatex(out));
10734
11024
  if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
@@ -10737,7 +11027,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
10737
11027
  }
10738
11028
  const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
10739
11029
  step(
10740
- `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join11(out, "index.html")}`
11030
+ `build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join12(out, "index.html")}`
10741
11031
  );
10742
11032
  for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
10743
11033
  for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
@@ -10761,40 +11051,40 @@ async function deckRuntime() {
10761
11051
  }
10762
11052
  }
10763
11053
  function playerBundle() {
10764
- const require2 = createRequire2(import.meta.url);
11054
+ const require2 = createRequire3(import.meta.url);
10765
11055
  try {
10766
- return join11(dirname3(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
11056
+ return join12(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
10767
11057
  } catch {
10768
11058
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
10769
11059
  }
10770
11060
  }
10771
11061
  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");
11062
+ const require2 = createRequire3(import.meta.url);
11063
+ const dist = join12(dirname4(require2.resolve("katex/package.json")), "dist");
11064
+ const css = await readFile11(join12(dist, "katex.min.css"), "utf8");
10775
11065
  const written = [];
10776
- await mkdir9(join11(out, "katex/fonts"), { recursive: true });
10777
- for (const file of await readdir2(join11(dist, "fonts"))) {
11066
+ await mkdir9(join12(out, "katex/fonts"), { recursive: true });
11067
+ for (const file of await readdir2(join12(dist, "fonts"))) {
10778
11068
  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));
11069
+ await cp(join12(dist, "fonts", file), join12(out, "katex/fonts", file));
11070
+ written.push(join12(out, "katex/fonts", file));
10781
11071
  }
10782
11072
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
10783
11073
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
10784
11074
  return kept ? `src:${kept}` : whole;
10785
11075
  });
10786
- await writeFile11(join11(out, "katex/katex.min.css"), woff2Only);
10787
- written.push(join11(out, "katex/katex.min.css"));
11076
+ await writeFile11(join12(out, "katex/katex.min.css"), woff2Only);
11077
+ written.push(join12(out, "katex/katex.min.css"));
10788
11078
  return written;
10789
11079
  }
10790
11080
  async function copyAssets(sourceDir, out) {
10791
- const from = join11(resolve4(sourceDir), "assets");
11081
+ const from = join12(resolve5(sourceDir), "assets");
10792
11082
  if (!await stat2(from).catch(() => null)) return [];
10793
- await cp(from, join11(out, "assets"), { recursive: true });
10794
- return [join11(out, "assets")];
11083
+ await cp(from, join12(out, "assets"), { recursive: true });
11084
+ return [join12(out, "assets")];
10795
11085
  }
10796
11086
  async function copyAudio(from, narration, out) {
10797
- const dir = join11(out, narration.dir);
11087
+ const dir = join12(out, narration.dir);
10798
11088
  await mkdir9(dir, { recursive: true });
10799
11089
  const names = [
10800
11090
  ...new Set(
@@ -10802,18 +11092,18 @@ async function copyAudio(from, narration, out) {
10802
11092
  )
10803
11093
  ].sort();
10804
11094
  for (const name of names) {
10805
- await cp(join11(resolve4(from), name), join11(dir, name)).catch(() => {
11095
+ await cp(join12(resolve5(from), name), join12(dir, name)).catch(() => {
10806
11096
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
10807
11097
  });
10808
11098
  }
10809
- return names.map((n3) => join11(dir, n3));
11099
+ return names.map((n3) => join12(dir, n3));
10810
11100
  }
10811
11101
  async function refreshFont(storyboard, source, out, step) {
10812
11102
  try {
10813
11103
  const bundle = await bundleFont(
10814
11104
  storyboard.lang,
10815
11105
  JSON.stringify(source) + JSON.stringify(storyboard),
10816
- join11(out, "assets", "fonts")
11106
+ join12(out, "assets", "fonts")
10817
11107
  );
10818
11108
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
10819
11109
  return bundle?.css;
@@ -10826,7 +11116,7 @@ async function refreshFont(storyboard, source, out, step) {
10826
11116
  }
10827
11117
 
10828
11118
  // src/server/upload.ts
10829
- import { posix, sep } from "node:path";
11119
+ import { posix, sep as sep2 } from "node:path";
10830
11120
  import { unzipSync as unzipSync2 } from "fflate";
10831
11121
  var MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
10832
11122
  var ZIP_LIMITS = {
@@ -10863,8 +11153,8 @@ function safeEntryPath(name) {
10863
11153
  if (path2.length > 512 || kept.some((s) => s.length > 200)) return null;
10864
11154
  return path2;
10865
11155
  }
10866
- function insideRoot(root2, joined) {
10867
- return joined === root2 || joined.startsWith(root2.endsWith(sep) ? root2 : root2 + sep);
11156
+ function insideRoot2(root2, joined) {
11157
+ return joined === root2 || joined.startsWith(root2.endsWith(sep2) ? root2 : root2 + sep2);
10868
11158
  }
10869
11159
  function readZip(bytes, limits = ZIP_LIMITS) {
10870
11160
  const seen = [];
@@ -11169,7 +11459,7 @@ function int(name, raw2) {
11169
11459
 
11170
11460
  // src/server/pipeline.ts
11171
11461
  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";
11462
+ import { dirname as dirname5, isAbsolute, join as join13, relative, resolve as resolve6 } from "node:path";
11173
11463
  var AUDIO_DIR = "audio";
11174
11464
  var NARRATION_FILE = "narration.json";
11175
11465
  var MAX_REMOTE_FIGURES = 40;
@@ -11192,10 +11482,10 @@ function stagesFor(options) {
11192
11482
  async function runPipeline(job, input) {
11193
11483
  const { options } = input;
11194
11484
  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")
11485
+ upload: join13(job.dir, "upload"),
11486
+ src: join13(job.dir, "src"),
11487
+ audio: join13(job.dir, AUDIO_DIR),
11488
+ deck: join13(job.dir, "deck")
11199
11489
  };
11200
11490
  const warnings = [...options.warnings];
11201
11491
  const url = (rel) => `/d/${job.id}/${rel}`;
@@ -11219,21 +11509,21 @@ async function runPipeline(job, input) {
11219
11509
  theme: options.stated.theme ? prefs.theme : planned.theme
11220
11510
  };
11221
11511
  assertRefsResolve(storyboard, source, { pending: options.images ? "allow" : "refuse" });
11222
- await writeJson(join12(job.dir, "storyboard.json"), storyboard);
11512
+ await writeJson(join13(job.dir, "storyboard.json"), storyboard);
11223
11513
  job.done("plan", `${storyboard.beats.length} beats`);
11224
11514
  for (const f of scanBeatCount(storyboard, prefs)) warnings.push(f.message);
11225
11515
  if (options.images) {
11226
11516
  job.begin("illustrate");
11227
11517
  const drawn = await illustrate(storyboard, source, {
11228
11518
  prefs,
11229
- assetsDir: join12(dirs.src, "assets"),
11519
+ assetsDir: join13(dirs.src, "assets"),
11230
11520
  ...input.imageChain ? { chain: input.imageChain } : {},
11231
11521
  onStep: (line2) => job.log(line2)
11232
11522
  });
11233
11523
  storyboard = drawn.storyboard;
11234
11524
  source = drawn.source;
11235
- await writeJson(join12(dirs.src, "source.json"), source);
11236
- await writeJson(join12(job.dir, "storyboard.json"), storyboard);
11525
+ await writeJson(join13(dirs.src, "source.json"), source);
11526
+ await writeJson(join13(job.dir, "storyboard.json"), storyboard);
11237
11527
  for (const p of drawn.illustrated) {
11238
11528
  job.log(
11239
11529
  `illustrate: ${p.beatId} \u2192 assets/${p.src} via ${p.provider}${p.cached ? " (cached)" : ""}`
@@ -11260,7 +11550,7 @@ async function runPipeline(job, input) {
11260
11550
  dir: dirs.audio,
11261
11551
  format: options.format
11262
11552
  });
11263
- await writeJson(join12(dirs.audio, NARRATION_FILE), spoken);
11553
+ await writeJson(join13(dirs.audio, NARRATION_FILE), spoken);
11264
11554
  narration = { voice: spoken.voice, dir: AUDIO_DIR, beats: spoken.beats };
11265
11555
  const segments2 = Object.values(spoken.beats).flat();
11266
11556
  const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
@@ -11286,6 +11576,13 @@ async function runPipeline(job, input) {
11286
11576
  onBeatError: (id2, err) => {
11287
11577
  warnings.push(`slide ${id2} was left out: ${err.message}`);
11288
11578
  job.log(`build: dropped ${id2} \u2014 ${err.message}`);
11579
+ },
11580
+ // A beat that IS in the deck, drawn with one of its parts dropped so it
11581
+ // would fit. The slide looks finished, so the job's warnings are the only
11582
+ // place the person who asked for it finds out otherwise.
11583
+ onBeatWarning: (id2, warning) => {
11584
+ warnings.push(`slide ${id2} was drawn short: ${warning}`);
11585
+ job.log(`build: kept ${id2} \u2014 ${warning}`);
11289
11586
  }
11290
11587
  });
11291
11588
  for (const d of built.cut.dropped) warnings.push(`cut ${d.beat.id} \u2014 ${d.reason}`);
@@ -11307,7 +11604,7 @@ async function runPipeline(job, input) {
11307
11604
  job.log("render: capturing frames \u2014 two minutes for a four-minute video");
11308
11605
  const out = await render({
11309
11606
  deck: dirs.deck,
11310
- out: join12(dirs.deck, "video.mp4"),
11607
+ out: join13(dirs.deck, "video.mp4"),
11311
11608
  subtitles: "sidecar",
11312
11609
  log: (line2) => job.log(line2),
11313
11610
  // The last of the three length levers, and the only one that acts on a
@@ -11357,7 +11654,7 @@ async function runPipeline(job, input) {
11357
11654
  }
11358
11655
  async function ingest(job, input, dirs, warnings) {
11359
11656
  await mkdir10(dirs.upload, { recursive: true });
11360
- const root2 = resolve5(dirs.upload);
11657
+ const root2 = resolve6(dirs.upload);
11361
11658
  let docPath;
11362
11659
  const upload = input.upload;
11363
11660
  const bothOrNeither = new UploadError(
@@ -11367,12 +11664,12 @@ async function ingest(job, input, dirs, warnings) {
11367
11664
  );
11368
11665
  if (input.url !== void 0) {
11369
11666
  if (upload !== void 0) throw bothOrNeither;
11370
- const harvested = await harvestBounded(input.url, join12(root2, "assets"), {
11667
+ const harvested = await harvestBounded(input.url, join13(root2, "assets"), {
11371
11668
  ...HARVEST_LIMITS,
11372
11669
  ...input.harvest
11373
11670
  });
11374
11671
  warnings.push(...harvested.warnings);
11375
- docPath = join12(root2, "document.md");
11672
+ docPath = join13(root2, "document.md");
11376
11673
  await writeFile12(docPath, harvested.markdown);
11377
11674
  job.log(
11378
11675
  `ingest: harvested ${input.url} \u2014 "${harvested.title}", ${harvested.assets.length} asset(s)`
@@ -11389,13 +11686,13 @@ async function ingest(job, input, dirs, warnings) {
11389
11686
  const { files, warnings: zipWarnings } = readZip(upload.bytes);
11390
11687
  warnings.push(...zipWarnings);
11391
11688
  for (const [rel, bytes] of Object.entries(files)) {
11392
- const to = resolve5(join12(root2, rel));
11393
- if (!insideRoot(root2, to))
11689
+ const to = resolve6(join13(root2, rel));
11690
+ if (!insideRoot2(root2, to))
11394
11691
  throw new UploadError(`Refusing to write ${rel}.`, "Re-zip from inside the folder.");
11395
- await mkdir10(dirname4(to), { recursive: true });
11692
+ await mkdir10(dirname5(to), { recursive: true });
11396
11693
  await writeFile12(to, bytes);
11397
11694
  }
11398
- docPath = join12(root2, pickMarkdown(files));
11695
+ docPath = join13(root2, pickMarkdown(files));
11399
11696
  job.log(
11400
11697
  `ingest: unpacked ${Object.keys(files).length} file(s), reading ${pickMarkdown(files)}`
11401
11698
  );
@@ -11407,7 +11704,7 @@ async function ingest(job, input, dirs, warnings) {
11407
11704
  `DeckSmith reads ${MARKDOWN_EXTS.join(", ")} or a .zip containing one. Export the document to markdown first.`
11408
11705
  );
11409
11706
  }
11410
- docPath = join12(root2, "document.md");
11707
+ docPath = join13(root2, "document.md");
11411
11708
  await writeFile12(docPath, upload.bytes);
11412
11709
  }
11413
11710
  const text2 = await readFile12(docPath, "utf8");
@@ -11430,8 +11727,8 @@ async function ingest(job, input, dirs, warnings) {
11430
11727
  `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations, ${parsed.tables.length} tables`
11431
11728
  );
11432
11729
  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);
11730
+ const source = await fetchFigures(guarded, join13(resolve6(dirs.src), "assets"), warnings);
11731
+ await writeJson(join13(dirs.src, "source.json"), source);
11435
11732
  return source;
11436
11733
  }
11437
11734
  async function harvestBounded(url, dir, opts) {
@@ -11507,8 +11804,8 @@ async function guardFigures(source, root2, allowRemote, warnings) {
11507
11804
  warnings.push(`figure ${figure.id} was left out: "${scheme}:" figures are not read`);
11508
11805
  continue;
11509
11806
  }
11510
- const abs = isAbsolute(src) ? resolve5(src) : resolve5(join12(root2, src.replace(/^\.?\//, "")));
11511
- if (!insideRoot(root2, abs)) {
11807
+ const abs = isAbsolute(src) ? resolve6(src) : resolve6(join13(root2, src.replace(/^\.?\//, "")));
11808
+ if (!insideRoot2(root2, abs)) {
11512
11809
  warnings.push(`figure ${figure.id} was left out: "${src}" points outside the upload`);
11513
11810
  continue;
11514
11811
  }
@@ -11522,10 +11819,10 @@ async function guardFigures(source, root2, allowRemote, warnings) {
11522
11819
  }
11523
11820
  async function pack2(job, ctx) {
11524
11821
  try {
11525
- const assets = join12(resolve5(ctx.dirs.src), "assets");
11822
+ const assets = join13(resolve6(ctx.dirs.src), "assets");
11526
11823
  const requests = ctx.source.figures.map((f) => ({
11527
11824
  id: f.id,
11528
- url: join12(assets, f.src),
11825
+ url: join13(assets, f.src),
11529
11826
  prefer: "bake"
11530
11827
  }));
11531
11828
  const plan = await planMedia(requests, async (url) => ({
@@ -11534,7 +11831,7 @@ async function pack2(job, ctx) {
11534
11831
  const files = { ...plan.files };
11535
11832
  if (ctx.narration) {
11536
11833
  for (const name of audioNames(ctx.narration)) {
11537
- files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile12(join12(ctx.dirs.audio, name)));
11834
+ files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile12(join13(ctx.dirs.audio, name)));
11538
11835
  }
11539
11836
  }
11540
11837
  const container = {
@@ -11551,10 +11848,10 @@ async function pack2(job, ctx) {
11551
11848
  },
11552
11849
  source: ctx.source,
11553
11850
  storyboard: ctx.storyboard,
11554
- ...ctx.narration ? { narration: await readJson(join12(ctx.dirs.audio, NARRATION_FILE)) } : {},
11851
+ ...ctx.narration ? { narration: await readJson(join13(ctx.dirs.audio, NARRATION_FILE)) } : {},
11555
11852
  media: plan.media
11556
11853
  };
11557
- const bytes = await writePack(container, files, join12(ctx.dirs.deck, "deck.deck"));
11854
+ const bytes = await writePack(container, files, join13(ctx.dirs.deck, "deck.deck"));
11558
11855
  job.log(`pack: ${Math.round(bytes / 1024)} KB \u2192 deck.deck`);
11559
11856
  return ctx.url("deck.deck");
11560
11857
  } catch (err) {
@@ -11577,7 +11874,7 @@ function host(src) {
11577
11874
  }
11578
11875
  }
11579
11876
  async function writeJson(path2, value) {
11580
- await mkdir10(dirname4(path2), { recursive: true });
11877
+ await mkdir10(dirname5(path2), { recursive: true });
11581
11878
  await writeFile12(path2, `${JSON.stringify(value, null, 2)}
11582
11879
  `);
11583
11880
  }
@@ -11989,13 +12286,13 @@ var PAGE_BUDGET = {
11989
12286
  };
11990
12287
  async function readPage(url, work2) {
11991
12288
  await mkdir11(work2, { recursive: true });
11992
- const dir = await mkdtemp3(join13(work2, "harvest-"));
12289
+ const dir = await mkdtemp3(join14(work2, "harvest-"));
11993
12290
  try {
11994
12291
  const page = await harvest(url, dir, PAGE_BUDGET);
11995
12292
  const files = {
11996
12293
  "document.md": new TextEncoder().encode(page.markdown)
11997
12294
  };
11998
- for (const asset of page.assets) files[basename4(asset)] = await readFile13(asset);
12295
+ for (const asset of page.assets) files[basename5(asset)] = await readFile13(asset);
11999
12296
  const warnings = [...page.warnings];
12000
12297
  if (page.clips.length > 0) {
12001
12298
  warnings.push(
@@ -12029,16 +12326,16 @@ var statusSchema = z5.object({
12029
12326
  var DEFAULT_WAIT = 45;
12030
12327
  function deckTools(opts) {
12031
12328
  const queue = new Queue({ maxQueued: 8 });
12032
- const root2 = resolve6(opts.root);
12033
- let cached;
12329
+ const root2 = resolve7(opts.root);
12330
+ let cached2;
12034
12331
  const found = async () => {
12035
- cached ??= await (opts.probe ?? prereqs)();
12036
- return cached;
12332
+ cached2 ??= await (opts.probe ?? prereqs)();
12333
+ return cached2;
12037
12334
  };
12038
- const insideRoot2 = (p) => {
12335
+ const insideRoot3 = (p) => {
12039
12336
  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)) {
12337
+ const full = resolve7(p);
12338
+ if (full !== root2 && !full.startsWith(root2 + sep3)) {
12042
12339
  throw new Error(
12043
12340
  `document_path is outside the server's root. It may only read files under ${root2}.`
12044
12341
  );
@@ -12099,7 +12396,7 @@ function deckTools(opts) {
12099
12396
  }
12100
12397
  const settings = input.settings ?? {};
12101
12398
  const options = parseOptions(fieldsFor(settings));
12102
- const file = input.document_path ? insideRoot2(input.document_path) : void 0;
12399
+ const file = input.document_path ? insideRoot3(input.document_path) : void 0;
12103
12400
  const missing = missingFor(await found(), options);
12104
12401
  if (missing.length) {
12105
12402
  throw new Error(
@@ -12115,7 +12412,7 @@ function deckTools(opts) {
12115
12412
  }
12116
12413
  }
12117
12414
  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";
12415
+ const filename = page ? "page.zip" : input.document_path ? input.document_path.split(sep3).pop() : "document.md";
12119
12416
  const bytes = page ? page.zip : file ? await readFile13(file) : new TextEncoder().encode(input.document_text);
12120
12417
  if (bytes.byteLength > MAX_UPLOAD_BYTES) {
12121
12418
  throw new Error(
@@ -12123,7 +12420,7 @@ function deckTools(opts) {
12123
12420
  );
12124
12421
  }
12125
12422
  const id2 = randomBytes(16).toString("base64url");
12126
- const dir = join13(opts.work, id2);
12423
+ const dir = join14(opts.work, id2);
12127
12424
  await mkdir11(dir, { recursive: true });
12128
12425
  queue.submit({
12129
12426
  id: id2,
@@ -12151,7 +12448,7 @@ function deckTools(opts) {
12151
12448
  };
12152
12449
  }
12153
12450
  function dirOf(work2, id2) {
12154
- return join13(work2, id2);
12451
+ return join14(work2, id2);
12155
12452
  }
12156
12453
  function waitFor(queue, id2, seconds) {
12157
12454
  const now = queue.view(id2);
@@ -12183,9 +12480,9 @@ function report(view, dir) {
12183
12480
  // (src/server/pipeline.ts). This said `src/storyboard.json` for one run, which
12184
12481
  // is the shape of the CLI's output directory and not the server's, and an
12185
12482
  // agent told to read it would have found nothing there.
12186
- storyboard_path: join13(dir, "storyboard.json"),
12483
+ storyboard_path: join14(dir, "storyboard.json"),
12187
12484
  ...done && view.result ? {
12188
- deck_path: join13(dir, "deck"),
12485
+ deck_path: join14(dir, "deck"),
12189
12486
  slides: view.result.slides,
12190
12487
  duration_seconds: view.result.duration,
12191
12488
  warnings: view.result.warnings
@@ -12194,7 +12491,7 @@ function report(view, dir) {
12194
12491
  };
12195
12492
  }
12196
12493
  function defaultWork() {
12197
- return join13(tmpdir3(), "decksmith-mcp");
12494
+ return join14(tmpdir4(), "decksmith-mcp");
12198
12495
  }
12199
12496
 
12200
12497
  // src/mcp/main.ts
@@ -12204,6 +12501,7 @@ for (const level of ["log", "info", "debug", "warn", "trace"]) {
12204
12501
  `);
12205
12502
  };
12206
12503
  }
12504
+ guardTmpdir();
12207
12505
  var env = process.env;
12208
12506
  var root = env.DECKSMITH_MCP_ROOT ?? homedir3();
12209
12507
  var work = env.DECKSMITH_MCP_WORK ?? defaultWork();