@jokerized/decksmith 0.3.0 → 0.3.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
@@ -293,10 +293,16 @@ var insideSchema = z.object({
293
293
  */
294
294
  label: z.string().optional()
295
295
  });
296
+ var beatRoleSchema = z.enum(["intro", "background", "limitations", "conclusion"]);
296
297
  var beatCore = {
297
298
  id: z.string(),
298
299
  /** What the viewer should understand after this beat. */
299
300
  intent: z.string(),
301
+ /**
302
+ * OPTIONAL, and only ever present when `prefs.genre` is `paper`. The
303
+ * structural job this beat does; see `beatRoleSchema`.
304
+ */
305
+ role: beatRoleSchema.optional(),
300
306
  /** Optional: this beat happens inside a named part of the beat before it. */
301
307
  inside: insideSchema.optional(),
302
308
  /** The source sentence or equation this beat is accountable to. */
@@ -443,6 +449,26 @@ var prefsSchema = z.object({
443
449
  tone: z.enum(["plain", "academic", "conversational", "punchy"]).default("plain"),
444
450
  /** How much text a slide may carry before it should have been a diagram. */
445
451
  density: z.enum(["sparse", "normal", "dense"]).default("normal"),
452
+ /**
453
+ * What kind of document is being explained, DECLARED and never sniffed.
454
+ *
455
+ * `paper` asks the planner for the shape a research talk has: open on the
456
+ * problem and the ground the work stands on, close on what it does not do and
457
+ * then what to take away. `general` is every deck built before this existed
458
+ * and changes nothing — no prompt block, no `role` in the planner's schema, no
459
+ * scan.
460
+ *
461
+ * WHY DECLARED. A ten-role heading lexicon (en/ko/ja/zh, numbered-prefix
462
+ * tolerant) run over all 351 markdown files in this repository scored 345 of
463
+ * them at zero role hits and none at three or more. `src/source/markdown.ts`
464
+ * says why in its first line: the input is a hypepaper-style ANALYSIS of a
465
+ * paper, a rewrite that has already discarded the headings a detector would
466
+ * key on. A classifier here would be a guess with a confidence score attached,
467
+ * and it would guess wrong on the Korean fixture. So the author says so once —
468
+ * `--genre paper`, or one line in a `decksmith.config.json` above a directory
469
+ * of papers — and every run under it costs no further typing.
470
+ */
471
+ genre: z.enum(["general", "paper"]).default("general"),
446
472
  /**
447
473
  * How long the finished thing should run, in seconds. Optional: absent means
448
474
  * "as long as it takes", which is what every deck built before this did.
@@ -703,7 +729,10 @@ function selectBeats(storyboard, budget2, seconds = {}) {
703
729
  const protectedIds = protect(live, len, cap);
704
730
  const keep = knapsack(live, len, cap, protectedIds);
705
731
  if (!keep) {
706
- const all = knapsack(live, len, cap, /* @__PURE__ */ new Set());
732
+ const ends = new Set(
733
+ [live[0]?.id, live[live.length - 1]?.id].filter((id2) => !!id2)
734
+ );
735
+ const all = knapsack(live, len, cap, ends) ?? knapsack(live, len, cap, /* @__PURE__ */ new Set());
707
736
  const chosen = all ?? [live[0]];
708
737
  return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, false);
709
738
  }
@@ -735,8 +764,17 @@ function protect(live, len, cap) {
735
764
  }
736
765
  const picture = cheapest && !ids.has(cheapest.id) ? [cheapest] : [];
737
766
  for (const b of picture) ids.add(b.id);
767
+ const byId = new Map(live.map((b) => [b.id, b]));
768
+ const roled = [];
769
+ for (const b of live) {
770
+ if (!b.role) continue;
771
+ for (let hop = b; hop && !ids.has(hop.id); hop = byId.get(hop.inside?.beat ?? "")) {
772
+ ids.add(hop.id);
773
+ roled.push(hop);
774
+ }
775
+ }
738
776
  const ends = /* @__PURE__ */ new Set([first?.id, last?.id]);
739
- const releasable = [...tier, ...coverage, ...picture].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
777
+ const releasable = [...tier, ...coverage, ...picture, ...roled].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
740
778
  const cost = () => live.filter((b) => ids.has(b.id)).reduce((s, b) => s + len(b), 0);
