wicked-garden 12.41.0 → 12.43.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.43.0"}], "version": "12.43.0"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-garden",
3
- "version": "12.41.0",
3
+ "version": "12.43.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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.43.0] — 2026-10-05
6
+
7
+ ### Fixed
8
+ - **Walkthrough tool: the proof root is what crew's walkthrough view and acceptance read (wicked-garden#1208).** `scripts/demo/walkthrough.mjs record` wrote only `{key, verdict, failed_at_sec}` per chapter, `progress.json` only at the end, its segments under `<root>/segments/`, no planned chapter list and no stitched take — so crew's view (`GET /runs/:id/walkthrough`, WT-W1) showed "Starting the app" for the whole recording, then a failed chapter by its key with no title, no check, no frame and nothing to watch. The tool now writes crew's contract: `chapters.json` (`{key, title, blurb, tags, resets}`) before the app starts; `progress.json` `{state: "starting_app"}` → `{state: "recording", chapter, index, total}` per chapter → `{state: "judging"}`; `result.json` with a top-level `tree` and per chapter `title`, `takes`, `failed_at_sec` (the earliest failing check's `at_sec`, else the take's `failure.json` — DES §4.5), `failed_frame` (the earliest failing on-screen check's frame, else one cut from the take at `failed_at_sec`), `proves`, `legs` and `checks[] {id, kind, sentence, passed, at_sec, evidence[] (root-relative paths), vault_entry, detail}`; the segments under `demo-video/segments/<key>/`, and the take stitched from each chapter's kept video (the good take, else the failed take the recorder kept) into `demo-video/demo.mp4` with `demo-video/chapters.md` markers and `demo-video/poster.jpg`. **The seal is now the form crew recomputes** (`walkthrough-acceptance.ts`, WT-W2): sha256 over `"<sha256 hex> <posix relative path>\n"` lines in UTF-8 byte order — the tool hashed `"<path>:<sha>\n"` in locale order, so every live take would have been denied as "walkthrough evidence changed after it was sealed". Like crew, the seal refuses a root holding a link or a non-regular entry outside `app/`/`data/`, or a file it cannot read: `record` then writes `INCONCLUSIVE` with `cause: unsealable_root` and prints the seal line with `bundle_sha: null` (crew reads it as not sealed and denies); `seal --root` exits 1 and says why. A chapter whose take failed has its kept video published at `demo-video/segments/<key>/segment.mp4` too (a copy; `guard.json` and `failed-<take>/failure.json` still say it failed), since that is the one path crew reads a chapter as recorded from. A segment key outside `^[a-z0-9][a-z0-9-]*$` (what `record.mjs` and crew accept) is refused as `storyline_refused`, naming the key, instead of being dropped by the view silently. `record.mjs` exports `stitchParts` (the one stitch, also used by its own `--stitch`) and `extractFrame`. Tests: `tests/demo/test_walkthrough.py` (the seal against a Python recompute of crew's form, `chapters.json`, the running progress read mid-take, the key rule, and a PASS+FAIL recording checked field by field).
9
+ - **`_estate_client.py` keeps estate's diagnostics block (wicked-garden#1211).** The shim read `content[0]` of an MCP result only; estate's RetrievalTools put `STALENESS:` (and since estate#242 `CLAMPED:`, plus `LEXICAL-FALLBACK:`) in the second text block, so every skill going through the shim could not tell a stale or clamped answer from a fresh, complete one. `_unwrap_blocks(envelope)` returns `(payload, diagnostics_lines)`, `call_blocks(tool, arguments)` returns `{"result", "diagnostics"}`, and the CLI's `call` action — the command every skill names — prints `{"result": …, "diagnostics": [...]}` so the seat reading stdout sees the marker (additive: `result` is where it always was; a consumer that never reads `diagnostics` sees no change). `call`/`_unwrap` keep their one-value contract. The search skill and its hotspots ref say where the marker now appears. The governed read-verb allowlist the skill lint mirrors (`tests/test_skill_governed_grounding.py`) is unchanged: wicked-core's `ESTATE_READ_VERBS` still lacks `lineage`/`traverse`/`hotspots` (wicked-core#729 open), and garden must not coach a verb the fence denies.
10
+
11
+ ### Changed
12
+ - **Domain vendor: the schema bundle `VERSION` follows the owner to 1.2.1.** wicked-core#699 bumped its governance schema bundle to 1.2.1 (conformance-rules 1.2.0 declares the DC-S1 project-rule fields it already documented). `skills/domain/vendor/VERSION` now matches, so `tests/domain/test_schema_vendor_pin.py` passes again against a current wicked-core checkout. `domain-model.schema.json` is byte-unchanged, and documents still carry `schema_version: "1.0.0"`.
13
+
14
+ ## [12.42.0] — 2026-10-03
15
+
16
+ ### Changed
17
+ - **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.
18
+
5
19
  ## [12.41.0] — 2026-10-03
6
20
 
7
21
  ### Added
@@ -966,7 +980,9 @@ through `v9.2.18`. Major themes across that span:
966
980
 
967
981
  For each of those releases, see the corresponding tag in git history.
968
982
 
969
- [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...HEAD
983
+ [Unreleased]: https://github.com/mikeparcewski/wicked-garden/compare/v12.43.0...HEAD
984
+ [12.43.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.42.0...v12.43.0
985
+ [12.42.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.41.0...v12.42.0
970
986
  [12.41.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.40.0...v12.41.0
971
987
  [12.40.0]: https://github.com/mikeparcewski/wicked-garden/compare/v12.39.0...v12.40.0
972
988
  [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.43.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": [
@@ -727,31 +727,51 @@ def _rpc(method: str, params: dict, *, timeout: float) -> Optional[dict]:
727
727
  return responses.get(2)
728
728
 
729
729
 
730
- def _unwrap(envelope: Optional[dict]) -> Optional[Any]:
731
- """Extract a tool's payload from an MCP tools/call envelope.
732
-
733
- Estate wraps results as {"result": {"content": [{"type":"text","text": …}],
734
- "isError": bool}}. The text is usually the tool's own JSON string, which is
735
- parsed and returned; a tool that emits plain (non-JSON) text gets its text
736
- handed back verbatim as a str. Returns None on error / isError=true /
737
- a malformed envelope shape.
730
+ def _unwrap_blocks(envelope: Optional[dict]) -> "tuple[Optional[Any], list[str]]":
731
+ """Extract a tool's payload AND its diagnostics from an MCP tools/call envelope.
732
+
733
+ Estate wraps results as {"result": {"content": [{"type":"text","text": …}, …],
734
+ "isError": bool}}. `content[0]` is the tool's own JSON string, which is parsed
735
+ and returned (a tool that emits plain, non-JSON text gets its text handed back
736
+ verbatim as a str). `content[1]`, when present, is the diagnostics block the
737
+ estate MCP server appends (`wicked-estate-mcp/src/lib.rs`, "Append diagnostics
738
+ as a second text block"): one line each — `STALENESS: …` (W7.4 freshness),
739
+ `CLAMPED: …` (estate#242, a bound the server lowered), `LEXICAL-FALLBACK: …`.
740
+ They are returned as a list of lines so a caller can tell a stale or clamped
741
+ answer from a fresh, complete one (wicked-garden#1211).
742
+
743
+ Returns (None, []) on error / isError=true / a malformed envelope shape.
738
744
  """
739
745
  if not isinstance(envelope, dict):
740
- return None
746
+ return None, []
741
747
  result = envelope.get("result")
742
748
  if not isinstance(result, dict) or result.get("isError"):
743
- return None
749
+ return None, []
744
750
  content = result.get("content")
745
751
  if not isinstance(content, list) or not content:
746
- return None
752
+ return None, []
747
753
  text = content[0].get("text") if isinstance(content[0], dict) else None
748
754
  if text is None:
749
- return None
755
+ return None, []
750
756
  try:
751
- return json.loads(text)
757
+ payload: Any = json.loads(text)
752
758
  except (json.JSONDecodeError, TypeError):
753
759
  # Some tools may return plain text; hand it back verbatim.
754
- return text
760
+ payload = text
761
+ diagnostics: list[str] = []
762
+ for block in content[1:]:
763
+ more = block.get("text") if isinstance(block, dict) else None
764
+ if isinstance(more, str):
765
+ diagnostics.extend(line for line in more.split("\n") if line.strip())
766
+ return payload, diagnostics
767
+
768
+
769
+ def _unwrap(envelope: Optional[dict]) -> Optional[Any]:
770
+ """The payload alone (`content[0]` parsed) — see `_unwrap_blocks` for both blocks.
771
+
772
+ Returns None on error / isError=true / a malformed envelope shape.
773
+ """
774
+ return _unwrap_blocks(envelope)[0]
755
775
 
756
776
 
757
777
  # ─────────────────────────────────────────────────────────────────────────────
@@ -791,6 +811,24 @@ def call(tool: str, arguments: Optional[dict] = None, timeout: float = 8.0) -> O
791
811
  return None
792
812
 
793
813
 
814
+ def call_blocks(tool: str, arguments: Optional[dict] = None, timeout: float = 8.0) -> Optional[dict]:
815
+ """Like `call`, but keep the diagnostics block (wicked-garden#1211).
816
+
817
+ Returns {"result": <parsed payload>, "diagnostics": [<line>, …]} — the
818
+ `STALENESS:` / `CLAMPED:` / `LEXICAL-FALLBACK:` lines estate rides in the
819
+ second content block — or None when estate is unreachable or the tool
820
+ errored (the same fail-open shape as `call`).
821
+ """
822
+ try:
823
+ env = _rpc("tools/call", {"name": tool, "arguments": arguments or {}}, timeout=timeout)
824
+ except Exception:
825
+ return None
826
+ payload, diagnostics = _unwrap_blocks(env)
827
+ if payload is None and not diagnostics:
828
+ return None
829
+ return {"result": payload, "diagnostics": diagnostics}
830
+
831
+
794
832
  def call_raw(tool: str, arguments: Optional[dict] = None, timeout: float = 8.0) -> Optional[dict]:
795
833
  """Like `call`, but return the full MCP envelope (for callers that need
796
834
  `isError` / staleness diagnostics). Fail-open (None)."""
@@ -1053,7 +1091,11 @@ def main(argv: list) -> int:
1053
1091
  if not tool:
1054
1092
  _emit({"error": "call requires {\"tool\": \"<ToolName>\", \"arguments\": {...}}"})
1055
1093
  return 1
1056
- _emit({"result": call(tool, args.get("arguments", {}))})
1094
+ # Both blocks, in-band (wicked-garden#1211): the seat reading stdout sees the
1095
+ # `STALENESS:` / `CLAMPED:` lines beside the payload, as the MCP caller does.
1096
+ blocks = call_blocks(tool, args.get("arguments", {}))
1097
+ _emit({"result": blocks["result"] if blocks else None,
1098
+ "diagnostics": blocks["diagnostics"] if blocks else []})
1057
1099
  elif action == "propose":
1058
1100
  kind_type = args.get("kind_type")
1059
1101
  payload = args.get("payload")
@@ -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) --------------------------------------------------
@@ -78,6 +79,49 @@ export function failedAtInVideo(dir, failedAt, trimStart = SEGMENT_TRIM_START) {
78
79
  }
79
80
  }
80
81
 
82
+ // ---- exported stitch ---------------------------------------------------------------------------
83
+ /**
84
+ * Join segment videos (same codec, size and frame rate — every take is cut by buildVideo) into one MP4 with
85
+ * chapter metadata, without re-encoding. `parts` = [{ title, file }] in take order; `workDir` holds the two
86
+ * ffmpeg list files. Returns the `| m:ss | Title |` marker rows (chapters.md) and the total seconds.
87
+ */
88
+ export function stitchParts(parts, outFile, { ffmpeg = "ffmpeg", title = "Demo", workDir }) {
89
+ const ffprobe = ffprobeOf(ffmpeg);
90
+ const probeSeconds = (file) => {
91
+ const r = spawnSync(ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file], { encoding: "utf8" });
92
+ if (r.status !== 0) throw new Error(`ffprobe failed on ${file}: ${r.stderr}`);
93
+ return Number(r.stdout.trim());
94
+ };
95
+ if (!parts.length) throw new Error("no segments recorded yet");
96
+ fs.mkdirSync(workDir, { recursive: true });
97
+ const list = path.join(workDir, "concat.txt");
98
+ fs.writeFileSync(list, parts.map((p) => `file '${p.file.replace(/\\/g, "/").replace(/'/g, "'\\''")}'`).join("\n") + "\n");
99
+ let at = 0;
100
+ const meta = [";FFMETADATA1", `title=${String(title).replace(/[=;#\\\n]/g, " ")}`];
101
+ const rows = ["| Time | Chapter |", "|---|---|"];
102
+ for (const p of parts) {
103
+ const d = probeSeconds(p.file);
104
+ meta.push("[CHAPTER]", "TIMEBASE=1/1000", `START=${Math.round(at * 1000)}`, `END=${Math.round((at + d) * 1000)}`, `title=${String(p.title).replace(/[=;#\\\n]/g, " ")}`);
105
+ rows.push(`| ${Math.floor(at / 60)}:${String(Math.floor(at % 60)).padStart(2, "0")} | ${p.title} |`);
106
+ at += d;
107
+ }
108
+ const metaFile = path.join(workDir, "chapters.ffmeta");
109
+ fs.writeFileSync(metaFile, meta.join("\n") + "\n");
110
+ const r = spawnSync(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-i", metaFile, "-map", "0", "-map_metadata", "1",
111
+ "-map_chapters", "1", "-map", "-0:d", "-c", "copy", "-movflags", "+faststart", outFile], { stdio: ["ignore", "ignore", "pipe"] });
112
+ if (r.status !== 0) throw new Error(`stitch failed: ${r.stderr?.toString().slice(-1500)}`);
113
+ return { seconds: at, rows };
114
+ }
115
+
116
+ /** One frame of a video as a JPEG (`atSec` into it); false when ffmpeg could not decode one there. */
117
+ export function extractFrame(video, atSec, outFile, { ffmpeg = "ffmpeg" } = {}) {
118
+ const r = spawnSync(ffmpeg, ["-y", "-ss", String(Math.max(0, atSec)), "-i", video, "-frames:v", "1", "-q:v", "3", outFile],
119
+ { stdio: ["ignore", "ignore", "pipe"] });
120
+ const ok = r.status === 0 && fs.existsSync(outFile) && fs.statSync(outFile).size > 0;
121
+ if (!ok) fs.rmSync(outFile, { force: true });
122
+ return ok;
123
+ }
124
+
81
125
  // ---- exported recorder factory -----------------------------------------------------------------
82
126
  /**
83
127
  * Build a reusable per-segment recorder bound to a story context.
@@ -125,12 +169,15 @@ export function makeSegmentRecorder({
125
169
  let videoError = null;
126
170
  if (fs.existsSync(tlPath)) {
127
171
  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"));
172
+ // The master (and the frames dump, if any) go with the timeline; they are dropped once a video is built from them.
173
+ for (const f of [CAPTURE, "frames"]) {
174
+ if (fs.existsSync(path.join(localSegDir(key), f))) fs.renameSync(path.join(localSegDir(key), f), path.join(dir, f));
175
+ }
129
176
  try {
130
177
  buildVideo(dir, path.join(dir, "segment.mp4"), { trimStart, ffmpeg: FFMPEG_BIN });
131
178
  video = "segment.mp4";
179
+ fs.rmSync(path.join(dir, CAPTURE), { force: true });
132
180
  fs.rmSync(path.join(dir, "frames"), { recursive: true, force: true });
133
- fs.rmSync(path.join(dir, "frames.ffconcat"), { force: true });
134
181
  } catch (e) {
135
182
  videoError = e.message;
136
183
  fs.rmSync(path.join(dir, "segment.mp4"), { force: true });
@@ -159,14 +206,15 @@ export function makeSegmentRecorder({
159
206
  console.log(`● ${seg.key} (take ${take})`);
160
207
  const stage = new Stage({
161
208
  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,
209
+ stickyOffset: story.stickyOffset, headful: process.env.DEMO_HEADFUL === "1", keepFrames: process.env.DEMO_KEEP_FRAMES === "1",
210
+ locale: story.locale, timezoneId: story.timezoneId, writableOrigin, consoleHandler, ffmpeg: FFMPEG_BIN,
164
211
  });
165
212
  const timings = {};
166
213
  let ctx = makeCtx(stage, timings);
167
214
  fs.mkdirSync(localSegDir(seg.key), { recursive: true });
168
215
  fs.rmSync(localSegVideo(seg.key), { force: true });
169
216
  fs.rmSync(path.join(localSegDir(seg.key), "timeline.json"), { force: true });
217
+ fs.rmSync(path.join(localSegDir(seg.key), CAPTURE), { force: true });
170
218
  let failure = null;
171
219
  let failedAt = null;
172
220
  try {
@@ -203,7 +251,8 @@ export function makeSegmentRecorder({
203
251
  } finally {
204
252
  await stage.close().catch(() => {});
205
253
  }
206
- let err = failure;
254
+ // An encoder that failed while close() drained it is a failed take too: its master may be short of the end.
255
+ let err = failure ?? stage.encoderError ?? null;
207
256
  if (stage.blocked.length) {
208
257
  err = sideEffectError(seg.key, stage.blocked);
209
258
  if (failure !== null) err.cause = failure;
@@ -235,7 +284,6 @@ export function makeSegmentRecorder({
235
284
  // ---- CLI entry point (only runs when invoked directly) -----------------------------------------
236
285
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
237
286
  const FFMPEG = process.env.FFMPEG || "ffmpeg";
238
- const FFPROBE = FFMPEG.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
239
287
 
240
288
  function usage(msg) {
241
289
  if (msg) console.error(`error: ${msg}\n`);
@@ -296,12 +344,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
296
344
  const readTimings = (key) => { try { return JSON.parse(fs.readFileSync(path.join(segDir(key), "timings.json"), "utf8")); } catch { return {}; } };
297
345
 
298
346
  // ---- stitch ----
299
- function probeSeconds(file) {
300
- const r = spawnSync(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file], { encoding: "utf8" });
301
- if (r.status !== 0) throw new Error(`ffprobe failed on ${file}: ${r.stderr}`);
302
- return Number(r.stdout.trim());
303
- }
304
-
305
347
  /** A segment's guard.json (how its last take was recorded), or null when it has none. */
306
348
  function readGuard(key) {
307
349
  try { return JSON.parse(fs.readFileSync(path.join(segDir(key), "guard.json"), "utf8")); } catch { return null; }
@@ -327,24 +369,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
327
369
  refuseFailedTakes();
328
370
  const segs = SEGMENTS.filter((s) => fs.existsSync(segVideo(s.key)));
329
371
  const missing = SEGMENTS.filter((s) => !fs.existsSync(segVideo(s.key))).map((s) => s.key);
330
- if (!segs.length) throw new Error("no segments recorded yet");
331
- const list = path.join(SEG_DIR, "concat.txt");
332
- fs.writeFileSync(list, segs.map((s) => `file '${segVideo(s.key).replace(/\\/g, "/").replace(/'/g, "'\\''")}'`).join("\n") + "\n");
333
- let at = 0;
334
- const meta = [";FFMETADATA1", `title=${(story.title || "Demo").replace(/[=;#\\\n]/g, " ")}`];
335
- const rows = ["| Time | Chapter |", "|---|---|"];
336
- for (const s of segs) {
337
- const d = probeSeconds(segVideo(s.key));
338
- const title = s.intro ? "Introduction" : s.title;
339
- meta.push("[CHAPTER]", "TIMEBASE=1/1000", `START=${Math.round(at * 1000)}`, `END=${Math.round((at + d) * 1000)}`, `title=${title.replace(/[=;#\\\n]/g, " ")}`);
340
- rows.push(`| ${Math.floor(at / 60)}:${String(Math.floor(at % 60)).padStart(2, "0")} | ${title} |`);
341
- at += d;
342
- }
343
- fs.writeFileSync(path.join(SEG_DIR, "chapters.ffmeta"), meta.join("\n") + "\n");
344
- // Segments share codec, size and frame rate, so they join without re-encoding.
345
- const r = spawnSync(FFMPEG, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-i", path.join(SEG_DIR, "chapters.ffmeta"), "-map", "0", "-map_metadata", "1",
346
- "-map_chapters", "1", "-map", "-0:d", "-c", "copy", "-movflags", "+faststart", FINAL], { stdio: ["ignore", "ignore", "pipe"] });
347
- if (r.status !== 0) throw new Error(`stitch failed: ${r.stderr?.toString().slice(-1500)}`);
372
+ const { seconds: at, rows } = stitchParts(
373
+ segs.map((s) => ({ title: s.intro ? "Introduction" : s.title, file: segVideo(s.key) })),
374
+ FINAL, { ffmpeg: FFMPEG, title: story.title || "Demo", workDir: SEG_DIR },
375
+ );
348
376
  fs.writeFileSync(path.join(OUT, "chapters.md"), rows.join("\n") + "\n");
349
377
  fs.writeFileSync(path.join(OUT, "timings.json"), JSON.stringify(Object.fromEntries(SEGMENTS.map((s) => [s.key, readTimings(s.key)])), null, 2));
350
378
  // How the stitched segments were recorded: read-only only when every one of them was (a segment recorded