@jokerized/decksmith 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +794 -0
  2. package/dist/cli.js +8906 -0
  3. package/dist/deck-runtime.js +55 -0
  4. package/dist/index.js +8590 -0
  5. package/dist/mcp.js +7809 -0
  6. package/dist/server/errors.js +73 -0
  7. package/dist/server/http.js +504 -0
  8. package/dist/server/main.js +91 -0
  9. package/dist/server/options.js +198 -0
  10. package/dist/server/pipeline.js +356 -0
  11. package/dist/server/queue.js +195 -0
  12. package/dist/server/ui.js +1614 -0
  13. package/dist/server/upload.js +232 -0
  14. package/dist/types/cli.d.ts +1 -0
  15. package/dist/types/deck/runtime.d.ts +59 -0
  16. package/dist/types/deck/subtitles.d.ts +101 -0
  17. package/dist/types/emit/archetypes/annotated-figure.d.ts +103 -0
  18. package/dist/types/emit/archetypes/bar-compare.d.ts +30 -0
  19. package/dist/types/emit/archetypes/callout.d.ts +7 -0
  20. package/dist/types/emit/archetypes/claim-figure.d.ts +9 -0
  21. package/dist/types/emit/archetypes/data-table.d.ts +21 -0
  22. package/dist/types/emit/archetypes/equation-walk.d.ts +2 -0
  23. package/dist/types/emit/archetypes/grid.d.ts +16 -0
  24. package/dist/types/emit/archetypes/index.d.ts +19 -0
  25. package/dist/types/emit/archetypes/line-chart.d.ts +22 -0
  26. package/dist/types/emit/archetypes/pipeline.d.ts +81 -0
  27. package/dist/types/emit/archetypes/split-compare.d.ts +2 -0
  28. package/dist/types/emit/archetypes/stack.d.ts +93 -0
  29. package/dist/types/emit/archetypes/title.d.ts +188 -0
  30. package/dist/types/emit/camera.d.ts +397 -0
  31. package/dist/types/emit/composition.d.ts +191 -0
  32. package/dist/types/emit/island.d.ts +19 -0
  33. package/dist/types/emit/kit.d.ts +256 -0
  34. package/dist/types/emit/svg.d.ts +177 -0
  35. package/dist/types/emit/theme.d.ts +65 -0
  36. package/dist/types/emit/themes/index.d.ts +40 -0
  37. package/dist/types/emit/themes/ink.d.ts +12 -0
  38. package/dist/types/emit/themes/mono.d.ts +20 -0
  39. package/dist/types/emit/themes/paper.d.ts +18 -0
  40. package/dist/types/index.d.ts +200 -0
  41. package/dist/types/mcp/main.d.ts +2 -0
  42. package/dist/types/mcp/prereqs.d.ts +22 -0
  43. package/dist/types/mcp/tools.d.ts +212 -0
  44. package/dist/types/narrate/narrate.d.ts +87 -0
  45. package/dist/types/narrate/tts.d.ts +134 -0
  46. package/dist/types/narrate/voices.d.ts +29 -0
  47. package/dist/types/pack/media.d.ts +61 -0
  48. package/dist/types/pack/pack.d.ts +16 -0
  49. package/dist/types/plan/codex.d.ts +24 -0
  50. package/dist/types/plan/duration.d.ts +394 -0
  51. package/dist/types/plan/prompt.d.ts +47 -0
  52. package/dist/types/plan/refs.d.ts +22 -0
  53. package/dist/types/plan/select.d.ts +116 -0
  54. package/dist/types/prefs.d.ts +43 -0
  55. package/dist/types/render/captions.d.ts +108 -0
  56. package/dist/types/render/ffmpeg.d.ts +129 -0
  57. package/dist/types/render/render.d.ts +123 -0
  58. package/dist/types/render/timing.d.ts +290 -0
  59. package/dist/types/server/errors.d.ts +11 -0
  60. package/dist/types/server/http.d.ts +57 -0
  61. package/dist/types/server/main.d.ts +1 -0
  62. package/dist/types/server/options.d.ts +90 -0
  63. package/dist/types/server/pipeline.d.ts +21 -0
  64. package/dist/types/server/queue.d.ts +105 -0
  65. package/dist/types/server/ui.d.ts +9 -0
  66. package/dist/types/server/upload.d.ts +107 -0
  67. package/dist/types/source/assets.d.ts +11 -0
  68. package/dist/types/source/fonts.d.ts +15 -0
  69. package/dist/types/source/markdown.d.ts +8 -0
  70. package/dist/types/types.d.ts +1992 -0
  71. package/dist/types/verify/budget.d.ts +41 -0
  72. package/dist/types/verify/check.d.ts +78 -0
  73. package/dist/types/verify/drift.d.ts +158 -0
  74. package/dist/types/verify/fidelity.d.ts +247 -0
  75. package/dist/types/verify/index.d.ts +207 -0
  76. package/dist/types/verify/overprint.d.ts +133 -0
  77. package/dist/types/verify/typefloor.d.ts +50 -0
  78. package/package.json +84 -0