741
779
  for (const b of releasable) {
742
780
  if (cost() <= cap) break;
@@ -4717,6 +4755,9 @@ function depthCss(sid, pose, part) {
4717
4755
  // src/emit/archetypes/stack.ts
4718
4756
  var GAP3 = 44;
4719
4757
  var NUM_X = 48;
4758
+ function numSpine(floor) {
4759
+ return Math.round(NUM_X * floor * 100 / MIN_FONT) / 100;
4760
+ }
4720
4761
  var PROBE_X = 16;
4721
4762
  var PROBE_H = 46;
4722
4763
  var NUM_W = 70;
@@ -4899,7 +4940,7 @@ var stack = (beat, ctx) => {
4899
4940
  );
4900
4941
  const num = text(
4901
4942
  String(i + 1),
4902
- { x: NUM_X, y: mid },
4943
+ { x: numSpine(L.floor), y: mid },
4903
4944
  { size: L.floor, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4904
4945
  );
4905
4946
  return group(slab(L.x0, y0, L, tint, lift2, stroke), { id: id(sid, "lay", i), class: "lay" }) + group(num + leader + dot + label + note2, { id: id(sid, "cap", i), class: "cap" });
@@ -5669,6 +5710,76 @@ import { tmpdir } from "node:os";
5669
5710
  import { join as join3 } from "node:path";
5670
5711
  import { z as z2 } from "zod";
5671
5712
 
5713
+ // src/plan/arc.ts
5714
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
5715
+ function requiredRoles(beatCount) {
5716
+ if (beatCount >= 8) return ARC_ROLES;
5717
+ if (beatCount >= 5) return ["limitations", "conclusion"];
5718
+ return [];
5719
+ }
5720
+ function paperArcRequested(prefs) {
5721
+ return prefs.genre === "paper";
5722
+ }
5723
+ function arcBeats(storyboard) {
5724
+ const by = /* @__PURE__ */ new Map();
5725
+ for (const beat of storyboard.beats) {
5726
+ if (!beat.role) continue;
5727
+ by.set(beat.role, [...by.get(beat.role) ?? [], beat]);
5728
+ }
5729
+ return by;
5730
+ }
5731
+ function arcProblems(storyboard, prefs) {
5732
+ if (!paperArcRequested(prefs)) return [];
5733
+ const beats = storyboard.beats;
5734
+ const need = requiredRoles(Math.min(prefs.slides, beats.length));
5735
+ if (!need.length) return [];
5736
+ const out = [];
5737
+ const by = arcBeats(storyboard);
5738
+ for (const role of need) {
5739
+ const held = by.get(role) ?? [];
5740
+ if (held.length === 0) {
5741
+ out.push(
5742
+ `No beat carries role "${role}". A paper deck is meant to have one, and the source may not support it \u2014 write the beat, or drop \`--genre paper\` for this document.`
5743
+ );
5744
+ continue;
5745
+ }
5746
+ if (held.length > 1) {
5747
+ out.push(
5748
+ `${held.length} beats carry role "${role}" (${held.map((b) => b.id).join(", ")}). A structural job belongs to one slide.`
5749
+ );
5750
+ }
5751
+ }
5752
+ const last = beats[beats.length - 1];
5753
+ if (need.includes("conclusion") && by.has("conclusion") && last?.role !== "conclusion") {
5754
+ out.push(
5755
+ `The deck ends on "${last?.id}" (${last?.archetype}), not on its conclusion. The conclusion is the last thing the viewer sees or it is not a conclusion.`
5756
+ );
5757
+ }
5758
+ const limIdx = beats.findIndex((b) => b.role === "limitations");
5759
+ const conIdx = beats.findIndex((b) => b.role === "conclusion");
5760
+ if (need.includes("limitations") && limIdx >= 0 && conIdx >= 0 && limIdx !== conIdx - 1) {
5761
+ out.push(
5762
+ `The limitations beat is not the slide immediately before the conclusion. The two are a pair and the caveat comes first.`
5763
+ );
5764
+ }
5765
+ const openingWindow = beats.slice(0, 3).map((b) => b.role);
5766
+ for (const role of need.filter((r) => r === "intro" || r === "background")) {
5767
+ if (by.has(role) && !openingWindow.includes(role)) {
5768
+ out.push(
5769
+ `The "${role}" beat is not in the first three slides. It is what the rest of the deck is understood against, so it has to arrive before the mechanism does.`
5770
+ );
5771
+ }
5772
+ }
5773
+ const lim = by.get("limitations")?.[0];
5774
+ const con = by.get("conclusion")?.[0];
5775
+ if (lim && con && lim.archetype === con.archetype) {
5776
+ out.push(
5777
+ `The limitations and conclusion beats are both \`${lim.archetype}\`. Two of the same picture running is what RULE 1 forbids; draw the conclusion where the source states it.`
5778
+ );
5779
+ }
5780
+ return out;
5781
+ }
5782
+
5672
5783
  // src/plan/duration.ts
5673
5784
  var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
5674
5785
  var LAST_HOLD_SECONDS = 4.2;
@@ -6258,6 +6369,46 @@ ${REVEAL_COUNTS}` : ` - ${n3 === 1 ? "ONE SENTENCE" : `${n3} SENTENCES`} FOR TH
6258
6369
  miss its duration.`;
6259
6370
  return { sentences, length };
6260
6371
  }
6372
+ function paperArc(slides) {
6373
+ const asked = requiredRoles(slides);
6374
+ const full = asked.includes("intro");
6375
+ return `
6376
+
6377
+ PAPER ARC \u2014 this source was declared a research paper.
6378
+
6379
+ Four beats have a structural job, and each one NAMES its job in \`role\`. Every
6380
+ other beat leaves \`role\` off. A role is a job, not a heading: never write
6381
+ "Related work" or "Conclusion" as a headline, because RULE 8 still applies to
6382
+ all four.
6383
+ ${full ? `
6384
+ role: "intro" Near the front. What problem exists and who has it, in
6385
+ the viewer's own terms. This is the opening RULE 6
6386
+ already asks for, named so the deck can be checked.
6387
+ role: "background" In the first three beats. What people did before this
6388
+ work, and where that ran out. Take it from what the
6389
+ source itself says about earlier approaches \u2014 if the
6390
+ source says nothing about them, leave the role off
6391
+ rather than inventing a literature (RULE 3).` : `
6392
+ This deck is short, so only the ENDING is required \u2014 an opening the deck
6393
+ already has is not worth a slide of its own here.`}
6394
+ role: "limitations" THE SECOND-TO-LAST beat. What the work does not do, in
6395
+ the source's own admission. Not a hedge inside another
6396
+ beat's sentence: its own slide.
6397
+ role: "conclusion" THE LAST beat, with nothing after it. What the viewer
6398
+ should carry away.
6399
+
6400
+ - The closing pair is TWO beats and they must not share an archetype (RULE 1).
6401
+ A limitation the source admits to is usually a panel; the conclusion is the
6402
+ claim the deck lands, so draw it where the source states it \u2014 bars, a
6403
+ contrast, the figure that settles it \u2014 and fall back to a panel only when it
6404
+ genuinely has no shape.
6405
+ - Give all four a weight of 0.8 or above. A short cut keeps the
6406
+ highest-weighted beats, and a structural beat below 0.8 is one the deck
6407
+ loses at the first budget.
6408
+ - If the source does not support one of these, LEAVE IT OUT. A slide that
6409
+ admits a limitation the paper never admits is worse than no slide.
6410
+ `;
6411
+ }
6261
6412
  function illustrations(images) {
6262
6413
  return `
6263
6414
 
@@ -6286,7 +6437,7 @@ nothing, so it can never dangle.
6286
6437
  }
6287
6438
  function systemPrompt(prefs) {
6288
6439
  const plan = durationPlan(prefs);
6289
- return `${rules(cadenceFor(prefs, plan))}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6440
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6290
6441
 
6291
6442
  PREFERENCES \u2014 chosen by the person who asked for this deck.
6292
6443
  ${prefs.duration === void 0 ? "" : `
@@ -6597,8 +6748,11 @@ function stripNulls(node) {
6597
6748
  return out;
6598
6749
  }
6599
6750
  var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
6600
- function hideFromPlanner(node) {
6601
- if (Array.isArray(node)) return node.map(hideFromPlanner);
6751
+ function plannerInvisible(prefs) {
6752
+ return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
6753
+ }
6754
+ function hideFromPlanner(node, hidden) {
6755
+ if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
6602
6756
  if (node === null || typeof node !== "object") return node;
6603
6757
  const src = node;
6604
6758
  const out = {};
@@ -6606,28 +6760,32 @@ function hideFromPlanner(node) {
6606
6760
  if (key === "properties" && value && typeof value === "object") {
6607
6761
  const kept = {};
6608
6762
  for (const [prop, sub] of Object.entries(value))
6609
- if (!PLANNER_INVISIBLE.has(prop)) kept[prop] = hideFromPlanner(sub);
6763
+ if (!hidden.has(prop)) kept[prop] = hideFromPlanner(sub, hidden);
6610
6764
  out.properties = kept;
6611
6765
  continue;
6612
6766
  }
6613
6767
  if (key === "required" && Array.isArray(value)) {
6614
- out.required = value.filter((r) => typeof r !== "string" || !PLANNER_INVISIBLE.has(r));
6768
+ out.required = value.filter((r) => typeof r !== "string" || !hidden.has(r));
6615
6769
  continue;
6616
6770
  }
6617
- out[key] = hideFromPlanner(value);
6771
+ out[key] = hideFromPlanner(value, hidden);
6618
6772
  }
6619
6773
  return out;
6620
6774
  }
6621
- var SCHEMA = hideFromPlanner(
6622
- forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }))
6623
- );
6775
+ function schemaFor(prefs) {
6776
+ return hideFromPlanner(
6777
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
6778
+ plannerInvisible(prefs)
6779
+ );
6780
+ }
6781
+ var SCHEMA = schemaFor({ genre: "general" });
6624
6782
  async function codexPlanner(source, opts = {}) {
6625
6783
  const prefs = opts.prefs ?? prefsSchema.parse({});
6626
6784
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
6627
6785
  try {
6628
6786
  const schemaPath = join3(dir, "storyboard.schema.json");
6629
6787
  const outPath = join3(dir, "storyboard.json");
6630
- await writeFile3(schemaPath, JSON.stringify(SCHEMA));
6788
+ await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
6631
6789
  await (opts.run ?? runCodex)({
6632
6790
  prompt: buildPrompt(source, prefs),
6633
6791
  schemaPath,
@@ -7692,6 +7850,7 @@ function prefsFromFlags(flags2) {
7692
7850
  if (flags2.lang !== void 0) patch.lang = flags2.lang;
7693
7851
  if (flags2.tone !== void 0) patch.tone = flags2.tone;
7694
7852
  if (flags2.density !== void 0) patch.density = flags2.density;
7853
+ if (flags2.genre !== void 0) patch.genre = flags2.genre;
7695
7854
  if (flags2.duration !== void 0) patch.duration = number("--duration", flags2.duration);
7696
7855
  if (flags2.theme !== void 0) patch.theme = flags2.theme;
7697
7856
  if (flags2.speed !== void 0) patch.animationSpeed = number("--speed", flags2.speed);
@@ -8443,13 +8602,14 @@ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
8443
8602
  out
8444
8603
  ];
8445
8604
  }
8605
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
8446
8606
  function audioGraph(inputs, seconds, first = 1) {
8447
8607
  const lines = inputs.map(
8448
8608
  (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
8449
8609
  );
8450
8610
  const labels = inputs.map((_, i) => `[d${i}]`).join("");
8451
8611
  lines.push(
8452
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,apad=whole_dur=${seconds.toFixed(3)}[aout]`
8612
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
8453
8613
  );
8454
8614
  return lines.join(";\n");
8455
8615
  }
@@ -9998,24 +10158,25 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
9998
10158
  check2(dir, { ...opts, at: stops.map((s) => s.t) }),
9999
10159
  opts.fidelity === false ? null : fidelity(dir, { stops })
10000
10160
  ]);
