@jokerized/decksmith 0.4.1 → 0.5.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.
@@ -0,0 +1,102 @@
1
+ import type { DeckNarration } from "../emit/composition.js";
2
+ import type { Source, Storyboard } from "../types.js";
3
+ /** Progress, for a caller that has somewhere to put it. */
4
+ type Step = (message: string) => void;
5
+ /**
6
+ * Where a narrated deck keeps its voice, in the built deck and inside a pack
7
+ * alike. One name in one place: the emitter writes it into the island, `build`
8
+ * copies into it, `pack` stores under it, and `unpack` restores it — and none of
9
+ * them has to agree with the others by memory.
10
+ */
11
+ export declare const AUDIO_DIR = "audio";
12
+ /** The mp3s an island names, deduplicated and in a stable order. */
13
+ export declare function audioNames(narration: {
14
+ beats: Record<string, {
15
+ audio: string;
16
+ }[]>;
17
+ }): string[];
18
+ /**
19
+ * Vendor KaTeX's stylesheet and woff2 fonts beside the deck.
20
+ *
21
+ * The HyperFrames compiler inlines `<script src>` but not `<link rel=stylesheet>`,
22
+ * so a CDN-linked stylesheet is fetched — with its fonts — during capture. That
23
+ * breaks "no network at render time" (invariant 4) and makes equation decks
24
+ * nondeterministic in a way that took four experiments to corner. The frame that
25
+ * finally showed it differed by exactly one glyph, the caligraphic W of
26
+ * `\mathcal{W}`, present in one render and not the other — a font that had
27
+ * arrived by that frame on one run and not on the next.
28
+ *
29
+ * Only woff2 is copied. KaTeX ships woff2, woff and ttf of every family; every
30
+ * browser this deck will ever open in reads woff2, and the other two are three
31
+ * quarters of the payload. The rewrite drops their `url(...)` entries so nothing
32
+ * requests a file that is not there.
33
+ */
34
+ export declare function vendorKatex(out: string): Promise<string[]>;
35
+ /**
36
+ * The scripts a deck runs, copied beside it.
37
+ *
38
+ * Pinned by package.json rather than by a URL, so the version that renders is
39
+ * the version that was installed and tested. See the note on GSAP_SRC in
40
+ * src/emit/composition.ts for why this is not left to the compiler's inliner.
41
+ *
42
+ * WHAT THE HEAD LOADS, AND NOTHING ELSE. `composition` is read rather than
43
+ * `laid.plugins` threaded out of `emit`, because the question this is answering
44
+ * is literally "which of these does the page ask for" — and asking the page
45
+ * cannot drift from the page. It also makes the conditional plugins honest on
46
+ * disk as well as in the head: MorphSVG is 21,195 bytes that a deck without a
47
+ * reshape neither loads NOR carries, which is the rule `PLUGINS` states.
48
+ */
49
+ export declare function vendorScripts(out: string, composition: string): Promise<string[]>;
50
+ /**
51
+ * `assets/` beside source.json, if there is one. Absent is the ordinary case.
52
+ *
53
+ * NAMED FILES ONLY, never the directory.
54
+ *
55
+ * This was `cp(from, ..., { recursive: true })`, which copied whatever happened
56
+ * to be beside the figures. Two costs, one of them a hole: the shipped demo
57
+ * carried 640 KB of JPEGs no beat referenced, and — since a deck is served over
58
+ * HTTP from a directory a stranger's upload contributed to — an `.svg` or
59
+ * `.html` that rode along was served from the deck's own path. The CSP sandbox
60
+ * now covers that, but a file that never arrives needs no containment.
61
+ *
62
+ * `fonts/` is the one directory that comes along, because `refreshFont` writes
63
+ * the subsetted bundle into it and the stylesheet names its own files.
64
+ *
65
+ * A CLIP CONTRIBUTES TWO FILES, and forgetting the second is a 404 at run time
66
+ * rather than a build error: `poster` is what the video shows before its first
67
+ * frame decodes, and what a player-page clip degrades to when there is no
68
+ * downloadable file at all. Named here because this set is the ONLY thing that
69
+ * reaches the built deck — a poster left out is simply absent, and the deck's
70
+ * own runtime reports it as `http_error 404`, a long way from this line.
71
+ */
72
+ export declare function copyAssets(sourceDir: string, out: string, figures: readonly {
73
+ src: string;
74
+ poster?: string;
75
+ }[], step: Step): Promise<string[]>;
76
+ /**
77
+ * Copy the spoken audio into the deck. Only the files the island references: an
78
+ * audio directory is content-addressed and accumulates every take ever made, and
79
+ * shipping the ones a re-edit orphaned would double a deck's size for nothing.
80
+ *
81
+ * `narration.dir`, NOT the `AUDIO_DIR` constant, and the difference is not a
82
+ * preference. `dir` is the string the emitter wrote into the island, so it is
83
+ * what the built page asks the network for; copying to a name the page does not
84
+ * name is a 404 per sentence and silence for the whole deck. `AUDIO_DIR` is
85
+ * where `dir` COMES FROM — `loadNarration` and the server both set it — which is
86
+ * why the two have never disagreed, and exactly why nothing would have caught
87
+ * them if they had.
88
+ */
89
+ export declare function copyAudio(from: string, narration: DeckNarration, out: string, step: Step): Promise<string[]>;
90
+ /**
91
+ * The planner writes headlines the source never contained, so the subset `ingest`
92
+ * cut can be missing glyphs — and a missing glyph falls back silently, which is
93
+ * the failure invariant 9 exists to prevent. Re-cut over the text the deck really
94
+ * renders. The bundle is content-hashed, so this is a no-op when nothing new
95
+ * appeared, and a build that cannot reach the font service keeps what it copied.
96
+ *
97
+ * Every glyph a document or plan can put on screen: serializing the whole object
98
+ * over-collects ids and TeX, but those are ASCII and the bundle only exists for
99
+ * CJK — over-collecting costs a few bytes, under-collecting costs a tofu box.
100
+ */
101
+ export declare function refreshFont(storyboard: Storyboard, source: Source, out: string, step: Step): Promise<string | undefined>;
102
+ export {};
@@ -158,8 +158,10 @@ export interface Voice {
158
158
  * So this split decides what the strip says, and whether anything retries
159
159
  * unasked. It does NOT decide whether a person may retry: a gesture re-arms
160
160
  * either failure (see `unlock`), because someone asking is not the same as us
161
- * guessing. And no gate here can see any of it — nothing in the suite opens
162
- * deck.html or plays a sound.
161
+ * guessing. `test/deck-page.test.ts` now opens deck.html and asserts the strip
162
+ * NAMES an unplayable segment instead of going quiet — it was one of the four
163
+ * regressions that gate was proved against. Nothing still plays a sound, so
164
+ * which of the two failures a real browser hits is untested.
163
165
  *
164
166
  * `NotSupportedError` is the unplayable one — observed rather than taken from
165
167
  * the spec: a source that cannot be fetched at all rejects `play()` with
@@ -109,7 +109,39 @@ export declare function zoomOf(format: Format): number;
109
109
  * afford it.
110
110
  */
111
111
  export declare function contentW(format: Format): number;
112
+ /**
113
+ * REFERENCE PX, WHICH IS WHY THE RESERVE IS CONVERTED HERE. `format.captionReserve`
114
+ * is measured on the CANVAS — `bandReserve` derives it from `burnStyle`'s
115
+ * ratios, which are canvas ratios — and everything on this side of the codebase
116
+ * does its arithmetic in reference px, which `refHeight` has already scaled. At
117
+ * 1080x1920 the canvas-to-reference factor is 2560/1920, so a 301px reserve is
118
+ * 402 reference px; subtracting the canvas number raw would under-reserve by a
119
+ * quarter and put the ink back under the band.
120
+ *
121
+ * `Math.ceil`, not `round`: a fractional px of reserve that gets rounded away is
122
+ * a fractional px of ink inside the band.
123
+ *
124
+ * SUBTRACTING HERE IS NOT ENOUGH ON ITS OWN, and the design note this implements
125
+ * was wrong about that. It claimed every archetype lays out into `contentH`, so
126
+ * that one subtraction would move all of them. Ten of them — `bar-compare`,
127
+ * `callout`, `claim-figure`, `data-table`, `equation-morph`, `equation-walk`,
128
+ * `grid`, `index`, `line-chart`, `pipeline` — never mention `contentH` at all.
129
+ * What actually bounds them is `.scene`'s CSS padding in `theme.ts`, which
130
+ * centres their content in the box the stylesheet leaves. MEASURED: subtracting
131
+ * only here moved the 9x16 demo's worst stop by nothing at all — 0.529% of the
132
+ * frame was ink inside the band both before and after, byte for byte.
133
+ *
134
+ * So the reserve comes off BOTH, and the pair is exactly the hazard the header
135
+ * of this file warns about: a stylesheet that disagrees with the arithmetic
136
+ * clips at the canvas edge, and no gate reads the stylesheet.
137
+ *
138
+ * NOT IN `PAD_Y` ITSELF. The padding is symmetric and the reserve is not — a
139
+ * symmetric version would throw away as much off the TOP of every slide for
140
+ * nothing. `theme.ts` adds it to the bottom only.
141
+ */
112
142
  export declare function contentH(format: Format): number;
143
+ /** `format.captionReserve`, canvas px, expressed in this file's reference px. */
144
+ export declare function reserveRef(format: Format): number;
113
145
  export interface Theme {
114
146
  bg: string;
115
147
  fg: string;
@@ -226,7 +226,10 @@ export interface BuildDeckOptions {
226
226
  narration?: DeckNarration;
227
227
  /** Directory holding those mp3s now; they are copied into the deck. */
228
228
  audioFrom?: string;
229
- /** Directory whose `assets/` is copied in. Defaults to `out`'s neighbours. */
229
+ /**
230
+ * Directory whose `assets/` is copied in. Absent, nothing is. Only the files
231
+ * `source.figures` names travel, plus `fonts/` — see `copyAssets`.
232
+ */
230
233
  assetsFrom?: string;
231
234
  /** Progress lines. Silent by default: a library that prints is a library you
232
235
  * cannot run inside a request handler. */
@@ -242,10 +245,11 @@ export interface BuildDeckOptions {
242
245
  * which a caller may not have and may not want on the request path; call it
243
246
  * yourself when you do.
244
247
  *
245
- * NOTE: this is the same sequence as `build` in src/cli.ts, written out a second
246
- * time rather than shared, because that file is owned elsewhere this pass. The
247
- * next person to touch cli.ts should delete its copy and call this — two
248
- * implementations of "what a deck directory contains" is exactly the kind of
249
- * drift that ends in a deck that opens locally and 404s in production.
248
+ * The file work is `src/build/files.ts`, which `build` in src/cli.ts calls too —
249
+ * it used to be written out twice, and the three ways the copies had already
250
+ * drifted are recorded in that module's header. Anything a deck directory
251
+ * contains beyond `index.html` and its two manifests goes there, not here, so
252
+ * the two callers cannot answer "what does a deck directory contain" differently
253
+ * again.
250
254
  */
251
255
  export declare function buildDeck(storyboard: Storyboard, source: Source, outDir: string, opts?: BuildDeckOptions): Promise<BuildResult>;
@@ -27,10 +27,22 @@ export interface SubtitlePlan {
27
27
  */
28
28
  export declare function subtitlePlan(mode: SubtitleMode): SubtitlePlan;
29
29
  /**
30
- * WHAT A REAL FIX NEEDS. Making the band optional did not stop it covering the
31
- * slide; it only means you can now choose not to be covered. Anyone who wants
32
- * `--subtitles burn` to be safe rather than merely available has to do this, and
33
- * the shape of it is not obvious, so it is written down once here.
30
+ * WHAT A REAL FIX NEEDED — BUILT, on 2026-09-12, as `build --reserve-captions`.
31
+ * The five steps below are the plan it was built from and they are kept because
32
+ * four of them are still the reason the code is shaped this way. Step 3 is kept
33
+ * for the opposite reason: it was WRONG, and the correction is the useful part.
34
+ *
35
+ * WHERE IT IS NOW. `bandReserve` (src/types.ts) is the one source for the band's
36
+ * box; `Format.captionReserve` carries it; `contentH` AND `.scene`'s padding
37
+ * both give it up; `fidelity`'s `ink_in_caption_reserve` fails a deck that draws
38
+ * into it; and `render --subtitles burn` refuses a deck whose `timing.json`
39
+ * reserved nothing. Measured on the twelve-beat demo at 1080x1920 over 31 stops:
40
+ * reserved, 0 of 31 stops put ink in the band; the same deck unreserved and
41
+ * judged against the same band, 1 of 31 at 0.529% of the frame.
42
+ *
43
+ * Making the band optional did not stop it covering the slide; it only meant you
44
+ * could choose not to be covered. The shape of the real fix was not obvious, so
45
+ * it was written down here first.
34
46
  *
35
47
  * THE MEASUREMENT. At 1080x1920 the band is 862x120px and `burnStyle`'s
36
48
  * `marginV` puts its baseline 173px off the bottom, so it occupies the bottom
@@ -58,14 +70,30 @@ export declare function subtitlePlan(mode: SubtitleMode): SubtitlePlan;
58
70
  * `--reserve-captions` flag. It belongs on `Format` and not on `DeckOptions`
59
71
  * because it changes the drawable box, which is what a format IS.
60
72
  *
61
- * 3. THE DRAWABLE BOX SHRINKS, IN ONE PLACE. `contentH` in src/emit/kit.ts
62
- * becomes `height - 2·padY - captionReserve`. That is the whole geometric
63
- * change: every archetype was audited for this and none of the twelve reads
64
- * `format.height` directly — only `title.ts`, and only to ask which way up
65
- * the canvas is. They all lay out into `contentH`, so subtracting there moves
66
- * all of them at once. Note it must NOT go into `padY`: the padding is
67
- * symmetric and the reserve is not, and a symmetric version would throw away
68
- * as much off the top for nothing.
73
+ * 3. THE DRAWABLE BOX SHRINKS, IN ONE PLACE — AND THIS STEP WAS WRONG. It said
74
+ * `contentH` in src/emit/kit.ts becomes `height - 2·padY - captionReserve`,
75
+ * and that "they all lay out into `contentH`, so subtracting there moves all
76
+ * of them at once."
77
+ *
78
+ * They do not. TEN of the archetypes never mention `contentH` —
79
+ * `bar-compare`, `callout`, `claim-figure`, `data-table`, `equation-morph`,
80
+ * `equation-walk`, `grid`, `index`, `line-chart`, `pipeline`. What bounds
81
+ * them is `.scene`'s CSS padding in src/emit/theme.ts, which centres their
82
+ * content in whatever box the stylesheet leaves. The audit behind the
83
+ * original claim asked which archetypes read `format.height`, which is a
84
+ * different question and has a comforting answer.
85
+ *
86
+ * MEASURED, because the plan read convincingly enough to ship on: with the
87
+ * subtraction in `contentH` alone, the 9x16 demo's worst stop was 0.529% of
88
+ * the frame in ink inside the band — identical, to the digit, to the same
89
+ * deck with no reserve at all. So the reserve is taken in BOTH places, and
90
+ * they are the exact pair src/emit/kit.ts's own header warns about: a
91
+ * stylesheet that disagrees with the arithmetic, which no gate reads.
92
+ *
93
+ * Still true, and still the reason it is not simply `padY`: the padding is
94
+ * symmetric and the reserve is not. `theme.ts` adds it to the bottom only,
95
+ * and emits the two-value padding unchanged when the reserve is 0, so a deck
96
+ * that reserves nothing is byte-identical to one built before this existed.
69
97
  *
70
98
  * 4. A GATE, OR THIS REGRESSES. `src/verify/check.ts` must fail a deck whose
71
99
  * audience text falls inside the reserve — invariant 5 with a new floor. A
@@ -100,6 +100,17 @@ export interface Timing {
100
100
  /** Directory holding the mp3s, relative to the deck. `""` when silent. */
101
101
  audioDir: string;
102
102
  voice: string;
103
+ /**
104
+ * Canvas px the deck's layout gave up at the bottom for a burned caption, 0
105
+ * if it gave up none. Recorded because the burn decision is taken at RENDER
106
+ * time and the layout was fixed at BUILD time: without this, `--subtitles
107
+ * burn` over a deck built before the reserve existed silently puts the band
108
+ * back on the slide's own text, which is the defect that started all of this.
109
+ *
110
+ * Optional, and absent reads as 0: every `timing.json` already on disk was
111
+ * written without it, and those decks did reserve nothing.
112
+ */
113
+ captionReserve?: number;
103
114
  scenes: TimedScene[];
104
115
  segments: TimedSegment[];
105
116
  }
@@ -2439,7 +2439,71 @@ export interface Format {
2439
2439
  * failure. Omitted when every destination for the format agrees on `maxSeconds`.
2440
2440
  */
2441
2441
  warnSeconds?: number;
2442
+ /**
2443
+ * Canvas px of the bottom of the frame reserved for a burned-in caption, which
2444
+ * the drawable box gives up. 0 — the default — means nothing is reserved.
2445
+ *
2446
+ * ON `Format` AND NOT ON `DeckOptions`, because it changes the drawable box,
2447
+ * and the drawable box is what a format IS. Two decks built from one
2448
+ * storyboard, one reserving and one not, are different formats in every sense
2449
+ * that matters downstream: different `contentH`, different archetype layouts,
2450
+ * different type sizes. `id` is left alone — the profile's pacing, budget and
2451
+ * navigability are unchanged — but `writeTiming` records the number so
2452
+ * `render` can refuse to burn over a deck that never made room. See the
2453
+ * design note above `RenderOptions` in src/render/render.ts.
2454
+ *
2455
+ * Optional in the TYPE for the same reason as `maxSeconds`: every test fixture
2456
+ * that needs a canvas would otherwise have to invent a reserve it does not
2457
+ * care about, and absent must read as "reserved nothing", which is the state
2458
+ * every deck built before this existed is actually in.
2459
+ */
2460
+ captionReserve?: number;
2442
2461
  }
2462
+ /**
2463
+ * THE ONE SOURCE FOR THE BAND'S BOX, because a second copy of these ratios is
2464
+ * exactly how the collision came back.
2465
+ *
2466
+ * These live in `types.ts` rather than beside `burnStyle` (src/render/ffmpeg.ts)
2467
+ * for a layering reason that is not negotiable: `src/emit` may not import
2468
+ * `src/render` — the dependency runs the other way, `src/render/timing.ts`
2469
+ * imports four things out of `src/emit`. The emitter is the side that has to
2470
+ * shrink its drawable box, so the numbers have to sit where BOTH can reach
2471
+ * them, and `Format` is already here.
2472
+ *
2473
+ * `burnStyle` reads these; it does not restate them.
2474
+ */
2475
+ export declare const CAPTION_FONT_RATIO = 0.037;
2476
+ export declare const CAPTION_MARGIN_V_RATIO = 0.09;
2477
+ export declare const CAPTION_MARGIN_X_RATIO = 0.04;
2478
+ /** The `--line` default `captionPage` writes, as a multiple of the font size. */
2479
+ export declare const CAPTION_LINE_RATIO = 1.6;
2480
+ /**
2481
+ * Two, and it is bounded rather than hoped for: `splitCue` caps a cue at 84
2482
+ * characters and `wrap` breaks it near the middle, and `burnStyle`'s font size
2483
+ * is chosen so the longer half still fits one line with 9% to spare. A third
2484
+ * line is the failure that sizing exists to prevent, so reserving for two is
2485
+ * reserving for the worst case that can actually occur.
2486
+ */
2487
+ export declare const CAPTION_MAX_LINES = 2;
2488
+ /** The burned band's font size at this canvas width. */
2489
+ export declare function captionFontSize(width: number): number;
2490
+ /**
2491
+ * The bottom strip the burned caption may occupy, `marginV` included, in CANVAS
2492
+ * px — everything `src/emit` must therefore keep its ink out of.
2493
+ *
2494
+ * AN UPPER BOUND, DELIBERATELY, and derived from the CSS rather than from a
2495
+ * browser. `captionPage` declares `line-height: round(F * 1.6)` and the real
2496
+ * painted line box is whatever the font actually produces, which is smaller: at
2497
+ * 1080x1920 this returns **301px** against the **293px** measured off two
2498
+ * rendered frames in `.planning/EXPERIMENT-010-reconcile.md`. Eight px of slack,
2499
+ * on the safe side. A bound that needed a browser to compute could not be read
2500
+ * by the emitter at all, which is the whole reason the collision survived this
2501
+ * long — the band's height was only known after layout, an hour too late.
2502
+ *
2503
+ * Under-reserving reintroduces the defect silently, so if these two ever have to
2504
+ * disagree, this one is the one that must be larger.
2505
+ */
2506
+ export declare function bandReserve(width: number, height: number): number;
2443
2507
  /** A real place a built file gets uploaded, and the length it stops accepting at. */
2444
2508
  export interface Destination {
2445
2509
  /** What a person would call it. Printed in budget findings. */
@@ -2467,6 +2531,29 @@ export interface Destination {
2467
2531
  * Verified by hand, not by network (nothing here may reach one). These move;
2468
2532
  * when one does, the finding it produces names it, so the stale number is
2469
2533
  * findable from the message rather than only from this file.
2534
+ *
2535
+ * LAST CHECKED 2026-09-17, against each platform's own pages where they would
2536
+ * load. And it happened again: FACEBOOK REELS IS GONE, because its 90 was stale
2537
+ * the same way Instagram's once was. Meta's newsroom, 2025-06-17: "Reels on
2538
+ * Facebook will also not have any length or format restrictions." By the rule
2539
+ * above a platform that takes anything adds nothing but slack — and the 90 was
2540
+ * not harmless slack, it was the tightest entry, so it made `near_budget` tell
2541
+ * every short between 1m30s and 3m00s that it was "not postable everywhere",
2542
+ * naming a destination that would have taken it. The 90 had been flagged in
2543
+ * EXPERIMENT-010 as "from memory, not the network" for months before anyone
2544
+ * looked.
2545
+ *
2546
+ * Note what each remaining number MEANS, because they are not the same thing:
2547
+ *
2548
+ * - YouTube Shorts, 180 — a CLASSIFICATION limit. A vertical or square upload of
2549
+ * three minutes or less is a Short; longer is long-form, off the Shorts feed.
2550
+ * YouTube Help, "Understand three-minute YouTube Shorts".
2551
+ * - Instagram Reels, 180 — a DISTRIBUTION limit, not an upload cap. Uploads run
2552
+ * longer, but "we recommend videos to unconnected audiences that are 3 minutes
2553
+ * or less" (Instagram for Creators, FAQ). For a short, reaching people who do
2554
+ * not follow you is the point, so this is the limit that constrains it.
2555
+ * - X, standard account, 140 — NOT verified at source: X's help centre refused
2556
+ * the fetch. Every secondary source agrees and it has not moved in years.
2470
2557
  */
2471
2558
  export declare const DESTINATIONS: Readonly<Record<PresetId, readonly Destination[]>>;
2472
2559
  /** The loosest destination: what the format may produce at all. */
@@ -26,7 +26,7 @@ export declare function readCanvas(html: string): Canvas | undefined;
26
26
  * be its own is a gate people learn to ignore. Today the two 16:9 profiles have
27
27
  * the same (absent) budget, so the reading is exact as well as safe.
28
28
  */
29
- export declare function profilesFor(width: number, height: number): Format[];
29
+ export declare function profilesFor(width: number, height: number, formats?: Readonly<Record<string, Format>>): Format[];
30
30
  /**
31
31
  * The budget finding for one built composition, if there is one.
32
32
  *
@@ -37,5 +37,13 @@ export declare function profilesFor(width: number, height: number): Format[];
37
37
  * length of the beat at the same index, and getting that pairing wrong would
38
38
  * attribute one beat's seconds to another. Absent, it is re-derived with the
39
39
  * flat threshold, which is the same list only while the budget cut nothing.
40
+ *
41
+ * `formats` is a seam for the test and nothing else, and it exists because of
42
+ * what the shipped table stopped containing. `near_budget` fires only when a
43
+ * format's destinations DISAGREE, and since Facebook Reels left `DESTINATIONS`
44
+ * on 2026-09-17 none of them do. A branch no shipped format can reach is a gate
45
+ * that cannot fail — the thing this project keeps finding — so the test hands in
46
+ * a table that disagrees, rather than the rule being deleted and silently absent
47
+ * the next time a real destination is tighter than the rest.
40
48
  */
41
- export declare function scanBudget(html: string, storyboard?: Storyboard, kept?: readonly Beat[]): Finding[];
49
+ export declare function scanBudget(html: string, storyboard?: Storyboard, kept?: readonly Beat[], formats?: Readonly<Record<string, Format>>): Finding[];
@@ -162,7 +162,37 @@ export interface Measured extends Stop {
162
162
  ink: number;
163
163
  /** Where the band began, as a fraction of frame height. Reported for triage. */
164
164
  bandTop: number;
165
+ /**
166
+ * Non-background pixels inside the caption RESERVE, over the whole frame's
167
+ * pixels. `undefined` on a deck that reserved nothing — there is no band to
168
+ * be inside, and 0 would read as "measured and clear".
169
+ */
170
+ reserveInk?: number;
165
171
  }
172
+ /**
173
+ * Ink is allowed in the reserve up to this fraction of the whole frame, and the
174
+ * number is measured rather than chosen.
175
+ *
176
+ * MEASURED on the twelve-beat demo at `short-9x16`, 1080x1920, 31 stops, the two
177
+ * builds differing only in `--reserve-captions`:
178
+ *
179
+ * | build | stops with ink in the band | worst |
180
+ * | --- | --- | --- |
181
+ * | `--reserve-captions` | 0 of 31 | 0 |
182
+ * | the same deck, unreserved, judged against the same band | 1 of 31 | 0.529% |
183
+ *
184
+ * So on a correct build the strip is background and anything at all is a real
185
+ * intrusion. The tolerance exists only so a stray antialiased edge cannot fail a
186
+ * build, and it is an ORDER OF MAGNITUDE below `INK_FLOOR`: one short headline
187
+ * is 0.15% of the frame, so 0.01% cannot be a glyph and can only be a fringe.
188
+ *
189
+ * NOT fitted to the failing case: the intrusion it has to catch is 0.529%, fifty
190
+ * times this, so the gate is not balanced on a margin. And note what the control
191
+ * is worth — the first version of this fix subtracted the reserve from
192
+ * `contentH` alone, and the reserved build measured 0.529% too, identically.
193
+ * The gate is what found that; no other gate in the stack moved.
194
+ */
195
+ export declare const RESERVE_INK_TOLERANCE = 0.0001;
166
196
  export interface FidelityOptions {
167
197
  floor?: number;
168
198
  /**
@@ -172,7 +202,15 @@ export interface FidelityOptions {
172
202
  */
173
203
  stops?: readonly Stop[];
174
204
  timeoutMs?: number;
205
+ /** Override the reserve read off `timing.json`. For tests and the 015 harness. */
206
+ captionReserve?: number;
175
207
  }
208
+ /**
209
+ * The caption reserve this deck was BUILT with, in canvas px, out of its own
210
+ * `timing.json`. 0 when the file is missing, unparseable, or predates the field
211
+ * — all three mean the same thing: nothing was reserved.
212
+ */
213
+ export declare function readReserve(timingText: string | null): number;
176
214
  export interface FidelityReport {
177
215
  stops: Measured[];
178
216
  /**
@@ -236,6 +274,23 @@ export declare function readStops(timing: string | null, deckPage: string | null
236
274
  * is named because that is the frame to open.
237
275
  */
238
276
  export declare function gradeFidelity(rows: readonly Measured[], floor?: number): Finding[];
277
+ /**
278
+ * Ink that got into the strip the burned caption is going to occupy.
279
+ *
280
+ * WHY THIS RULE EXISTS AT ALL. `contentH` already subtracts the reserve, so on
281
+ * a correct build this can never fire — which is exactly the argument for
282
+ * having it. The geometry is one refactor, one new archetype, or one absolute
283
+ * position away from being undone, and the failure is invisible to every other
284
+ * gate in the stack: the deck passes, the video renders, the band lands on the
285
+ * slide's own text, and the only way anyone finds out is by opening a frame.
286
+ * That is the failure mode this project keeps producing, and a purely geometric
287
+ * fix with no gate is how it comes back.
288
+ *
289
+ * An ERROR, not a warning. The deck asked for the reserve — `--reserve-captions`
290
+ * is typed on purpose — so ink inside it is the build failing to deliver the
291
+ * thing it was told to deliver, not a note about taste.
292
+ */
293
+ export declare function gradeReserve(rows: readonly Measured[], tolerance?: number): Finding[];
239
294
  /**
240
295
  * Seek every declared stop and count the ink below its caption.
241
296
  *
@@ -60,11 +60,16 @@ export declare function verify(dir: string, opts?: VerifyOptions, storyboard?: S
60
60
  /**
61
61
  * Every mp3 the narration island promises is actually in the deck.
62
62
  *
63
- * Nothing else notices this. `hyperframes check` never opens `deck.html`, and
64
- * the runtime treats a missing file exactly like a browser that refused to
65
- * autoplay — it clears the subtitles and moves on. So a deck that lost its audio
63
+ * Nothing else notices this in a BUILD. `hyperframes check` never opens
64
+ * `deck.html`, and the runtime treats a missing file exactly like a browser that
65
+ * refused to autoplay — it clears the subtitles and moves on. So a deck that lost its audio
66
66
  * on the way to a web host presents in silence and says nothing about why, which
67
67
  * is the failure mode a gate exists for.
68
+ *
69
+ * `test/deck-page.test.ts` does now open `deck.html` in a browser, and asserts
70
+ * that an unplayable segment is NAMED in the flag strip rather than going quiet.
71
+ * That is a test, not a build gate: it runs on a deck the suite builds, and says
72
+ * nothing about the deck a user is shipping. This still has to exist.
68
73
  */
69
74
  export declare function scanNarration(page: string, files: ReadonlySet<string>): Finding[];
70
75
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jokerized/decksmith",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Turn a source document into an animated explanation deck.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,7 +63,7 @@
63
63
  "commander": "^14.0.1",
64
64
  "fflate": "^0.8.3",
65
65
  "gsap": "^3.14.2",
66
- "hyperframes": "0.8.33",
66
+ "hyperframes": "0.8.43",
67
67
  "katex": "^0.16.11",
68
68
  "puppeteer-core": "^25.3.0",
69
69
  "remark-gfm": "^4.0.1",