@bytesbybrandon/living-docs 0.2.1 → 0.3.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.
package/README.md CHANGED
@@ -10,8 +10,10 @@ Ships an AXI CLI and a stdio MCP server over the same core.
10
10
 
11
11
  ### Key Features
12
12
 
13
- * **Three themes:** `paper-light` for papers and reports, `paper-dark` for reading notes and essays, `hud` for repo audits and system snapshots.
13
+ * **Themes:** `paper-light` for papers and reports, `paper-dark` for reading notes and essays, `hud` for repo audits and system snapshots. Curated looks (`field-notes`, `editorial`, `blueprint`) sit on them, and `ingest --draft` designs a look and accent from the source itself.
14
14
  * **Generated visuals:** diagrams and charts render as inline SVG from a compact spec, and the console hero uses a seeded graph layout.
15
+ * **Living timeline:** cues highlight the source's own sentences, light up the figure parts they describe, count numbers, type text, and scroll the video frame. Motion stays inside each block, so figures and text stay flat and legible. Drafted docs get cues automatically. See [Timeline and video](docs/guide/timeline.md).
16
+ * **Video export:** `render --format mp4|webm|gif` records the timeline frame by frame, with optional Playwright and ffmpeg.
15
17
  * **Figure motion:** `draw`, `reveal`, and `steps` animate a generated figure with scoped CSS, then freeze for print and reduced motion. See [Document model](docs/guide/document-model.md).
16
18
  * **Per-source style tokens:** palette, seed color, type pairing, density, and figure treatment adapt a theme without arbitrary CSS. See [Style tokens](docs/guide/style-tokens.md).
17
19
  * **One self-contained file:** inline CSS, inline SVG, no network assets, and print rules. See [Guarantees](docs/guide/guarantees.md).
@@ -21,7 +23,7 @@ Ships an AXI CLI and a stdio MCP server over the same core.
21
23
 
22
24
  * **Language:** JavaScript on Node.js 20 or newer, ECMAScript modules.
23
25
  * **Rendering:** no UI framework and no build step; blocks, SVG, and theme CSS are plain JavaScript.
24
- * **Dependencies:** `@modelcontextprotocol/sdk` for the MCP server and `unpdf` for PDF text extraction.
26
+ * **Dependencies:** `@modelcontextprotocol/sdk` for the MCP server and `unpdf` for PDF text extraction. Video export uses the optional `playwright` package and `ffmpeg`.
25
27
  * **Tests:** the Node.js built-in test runner.
26
28
 
27
29
  ### Table of Contents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytesbybrandon/living-docs",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Turn a source document into a designed, self-contained living doc: academic paper, dark reading notes, or telemetry console. Ships an AXI CLI and an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,12 +47,23 @@
47
47
  "access": "public"
48
48
  },
49
49
  "scripts": {
50
- "test": "node --test tests/cli.test.js tests/ingest.test.js tests/mcp.test.js tests/motion.test.js tests/render.test.js tests/site.test.js tests/style.test.js tests/validate.test.js",
50
+ "test": "node --test tests/cli.test.js tests/ingest.test.js tests/mcp.test.js tests/motion.test.js tests/render.test.js tests/site.test.js tests/style.test.js tests/timeline.test.js tests/validate.test.js",
51
51
  "samples": "node scripts/build-samples.js",
52
52
  "site": "node scripts/build-site.js"
53
53
  },
54
54
  "dependencies": {
55
55
  "@modelcontextprotocol/sdk": "^1.32.2",
56
56
  "unpdf": "^2.1.0"
57
+ },
58
+ "devDependencies": {
59
+ "playwright": "^1.62.1"
60
+ },
61
+ "peerDependencies": {
62
+ "playwright": ">=1.40.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "playwright": {
66
+ "optional": true
67
+ }
57
68
  }
58
69
  }
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "blocks": [
13
13
  {
14
+ "id": "scene",
14
15
  "type": "graphScene",
15
16
  "title": "AI CONFIGS / OPEN SOURCE",
16
17
  "meta": ["SRC GITHUB", "5 TEAMS", "5 REPOS", "548.4K STARS", "$0"],
@@ -60,6 +61,7 @@
60
61
  }
61
62
  },
