@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/index.js CHANGED
@@ -290,10 +290,16 @@ var insideSchema = z.object({
290
290
  */
291
291
  label: z.string().optional()
292
292
  });
293
+ var beatRoleSchema = z.enum(["intro", "background", "limitations", "conclusion"]);
293
294
  var beatCore = {
294
295
  id: z.string(),
295
296
  /** What the viewer should understand after this beat. */
296
297
  intent: z.string(),
298
+ /**
299
+ * OPTIONAL, and only ever present when `prefs.genre` is `paper`. The
300
+ * structural job this beat does; see `beatRoleSchema`.
301
+ */
302
+ role: beatRoleSchema.optional(),
297
303
  /** Optional: this beat happens inside a named part of the beat before it. */
298
304
  inside: insideSchema.optional(),
299
305
  /** The source sentence or equation this beat is accountable to. */
@@ -440,6 +446,26 @@ var prefsSchema = z.object({
440
446
  tone: z.enum(["plain", "academic", "conversational", "punchy"]).default("plain"),
441
447
  /** How much text a slide may carry before it should have been a diagram. */
442
448
  density: z.enum(["sparse", "normal", "dense"]).default("normal"),
449
+ /**
450
+ * What kind of document is being explained, DECLARED and never sniffed.
451
+ *
452
+ * `paper` asks the planner for the shape a research talk has: open on the
453
+ * problem and the ground the work stands on, close on what it does not do and
454
+ * then what to take away. `general` is every deck built before this existed
455
+ * and changes nothing — no prompt block, no `role` in the planner's schema, no
456
+ * scan.
457
+ *
458
+ * WHY DECLARED. A ten-role heading lexicon (en/ko/ja/zh, numbered-prefix
459
+ * tolerant) run over all 351 markdown files in this repository scored 345 of
460
+ * them at zero role hits and none at three or more. `src/source/markdown.ts`
461
+ * says why in its first line: the input is a hypepaper-style ANALYSIS of a
462
+ * paper, a rewrite that has already discarded the headings a detector would
463
+ * key on. A classifier here would be a guess with a confidence score attached,
464
+ * and it would guess wrong on the Korean fixture. So the author says so once —
465
+ * `--genre paper`, or one line in a `decksmith.config.json` above a directory
466
+ * of papers — and every run under it costs no further typing.
467
+ */
468
+ genre: z.enum(["general", "paper"]).default("general"),
443
469
  /**
444
470
  * How long the finished thing should run, in seconds. Optional: absent means
445
471
  * "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" });
@@ -6135,6 +6176,76 @@ import { tmpdir } from "node:os";
6135
6176
  import { join as join3 } from "node:path";
6136
6177
  import { z as z2 } from "zod";
6137
6178
 
6179
+ // src/plan/arc.ts
6180
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
6181
+ function requiredRoles(beatCount) {
6182
+ if (beatCount >= 8) return ARC_ROLES;
6183
+ if (beatCount >= 5) return ["limitations", "conclusion"];
6184
+ return [];
6185
+ }
6186
+ function paperArcRequested(prefs) {
6187
+ return prefs.genre === "paper";
6188
+ }
6189
+ function arcBeats(storyboard) {
6190
+ const by = /* @__PURE__ */ new Map();
6191
+ for (const beat of storyboard.beats) {
6192
+ if (!beat.role) continue;
6193
+ by.set(beat.role, [...by.get(beat.role) ?? [], beat]);
6194
+ }
6195
+ return by;
6196
+ }
6197
+ function arcProblems(storyboard, prefs) {
6198
+ if (!paperArcRequested(prefs)) return [];
6199
+ const beats = storyboard.beats;
6200
+ const need = requiredRoles(Math.min(prefs.slides, beats.length));
6201
+ if (!need.length) return [];
6202
+ const out = [];
6203
+ const by = arcBeats(storyboard);
6204
+ for (const role of need) {
6205
+ const held = by.get(role) ?? [];
6206
+ if (held.length === 0) {
6207
+ out.push(
6208
+ `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.`
6209
+ );
6210
+ continue;
6211
+ }
6212
+ if (held.length > 1) {
6213
+ out.push(
6214
+ `${held.length} beats carry role "${role}" (${held.map((b) => b.id).join(", ")}). A structural job belongs to one slide.`
6215
+ );
6216
+ }
6217
+ }
6218
+ const last = beats[beats.length - 1];
6219
+ if (need.includes("conclusion") && by.has("conclusion") && last?.role !== "conclusion") {
6220
+ out.push(
6221
+ `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.`
6222
+ );
6223
+ }
6224
+ const limIdx = beats.findIndex((b) => b.role === "limitations");
6225
+ const conIdx = beats.findIndex((b) => b.role === "conclusion");
6226
+ if (need.includes("limitations") && limIdx >= 0 && conIdx >= 0 && limIdx !== conIdx - 1) {
6227
+ out.push(
6228
+ `The limitations beat is not the slide immediately before the conclusion. The two are a pair and the caveat comes first.`
6229
+ );
6230
+ }
6231
+ const openingWindow = beats.slice(0, 3).map((b) => b.role);
6232
+ for (const role of need.filter((r) => r === "intro" || r === "background")) {
6233
+ if (by.has(role) && !openingWindow.includes(role)) {
6234
+ out.push(
6235
+ `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.`
6236
+ );
6237
+ }
6238
+ }
6239
+ const lim = by.get("limitations")?.[0];
6240
+ const con = by.get("conclusion")?.[0];
6241
+ if (lim && con && lim.archetype === con.archetype) {
6242
+ out.push(
6243
+ `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.`
6244
+ );
6245
+ }
6246
+ return out;
6247
+ }
6248
+
6138
6249
  // src/plan/duration.ts
6139
6250
  var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
6140
6251
  var LAST_HOLD_SECONDS = 4.2;
@@ -6724,6 +6835,46 @@ ${REVEAL_COUNTS}` : ` - ${n3 === 1 ? "ONE SENTENCE" : `${n3} SENTENCES`} FOR TH
6724
6835
  miss its duration.`;
6725
6836
  return { sentences, length };
6726
6837
  }
6838
+ function paperArc(slides) {
6839
+ const asked = requiredRoles(slides);
6840
+ const full = asked.includes("intro");
6841
+ return `
6842
+
6843
+ PAPER ARC \u2014 this source was declared a research paper.
6844
+
6845
+ Four beats have a structural job, and each one NAMES its job in \`role\`. Every
6846
+ other beat leaves \`role\` off. A role is a job, not a heading: never write
6847
+ "Related work" or "Conclusion" as a headline, because RULE 8 still applies to
6848
+ all four.
6849
+ ${full ? `
6850
+ role: "intro" Near the front. What problem exists and who has it, in
6851
+ the viewer's own terms. This is the opening RULE 6
6852
+ already asks for, named so the deck can be checked.
6853
+ role: "background" In the first three beats. What people did before this
6854
+ work, and where that ran out. Take it from what the
6855
+ source itself says about earlier approaches \u2014 if the
6856
+ source says nothing about them, leave the role off
6857
+ rather than inventing a literature (RULE 3).` : `
6858
+ This deck is short, so only the ENDING is required \u2014 an opening the deck
6859
+ already has is not worth a slide of its own here.`}
6860
+ role: "limitations" THE SECOND-TO-LAST beat. What the work does not do, in
6861
+ the source's own admission. Not a hedge inside another
6862
+ beat's sentence: its own slide.
6863
+ role: "conclusion" THE LAST beat, with nothing after it. What the viewer
6864
+ should carry away.
6865
+
6866
+ - The closing pair is TWO beats and they must not share an archetype (RULE 1).
6867
+ A limitation the source admits to is usually a panel; the conclusion is the
6868
+ claim the deck lands, so draw it where the source states it \u2014 bars, a
6869
+ contrast, the figure that settles it \u2014 and fall back to a panel only when it
6870
+ genuinely has no shape.
6871
+ - Give all four a weight of 0.8 or above. A short cut keeps the
6872
+ highest-weighted beats, and a structural beat below 0.8 is one the deck
6873
+ loses at the first budget.
6874
+ - If the source does not support one of these, LEAVE IT OUT. A slide that
6875
+ admits a limitation the paper never admits is worse than no slide.
6876
+ `;
6877
+ }
6727
6878
  function illustrations(images) {
6728
6879
  return `
6729
6880
 
@@ -6752,7 +6903,7 @@ nothing, so it can never dangle.
6752
6903
  }
6753
6904
  function systemPrompt(prefs) {
6754
6905
  const plan = durationPlan(prefs);
6755
- return `${rules(cadenceFor(prefs, plan))}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6906
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6756
6907
 
6757
6908
  PREFERENCES \u2014 chosen by the person who asked for this deck.
6758
6909
  ${prefs.duration === void 0 ? "" : `
@@ -7063,8 +7214,11 @@ function stripNulls(node) {
7063
7214
  return out;
7064
7215
  }
7065
7216
  var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
7066
- function hideFromPlanner(node) {
7067
- if (Array.isArray(node)) return node.map(hideFromPlanner);
7217
+ function plannerInvisible(prefs) {
7218
+ return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
7219
+ }
7220
+ function hideFromPlanner(node, hidden) {
7221
+ if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
7068
7222
  if (node === null || typeof node !== "object") return node;
7069
7223
  const src = node;
7070
7224
  const out = {};
@@ -7072,28 +7226,32 @@ function hideFromPlanner(node) {
7072
7226
  if (key === "properties" && value && typeof value === "object") {
7073
7227
  const kept = {};
7074
7228
  for (const [prop, sub] of Object.entries(value))
7075
- if (!PLANNER_INVISIBLE.has(prop)) kept[prop] = hideFromPlanner(sub);
7229
+ if (!hidden.has(prop)) kept[prop] = hideFromPlanner(sub, hidden);
7076
7230
  out.properties = kept;
7077
7231
  continue;
7078
7232
  }
7079
7233
  if (key === "required" && Array.isArray(value)) {
7080
- out.required = value.filter((r) => typeof r !== "string" || !PLANNER_INVISIBLE.has(r));
7234
+ out.required = value.filter((r) => typeof r !== "string" || !hidden.has(r));
7081
7235
  continue;
7082
7236
  }
7083
- out[key] = hideFromPlanner(value);
7237
+ out[key] = hideFromPlanner(value, hidden);
7084
7238
  }
7085
7239
  return out;
7086
7240
  }
7087
- var SCHEMA = hideFromPlanner(
7088
- forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }))
7089
- );
7241
+ function schemaFor(prefs) {
7242
+ return hideFromPlanner(
7243
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
7244
+ plannerInvisible(prefs)
7245
+ );
7246
+ }
7247
+ var SCHEMA = schemaFor({ genre: "general" });
7090
7248
  async function codexPlanner(source, opts = {}) {
7091
7249
  const prefs = opts.prefs ?? prefsSchema.parse({});
7092
7250
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
7093
7251
  try {
7094
7252
  const schemaPath = join3(dir, "storyboard.schema.json");
7095
7253
  const outPath = join3(dir, "storyboard.json");
7096
- await writeFile3(schemaPath, JSON.stringify(SCHEMA));
7254
+ await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
7097
7255
  await (opts.run ?? runCodex)({
7098
7256
  prompt: buildPrompt(source, prefs),
7099
7257
  schemaPath,
@@ -9127,24 +9285,25 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
9127
9285
  check(dir, { ...opts, at: stops.map((s) => s.t) }),
9128
9286
  opts.fidelity === false ? null : fidelity(dir, { stops })
9129
9287
  ]);
9130
- const seen = [...ours, ...frames2?.findings ?? []];
9288
+ const storyboardFindings = storyboard ? [
9289
+ ...scanDiagrammatic(storyboard),
9290
+ ...scanHeadlines(storyboard),
9291
+ ...scanRepeatedObject(storyboard),
9292
+ // Needs the source as well, and says nothing without it — the same
9293
+ // silence `scanNarrationLead` keeps when its manifest is missing.
9294
+ ...source ? scanUnusedFigures(storyboard, source) : []
9295
+ ] : [];
9296
+ const seen = [...ours, ...frames2?.findings ?? [], ...storyboardFindings];
9131
9297
  return {
9132
9298
  passed: verdict.passed && seen.every((f) => f.severity !== "error"),
9133
9299
  findings: [
9134
9300
  ...seen,
9135
- // `scanBeatCount` is deliberately NOT here. It needs the preferences the
9136
- // deck was asked for, and `verify <dir>` is handed a built directory and
9137
- // nothing else — the same gating `scanNarrationLead` gets above, and for
9138
- // the same reason: a check that cannot see its inputs must not report that
9139
- // it found nothing. It runs at `plan` and `build`, where prefs exist.
9140
- ...storyboard ? [
9141
- ...scanDiagrammatic(storyboard),
9142
- ...scanHeadlines(storyboard),
9143
- ...scanRepeatedObject(storyboard),
9144
- // Needs the source as well, and says nothing without it — the same
9145
- // silence `scanNarrationLead` keeps when its manifest is missing.
9146
- ...source ? scanUnusedFigures(storyboard, source) : []
9147
- ] : [],
9301
+ // `scanBeatCount` and `scanPaperArc` are deliberately NOT here. Both need
9302
+ // the preferences the deck was asked for, and `verify <dir>` is handed a
9303
+ // built directory and nothing else — the same gating `scanNarrationLead`
9304
+ // gets above, and for the same reason: a check that cannot see its inputs
9305
+ // must not report that it found nothing. They run at `plan` and `build`,
9306
+ // where prefs exist.
9148
9307
  ...verdict.findings
9149
9308
  ]
9150
9309
  };
@@ -9329,6 +9488,32 @@ function scanBeatCount(storyboard, prefs) {
9329
9488
  }
9330
9489
  ];
9331
9490
  }
9491
+ function scanPaperArc(storyboard, prefs) {
9492
+ return arcProblems(storyboard, prefs).map((message) => ({
9493
+ severity: "warning",
9494
+ gate: "storyboard",
9495
+ rule: "paper_arc",
9496
+ message
9497
+ }));
9498
+ }
9499
+ function scanNarrationDrift(storyboard, narration) {
9500
+ const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
9501
+ const stale = [];
9502
+ for (const beat of storyboard.beats) {
9503
+ const segments = narration.beats[beat.id];
9504
+ if (!segments?.length || !flat(beat.narration)) continue;
9505
+ if (flat(segments.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
9506
+ }
9507
+ if (!stale.length) return [];
9508
+ return [
9509
+ {
9510
+ severity: "error",
9511
+ gate: "storyboard",
9512
+ rule: "narration_drift",
9513
+ 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.`
9514
+ }
9515
+ ];
9516
+ }
9332
9517
  var LEAD_SECONDS = 1;