package/README.md ADDED
@@ -0,0 +1,794 @@
1
+ # DeckSmith
2
+
3
+ DeckSmith turns a source document — a paper, a spec, a set of course notes — into an
4
+ animated explanation deck: one self-contained HTML file you can present, and the same
5
+ storyboard rendered to a 9:16 short or an MP4 without replanning.
6
+
7
+ Rendering is bought, not built. A deck is a
8
+ [HyperFrames](https://github.com/heygen-com/hyperframes) composition (Apache-2.0, pinned
9
+ at 0.7.71), so the animation runtime, the headless capture and the FFmpeg encode are
10
+ upstream's. What DeckSmith owns is the part nothing else does well: turning a document
11
+ into a *good explanation*, and being able to check that the explanation is true to its
12
+ source.
13
+
14
+ ## Prerequisites
15
+
16
+ - Node 22 or later, and nothing else to **generate** a deck. `ingest`, `build`, `pack` and
17
+ `unpack` are pure Node: they read files, write files, and never open a browser. Decide
18
+ your deployment on that line — a service that turns papers into decks runs in a plain
19
+ Node image.
20
+ - A Chromium build and ffmpeg to **render or check** one. `verify` drives the HyperFrames
21
+ gates in a headless browser, and the MP4 encode is ffmpeg's. Both arrive with the
22
+ hyperframes toolchain during `npm install`; there is nothing to install by hand.
23
+ - Two commands reach outside for their own reasons: `plan` runs the Codex CLI, and
24
+ `narrate` runs edge-tts over the network.
25
+
26
+ ```sh
27
+ npm install
28
+ npm run build # dist/cli.js, dist/index.js, the deck runtime, and dist/types/
29
+ npm run check # typecheck + lint + test
30
+ ```
31
+
32
+ ## The pipeline
33
+
34
+ Each command reads a file, writes a file, and stops — so you can look at, diff, and
35
+ hand-edit everything in between.
36
+
37
+ ```sh
38
+ decksmith ingest analysis.md -o source.json # structure, figures, equations, provenance
39
+ decksmith plan source.json -o storyboard.json # beats — this is the one to read
40
+ decksmith narrate storyboard.json --source source.json -o audio/ # optional
41
+ decksmith build storyboard.json --source source.json --format deck-16x9 -o out/
42
+ decksmith verify out/
43
+ decksmith render out/ -o talk.mp4 # picture + narration + subtitles
44
+ decksmith drift out/ # render twice, compare frame by frame
45
+
46
+ decksmith pack storyboard.json --source source.json -o talk.deck # one file to keep
47
+ decksmith unpack talk.deck -o reopened/
48
+ ```
49
+
50
+ - **ingest** — document to `Source`: sections, figures, equations and tables, each with a
51
+ stable id so a later stage can point back at it.
52
+ - **plan** — `Source` to `Storyboard`: an ordered list of beats. Each beat carries an
53
+ `intent` (what the viewer should understand), an optional `claim` it is accountable to,
54
+ `evidence` refs into the Source, a `weight`, and an archetype with its parameters.
55
+
56
+ This is the only step that needs a model, and it runs on the **Codex CLI already
57
+ installed on your machine** (`codex exec`) — under your existing subscription, with no
58
+ API key and no metered tokens. `--output-schema` gives the same guarantee a structured
59
+ API call would: the final message is schema-conformant JSON, no prose to strip. Two
60
+ gates follow it — `storyboardSchema` proves the shape, and `assertRefsResolve` proves
61
+ the storyboard is about *this* source, which is the part a planner actually gets wrong.
62
+
63
+ You can also skip it entirely. `storyboard.json` is just a file: hand-write it, or have
64
+ any assistant write it, and `build` cannot tell the difference.
65
+ - **narrate** — each beat's `narration` text, spoken by edge-tts, one audio file and one
66
+ set of subtitle cues per *stop*. Optional, and the only command that needs the network.
67
+ See "Narration" below.
68
+ - **build** — `Storyboard` to a composition, per format profile. Beats become scenes,
69
+ scenes become their own paused GSAP timelines, hold points become slideshow fragments.
70
+ Narration sitting beside the storyboard is picked up automatically.
71
+ - **verify** — runs the HyperFrames gates over a built directory and returns a `Verdict`:
72
+ lint, runtime, layout, motion and contrast. Read what that does *not* cover before
73
+ trusting it — see "What the gates do not check" below.
74
+ - **render** — a built deck to a finished video: capture, retime, mux, subtitles. The
75
+ retiming is the interesting part. The composition reveals everything a beat has to show
76
+ and then sits still for the rest of its window, so playing it back linearly puts the
77
+ narration seconds away from the picture it describes. `render` instead does what a
78
+ presenter does — freezes each hold for exactly its sentence's length, then plays out
79
+ whatever the scene actually ends on. Needs Chromium and ffmpeg, and needs the
80
+ `timing.json` that `build` writes.
81
+ - **drift** — renders the deck twice and compares every frame. `--identical` fails on any
82
+ differing byte, which is only honest for an image-free deck with no camera;
83
+ the default compares PSNR against a 40 dB floor. Costs two full renders, so it is a
84
+ thing you schedule rather than a thing you run per build. See
85
+ `.planning/EXPERIMENT-006-diagrams.md` for why byte-identical is not available in
86
+ general on this stack.
87
+ - **pack** / **unpack** — the whole deck as one `.deck` file, and back again. See "The
88
+ `.deck` container" below.
89
+
90
+ ### What `build` writes
91
+
92
+ ```
93
+ out/
94
+ index.html the composition — what check, snapshot and render consume
95
+ deck.html open this: the navigable deck
96
+ hyperframes-player.global.js copied from the hyperframes package, so nothing needs a CDN
97
+ hyperframes.json assets/
98
+ audio/ only when the deck is narrated
99
+ ```
100
+
101
+ Serve the directory and open `deck.html` — any static server will do. It must be http,
102
+ not `file://`: the wrapper drives the composition through its iframe, and a file-origin
103
+ iframe cannot be reached. The deck says so in the console rather than rendering blank.
104
+
105
+ Arrow keys, Space and PageUp/PageDown step; clicking the left or right third does too;
106
+ `Home`/`End` jump; `n` toggles presenter notes; `f` is fullscreen; `m` mutes the voice and
107
+ `s` hides the subtitles. Every step is deep-linkable (`#3` is slide 3, `#3.2` its second
108
+ reveal).
109
+
110
+ Stepping forward *plays* the reveal rather than cutting to it — the step layer sweeps the
111
+ composition's timelines across frames instead of seeking once. Backward steps, `Home`/`End`
112
+ and deep links cut, because entrance tweens run in reverse look like elements un-drawing
113
+ themselves. A held slide keeps a slow ambient motion on one focal element so it reads as a
114
+ live document rather than a screenshot. Both respect `prefers-reduced-motion`, and both
115
+ exist only on the deck page: ambient motion is gated behind a class the composition never
116
+ sets, so `render` output is byte-identical with or without it.
117
+
118
+ Navigation is ours rather than upstream's because upstream's is broken at 0.7.71/0.7.72
119
+ — `player.scenes` never populates, reproduced on HeyGen's own reference example. The
120
+ same layer also paints the composition, because the standalone player moves its clock
121
+ without driving scene timelines or clip visibility. See
122
+ `.planning/EXPERIMENT-003-deck-mode.md` and `-004-step-layer.md`.
123
+
124
+ ### What the gates do not check
125
+
126
+ The failure this project keeps producing is a **green gate over wrong output**, and there
127
+ are now ten documented cases. Nearly every one was caught by a human looking at the
128
+ artifact. Three are worth reading as patterns rather than bugs:
129
+
130
+ - A camera move that the video renderer replaced with a still, for exactly the right
131
+ number of frames, while lint, check, the type floor and the two-render drift gate all
132
+ passed — [`.planning/EXPERIMENT-007-reconcile.md`](.planning/EXPERIMENT-007-reconcile.md).
133
+ - `build --format short-9x16` reporting PASS over decks whose content ran off the right
134
+ edge, because the content box was hardcoded to 16:9 —
135
+ [`.planning/EXPERIMENT-008-reconcile.md`](.planning/EXPERIMENT-008-reconcile.md). The
136
+ same round found two archetypes silently sharing one CSS class, which no gate can see:
137
+ `verify` measures rendered geometry, and the geometry happened to come out right.
138
+ - Seven seams where the deck cut to flat background for three to five frames, and eleven
139
+ frames at a cameraed one, with every gate green — nobody had extracted a frame at a
140
+ seam. And, at 9:16, burned captions sitting on top of the slide's own text on 54% of
141
+ sampled frames, because `marginV` was measured to clear *player chrome* and nothing ever
142
+ guaranteed it cleared the *composition*. Both in
143
+ [`.planning/EXPERIMENT-010-reconcile.md`](.planning/EXPERIMENT-010-reconcile.md); the
144
+ caption one is **still open**.
145
+
146
+ `verify` is good at mechanics — overflow, overlap, contrast, motion, determinism. It has
147
+ no opinion about whether a slide communicates. Across four experiments it passed, in
148
+ turn: an unreadable slide, a deck with zero navigable slides, and a deck that navigated
149
+ perfectly while displaying nothing. **Look at the output before you ship it.** Fidelity
150
+ checking against the source (design §5) is not in v0.
151
+
152
+ `--format` selects a profile: `deck-16x9`, `video-16x9`, `short-9x16`, `post-1x1`. A
153
+ profile decides canvas, pacing, how many beats survive (`minWeight`) and how long the cut
154
+ may run (`maxSeconds`) — never what a beat means. That is why one storyboard retargets
155
+ instead of being re-cropped.
156
+
157
+ Every archetype derives its layout from `contentW(format)` / `contentH(format)`, so a
158
+ portrait canvas gets a portrait arrangement rather than a squeezed landscape one: pipeline
159
+ runs down the page, split-compare stacks its panels, bar-compare puts each label above its
160
+ own rail. Six archetypes branch on `isPortrait(format)`; the rest are width-driven and
161
+ need no branch. Square (`post-1x1`) deliberately takes the landscape branch.
162
+
163
+ ### Fitting a short
164
+
165
+ `minWeight` and `maxSeconds` describe the same editorial decision from two directions, and
166
+ for any given storyboard they can disagree. `short-9x16` allows 3m00s; the demo's twelve
167
+ beats are all weighted ≥0.7, so the profile's 0.6 floor keeps all of them and the narrated
168
+ cut runs 4m07s.
169
+
170
+ **`build` now fits the deck to the format's length**, and says what that cost:
171
+
172
+ ```sh
173
+ decksmith build storyboard.json --source source.json --format short-9x16 -o short/
174
+ # build: 9 of 12 beats at 1080×1920 in ink → short/index.html
175
+ # build: cut b03 (annotated-figure, 27.5s) — Cut to fit short-9x16's 3m00s: 27.5s for
176
+ # weight 0.9 is 0.033 weight per second, and the beats kept buy more per second.
177
+ # Its family (structure) still has 3 beat(s) in the cut.
178
+ # build: cut b06 (stack, 24.7s) — …
179
+ # build: cut b11 (claim-figure, 15.5s) — …
180
+ # PASS — 0 error(s), 2 warning(s)
181
+ ```
182
+
183
+ The rule is `selectBeats` (`src/plan/select.ts`), and it is not "drop the lightest". In
184
+ priority order it keeps a cut **coherent**, then **covered** — the deck's first and last
185
+ beat, and one beat of every archetype family the full deck used — then **fitting**, and
186
+ only then heaviest. Author weight is the last tiebreak, because weight says how much a
187
+ beat matters and nothing about what it costs: two 14-second beats at 0.80 are worth more
188
+ to a three-minute budget than one 39-second beat at 0.95, and a threshold cannot say so.
189
+
190
+ Every casualty arrives with a sentence naming what it cost, what it was worth, and what
191
+ the deck still has of its kind. **That printing is the reason the trim is allowed at
192
+ all.** A budget that trims quietly is the failure the budget gate spent three experiments
193
+ refusing to become: PASS on a deck with a third of its argument missing and nothing
194
+ anywhere saying which third. Library callers get the same answer as `buildDeck().cut`.
195
+
196
+ `--min-weight` is unchanged, and is still how you say which beats you want gone:
197
+
198
+ ```sh
199
+ decksmith build storyboard.json --source source.json \
200
+ --format short-9x16 --min-weight 0.85 -o short/
201
+ # build: 8 of 12 beats at 1080×1920 in ink (4 below minWeight 0.85) → short/index.html
202
+ # PASS — 0 error(s), 2 warning(s)
203
+ ```
204
+
205
+ It is a **floor, applied first**, and its casualties are reported separately so an
206
+ author's own cut is never blamed on the budget. The budget only trims what is left, and
207
+ only if that still does not fit — at 0.85 the demo's eight beats run 2m49s, so nothing
208
+ more is dropped and the output is byte-for-byte what it was before selection existed. It
209
+ lives on the command rather than in the profile because which beats survive a shorter cut
210
+ is a judgement about *this* deck, not about the canvas.
211
+
212
+ The budget gate still exists and is now the backstop: it fires when no cut of these beats
213
+ fits — the narration itself has to get shorter — or when a deck was assembled by
214
+ something other than `build`.
215
+
216
+ Two things selection deliberately does **not** do. It never shortens a beat, because a
217
+ beat's length is measured speech and the only way to shorten one is to write a shorter
218
+ sentence — a `plan`-time decision, and the better product (twelve beats at 15s beats nine
219
+ at 20s). And it does not repair a **dangling citation**: when a kept beat cites a figure
220
+ only a dropped beat showed, `build` prints `check the wording` and leaves it, because
221
+ dropping the citing beat too would lose the claim to save the footnote.
222
+
223
+ ## Running the server
224
+
225
+ There is a web front end: drop a document, pick a format, watch the stages, get a deck.
226
+ It is `npm run serve`, it binds `127.0.0.1:8475`, and it calls the library directly —
227
+ `src/server/pipeline.ts` imports `src/index.ts` and shells out to nothing.
228
+
229
+ ```sh
230
+ npm run serve # builds dist/, builds dist/server/, then listens
231
+ open http://127.0.0.1:8475
232
+ ```
233
+
234
+ Startup says what is missing before anyone waits on it:
235
+
236
+ ```
237
+ decksmith: http://127.0.0.1:8475
238
+ decksmith: work /tmp/decksmith-server, one job at a time, 8 may wait
239
+ decksmith: no auth, no TLS. Bound to 127.0.0.1 — set DECKSMITH_HOST to open it, knowing that.
240
+ decksmith: codex, edge-tts and ffmpeg all found
241
+ ```
242
+
243
+ ### The HTTP surface
244
+
245
+ | Route | What it does |
246
+ |---|---|
247
+ | `POST /api/jobs` | multipart: `file` (.md/.markdown/.txt/.zip) plus option fields → `202 {id}` |
248
+ | `GET /api/jobs/:id` | `{state, stage, steps[], log[], error, result, queuePosition}` |
249
+ | `GET /api/jobs/:id/events` | the same payload as SSE on every change |
250
+ | `GET /api/formats` | the presets, themes, tones, densities and canvas bounds the picker draws from |
251
+ | `GET /d/:id/...` | the built deck, served statically; `/d/:id/deck.html` is the player |
252
+
253
+ Options on `POST`, all optional, all defaulted server-side: `format`, `width`+`height`,
254
+ `theme`, `slides`, `lang`, `tone`, `density`, `speed`, `narrate`, `voice`, `video`.
255
+
256
+ `width`/`height` override the named preset's canvas and **keep its pacing but not its
257
+ name** — `--format short-9x16 --width 1080 --height 1350` builds `custom-1080x1350` with
258
+ a Reel's weight floor and duration ceiling, because "short-9x16" printed over a 4:5
259
+ canvas would be a lie in every cut explanation that quotes it. Canvases run 64–5648px a
260
+ side, up to 4 megapixels, between 1:8 and 8:1. The first three of those are the library's
261
+ (`canvasProblem` in `src/types.ts`, derived from the layout and Chrome's texture ceiling);
262
+ the megapixel ceiling is the server's own, because capture holds whole frames in memory
263
+ on a box every job shares. `GET /api/formats` publishes all of them and the page enforces
264
+ exactly those numbers — they were three disagreeing tables until 2026-07-28.
265
+
266
+ ### Configuration
267
+
268
+ Every knob is an environment variable, and every default is the safe one rather than the
269
+ generous one.
270
+
271
+ | Variable | Default | Why |
272
+ |---|---|---|
273
+ | `PORT` | `8475` | |
274
+ | `DECKSMITH_HOST` | `127.0.0.1` | There is no auth. Opening it should require typing something. |
275
+ | `DECKSMITH_WORK` | `$TMPDIR/decksmith-server` | One directory per job; swept by age. |
276
+ | `DECKSMITH_MAX_UPLOAD` | 25 MB | |
277
+ | `DECKSMITH_MAX_QUEUE` | `8` | Concurrency is 1. Past 8 the answer is "full", not "position 400". |
278
+ | `DECKSMITH_JOB_TTL_MIN` | `120` | Files outlive the in-memory record; orphans are swept on boot. |
279
+ | `DECKSMITH_JOBS_PER_HOUR` | `5` | Per IP. Charged only on an **accepted** job. |
280
+ | `DECKSMITH_REQS_PER_MIN` | `240` | Per IP. |
281
+ | `DECKSMITH_FETCH_FIGURES` | off | See below. |
282
+ | `DECKSMITH_DECK_SANDBOX` | on | Serves decks under `CSP: sandbox`. |
283
+
284
+ **Remote figures are off by default, and that is a security decision.** `fetchFigures`
285
+ does `readFile(src)` on anything that is not an http URL, so an uploaded document
286
+ containing `![](../../../etc/ssh/ssh_host_rsa_key)` would have this process read it.
287
+ Relative paths are resolved inside the upload directory and confined there; http figures
288
+ are dropped with a named warning unless you turn them on. A hostname allowlist does not
289
+ close the SSRF — DNS can answer differently the second time — so none is pretended.
290
+
291
+ ### What is missing before this is public
292
+
293
+ This runs a demo on a laptop. It is **not** ready to face the internet, and the gap is
294
+ not a polish gap:
295
+
296
+ - **No authentication and no TLS.** Anyone who can reach the port can spend your Codex
297
+ quota. This is why the default bind is loopback.
298
+ - **No CSRF defence.** `POST /api/jobs` is `multipart/form-data`, which is a CORS-*simple*
299
+ request: any website a victim visits can make their browser submit a job. There is no
300
+ token and no `Origin` check. Fix that before `DECKSMITH_HOST` is ever anything else.
301
+ - **Codex spend is unmetered per upload.** The per-IP hourly limit is the only brake, and
302
+ it is per-IP.
303
+ - **Rate limiting is by `socket.remoteAddress` only.** `X-Forwarded-For` is deliberately
304
+ not read, because unproxied it is a header the client writes. Behind a reverse proxy
305
+ every client therefore looks like one address and the limits collapse — teach the proxy
306
+ to rate-limit, or teach this to trust exactly one hop.
307
+ - **Deck files are readable by anyone holding the 128-bit id.**
308
+ - **The deck sandbox does not isolate the origin, and cannot on one host.** Decks are
309
+ served under `Content-Security-Policy: sandbox allow-scripts allow-same-origin
310
+ allow-downloads`, which still denies top-level navigation, popups, forms and modals —
311
+ but `allow-same-origin` is not optional: `deck.html` is the HyperFrames player and it
312
+ drives the composition through `iframe.contentDocument`, so an opaque origin makes
313
+ every slide render blank while the job reports `done`. Real isolation means serving
314
+ `/d/:id` from a **separate origin**, which is a second listener and is not built.
315
+ - **No persistence.** A restart forgets every job record. The files survive and are swept
316
+ by age on boot.
317
+ - **Disk is bounded only by TTL × queue rate**, and uploaded files are not scanned.
318
+
319
+ ## Connecting an agent (MCP)
320
+
321
+ `decksmith-mcp` is a stdio [MCP](https://modelcontextprotocol.io) server, so an agent can
322
+ turn a document into a deck with the same settings the CLI takes.
323
+
324
+ ```json
325
+ {
326
+ "mcpServers": {
327
+ "decksmith": {
328
+ "command": "node",
329
+ "args": ["/absolute/path/to/DeckSmith/dist/mcp.js"],
330
+ "env": {
331
+ "DECKSMITH_MCP_ROOT": "/Users/me/papers",
332
+ "DECKSMITH_MCP_WORK": "/Users/me/.decksmith"
333
+ }
334
+ }
335
+ }
336
+ }
337
+ ```
338
+
339
+ Build it first with `npm run build && npm run build:mcp`. `DECKSMITH_MCP_ROOT` is the fence
340
+ — documents outside it are not readable, and it defaults to your home directory rather than
341
+ the working directory, because not every client launches a stdio server anywhere useful.
342
+
343
+ Four tools:
344
+
345
+ | tool | what it does |
346
+ |---|---|
347
+ | `decksmith_capabilities` | formats, themes, every setting's range, and which of Codex, edge-tts, ffmpeg and Chrome are installed |
348
+ | `decksmith_estimate_length` | what a duration/slides/density combination costs, and what it cannot buy — instant, no job |
349
+ | `decksmith_create_deck` | document + settings → a deck, and optionally an mp4 |
350
+ | `decksmith_job_status` | where a job got to |
351
+
352
+ **The pipeline takes minutes**, so `create_deck` and `job_status` block for `wait_seconds`
353
+ (default 45, max 300) and then answer with whatever is true; the job keeps running between
354
+ calls. Prerequisites are checked *before* the job starts, so a missing ffmpeg is a message
355
+ in milliseconds rather than a failure four minutes in.
356
+
357
+ Every report carries `storyboard_path`, and that is the point of the surface rather than a
358
+ convenience: the storyboard is JSON on disk, an agent can read and edit it natively, and
359
+ [it is the human checkpoint](#the-storyboard-is-the-human-checkpoint) where the quality is
360
+ won. `estimate_length` returns `durationPlan`'s warnings verbatim for the same reason —
361
+ they are the product telling you what your settings cost.
362
+
363
+ No setting has a default in the tool schema. Absence is the signal: a theme you did not
364
+ mention loses to the storyboard's own, and a language you did not mention loses to the
365
+ document's.
366
+
367
+ ## Using it as a library
368
+
369
+ A server generating a deck per document should not shell out to a binary. It costs an
370
+ argv round-trip for a 200 KB JSON document, it turns a typed failure into an exit code,
371
+ and it gives you no way to edit the storyboard between stages. So the same pipeline is
372
+ importable, and `src/index.ts` is the whole of the public surface — chosen name by name,
373
+ each with the reason it is there.
374
+
375
+ ### Installing
376
+
377
+ ```sh
378
+ npm install @jokerized/decksmith
379
+ ```
380
+
381
+ Scoped, because the bare name `decksmith` on npm is somebody else's package — at 1.1.3,
382
+ and nothing to do with this one. The two executables keep their short names: `decksmith`
383
+ and `decksmith-mcp`.
384
+
385
+ Installing from git works too and needs no registry, which is what to reach for if you
386
+ want a commit that is not a release:
387
+
388
+ ```sh
389
+ npm install "github:ca1773130n/DeckSmith#<commit>"
390
+ ```
391
+
392
+ `dist/` is not committed, so the package builds itself during install: `prepare` runs
393
+ `npm run build`, and npm runs `prepare` on a git install and before a publish. Pin a
394
+ commit rather than a branch — the build is the package.
395
+
396
+ ### Releasing
397
+
398
+ Pushing a `v*` tag publishes it. `.github/workflows/release.yml` re-runs the four gates
399
+ — a tag can point at any commit, including one that never saw a pull request — checks
400
+ that the tag matches `version` in `package.json`, and publishes.
401
+
402
+ There is no npm token anywhere in the repository. The workflow authenticates by OIDC
403
+ ([npm trusted publishing](https://docs.npmjs.com/trusted-publishers)): npm mints a
404
+ short-lived credential for this workflow, on this repository, at publish time. Two
405
+ consequences worth knowing before you touch anything:
406
+
407
+ - **The workflow's filename is part of the credential.** The trusted publisher on
408
+ npmjs.com names `release.yml` exactly, and npm does not check that configuration when
409
+ you save it. Rename the file and publishing fails as an authentication error that says
410
+ nothing about a rename.
411
+ - **`repository.url` in `package.json` must match this repo exactly**, for the same
412
+ reason.
413
+
414
+ ```sh
415
+ npm version minor # bumps package.json and tags
416
+ git push --follow-tags
417
+ ```
418
+
419
+ **The first publish of a new package cannot use OIDC.** npm requires a package to exist
420
+ before a trusted publisher can be attached to it — the website and `npm trust` both say
421
+ so — so version 0.1.0 has to be pushed by hand, once:
422
+
423
+ ```sh
424
+ npm login # your account, your 2FA
425
+ npm publish --access public # scoped packages default to private
426
+ ```
427
+
428
+ Then attach the trusted publisher (npmjs.com → the package → Settings → Trusted
429
+ publishing, or `npm trust github ...`), and every release after that is a tag. Publishing
430
+ by hand rather than with a bootstrap automation token is deliberate: it means no
431
+ long-lived credential is ever created, so there is none to revoke afterwards and none to
432
+ forget about.
433
+
434
+ ### Generating a deck
435
+
436
+ ```js
437
+ import { buildDeck, FORMATS, sourceSchema, storyboardSchema, verify } from "@jokerized/decksmith";
438
+
439
+ const source = sourceSchema.parse(JSON.parse(await readFile("source.json", "utf8")));
440
+ const storyboard = storyboardSchema.parse(JSON.parse(await readFile("storyboard.json", "utf8")));
441
+
442
+ const { out, files, navigable } = await buildDeck(storyboard, source, "./deck", {
443
+ format: FORMATS["deck-16x9"],
444
+ theme: "ink",
445
+ assetsFrom: ".", // the directory whose assets/ holds the figures
446
+ onStep: console.log, // silent otherwise: a library that prints is one you
447
+ }); // cannot run inside a request handler
448
+
449
+ const verdict = await verify(out); // needs Chrome; skip it on the request path
450
+ ```
451
+
452
+ `buildDeck` is the `build` verb minus argv, and writes exactly what the CLI writes — the
453
+ demo deck built this way is byte-identical to `decksmith build`'s. It does **not** run the
454
+ gates, because `verify` wants a browser and a caller may have neither one nor the patience
455
+ for it; call `verify` yourself when you want it.
456
+
457
+ ### The surface
458
+
459
+ | Stage | Exports |
460
+ |---|---|
461
+ | ingest | `parseMarkdown`, `fetchFigures`, `bundleFont` |
462
+ | plan | `codexPlanner` with its `Runner` type, `assertRefsResolve`, `systemPrompt`, `renderSource` |
463
+ | narrate | `narrate`, `pickVoice`, `narratableLangs` |
464
+ | emit | `buildDeck`, `emitDeck`, `emitComposition`, `resolveTheme`, `THEMES`, `THEME_NAMES` |
465
+ | verify | `verify`, `check`, `parseCheckReport` |
466
+ | pack | `writePack`, `readPack`, `openPack`, `planMedia`, `mediaSummary` |
467
+ | prefs | `loadPrefs`, `CONFIG_FILE` |
468
+ | contract | everything in `src/types.ts` — every schema, `Source`, `Storyboard`, `Beat`, `Format`, `FORMATS`, `Verdict` |
469
+
470
+ Two of those are worth pointing at. `Runner` is exported so you can drive planning through
471
+ an SDK instead of a subprocess, which is what a server actually wants — `codexPlanner`
472
+ shells to the Codex CLI only because that is the right default at a terminal. And
473
+ `emitDeck` sits underneath `buildDeck` for callers writing to object storage or a response
474
+ body rather than a filesystem: it is pure, taking strings in and returning strings out.
475
+
476
+ Anything not listed is deliberately absent, and adding to the list is a promise we cannot
477
+ quietly take back. `prefsFromFlags` is the clearest example: it translates commander's flag
478
+ object, which is the CLI's problem and nobody else's.
479
+
480
+ ## Preferences
481
+
482
+ What the person asking for the deck gets to decide. Split deliberately: `plan` reads the
483
+ ones that change *what is said*, `emit` reads the ones that change *how it looks*, and
484
+ neither reaches for a field it does not own — which is why changing a preference never
485
+ invalidates a storyboard you have already edited.
486
+
487
+ | Preference | Default | Read by | What it does |
488
+ |---|---|---|---|
489
+ | `slides` | `12` | plan | target beat count. A target to come close to, not a quota to fill |
490
+ | `lang` | the source's | plan | BCP-47. Drives the copy, the voice, and the font subset |
491
+ | `tone` | `plain` | plan | `plain` · `academic` · `conversational` · `punchy` |
492
+ | `density` | `normal` | plan | `sparse` · `normal` · `dense` — how much text a slide may carry |
493
+ | `theme` | `ink` | emit | `ink` · `paper` · `mono` |
494
+ | `animationSpeed` | `1` | emit | multiplies every duration, hold and beat length. Below 1 is faster |
495
+ | `narration.voice` | picked for `lang`+`tone` | narrate | an explicit edge-tts voice id |
496
+ | `narration.rate` | `+0%` | narrate | edge-tts prosody |
497
+ | `narration.pitch` | `+0Hz` | narrate | edge-tts prosody |
498
+ | `narration.subtitles` | `true` | narrate | write subtitle cues alongside the audio |
499
+
500
+ Three layers, in increasing precedence: these defaults, then the nearest
501
+ `decksmith.config.json` found by walking up from the working directory, then flags.
502
+ Walking up is what every other tool in a repo does, so a config at the project root
503
+ governs a deck built from a subdirectory without anyone naming a path.
504
+
505
+ ```json
506
+ {
507
+ "slides": 16,
508
+ "lang": "ko",
509
+ "tone": "conversational",
510
+ "density": "sparse",
511
+ "theme": "paper",
512
+ "animationSpeed": 0.8,
513
+ "narration": {
514
+ "enabled": true,
515
+ "voice": "ko-KR-HyunsuMultilingualNeural",
516
+ "rate": "+8%",
517
+ "subtitles": true
518
+ }
519
+ }
520
+ ```
521
+
522
+ An unknown key is an error, by name: a misspelled preference that is silently dropped
523
+ looks exactly like a preference the tool ignores, and you spend the next hour wondering
524
+ why `slideCount` did nothing.
525
+
526
+ ```
527
+ decksmith.config.json: unknown preference "narration.speed". Valid: enabled, voice, rate, pitch, subtitles.
528
+ ```
529
+
530
+ On the command line: `--slides --lang --tone --density` on `plan`, `--theme --speed` on
531
+ `build`, `--voice --rate --pitch --no-subtitles` on `narrate`, and all of them on `pack`,
532
+ which records the preferences the deck was made under.
533
+
534
+ A preference sitting at its default says nothing, so a stored artifact wins over it and
535
+ loses to anything you type. `plan` stamps `lang` and `theme` into the storyboard it
536
+ writes; `build` then uses the storyboard's unless `--theme` or a config file restates one.
537
+ Language is never overridden at build time — it describes copy that is already written.
538
+
539
+ ## Themes
540
+
541
+ Three, each a position rather than a hue.
542
+
543
+ | Theme | Ground | For |
544
+ |---|---|---|
545
+ | `ink` | near-black | a dark room and a projector. The default |
546
+ | `paper` | warm off-white | a lit room, a shared screen, print |
547
+ | `mono` | white on black, one red | bad projection and greyscale printing, where hue does not survive |
548
+
549
+ `mono`'s four tones are a grey ladder plus one red rather than four hues, because value is
550
+ what survives a bad projector and hue is not; four hues would have collapsed into one
551
+ grey. All three keep the Inter stack — font families auto-resolve from a fixed allowlist,
552
+ and a serif naming a family the bundle does not declare falls back silently.
553
+
554
+ A theme is a name and a palette, and that is the whole extension point: a new one is a
555
+ file in `src/emit/themes/` plus a line in `THEMES`. No archetype learns it exists.
556
+
557
+ ## Narration
558
+
559
+ ```sh
560
+ decksmith narrate storyboard.json --source source.json -o audio/
561
+ decksmith build storyboard.json --source source.json -o out/ # finds audio/ by itself
562
+ ```
563
+
564
+ `narrate` speaks each beat's `narration` field with
565
+ [edge-tts](https://github.com/rany2/edge-tts) and writes `audio/narration.json` beside the
566
+ mp3s. `build` picks that up from `audio/` next to the storyboard — or from `--narration
567
+ <file>`, or not at all with `--no-narration`. A deck with no narration builds exactly as
568
+ it always did, byte for byte.
569
+
570
+ **The unit is the stop, not the slide.** A beat's stops are its landing plus each of its
571
+ holds — the points a presenter pauses at — and each gets its own audio file and its own
572
+ subtitle cues. So the sentence a viewer hears is the sentence that belongs to the thing
573
+ that just appeared, and the deck advances on speech rather than on a number somebody
574
+ guessed. Write one sentence per reveal, in reveal order. Fewer sentences than stops leaves
575
+ the later reveals silent; more, and the surplus joins the last one.
576
+
577
+ Playback reads the audio element's own clock, never a timer: a timer agrees with the audio
578
+ right up until the first stall, and a stall is exactly when a viewer looks at the subtitle
579
+ to find out what they missed. `m` mutes (captions keep tracking, and a muted element is
580
+ exempt from the autoplay policy, so it doubles as the escape hatch); `s` hides subtitles.
581
+ If the browser refuses to autoplay, the deck says `press any key for sound` once and
582
+ navigation carries on regardless.
583
+
584
+ Audio is content-addressed on the text, voice, rate and pitch, so re-narrating an edited
585
+ deck re-speaks only the sentences that moved — and two beats saying the same sentence
586
+ share one file. `verify` fails a deck whose island names an mp3 that is not in the
587
+ directory; nothing else notices, because a missing file looks identical to a browser that
588
+ declined to play.
589
+
590
+ `edge-tts` must be on your PATH, or installable as `python3 -m edge_tts`; set
591
+ `DECKSMITH_EDGE_TTS` to point at it directly. It is the only part of DeckSmith that needs
592
+ the network, which is why it is its own command and not a step inside `build`.
593
+
594
+ ## The `.deck` container
595
+
596
+ One file holding the whole deck: the source, the storyboard, the preferences it was made
597
+ under, the narration with its audio, and the media.
598
+
599
+ ```sh
600
+ decksmith pack storyboard.json --source source.json -o talk.deck # --bake (default) | --link
601
+ decksmith unpack talk.deck -o reopened/
602
+ ```
603
+
604
+ It carries the source and the storyboard, never the built HTML — the HTML is a projection
605
+ of those two, and every format profile makes a different one, so shipping it would be
606
+ shipping a stale copy of something that regenerates in a second. `unpack` puts the figures
607
+ back under `assets/` where `build` looks for them, so the round trip rebuilds offline.
608
+
609
+ Inside is a ZIP: `deck.json`, `media/`, `audio/`. Entries are sorted and stamped with a
610
+ fixed mtime, and already-compressed payloads are stored rather than deflated, so the same
611
+ inputs produce the same bytes and a 200 MB figure pack does not spend minutes
612
+ re-compressing its own JPEGs.
613
+
614
+ ### bake, link, embed
615
+
616
+ Every asset travels one of three ways, and you choose between the first two only.
617
+
618
+ - **bake** — the bytes come in. The pack works offline and forever. This is the default.
619
+ - **link** — the URL stays. The pack is small and the asset stays current. `--link`.
620
+ - **embed** — for URLs that are not files at all. **A YouTube or Vimeo link is a player
621
+ page, and is never downloaded** — doing so would be technically wrong and, usually,
622
+ against its terms. This is a property of the URL, not a choice: you cannot ask for it and
623
+ you cannot override it. The same holds for Dailymotion, Twitch, Loom, Wistia,
624
+ Streamable, Bilibili, SoundCloud and TikTok, and any subdomain of them.
625
+
626
+ Baking requires confidence that the URL names a file. A local path or a `data:` URL is one
627
+ by construction; a remote URL has to end in an extension we recognise, and one that comes
628
+ back as `text/html` is demoted on the spot — otherwise a login interstitial ends up stored
629
+ under a `.jpg`-shaped id. Every demotion is named on stderr rather than left for you to
630
+ discover. A fetch that fails throws instead of quietly downgrading: you asked for a
631
+ self-contained pack, and you would not otherwise learn you did not get one.
632
+
633
+ Reading is the half that matters, because a pack arrives from other people. The version is
634
+ checked before the manifest is believed, the manifest is validated, and entry paths that
635
+ are absolute or contain `..` are rejected on the way in and on the way out.
636
+
637
+ ## The storyboard is the human checkpoint
638
+
639
+ This is the centre of the design, not a convenience. Everything downstream is a
640
+ projection of `storyboard.json`, so it is the last point where a fix is cheap: editing one
641
+ beat costs a line, and fixing twelve realized slides costs an afternoon. Stop after `plan`
642
+ and read it. If the storyboard is mediocre, no amount of rendering fidelity rescues the
643
+ output.
644
+
645
+ It describes pedagogy, not geometry — no canvas size, no colours, no coordinates. Those
646
+ are `emit` decisions per format, which is what lets the same beats render as a lecture
647
+ panel and as a two-second punch in a reel.
648
+
649
+ Provenance is what makes the plan checkable rather than merely plausible. Because a beat
650
+ names the figure or equation it rests on, a later pass can ask whether the animation
651
+ actually asserts what the source asserts. Prior art verifies that slides *look* fine;
652
+ nothing verifies that they are *true*.
653
+
654
+ ## The twelve archetypes
655
+
656
+ The explanatory vocabulary. These came out of hand-building a real deck
657
+ (`.planning/EXPERIMENT-002-thinksr-korean.md`), not from guessing at what might be useful.
658
+
659
+ | Archetype | For | Key params |
660
+ |---|---|---|
661
+ | `title` | opening or section break | `headline`, `eyebrow?`, `sub?` |
662
+ | `claim-figure` | one assertion beside the figure that supports it | `claim`, `figureId` |
663
+ | `equation-walk` | an equation explained symbol by symbol | `equationId`, `terms` (1–4) |
664
+ | `data-table` | a results table with rows revealed in argument order | `tableId`, `highlight` |
665
+ | `line-chart` | a trend, with per-step deltas | `points`, `deltas?`, `readout?` |
666
+ | `callout` | 1–3 panels of prose: definitions, contrasts, takeaways | `panels`, `note?` |
667
+ | `pipeline` | stages in a flow, arrowed, with an optional feedback loop | `stages` (2–6), `loop?` |
668
+ | `annotated-figure` | a figure cropped to the panel under discussion, with leader lines | `figureId`, `crop?`, `notes` |
669
+ | `grid` | regions of a field lit in turn: windows, patches, receptive fields | `cols`, `rows`, `regions` |
670
+ | `bar-compare` | magnitudes that share a unit, grown from zero | `bars` (2–8), `unit?` |
671
+ | `stack` | layers drawn bottom-up as offset planes | `layers` (2–7) |
672
+ | `split-compare` | two things side by side, each figure or lines | `left`, `right` |
673
+
674
+ The last six draw the mechanism rather than describe it, and `verify` warns when a deck
675
+ leans on the others: a deck of headlines and bullet panels is what every other slide
676
+ generator already makes.
677
+
678
+ Each maps to exactly one emitter. Adding a domain means adding archetypes — the core never
679
+ learns what a camera frustum or an orderbook is.
680
+
681
+ ## Adding an archetype
682
+
683
+ 1. Add a params schema and one member to the `beatSchema` union in `src/types.ts`. The
684
+ union is closed on purpose: an unhandled archetype is a type error, not a runtime
685
+ surprise.
686
+ 2. Write `src/emit/archetypes/<name>.ts` as an `Emitter<"<name>">`. It returns a `Scene`:
687
+ inner HTML, GSAP statements, hold points, and its own CSS. It owns one scene's insides
688
+ and nothing else.
689
+ 3. Register it in `src/emit/archetypes/index.ts`.
690
+
691
+ Nothing else changes. The document shell, the deck runtime, the format profiles and the
692
+ verify gates never learn the new name.
693
+
694
+ ## Deck navigation is ours
695
+
696
+ The step layer in `src/deck/` is DeckSmith code, not upstream's, because upstream deck
697
+ navigation does not work at 0.7.71. `player.scenes` never populates, so
698
+ `SlideshowController` has no slide-to-time map to bind and every key press is a no-op —
699
+ reproduced on HeyGen's own unmodified reference example, on both 0.7.71 and 0.7.72. What
700
+ does work, exactly as documented, is `player.seek(t)`. So we read the slideshow island,
701
+ map steps to absolute times, and drive `seek()` ourselves. It is about a hundred lines and
702
+ it stops the primary deliverable from being blocked on someone else's roadmap.
703
+
704
+ Full writeup, including the control experiment that settled it:
705
+ [`.planning/EXPERIMENT-003-deck-mode.md`](.planning/EXPERIMENT-003-deck-mode.md).
706
+
707
+ The corollary is worth internalising before you trust a green gate: `check` has passed
708
+ twice on artifacts that were broken. The gates verify the mechanics of what the structure
709
+ exposes, and a structurally wrong deck exposes nothing to check.
710
+
711
+ ## Invariants the generator enforces
712
+
713
+ These were all learned by breaking them, and they are why build output is generated
714
+ mechanically rather than free-hand.
715
+
716
+ - Every tween is `fromTo`. `from()` captures its end state at construction, which is wrong
717
+ under the arbitrary seeking that navigation performs.
718
+ - Every timeline selector is scoped to its scene (`#s3 .term`, never `.term`). Unscoped
719
+ selectors fail lint and, once bundled, silently animate other scenes' elements.
720
+ - Scenes carry `data-composition-id/start/duration/label` and nothing else — no
721
+ `data-track-index`, no per-scene width or height.
722
+ - Each scene registers its own paused timeline with times relative to its own start; the
723
+ root timeline holds only a dummy tween spanning the deck.
724
+ - KaTeX renders with `output: "html"`. The default also emits a hidden MathML mirror that
725
+ the layout inspector reads as overlapping text.
726
+ - No `Date.now()`, no `Math.random()`, no network at render time *in the composition*.
727
+ Two renders of an image-free deck must be byte-identical. `deck.html` is exempt: it is
728
+ presented, never rendered, and its subtitle loop legitimately reads a clock.
729
+ - `deck.html` never contains the string `data-composition-id`. A root-level HTML file that
730
+ does trips lint's `multiple_root_compositions`, and the deck stops being navigable.
731
+ - Fonts auto-resolve only from a fixed allowlist. Inter is on it, Noto Sans KR is not, so
732
+ every CJK deck ships its own subsetted `@font-face` or the text silently falls back.
733
+ - Audience text never goes below 40px at 1920x1080, display equations sit at 60–76px. A
734
+ 30px equation passes every automated gate and is unreadable from row six.
735
+ - **No archetype declares its own content width.** The box comes from
736
+ `contentW(format)` / `contentH(format)` in `src/emit/kit.ts`, which are
737
+ `format.width - 2 * PAD_X` and `format.height - 2 * PAD_Y` — the same padding `baseCss`
738
+ writes, so the stylesheet and the arithmetic cannot drift. A module-level `const W =
739
+ 1700` is 16:9 hardcoded into a file that will one day be asked for 1080×1920, and that
740
+ is exactly how the vertical deck came to run off its own canvas while reporting PASS.
741
+ Pixel counts chosen against the 1700px box (`bar-compare`'s plot minimums, for one) go
742
+ through `share(px, width)` so they stay proportions rather than becoming constants.
743
+ - **A CSS class belongs to exactly one archetype.** One stylesheet serves the whole deck,
744
+ so a class is deck-global while the file declaring it looks local. Two archetypes once
745
+ both defined `.stackwrap`, with opposite intentions about stretching; it rendered
746
+ correctly only because one wrapper happened to already be its box's width. No gate reads
747
+ CSS, so this is pinned by a test instead — `archetypes.test.ts` fails when two
748
+ archetypes say different things about the same class name.
749
+ - **Nothing is driven by a GSAP callback.** Capture seeks rather than plays, and `seek()`
750
+ passes `suppressEvents` — so `onUpdate`, `onStart` and `onComplete` never fire while a
751
+ frame is being taken. Motion applied from a callback therefore looks right in a browser
752
+ and renders a **frozen video**, with every gate green: lint, check, the type floor, and
753
+ even `drift`, which passes twice over because both renders freeze identically. State
754
+ must be applied by the thing being seeked — tween the property. This is the most
755
+ dangerous failure shape in the project, and no automated gate can see it.
756
+
757
+ ## Repo layout
758
+
759
+ ```
760
+ src/index.ts the library surface — the only file consumers import
761
+ src/cli.ts the nine verbs, argv and stderr
762
+ src/types.ts the contract: Source, Storyboard, Beat, Format, Verdict
763
+ src/prefs.ts the three-layer preference resolver
764
+ src/emit/kit.ts the seam between the deck shell and the archetype emitters
765
+ src/emit/archetypes/ one emitter per archetype
766
+ src/emit/themes/ one palette per file; the registry is the extension point
767
+ src/narrate/ edge-tts, one segment per stop
768
+ src/pack/ the .deck container and its bake/link/embed policy
769
+ src/deck/ our step layer over player.seek(), and the subtitle reader
770
+ src/server/ `npm run serve` — routes, queue, the five stages, and the one page
771
+ (ui.ts). Everything here reaches the library through ../index.js
772
+ only; see the note at the top of ui.ts for what breaks otherwise.
773
+ .planning/ the design sketch and the experiment writeups
774
+ experiments/ hand-built decks; hf-thinksr is the shape the emitter targets
775
+ ```
776
+
777
+ `experiments/hf-thinksr/index.html` is a working deck built by hand. When a generated deck
778
+ misbehaves, diff against it.
779
+
780
+ ## A note on `npm audit`
781
+
782
+ `npm audit` reports advisories against `sharp`, `onnxruntime-node`, `adm-zip` and
783
+ `@hono/node-server`. Every one is transitive through hyperframes' own build toolchain, and
784
+ none is fixable from here. This is build-time tooling running on inputs we author, and
785
+ the CLI and library expose no network-facing surface, so the advisories are tracked, not
786
+ gating. (`npm run serve` does listen — see "Running the server" for what that is and is
787
+ not ready for — but it binds loopback by default and none of these packages is on its
788
+ request path.)
789
+ `.github/workflows/upstream-drift.yml` files an issue when the pin falls behind, which is
790
+ where a real fix would arrive.
791
+
792
+ ## Licence
793
+
794
+ MIT.