@bytesbybrandon/living-docs 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/docs/guide/cli.md +61 -0
- package/docs/guide/document-model.md +117 -0
- package/docs/guide/guarantees.md +31 -0
- package/docs/guide/mcp.md +47 -0
- package/docs/guide/pipeline.md +36 -0
- package/docs/guide/reactive-workflow.md +21 -0
- package/docs/guide/style-tokens.md +60 -0
- package/docs/guide/timeline.md +75 -0
- package/package.json +14 -2
- package/samples/agent-skills-audit.json +38 -1
- package/samples/end-of-harness-engineering.json +30 -7
- package/samples/harness-engineering.json +32 -2
- package/src/axi/cli.js +53 -7
- package/src/export/video.js +150 -0
- package/src/ingest/cues.js +81 -0
- package/src/ingest/index.js +9 -3
- package/src/ingest/style.js +57 -0
- package/src/mcp/server.js +32 -8
- package/src/model/markdown.js +7 -2
- package/src/model/schema.js +64 -12
- package/src/model/validate.js +82 -4
- package/src/render/blocks.js +9 -5
- package/src/render/index.js +16 -5
- package/src/render/style.js +1 -1
- package/src/render/svg.js +13 -16
- package/src/render/themes/hud.js +18 -7
- package/src/render/themes/variants.js +43 -0
- package/src/render/timeline.js +304 -0
|
@@ -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":
|
|
56
|
-
{ "id": "editor", "label": "4B editor", "sub": "reads the failures", "x":
|
|
57
|
-
{ "id": "held", "label": "held-out questions", "x":
|
|
58
|
-
{ "id": "best", "label": "best edit kept", "sub": "1 of 8 survives", "x":
|
|
59
|
-
{ "id": "file", "label": "harness file", "sub": "now v3", "x":
|
|
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
|
-
|
|
219
|
-
if (
|
|
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
|
|
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 -y -p living-docs-axi -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
|
+
}
|
package/src/ingest/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { THEMES } from '../model/schema.js';
|
|
2
|
+
import { toHex, hslToRgb, parseHex } from '../render/style.js';
|
|
3
|
+
|
|
4
|
+
// Design a doc from its own source: pick a look from the shape of the content and
|
|
5
|
+
// derive an accent from the title. Deterministic, validated tokens only, always overridable.
|
|
6
|
+
|
|
7
|
+
const BASES = new Set(['paper-light', 'paper-dark']);
|
|
8
|
+
|
|
9
|
+
function hash(text) {
|
|
10
|
+
let h = 2166136261;
|
|
11
|
+
for (const ch of text) h = Math.imul(h ^ ch.codePointAt(0), 16777619);
|
|
12
|
+
return h >>> 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function words(blocks) {
|
|
16
|
+
return blocks.reduce((sum, b) => sum + (typeof b.markdown === 'string' ? b.markdown.split(/\s+/).length : 0), 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pick a theme from the content shape. */
|
|
20
|
+
export function chooseTheme(model) {
|
|
21
|
+
const body = model.blocks.filter(b => b.type !== 'heading');
|
|
22
|
+
const count = type => body.filter(b => b.type === type).length;
|
|
23
|
+
const code = count('code');
|
|
24
|
+
const tables = count('table');
|
|
25
|
+
const dense = count('table') + count('list');
|
|
26
|
+
if (code >= 3 || (body.length && code / body.length >= 0.15)) return 'blueprint';
|
|
27
|
+
if (model.meta.abstract && (tables >= 1 || model.blocks.some(b => b.type === 'heading' && /^\d/.test(b.title)))) return 'paper-light';
|
|
28
|
+
if (words(body) >= 1500 && tables === 0) return 'editorial';
|
|
29
|
+
if (body.length >= 10 && dense / body.length >= 0.4) return 'paper-dark';
|
|
30
|
+
if (body.length <= 14) return 'field-notes';
|
|
31
|
+
return 'paper-dark';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Accent seed from the title: a stable hue at a tuned saturation and lightness. */
|
|
35
|
+
export function seedFromTitle(title, theme) {
|
|
36
|
+
const hue = (hash(title) % 360) / 360;
|
|
37
|
+
return toHex(hslToRgb({ h: hue, s: 0.5, l: theme === 'paper-dark' ? 0.62 : 0.38 }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return { theme, style } for a drafted model.
|
|
42
|
+
* `theme` and `seed` are explicit overrides; curated looks keep their own accent.
|
|
43
|
+
*/
|
|
44
|
+
export function draftDesign(model, { theme, seed } = {}) {
|
|
45
|
+
if (theme && !THEMES[theme]) throw new Error(`unknown theme "${theme}"`);
|
|
46
|
+
const chosen = theme || chooseTheme(model);
|
|
47
|
+
const base = THEMES[chosen].base || chosen;
|
|
48
|
+
const style = {};
|
|
49
|
+
const accent = seed && parseHex(seed) ? seed : BASES.has(chosen) ? seedFromTitle(model.meta.title, chosen) : null;
|
|
50
|
+
if (accent) style.seed = accent;
|
|
51
|
+
const body = model.blocks.length;
|
|
52
|
+
if (body <= 12) style.density = 'roomy';
|
|
53
|
+
else if (body >= 80) style.density = 'compact';
|
|
54
|
+
if (chosen === 'blueprint') style.type = 'technical';
|
|
55
|
+
if (chosen === 'editorial') style.figure = 'minimal';
|
|
56
|
+
return { theme: chosen, base, style };
|
|
57
|
+
}
|