62
63
  {
64
+ "id": "grid",
63
65
  "type": "panels",
64
66
  "columns": 4,
65
67
  "items": [
@@ -256,6 +258,7 @@
256
258
  "markdown": "This page is a single moment in a repository audit: one matched skill, its repository, and the run that surfaced it. Every number on the console is a fact about that snapshot, not a projection. The graph counts nodes and links across five repositories; the coverage grid marks which job families each repository actually covers."
257
259
  },
258
260
  {
261
+ "id": "totals",
259
262
  "type": "stats",
260
263
  "items": [
261
264
  { "label": "Repositories scanned", "value": "5", "note": "github" },
@@ -270,5 +273,39 @@
270
273
  "title": "Reading the console",
271
274
  "body": "Panels are independent: each one answers a different question about the same snapshot. The hero graph shows adjacency, not importance; the coverage grid is the only panel that scores completeness."
272
275
  }
273
- ]
276
+ ],
277
+ "timeline": {
278
+ "loop": true,
279
+ "duration": 16,
280
+ "cues": [
281
+ { "at": 0.2, "dur": 0.6, "do": "reveal", "target": "grid/01" },
282
+ { "at": 0.28, "dur": 0.6, "do": "reveal", "target": "grid/02" },
283
+ { "at": 0.36, "dur": 0.6, "do": "reveal", "target": "grid/03" },
284
+ { "at": 0.44, "dur": 0.6, "do": "reveal", "target": "grid/04" },
285
+ { "at": 0.52, "dur": 0.6, "do": "reveal", "target": "grid/05" },
286
+ { "at": 0.6000000000000001, "dur": 0.6, "do": "reveal", "target": "grid/06" },
287
+ { "at": 0.6799999999999999, "dur": 0.6, "do": "reveal", "target": "grid/07" },
288
+ { "at": 0.76, "dur": 0.6, "do": "reveal", "target": "grid/08" },
289
+ { "at": 0.8400000000000001, "dur": 0.6, "do": "reveal", "target": "grid/09" },
290
+ { "at": 0.9199999999999999, "dur": 0.6, "do": "reveal", "target": "grid/10" },
291
+ { "at": 1, "dur": 0.6, "do": "reveal", "target": "grid/11" },
292
+ { "at": 1.08, "dur": 0.6, "do": "reveal", "target": "grid/12" },
293
+ { "at": 0.6, "dur": 1.6, "do": "count", "target": "grid/02" },
294
+ { "at": 0.6, "dur": 1.6, "do": "count", "target": "grid/08" },
295
+ { "at": 0.8, "dur": 1.6, "do": "count", "target": "grid/11" },
296
+ { "at": 1, "dur": 2.4, "do": "type", "target": "grid/06" },
297
+ { "at": 1.2, "dur": 2.2, "do": "type", "target": "grid/09" },
298
+ { "at": 3, "dur": 1.4, "do": "activate", "target": "scene/spec-driven-development" },
299
+ { "at": 3.6, "dur": 1.8, "do": "focus", "target": "grid/03" },
300
+ { "at": 5.6, "dur": 1.4, "do": "activate", "target": "scene/code-review-and-quality" },
301
+ { "at": 6.2, "dur": 1.8, "do": "focus", "target": "grid/07" },
302
+ { "at": 8.2, "dur": 1.4, "do": "activate", "target": "scene/context-engineering" },
303
+ { "at": 8.8, "dur": 1.8, "do": "focus", "target": "grid/12" },
304
+ { "at": 10.8, "dur": 1.4, "do": "activate", "target": "scene/interview-me" },
305
+ { "at": 11.4, "dur": 1.8, "do": "focus", "target": "grid/04" },
306
+ { "at": 13.4, "dur": 1.4, "do": "camera", "target": "grid/08" },
307
+ { "at": 13.6, "dur": 1.2, "do": "count", "target": "grid/08" },
308
+ { "at": 14.9, "dur": 1, "do": "camera", "target": "page" }
309
+ ]
310
+ }
274
311
  }
@@ -9,7 +9,7 @@
9
9
  "date": "2026",
10
10
  "kicker": "Reading notes · The End of Harness Engineering (2026)",
11
11
  "runningHead": "Harness engineering · loop engineering",