9333
9518
  var EMPHASISED = /* @__PURE__ */ new Set(["equation-walk", "data-table"]);
9334
9519
  function scanNarrationLead(beats, timing) {
@@ -9712,13 +9897,14 @@ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
9712
9897
  out
9713
9898
  ];
9714
9899
  }
9900
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
9715
9901
  function audioGraph(inputs, seconds, first = 1) {
9716
9902
  const lines = inputs.map(
9717
9903
  (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
9718
9904
  );
9719
9905
  const labels = inputs.map((_, i) => `[d${i}]`).join("");
9720
9906
  lines.push(
9721
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,apad=whole_dur=${seconds.toFixed(3)}[aout]`
9907
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
9722
9908
  );
9723
9909
  return lines.join(";\n");
9724
9910
  }
@@ -10451,6 +10637,7 @@ export {
10451
10637
  assertInsideResolves,
10452
10638
  assertRefsResolve,
10453
10639
  barCompareParamsSchema,
10640
+ beatRoleSchema,
10454
10641
  beatSchema,
10455
10642
  buildDeck,
10456
10643
  bundleFont,
@@ -10517,7 +10704,9 @@ export {
10517
10704
  sampleTimes,
10518
10705
  scanBeatCount,
10519
10706
  scanHeadlines,
10707
+ scanNarrationDrift,
10520
10708
  scanNarrationLead,
10709
+ scanPaperArc,
10521
10710
  scanRepeatedObject,
10522
10711
  scanUnusedFigures,
10523
10712
  sectionSchema,
package/dist/mcp.js CHANGED
@@ -310,10 +310,16 @@ var insideSchema = z.object({
310
310
  */
311
311
  label: z.string().optional()
312
312
  });
313
+ var beatRoleSchema = z.enum(["intro", "background", "limitations", "conclusion"]);
313
314
  var beatCore = {
314
315
  id: z.string(),
315
316
  /** What the viewer should understand after this beat. */
316
317
  intent: z.string(),
318
+ /**
319
+ * OPTIONAL, and only ever present when `prefs.genre` is `paper`. The
320
+ * structural job this beat does; see `beatRoleSchema`.
321
+ */
322
+ role: beatRoleSchema.optional(),
317
323
  /** Optional: this beat happens inside a named part of the beat before it. */
318
324
  inside: insideSchema.optional(),
319
325
  /** The source sentence or equation this beat is accountable to. */
@@ -448,6 +454,26 @@ var prefsSchema = z.object({
448
454
  tone: z.enum(["plain", "academic", "conversational", "punchy"]).default("plain"),
449
455
  /** How much text a slide may carry before it should have been a diagram. */
450
456
  density: z.enum(["sparse", "normal", "dense"]).default("normal"),
457
+ /**
458
+ * What kind of document is being explained, DECLARED and never sniffed.
459
+ *
460
+ * `paper` asks the planner for the shape a research talk has: open on the
461
+ * problem and the ground the work stands on, close on what it does not do and
462
+ * then what to take away. `general` is every deck built before this existed
463
+ * and changes nothing — no prompt block, no `role` in the planner's schema, no
464
+ * scan.
465
+ *
466
+ * WHY DECLARED. A ten-role heading lexicon (en/ko/ja/zh, numbered-prefix
467
+ * tolerant) run over all 351 markdown files in this repository scored 345 of
468
+ * them at zero role hits and none at three or more. `src/source/markdown.ts`
469
+ * says why in its first line: the input is a hypepaper-style ANALYSIS of a
470
+ * paper, a rewrite that has already discarded the headings a detector would
471
+ * key on. A classifier here would be a guess with a confidence score attached,
472
+ * and it would guess wrong on the Korean fixture. So the author says so once —
473
+ * `--genre paper`, or one line in a `decksmith.config.json` above a directory
474
+ * of papers — and every run under it costs no further typing.
475
+ */
476
+ genre: z.enum(["general", "paper"]).default("general"),
451
477
  /**
452
478
  * How long the finished thing should run, in seconds. Optional: absent means
453
479
  * "as long as it takes", which is what every deck built before this did.
@@ -705,7 +731,10 @@ function selectBeats(storyboard, budget2, seconds = {}) {
705
731
  const protectedIds = protect(live, len, cap);
706
732
  const keep = knapsack(live, len, cap, protectedIds);
707
733
  if (!keep) {
708
- const all = knapsack(live, len, cap, /* @__PURE__ */ new Set());
734
+ const ends = new Set(
735
+ [live[0]?.id, live[live.length - 1]?.id].filter((id2) => !!id2)
736
+ );
737
+ const all = knapsack(live, len, cap, ends) ?? knapsack(live, len, cap, /* @__PURE__ */ new Set());
709
738
  const chosen = all ?? [live[0]];
710
739
  return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, false);
711
740
  }
@@ -737,8 +766,17 @@ function protect(live, len, cap) {
737
766
  }
738
767
  const picture = cheapest && !ids.has(cheapest.id) ? [cheapest] : [];
739
768
  for (const b of picture) ids.add(b.id);
769
+ const byId = new Map(live.map((b) => [b.id, b]));
770
+ const roled = [];
771
+ for (const b of live) {
772
+ if (!b.role) continue;
773
+ for (let hop = b; hop && !ids.has(hop.id); hop = byId.get(hop.inside?.beat ?? "")) {
774
+ ids.add(hop.id);
775
+ roled.push(hop);
776
+ }
777
+ }
740
778
  const ends = /* @__PURE__ */ new Set([first?.id, last?.id]);
741
- const releasable = [...tier, ...coverage, ...picture].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
779
+ const releasable = [...tier, ...coverage, ...picture, ...roled].filter((b) => !ends.has(b.id)).sort((a, b) => rate(a) - rate(b) || len(b) - len(a));
742
780
  const cost = () => live.filter((b) => ids.has(b.id)).reduce((s, b) => s + len(b), 0);
743
781
  for (const b of releasable) {
744
782
  if (cost() <= cap) break;
@@ -4719,6 +4757,9 @@ function depthCss(sid, pose, part) {
4719
4757
  // src/emit/archetypes/stack.ts
4720
4758
  var GAP3 = 44;
4721
4759
  var NUM_X = 48;
4760
+ function numSpine(floor) {
4761
+ return Math.round(NUM_X * floor * 100 / MIN_FONT) / 100;
4762
+ }
4722
4763
  var PROBE_X = 16;
4723
4764
  var PROBE_H = 46;
4724
4765
  var NUM_W = 70;
@@ -4901,7 +4942,7 @@ var stack = (beat, ctx) => {
4901
4942
  );
4902
4943
  const num2 = text(
4903
4944
  String(i + 1),
4904
- { x: NUM_X, y: mid },
4945
+ { x: numSpine(L.floor), y: mid },
4905
4946
  { size: L.floor, weight: 600, fill: theme.dim, anchor: "end", vAlign: "middle" }
4906
4947
  );
4907
4948
  return group(slab(L.x0, y0, L, tint, lift2, stroke), { id: id(sid, "lay", i), class: "lay" }) + group(num2 + leader + dot + label + note, { id: id(sid, "cap", i), class: "cap" });
@@ -6134,6 +6175,17 @@ import { tmpdir } from "node:os";
6134
6175
  import { join as join3 } from "node:path";
6135
6176
  import { z as z2 } from "zod";
6136
6177
 
6178
+ // src/plan/arc.ts
6179
+ var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
6180
+ function requiredRoles(beatCount) {
6181
+ if (beatCount >= 8) return ARC_ROLES;
6182
+ if (beatCount >= 5) return ["limitations", "conclusion"];
6183
+ return [];
6184
+ }
6185
+ function paperArcRequested(prefs) {
6186
+ return prefs.genre === "paper";
6187
+ }
6188
+
6137
6189
  // src/plan/duration.ts
6138
6190
  var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
6139
6191
  var LAST_HOLD_SECONDS = 4.2;
@@ -6723,6 +6775,46 @@ ${REVEAL_COUNTS}` : ` - ${n3 === 1 ? "ONE SENTENCE" : `${n3} SENTENCES`} FOR TH
6723
6775
  miss its duration.`;
6724
6776
  return { sentences, length };
6725
6777
  }
6778
+ function paperArc(slides) {
6779
+ const asked = requiredRoles(slides);
6780
+ const full = asked.includes("intro");
6781
+ return `
6782
+
6783
+ PAPER ARC \u2014 this source was declared a research paper.
6784
+
6785
+ Four beats have a structural job, and each one NAMES its job in \`role\`. Every
6786
+ other beat leaves \`role\` off. A role is a job, not a heading: never write
6787
+ "Related work" or "Conclusion" as a headline, because RULE 8 still applies to
6788
+ all four.
6789
+ ${full ? `
6790
+ role: "intro" Near the front. What problem exists and who has it, in
6791
+ the viewer's own terms. This is the opening RULE 6
6792
+ already asks for, named so the deck can be checked.
6793
+ role: "background" In the first three beats. What people did before this
6794
+ work, and where that ran out. Take it from what the
6795
+ source itself says about earlier approaches \u2014 if the
6796
+ source says nothing about them, leave the role off
6797
+ rather than inventing a literature (RULE 3).` : `
6798
+ This deck is short, so only the ENDING is required \u2014 an opening the deck
6799
+ already has is not worth a slide of its own here.`}
6800
+ role: "limitations" THE SECOND-TO-LAST beat. What the work does not do, in
6801
+ the source's own admission. Not a hedge inside another
6802
+ beat's sentence: its own slide.
6803
+ role: "conclusion" THE LAST beat, with nothing after it. What the viewer
6804
+ should carry away.
6805
+
6806
+ - The closing pair is TWO beats and they must not share an archetype (RULE 1).
6807
+ A limitation the source admits to is usually a panel; the conclusion is the
6808
+ claim the deck lands, so draw it where the source states it \u2014 bars, a
6809
+ contrast, the figure that settles it \u2014 and fall back to a panel only when it
6810
+ genuinely has no shape.
6811
+ - Give all four a weight of 0.8 or above. A short cut keeps the
6812
+ highest-weighted beats, and a structural beat below 0.8 is one the deck
6813
+ loses at the first budget.
6814
+ - If the source does not support one of these, LEAVE IT OUT. A slide that
6815
+ admits a limitation the paper never admits is worse than no slide.
6816
+ `;
6817
+ }
6726
6818
  function illustrations(images) {
6727
6819
  return `
6728
6820
 
@@ -6751,7 +6843,7 @@ nothing, so it can never dangle.
6751
6843
  }
6752
6844
  function systemPrompt(prefs) {
6753
6845
  const plan = durationPlan(prefs);
6754
- return `${rules(cadenceFor(prefs, plan))}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6846
+ return `${rules(cadenceFor(prefs, plan))}${paperArcRequested(prefs) ? paperArc(prefs.slides) : ""}${prefs.images.enabled ? illustrations(prefs.images) : ""}
6755
6847
 
6756
6848
  PREFERENCES \u2014 chosen by the person who asked for this deck.
6757
6849
  ${prefs.duration === void 0 ? "" : `
@@ -7020,8 +7112,11 @@ function stripNulls(node) {
7020
7112
  return out;
7021
7113
  }
7022
7114
  var PLANNER_INVISIBLE = /* @__PURE__ */ new Set(["tilt"]);
7023
- function hideFromPlanner(node) {
7024
- if (Array.isArray(node)) return node.map(hideFromPlanner);
7115
+ function plannerInvisible(prefs) {
7116
+ return paperArcRequested(prefs) ? PLANNER_INVISIBLE : /* @__PURE__ */ new Set([...PLANNER_INVISIBLE, "role"]);
7117
+ }
7118
+ function hideFromPlanner(node, hidden) {
7119
+ if (Array.isArray(node)) return node.map((n3) => hideFromPlanner(n3, hidden));
7025
7120
  if (node === null || typeof node !== "object") return node;
7026
7121
  const src = node;
7027
7122
  const out = {};
@@ -7029,28 +7124,32 @@ function hideFromPlanner(node) {
7029
7124
  if (key === "properties" && value && typeof value === "object") {
7030
7125
  const kept = {};
7031
7126
  for (const [prop, sub] of Object.entries(value))
7032
- if (!PLANNER_INVISIBLE.has(prop)) kept[prop] = hideFromPlanner(sub);
7127
+ if (!hidden.has(prop)) kept[prop] = hideFromPlanner(sub, hidden);
7033
7128
  out.properties = kept;
7034
7129
  continue;
7035
7130
  }
7036
7131
  if (key === "required" && Array.isArray(value)) {
7037
- out.required = value.filter((r) => typeof r !== "string" || !PLANNER_INVISIBLE.has(r));
7132
+ out.required = value.filter((r) => typeof r !== "string" || !hidden.has(r));
7038
7133
  continue;
7039
7134
  }
7040
- out[key] = hideFromPlanner(value);
7135
+ out[key] = hideFromPlanner(value, hidden);
7041
7136
  }
7042
7137
  return out;
7043
7138
  }
7044
- var SCHEMA = hideFromPlanner(
7045
- forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" }))
7046
- );
7139
+ function schemaFor(prefs) {
7140
+ return hideFromPlanner(
7141
+ forStructuredOutput(z2.toJSONSchema(storyboardSchema, { io: "input" })),
7142
+ plannerInvisible(prefs)
7143
+ );
7144
+ }
7145
+ var SCHEMA = schemaFor({ genre: "general" });
7047
7146
  async function codexPlanner(source, opts = {}) {
7048
7147
  const prefs = opts.prefs ?? prefsSchema.parse({});
7049
7148
  const dir = await mkdtemp(join3(tmpdir(), "decksmith-plan-"));
7050
7149
  try {
7051
7150
  const schemaPath = join3(dir, "storyboard.schema.json");
7052
7151
  const outPath = join3(dir, "storyboard.json");
7053
- await writeFile3(schemaPath, JSON.stringify(SCHEMA));
7152
+ await writeFile3(schemaPath, JSON.stringify(schemaFor(prefs)));
7054
7153
  await (opts.run ?? runCodex)({
7055
7154
  prompt: buildPrompt(source, prefs),
7056
7155
  schemaPath,
@@ -8173,13 +8272,14 @@ function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
8173
8272
  out
8174
8273
  ];
8175
8274
  }
8275
+ var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
8176
8276
  function audioGraph(inputs, seconds, first = 1) {
8177
8277
  const lines = inputs.map(
8178
8278
  (input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
8179
8279
  );
8180
8280
  const labels = inputs.map((_, i) => `[d${i}]`).join("");
8181
8281
  lines.push(
8182
- `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,apad=whole_dur=${seconds.toFixed(3)}[aout]`
8282
+ `${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
8183
8283
  );
8184
8284
  return lines.join(";\n");
8185
8285
  }
@@ -8968,6 +9068,7 @@ function parseOptions(fields) {
8968
9068
  if (str(fields.lang) !== void 0) patch.lang = requireLang(str(fields.lang));
8969
9069
  if (str(fields.tone) !== void 0) patch.tone = str(fields.tone);
8970
9070
  if (str(fields.density) !== void 0) patch.density = str(fields.density);
9071
+ if (str(fields.genre) !== void 0) patch.genre = str(fields.genre);
8971
9072
  if (str(fields.duration) !== void 0)
8972
9073
  patch.duration = num("duration", fields.duration);
8973
9074
  if (str(fields.speed) !== void 0) patch.animationSpeed = num("speed", fields.speed);
@@ -9818,6 +9919,9 @@ var settingsSchema = z4.object({
9818
9919
  ),
9819
9920
  tone: z4.enum(["plain", "academic", "conversational", "punchy"]).optional(),
9820
9921
  density: z4.enum(["sparse", "normal", "dense"]).optional().describe("How much text a SLIDE carries. A different axis from narration_density."),
9922
+ genre: z4.enum(["general", "paper"]).optional().describe(
9923
+ "DECLARED, never detected. `paper` asks for a research-talk shape \u2014 an introduction and background at the front, a limitations slide and then a conclusion at the end \u2014 and reports where the deck missed it. There is no detector: the documents this tool ingests are analyses OF papers, rewritten in a way that drops the headings a detector would need, so nothing infers this and absent means `general`."
9924
+ ),
9821
9925
  duration: z4.number().min(10).max(1800).optional().describe(
9822
9926
  "Target seconds. Sets the pace, derives the slide count when you give none, and overrides animation_speed. Call decksmith_estimate_length first \u2014 a short target buys its words by saying less, not by playing faster."
9823
9927
  ),
@@ -9849,6 +9953,7 @@ function fieldsFor(s) {
9849
9953
  put("lang", s.lang);
9850
9954
  put("tone", s.tone);
9851
9955
  put("density", s.density);
9956
+ put("genre", s.genre);
9852
9957
  put("duration", s.duration);
9853
9958
  put("slides", s.slides);
9854
9959
  put("speed", s.animation_speed);
@@ -25,6 +25,8 @@ import type { BeatOf, Format } from "../../types.js";
25
25
  import type { Emitter } from "../kit.js";
26
26
  import { type Face } from "../svg.js";
27
27
  type Params = BeatOf<"stack">["params"];
28
+ /** Where the numerals' right edge lands once the type floor has moved. */
29
+ export declare function numSpine(floor: number): number;
28
30
  export interface StackLayout {
29
31
  /**
30
32
  * The type floor this layout solved against, which is `MIN_FONT` for a flat
@@ -111,7 +111,7 @@ export { verify } from "./verify/index.js";
111
111
  * deck was asked for, which a built directory does not carry — and
112
112
  * `scanUnusedFigures` folds in only where the source was passed alongside.
113
113
  */
114
- export { scanBeatCount, scanHeadlines, scanNarrationLead, scanRepeatedObject, scanUnusedFigures, } from "./verify/index.js";
114
+ export { scanBeatCount, scanNarrationDrift, scanPaperArc, scanHeadlines, scanNarrationLead, scanRepeatedObject, scanUnusedFigures, } from "./verify/index.js";
115
115
  export { check, parseCheckReport, sampleTimes } from "./verify/check.js";
116
116
  export type { CheckOptions } from "./verify/check.js";
117
117
  /**