10001
- const seen = [...ours, ...frames2?.findings ?? []];
10161
+ const storyboardFindings = storyboard ? [
10162
+ ...scanDiagrammatic(storyboard),
10163
+ ...scanHeadlines(storyboard),
10164
+ ...scanRepeatedObject(storyboard),
10165
+ // Needs the source as well, and says nothing without it — the same
10166
+ // silence `scanNarrationLead` keeps when its manifest is missing.
10167
+ ...source ? scanUnusedFigures(storyboard, source) : []
10168
+ ] : [];
10169
+ const seen = [...ours, ...frames2?.findings ?? [], ...storyboardFindings];
10002
10170
  return {
10003
10171
  passed: verdict.passed && seen.every((f) => f.severity !== "error"),
10004
10172
  findings: [
10005
10173
  ...seen,
10006
- // `scanBeatCount` is deliberately NOT here. It needs the preferences the
10007
- // deck was asked for, and `verify <dir>` is handed a built directory and
10008
- // nothing else — the same gating `scanNarrationLead` gets above, and for
10009
- // the same reason: a check that cannot see its inputs must not report that
10010
- // it found nothing. It runs at `plan` and `build`, where prefs exist.
10011
- ...storyboard ? [
10012
- ...scanDiagrammatic(storyboard),
10013
- ...scanHeadlines(storyboard),
10014
- ...scanRepeatedObject(storyboard),
10015
- // Needs the source as well, and says nothing without it — the same
10016
- // silence `scanNarrationLead` keeps when its manifest is missing.
10017
- ...source ? scanUnusedFigures(storyboard, source) : []
10018
- ] : [],
10174
+ // `scanBeatCount` and `scanPaperArc` are deliberately NOT here. Both need
10175
+ // the preferences the deck was asked for, and `verify <dir>` is handed a
10176
+ // built directory and nothing else — the same gating `scanNarrationLead`
10177
+ // gets above, and for the same reason: a check that cannot see its inputs
10178
+ // must not report that it found nothing. They run at `plan` and `build`,
10179
+ // where prefs exist.
10019
10180
  ...verdict.findings
10020
10181
  ]
10021
10182
  };
