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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -1
- package/package.json +1 -1
- package/scripts/_estate_client.py +57 -15
- package/scripts/demo/postprocess.mjs +76 -42
- package/scripts/demo/record.mjs +61 -33
- package/scripts/demo/stage.mjs +147 -27
- package/scripts/demo/walkthrough.mjs +219 -51
- 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
- package/skills/domain/vendor/README.md +4 -2
- package/skills/domain/vendor/VERSION +1 -1
- package/skills/search/SKILL.md +2 -1
- package/skills/search/refs/hotspots.md +2 -1
|
@@ -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.43.0"}], "version": "12.43.0"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-garden",
|
|
3
|
-
"version": "12.
|
|
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.
|
|
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.
|
|
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
|
|
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}}.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) --------------------------------------------------
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|