wicked-garden 12.41.0 → 12.42.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.
@@ -1 +1 @@
1
- {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.41.0"}], "version": "12.41.0"}
1
+ {"name": "wicked-garden", "owner": {"name": "Mike Parcewski"}, "plugins": [{"name": "wicked-garden", "source": "./", "description": "Your coding agent already plans and swarms \u2014 Wicked Garden is the curated toolkit for what it can't: proof-not-claims gates, code links grep can't see, deterministic refactor, cross-session memory, real multi-model second opinions. Don't fight the harness; fill its gaps. The gate needs wicked-vault (a self-contained infra peer, installed directly) and re-derives through garden's built-in engine; estate/bus are opt-in layers (wicked-testing and wicked-brain are retired \u2014 QE ships in-catalog as the qe domain, memory lives in wicked-estate).", "version": "12.42.0"}], "version": "12.42.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.41.0",
3
+ "version": "12.42.0",
4
4
  "description": "Your coding agent's harness already plans and swarms; Wicked Garden is the curated toolkit for what it can't do alone. Proof, not claims \u2014 gates re-derive 'done' through the internal loom engine (fail-closed, independently attested via wicked-vault), so a self-asserted pass can't lie its way green. Relationships grep can't see \u2014 the wicked-estate code graph + injected bus/dispatch/capability/archetype edges power blast-radius & lineage. Plus deterministic multi-file refactor (wicked-patch), cross-session memory + knowledge (the mem domain over wicked-estate), real multi-model second opinions (jam:council), portable expertise as on-demand skill-refs, and evidence-gated testing (the in-catalog qe domain). It reads the shape of your work to apply the right rigor \u2014 steering, not a pipeline \u2014 then gets out of the way. The compiler stamps a self-contained evidence gate into any repo that runs with no wicked-garden installed. Don't fight the harness; fill its gaps. The evidence gate requires wicked-vault, a self-contained infra peer wicked-garden installs directly; wicked-bus is an opt-in layer \u2014 install what you'll use, the toolkit works without the rest.",
5
5
  "wicked_vault_version": "^0.5.0",
6
6
  "wicked_ledger_version": "^0.4.0",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.42.0] — 2026-10-03
6
+
7
+ ### Changed
8
+ - **Demo recorder: encodes while it records; no frame file touches disk.** Before, `scripts/demo/stage.mjs` wrote every changed screencast frame as a JPEG (q95, 1920x1080: ~8 MB/s, about 5 GB per 10-minute take) and `postprocess.mjs` encoded them afterwards from an ffconcat list. Now a 30 fps ticker hands the newest screencast picture to an ffmpeg child over stdin (`-f image2pipe`), which writes the take's master `segments/<key>/capture.mp4` live: H.264 at a near-lossless CRF 12 with one-second fragments (`+frag_keyframe+empty_moov`), so a take that dies leaves a file that plays up to its last complete second. Master frame k is the picture at t0 + k/30, so wall clock → video time stays one line and `videoClock()` keeps its contract (`outAt`, the trim, the time-lapse spans). `buildVideo()` is now a cut of the master: `trim` for the opening, a `select` that keeps one frame in `factor` across every `Stage.fast()` span and re-times the rest back to back, the chapter markers, the final CRF 17 pass; no frame files are read. Each segment keeps `capture.mp4` + `timeline.json` (what `--reencode` re-cuts from) in place of `frames/`; a failed take's `failed-<take>/` keeps the master only when no video could be cut from it, as it kept the frames. The timeline is rewritten at every mark, so a recorder killed mid-take leaves a cuttable master and clock. A dead encoder fails the take at its next step (`encoder_failed`) instead of filming into nothing. `DEMO_KEEP_FRAMES=1` also dumps every screencast frame to `frames/` (with `timeline.json` `frames` timestamps) for debugging. Measured on one take against the 12.41.0 recipe run on the same frames: see `tests/demo/test_live_encode.py` (SSIM against the frames recipe and against a lossless build of the frames; the pipe's video is as close to the frames as the single-encode recipe was). `contact_sheet.py` already read the video. Timelines recorded by earlier versions (frames, no master) are not re-cut: re-record the segment.
9
+
5
10
  ## [12.41.0] — 2026-10-03
6
11
 
7
12
  ### Added
@@ -966,7 +971,8 @@ through `v9.2.18`. Major themes across that span:
966
971
 
967
972
  For each of those releases, see the corresponding tag in git history.
968
973
 
969
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...HEAD
974
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...HEAD
975
+ [12.42.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...v12.42.0
970
976
  [12.41.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.40.0...v12.41.0
971
977
  [12.40.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.39.0...v12.40.0
972
978
  [12.39.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.38.1...v12.39.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.41.0",
3
+ "version": "12.42.0",
4
4
  "type": "module",
5
5
  "description": "Curated toolkit for what AI coding agents can't do alone: evidence-gated work, codegraph relationships, deterministic multi-file refactor, multi-model second opinions, and cross-session memory. Installs as a Claude Code plugin.",
6
6
  "keywords": [
@@ -1,60 +1,91 @@
1
- // Part of the wicked-garden-demo skill. Turns a recorded timeline (frames + marks) into a 1080p H.264 MP4 with chapter markers.
1
+ // Part of the wicked-garden-demo skill. Cuts a take's master (capture.mp4 + timeline.json) into a 1080p H.264 MP4
2
+ // with chapter markers.
2
3
  //
3
- // Each screencast frame lasts until the next one; spans marked by Stage.fast() are compressed by their factor,
4
- // so long model waits play as a visible time-lapse while the on-screen badge states the real elapsed time.
4
+ // The master is the take at a constant FPS, encoded live while it recorded (stage.mjs): frame k is the picture at
5
+ // t0 + k/FPS, so wall clock and master time are the same line. The cut drops the opening trim, keeps one frame in
6
+ // `factor` across every span marked by Stage.fast() and re-times what is left back to back, so long model waits play
7
+ // as a visible time-lapse while the on-screen badge states the real elapsed time; the chapter marks land where
8
+ // videoClock().outAt() says. No frame file is read or written.
5
9
  // Usage: wicked-garden run scripts/demo/postprocess.mjs <outDir> [output.mp4]
6
10
  import fs from "node:fs";
7
11
  import path from "node:path";
8
12
  import { spawnSync } from "node:child_process";
9
13
  import { pathToFileURL } from "node:url";
10
14
 
11
- const FPS = 30;
15
+ /** The master's constant frame rate. */
16
+ export const FPS = 30;
17
+ /** The master's file name inside a take's directory. */
18
+ export const CAPTURE = "capture.mp4";
19
+ /** ffprobe beside a given ffmpeg binary (`FFMPEG=/path/to/ffmpeg` is honoured for both). */
20
+ export const ffprobeOf = (ffmpeg) => ffmpeg.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
12
21
 
13
22
  /**
14
- * The video clock of a timeline: the frames buildVideo keeps, the time-lapse spans, and `outAt(t)`, the
15
- * position in the built video (seconds) of a wall-clock instant `t`. Throws when no frame is kept.
23
+ * The video clock of a timeline, in master frames so it agrees with the cut to the frame: `inFrame`, the first
24
+ * master frame kept; `spans`, the time-lapse spans as [a, b) frame ranges with their factor; `keptBefore(k)`, how many
25
+ * cut frames precede master frame k; and `outAt(t)`, the position in the cut video (seconds) of a wall-clock instant.
26
+ * Throws when a closed take says nothing was recorded past the trim; a timeline a killed take left (no frame count)
27
+ * is clocked from its t0 and the master is counted by buildVideo.
16
28
  */
17
29
  export function videoClock(tl, trimStart = 0) {
18
- const frames = tl.frames.filter((f) => f.t >= tl.t0 + trimStart);
19
- if (!frames.length) throw new Error("no frames recorded");
20
- const speed = [...tl.marks.speed].sort((a, b) => a.start - b.start);
21
-
22
- // Output time for a wall-clock instant: integrate 1/factor over the marked spans.
23
- const origin = frames[0].t;
24
- const outAt = (t) => {
25
- let out = t - origin;
26
- for (const s of speed) {
27
- const a = Math.max(s.start, origin), b = Math.min(s.end, t);
28
- if (b > a) out -= (b - a) * (1 - 1 / s.factor);
30
+ const fps = tl.capture?.fps ?? FPS;
31
+ const inFrame = Math.max(0, Math.ceil(trimStart * fps - 1e-9));
32
+ if (typeof tl.capture?.frames === "number" && tl.capture.frames <= inFrame) throw new Error("no frames recorded");
33
+ const toFrame = (t) => Math.round((t - tl.t0) * fps);
34
+ // Inside a span only frame a and every frame where floor((k - a) / factor) advances are kept: one in `factor`.
35
+ // Spans never overlap here: a later span starts where the one before it ends (a nested Stage.fast() counts once).
36
+ const spans = [];
37
+ for (const s of [...tl.marks.speed].sort((x, y) => x.start - y.start)) {
38
+ const a = Math.max(inFrame, toFrame(s.start), spans.length ? spans[spans.length - 1].b : 0), b = toFrame(s.end);
39
+ if (b > a && s.factor > 1) spans.push({ a, b, f: s.factor });
40
+ }
41
+ const keptIn = (a, b, f) => (b > a ? Math.floor((b - a - 1) / f) + 1 : 0);
42
+ const keptBefore = (k) => {
43
+ let kept = Math.max(0, k - inFrame);
44
+ for (const s of spans) {
45
+ const b = Math.min(s.b, k);
46
+ if (b > s.a) kept -= (b - s.a) - keptIn(s.a, b, s.f);
29
47
  }
30
- return out;
48
+ return kept;
31
49
  };
32
- return { frames, speed, outAt };
50
+ const outAt = (t) => keptBefore(toFrame(t)) / fps;
51
+ return { inFrame, fps, spans, keptIn, keptBefore, outAt, speed: tl.marks.speed };
52
+ }
53
+
54
+ /** Frames in the master, by demuxing it (exact for a master whose writer died: the complete packets count). */
55
+ function countFrames(ffprobe, file) {
56
+ const r = spawnSync(ffprobe, ["-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", file],
57
+ { encoding: "utf8" });
58
+ if (r.status !== 0) throw new Error(`ffprobe failed on ${file}: ${(r.error?.message ?? r.stderr ?? "").slice(-500)}`);
59
+ const n = Number(r.stdout.trim());
60
+ if (!Number.isInteger(n)) throw new Error(`ffprobe gave no frame count for ${file}: ${r.stdout.slice(0, 200)}`);
61
+ return n;
33
62
  }
34
63
 
35
64
  export function buildVideo(outDir, outFile, { ffmpeg = process.env.FFMPEG || "ffmpeg", tail = 0.2, trimStart = 0 } = {}) {
36
65
  const tl = JSON.parse(fs.readFileSync(path.join(outDir, "timeline.json"), "utf8"));
37
- const { frames, speed, outAt } = videoClock(tl, trimStart);
38
-
39
- // Resample to constant frame rate: output tick k shows the newest frame captured at or before k/FPS, and each
40
- // run of identical picks becomes one concat entry lasting (run length / FPS). Bursts collapse, gaps hold.
41
- const outTimes = frames.map((f) => outAt(f.t));
42
- // The screencast only emits a frame when the picture changes, so a static hold at the end (a closing card, a final
43
- // pause) has no frame of its own: end at the moment recording stopped, not at the last captured frame.
44
- const total = Math.max(outTimes[outTimes.length - 1] + 1 / FPS, outAt(tl.tEnd ?? frames[frames.length - 1].t)) + tail;
45
- const ticks = Math.ceil(total * FPS);
46
- const runs = [];
47
- for (let k = 0, i = 0; k < ticks; k++) {
48
- while (i + 1 < frames.length && outTimes[i + 1] <= k / FPS) i++;
49
- if (runs.length && runs[runs.length - 1].i === i) runs[runs.length - 1].n++;
50
- else runs.push({ i, n: 1 });
66
+ const capture = path.join(outDir, tl.capture?.file ?? CAPTURE);
67
+ if (!fs.existsSync(capture)) {
68
+ throw new Error(tl.capture?.error ?? `${path.basename(capture)} not found in ${outDir}` +
69
+ (Array.isArray(tl.frames) && !tl.capture ? " (recorded from frames by an earlier version; re-record the segment)" : ""));
51
70
  }
52
- const lines = ["ffconcat version 1.0"];
53
- for (const r of runs) lines.push(`file 'frames/${frames[r.i].file}'`, `duration ${(r.n / FPS).toFixed(6)}`);
54
- lines.push(`file 'frames/${frames[runs[runs.length - 1].i].file}'`); // concat demuxer needs the last file repeated
55
- fs.writeFileSync(path.join(outDir, "frames.ffconcat"), lines.join("\n"));
71
+ const { inFrame, fps, spans, keptBefore, outAt, speed } = videoClock(tl, trimStart);
72
+ // What the master holds is what counts: an encoder that died late still left playable fragments to cut, and its
73
+ // failure is the explanation only when nothing can be.
74
+ let nFrames;
75
+ try { nFrames = countFrames(ffprobeOf(ffmpeg), capture); } catch (e) { throw new Error(tl.capture?.error ?? e.message); }
76
+ if (nFrames <= inFrame) throw new Error(tl.capture?.error ?? "no frames recorded");
77
+
78
+ // Master frame k is the picture at t0 + k/fps. Inside a marked span one frame in `factor` is kept; the kept frames
79
+ // are re-timed back to back at fps. In the select expression n counts frames after the trim (n = k - inFrame).
80
+ const keep = spans.filter((s) => s.a < nFrames).reduce((rest, s) => {
81
+ const a = s.a - inFrame, b = Math.min(s.b, nFrames) - inFrame - 1;
82
+ return `if(between(n\\,${a}\\,${b})\\,gt(floor((n-${a})/${s.f})\\,floor((n-${a}-1)/${s.f}))\\,${rest})`;
83
+ }, "1");
84
+ const kept = keptBefore(nFrames);
85
+ const total = kept / fps + tail;
56
86
 
57
- const chapters = tl.marks.chapters.map((c) => ({ ...c, out: Math.max(0, outAt(c.t)) }));
87
+ // A chapter marked after the master's last frame (a take killed within the second) sits at the end, not past it.
88
+ const chapters = tl.marks.chapters.map((c) => ({ ...c, out: Math.min(Math.max(0, outAt(c.t)), kept / fps) }));
58
89
  const meta = [";FFMETADATA1", `title=${(tl.title || "Demo").replace(/[=;#\\\n]/g, " ")}`];
59
90
  chapters.forEach((c, i) => {
60
91
  const start = Math.round(c.out * 1000), end = Math.round((i + 1 < chapters.length ? chapters[i + 1].out : total) * 1000);
@@ -62,15 +93,18 @@ export function buildVideo(outDir, outFile, { ffmpeg = process.env.FFMPEG || "ff
62
93
  });
63
94
  fs.writeFileSync(path.join(outDir, "chapters.ffmeta"), meta.join("\n") + "\n");
64
95
 
65
- const args = ["-y", "-f", "concat", "-safe", "0", "-i", "frames.ffconcat", "-i", "chapters.ffmeta", "-map_metadata", "1", "-map_chapters", "1",
66
- "-t", total.toFixed(3), "-vf", `fps=${FPS},format=yuv420p`, "-c:v", "libx264", "-preset", "slow", "-crf", "17", "-tune", "stillimage", "-movflags", "+faststart", path.resolve(outFile)];
96
+ // tpad holds the last picture for `tail` (a static close has frames of its own in the master, but the cut
97
+ // should not end on the very tick recording stopped).
98
+ const vf = `trim=start_frame=${inFrame},select='${keep}',setpts=N/(${fps}*TB),tpad=stop_mode=clone:stop_duration=${tail.toFixed(3)},format=yuv420p`;
99
+ const args = ["-y", "-i", capture, "-i", "chapters.ffmeta", "-map_metadata", "1", "-map_chapters", "1", "-vf", vf, "-r", String(fps), "-fps_mode", "cfr",
100
+ "-c:v", "libx264", "-preset", "slow", "-crf", "17", "-tune", "stillimage", "-movflags", "+faststart", path.resolve(outFile)];
67
101
  const r = spawnSync(ffmpeg, args, { cwd: outDir, stdio: ["ignore", "ignore", "pipe"] });
68
- if (r.status !== 0) throw new Error(`ffmpeg failed: ${r.stderr?.toString().slice(-2000)}`);
102
+ if (r.status !== 0) throw new Error(`ffmpeg failed: ${(r.error?.message ?? r.stderr?.toString() ?? "").slice(-2000)}`);
69
103
 
70
104
  const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
71
105
  const md = ["| Time | Chapter |", "|---|---|", ...chapters.map((c) => `| ${fmt(c.out)} | ${c.title} |`)];
72
106
  fs.writeFileSync(path.join(outDir, "chapters.md"), md.join("\n") + "\n");
73
- return { seconds: total, chapters: chapters.map((c) => ({ at: fmt(c.out), title: c.title })), speedups: speed.length, frames: frames.length };
107
+ return { seconds: total, chapters: chapters.map((c) => ({ at: fmt(c.out), title: c.title })), speedups: speed.length, frames: nFrames - inFrame };
74
108
  }
75
109
 
76
110
  if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
@@ -10,14 +10,15 @@
10
10
  // segments/<key>/failed-<take>/ and is never stitched.
11
11
  //
12
12
  // Each non-intro segment opens on its own chapter slide (the storyline's beforeSegment hook runs behind it) and ends
13
- // on the plain stage background, so segments join with a clean cut. Frames and timelines are kept per segment, so a
14
- // segment can be re-recorded alone and the whole video re-encoded without recording again.
13
+ // on the plain stage background, so segments join with a clean cut. Each segment keeps its master (capture.mp4,
14
+ // encoded live while it recorded) and timeline.json, so a segment can be re-recorded alone and the whole video
15
+ // re-cut (--reencode) without recording again. No frame files are written (DEMO_KEEP_FRAMES=1 dumps them for debugging).
15
16
  import fs from "node:fs";
16
17
  import path from "node:path";
17
18
  import { spawnSync } from "node:child_process";
18
19
  import { pathToFileURL } from "node:url";
19
20
  import { Stage } from "./stage.mjs";
20
- import { buildVideo, videoClock } from "./postprocess.mjs";
21
+ import { buildVideo, videoClock, CAPTURE, ffprobeOf } from "./postprocess.mjs";
21
22
  import { fixtureOrigin, sideEffectError } from "./readonly.mjs";
22
23
 
23
24
  // ---- module-level constants (used by exports) --------------------------------------------------
@@ -125,12 +126,15 @@ export function makeSegmentRecorder({
125
126
  let videoError = null;
126
127
  if (fs.existsSync(tlPath)) {
127
128
  fs.renameSync(tlPath, path.join(dir, "timeline.json"));
128
- if (fs.existsSync(path.join(localSegDir(key), "frames"))) fs.renameSync(path.join(localSegDir(key), "frames"), path.join(dir, "frames"));
129
+ // The master (and the frames dump, if any) go with the timeline; they are dropped once a video is built from them.
130
+ for (const f of [CAPTURE, "frames"]) {
131
+ if (fs.existsSync(path.join(localSegDir(key), f))) fs.renameSync(path.join(localSegDir(key), f), path.join(dir, f));
132
+ }
129
133
  try {
130
134
  buildVideo(dir, path.join(dir, "segment.mp4"), { trimStart, ffmpeg: FFMPEG_BIN });
131
135
  video = "segment.mp4";
136
+ fs.rmSync(path.join(dir, CAPTURE), { force: true });
132
137
  fs.rmSync(path.join(dir, "frames"), { recursive: true, force: true });
133
- fs.rmSync(path.join(dir, "frames.ffconcat"), { force: true });
134
138
  } catch (e) {
135
139
  videoError = e.message;
136
140
  fs.rmSync(path.join(dir, "segment.mp4"), { force: true });
@@ -159,14 +163,15 @@ export function makeSegmentRecorder({
159
163
  console.log(`● ${seg.key} (take ${take})`);
160
164
  const stage = new Stage({
161
165
  baseUrl, outDir: localSegDir(seg.key), chapters: CHAPTERS_LIST, brand,
162
- stickyOffset: story.stickyOffset, headful: process.env.DEMO_HEADFUL === "1",
163
- locale: story.locale, timezoneId: story.timezoneId, writableOrigin, consoleHandler,
166
+ stickyOffset: story.stickyOffset, headful: process.env.DEMO_HEADFUL === "1", keepFrames: process.env.DEMO_KEEP_FRAMES === "1",
167
+ locale: story.locale, timezoneId: story.timezoneId, writableOrigin, consoleHandler, ffmpeg: FFMPEG_BIN,
164
168
  });
165
169
  const timings = {};
166
170
  let ctx = makeCtx(stage, timings);
167
171
  fs.mkdirSync(localSegDir(seg.key), { recursive: true });
168
172
  fs.rmSync(localSegVideo(seg.key), { force: true });
169
173
  fs.rmSync(path.join(localSegDir(seg.key), "timeline.json"), { force: true });
174
+ fs.rmSync(path.join(localSegDir(seg.key), CAPTURE), { force: true });
170
175
  let failure = null;
171
176
  let failedAt = null;
172
177
  try {
@@ -203,7 +208,8 @@ export function makeSegmentRecorder({
203
208
  } finally {
204
209
  await stage.close().catch(() => {});
205
210
  }
206
- let err = failure;
211
+ // An encoder that failed while close() drained it is a failed take too: its master may be short of the end.
212
+ let err = failure ?? stage.encoderError ?? null;
207
213
  if (stage.blocked.length) {
208
214
  err = sideEffectError(seg.key, stage.blocked);
209
215
  if (failure !== null) err.cause = failure;
@@ -235,7 +241,7 @@ export function makeSegmentRecorder({
235
241
  // ---- CLI entry point (only runs when invoked directly) -----------------------------------------
236
242
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
237
243
  const FFMPEG = process.env.FFMPEG || "ffmpeg";
238
- const FFPROBE = FFMPEG.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
244
+ const FFPROBE = ffprobeOf(FFMPEG);
239
245
 
240
246
  function usage(msg) {
241
247
  if (msg) console.error(`error: ${msg}\n`);
@@ -2,14 +2,26 @@
2
2
  //
3
3
  // The app runs inside a 1600x900 browser-window frame on a 1920x1080 stage page; the lower-third caption band,
4
4
  // cursor, callouts and title cards are stage DOM, so captions never cover the product UI and everything is
5
- // rendered with real fonts and CSS transitions. Frames come from the Chrome DevTools screencast (JPEG q95,
6
- // wall-clock timestamps) instead of Playwright's built-in recorder, whose fixed ~1 Mbps VP8 blurs text at
7
- // 1080p. postprocess.mjs turns the frames into an H.264 MP4, time-lapsing the marked waits and embedding
8
- // chapter markers.
5
+ // rendered with real fonts and CSS transitions. Pictures come from the Chrome DevTools screencast (JPEG q95, one
6
+ // per change) instead of Playwright's built-in recorder, whose fixed ~1 Mbps VP8 blurs text at 1080p.
7
+ //
8
+ // Nothing raw touches disk. A 30 fps ticker hands the newest screencast picture to an ffmpeg child over stdin
9
+ // (`-f image2pipe`), which encodes the master `capture.mp4` while the take runs: H.264 at a near-lossless CRF 12
10
+ // (so the master is never the step that softens text; the final CRF 17 pass happens once, in the cut), one-second
11
+ // fragments (a take that dies leaves a file that plays up to its last complete second). Master frame k is the
12
+ // picture at t0 + k/30, so wall clock → master time is one straight line and postprocess.mjs cuts the master into
13
+ // segment.mp4: the opening trim, the marked waits time-lapsed, chapter markers, and the clock videoClock() gives.
14
+ // The ticker re-sends the current picture when nothing changed, which is the constant-frame-rate resampling the
15
+ // cut used to do from timestamps ("the newest frame at or before each tick"), done live. Before this, every
16
+ // changed frame was written as a JPEG and encoded afterwards: ~8 MB/s, about 5 GB per 10-minute take.
17
+ // DEMO_KEEP_FRAMES=1 (keepFrames) also writes every screencast frame to frames/ with its timestamp in
18
+ // timeline.json, for looking at what the screencast delivered; the master is still what gets cut.
9
19
  import { loadChromium } from "./_playwright.mjs";
10
20
  import { armGuard } from "./readonly.mjs";
21
+ import { CAPTURE, FPS } from "./postprocess.mjs";
11
22
  import fs from "node:fs";
12
23
  import path from "node:path";
24
+ import { spawn } from "node:child_process";
13
25
 
14
26
  // Served from the app's own origin (route interception), so the app iframe is same-origin with the stage.
15
27
  const STAGE_PATH = "/__demo_stage__";
@@ -130,6 +142,28 @@ window.__stage={
130
142
 
131
143
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
132
144
 
145
+ // The master: near-lossless, fast enough to keep up with 30 fps on a busy host, a keyframe (= a fragment) every second.
146
+ const MASTER_ARGS = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-tune", "stillimage", "-g", String(FPS), "-keyint_min", String(FPS),
147
+ "-pix_fmt", "yuv420p", "-movflags", "+frag_keyframe+empty_moov+default_base_moof"];
148
+ // Frames waiting on the encoder: above WARN the take says so when it closes; above FAIL (about half a minute of
149
+ // 1080p pictures) the host cannot encode live and the take fails rather than grow without bound.
150
+ const BACKLOG_WARN = 32 * 1024 * 1024;
151
+ const BACKLOG_FAIL = 256 * 1024 * 1024;
152
+
153
+ function encoderFailure(message) {
154
+ const e = new Error(message);
155
+ e.code = "encoder_failed";
156
+ return e;
157
+ }
158
+
159
+ function withTimeout(promise, ms, message) {
160
+ let timer;
161
+ return Promise.race([
162
+ promise.finally(() => clearTimeout(timer)),
163
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }),
164
+ ]);
165
+ }
166
+
133
167
  export class Stage {
134
168
  /**
135
169
  * @param {object} o
@@ -140,10 +174,15 @@ export class Stage {
140
174
  * @param {number} [o.stickyOffset=88] px to leave above a tall element when scrolling it into view (app header)
141
175
  * @param {boolean} [o.headful=false] show the browser window while recording
142
176
  * @param {string|null} [o.writableOrigin=null] the fixture origin writes may go to (readonly.mjs fixtureOrigin); null = read-only
177
+ * @param {string} [o.ffmpeg] ffmpeg binary for the live master (default FFMPEG env or "ffmpeg")
178
+ * @param {boolean} [o.keepFrames=false] also write every screencast frame to frames/ (debugging; DEMO_KEEP_FRAMES=1)
143
179
  */
144
- constructor({ baseUrl, outDir, chapters, brand, stickyOffset = 88, headful = false, locale = "en-US", timezoneId, writableOrigin = null, consoleHandler = null }) {
180
+ constructor({ baseUrl, outDir, chapters, brand, stickyOffset = 88, headful = false, locale = "en-US", timezoneId, writableOrigin = null, consoleHandler = null,
181
+ ffmpeg = process.env.FFMPEG || "ffmpeg", keepFrames = false }) {
145
182
  this.baseUrl = baseUrl.replace(/\/$/, "");
146
183
  this.outDir = outDir;
184
+ this.ffmpeg = ffmpeg;
185
+ this.keepFrames = keepFrames;
147
186
  this.brand = { name: brand?.name ?? "Demo", logoSvg: brand?.logoSvg ?? "", accent: brand?.accent ?? "#ee0000" };
148
187
  this.chapterNames = chapters;
149
188
  this.stickyOffset = stickyOffset;
@@ -155,13 +194,18 @@ export class Stage {
155
194
  this.blocked = [];
156
195
  this.consoleHandler = consoleHandler;
157
196
  this.marks = { chapters: [], speed: [], captions: [] };
158
- this.frameCount = 0;
159
197
  this.chapterIndex = -1;
198
+ this.latest = null; // the newest screencast picture (JPEG bytes)
199
+ this.emitted = 0; // master frames handed to the encoder
200
+ this.maxBacklog = 0; // most bytes ever waiting on the encoder
201
+ this.encoderError = null;
160
202
  }
161
203
 
162
204
  async open(firstPath = "/") {
163
- fs.mkdirSync(path.join(this.outDir, "frames"), { recursive: true });
164
- for (const f of fs.readdirSync(path.join(this.outDir, "frames"))) fs.unlinkSync(path.join(this.outDir, "frames", f));
205
+ fs.mkdirSync(this.outDir, { recursive: true });
206
+ fs.rmSync(path.join(this.outDir, "frames"), { recursive: true, force: true });
207
+ fs.rmSync(path.join(this.outDir, CAPTURE), { force: true });
208
+ if (this.keepFrames) fs.mkdirSync(path.join(this.outDir, "frames"));
165
209
  this.frames = [];
166
210
  this.browser = await (await loadChromium()).launch({ headless: !this.headful, args: ["--force-color-profile=srgb", "--hide-scrollbars"] });
167
211
  // A service worker answers requests the context's routes never see, so a guarded recording runs without one.
@@ -184,23 +228,90 @@ export class Stage {
184
228
  this.app = this.page.frameLocator("#app");
185
229
  this.appFrame = await (await this.page.$("#app")).contentFrame();
186
230
  await this.appFrame.waitForLoadState("networkidle").catch(() => {});
187
- // Screencast: every changed frame with its wall-clock timestamp (seconds).
231
+ // Screencast: every changed picture, kept as "the newest" for the ticker (and on disk only with keepFrames).
188
232
  this.cdp = await this.context.newCDPSession(this.page);
189
- this.cdp.on("Page.screencastFrame", async (ev) => {
190
- const name = `f${String(this.frameCount++).padStart(6, "0")}.jpg`;
191
- fs.writeFileSync(path.join(this.outDir, "frames", name), Buffer.from(ev.data, "base64"));
192
- this.frames.push({ file: name, t: ev.metadata.timestamp });
193
- await this.cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {});
233
+ let gotFirst;
234
+ const first = new Promise((resolve) => { gotFirst = resolve; });
235
+ this.cdp.on("Page.screencastFrame", (ev) => {
236
+ // Frames due before this picture existed show the previous one: a change lands at its own time, never early.
237
+ if (this.ticker) this.emitUpTo(Math.min(ev.metadata.timestamp, this.now()));
238
+ this.latest = Buffer.from(ev.data, "base64");
239
+ if (this.keepFrames) {
240
+ const name = `f${String(this.frames.length).padStart(6, "0")}.jpg`;
241
+ fs.writeFileSync(path.join(this.outDir, "frames", name), this.latest);
242
+ this.frames.push({ file: name, t: ev.metadata.timestamp });
243
+ }
244
+ gotFirst();
245
+ this.cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {});
194
246
  });
195
247
  await this.cdp.send("Page.startScreencast", { format: "jpeg", quality: 95, maxWidth: W, maxHeight: H, everyNthFrame: 1 });
196
- this.t0 = Date.now() / 1000;
248
+ await withTimeout(first, 15_000, "the screencast delivered no frame within 15 s");
249
+ // The master: frame k is the picture at t0 + k/FPS. Frames fall due with the clock; whatever is due is sent at
250
+ // each tick and before each new picture (so a late tick costs no time and shifts nothing earlier).
251
+ this.encoder = this.startEncoder();
252
+ this.t0 = this.now();
253
+ this.ticker = setInterval(() => this.emitUpTo(this.now()), 1000 / FPS);
254
+ this.emitUpTo(this.t0); // frame 0
255
+ this.writeTimeline(false);
197
256
  this.cursorAt = { x: FRAME.x + FRAME.w / 2, y: FRAME.y + FRAME.h / 2 };
198
257
  await this.stage((s, c) => s.cursor(c.x, c.y, false), this.cursorAt);
199
258
  }
200
259
 
260
+ startEncoder() {
261
+ const child = spawn(this.ffmpeg, ["-y", "-v", "error", "-f", "image2pipe", "-framerate", String(FPS), "-i", "-", ...MASTER_ARGS, path.join(this.outDir, CAPTURE)],
262
+ { stdio: ["pipe", "ignore", "pipe"] });
263
+ let stderr = "";
264
+ child.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); });
265
+ child.stdin.on("error", () => {}); // EPIPE once the encoder is gone: the exit handler reports why
266
+ const done = new Promise((resolve) => {
267
+ child.on("error", (e) => { this.encoderError ??= encoderFailure(`ffmpeg failed to start: ${e.message}`); resolve(); });
268
+ child.on("exit", (code, signal) => {
269
+ if (code !== 0) this.encoderError ??= encoderFailure(`ffmpeg failed (${signal ?? code}): ${stderr.trim()}`);
270
+ resolve();
271
+ });
272
+ });
273
+ return { child, done };
274
+ }
275
+
276
+ /** Hands the encoder every master frame due by wall-clock instant t, each showing the current picture. */
277
+ emitUpTo(t) {
278
+ if (this.encoderError || !this.latest) return;
279
+ const due = Math.floor((t - this.t0) * FPS) + 1 - this.emitted;
280
+ if (due <= 0) return;
281
+ const { stdin } = this.encoder.child;
282
+ for (let i = 0; i < due; i++) stdin.write(this.latest);
283
+ this.emitted += due;
284
+ this.maxBacklog = Math.max(this.maxBacklog, stdin.writableLength);
285
+ if (stdin.writableLength > BACKLOG_FAIL) {
286
+ this.encoderError = encoderFailure(`ffmpeg fell ${Math.round(stdin.writableLength / 1048576)} MB of frames behind real time: this host cannot encode 1080p at 30 fps live`);
287
+ this.encoder.child.kill("SIGKILL");
288
+ }
289
+ }
290
+
291
+ /** The take's clock and marks so far (tEnd and the frame count only once closed): a killed take still leaves what
292
+ * the cut needs, and postprocess counts the master's frames itself. */
293
+ writeTimeline(final) {
294
+ const meta = {
295
+ t0: this.t0, tEnd: final ? this.tEnd : null,
296
+ capture: { file: CAPTURE, fps: FPS, ...(final ? { frames: this.emitted } : {}), maxBacklogBytes: this.maxBacklog, ...(this.encoderError ? { error: this.encoderError.message } : {}) },
297
+ marks: this.marks, size: { w: W, h: H },
298
+ ...(this.keepFrames ? { frames: this.frames } : {}),
299
+ };
300
+ fs.writeFileSync(path.join(this.outDir, "timeline.json"), JSON.stringify(meta, null, 1));
301
+ return meta;
302
+ }
303
+
304
+ mark(kind, m) {
305
+ this.marks[kind].push(m);
306
+ if (this.t0 !== undefined) this.writeTimeline(false);
307
+ }
308
+
309
+ /** A dead encoder fails the take at its next step instead of filming ten minutes into nothing. */
310
+ check() { if (this.encoderError) throw this.encoderError; }
311
+
201
312
  now() { return Date.now() / 1000; }
202
- stage(fn, arg) { return this.page.evaluate(`(${fn.toString()})(window.__stage, ${JSON.stringify(arg ?? null)})`); }
203
- hold(ms) { return sleep(ms); }
313
+ stage(fn, arg) { this.check(); return this.page.evaluate(`(${fn.toString()})(window.__stage, ${JSON.stringify(arg ?? null)})`); }
314
+ hold(ms) { this.check(); return sleep(ms); }
204
315
 
205
316
  async card(html, ms) {
206
317
  await this.stage((s, h) => s.card(h), html);
@@ -210,13 +321,13 @@ export class Stage {
210
321
 
211
322
  async chapter(i) {
212
323
  this.chapterIndex = i;
213
- this.marks.chapters.push({ t: this.now(), title: this.chapterNames[i] });
324
+ this.mark("chapters", { t: this.now(), title: this.chapterNames[i] });
214
325
  await this.stage((s, a) => s.progress(a.n, a.i, a.label), { n: this.chapterNames.length, i, label: `Chapter ${i + 1} of ${this.chapterNames.length} · ${this.chapterNames[i]}` });
215
326
  }
216
327
 
217
328
  /** Lower-third caption. body may contain <em>. readMs is how long the viewer needs it (scaled by length when omitted). */
218
329
  async caption(kicker, title, body = "", readMs) {
219
- this.marks.captions.push({ t: this.now(), kicker, title, body });
330
+ this.mark("captions", { t: this.now(), kicker, title, body });
220
331
  await this.stage((s, c) => s.caption(c), { kicker, title, body });
221
332
  await sleep(readMs ?? Math.min(9000, 2200 + (title.length + body.length) * 38));
222
333
  }
@@ -292,7 +403,7 @@ export class Stage {
292
403
  await sleep(600);
293
404
  const t = this.now();
294
405
  try { return await fn(); } finally {
295
- this.marks.speed.push({ start: t, end: this.now(), factor, label });
406
+ this.mark("speed", { start: t, end: this.now(), factor, label });
296
407
  await sleep(400);
297
408
  await this.stage((s) => s.speed(false));
298
409
  await sleep(400);
@@ -302,15 +413,24 @@ export class Stage {
302
413
  async close() {
303
414
  // Also after an open() that failed part-way: close what was opened, and write a timeline only once recording began.
304
415
  let meta = null;
305
- if (this.cdp) {
306
- await sleep(600);
307
- await this.cdp.send("Page.stopScreencast").catch(() => {});
308
- await sleep(300);
416
+ if (this.ticker) {
417
+ await sleep(600); // the last picture settles before the master stops
418
+ clearInterval(this.ticker);
419
+ this.ticker = null;
420
+ this.tEnd = this.now();
421
+ this.emitUpTo(this.tEnd);
309
422
  }
310
- if (this.t0 !== undefined) {
311
- meta = { t0: this.t0, tEnd: this.now(), frames: this.frames, marks: this.marks, size: { w: W, h: H } };
312
- fs.writeFileSync(path.join(this.outDir, "timeline.json"), JSON.stringify(meta, null, 1));
423
+ if (this.cdp) await this.cdp.send("Page.stopScreencast").catch(() => {});
424
+ if (this.encoder) {
425
+ // EOF, then the encoder writes what it still holds and closes the file.
426
+ this.encoder.child.stdin.end();
427
+ await withTimeout(this.encoder.done, 120_000, "ffmpeg did not finish").catch((e) => {
428
+ this.encoderError ??= encoderFailure(`${e.message}: killed`);
429
+ this.encoder.child.kill("SIGKILL");
430
+ });
431
+ if (this.maxBacklog > BACKLOG_WARN) console.warn(` encoder fell behind real time (up to ${Math.round(this.maxBacklog / 1048576)} MB of frames waited); the master is complete`);
313
432
  }
433
+ if (this.t0 !== undefined) meta = this.writeTimeline(true);
314
434
  await this.context?.close().catch(() => {});
315
435
  await this.browser?.close().catch(() => {});
316
436
  return meta;
@@ -6,8 +6,8 @@ description: |
6
6
  alternates, runs a live rehearsal with measured timings, and writes a
7
7
  presenter script with a chapter list ready for recording.
8
8
  record — records each chapter as its own captioned, chaptered segment via
9
- Playwright + ffmpeg (DevTools frames at JPEG q95, H.264 CRF 17), then
10
- stitches them into one MP4 with chapter markers.
9
+ Playwright + ffmpeg (DevTools screencast encoded live to H.264, no frame
10
+ files on disk, final CRF 17), then stitches them into one MP4 with chapters.
11
11
  review — generates labelled contact sheets of stills (at chapter slides,
12
12
  joins, and the end) and delivers a per-issue verdict: re-encode, re-record
13
13
  one segment, or fix the app.
@@ -192,12 +192,21 @@ wicked-garden run scripts/demo/record.mjs <storyline.mjs> # record
192
192
  wicked-garden run scripts/demo/record.mjs <storyline.mjs> 03-search # (re)record one segment, restitch
193
193
  wicked-garden run scripts/demo/record.mjs <storyline.mjs> --all # re-record everything
194
194
  wicked-garden run scripts/demo/record.mjs <storyline.mjs> --stitch # stitch existing segments only
195
- wicked-garden run scripts/demo/record.mjs <storyline.mjs> --reencode # rebuild from saved frames, restitch
195
+ wicked-garden run scripts/demo/record.mjs <storyline.mjs> --reencode # re-cut from each segment's saved master, restitch
196
196
  wicked-garden run scripts/demo/record.mjs <storyline.mjs> --list # which segments are recorded
197
197
  ```
198
198
 
199
199
  Options: `--out <dir>` (default `demo-video/` next to the storyline), `--fixture-origin <origin>` (see below).
200
- Environment: `FFMPEG`, `DEMO_BASE_URL`, `DEMO_HEADFUL=1`.
200
+ Environment: `FFMPEG`, `DEMO_BASE_URL`, `DEMO_HEADFUL=1`, `DEMO_KEEP_FRAMES=1` (debugging only, see below).
201
+
202
+ **Encoded while it records; no frame files.** The screencast is fed to ffmpeg at 30 fps as the take runs and lands
203
+ as `segments/<key>/capture.mp4`, a near-lossless H.264 master (CRF 12, one-second fragments) whose frame k is the
204
+ picture at t0 + k/30; the segment video is a cut of that master (opening trim, time-lapsed waits, chapters, CRF 17).
205
+ A take leaves about two videos' worth of bytes, not a JPEG per frame (which was ~8 MB/s, ~5 GB per 10-minute take).
206
+ A take that is killed still leaves a master that plays up to its last complete second, and the timeline written so
207
+ far, so `--reencode` can cut it. `DEMO_KEEP_FRAMES=1` additionally writes every screencast frame to
208
+ `segments/<key>/frames/` (with timestamps in `timeline.json`) to look at what the screencast delivered — use it on a
209
+ short take only; the master is still what gets cut.
201
210
 
202
211
  **Read-only by default.** While it records, the browser sends no writes: every request that is not
203
212
  GET, HEAD or OPTIONS is aborted, and so is every WebSocket frame the page sends (frames the server pushes
@@ -214,7 +223,7 @@ switch. `recording.json` records how each stitched segment was recorded (`read-o
214
223
  `fixture-writable`), and each segment's `guard.json` names the origin.
215
224
 
216
225
  **A failed take keeps its evidence.** When a take fails (an error, or a blocked write), its timeline,
217
- a video built from the frames it has, and `failure.json` (the error, any blocked requests,
226
+ a video cut from the master it has, and `failure.json` (the error, any blocked requests,
218
227
  `failed_at_sec`) are kept in `segments/<key>/failed-<take>/`. Nothing of it is left where a stitch could
219
228
  use it: the segment's old `segment.mp4` is deleted, and while any segment's last take failed, `--reencode`
220
229
  and `--stitch` refuse to run (`failed_take`, or `side_effect_blocked` for a blocked write). Re-record it
@@ -5,17 +5,24 @@ time; read this before the first long run.
5
5
 
6
6
  ## Capture and encoding
7
7
 
8
+ **The disk fills while recording (gigabytes per take).**
9
+ Cause: a screencast frame written to disk as a JPEG per change is ~8 MB/s at 1080p: about 5 GB per 10-minute take.
10
+ Fix: encode while recording. The engine feeds the newest picture to ffmpeg at 30 fps and keeps only the H.264 master
11
+ (`capture.mp4`, near-lossless) plus the cut; a take leaves about two videos' worth of bytes. `DEMO_KEEP_FRAMES=1`
12
+ brings the frame files back for a short debugging take only.
13
+
8
14
  **The last seconds of a segment are missing (closing card cut off).**
9
15
  Cause: the Chrome screencast only emits a frame when the picture changes, so a static hold at the end has no frame.
10
- Fix: end each clip at the moment recording stopped, not at the last captured frame (the engine does this).
16
+ Fix: the master is fed at a constant rate until the moment recording stops, so a hold is in the video (the engine does this).
11
17
 
12
18
  **The video runs longer than the recording, or plays in jerks.**
13
19
  Cause: bursts of frames with near-identical timestamps each padded to a minimum duration.
14
- Fix: resample to a constant frame rate: for each output tick, show the newest frame captured at or before it.
20
+ Fix: a constant frame rate: at each tick, the newest picture (the engine's 30 fps ticker does exactly this, live).
15
21
 
16
22
  **Text is soft or smeared.**
17
23
  Cause: Playwright's built-in `recordVideo` encodes VP8 at a low fixed bitrate.
18
- Fix: capture DevTools screencast frames (JPEG q95) and encode H.264 yourself (CRF ~17, `yuv420p`, `+faststart`).
24
+ Fix: take DevTools screencast pictures (JPEG q95) and encode H.264 yourself: a near-lossless master (CRF 12) while
25
+ recording, then one CRF 17 pass for the cut (`yuv420p`, `+faststart`). The master is never the step that softens text.
19
26
 
20
27
  **The time-lapse compressed the wrong part.**
21
28
  Cause: speed marks and frame timestamps on different clocks.
@@ -85,7 +92,7 @@ Fix: create `ctx.waitForResponse(re, { method: "GET" })` before the action, then
85
92
  and time the whole thing with `ctx.time`.
86
93
 
87
94
  **The closing card shows an old number after re-recording one segment.**
88
- Cause: the closing card is baked into the last segment's frames when it is recorded.
95
+ Cause: the closing card is baked into the last segment's master when it is recorded.
89
96
  Fix: the runner now re-records the last segment automatically whenever another segment is re-recorded (keep that
90
97
  segment short); `--keep-closing` opts out. Check the end of the video after every partial re-record.
91
98
 
@@ -107,7 +114,7 @@ Fix: record per segment; re-record only the one that failed. Test risky segments
107
114
  **Fonts differ between machines or render as a fallback offline.**
108
115
  Cause: the stage loads a web font from a CDN.
109
116
  Fix: record with network access, or vendor the font files next to the stage and reference them locally. The
110
- finished video is offline-safe either way: the fonts are baked into the frames.
117
+ finished video is offline-safe either way: the fonts are baked into the picture.
111
118
 
112
119
  **Local storage or cookies behave oddly inside the frame.**
113
120
  Cause: a stage page on a different origin partitions the app's storage.
@@ -15,8 +15,8 @@ Each item is **symptom → likely cause → fix** and a verdict: **[encode]** re
15
15
  re-apply the speed factors. [encode]
16
16
  - [ ] **Stutter, judder or variable frame rate** → frames encoded with their raw capture durations → resample to a
17
17
  constant 30 fps before encoding. [encode]
18
- - [ ] **Blurry text** → a low-bitrate recorder (browser automation built-in video is often ~1 Mbps VP8) → capture frames
19
- as high-quality JPEG/PNG and encode H.264 at low CRF, `yuv420p`, `+faststart`. [encode]
18
+ - [ ] **Blurry text** → a low-bitrate recorder (browser automation built-in video is often ~1 Mbps VP8) → take the
19
+ screencast as high-quality JPEG, encode a near-lossless H.264 master live, cut at low CRF, `yuv420p`, `+faststart`. [encode]
20
20
 
21
21
  ## Openings, joins and the end
22
22
 
@@ -9,15 +9,17 @@ wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs # r
9
9
  wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs 03-search # (re)record one or more segments, restitch
10
10
  wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs --all # re-record everything
11
11
  wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs --stitch # only stitch what exists
12
- wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs --reencode # rebuild every segment from saved frames, restitch
12
+ wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs --reencode # re-cut every segment from its saved master, restitch
13
13
  wicked-garden run scripts/demo/record.mjs path/to/storyline.mjs --list # segments and whether each is recorded
14
14
  ```
15
15
 
16
16
  Options: `--out <dir>` (default: `demo-video/` next to the storyline), `--keep-closing` (see below). Environment: `FFMPEG` (path to ffmpeg; ffprobe
17
- is found next to it), `DEMO_BASE_URL` (overrides `baseUrl`), `DEMO_HEADFUL=1` (show the browser while recording).
17
+ is found next to it), `DEMO_BASE_URL` (overrides `baseUrl`), `DEMO_HEADFUL=1` (show the browser while recording),
18
+ `DEMO_KEEP_FRAMES=1` (also dump every screencast frame to `frames/`, for debugging a short take).
18
19
 
19
20
  Output in `<out>/`: `<slug>.mp4` (stitched, one chapter per segment), `chapters.md`, `timings.json`, and per segment
20
- `segments/<key>/segment.mp4` plus the raw capture (`frames/`, `timeline.json`) that `--reencode` rebuilds from.
21
+ `segments/<key>/segment.mp4` plus the raw capture (`capture.mp4`, the 30 fps master encoded live while the take ran, and
22
+ `timeline.json`) that `--reencode` re-cuts from. No frame files are written.
21
23
  The MP4 holds one video stream plus a small data stream: the chapter track ffmpeg writes for players that read
22
24
  QuickTime-style chapters. That is expected.
23
25