@@ -10200,6 +10361,32 @@ function scanBeatCount(storyboard, prefs) {
10200
10361
  }
10201
10362
  ];
10202
10363
  }
10364
+ function scanPaperArc(storyboard, prefs) {
10365
+ return arcProblems(storyboard, prefs).map((message) => ({
10366
+ severity: "warning",
10367
+ gate: "storyboard",
10368
+ rule: "paper_arc",
10369
+ message
10370
+ }));
10371
+ }
10372
+ function scanNarrationDrift(storyboard, narration) {
10373
+ const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
10374
+ const stale = [];
10375
+ for (const beat of storyboard.beats) {
10376
+ const segments = narration.beats[beat.id];
10377
+ if (!segments?.length || !flat(beat.narration)) continue;
10378
+ if (flat(segments.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
10379
+ }
10380
+ if (!stale.length) return [];
10381
+ return [
10382
+ {
10383
+ severity: "error",
10384
+ gate: "storyboard",
10385
+ rule: "narration_drift",
10386
+ message: `The recorded narration does not say what ${stale.length} beat(s) say they say: ${stale.join(", ")}. narration.json is keyed by beat id and carries no link to the plan it was made for, so a storyboard whose beats were renumbered or rewritten keeps matching ids and speaks the wrong slide. Re-run \`decksmith narrate\` for this storyboard \u2014 the audio cache is keyed by TEXT, so lines that did not change are not re-synthesised.`
10387
+ }
10388
+ ];
10389
+ }
10203
10390
  var LEAD_SECONDS = 1;
10204
10391
  var EMPHASISED = /* @__PURE__ */ new Set(["equation-walk", "data-table"]);
10205
10392
  function scanNarrationLead(beats, timing) {
@@ -10379,7 +10566,10 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
10379
10566
  )}
10380
10567
  `;
10381
10568
  function planFlags(cmd) {
10382
- return cmd.option("--lang <bcp47>", "language of the deck's copy").option("--tone <tone>", "plain | academic | conversational | punchy").option("--density <level>", "sparse | normal | dense");
10569
+ return cmd.option("--lang <bcp47>", "language of the deck's copy").option("--tone <tone>", "plain | academic | conversational | punchy").option("--density <level>", "sparse | normal | dense").option(
10570
+ "--genre <genre>",
10571
+ "general | paper \u2014 paper asks for an intro, background, limitations and conclusion"
10572
+ );
10383
10573
  }
10384
10574
  function lengthFlags(cmd) {
10385
10575
  return cmd.option("--duration <s>", "target length of the finished video, in seconds (10\u20131800)").option("--slides <n>", "target beat count (3\u201340)").option(
@@ -10406,6 +10596,7 @@ function flags(o) {
10406
10596
  "lang",
10407
10597
  "tone",
10408
10598
  "density",
10599
+ "genre",
10409
10600
  "duration",
10410
10601
  "narrationDensity",
10411
10602
  "theme",
@@ -10475,6 +10666,11 @@ imageFlags(
10475
10666
  }
10476
10667
  for (const f of [
10477
10668
  ...scanBeatCount(storyboard, prefs),
10669
+ // Silent unless `--genre paper` was declared. Here for the same reason
10670
+ // `scanBeatCount` is: the fix is a beat in a file the author has open, and
10671
+ // this is the last moment before a minute of TTS is spent on a deck that
10672
+ // does not end where it was asked to.
10673
+ ...scanPaperArc(storyboard, prefs),
10478
10674
  ...scanHeadlines(storyboard),
10479
10675
  ...scanRepeatedObject(storyboard),
10480
10676
  // A figure the plan ignored is cheapest to fix here, where the answer is one
@@ -10548,7 +10744,7 @@ voiceFlags(
10548
10744
  });
10549
10745
  lookFlags(
10550
10746
  sizeFlags(
10551
- lengthFlags(program.command("build")).description("Emit the composition, write its assets, and run the gates.").argument("<storyboard>", "storyboard.json, edited to taste").requiredOption("--source <file>", "source.json the storyboard was planned from").requiredOption("-o, --out <dir>", "directory to write the deck into").option("--format <id>", `output profile: ${Object.keys(FORMATS).join(" | ")}`, "deck-16x9").option("--min-weight <n>", "keep only beats at or above this weight \u2014 see the budget gate").option("--narration <file>", `${NARRATION_FILE} from \`decksmith narrate\``).option("--no-narration", "ignore narration sitting beside the storyboard").option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser")
10747
+ lengthFlags(program.command("build")).description("Emit the composition, write its assets, and run the gates.").argument("<storyboard>", "storyboard.json, edited to taste").requiredOption("--source <file>", "source.json the storyboard was planned from").requiredOption("-o, --out <dir>", "directory to write the deck into").option("--format <id>", `output profile: ${Object.keys(FORMATS).join(" | ")}`, "deck-16x9").option("--min-weight <n>", "keep only beats at or above this weight \u2014 see the budget gate").option("--genre <genre>", "general | paper \u2014 report when a paper deck lacks its arc").option("--narration <file>", `${NARRATION_FILE} from \`decksmith narrate\``).option("--no-narration", "ignore narration sitting beside the storyboard").option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser")
10552
10748
  )