12
- "abstract": "A harness is the program written around a model, and writing it by hand has been the skill. The paper argues that this is ending. A hand-written harness is one guess about one task on one afternoon; once it ships, it runs for months, unread and unscored. In the loop the paper describes, the harness is a file that grades itself: a small 4B model reads what failed and proposes 8 edits, and only the edit that scores best on held-out questions survives. This repeats every round. The big model stays frozen; only the file around it changes.",
12
+ "abstract": "A harness is the program written around a model, and writing it by hand has been the skill. The paper argues that this is ending. [[mark:guess|A hand-written harness is one guess about one task on one afternoon;]] once it ships, it runs for months, unread and unscored. In the loop the paper describes, the harness is a file that grades itself: [[mark:editor|a small 4B model reads what failed and proposes 8 edits]], and [[mark:best|only the edit that scores best on held-out questions survives]]. This repeats every round. [[mark:frozen|The big model stays frozen; only the file around it changes.]]",
13
13
  "indexTerms": [
14
14
  "harness engineering",
15
15
  "self-improving harness",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  {
42
42
  "type": "figure",
43
+ "id": "loop",
43
44
  "caption": "Two ways to write a harness. The hand-written path is a single guess; the loop-written path reads failures, proposes edits, and keeps only what survives held-out scoring.",
44
45
  "spec": "diagram",
45
46
  "motion": "draw",
@@ -52,11 +53,11 @@
52
53
  { "id": "v0", "label": "harness v0", "sub": "one guess", "x": 214, "y": 130, "w": 140, "h": 62, "icon": "file" },
53
54
  { "id": "unscored", "label": "unscored", "sub": "never graded", "x": 78, "y": 240, "w": 150, "h": 52, "kind": "dashed" },
54
55
  { "id": "frozen", "label": "frozen big model", "sub": "e.g. Claude, never updated", "x": 402, "y": 130, "w": 180, "h": 62, "kind": "accent", "icon": "star" },
55
- { "id": "failures", "label": "failures", "sub": "what went wrong", "x": 402, "y": 28, "w": 150, "h": 54, "kind": "accent" },
56
- { "id": "editor", "label": "4B editor", "sub": "reads the failures", "x": 592, "y": 28, "w": 160, "h": 54 },
57
- { "id": "held", "label": "held-out questions", "x": 786, "y": 28, "w": 170, "h": 54 },
58
- { "id": "best", "label": "best edit kept", "sub": "1 of 8 survives", "x": 786, "y": 130, "w": 170, "h": 54 },
59
- { "id": "file", "label": "harness file", "sub": "now v3", "x": 786, "y": 236, "w": 170, "h": 56, "icon": "file" }
56
+ { "id": "failures", "label": "failures", "sub": "what went wrong", "x": 384, "y": 28, "w": 140, "h": 54, "kind": "accent" },
57
+ { "id": "editor", "label": "4B editor", "sub": "reads the failures", "x": 552, "y": 28, "w": 150, "h": 54 },
58
+ { "id": "held", "label": "held-out questions", "x": 826, "y": 28, "w": 150, "h": 54 },
59
+ { "id": "best", "label": "best edit kept", "sub": "1 of 8 survives", "x": 826, "y": 130, "w": 150, "h": 54 },
60
+ { "id": "file", "label": "harness file", "sub": "now v3", "x": 826, "y": 236, "w": 150, "h": 56, "icon": "file" }
60
61
  ],
61
62
  "edges": [
62
63
  { "from": "person", "to": "v0", "style": "arrow" },
@@ -110,6 +111,7 @@
110
111
  },
111
112
  {
112
113
  "type": "figure",
114
+ "id": "score",
113
115
  "caption": "Held-out score by round. Each round the editor proposes eight edits; the seven rejected are grey, the kept one is orange. The harness changes only when an edit wins.",
114
116
  "spec": "chart",
115
117
  "motion": "steps",
@@ -176,5 +178,26 @@
176
178
  "Held-out questions are kept out of the editing step and used only for scoring."
177
179
  ]
178
180
  }
179
- ]
181
+ ],
182
+ "timeline": {
183
+ "cues": [
184
+ { "at": 0.4, "dur": 2.2, "do": "highlight", "target": "mark:guess" },
185
+ { "at": 0.6, "dur": 1, "do": "focus", "target": "loop/person" },
186
+ { "at": 1.4, "dur": 1.1, "do": "focus", "target": "loop/v0" },
187
+ { "at": 2.8, "dur": 2.2, "do": "highlight", "target": "mark:editor" },
188
+ { "at": 3, "dur": 1, "do": "focus", "target": "loop/failures" },
189
+ { "at": 3.8, "dur": 1.1, "do": "focus", "target": "loop/editor" },
190
+ { "at": 5.2, "dur": 2.2, "do": "highlight", "target": "mark:best" },
191
+ { "at": 5.4, "dur": 1, "do": "focus", "target": "loop/held" },
192
+ { "at": 6.2, "dur": 1.1, "do": "focus", "target": "loop/best" },
193
+ { "at": 7.6, "dur": 2.2, "do": "highlight", "target": "mark:frozen" },
194
+ { "at": 7.8, "dur": 1, "do": "focus", "target": "loop/frozen" },
195
+ { "at": 8.6, "dur": 1.1, "do": "focus", "target": "loop/file" },
196
+ { "at": 10.2, "dur": 1.6, "do": "camera", "target": "loop/editor" },
197
+ { "at": 12.2, "dur": 1.4, "do": "camera", "target": "loop/best" },
198
+ { "at": 14, "dur": 1.8, "do": "camera", "target": "score" },
199
+ { "at": 15.4, "dur": 2, "do": "focus", "target": "score" },
200
+ { "at": 17.8, "dur": 1.8, "do": "camera", "target": "page" }
201
+ ]
202
+ }
180
203
  }
@@ -11,7 +11,7 @@
11
11
  "stamps": [
12
12
  "arXiv:2609.00006v1 [cs.SE] 15 Jul 2026"
13
13
  ],
