@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/cli.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { cp, mkdir as mkdir10, mkdtemp as mkdtemp4, readdir as readdir4, readFile as readFile17, rm as rm9, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
5
- import { createRequire as createRequire3 } from "node:module";
6
- import { tmpdir as tmpdir4 } from "node:os";
7
- import { dirname as dirname4, join as join16, relative as relative2, resolve as resolve5 } from "node:path";
5
+ import { createRequire as createRequire4 } from "node:module";
6
+ import { tmpdir as tmpdir5 } from "node:os";
7
+ import { dirname as dirname5, join as join17, relative as relative2, resolve as resolve6 } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { Command } from "commander";
10
10
 
@@ -418,15 +418,53 @@ var dataTableParamsSchema = z.object({
418
418
  path: ["highlight"],
419
419
  message: "every highlight must name a row that params.rows draws"
420
420
  });
421
+ var chartPointSchema = z.object({ x: z.string(), y: z.number() });
421
422
  var lineChartParamsSchema = z.object({
422
423
  eyebrow: z.string().optional(),
423
424
  headline: z.string(),
424
425
  xLabel: z.string(),
425
426
  yLabel: z.string(),
426
- points: z.array(z.object({ x: z.string(), y: z.number() })).min(2),
427
+ points: z.array(chartPointSchema).min(2),
427
428
  /** Inter-point annotations, e.g. per-step deltas. One fewer than `points`. */
428
429
  deltas: z.array(z.string()).optional(),
429
- readout: z.string().optional()
430
+ readout: z.string().optional(),
431
+ /**
432
+ * THE SAME MEASUREMENT UNDER A SECOND CONDITION — a baseline the main series
433
+ * is to be read against, where the point is the change in the SHAPE of the
434
+ * curve rather than two numbers.
435
+ *
436
+ * Drawn first and alone; then the curve lifts off it and reshapes into
437
+ * `points`, leaving this one behind as a ghost. `label` names the ghost and
438
+ * is drawn wherever the chart has room for it.
439
+ *
440
+ * KEEP IT SHORT — two or three words. A label wider than the plot is refused
441
+ * outright, and that refusal reaches `onBeatError` and costs the whole beat.
442
+ * One that fits but cannot be placed clear of the axis names, the tick and
443
+ * category labels, the values, the deltas and both curves is DROPPED
444
+ * instead, silently: an unnamed ghost is still legibly the fainter, earlier
445
+ * curve, where a name printed through a number is a defect in both of them.
446
+ * Nothing warns about that one — see the placement note in
447
+ * `src/emit/archetypes/line-chart.ts`.
448
+ */
449
+ compare: z.object({ label: z.string(), points: z.array(chartPointSchema).min(2) }).optional()
450
+ }).superRefine((p, ctx) => {
451
+ if (!p.compare) return;
452
+ if (p.compare.points.length !== p.points.length) {
453
+ ctx.addIssue({
454
+ code: z.ZodIssueCode.custom,
455
+ path: ["compare", "points"],
456
+ message: `compare has ${p.compare.points.length} points against ${p.points.length}; a comparison is over the same x values`
457
+ });
458
+ return;
459
+ }
460
+ const off = p.compare.points.findIndex((c, i) => c.x !== p.points[i]?.x);
461
+ if (off >= 0) {
462
+ ctx.addIssue({
463
+ code: z.ZodIssueCode.custom,
464
+ path: ["compare", "points", off, "x"],
465
+ message: `compare point ${off} is "${p.compare.points[off]?.x}" where points has "${p.points[off]?.x}"`
466
+ });
467
+ }
430
468
  });
431
469
  var calloutParamsSchema = z.object({
432
470
  eyebrow: z.string().optional(),
@@ -1206,8 +1244,8 @@ async function bundleFont(lang, glyphs2, dir) {
1206
1244
  ${text2}`).digest("hex").slice(0, 16)} */`;
1207
1245
  await mkdir(dir, { recursive: true });
1208
1246
  const cssPath = join(dir, "fonts.css");
1209
- const cached = await readFile2(cssPath, "utf8").catch(() => "");
1210
- if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
1247
+ const cached2 = await readFile2(cssPath, "utf8").catch(() => "");
1248
+ if (cached2.startsWith(stamp)) return { family, css: cached2, files: localNames(cached2) };
1211
1249
  const res = await fetch(
1212
1250
  `https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
1213
1251
  { headers: { "User-Agent": UA } }
@@ -1420,6 +1458,21 @@ function nv(v) {
1420
1458
  }
1421
1459
  var DRAW_FROM = { drawSVG: "0%" };
1422
1460
  var DRAW_TO = { drawSVG: "100%" };
1461
+ var SHAPE_INDEX = 0;
1462
+ function reshape(target, to, at, seconds, first) {
1463
+ if (!(seconds > 0)) throw new Error(`reshape ${target}: ${seconds}s is no time to reshape in`);
1464
+ return fromTo(
1465
+ target,
1466
+ { morphSVG: { shape: target, shapeIndex: SHAPE_INDEX } },
1467
+ {
1468
+ morphSVG: { shape: to, shapeIndex: SHAPE_INDEX },
1469
+ duration: sec(seconds),
1470
+ ease: "power2.inOut",
1471
+ ...first ? {} : { immediateRender: false }
1472
+ },
1473
+ sec(at)
1474
+ );
1475
+ }
1423
1476
  function travel(target, route, at, seconds) {
1424
1477
  if (!(seconds > 0)) throw new Error(`travel ${target}: ${seconds}s is no time to travel in`);
1425
1478
  const legs = [];
@@ -4101,6 +4154,12 @@ var CHART_GAP = 60;
4101
4154
  var READOUT_W = 460;
4102
4155
  var READOUT_LH = 1.35;
4103
4156
  var TALL_ASPECT = 1.15;
4157
+ var RESHAPE_SECONDS = 1.2;
4158
+ var GHOST_OPACITY = 0.28;
4159
+ var DRAW_SECONDS = 1.8;
4160
+ var DRAW_FLOOR = 1;
4161
+ var SEPARATE = 0.5;
4162
+ var STEP_FLOOR = 0.15;
4104
4163
  function chartScale(values) {
4105
4164
  const lo = Math.min(...values);
4106
4165
  const hi = Math.max(...values);
@@ -4131,7 +4190,8 @@ var lineChart = (beat, ctx) => {
4131
4190
  const { sid, theme } = ctx;
4132
4191
  const p = beat.params;
4133
4192
  const face = faceOf(theme.fontStack);
4134
- const scale = chartScale(p.points.map((pt) => pt.y));
4193
+ const cmp = p.compare;
4194
+ const scale = chartScale([...p.points, ...cmp?.points ?? []].map((pt) => pt.y));
4135
4195
  const box = contentW(ctx.format);
4136
4196
  const tall = isPortrait(ctx.format);
4137
4197
  const width = tall ? box : box - (p.readout ? CHART_GAP + READOUT_W : 0);
@@ -4174,11 +4234,15 @@ var lineChart = (beat, ctx) => {
4174
4234
  const LABEL_SIZE3 = 40;
4175
4235
  const runW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
4176
4236
  const catW = (s) => textWidth(s, LABEL_SIZE3, 400, 0, false, face);
4237
+ const nameW = (s) => textWidth(s, LABEL_SIZE3, 500, 0, false, face);
4238
+ const ghostW = (s) => textWidth(s, LABEL_SIZE3, 600, 0, false, face);
4177
4239
  const shownX = fitIndices((i) => catW(p.points[i]?.x ?? ""), false);
4178
4240
  const xLabels = p.points.map(
4179
4241
  (pt, i) => shownX.has(i) ? `<text x="${n2(x(i))}" y="${H - PAD2.b + 56}">${esc(pt.x)}</text>` : ""
4180
4242
  ).join("");
4181
- const path2 = p.points.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
4243
+ const pathOf = (pts) => pts.map((pt, i) => `${i === 0 ? "M" : "L"}${n2(x(i))},${n2(y(pt.y))}`).join(" ");
4244
+ const path2 = pathOf(p.points);
4245
+ const basePath = cmp ? pathOf(cmp.points) : "";
4182
4246
  const dots = p.points.map(
4183
4247
  (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}" />`
4184
4248
  ).join("");
@@ -4214,6 +4278,75 @@ var lineChart = (beat, ctx) => {
4214
4278
  }).join("");
4215
4279
  const first = { x: x(0), y: y(p.points[0]?.y ?? 0) };
4216
4280
  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" />` : "";
4281
+ const lineTag = (part, d, extra = "") => `<path class="chartline" id="${sid}-${part}" d="${d}" fill="none" stroke="${theme.accent}"${extra} />`;
4282
+ const chartlines = cmp ? [
4283
+ lineTag("base", basePath),
4284
+ lineTag("line", basePath, ' opacity="0"'),
4285
+ `<path id="${sid}-target" d="${path2}" fill="none" stroke="none" />`
4286
+ ].join("\n ") : lineTag("line", path2);
4287
+ const curveBand = (pts, x0, x1) => {
4288
+ const ys = [];
4289
+ const py = (i) => y(pts[i]?.y ?? 0);
4290
+ for (let i = 0; i < pts.length; i++) {
4291
+ if (x(i) >= x0 && x(i) <= x1) ys.push(py(i));
4292
+ }
4293
+ for (let i = 0; i + 1 < pts.length; i++) {
4294
+ const [ax, bx] = [x(i), x(i + 1)];
4295
+ for (const edge of [x0, x1]) {
4296
+ if (edge > Math.min(ax, bx) && edge < Math.max(ax, bx)) {
4297
+ ys.push(py(i) + (py(i + 1) - py(i)) * (edge - ax) / (bx - ax));
4298
+ }
4299
+ }
4300
+ }
4301
+ return ys.length > 0 ? [Math.min(...ys), Math.max(...ys)] : [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
4302
+ };
4303
+ const fixedBoxes = [
4304
+ // `.axname` is the one run here set at weight 500 rather than 400, and
4305
+ // under-charging a width is the unrecoverable direction (see `padR`).
4306
+ // No `text-anchor` on the y half, so it runs rightwards from x=0.
4307
+ { x: 0, y: 42 - LABEL_SIZE3, w: nameW(p.yLabel), h: LABEL_SIZE3 },
4308
+ {
4309
+ x: PAD2.l + plotW / 2 - nameW(p.xLabel) / 2,
4310
+ y: H - 16 - LABEL_SIZE3,
4311
+ w: nameW(p.xLabel),
4312
+ h: LABEL_SIZE3
4313
+ },
4314
+ ...ticks.map((v) => {
4315
+ const w = runW(v.toFixed(scale.decimals));
4316
+ return { x: PAD2.l - 22 - w, y: y(v) + 13 - LABEL_SIZE3, w, h: LABEL_SIZE3 };
4317
+ }),
4318
+ ...p.points.map((pt, i) => ({ pt, i })).filter(({ i }) => shownX.has(i)).map(({ pt, i }) => labelBox(x(i), H - PAD2.b + 56, pt.x))
4319
+ ];
4320
+ const lastI = p.points.length - 1;
4321
+ const ghost = (() => {
4322
+ if (!cmp) return void 0;
4323
+ const w = ghostW(cmp.label);
4324
+ if (w > plotW) {
4325
+ throw new Error(
4326
+ `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.`
4327
+ );
4328
+ }
4329
+ const atEnd = (i, start) => {
4330
+ const cy = y(cmp.points[i]?.y ?? 0);
4331
+ const away = Math.max(44, cy - 26);
4332
+ const toward = Math.min(cy + 52, H - PAD2.b + 4);
4333
+ const sides = cy < y(p.points[i]?.y ?? 0) ? [away, toward] : [toward, away];
4334
+ return sides.map((by) => ({ i, start, by }));
4335
+ };
4336
+ const drawnDeltas = deltasFit ? deltaBoxes : [];
4337
+ return [...atEnd(lastI, false), ...atEnd(0, true)].find((c) => {
4338
+ const box2 = { x: c.start ? x(c.i) : x(c.i) - w, y: c.by - LABEL_SIZE3, w, h: LABEL_SIZE3 };
4339
+ if (fixedBoxes.some((f) => overlaps(box2, f))) return false;
4340
+ if (valueBoxes.some((v) => overlaps(box2, v))) return false;
4341
+ if (drawnDeltas.some((d) => d !== null && overlaps(box2, d))) return false;
4342
+ return ![p.points, cmp.points].some((series) => {
4343
+ const [top, bottom] = curveBand(series, box2.x, box2.x + box2.w);
4344
+ return Math.min(box2.y + box2.h, bottom) - Math.max(box2.y, top) > 8;
4345
+ });
4346
+ });
4347
+ })();
4348
+ const ghostLabel = cmp && ghost ? `
4349
+ <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>` : "";
4217
4350
  const readout = p.readout ? `
4218
4351
  <div class="readout" id="${sid}-read">${esc(p.readout)}</div>` : "";
4219
4352
  const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
@@ -4226,7 +4359,7 @@ var lineChart = (beat, ctx) => {
4226
4359
  the svg and the layout gate reports container_overflow. -->
4227
4360
  <text class="axname" x="0" y="42">${esc(p.yLabel)}</text>
4228
4361
  <text class="axname" x="${n2(PAD2.l + plotW / 2)}" y="${H - 16}" text-anchor="middle">${esc(p.xLabel)}</text>
4229
- <path class="chartline" id="${sid}-line" d="${path2}" fill="none" stroke="${theme.accent}" />
4362
+ ${chartlines}${ghostLabel}
4230
4363
  <g>${dots}</g>
4231
4364
  ${ring}
4232
4365
  <g class="ptlab" text-anchor="middle">${values}</g>
@@ -4234,10 +4367,52 @@ var lineChart = (beat, ctx) => {
4234
4367
  </svg>${readout}
4235
4368
  </div>`;
4236
4369
  const draw2 = 0.8;
4237
- const step2 = Math.min(0.45, 1.8 / p.points.length);
4370
+ const count = p.points.length;
4371
+ const idealStep = Math.min(0.45, DRAW_SECONDS / count);
4372
+ const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
4373
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
4374
+ const tailAfter = (s) => Math.max(
4375
+ s * count + 0.4,
4376
+ // the ring: its walk, then the 0.4s it takes to leave
4377
+ 0.3 + s * (count - 1),
4378
+ // the dots
4379
+ 0.5 + s * (count - 1),
4380
+ ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
4381
+ );
4382
+ const room = beat.seconds - spine;
4383
+ let drawCents = Math.round(DRAW_SECONDS * 100);
4384
+ let stepCents = Math.floor(idealStep * 100);
4385
+ if (cmp) {
4386
+ while (drawCents / 100 + tailAfter(stepCents / 100) > room) {
4387
+ if (drawCents > Math.round(DRAW_FLOOR * 100)) drawCents--;
4388
+ else if (stepCents > Math.round(STEP_FLOOR * 100)) stepCents--;
4389
+ else break;
4390
+ }
4391
+ }
4392
+ const drawFor = cmp ? drawCents / 100 : DRAW_SECONDS;
4393
+ const step2 = cmp ? stepCents / 100 : idealStep;
4394
+ const lift2 = sec(draw2 + drawFor);
4395
+ const settled = cmp ? sec(lift2 + SEPARATE + RESHAPE_SECONDS) : draw2;
4238
4396
  const tl = [
4239
4397
  ...chromeIn(sid, p.eyebrow !== void 0),
4240
- tween(`#${sid}-line`, DRAW_FROM, { ...DRAW_TO, duration: 1.8, ease: "none" }, draw2),
4398
+ tween(
4399
+ `#${sid}-${cmp ? "base" : "line"}`,
4400
+ DRAW_FROM,
4401
+ { ...DRAW_TO, duration: drawFor, ease: "none" },
4402
+ draw2
4403
+ )
4404
+ ];
4405
+ if (cmp) {
4406
+ tl.push(
4407
+ ...ghost ? [tween(`#${sid}-ghostlab`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, draw2 + 0.4)] : [],
4408
+ // The copy lifts off — same geometry, so the half-second reads as one line
4409
+ // separating from itself rather than as a second line arriving.
4410
+ tween(`#${sid}-line`, { opacity: 0 }, { opacity: 1, duration: SEPARATE }, lift2),
4411
+ tween(`#${sid}-base`, { opacity: 1 }, { opacity: GHOST_OPACITY, duration: SEPARATE }, lift2),
4412
+ reshape(`#${sid}-line`, `#${sid}-target`, lift2 + SEPARATE, RESHAPE_SECONDS, true)
4413
+ );
4414
+ }
4415
+ tl.push(
4241
4416
  tween(
4242
4417
  `#${sid} .dot`,
4243
4418
  // Origin in both halves, or GSAP's smoothOrigin compensates the change with
@@ -4245,37 +4420,43 @@ var lineChart = (beat, ctx) => {
4245
4420
  // polyline they mark, inside the frame and so invisible to every gate.
4246
4421
  { opacity: 0, scale: 0, transformOrigin: "center" },
4247
4422
  { opacity: 1, scale: 1, transformOrigin: "center", duration: 0.3, stagger: step2 },
4248
- draw2
4423
+ settled
4249
4424
  ),
4250
- tween(`#${sid} .pv`, { opacity: 0 }, { opacity: 1, duration: 0.3, stagger: step2 }, draw2 + 0.2)
4251
- ];
4425
+ tween(
4426
+ `#${sid} .pv`,
4427
+ { opacity: 0 },
4428
+ { opacity: 1, duration: 0.3, stagger: step2 },
4429
+ settled + 0.2
4430
+ )
4431
+ );
4252
4432
  if (deltas) {
4253
4433
  tl.push(
4254
4434
  tween(
4255
4435
  `#${sid} .dv`,
4256
4436
  { opacity: 0, y: -10 },
4257
4437
  { opacity: 1, y: 0, duration: 0.35, stagger: step2 },
4258
- draw2 + 0.6
4438
+ settled + 0.6
4259
4439
  )
4260
4440
  );
4261
4441
  }
4262
- if (p.points.length > 1) {
4442
+ const walk = cmp ? sec(step2 * count) : DRAW_SECONDS;
4443
+ if (count > 1) {
4263
4444
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
4264
4445
  tl.push(
4265
- tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, draw2),
4266
- ...travel(`#${sid}-ring`, route, draw2, 1.8),
4446
+ tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
4447
+ ...travel(`#${sid}-ring`, route, settled, walk),
4267
4448
  // And it leaves once the curve is whole: a marker parked on the last
4268
4449
  // point for the rest of the beat reads as a defect, not as emphasis.
4269
4450
  tween(
4270
4451
  `#${sid}-ring`,
4271
4452
  { opacity: 1 },
4272
4453
  { opacity: 0, duration: 0.4, immediateRender: false },
4273
- draw2 + 1.8
4454
+ settled + walk
4274
4455
  )
4275
4456
  );
4276
4457
  }
4277
- const drawn = draw2 + step2 * p.points.length + 0.4;
4278
- const holds = [drawn];
4458
+ const drawn = settled + (cmp ? tailAfter(step2) : step2 * count + 0.4);
4459
+ const holds = cmp ? [lift2, drawn] : [drawn];
4279
4460
  if (p.readout) {
4280
4461
  tl.push(
4281
4462
  // It enters from wherever it sits: from the right when it is beside the
@@ -4284,9 +4465,22 @@ var lineChart = (beat, ctx) => {
4284
4465
  );
4285
4466
  holds.push(drawn + 0.8);
4286
4467
  }
4468
+ const end = holds[holds.length - 1] ?? 0;
4469
+ const need = sec(end + 0.15);
4470
+ if (cmp && need > beat.seconds + 1e-9) {
4471
+ return {
4472
+ ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
4473
+ warnings: [
4474
+ `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.`
4475
+ ]
4476
+ };
4477
+ }
4287
4478
  return {
4288
4479
  html,
4289
4480
  tl,
4481
+ // Named only when a beat actually reshapes, which is what keeps MorphSVG's
4482
+ // 21,195 bytes off every deck that does not. See `PLUGINS` in composition.ts.
4483
+ ...cmp ? { plugins: ["morphSVG"] } : {},
4290
4484
  holds: holdsWithin(holds, beat.seconds),
4291
4485
  css: [
4292
4486
  chromeCss(theme),
@@ -4311,6 +4505,14 @@ var lineChart = (beat, ctx) => {
4311
4505
  `.axname{font-size:40px;fill:${theme.muted};font-weight:500}`,
4312
4506
  `.ptlab{font-size:40px;fill:${theme.fg};font-weight:600}`,
4313
4507
  `.delta{font-size:40px;fill:${theme.tones.b};font-weight:600}`,
4508
+ // Only when a ghost was actually named — not merely when there is a ghost,
4509
+ // since the label is dropped where neither side of the baseline is clear.
4510
+ // An unconditional rule would move the stylesheet bytes of every line chart
4511
+ // ever built for a part they do not draw, which is the whole thing
4512
+ // `Scene.plugins` is careful about one level up. `muted`, not `dim`: it
4513
+ // names a series, so it is read, and the curve it names is the thing that
4514
+ // has been faded, not its label.
4515
+ ...ghost ? [`.ghostlab{font-size:40px;fill:${theme.muted};font-weight:600}`] : [],
4314
4516
  // 1.7 set the two lines of a wrapped readout 68px apart, which reads as two
4315
4517
  // unrelated fragments rather than one sentence. 1.35 keeps it a paragraph.
4316
4518
  `.readout{font-size:${BODY_SIZE}px;line-height:${READOUT_LH};color:${theme.muted};max-width:${READOUT_W}px}`,
@@ -5556,7 +5758,10 @@ function round4(n3) {
5556
5758
  // src/emit/composition.ts
5557
5759
  var GSAP_SRC = "./vendor/gsap.min.js";
5558
5760
  var DRAWSVG_SRC = "./vendor/DrawSVGPlugin.min.js";
5559
- var MORPH_SRC = "./vendor/ds-morph.js";
5761
+ var PLUGINS = {
5762
+ dsMorph: { src: "./vendor/ds-morph.js", global: "DSMorphPlugin" },
5763
+ morphSVG: { src: "./vendor/MorphSVGPlugin.min.js", global: "MorphSVGPlugin" }
5764
+ };
5560
5765
  var KATEX_JS = "./vendor/katex.min.js";
5561
5766
  var KATEX_CSS = "./katex/katex.min.css";
5562
5767
  function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
@@ -5664,7 +5869,15 @@ function layout(storyboard, source, format, opts = {}) {
5664
5869
  }
5665
5870
  if (scene.css) archetypeCss.add(scene.css.trim());
5666
5871
  if (scene.measure?.length) builds = true;
5667
- for (const p of scene.plugins ?? []) plugins.add(p);
5872
+ for (const w of cut2.scene.warnings ?? []) opts.onBeatWarning?.(beat.id, w);
5873
+ for (const p of scene.plugins ?? []) {
5874
+ if (!Object.hasOwn(PLUGINS, p)) {
5875
+ throw new Error(
5876
+ `${beat.archetype} ${beat.id}: no vendored plugin named "${p}" \u2014 Scene.plugins takes ${Object.keys(PLUGINS).join(" or ")}`
5877
+ );
5878
+ }
5879
+ plugins.add(p);
5880
+ }
5668
5881
  scenes.push(
5669
5882
  sceneHtml(
5670
5883
  sid,
@@ -5781,9 +5994,11 @@ function renderComposition(storyboard, format, laid) {
5781
5994
  <link rel="stylesheet" href="${FONT_BUNDLE_HREF}" />` : "";
5782
5995
  const island = format.navigable ? `
5783
5996
  ${emitIsland(slides)}` : "";
5784
- const morph = laid.plugins.has("dsMorph") ? `
5785
- <script src="${MORPH_SRC}"></script>
5786
- <script>gsap.registerPlugin(DSMorphPlugin);</script>` : "";
5997
+ const plugins = Object.entries(PLUGINS).filter(([name]) => laid.plugins.has(name)).map(
5998
+ ([, p]) => `
5999
+ <script src="${p.src}"></script>
6000
+ <script>gsap.registerPlugin(${p.global});</script>`
6001
+ ).join("");
5787
6002
  return `<!doctype html>
5788
6003
  <html lang="${esc(storyboard.lang)}" data-resolution="${orientation}">
5789
6004
  <head>
@@ -5792,7 +6007,7 @@ ${emitIsland(slides)}` : "";
5792
6007
  <meta name="viewport" content="width=${format.width}, height=${format.height}" />
5793
6008
  <script src="${GSAP_SRC}"></script>
5794
6009
  <script src="${DRAWSVG_SRC}"></script>
5795
- <script>gsap.registerPlugin(DrawSVGPlugin);</script>${morph}
6010
+ <script>gsap.registerPlugin(DrawSVGPlugin);</script>${plugins}
5796
6011
  <link rel="stylesheet" href="${KATEX_CSS}" />
5797
6012
  <script src="${KATEX_JS}"></script>${fontLink}${fontFace}
5798
6013
  <style>
@@ -6187,8 +6402,8 @@ async function send(url2, target, headers, signal, opts) {
6187
6402
  ...secure && isIP(host) === 0 ? { servername: host } : {}
6188
6403
  };
6189
6404
  try {
6190
- return await new Promise((resolve6, reject) => {
6191
- const req = (secure ? httpsRequest : httpRequest)(options, resolve6);
6405
+ return await new Promise((resolve7, reject) => {
6406
+ const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
6192
6407
  req.on("error", reject);
6193
6408
  req.end();
6194
6409
  });
@@ -6274,7 +6489,7 @@ function message(err) {
6274
6489
  async function fetchFigures(source, dir, warnings) {
6275
6490
  const drops = warnings ?? [];
6276
6491
  await mkdir2(dir, { recursive: true });
6277
- const cached = await readdir(dir).catch(() => []);
6492
+ const cached2 = await readdir(dir).catch(() => []);
6278
6493
  const figures = [];
6279
6494
  for (const figure of source.figures) {
6280
6495
  if (figure.kind === "clip") {
@@ -6282,7 +6497,7 @@ async function fetchFigures(source, dir, warnings) {
6282
6497
  continue;
6283
6498
  }
6284
6499
  try {
6285
- figures.push(figureSchema.parse(await localize(figure, dir, cached)));
6500
+ figures.push(figureSchema.parse(await localize(figure, dir, cached2)));
6286
6501
  } catch (err) {
6287
6502
  drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
6288
6503
  }
@@ -6296,15 +6511,15 @@ async function fetchFigures(source, dir, warnings) {
6296
6511
  }
6297
6512
  return parsed.data;
6298
6513
  }
6299
- async function localize(figure, dir, cached) {
6514
+ async function localize(figure, dir, cached2) {
6300
6515
  const stem = assetStem(figure.id, figure.src);
6301
- const hit = cached.find((name) => name.startsWith(`${stem}.`));
6516
+ const hit = cached2.find((name) => name.startsWith(`${stem}.`));
6302
6517
  const bytes = hit ? await readFile3(join2(dir, hit)) : await load(figure.src);
6303
6518
  const size3 = imageSize(bytes);
6304
6519
  if (hit) return { ...figure, src: hit, ...size3 };
6305
6520
  const src = `${stem}${assetExt(bytes, figure.src)}`;
6306
6521
  await writeFile2(join2(dir, src), bytes);
6307
- cached.push(src);
6522
+ cached2.push(src);
6308
6523
  return { ...figure, src, ...size3 };
6309
6524
  }
6310
6525
  function assetStem(id2, src) {
@@ -6853,7 +7068,10 @@ var REVEALS = {
6853
7068
  "equation-walk": "one per term",
6854
7069
  "equation-morph": "2",
6855
7070
  "data-table": "one per highlighted row, plus 1",
6856
- "line-chart": "1",
7071
+ // "long enough" is not a hedge: below the emitter's own floor the comparison
7072
+ // is dropped and the chart stops twice becoming a chart that stops once. See
7073
+ // the line-chart entry under THE THIRTEEN ARCHETYPES for what that costs.
7074
+ "line-chart": "1, or 2 when compare is given and the beat is long enough for it",
6857
7075
  callout: "one per panel",
6858
7076
  pipeline: "one per stage",
6859
7077
  "annotated-figure": "one per note, plus 1",
@@ -6976,6 +7194,29 @@ DRAWING ARCHETYPES \u2014 reach here first
6976
7194
  scale, over training. Points come from the source's numbers;
6977
7195
  deltas, if given, are the steps between consecutive points, so
6978
7196
  there is always one fewer.
7197
+ \`compare\` adds a SECOND CONDITION over the same axis \u2014 the tell
7198
+ there is THE SAME QUANTITY MEASURED TWO WAYS, a baseline and the
7199
+ result, where what the source is claiming is the change in the
7200
+ SHAPE of the curve and not two numbers. Its \`label\` names the
7201
+ baseline ("without pretraining", "FP32") and is drawn on the
7202
+ slide where the chart has room for it. Keep it SHORT \u2014 two or
7203
+ three words. A label wider than the plot is refused and the
7204
+ whole slide goes with it, and one that cannot be placed clear of
7205
+ the axis names, the values and both curves is dropped without a
7206
+ word, leaving the baseline unnamed. A \`readout\` takes the plot
7207
+ down to about two thirds of its width, so a beat that has one
7208
+ has room for a shorter label still.
7209
+ \`compare.points\` must carry the SAME x values, in the
7210
+ same order, as \`points\`; the baseline is drawn first, then the
7211
+ curve reshapes into \`points\` and leaves the baseline behind. Do
7212
+ not use it for two unrelated series that happen to share an
7213
+ axis \u2014 nothing reshapes into something it is not a version of.
7214
+ A compare beat also costs TIME \u2014 the baseline draws, is held,
7215
+ then reshapes \u2014 so give it \`seconds\` of 7, or 8 with a
7216
+ \`readout\`. Give it too few and the emitter draws the chart
7217
+ without the comparison rather than stopping on a half-drawn one:
7218
+ the slide is still there, but the point about the baseline is
7219
+ gone.
6979
7220
 
6980
7221
  DESCRIBING ARCHETYPES \u2014 the fallbacks
6981
7222
 
@@ -7760,7 +8001,7 @@ function codexCommand(args) {
7760
8001
  }
7761
8002
  function runCodex(args) {
7762
8003
  const { argv, env } = codexCommand(args);
7763
- return new Promise((resolve6, reject) => {
8004
+ return new Promise((resolve7, reject) => {
7764
8005
  const child = spawn("codex", argv, {
7765
8006
  stdio: ["pipe", "ignore", "pipe"],
7766
8007
  ...env === void 0 ? {} : { env }
@@ -7783,7 +8024,7 @@ function runCodex(args) {
7783
8024
  });
7784
8025
  child.on("close", (code) => {
7785
8026
  clearTimeout(timer);
7786
- if (code === 0) return resolve6();
8027
+ if (code === 0) return resolve7();
7787
8028
  reject(
7788
8029
  new Error(`codex exec exited ${code}.
7789
8030
  ${stderr.trim().split("\n").slice(-8).join("\n")}`)
@@ -8244,7 +8485,7 @@ async function find2(can) {
8244
8485
  throw new Error(MISSING);
8245
8486
  }
8246
8487
  function runArgv(cmd, args) {
8247
- return new Promise((resolve6) => {
8488
+ return new Promise((resolve7) => {
8248
8489
  const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
8249
8490
  let stdout = "";
8250
8491
  let stderr = "";
@@ -8254,8 +8495,8 @@ function runArgv(cmd, args) {
8254
8495
  child.stderr.on("data", (b) => {
8255
8496
  stderr += b.toString();
8256
8497
  });
8257
- child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
8258
- child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
8498
+ child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
8499
+ child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
8259
8500
  });
8260
8501
  }
8261
8502
  var edgeTts = {
@@ -8342,8 +8583,8 @@ async function synthesize(text2, opts) {
8342
8583
  const file = `${key}.mp3`;
8343
8584
  const audio = join6(opts.dir, file);
8344
8585
  const sidecar = join6(opts.dir, `${key}.json`);
8345
- const cached = await readSidecar(sidecar);
8346
- if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
8586
+ const cached2 = await readSidecar(sidecar);
8587
+ if (cached2) return { audio, file, seconds: cached2.seconds, cues: cached2.cues };
8347
8588
  await mkdir4(opts.dir, { recursive: true });
8348
8589
  const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
8349
8590
  const { cues, seconds } = spoken;
@@ -8681,18 +8922,26 @@ import { createRequire as createRequire2 } from "node:module";
8681
8922
  import { homedir as homedir2 } from "node:os";
8682
8923
  import { join as join8 } from "node:path";
8683
8924
  import { pathToFileURL } from "node:url";
8684
- async function chromePath(need = "open the deck with") {
8685
- const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
8686
- if (explicit) return explicit;
8925
+ async function resolveChrome(need = "open the deck with") {
8926
+ const env = process.env.HYPERFRAMES_BROWSER_PATH || process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
8927
+ if (env) return { path: env, source: "env" };
8687
8928
  const { getInstalledBrowsers } = await import("@puppeteer/browsers");
8688
- const cacheDir = process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer");
8689
- const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
8690
- const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
8691
- if (found) return found.executablePath;
8929
+ const pick = async (cacheDir) => {
8930
+ const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
8931
+ const ordered = [...installed2].sort((a, b) => b.buildId.localeCompare(a.buildId, "en"));
8932
+ return ordered.find((b) => b.browser === "chrome-headless-shell") ?? ordered.find((b) => b.browser === "chrome");
8933
+ };
8934
+ const hf = await pick(join8(homedir2(), ".cache", "hyperframes", "chrome"));
8935
+ if (hf) return { path: hf.executablePath, source: "hyperframes" };
8936
+ const pup = await pick(process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer"));
8937
+ if (pup) return { path: pup.executablePath, source: "puppeteer" };
8692
8938
  throw new Error(
8693
8939
  `no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
8694
8940
  );
8695
8941
  }
8942
+ async function chromePath(need = "open the deck with") {
8943
+ return (await resolveChrome(need)).path;
8944
+ }
8696
8945
  function runtimePath() {
8697
8946
  return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
8698
8947
  }
@@ -8709,12 +8958,24 @@ async function openDeck(dir, opts = {}) {
8709
8958
  if (!width || !height) throw new Error("index.html declares no canvas size");
8710
8959
  const timeout = opts.timeoutMs ?? 6e4;
8711
8960
  const { default: puppeteer } = await import("puppeteer-core");
8961
+ const chrome2 = await resolveChrome();
8712
8962
  const browser = await puppeteer.launch({
8713
- executablePath: await chromePath(),
8963
+ executablePath: chrome2.path,
8714
8964
  headless: true,
8715
- // A retina host would otherwise hand back a 2x frame, whose clip is not the
8716
- // renderer's.
8717
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
8965
+ args: [
8966
+ // A retina host would otherwise hand back a 2x frame, whose clip is not the
8967
+ // renderer's.
8968
+ "--force-device-scale-factor=1",
8969
+ "--hide-scrollbars",
8970
+ // WITHOUT THESE A WebGL CANVAS GETS NO CONTEXT AND SCREENSHOTS EMPTY. The
8971
+ // renderer passes its own GL arguments; this path passed none, so it drew
8972
+ // a blank rectangle and reported success. SwiftShader rather than the host
8973
+ // GPU on purpose: it is the one backend available on every machine, and a
8974
+ // gate that quietly follows the host's hardware is measuring the host.
8975
+ "--use-gl=angle",
8976
+ "--use-angle=swiftshader",
8977
+ "--enable-unsafe-swiftshader"
8978
+ ]
8718
8979
  });
8719
8980
  try {
8720
8981
  const page = await browser.newPage();
@@ -8730,6 +8991,18 @@ async function openDeck(dir, opts = {}) {
8730
8991
  { timeout }
8731
8992
  );
8732
8993
  await page.evaluate(() => document.fonts.ready);
8994
+ const canvas = await page.evaluate(() => {
8995
+ const el = document.querySelector("canvas");
8996
+ if (!el) return { present: false, gl: true };
8997
+ const probe2 = document.createElement("canvas");
8998
+ const gl = !!(probe2.getContext("webgl2") ?? probe2.getContext("webgl"));
8999
+ return { present: true, gl };
9000
+ });
9001
+ if (canvas.present && !canvas.gl) {
9002
+ throw new Error(
9003
+ `this deck draws on a <canvas> and ${chrome2.path} cannot create a WebGL context, so the capture would be of an empty rectangle. Chrome came from ${chrome2.source}; the renderer uses hyperframes' own shell. Set HYPERFRAMES_BROWSER_PATH to the binary \`hyperframes render\` uses so the gate and the render are the same browser.`
9004
+ );
9005
+ }
8733
9006
  const cdp = await page.createCDPSession();
8734
9007
  return {
8735
9008
  width,
@@ -9251,7 +9524,7 @@ ${tail3}`);
9251
9524
  }
9252
9525
  }
9253
9526
  function runLive(file, args) {
9254
- return new Promise((resolve6, reject) => {
9527
+ return new Promise((resolve7, reject) => {
9255
9528
  const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
9256
9529
  child.on("error", (err) => {
9257
9530
  reject(
@@ -9259,7 +9532,7 @@ function runLive(file, args) {
9259
9532
  );
9260
9533
  });
9261
9534
  child.on("close", (code, signal) => {
9262
- if (code === 0) resolve6();
9535
+ if (code === 0) resolve7();
9263
9536
  else if (signal) {
9264
9537
  reject(
9265
9538
  new Error(
@@ -10083,7 +10356,11 @@ async function readInBrowser(html, timeoutMs) {
10083
10356
  "--disable-background-networking",
10084
10357
  "--disable-extensions",
10085
10358
  "--no-default-browser-check",
10086
- "--no-first-run"
10359
+ "--no-first-run",
10360
+ // The floor under interception — see the note above. Not a hardening
10361
+ // nicety: without it the open-proxy property holds only as fast as CDP
10362
+ // happens to be that run.
10363
+ "--host-resolver-rules=MAP * ~NOTFOUND"
10087
10364
  ]
10088
10365
  });
10089
10366
  try {
@@ -11052,9 +11329,57 @@ function textOf(node) {
11052
11329
  }
11053
11330
  }
11054
11331
 
11332
+ // src/tmpdir.ts
11333
+ import { realpathSync } from "node:fs";
11334
+ import { createRequire as createRequire3 } from "node:module";
11335
+ import { tmpdir as tmpdir3 } from "node:os";
11336
+ import { basename as basename4, dirname as dirname4, join as join12, resolve as resolve5, sep } from "node:path";
11337
+ var cached;
11338
+ function real(dir) {
11339
+ let at = resolve5(dir);
11340
+ const tail3 = [];
11341
+ for (; ; ) {
11342
+ try {
11343
+ return join12(realpathSync(at), ...tail3);
11344
+ } catch {
11345
+ const up = dirname4(at);
11346
+ if (up === at) return resolve5(dir);
11347
+ tail3.unshift(basename4(at));
11348
+ at = up;
11349
+ }
11350
+ }
11351
+ }
11352
+ function packageRoot() {
11353
+ cached ??= real(dirname4(createRequire3(import.meta.url).resolve("../package.json")));
11354
+ return cached;
11355
+ }
11356
+ function insideRoot(dir) {
11357
+ const root = packageRoot();
11358
+ const at = real(dir);
11359
+ return at === root || at.startsWith(root + sep);
11360
+ }
11361
+ function guardTmpdir() {
11362
+ const before = tmpdir3();
11363
+ if (!insideRoot(before)) return;
11364
+ delete process.env.TMPDIR;
11365
+ const after = tmpdir3();
11366
+ if (insideRoot(after)) {
11367
+ throw new Error(
11368
+ `tmpdir: ${after} is still inside ${packageRoot()}. Set TMPDIR to a real temp directory.`
11369
+ );
11370
+ }
11371
+ const worker = process.env.VITEST_WORKER_ID;
11372
+ if (worker === void 0 || worker === "1") {
11373
+ process.stderr.write(
11374
+ `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.
11375
+ `
11376
+ );
11377
+ }
11378
+ }
11379
+
11055
11380
  // src/verify/index.ts
11056
11381
  import { readdir as readdir3, readFile as readFile16 } from "node:fs/promises";
11057
- import { join as join15, relative, sep } from "node:path";
11382
+ import { join as join16, relative, sep as sep2 } from "node:path";
11058
11383
 
11059
11384
  // src/verify/budget.ts
11060
11385
  function readCanvas(html) {
@@ -11162,7 +11487,7 @@ function clock2(seconds) {
11162
11487
  // src/verify/check.ts
11163
11488
  import { execFile as execFile2 } from "node:child_process";
11164
11489
  import { readFile as readFile13 } from "node:fs/promises";
11165
- import { join as join12 } from "node:path";
11490
+ import { join as join13 } from "node:path";
11166
11491
  import { promisify as promisify2 } from "node:util";
11167
11492
  var run2 = promisify2(execFile2);
11168
11493
  var DEFAULT_TIMEOUT_MS3 = 24e4;
@@ -11192,7 +11517,7 @@ async function check2(dir, opts = {}) {
11192
11517
  async function readComposition(dir) {
11193
11518
  let html;
11194
11519
  try {
11195
- html = await readFile13(join12(dir, "index.html"), "utf8");
11520
+ html = await readFile13(join13(dir, "index.html"), "utf8");
11196
11521
  } catch {
11197
11522
  return { transit: [], duration: 0 };
11198
11523
  }
@@ -11341,7 +11666,7 @@ function tail(s) {
11341
11666
 
11342
11667
  // src/verify/fidelity.ts
11343
11668
  import { readFile as readFile14 } from "node:fs/promises";
11344
- import { join as join13 } from "node:path";
11669
+ import { join as join14 } from "node:path";
11345
11670
 
11346
11671
  // src/verify/typefloor.ts
11347
11672
  var TYPE_FLOOR_PX = 40;
@@ -11788,8 +12113,8 @@ async function fidelity(dir, opts = {}) {
11788
12113
  elapsedMs: Date.now() - started
11789
12114
  });
11790
12115
  const stops = opts.stops ?? readStops(
11791
- await readFile14(join13(dir, TIMING_FILE), "utf8").catch(() => null),
11792
- await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => null)
12116
+ await readFile14(join14(dir, TIMING_FILE), "utf8").catch(() => null),
12117
+ await readFile14(join14(dir, DECK_PAGE), "utf8").catch(() => null)
11793
12118
  );
11794
12119
  if (stops.length === 0) return notMeasured("the deck declares no stops");
11795
12120
  let deck = null;
@@ -11848,8 +12173,8 @@ async function fidelity(dir, opts = {}) {
11848
12173
  import { execFile as execFile3 } from "node:child_process";
11849
12174
  import { createHash as createHash9 } from "node:crypto";
11850
12175
  import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile15, rm as rm8 } from "node:fs/promises";
11851
- import { tmpdir as tmpdir3 } from "node:os";
11852
- import { join as join14 } from "node:path";
12176
+ import { tmpdir as tmpdir4 } from "node:os";
12177
+ import { join as join15 } from "node:path";
11853
12178
  import { promisify as promisify3 } from "node:util";
11854
12179
  var run3 = promisify3(execFile3);
11855
12180
  var FLOOR_DB = 40;
@@ -11888,9 +12213,9 @@ function measureMotion(hashes, scenes, seconds) {
11888
12213
  async function drift(dir, opts = {}) {
11889
12214
  const mode = opts.mode ?? "psnr";
11890
12215
  const floorDb = opts.floorDb ?? FLOOR_DB;
11891
- const work = opts.workDir ?? await mkdtemp3(join14(tmpdir3(), "decksmith-drift-"));
11892
- const a = join14(work, "a");
11893
- const b = join14(work, "b");
12216
+ const work = opts.workDir ?? await mkdtemp3(join15(tmpdir4(), "decksmith-drift-"));
12217
+ const a = join15(work, "a");
12218
+ const b = join15(work, "b");
11894
12219
  const report2 = await compare(dir, a, b, mode, floorDb, opts);
11895
12220
  const keep = opts.keep || !report2.passed;
11896
12221
  if (keep) report2.kept = { a, b };
@@ -11945,11 +12270,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
11945
12270
  const differing = [];
11946
12271
  const ha = [];
11947
12272
  for (const [i, name] of fa.entries()) {
11948
- const one = await sha(join14(a, name));
12273
+ const one = await sha(join15(a, name));
11949
12274
  ha.push(one);
11950
- if (one !== await sha(join14(b, name))) differing.push(i + 1);
12275
+ if (one !== await sha(join15(b, name))) differing.push(i + 1);
11951
12276
  }
11952
- const html = await readFile15(join14(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
12277
+ const html = await readFile15(join15(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
11953
12278
  const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
11954
12279
  let worst;
11955
12280
  if (differing.length > 0) {
@@ -12097,11 +12422,11 @@ async function psnr(a, b, firstFrame) {
12097
12422
  "-start_number",
12098
12423
  String(seq.start),
12099
12424
  "-i",
12100
- join14(a, seq.pattern),
12425
+ join15(a, seq.pattern),
12101
12426
  "-start_number",
12102
12427
  String(seq.start),
12103
12428
  "-i",
12104
- join14(b, seq.pattern),
12429
+ join15(b, seq.pattern),
12105
12430
  "-lavfi",
12106
12431
  "psnr=stats_file=-",
12107
12432
  "-f",
@@ -12176,7 +12501,7 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12176
12501
  const html = await readCompositions(dir);
12177
12502
  const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
12178
12503
  const narration = scanNarration(
12179
- await readFile16(join15(dir, DECK_PAGE), "utf8").catch(() => ""),
12504
+ await readFile16(join16(dir, DECK_PAGE), "utf8").catch(() => ""),
12180
12505
  await listFiles(dir)
12181
12506
  );
12182
12507
  const budget3 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
@@ -12213,8 +12538,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12213
12538
  }
12214
12539
  async function declaredStops(dir) {
12215
12540
  return readStops(
12216
- await readFile16(join15(dir, TIMING_FILE), "utf8").catch(() => null),
12217
- await readFile16(join15(dir, DECK_PAGE), "utf8").catch(() => null)
12541
+ await readFile16(join16(dir, TIMING_FILE), "utf8").catch(() => null),
12542
+ await readFile16(join16(dir, DECK_PAGE), "utf8").catch(() => null)
12218
12543
  );
12219
12544
  }
12220
12545
  var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
@@ -12254,7 +12579,7 @@ function scanNarration(page, files) {
12254
12579
  ];
12255
12580
  }
12256
12581
  async function readTiming2(dir) {
12257
- const raw2 = await readFile16(join15(dir, TIMING_FILE), "utf8").catch(() => "");
12582
+ const raw2 = await readFile16(join16(dir, TIMING_FILE), "utf8").catch(() => "");
12258
12583
  if (!raw2) return void 0;
12259
12584
  try {
12260
12585
  const parsed = JSON.parse(raw2);
@@ -12266,7 +12591,7 @@ async function readTiming2(dir) {
12266
12591
  async function listFiles(dir) {
12267
12592
  const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12268
12593
  return new Set(
12269
- entries.filter((e) => e.isFile()).map((e) => relative(dir, join15(e.parentPath, e.name)).split(sep).join("/"))
12594
+ entries.filter((e) => e.isFile()).map((e) => relative(dir, join16(e.parentPath, e.name)).split(sep2).join("/"))
12270
12595
  );
12271
12596
  }
12272
12597
  var INSTEAD = {
@@ -12525,13 +12850,14 @@ async function readCompositions(dir) {
12525
12850
  const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
12526
12851
  const files = entries.filter(
12527
12852
  (e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
12528
- ).map((e) => join15(e.parentPath, e.name));
12853
+ ).map((e) => join16(e.parentPath, e.name));
12529
12854
  return Promise.all(
12530
12855
  files.map(async (f) => [relative(dir, f), await readFile16(f, "utf8")])
12531
12856
  );
12532
12857
  }
12533
12858
 
12534
12859
  // src/cli.ts
12860
+ guardTmpdir();
12535
12861
  var AUDIO_DIR = "audio";
12536
12862
  var NARRATION_FILE = "narration.json";
12537
12863
  var DEFAULTS = prefsSchema.parse({});
@@ -12544,43 +12870,48 @@ async function deckRuntime() {
12544
12870
  }
12545
12871
  }
12546
12872
  function playerBundle() {
12547
- const require2 = createRequire3(import.meta.url);
12873
+ const require2 = createRequire4(import.meta.url);
12548
12874
  try {
12549
- return join16(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12875
+ return join17(dirname5(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
12550
12876
  } catch {
12551
12877
  throw new Error('Cannot locate the hyperframes player. Run "npm install".');
12552
12878
  }
12553
12879
  }
12554
12880
  async function vendorKatex(out) {
12555
- const require2 = createRequire3(import.meta.url);
12556
- const dist = join16(dirname4(require2.resolve("katex/package.json")), "dist");
12557
- const css = await readFile17(join16(dist, "katex.min.css"), "utf8");
12558
- await mkdir10(join16(out, "katex/fonts"), { recursive: true });
12559
- for (const file of await readdir4(join16(dist, "fonts"))) {
12881
+ const require2 = createRequire4(import.meta.url);
12882
+ const dist = join17(dirname5(require2.resolve("katex/package.json")), "dist");
12883
+ const css = await readFile17(join17(dist, "katex.min.css"), "utf8");
12884
+ await mkdir10(join17(out, "katex/fonts"), { recursive: true });
12885
+ for (const file of await readdir4(join17(dist, "fonts"))) {
12560
12886
  if (file.endsWith(".woff2"))
12561
- await cp(join16(dist, "fonts", file), join16(out, "katex/fonts", file));
12887
+ await cp(join17(dist, "fonts", file), join17(out, "katex/fonts", file));
12562
12888
  }
12563
12889
  const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
12564
12890
  const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
12565
12891
  return kept ? `src:${kept}` : whole;
12566
12892
  });
12567
- await writeFile12(join16(out, "katex/katex.min.css"), woff2Only);
12893
+ await writeFile12(join17(out, "katex/katex.min.css"), woff2Only);
12568
12894
  }
12569
- async function vendorScripts(out) {
12570
- const require2 = createRequire3(import.meta.url);
12571
- await mkdir10(join16(out, "vendor"), { recursive: true });
12895
+ async function vendorScripts(out, composition) {
12896
+ const require2 = createRequire4(import.meta.url);
12897
+ await mkdir10(join17(out, "vendor"), { recursive: true });
12898
+ const wanted = (name) => composition.includes(`./vendor/${name}`);
12572
12899
  for (const [pkg, rel, name] of [
12573
12900
  ["gsap/package.json", "dist/gsap.min.js", "gsap.min.js"],
12574
12901
  ["gsap/package.json", "dist/DrawSVGPlugin.min.js", "DrawSVGPlugin.min.js"],
12902
+ ["gsap/package.json", "dist/MorphSVGPlugin.min.js", "MorphSVGPlugin.min.js"],
12575
12903
  ["katex/package.json", "dist/katex.min.js", "katex.min.js"]
12576
12904
  ]) {
12577
- const from = join16(dirname4(require2.resolve(pkg)), rel);
12578
- await cp(from, join16(out, "vendor", name));
12905
+ if (!wanted(name)) continue;
12906
+ const from = join17(dirname5(require2.resolve(pkg)), rel);
12907
+ await cp(from, join17(out, "vendor", name));
12908
+ }
12909
+ if (wanted("ds-morph.js")) {
12910
+ await cp(
12911
+ fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
12912
+ join17(out, "vendor", "ds-morph.js")
12913
+ );
12579
12914
  }
12580
- await cp(
12581
- fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
12582
- join16(out, "vendor", "ds-morph.js")
12583
- );
12584
12915
  }
12585
12916
  var HYPERFRAMES_JSON = `${JSON.stringify(
12586
12917
  {
@@ -12683,13 +13014,13 @@ program.command("ingest").description("Parse a document or a web page into sourc
12683
13014
  "--max-clip-seconds <n>",
12684
13015
  "URL only: seconds of each clip kept, the rest trimmed (maxClipSeconds; default 60)"
12685
13016
  ).option("--no-transcode", "URL only: ship each clip as the page served it, unshrunk").action(async (input, o) => {
12686
- const assets = join16(dirname4(resolve5(o.out)), "assets");
12687
- const scratch = url(input) ? await mkdtemp4(join16(tmpdir4(), "decksmith-harvest-")) : void 0;
13017
+ const assets = join17(dirname5(resolve6(o.out)), "assets");
13018
+ const scratch = url(input) ? await mkdtemp4(join17(tmpdir5(), "decksmith-harvest-")) : void 0;
12688
13019
  try {
12689
13020
  let md;
12690
13021
  let clips = [];
12691
13022
  if (scratch === void 0) {
12692
- md = await readFile17(resolve5(input), "utf8").catch(() => {
13023
+ md = await readFile17(resolve6(input), "utf8").catch(() => {
12693
13024
  throw new Error(`Cannot read ${input}.`);
12694
13025
  });
12695
13026
  } else {
@@ -12710,7 +13041,7 @@ program.command("ingest").description("Parse a document or a web page into sourc
12710
13041
  const dropped = [];
12711
13042
  const source = await fetchFigures(withClips, assets, dropped);
12712
13043
  for (const why3 of dropped) step(`ingest: ${why3}`);
12713
- const bundle = await bundleFont(source.lang, glyphs(source), join16(assets, "fonts"));
13044
+ const bundle = await bundleFont(source.lang, glyphs(source), join17(assets, "fonts"));
12714
13045
  if (bundle) step(`ingest: bundled ${bundle.family} for ${source.lang}`);
12715
13046
  await writeJson(o.out, source);
12716
13047
  step(`ingest: wrote ${o.out}`);
@@ -12771,7 +13102,7 @@ imageFlags(
12771
13102
  program.command("illustrate").description("Draw the pictures the plan asked for, and point the storyboard at them.").argument("<storyboard>", "storyboard.json carrying illustration briefs").requiredOption("--source <file>", "source.json the storyboard was planned from")
12772
13103
  ).action(async (sbPath, o) => {
12773
13104
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
12774
- const sourcePath = resolve5(String(o.source));
13105
+ const sourcePath = resolve6(String(o.source));
12775
13106
  const source = await readValidated(sourcePath, sourceSchema, "source");
12776
13107
  assertRefsResolve(storyboard, source, { pending: "allow" });
12777
13108
  const prefs = await loadPrefs(prefsFromFlags({ ...flags(o), images: true }));
@@ -12785,7 +13116,7 @@ imageFlags(
12785
13116
  );
12786
13117
  const drawn = await illustrate(storyboard, source, {
12787
13118
  prefs,
12788
- assetsDir: join16(dirname4(sourcePath), "assets"),
13119
+ assetsDir: join17(dirname5(sourcePath), "assets"),
12789
13120
  onStep: step
12790
13121
  });
12791
13122
  for (const p of drawn.illustrated) {
@@ -12795,9 +13126,9 @@ imageFlags(
12795
13126
  }
12796
13127
  await writeJson(sourcePath, drawn.source);
12797
13128
  await writeJson(sbPath, drawn.storyboard);
12798
- const cached = drawn.illustrated.filter((p) => p.cached).length;
13129
+ const cached2 = drawn.illustrated.filter((p) => p.cached).length;
12799
13130
  step(
12800
- `illustrate: ${drawn.illustrated.length} picture(s)${cached ? `, ${cached} cached` : ""} \u2192 ${o.source}, ${sbPath}`
13131
+ `illustrate: ${drawn.illustrated.length} picture(s)${cached2 ? `, ${cached2} cached` : ""} \u2192 ${o.source}, ${sbPath}`
12801
13132
  );
12802
13133
  });
12803
13134
  voiceFlags(
@@ -12810,18 +13141,18 @@ voiceFlags(
12810
13141
  const format = pickFormat(String(o.format), o.width, o.height);
12811
13142
  const chosen = await loadPrefs(prefsFromFlags(flags(o)));
12812
13143
  const prefs = stated(chosen, "lang") ? chosen : { ...chosen, lang: storyboard.lang };
12813
- const dir = resolve5(String(o.out));
13144
+ const dir = resolve6(String(o.out));
12814
13145
  const speaking = storyboard.beats.filter((b) => b.narration?.trim()).length;
12815
13146
  if (speaking === 0) {
12816
13147
  throw new Error(`No beat in ${sbPath} has a "narration" field, so there is nothing to speak.`);
12817
13148
  }
12818
13149
  step(`narrate: ${speaking} of ${storyboard.beats.length} beats have narration`);
12819
13150
  const narration = await narrate(storyboard, source, prefs, { dir, format });
12820
- await writeJson(join16(dir, NARRATION_FILE), narration);
13151
+ await writeJson(join17(dir, NARRATION_FILE), narration);
12821
13152
  const segments2 = Object.values(narration.beats).flat();
12822
13153
  const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
12823
13154
  step(
12824
- `narrate: ${segments2.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join16(dir, NARRATION_FILE)}`
13155
+ `narrate: ${segments2.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join17(dir, NARRATION_FILE)}`
12825
13156
  );
12826
13157
  });
12827
13158
  lookFlags(
@@ -12836,7 +13167,7 @@ lookFlags(
12836
13167
  const format = withMinWeight(pickFormat(o.format, o.width, o.height), o.minWeight);
12837
13168
  const prefs = await loadPrefs(prefsFromFlags(flags(o)), process.cwd(), source);
12838
13169
  const theme = stated(prefs, "theme") ?? storyboard.theme;
12839
- const out = resolve5(o.out);
13170
+ const out = resolve6(o.out);
12840
13171
  await mkdir10(out, { recursive: true });
12841
13172
  const found = await findNarration(sbPath, o.narration);
12842
13173
  const narration = found ? await loadNarration(found) : void 0;
@@ -12861,10 +13192,14 @@ lookFlags(
12861
13192
  // takes eleven good ones with it at the last stage. Printed, not swallowed:
12862
13193
  // a missing slide the terminal never mentioned is the failure this project
12863
13194
  // keeps finding by eye.
12864
- onBeatError: (id2, err) => step(`build: left out ${id2} \u2014 ${err.message}`)
13195
+ onBeatError: (id2, err) => step(`build: left out ${id2} \u2014 ${err.message}`),
13196
+ // And the slides that ARE here but not as they were planned. A beat drawn
13197
+ // with one of its parts dropped looks finished, so this line is the only
13198
+ // place anyone learns it is not.
13199
+ onBeatWarning: (id2, warning) => step(`build: kept ${id2} \u2014 ${warning}`)
12865
13200
  });
12866
- await writeFile12(join16(out, "index.html"), deck.composition);
12867
- await writeFile12(join16(out, "hyperframes.json"), HYPERFRAMES_JSON);
13201
+ await writeFile12(join17(out, "index.html"), deck.composition);
13202
+ await writeFile12(join17(out, "hyperframes.json"), HYPERFRAMES_JSON);
12868
13203
  await writeTiming(out, {
12869
13204
  storyboard,
12870
13205
  source,
@@ -12880,36 +13215,36 @@ lookFlags(
12880
13215
  ...narration ? { narration } : {}
12881
13216
  });
12882
13217
  if (deck.page) {
12883
- await writeFile12(join16(out, DECK_PAGE), deck.page);
12884
- await cp(playerBundle(), join16(out, PLAYER_FILE));
13218
+ await writeFile12(join17(out, DECK_PAGE), deck.page);
13219
+ await cp(playerBundle(), join17(out, PLAYER_FILE));
12885
13220
  }
12886
13221
  await vendorKatex(out);
12887
- await vendorScripts(out);
12888
- await copyAssets(dirname4(resolve5(o.source)), out, source.figures);
12889
- if (found && narration) await copyAudio(dirname4(found), narration, out);
13222
+ await vendorScripts(out, deck.composition);
13223
+ await copyAssets(dirname5(resolve6(o.source)), out, source.figures);
13224
+ if (found && narration) await copyAudio(dirname5(found), narration, out);
12890
13225
  const look = [theme, paced.speed === 1 ? "" : `${paced.speed}\xD7 speed`].filter(Boolean).join(", ");
12891
13226
  const { cut } = deck;
12892
13227
  const floor = cut.dropped.filter((d) => d.rule === "below_min_weight").length;
12893
13228
  const of = cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
12894
13229
  step(
12895
- `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join16(out, "index.html")}`
13230
+ `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join17(out, "index.html")}`
12896
13231
  );
12897
13232
  for (const f of scanPaperArc({ ...storyboard, beats: cut.kept }, prefs))
12898
13233
  step(`build: ${f.message}`);
12899
13234
  reportCut(cut);
12900
- if (deck.page) step(`build: navigable deck \u2192 ${join16(out, DECK_PAGE)}`);
13235
+ if (deck.page) step(`build: navigable deck \u2192 ${join17(out, DECK_PAGE)}`);
12901
13236
  await gate(out, false, storyboard, cut.kept, o.fidelity !== false, source);
12902
13237
  }
12903
13238
  );
12904
13239
  program.command("verify").description("Re-run the gates on a built deck.").argument("<dir>", "a built deck directory").option("--snapshots", "also write the contrast-pass PNGs to <dir>/snapshots", false).option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser").action(async (dir, o) => {
12905
- await gate(resolve5(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
13240
+ await gate(resolve6(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
12906
13241
  });
12907
13242
  program.command("frames").description("Write PNGs of a built deck through the capture path the renderer uses.").argument("<dir>", "a built deck directory").option(
12908
13243
  "--at <seconds...>",
12909
13244
  "absolute times to photograph; defaults to every hold the deck declares"
12910
13245
  ).option("--out <dir>", "where to write the PNGs (default: <dir>/frames)").action(async (dir, o) => {
12911
- const deck = resolve5(dir);
12912
- const out = resolve5(o.out ?? join16(deck, "frames"));
13246
+ const deck = resolve6(dir);
13247
+ const out = resolve6(o.out ?? join17(deck, "frames"));
12913
13248
  let times;
12914
13249
  if (o.at?.length) {
12915
13250
  times = o.at.map((raw2) => {
@@ -12919,8 +13254,8 @@ program.command("frames").description("Write PNGs of a built deck through the ca
12919
13254
  });
12920
13255
  } else {
12921
13256
  times = readStops(
12922
- await readFile17(join16(deck, TIMING_FILE), "utf8").catch(() => null),
12923
- await readFile17(join16(deck, DECK_PAGE), "utf8").catch(() => null)
13257
+ await readFile17(join17(deck, TIMING_FILE), "utf8").catch(() => null),
13258
+ await readFile17(join17(deck, DECK_PAGE), "utf8").catch(() => null)
12924
13259
  ).map((s) => s.t);
12925
13260
  if (times.length === 0)
12926
13261
  throw new Error(
@@ -12934,7 +13269,7 @@ program.command("frames").description("Write PNGs of a built deck through the ca
12934
13269
  program.command("drift").description("Render a built deck twice and compare, frame by frame. Minutes, not seconds.").argument("<dir>", "a built deck directory").option("--identical", "require every frame byte-identical \u2014 image-free decks only", false).option("--floor <dB>", "per-frame PSNR floor", String(FLOOR_DB)).option("--keep", "keep both frame directories even when they agree").option("--workers <n>", "pin both renders to one worker count (default: 1 vs 3)").action(
12935
13270
  async (dir, o) => {
12936
13271
  step("drift: rendering twice, several minutes a render");
12937
- const verdict = await drift(resolve5(dir), {
13272
+ const verdict = await drift(resolve6(dir), {
12938
13273
  mode: o.identical ? "identical" : "psnr",
12939
13274
  floorDb: Number(o.floor),
12940
13275
  ...o.keep ? { keep: true } : {},
@@ -12961,8 +13296,8 @@ program.command("render").description("Render a built deck to a finished video:
12961
13296
  throw new Error(`Unknown --subtitles "${o.subtitles}". Use sidecar, burn or none.`);
12962
13297
  }
12963
13298
  const result = await render({
12964
- deck: resolve5(dir),
12965
- out: resolve5(o.out),
13299
+ deck: resolve6(dir),
13300
+ out: resolve6(o.out),
12966
13301
  subtitles: o.subtitles,
12967
13302
  protocolTimeoutMs: Number(o.protocolTimeout),
12968
13303
  log: step,
@@ -12990,7 +13325,7 @@ voiceFlags(
12990
13325
  )
12991
13326
  ).action(async (sbPath, o) => {
12992
13327
  const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
12993
- const sourcePath = resolve5(String(o.source));
13328
+ const sourcePath = resolve6(String(o.source));
12994
13329
  const source = await readValidated(sourcePath, sourceSchema, "source");
12995
13330
  assertRefsResolve(storyboard, source);
12996
13331
  if (o.bake && o.link) throw new Error("Choose --bake or --link, not both.");
@@ -13005,7 +13340,7 @@ voiceFlags(
13005
13340
  const prefer = o.link ? "link" : "bake";
13006
13341
  const plan = await planMedia(figureAssets(source, sourcePath, prefer));
13007
13342
  const files = { ...plan.files };
13008
- if (found && narration) Object.assign(files, await audioFiles(dirname4(found), narration));
13343
+ if (found && narration) Object.assign(files, await audioFiles(dirname5(found), narration));
13009
13344
  const pack2 = {
13010
13345
  version: PACK_VERSION,
13011
13346
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -13020,7 +13355,7 @@ voiceFlags(
13020
13355
  ...narration ? { narration } : {},
13021
13356
  media: plan.media
13022
13357
  };
13023
- const out = resolve5(String(o.out));
13358
+ const out = resolve6(String(o.out));
13024
13359
  const bytes = await writePack(pack2, files, out);
13025
13360
  if (plan.demoted.length) {
13026
13361
  step(`pack: kept as links, not bakeable \u2014 ${plan.demoted.join(", ")}`);
@@ -13034,19 +13369,19 @@ voiceFlags(
13034
13369
  step(`pack: ${size2(bytes)} \u2192 ${out}`);
13035
13370
  });
13036
13371
  program.command("unpack").description("Open a .deck back into the files build reads.").argument("<file>", "a .deck archive").requiredOption("-o, --out <dir>", "directory to open it into").action(async (file, o) => {
13037
- const { pack: pack2, files } = await readPack(resolve5(file));
13038
- const out = resolve5(o.out);
13039
- await writeJson(join16(out, "source.json"), pack2.source);
13040
- await writeJson(join16(out, "storyboard.json"), pack2.storyboard);
13041
- await writeJson(join16(out, "decksmith.config.json"), pack2.prefs);
13042
- if (pack2.narration) await writeJson(join16(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
13372
+ const { pack: pack2, files } = await readPack(resolve6(file));
13373
+ const out = resolve6(o.out);
13374
+ await writeJson(join17(out, "source.json"), pack2.source);
13375
+ await writeJson(join17(out, "storyboard.json"), pack2.storyboard);
13376
+ await writeJson(join17(out, "decksmith.config.json"), pack2.prefs);
13377
+ if (pack2.narration) await writeJson(join17(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
13043
13378
  const figures = new Map(pack2.source.figures.map((f) => [f.id, f.src]));
13044
13379
  for (const [path2, bytes] of Object.entries(files)) {
13045
13380
  const media = pack2.media.find((m) => m.path === path2);
13046
13381
  const src = media && figures.get(media.id);
13047
- const to = src ? join16("assets", src) : path2;
13048
- await mkdir10(dirname4(join16(out, to)), { recursive: true });
13049
- await writeFile12(join16(out, to), bytes);
13382
+ const to = src ? join17("assets", src) : path2;
13383
+ await mkdir10(dirname5(join17(out, to)), { recursive: true });
13384
+ await writeFile12(join17(out, to), bytes);
13050
13385
  }
13051
13386
  const linked = pack2.media.filter((m) => m.policy !== "bake");
13052
13387
  step(`unpack: "${pack2.title}", ${pack2.storyboard.beats.length} beats \u2192 ${out}`);
@@ -13056,7 +13391,7 @@ program.command("unpack").description("Open a .deck back into the files build re
13056
13391
  );
13057
13392
  }
13058
13393
  step(
13059
- `unpack: decksmith build ${join16(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join16(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
13394
+ `unpack: decksmith build ${join17(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join17(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
13060
13395
  );
13061
13396
  });
13062
13397
  program.parseAsync(process.argv).catch((err) => {
@@ -13077,7 +13412,7 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
13077
13412
  );
13078
13413
  const verdict = await verify(dir, { snapshots, fidelity: fidelity2 }, storyboard, kept, source);
13079
13414
  process.stdout.write(report(verdict));
13080
- if (snapshots) step(`verify: snapshots in ${join16(dir, "snapshots")}`);
13415
+ if (snapshots) step(`verify: snapshots in ${join17(dir, "snapshots")}`);
13081
13416
  if (!verdict.passed) process.exitCode = 1;
13082
13417
  }
13083
13418
  function report(v) {
@@ -13094,12 +13429,12 @@ function report(v) {
13094
13429
  async function findNarration(sbPath, flag) {
13095
13430
  if (flag === false) return void 0;
13096
13431
  if (typeof flag === "string") {
13097
- const path2 = resolve5(flag);
13432
+ const path2 = resolve6(flag);
13098
13433
  if (!await stat2(path2).catch(() => null)) throw new Error(`Cannot read narration ${flag}.`);
13099
13434
  return path2;
13100
13435
  }
13101
- const beside = dirname4(resolve5(sbPath));
13102
- for (const candidate of [join16(beside, AUDIO_DIR, NARRATION_FILE), join16(beside, NARRATION_FILE)]) {
13436
+ const beside = dirname5(resolve6(sbPath));
13437
+ for (const candidate of [join17(beside, AUDIO_DIR, NARRATION_FILE), join17(beside, NARRATION_FILE)]) {
13103
13438
  if (await stat2(candidate).catch(() => null)) {
13104
13439
  step(`narration: using ${candidate}`);
13105
13440
  return candidate;
@@ -13119,11 +13454,11 @@ function audioNames(narration) {
13119
13454
  ].sort();
13120
13455
  }
13121
13456
  async function copyAudio(from, narration, out) {
13122
- const dir = join16(out, AUDIO_DIR);
13457
+ const dir = join17(out, AUDIO_DIR);
13123
13458
  await mkdir10(dir, { recursive: true });
13124
13459
  const names = audioNames(narration);
13125
13460
  for (const name of names) {
13126
- await cp(join16(from, name), join16(dir, name)).catch(() => {
13461
+ await cp(join17(from, name), join17(dir, name)).catch(() => {
13127
13462
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
13128
13463
  });
13129
13464
  }
@@ -13132,7 +13467,7 @@ async function copyAudio(from, narration, out) {
13132
13467
  async function audioFiles(from, narration) {
13133
13468
  const files = {};
13134
13469
  for (const name of audioNames(narration)) {
13135
- const bytes = await readFile17(join16(from, name)).catch(() => {
13470
+ const bytes = await readFile17(join17(from, name)).catch(() => {
13136
13471
  throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
13137
13472
  });
13138
13473
  files[`${AUDIO_DIR}/${name}`] = new Uint8Array(bytes);
@@ -13154,9 +13489,9 @@ function wrapScript(text2, width) {
13154
13489
  async function writeTiming(out, input) {
13155
13490
  try {
13156
13491
  const timing = planTiming(input);
13157
- await writeJson(join16(out, TIMING_FILE), timing);
13492
+ await writeJson(join17(out, TIMING_FILE), timing);
13158
13493
  step(
13159
- `build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join16(out, TIMING_FILE)}`
13494
+ `build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join17(out, TIMING_FILE)}`
13160
13495
  );
13161
13496
  } catch (err) {
13162
13497
  step(
@@ -13165,10 +13500,10 @@ async function writeTiming(out, input) {
13165
13500
  }
13166
13501
  }
13167
13502
  function figureAssets(source, sourcePath, prefer) {
13168
- const assets = join16(dirname4(sourcePath), "assets");
13503
+ const assets = join17(dirname5(sourcePath), "assets");
13169
13504
  return source.figures.map((f) => ({
13170
13505
  id: f.id,
13171
- url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join16(assets, f.src),
13506
+ url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join17(assets, f.src),
13172
13507
  prefer
13173
13508
  }));
13174
13509
  }
@@ -13197,7 +13532,7 @@ function withMinWeight(format, raw2) {
13197
13532
  return { ...format, minWeight };
13198
13533
  }
13199
13534
  async function copyAssets(sourceDir, out, figures) {
13200
- const from = join16(sourceDir, "assets");
13535
+ const from = join17(sourceDir, "assets");
13201
13536
  if (!await stat2(from).catch(() => null)) {
13202
13537
  step(`build: no assets/ beside source.json, skipping`);
13203
13538
  return;
@@ -13205,19 +13540,19 @@ async function copyAssets(sourceDir, out, figures) {
13205
13540
  const wanted = new Set(
13206
13541
  figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
13207
13542
  );
13208
- await mkdir10(join16(out, "assets"), { recursive: true });
13543
+ await mkdir10(join17(out, "assets"), { recursive: true });
13209
13544
  let copied = 0;
13210
13545
  for (const name of wanted) {
13211
- const src = resolve5(join16(from, name));
13212
- if (!src.startsWith(`${resolve5(from)}/`)) continue;
13546
+ const src = resolve6(join17(from, name));
13547
+ if (!src.startsWith(`${resolve6(from)}/`)) continue;
13213
13548
  if (!await stat2(src).catch(() => null)) continue;
13214
- await mkdir10(dirname4(join16(out, "assets", name)), { recursive: true });
13215
- await cp(src, join16(out, "assets", name));
13549
+ await mkdir10(dirname5(join17(out, "assets", name)), { recursive: true });
13550
+ await cp(src, join17(out, "assets", name));
13216
13551
  copied++;
13217
13552
  }
13218
- const fonts = join16(from, "fonts");
13553
+ const fonts = join17(from, "fonts");
13219
13554
  if (await stat2(fonts).catch(() => null)) {
13220
- await cp(fonts, join16(out, "assets", "fonts"), { recursive: true });
13555
+ await cp(fonts, join17(out, "assets", "fonts"), { recursive: true });
13221
13556
  }
13222
13557
  step(`build: copied ${copied} referenced figure(s)`);
13223
13558
  }
@@ -13226,7 +13561,7 @@ async function refreshFont(storyboard, source, out) {
13226
13561
  const bundle = await bundleFont(
13227
13562
  storyboard.lang,
13228
13563
  glyphs(source) + glyphs(storyboard),
13229
- join16(out, "assets", "fonts")
13564
+ join17(out, "assets", "fonts")
13230
13565
  );
13231
13566
  if (bundle) step(`build: font bundle covers ${bundle.family}`);
13232
13567
  return bundle?.css;
@@ -13241,7 +13576,7 @@ function glyphs(value) {
13241
13576
  return JSON.stringify(value);
13242
13577
  }
13243
13578
  async function readValidated(file, schema, label) {
13244
- const text2 = await readFile17(resolve5(file), "utf8").catch(() => {
13579
+ const text2 = await readFile17(resolve6(file), "utf8").catch(() => {
13245
13580
  throw new Error(`Cannot read ${label} file ${file}.`);
13246
13581
  });
13247
13582
  let json;
@@ -13259,8 +13594,8 @@ ${issues}`);
13259
13594
  return parsed.data;
13260
13595
  }
13261
13596
  async function writeJson(file, value) {
13262
- const path2 = resolve5(file);
13263
- await mkdir10(dirname4(path2), { recursive: true });
13597
+ const path2 = resolve6(file);
13598
+ await mkdir10(dirname5(path2), { recursive: true });
13264
13599
  await writeFile12(path2, `${JSON.stringify(value, null, 2)}
13265
13600
  `);
13266
13601
  }