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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -1
- package/package.json +1 -1
- package/scripts/demo/postprocess.mjs +76 -42
- package/scripts/demo/record.mjs +15 -9
- package/scripts/demo/stage.mjs +147 -27
- package/skills/demo/SKILL.md +14 -5
- package/skills/demo/refs/gotchas.md +12 -5
- package/skills/demo/refs/review-checklist.md +2 -2
- package/skills/demo/refs/storyline-api.md +5 -3
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
48
|
+
return kept;
|
|
31
49
|
};
|
|
32
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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:
|
|
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) {
|
package/scripts/demo/record.mjs
CHANGED
|
@@ -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.
|
|
14
|
-
// segment can be re-recorded alone and the whole video
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
244
|
+
const FFPROBE = ffprobeOf(FFMPEG);
|
|
239
245
|
|
|
240
246
|
function usage(msg) {
|
|
241
247
|
if (msg) console.error(`error: ${msg}\n`);
|
package/scripts/demo/stage.mjs
CHANGED
|
@@ -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.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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(
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this
|
|
193
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
306
|
-
await sleep(600);
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
311
|
-
|
|
312
|
-
|
|
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;
|
package/skills/demo/SKILL.md
CHANGED
|
@@ -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
|
|
10
|
-
stitches them into one MP4 with
|
|
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 #
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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) →
|
|
19
|
-
as high-quality JPEG
|
|
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 #
|
|
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 (`
|
|
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
|
|