14
- "abstract": "An agent is a model plus a harness: the runtime that couples an LLM to the world through a loop, tools, context management, safety controls, orchestration, and extension surfaces. This paper defines the harness, maps its seven canonical subsystems with the minimal and maximal implementation of each, and dissects eleven production coding harnesses along those subsystems. It does not benchmark or rank them; it describes and compares how the systems are built. Two absences and three extensibility standards survive a threefold corpus expansion across roughly four million lines of Python, TypeScript, and Rust.",
14
+ "abstract": "[[mark:agent|An agent is a model plus a harness: the runtime that couples an LLM to the world through a loop, tools, context management, safety controls, orchestration, and extension surfaces.]] This paper defines the harness, [[mark:maps|maps its seven canonical subsystems with the minimal and maximal implementation of each]], and dissects eleven production coding harnesses along those subsystems. It does not benchmark or rank them; it describes and compares how the systems are built. [[mark:standards|Two absences and three extensibility standards survive a threefold corpus expansion]] across roughly four million lines of Python, TypeScript, and Rust.",
15
15
  "indexTerms": [
16
16
  "coding agents",
17
17
  "harness engineering",
@@ -39,6 +39,7 @@
39
39
  },
40
40
  {
41
41
  "type": "figure",
42
+ "id": "anatomy",
42
43
  "caption": "An agent is a model plus a harness of seven subsystems. The harness owns everything outside the weights.",
43
44
  "spec": "diagram",
44
45
  "motion": "draw",
@@ -117,6 +118,7 @@
117
118
  },
118
119
  {
119
120
  "type": "figure",
121
+ "id": "standards",
120
122
  "caption": "Extensibility standards across eleven coding harnesses. Counts include partial support, greyed in the paper's original figure.",
121
123
  "spec": "chart",
122
124
  "motion": "steps",
@@ -163,6 +165,7 @@
163
165
  },
164
166
  {
165
167
  "type": "figure",
168
+ "id": "convergence",
166
169
  "caption": "Convergence becomes imitation: Codex reads Claude Code's hook vocabulary while OpenHands reads its plugin format.",
167
170
  "spec": "diagram",
168
171
  "motion": "draw",
@@ -182,6 +185,7 @@
182
185
  },
183
186
  {
184
187
  "type": "stats",
188
+ "id": "counts",
185
189
  "caption": "Counts stated in the abstract of the study.",
186
190
  "items": [
187
191
  { "label": "Cross-cutting observations", "value": "13" },
@@ -203,5 +207,31 @@
203
207
  "Adoption counts include partial support and are reported as fractions of eleven systems."
204
208
  ]
205
209
  }
206
- ]
210
+ ],
211
+ "timeline": {
212
+ "cues": [
213
+ { "at": 0.4, "dur": 4.2, "do": "highlight", "target": "mark:agent" },
214
+ { "at": 0.6, "dur": 0.9, "do": "focus", "target": "anatomy/model" },
215
+ { "at": 1.05, "dur": 0.9, "do": "focus", "target": "anatomy/loop" },
216
+ { "at": 1.5, "dur": 0.9, "do": "focus", "target": "anatomy/tools" },
217
+ { "at": 1.9500000000000002, "dur": 0.9, "do": "focus", "target": "anatomy/context" },
218
+ { "at": 2.4, "dur": 0.9, "do": "focus", "target": "anatomy/safety" },
219
+ { "at": 2.85, "dur": 0.9, "do": "focus", "target": "anatomy/orchestration" },
220
+ { "at": 3.3000000000000003, "dur": 0.9, "do": "focus", "target": "anatomy/extensions" },
221
+ { "at": 3.75, "dur": 0.9, "do": "focus", "target": "anatomy/integration" },
222
+ { "at": 4.8, "dur": 1.6, "do": "camera", "target": "anatomy" },
223
+ { "at": 5, "dur": 2.4, "do": "highlight", "target": "mark:maps" },
224
+ { "at": 5.6, "dur": 2, "do": "focus", "target": "anatomy" },
225
+ { "at": 7.8, "dur": 1.8, "do": "camera", "target": "standards" },
226
+ { "at": 8, "dur": 2.6, "do": "highlight", "target": "mark:standards" },
227
+ { "at": 9.4, "dur": 2, "do": "focus", "target": "standards" },
228
+ { "at": 11.6, "dur": 1.8, "do": "camera", "target": "convergence" },
229
+ { "at": 12.6, "dur": 1, "do": "focus", "target": "convergence/cc" },
230
+ { "at": 13.4, "dur": 1, "do": "focus", "target": "convergence/codex" },
231
+ { "at": 14.2, "dur": 1, "do": "focus", "target": "convergence/openhands" },
232
+ { "at": 15.4, "dur": 1.6, "do": "camera", "target": "counts" },
233
+ { "at": 15.8, "dur": 1.6, "do": "count", "target": "counts" },
234
+ { "at": 18, "dur": 1.8, "do": "camera", "target": "page" }
235
+ ]
236
+ }
207
237
  }
package/src/axi/cli.js CHANGED
@@ -8,6 +8,7 @@ import { renderDoc, themeList } from '../render/index.js';
8
8
  import { validateDoc, formatErrors } from '../model/validate.js';
9
9
  import { BLOCKS, THEMES, META, STYLE, blockFieldSummary } from '../model/schema.js';
10
10
  import { createPreviewServer } from '../server/preview.js';
11
+ import { exportVideo, FORMATS } from '../export/video.js';
11
12
 
12
13
  const HOME = os.homedir();