10553
10749
  ).action(
10554
10750
  async (sbPath, o) => {
@@ -10562,6 +10758,10 @@ lookFlags(
10562
10758
  await mkdir9(out, { recursive: true });
10563
10759
  const found = await findNarration(sbPath, o.narration);
10564
10760
  const narration = found ? await loadNarration(found) : void 0;
10761
+ if (narration) {
10762
+ const drift2 = scanNarrationDrift(storyboard, narration);
10763
+ if (drift2.length > 0) throw new Error(drift2[0]?.message ?? "narration drift");
10764
+ }
10565
10765
  if (narration && !format.navigable) {
10566
10766
  step(`build: ${format.id} renders linearly, so its narration is timing only`);
10567
10767
  }
@@ -10612,6 +10812,8 @@ lookFlags(
10612
10812
  step(
10613
10813
  `build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join15(out, "index.html")}`
10614
10814
  );
10815
+ for (const f of scanPaperArc({ ...storyboard, beats: cut.kept }, prefs))
10816
+ step(`build: ${f.message}`);
10615
10817
  reportCut(cut);
10616
10818
  if (deck.page) step(`build: navigable deck \u2192 ${join15(out, DECK_PAGE)}`);
10617
10819
  await gate(out, false, storyboard, cut.kept, o.fidelity !== false, source);