13
14
  const BIN = fileURLToPath(new URL('../../bin/living-docs-axi.js', import.meta.url));
@@ -16,6 +17,7 @@ class UsageError extends Error {}
16
17
 
17
18
  const FLAGS = {
18
19
  '--theme': 'value',
20
+ '--seed': 'value',
19
21
  '--out': 'value', '-o': 'value',
20
22
  '--section': 'value',
21
23
  '--url': 'value',
@@ -24,11 +26,16 @@ const FLAGS = {
24
26
  '--port': 'value',
25
27
  '--outline': 'bool',
26
28
  '--draft': 'bool',
29
+ '--static': 'bool',
27
30
  '--json': 'bool',
28
31
  '--stdout': 'bool',
29
32
  '--no-open': 'bool',
30
33
  '--full': 'bool',
31
34
  '--block': 'value',
35
+ '--format': 'value',
36
+ '--size': 'value',
37
+ '--fps': 'value',
38
+ '--scale': 'value',
32
39
  '--help': 'bool', '-h': 'bool'
33
40
  };
34
41
 
@@ -215,9 +222,10 @@ async function ingest({ args, flags }) {
215
222
  ];
216
223
 
217
224
  if (flags.draft) {
218
- const theme = flags.theme || 'paper-dark';
219
- if (!THEMES[theme]) throw new UsageError(`unknown theme "${theme}" (one of: ${Object.keys(THEMES).join(', ')})`);
220
- const model = draftModel(ingested, { theme });
225
+ if (flags.theme && !THEMES[flags.theme]) throw new UsageError(`unknown theme "${flags.theme}" (one of: ${Object.keys(THEMES).join(', ')})`);
226
+ if (flags.seed && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(flags.seed)) throw new UsageError('--seed must be a hex color like #2f6f8f');
227
+ const model = draftModel(ingested, { theme: flags.theme, seed: flags.seed, cues: !flags.static });
228
+ const theme = model.theme;
221
229
  const validation = validateDoc(model);
222
230
  if (!validation.ok) {
223
231
  return [`error: draft model is invalid`, formatErrors(validation.errors)].join('\n');
@@ -293,9 +301,47 @@ function runRender(flags, { quiet = false } = {}) {
293
301
  return { ok: true, status: lines.join('\n'), file: out, result };
294
302
  }
295
303
 
296
- function renderCommand({ args, flags }) {
304
+ async function renderVideo(flags) {
305
+ const format = flags.format;
306
+ const { model, baseDir } = loadModel(flags._input);
307
+ const theme = flags.theme || model.theme || 'paper-light';
308
+ const out = flags.out && flags.out !== '-' ? flags.out : path.join('docs', `${slugify(model.meta?.title || 'document')}-${theme}.${format}`);
309
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
310
+ const started = Date.now();
311
+ let result;
312
+ try {
313
+ result = await exportVideo(model, {
314
+ theme, baseDir, format, out,
315
+ size: flags.size,
316
+ fps: flags.fps ? Number(flags.fps) : undefined,
317
+ scale: flags.scale ? Number(flags.scale) : undefined
318
+ });
319
+ } catch (error) {
320
+ if (error.name !== 'RenderError') throw error;
321
+ process.exitCode = 1;
322
+ return ['error: model invalid', formatErrors(error.errors)].join('\n');
323
+ }
324
+ return [
325
+ 'ok: true',
326
+ `file: ${displayPath(out)}`,
327
+ `format: ${format}`,
328
+ `theme: ${result.theme}`,
329
+ `size: ${result.width}x${result.height}`,
330
+ `frames: ${result.frames}`,
331
+ `seconds: ${result.duration}`,
332
+ `bytes: ${fs.statSync(out).size}`,
333
+ `render_ms: ${Date.now() - started}`
334
+ ].join('\n') + helpLines([`Run \`living-docs-axi render ${displayPath(flags._input)} --theme ${theme}\` for the live HTML`]);
335
+ }
336
+
337
+ async function renderCommand({ args, flags }) {
297
338
  flags._input = args[0];
298
339
  if (!flags._input) throw new UsageError('render needs a model path (or `-` for stdin)');
340
+ if (flags.format && flags.format !== 'html') {
341
+ if (!FORMATS.includes(flags.format)) throw new UsageError(`--format must be one of: html, ${FORMATS.join(', ')}`);
342
+ if (flags.stdout) throw new UsageError('--stdout only works with html');
343
+ return renderVideo(flags);
344
+ }
299
345
  const rendered = runRender(flags);
300
346
  process.exitCode = rendered.ok ? 0 : 1;
301
347
  return rendered.status;
@@ -343,8 +389,8 @@ async function preview({ args, flags }) {
343
389
  }
344
390
 
345
391
  const COMMAND_HELP = {
346
- ingest: 'ingest <path|url|-> [--url <url>] [--text <text>] [--outline] [--section <n|id>] [--draft] [--theme <name>] [-o <file>] [--max-chars <n>] [--full]',
347
- render: 'render <model.json|-> [--theme <name>] [-o <file>] [--stdout]',
392
+ ingest: 'ingest <path|url|-> [--url <url>] [--text <text>] [--outline] [--section <n|id>] [--draft] [--static] [--theme <name>] [--seed <hex>] [-o <file>] [--max-chars <n>] [--full]',
393
+ render: 'render <model.json|-> [--theme <name>] [-o <file>] [--stdout] [--format html|mp4|webm|gif] [--size 1080x1350] [--fps 30] [--scale 1]',
348
394
  validate: 'validate <model.json|->',
349
395
  preview: 'preview [file|dir] [--port <n>] [--no-open]',
350
396
  themes: 'themes',
@@ -384,7 +430,7 @@ export async function main(argv) {
384
430
  case 'ingest':
385
431
  return await ingest({ args, flags });
386
432
  case 'render':
387
- return renderCommand({ args, flags });
433
+ return await renderCommand({ args, flags });
388
434
  case 'validate':
389
435
  return validateCommand({ args, flags });
390
436
  case 'preview':
@@ -0,0 +1,150 @@
1
+ // Video export: render the doc as a fixed stage, seek the timeline frame by frame in headless
2
+ // Chromium, and pipe PNG frames to ffmpeg. Playwright and ffmpeg are optional, found at run time.
3
+
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import path from 'node:path';
7
+ import { renderDoc, RenderError } from '../render/index.js';
8
+ import { timelineDuration } from '../render/timeline.js';
9
+
10
+ export const FORMATS = ['mp4', 'webm', 'gif'];
11
+
12
+ export class ExportError extends Error {
13
+ constructor(message, { code, hint } = {}) {
14
+ super(message);
15
+ this.name = 'ExportError';
16
+ this.code = code;
17
+ this.hint = hint;
18
+ }
19
+ }
20
+
21
+ export function parseSize(value = '1080x1350') {
22
+ const match = /^(\d{2,4})x(\d{2,4})$/.exec(String(value));
23
+ if (!match) throw new ExportError(`size must look like 1080x1350, got "${value}"`, { code: 'bad-size' });
24
+ const [width, height] = [Number(match[1]), Number(match[2])];
25
+ // yuv420p needs even dimensions.
26
+ if (width % 2 || height % 2) throw new ExportError('size needs even width and height', { code: 'bad-size' });
27
+ return { width, height };
28
+ }
29
+
30
+ async function loadChromium() {
31
+ try {
32
+ return (await import('playwright')).chromium;
33
+ } catch {
34
+ throw new ExportError('video export needs the optional playwright package', {
35
+ code: 'missing-playwright',
36
+ hint: 'install playwright beside living-docs (npm i -D playwright, or npx -p @bytesbybrandon/living-docs -p playwright living-docs-axi ...), then npx playwright install chromium'
37
+ });
38
+ }
39
+ }
40
+
41
+ function ffmpegPath() {
42
+ const bin = process.env.LIVING_DOCS_FFMPEG || 'ffmpeg';
43
+ const probe = spawnSync(bin, ['-version'], { stdio: 'ignore' });
44
+ if (probe.error || probe.status !== 0) {
45
+ throw new ExportError(`ffmpeg not found (${bin})`, {
46
+ code: 'missing-ffmpeg',
47
+ hint: 'install ffmpeg on PATH or set LIVING_DOCS_FFMPEG'
48
+ });
49
+ }
50
+ return bin;
51
+ }
52
+
53
+ /**
54
+ * Seek the stage frame by frame and hand each PNG to onFrame(buffer, index).
55
+ * Frames are a pure function of t, so two runs on one machine match.
56
+ */
57
+ export async function captureFrames({ html, width, height, scale = 1, fps = 30, duration, image = 'png' }, onFrame) {
58
+ const chromium = await loadChromium();
59
+ let browser;
60
+ try {
61
+ browser = await chromium.launch();
62
+ } catch (error) {
63
+ throw new ExportError(`could not start Chromium: ${error.message.split('\n')[0]}`, {
64
+ code: 'missing-browser',
65
+ hint: 'npx playwright install chromium'
66
+ });
67
+ }
68
+ try {
69
+ const page = await browser.newPage({
70
+ viewport: { width: Math.round(width / scale), height: Math.round(height / scale) },
71
+ deviceScaleFactor: scale
72
+ });
73
+ const pageErrors = [];
74
+ page.on('pageerror', error => pageErrors.push(error.message));
75
+ // The flag must exist before the runtime script runs; setContent skips init scripts.
76
+ await page.setContent(html.replace('<head>', '<head><script>window.__ldCapture = true;</script>'), { waitUntil: 'load' });
77
+ if (!(await page.evaluate(() => window.__ld && window.__ldCapture))) throw new ExportError('stage runtime did not start in capture mode', { code: 'stage-error' });
78
+ await page.evaluate(() => document.fonts.ready);
79
+ if (pageErrors.length) throw new ExportError(`stage script failed: ${pageErrors[0]}`, { code: 'stage-error' });
80
+ const total = Math.max(1, Math.round(duration * fps));
81
+ for (let i = 0; i < total; i++) {
82
+ await page.evaluate(t => window.__ld.seek(t), i / fps);
83
+ await onFrame(await page.screenshot(image === 'jpeg' ? { type: 'jpeg', quality: 95 } : { type: 'png' }), i);
84
+ }
85
+ return { frames: total };
86
+ } finally {
87
+ await browser.close();
88
+ }
89
+ }
90
+
91
+ function encoderArgs(format, { fps, gifWidth, gifFps }) {
92
+ if (format === 'mp4') {
93
+ return ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '22', '-movflags', '+faststart'];
94
+ }
95
+ if (format === 'webm') {
96
+ return ['-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', '-crf', '33', '-b:v', '0', '-row-mt', '1'];
97
+ }
98
+ // One pass: build a palette from every frame, then dither against it.
99
+ return ['-vf', `fps=${Math.min(gifFps, fps)},scale=${gifWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=stats_mode=full[p];[b][p]paletteuse=dither=sierra2_4a`, '-loop', '0'];
100
+ }
101
+
102
+ /** Always hand ffmpeg a local file: never an option, a URL, or a pipe, whatever the caller passed as `out`. */
103
+ export function ffmpegTarget(out) {
104
+ if (typeof out !== 'string' || !out.trim()) throw new ExportError('out must be a file path', { code: 'bad-output' });
105
+ return `file:${path.resolve(out)}`;
106
+ }
107
+
108
+ /**
109
+ * Render a model to mp4, webm, or gif.
110
+ * @returns {Promise<{file: string, format: string, frames: number, duration: number, width: number, height: number, framesSha256: string, theme: string}>}
111
+ */
112
+ export async function exportVideo(model, { theme, baseDir, format = 'mp4', size, fps = 30, scale = 1, out, gifWidth = 720, gifFps = 15 } = {}) {
113
+ if (!FORMATS.includes(format)) throw new ExportError(`format must be one of: html, ${FORMATS.join(', ')}`, { code: 'bad-format' });
114
+ const { width, height } = parseSize(size);
115
+ const rendered = renderDoc(model, { theme, baseDir, stage: true });
116
+ if (!rendered.ok) throw new RenderError('model invalid', { errors: rendered.errors });
117
+ const bin = ffmpegPath();
118
+ const duration = timelineDuration(model);
119
+ const ffmpeg = spawn(bin, [
120
+ '-y', '-loglevel', 'error',
121
+ '-f', 'image2pipe', '-framerate', String(fps), '-i', '-',
122
+ ...encoderArgs(format, { fps, gifWidth, gifFps }),
123
+ '-fflags', '+bitexact', '-map_metadata', '-1',
124
+ '-f', format, ffmpegTarget(out)
125
+ ], { stdio: ['pipe', 'ignore', 'pipe'] });
126
+ let stderr = '';
127
+ ffmpeg.stderr.on('data', chunk => { stderr += chunk; });
128
+ const done = new Promise((resolve, reject) => {
129
+ ffmpeg.on('error', reject);
130
+ ffmpeg.on('close', code => (code === 0 ? resolve() : reject(new ExportError(`ffmpeg failed: ${stderr.trim().split('\n').pop()}`, { code: 'ffmpeg-failed' }))));
131
+ });
132
+ const hash = createHash('sha256');
133
+ let captured;
134
+ try {
135
+ // JPEG frames are ~4x faster to grab; GIF keeps lossless PNG so the palette is not built from artifacts.
136
+ const image = format === 'gif' ? 'png' : 'jpeg';
137
+ captured = await captureFrames({ html: rendered.html, width, height, scale, fps, duration, image }, buffer => {
138
+ hash.update(buffer);
139
+ if (ffmpeg.stdin.write(buffer)) return null;
140
+ return new Promise(resolve => ffmpeg.stdin.once('drain', resolve));
141
+ });
142
+ } catch (error) {
143
+ ffmpeg.stdin.end();
144
+ await done.catch(() => {});
145
+ throw error;
146
+ }
147
+ ffmpeg.stdin.end();
148
+ await done;
149
+ return { file: out, format, frames: captured.frames, duration, width, height, framesSha256: hash.digest('hex'), theme: rendered.theme };
150
+ }
@@ -0,0 +1,81 @@
1
+ // Draft a timeline from the source itself: mark key sentences in reading order,
2
+ // highlight them one after another, pan the stage camera to section leads,
3
+ // and count up headline numbers. Deterministic: same model in, same cues out.
4
+
5
+ const STEP = 2.4;
6
+ const HOLD = 2.2;
7
+ const MAX_ABSTRACT = 4;
8
+ const MAX_LEADS = 5;
9
+
10
+ // Sentences that would collide with inline markup are left unmarked.
11
+ const SAFE = /^[^[\]|`*~]{40,240}$/;
12
+
13
+ function sentences(text) {
14
+ return String(text).split(/(?<=[.!?])\s+(?=[A-Z0-9"(])/).map(s => s.trim()).filter(Boolean);
15
+ }
16
+
17
+ function pick(text, limit) {
18
+ return sentences(text).filter(s => SAFE.test(s)).slice(0, limit);
19
+ }
20
+
21
+ function wrap(text, sentence, id) {
22
+ return text.replace(sentence, () => `[[mark:${id}|${sentence}]]`);
23
+ }
24
+
25
+ /**
26
+ * Return a copy of the model with marks, block ids, and a timeline.
27
+ * A model that already has a timeline is returned unchanged.
28
+ */
29
+ export function draftCues(model) {
30
+ if (model.timeline) return model;
31
+ const next = structuredClone(model);
32
+ const cues = [];
33
+ let at = 0.6;
34
+ let n = 0;
35
+ const usedIds = new Set(next.blocks.map(block => block.id).filter(Boolean));
36
+ const blockId = (block, prefix) => {
37
+ if (block.id) return block.id;
38
+ let i = 1;
39
+ while (usedIds.has(`${prefix}-${i}`)) i++;
40
+ block.id = `${prefix}-${i}`;
41
+ usedIds.add(block.id);
42
+ return block.id;
43
+ };
44
+
45
+ if (next.meta.abstract) {
46
+ for (const sentence of pick(next.meta.abstract, MAX_ABSTRACT)) {
47
+ const id = `s${++n}`;
48
+ next.meta.abstract = wrap(next.meta.abstract, sentence, id);
49
+ cues.push({ at, dur: HOLD, do: 'highlight', target: `mark:${id}` });
50
+ at += STEP;
51
+ }
52
+ }
53
+
54
+ let leads = 0;
55
+ let afterHeading = false;
56
+ let moved = false;
57
+ for (const block of next.blocks) {
58
+ if (block.type === 'heading' && (block.level || 2) === 2) { afterHeading = true; continue; }
59
+ if (block.type === 'stats') {
60
+ cues.push({ at, dur: 1.6, do: 'count', target: blockId(block, 'stats') });
61
+ at += 1;
62
+ continue;
63
+ }
64
+ if (!afterHeading || block.type !== 'prose' || leads >= MAX_LEADS) continue;
65
+ afterHeading = false;
66
+ const [sentence] = pick(block.markdown, 1);
67
+ if (!sentence) continue;
68
+ const id = `s${++n}`;
69
+ block.markdown = wrap(block.markdown, sentence, id);
70
+ cues.push({ at, dur: 1.4, do: 'camera', target: blockId(block, 'lead') });
71
+ cues.push({ at: at + 0.5, dur: HOLD, do: 'highlight', target: `mark:${id}` });
72
+ at += STEP + 0.6;
73
+ leads++;
74
+ moved = true;
75
+ }
76
+ if (moved) cues.push({ at, dur: 1.6, do: 'camera', target: 'page' });
77
+
78
+ if (!cues.length) return model;
79
+ next.timeline = { cues };
80
+ return next;
81
+ }
@@ -1,3 +1,5 @@
1
+ import { draftCues } from './cues.js';
2
+ import { draftDesign } from './style.js';
1
3
  import fs from 'node:fs/promises';
2
4
  import path from 'node:path';
3
5
  import { parseMarkdown, slug, wordCount } from '../model/markdown.js';
@@ -197,7 +199,7 @@ export function blockToModelBlock(block) {
197
199
  * Build a valid draft model straight from a source. This is the zero-judgment path:
198
200
  * it renders, then an agent upgrades sections, figures, and callouts.
199
201
  */
200
- export function draftModel(ingested, { theme = 'paper-dark', includePreamble = true, maxBlocks = 400 } = {}) {
202
+ export function draftModel(ingested, { theme, seed, includePreamble = true, maxBlocks = 400, cues = true } = {}) {
201
203
  const { meta, sections, preamble } = ingested;
202
204
  const blocks = [];
203
205
  let abstractPending = Boolean(meta.abstract);
@@ -224,9 +226,9 @@ export function draftModel(ingested, { theme = 'paper-dark', includePreamble = t
224
226
  if (blocks.length >= maxBlocks) break;
225
227
  }
226
228
 
227
- return {
229
+ const model = {
228
230
  schemaVersion: 1,
229
- theme,
231
+ theme: 'paper-dark',
230
232
  meta: {
231
233
  title: meta.title,
232
234
  ...(meta.subtitle ? { subtitle: meta.subtitle } : {}),
@@ -236,6 +238,10 @@ export function draftModel(ingested, { theme = 'paper-dark', includePreamble = t
236
238
  },
237
239
  blocks: blocks.length ? blocks : [{ type: 'prose', markdown: meta.title }]
238
240
  };
241
+ const design = draftDesign(model, { theme, seed });
242
+ model.theme = design.theme;
243
+ if (Object.keys(design.style).length) model.meta.style = design.style;
244
+ return cues ? draftCues(model) : model;
239
245
  }
240
246
 
241
247
  /**