demobites 1.0.9 → 1.4.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/README.md +160 -9
- package/launcher/index.mjs +210 -0
- package/package.json +38 -10
- package/recorder/SKILL.md +30 -0
- package/recorder/STANDALONE-ENDING.md +34 -0
- package/recorder/scripts/frame.mjs +58 -0
- package/recorder/scripts/mux.mjs +74 -0
- package/recorder/scripts/post.sh +29 -0
- package/recorder/scripts/tts.mjs +72 -0
- package/scripts/calibrate.mjs +307 -0
- package/scripts/check-release.mjs +8 -0
- package/scripts/media-tools.mjs +93 -0
- package/scripts/record.mjs +1089 -0
- package/scripts/release.mjs +48 -0
- package/scripts/trim.mjs +170 -0
- package/skill/SKILL.md +450 -0
- package/skill/scripts/briefs.mjs +168 -0
- package/skill/scripts/cleanup.mjs +104 -0
- package/skill/scripts/login.mjs +157 -0
- package/skill/scripts/manifest.mjs +371 -0
- package/skill/scripts/retake.mjs +76 -0
- package/skill/scripts/rules.mjs +60 -0
- package/skill/scripts/stage-wait.mjs +113 -0
- package/skill/scripts/status.mjs +89 -0
- package/skill/scripts/upload.mjs +330 -0
- package/skill/scripts/vocab.mjs +88 -0
- package/index.mjs +0 -5
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Standalone ending only — raw.webm -> demo.mp4.
|
|
3
|
+
# Composites the look: backdrop color, TRUE rounded-alpha corners (mask via
|
|
4
|
+
# alphamerge, generated by frame.mjs), shadow ring, and the record_from trim.
|
|
5
|
+
#
|
|
6
|
+
# LAW (shortest=1): the still PNG overlays are infinite inputs — shortest=1 on
|
|
7
|
+
# the final overlay keeps the cut exactly as long as the video, never longer.
|
|
8
|
+
# LAW (first-frame trim): trim=start=record_from so the published cut opens on
|
|
9
|
+
# the fully loaded page that record.mjs stamped.
|
|
10
|
+
#
|
|
11
|
+
# Usage: post.sh <takeDir> [backdrop-hex-no-#] (default backdrop 0f1420)
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
14
|
+
FFMPEG="$(node "$HERE/../../scripts/media-tools.mjs" 2>/dev/null | awk -F"\t" '$1=="ffmpeg"{print $3}')"
|
|
15
|
+
FFPROBE="$(node "$HERE/../../scripts/media-tools.mjs" 2>/dev/null | awk -F"\t" '$1=="ffprobe"{print $3}')"
|
|
16
|
+
[ -n "$FFMPEG" ] && [ -n "$FFPROBE" ] || { echo "ffmpeg/ffprobe: no working build found. Run npx demobite@latest again, or install them on your system." >&2; exit 1; }
|
|
17
|
+
command -v node >/dev/null 2>&1 || { echo "node is required on PATH." >&2; exit 1; }
|
|
18
|
+
D=${1:?Usage: post.sh <takeDir> [backdrop-hex-no-#]}
|
|
19
|
+
# Backdrop resolution mirrors frame.mjs exactly: CLI arg, then the remembered
|
|
20
|
+
# .recorder/config.json look.backdrop, then #0f1420 — the two MUST agree or
|
|
21
|
+
# the corner mask color and the composited backdrop drift apart.
|
|
22
|
+
BG=${2:-$(node -p "try{(JSON.parse(require('fs').readFileSync('.recorder/config.json','utf8')).look||{}).backdrop?.replace('#','')||'0f1420'}catch{'0f1420'}")}
|
|
23
|
+
[ -f "$D/raw.webm" ] || { echo "$D/raw.webm not found. Run record.mjs first." >&2; exit 1; }
|
|
24
|
+
[ -f "$D/shadow.png" ] && [ -f "$D/mask.png" ] || { echo "Overlays missing. Run: node frame.mjs $D" >&2; exit 1; }
|
|
25
|
+
TRIM=$(node -p "JSON.parse(require('fs').readFileSync('$D/manifest.json','utf8')).record_from||0")
|
|
26
|
+
"$FFMPEG" -y -loglevel error -i "$D/raw.webm" -i "$D/shadow.png" -i "$D/mask.png" -filter_complex \
|
|
27
|
+
"color=c=0x${BG}:s=1920x1080:r=30[bg];[0:v]trim=start=${TRIM},setpts=PTS-STARTPTS,scale=1728:972:flags=lanczos[v];[2:v]format=gray[m];[v][m]alphamerge[va];[bg][1:v]overlay=0:0[b1];[b1][va]overlay=96:54:shortest=1,fps=30,format=yuv420p[out]" \
|
|
28
|
+
-map "[out]" -c:v libx264 -preset medium -crf 19 -movflags +faststart "$D/demo.mp4"
|
|
29
|
+
echo "demo.mp4 duration: $("$FFPROBE" -v error -show_entries format=duration -of csv=p=0 "$D/demo.mp4")s"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Standalone ending only — voice the storyboard with the customer's OWN
|
|
3
|
+
// ElevenLabs key. Reads the narration lines from the take's manifest,
|
|
4
|
+
// synthesizes one mp3 per line, measures each with ffprobe, and writes
|
|
5
|
+
// <takeDir>/tts/tts.json so mux.mjs (and manifest.mjs) use MEASURED
|
|
6
|
+
// durations instead of estimates.
|
|
7
|
+
//
|
|
8
|
+
// Key resolution (never stored in config.json): ELEVENLABS_API_KEY env var,
|
|
9
|
+
// else the file .recorder/elevenlabs.key. The key never leaves this machine
|
|
10
|
+
// except to the ElevenLabs API.
|
|
11
|
+
//
|
|
12
|
+
// Usage: node tts.mjs <takeDir>
|
|
13
|
+
// Voice: .recorder/config.json tts.voice_id, else Alice (premade, free tier OK).
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const dir = process.argv[2];
|
|
19
|
+
if (!dir) {
|
|
20
|
+
console.error("Usage: node tts.mjs <takeDir>");
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
import { ffmpeg as FFMPEG, ffprobe as FFPROBE, requireMediaTools } from "../../scripts/media-tools.mjs";
|
|
24
|
+
requireMediaTools(["ffprobe"]);
|
|
25
|
+
|
|
26
|
+
let KEY = process.env.ELEVENLABS_API_KEY || "";
|
|
27
|
+
if (!KEY) {
|
|
28
|
+
try { KEY = fs.readFileSync(path.resolve(".recorder", "elevenlabs.key"), "utf8").trim(); } catch {}
|
|
29
|
+
}
|
|
30
|
+
if (!KEY) {
|
|
31
|
+
console.error("No ElevenLabs key. Set ELEVENLABS_API_KEY or put the key in .recorder/elevenlabs.key");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let cfg = {};
|
|
36
|
+
try { cfg = JSON.parse(fs.readFileSync(path.resolve(".recorder", "config.json"), "utf8")); } catch {}
|
|
37
|
+
const VOICE = cfg.tts?.voice_id || "Xb7hH8MSUJpSbSDYk0k2"; // Alice, clear engaging educator
|
|
38
|
+
|
|
39
|
+
const manPath = path.join(dir, "manifest.json");
|
|
40
|
+
if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
|
|
41
|
+
const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
|
|
42
|
+
const narrated = (man.steps ?? []).filter((s) => s.narration?.text);
|
|
43
|
+
if (narrated.length === 0) { console.error("No narration lines in the manifest."); process.exit(1); }
|
|
44
|
+
|
|
45
|
+
const outDir = path.join(dir, "tts");
|
|
46
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const step of narrated) {
|
|
49
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "xi-api-key": KEY, "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
text: step.narration.text,
|
|
54
|
+
model_id: "eleven_multilingual_v2",
|
|
55
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.7 },
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
console.error("TTS failed for step", step.n, res.status, await res.text());
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
63
|
+
const file = `seg${step.n}.mp3`;
|
|
64
|
+
fs.writeFileSync(path.join(outDir, file), buf);
|
|
65
|
+
const dur = parseFloat(execFileSync(FFPROBE(), [
|
|
66
|
+
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path.join(outDir, file),
|
|
67
|
+
]).toString());
|
|
68
|
+
out.push({ n: step.n, text: step.narration.text, file, duration: Math.round(dur * 100) / 100 });
|
|
69
|
+
console.log(`step ${step.n}: ${dur.toFixed(2)}s "${step.narration.text}"`);
|
|
70
|
+
}
|
|
71
|
+
fs.writeFileSync(path.join(outDir, "tts.json"), JSON.stringify(out, null, 2));
|
|
72
|
+
console.log(`tts.json written (${out.length} segments) in ${outDir}`);
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CALIBRATE the stamped timebase against the footage, then VERIFY it.
|
|
3
|
+
//
|
|
4
|
+
// History, so nobody resurrects the old estimators:
|
|
5
|
+
// (1) This script once SOLVED clean = a*wall + b from click-to-scene-change
|
|
6
|
+
// matching. The 2026-08-09 four-lane investigation proved the rate must
|
|
7
|
+
// never be fitted: app render latency is unequal across clicks and a line
|
|
8
|
+
// anchored on unequal latencies tilted the rate 10%. Identity is the law;
|
|
9
|
+
// trim.mjs stamps video = wall - record_from.
|
|
10
|
+
// (2) The offset-only "plausible latency" search that replaced it failed the
|
|
11
|
+
// same night, more quietly: with LinkedIn's variable latency, a 1.733s
|
|
12
|
+
// offset and a 1.970s offset BOTH looked plausible. The founder's eye
|
|
13
|
+
// caught the 0.237s difference on screen. Plausible is not true.
|
|
14
|
+
//
|
|
15
|
+
// What measures the truth: HOVER ANCHORS. CSS hover styles flip in the SAME
|
|
16
|
+
// video frame the real mouse crosses an element's edge. record.mjs stamps
|
|
17
|
+
// each click/hover target's bbox; the cursor track knows the exact wall time
|
|
18
|
+
// it crossed that bbox; ffmpeg scene-detection on the bbox crop finds the
|
|
19
|
+
// exact video time the pixels flipped. Each anchor is a frame-exact clock
|
|
20
|
+
// correspondence with NO app-latency guessing. A consensus sweep adopts the
|
|
21
|
+
// one offset that lights up multiple anchors at once (measured on the take
|
|
22
|
+
// that exposed the bug: 4 anchors, 23ms spread, residuals within one frame).
|
|
23
|
+
//
|
|
24
|
+
// Fallback when a take has no usable anchors: the old plausible-latency
|
|
25
|
+
// offset search, followed by the median-latency gate. Anchors outrank it.
|
|
26
|
+
//
|
|
27
|
+
// Usage: node calibrate.mjs <takeDir> (run after trim.mjs, before upload)
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import { ffmpeg as FFMPEG, requireMediaTools } from "./media-tools.mjs";
|
|
32
|
+
requireMediaTools(["ffmpeg"]);
|
|
33
|
+
|
|
34
|
+
const dir = process.argv[2];
|
|
35
|
+
if (!dir) { console.error("Usage: node calibrate.mjs <takeDir>"); process.exit(2); }
|
|
36
|
+
|
|
37
|
+
const manPath = path.join(dir, "manifest.json");
|
|
38
|
+
const clean = path.join(dir, "clean.mp4");
|
|
39
|
+
if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
|
|
40
|
+
if (!fs.existsSync(clean)) { console.error(`${clean} not found. Run trim.mjs first.`); process.exit(1); }
|
|
41
|
+
const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
|
|
42
|
+
const tb = man.timebase;
|
|
43
|
+
// IDEMPOTENCE (2026-09-02): always measure from the identity base. A previous
|
|
44
|
+
// run may have already applied a hover-anchor shift to `b`; re-running on top
|
|
45
|
+
// of it compounded two shifts (-0.71 then -1.67 on one take) and produced a
|
|
46
|
+
// clock that matched nothing. Re-base first, then anchor once.
|
|
47
|
+
if (tb && typeof man.record_from === "number") {
|
|
48
|
+
tb.a = 1; tb.k = 1; tb.b = -man.record_from; tb.videoRecordFrom = man.record_from;
|
|
49
|
+
tb.method = "identity (raw is wall-rate; see 2026-08-09 four-lane verification)";
|
|
50
|
+
}
|
|
51
|
+
if (!tb || typeof tb.a !== "number" || typeof tb.b !== "number") {
|
|
52
|
+
console.error("manifest.json has no timebase stamp. Run trim.mjs first.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const FRAME = { w: man.frame?.width ?? 1920, h: man.frame?.height ?? 1080 };
|
|
57
|
+
const clicks = (man.steps ?? [])
|
|
58
|
+
// A `type` step has no single consequence instant — its pixels keep changing
|
|
59
|
+
// for the whole typing run, so the detector lands at the END of it (+5..7 s
|
|
60
|
+
// every take, 2026-09-02) and poisons the median. Clicks only.
|
|
61
|
+
.filter((s) => typeof s.click_at === "number" && s.action !== "type")
|
|
62
|
+
.map((s) => ({ wall: s.click_at, label: s.label ?? "click" }))
|
|
63
|
+
.sort((x, y) => x.wall - y.wall);
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Phase 1: hover anchors — frame-exact, latency-free.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const track = (man.interactions?.mouseEvents ?? []).filter((e) => e.type === "move" || e.type === "click");
|
|
70
|
+
|
|
71
|
+
function bboxCrossing(step) {
|
|
72
|
+
// The wall time the cursor track ENTERS the target bbox (last entry before
|
|
73
|
+
// the dwell settles). Entry edge, not center arrival: the hover style flips
|
|
74
|
+
// at the edge. No crossing means the cursor started inside — unusable.
|
|
75
|
+
const bb = step.target?.bbox;
|
|
76
|
+
if (!bb || track.length === 0) return null;
|
|
77
|
+
const until = (typeof step.click_at === "number" ? step.click_at : step.t_end) + 0.2;
|
|
78
|
+
const from = (step.t_start ?? 0) - 0.5;
|
|
79
|
+
const inside = (e) => e.x >= bb.x && e.x <= bb.x + bb.w && e.y >= bb.y && e.y <= bb.y + bb.h;
|
|
80
|
+
let cross = null, prevIn = null;
|
|
81
|
+
for (const e of track) {
|
|
82
|
+
if (e.time < from) { prevIn = inside(e); continue; }
|
|
83
|
+
if (e.time > until) break;
|
|
84
|
+
const now = inside(e);
|
|
85
|
+
if (now && prevIn === false) cross = e.time;
|
|
86
|
+
prevIn = now;
|
|
87
|
+
}
|
|
88
|
+
return cross;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function roiChanges(bb) {
|
|
92
|
+
// ONSETS of visible change inside the padded bbox crop, in video time.
|
|
93
|
+
// Not threshold crossings: hover styles animate (LinkedIn fades its row
|
|
94
|
+
// background over ~150ms), and a threshold detector fires mid-fade —
|
|
95
|
+
// measured +150ms late against the hover-ladder ground truth. So: score
|
|
96
|
+
// EVERY frame (select='gte(scene,0)' + metadata), find peaks, and walk
|
|
97
|
+
// each peak back to the first frame of its rising run. The onset frame is
|
|
98
|
+
// when the real cursor crossed the edge.
|
|
99
|
+
const pad = 6;
|
|
100
|
+
const x0 = Math.max(0, bb.x - pad), y0 = Math.max(0, bb.y - pad);
|
|
101
|
+
const w = Math.min(FRAME.w - x0, bb.w + 2 * pad), h = Math.min(FRAME.h - y0, bb.h + 2 * pad);
|
|
102
|
+
const res = spawnSync(FFMPEG(), [
|
|
103
|
+
"-loglevel", "info", "-i", clean,
|
|
104
|
+
"-vf", `crop=${w}:${h}:${x0}:${y0},select='gte(scene,0)',metadata=print`, "-f", "null", "-",
|
|
105
|
+
], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
106
|
+
const err = `${res.stderr ?? ""}`;
|
|
107
|
+
const rows = [];
|
|
108
|
+
const re = /pts_time:([0-9.]+)[\s\S]*?lavfi\.scene_score=([0-9.]+)/g;
|
|
109
|
+
for (let m; (m = re.exec(err)); ) rows.push({ t: parseFloat(m[1]), s: parseFloat(m[2]) });
|
|
110
|
+
const onsets = [];
|
|
111
|
+
for (let i = 1; i < rows.length; i++) {
|
|
112
|
+
if (rows[i].s < 0.004) continue; // not a peak-worthy change
|
|
113
|
+
let j = i; // walk back over the rising run
|
|
114
|
+
while (j > 1 && rows[j - 1].s > Math.max(0.0008, rows[i].s * 0.12)) j--;
|
|
115
|
+
const onset = rows[j].t;
|
|
116
|
+
if (onsets.length === 0 || onset - onsets[onsets.length - 1] > 0.1) onsets.push(onset);
|
|
117
|
+
}
|
|
118
|
+
return onsets;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const anchors = [];
|
|
122
|
+
for (const s of man.steps ?? []) {
|
|
123
|
+
if (s.action !== "click" && s.action !== "hover") continue;
|
|
124
|
+
const wallCross = bboxCrossing(s);
|
|
125
|
+
if (wallCross == null) continue;
|
|
126
|
+
const pred = tb.a * wallCross + tb.b;
|
|
127
|
+
const changes = roiChanges(s.target.bbox).filter((c) => c >= pred - 3.2 && c <= pred + 1.2);
|
|
128
|
+
if (changes.length === 0) continue;
|
|
129
|
+
anchors.push({ label: s.label ?? s.action, pred, changes, weight: 1 / Math.sqrt(changes.length) });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`calibrate: map video = ${tb.a} * wall + ${tb.b.toFixed(3)} (${tb.method ?? "unstamped"})`);
|
|
133
|
+
console.log(`calibrate: ${anchors.length} hover anchor${anchors.length === 1 ? "" : "s"} usable`);
|
|
134
|
+
|
|
135
|
+
let anchored = false;
|
|
136
|
+
// BEACON OUTRANKS ANCHORS (2026-09-02, second prod bite-96 take): the head
|
|
137
|
+
// beacon had measured the cut to +-14 ms, yet ONE hover anchor locked onto a
|
|
138
|
+
// stray pixel change 2.66 s away and dragged the clock with it. When the
|
|
139
|
+
// beacon has spoken (trim stamped beaconDelta from >=3 matched flashes),
|
|
140
|
+
// anchors may only REFINE the clock inside a third of a second; they never
|
|
141
|
+
// re-decide it. Without a beacon the wide sweep stays (the pre-beacon world).
|
|
142
|
+
const beaconAnchored = typeof tb?.beaconDelta === "number";
|
|
143
|
+
const SWEEP_LO = beaconAnchored ? -0.35 : -3.0;
|
|
144
|
+
const SWEEP_HI = beaconAnchored ? 0.35 : 1.0;
|
|
145
|
+
if (beaconAnchored) console.log(`calibrate: beacon-anchored clock — hover anchors may refine by at most ${SWEEP_HI.toFixed(2)}s`);
|
|
146
|
+
if (anchors.length >= 2) {
|
|
147
|
+
const TOL = 0.067; // two frames
|
|
148
|
+
let best = { delta: 0, score: -1, resid: Infinity };
|
|
149
|
+
for (let d = SWEEP_LO; d <= SWEEP_HI + 0.0001; d += 1 / 60) {
|
|
150
|
+
let score = 0, resid = 0, hits = 0;
|
|
151
|
+
for (const a of anchors) {
|
|
152
|
+
const target = a.pred + d;
|
|
153
|
+
const nearest = a.changes.reduce((p, c) => (Math.abs(c - target) < Math.abs(p - target) ? c : p));
|
|
154
|
+
const r = Math.abs(nearest - target);
|
|
155
|
+
if (r <= TOL) { score += a.weight; resid += r; hits++; }
|
|
156
|
+
}
|
|
157
|
+
if (score > best.score + 1e-9 || (Math.abs(score - best.score) <= 1e-9 && resid < best.resid)) {
|
|
158
|
+
best = { delta: d, score, resid, hits };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (best.hits >= 2) {
|
|
162
|
+
// Snap to the median residual of the hit anchors: kills the sweep's
|
|
163
|
+
// quantization and lets one honest majority outvote a stray anchor.
|
|
164
|
+
{
|
|
165
|
+
const rs = [];
|
|
166
|
+
for (const a of anchors) {
|
|
167
|
+
const target = a.pred + best.delta;
|
|
168
|
+
const nearest = a.changes.reduce((p, c) => (Math.abs(c - target) < Math.abs(p - target) ? c : p));
|
|
169
|
+
if (Math.abs(nearest - target) <= TOL) rs.push(nearest - target);
|
|
170
|
+
}
|
|
171
|
+
rs.sort((p, q) => p - q);
|
|
172
|
+
if (rs.length) best.delta += rs[Math.floor(rs.length / 2)];
|
|
173
|
+
}
|
|
174
|
+
const residuals = [];
|
|
175
|
+
for (const a of anchors) {
|
|
176
|
+
const target = a.pred + best.delta;
|
|
177
|
+
const nearest = a.changes.reduce((p, c) => (Math.abs(c - target) < Math.abs(p - target) ? c : p));
|
|
178
|
+
const r = nearest - target;
|
|
179
|
+
if (Math.abs(r) <= TOL) {
|
|
180
|
+
residuals.push(r);
|
|
181
|
+
console.log(` anchor ${a.label}: flip at ${nearest.toFixed(3)}s, residual ${r >= 0 ? "+" : ""}${(r * 1000).toFixed(0)}ms`);
|
|
182
|
+
} else {
|
|
183
|
+
console.log(` anchor ${a.label}: no flip within tolerance at this offset (skipped)`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (residuals.length < 2) {
|
|
187
|
+
// One surviving anchor is an opinion, not a measurement.
|
|
188
|
+
console.log(`calibrate: only ${residuals.length} anchor survived the lock — clock left ${beaconAnchored ? "on the beacon" : "as stamped"}.`);
|
|
189
|
+
anchored = beaconAnchored;
|
|
190
|
+
best.delta = 0;
|
|
191
|
+
}
|
|
192
|
+
const rms = residuals.length >= 2 ? Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / residuals.length) : 0;
|
|
193
|
+
if (residuals.length >= 2 && rms > 0.08) {
|
|
194
|
+
console.error(`CALIBRATE FAILED: hover-anchor rms ${(rms * 1000).toFixed(0)}ms exceeds 80ms — footage disagrees with itself. Do not upload.`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
if (residuals.length >= 2 && Math.abs(best.delta) > 0.005) {
|
|
198
|
+
tb.b += best.delta;
|
|
199
|
+
tb.videoRecordFrom = -tb.b;
|
|
200
|
+
tb.method += ` + hover-anchor ${best.delta.toFixed(3)}s (${best.hits} anchors, rms ${(rms * 1000).toFixed(0)}ms)`;
|
|
201
|
+
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
202
|
+
console.log(`calibrate: hover anchors moved the clock ${best.delta.toFixed(3)}s — b is now ${tb.b.toFixed(3)} (rms ${(rms * 1000).toFixed(0)}ms)`);
|
|
203
|
+
} else if (residuals.length >= 2) {
|
|
204
|
+
console.log(`calibrate: hover anchors confirm the stamp as-is (rms ${(rms * 1000).toFixed(0)}ms)`);
|
|
205
|
+
}
|
|
206
|
+
if (residuals.length >= 2) anchored = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Phase 2: fallback offset search (only when anchors could not decide) and
|
|
212
|
+
// the latency sanity report. With anchors adopted, latency is advisory only:
|
|
213
|
+
// a slow app legitimately stretches click-to-consequence gaps, and a measured
|
|
214
|
+
// clock outranks a heuristic about them.
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
if (clicks.length === 0) {
|
|
218
|
+
console.log("verify: no clicks in this take, nothing further to check.");
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sceneChanges(threshold) {
|
|
223
|
+
const res = spawnSync(FFMPEG(), [
|
|
224
|
+
"-loglevel", "info", "-i", clean,
|
|
225
|
+
"-vf", `select='gt(scene,${threshold})',showinfo`, "-f", "null", "-",
|
|
226
|
+
], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
227
|
+
return [...`${res.stderr ?? ""}`.matchAll(/pts_time:([0-9.]+)/g)].map((m) => parseFloat(m[1]));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let scenes = [];
|
|
231
|
+
for (const th of [0.15, 0.08, 0.04, 0.02, 0.01]) {
|
|
232
|
+
scenes = sceneChanges(th);
|
|
233
|
+
if (scenes.length >= clicks.length) break;
|
|
234
|
+
}
|
|
235
|
+
if (scenes.length === 0) {
|
|
236
|
+
console.log(anchored
|
|
237
|
+
? "verify: no whole-frame scene changes — anchors already decided, done."
|
|
238
|
+
: "verify: no scene changes detected — app too static to verify, map stays as stamped.");
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const SWEEP_LO_FB = beaconAnchored ? -0.35 : -4;
|
|
243
|
+
const SWEEP_HI_FB = beaconAnchored ? 0.35 : 1.0;
|
|
244
|
+
if (!anchored) {
|
|
245
|
+
// Old plausible-latency search. Kept ONLY as the no-anchor fallback; its
|
|
246
|
+
// known failure mode (plausible != true, 0.237s off on a real take) is why
|
|
247
|
+
// hover anchors exist. Rate stays PINNED at 1.
|
|
248
|
+
const score = (delta) => {
|
|
249
|
+
let total = 0;
|
|
250
|
+
for (const c of clicks) {
|
|
251
|
+
const pred = tb.a * c.wall + tb.b + delta;
|
|
252
|
+
const after = scenes.filter((sc) => sc >= pred - 0.1);
|
|
253
|
+
if (after.length === 0) { total += 2; continue; }
|
|
254
|
+
const lat = after.reduce((a2, b2) => (Math.abs(a2 - pred) <= Math.abs(b2 - pred) ? a2 : b2)) - pred;
|
|
255
|
+
total += lat >= -0.1 && lat <= 0.9 ? Math.abs(lat - 0.1) : 2;
|
|
256
|
+
}
|
|
257
|
+
return total;
|
|
258
|
+
};
|
|
259
|
+
let bestDelta = 0, bestScore = score(0);
|
|
260
|
+
for (let d = SWEEP_LO_FB; d <= SWEEP_HI_FB + 0.0001; d += 1 / 30) {
|
|
261
|
+
const sc = score(d);
|
|
262
|
+
if (sc < bestScore - 1e-9) { bestScore = sc; bestDelta = d; }
|
|
263
|
+
}
|
|
264
|
+
if (Math.abs(bestDelta) > 0.005 && bestScore < clicks.length * 0.9) {
|
|
265
|
+
tb.b += bestDelta;
|
|
266
|
+
tb.videoRecordFrom = -tb.b;
|
|
267
|
+
tb.method += ` + offset search ${bestDelta.toFixed(3)}s (NO ANCHORS — estimate, not measurement)`;
|
|
268
|
+
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
269
|
+
console.log(`offset search (fallback): track shifted ${bestDelta.toFixed(3)}s — b adjusted to ${tb.b.toFixed(3)}`);
|
|
270
|
+
console.log("WARNING: no hover anchors were usable; this offset is an estimate. Prefer storyboards whose targets have hover styles.");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const latencies = [];
|
|
275
|
+
for (const c of clicks) {
|
|
276
|
+
const pred = tb.a * c.wall + tb.b;
|
|
277
|
+
const after = scenes.filter((s) => s >= pred - 0.15);
|
|
278
|
+
if (after.length === 0) {
|
|
279
|
+
console.log(` ${c.label}: predicted ${pred.toFixed(2)}s, no visible change after it`);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const nearest = after.reduce((a, b) => (Math.abs(a - pred) <= Math.abs(b - pred) ? a : b));
|
|
283
|
+
latencies.push(nearest - pred);
|
|
284
|
+
console.log(` ${c.label}: predicted ${pred.toFixed(2)}s, change at ${nearest.toFixed(2)}s, app latency ${nearest - pred >= 0 ? "+" : ""}${(nearest - pred).toFixed(3)}s`);
|
|
285
|
+
}
|
|
286
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
287
|
+
const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
|
|
288
|
+
if (!anchored && (median < -0.15 || median > 0.9)) {
|
|
289
|
+
console.error(
|
|
290
|
+
`VERIFY FAILED: median click latency ${median.toFixed(3)}s is outside [-0.15, +0.9]. ` +
|
|
291
|
+
"The stamped timebase looks wrong for this take. Do not upload — investigate record_from.",
|
|
292
|
+
);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
if (!beaconAnchored && anchored && (median < -0.6 || median > 2.5)) {
|
|
296
|
+
// INCIDENT 2026-09-02: +4.6 s "latency" on an anchored clock was the head
|
|
297
|
+
// miscut showing through (the anchors had locked onto the wrong beat).
|
|
298
|
+
console.error(`VERIFY FAILED: median click latency ${median.toFixed(3)}s on an anchor-measured clock is not an app delay, it is a broken timebase (the cut point or the anchor lock is wrong). Do not upload — film again.`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
if (beaconAnchored && (median < -0.6 || median > 2.5)) {
|
|
302
|
+
console.log(`WARNING: median click latency ${median.toFixed(3)}s on a beacon-measured clock — the clock is trusted, so the app genuinely answered late. The footage shows the wait; judge whether the beat reads as slow.`);
|
|
303
|
+
}
|
|
304
|
+
if (anchored && (median < -0.15 || median > 0.9)) {
|
|
305
|
+
console.log(`note: median click latency ${median.toFixed(3)}s is unusual, but the clock is anchor-measured — likely a genuinely slow app response. Judge the take on duration and feel.`);
|
|
306
|
+
}
|
|
307
|
+
console.log(`verify: PASS (${anchored ? "anchor-measured" : "estimated"}, median latency ${median >= 0 ? "+" : ""}${median.toFixed(3)}s)`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// prepublishOnly guard: a bare `npm publish` ships ONE of the three names and
|
|
3
|
+
// leaves the other two behind, which is exactly what the founder refused on
|
|
4
|
+
// 2026-09-15. Publish through `npm run release`.
|
|
5
|
+
if (process.env.DEMOBITE_RELEASE !== "1") {
|
|
6
|
+
console.error("Refusing a bare npm publish: demobite, agentic-recorder and demobites ship together. Run: npm run release");
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// media-tools — the ONE place the recorder resolves ffmpeg and ffprobe.
|
|
2
|
+
//
|
|
3
|
+
// Order (founder design 2026-09-09):
|
|
4
|
+
// 1. an explicit override in <project>/.recorder/config.json { "ffmpeg": "...", "ffprobe": "..." }
|
|
5
|
+
// 2. a SYSTEM binary on PATH that passes the compatibility probe (below)
|
|
6
|
+
// 3. the packaged binaries from @ffmpeg-installer/ffmpeg + @ffprobe-installer/ffprobe
|
|
7
|
+
// (one platform each), installed by the launcher beside Playwright in the skill folder
|
|
8
|
+
//
|
|
9
|
+
// Compatibility, not presence: trim/mux need libx264 and the aac encoder. An old
|
|
10
|
+
// or stripped system build fails the probe and the packaged binary wins.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// scripts/ lives directly under the skill folder; node_modules sits beside it.
|
|
19
|
+
const skillRoot = path.resolve(here, "..");
|
|
20
|
+
|
|
21
|
+
function probe(bin, tool) {
|
|
22
|
+
try {
|
|
23
|
+
const out = execFileSync(bin, ["-version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }).toString();
|
|
24
|
+
if (!/^ff(mpeg|probe) version/m.test(out)) return { ok: false, why: "not an ffmpeg build" };
|
|
25
|
+
if (tool === "ffmpeg") {
|
|
26
|
+
const enc = execFileSync(bin, ["-hide_banner", "-encoders"], { stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }).toString();
|
|
27
|
+
if (!/\blibx264\b/.test(enc)) return { ok: false, why: "no libx264 encoder" };
|
|
28
|
+
if (!/\baac\b/.test(enc)) return { ok: false, why: "no aac encoder" };
|
|
29
|
+
}
|
|
30
|
+
return { ok: true };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return { ok: false, why: e?.code === "ENOENT" ? "not found" : "did not run" };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fromOverride(tool) {
|
|
37
|
+
try {
|
|
38
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8"));
|
|
39
|
+
const v = cfg?.[tool];
|
|
40
|
+
return typeof v === "string" && v ? v : null;
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fromPackage(tool) {
|
|
45
|
+
// Per-platform packages: only the current platform's binary is downloaded.
|
|
46
|
+
const pkg = tool === "ffmpeg" ? "@ffmpeg-installer/ffmpeg" : "@ffprobe-installer/ffprobe";
|
|
47
|
+
for (const base of [skillRoot, process.cwd()]) {
|
|
48
|
+
try {
|
|
49
|
+
const req = createRequire(pathToFileURL(path.join(base, "package.json")));
|
|
50
|
+
const p = req(pkg)?.path;
|
|
51
|
+
if (typeof p === "string" && fs.existsSync(p)) return p;
|
|
52
|
+
} catch { /* not installed here */ }
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cache = {};
|
|
58
|
+
/** Resolve a tool. Returns { path, source, why } — path null when nothing works. */
|
|
59
|
+
export function resolveTool(tool) {
|
|
60
|
+
if (cache[tool]) return cache[tool];
|
|
61
|
+
const tried = [];
|
|
62
|
+
const o = fromOverride(tool);
|
|
63
|
+
if (o) { const r = probe(o, tool); if (r.ok) return (cache[tool] = { path: o, source: "config" }); tried.push(`config ${o}: ${r.why}`); }
|
|
64
|
+
const sys = probe(tool, tool);
|
|
65
|
+
if (sys.ok) return (cache[tool] = { path: tool, source: "system" });
|
|
66
|
+
tried.push(`system: ${sys.why}`);
|
|
67
|
+
const pk = fromPackage(tool);
|
|
68
|
+
if (pk) { const r = probe(pk, tool); if (r.ok) return (cache[tool] = { path: pk, source: "packaged" }); tried.push(`packaged ${pk}: ${r.why}`); }
|
|
69
|
+
else tried.push("packaged: not installed");
|
|
70
|
+
return (cache[tool] = { path: null, source: null, why: tried.join("; ") });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ffmpeg = () => resolveTool("ffmpeg").path;
|
|
74
|
+
export const ffprobe = () => resolveTool("ffprobe").path;
|
|
75
|
+
|
|
76
|
+
/** Exit with a clear message unless both tools resolve. Used by every script that films or finishes. */
|
|
77
|
+
export function requireMediaTools(tools = ["ffmpeg", "ffprobe"]) {
|
|
78
|
+
const missing = tools.map((t) => [t, resolveTool(t)]).filter(([, r]) => !r.path);
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
for (const [t, r] of missing) console.error(`${t}: no working build found (${r.why}).`);
|
|
81
|
+
console.error("Run `npx demobite@latest` again to install the packaged ffmpeg and ffprobe, or install them on your system.");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `node media-tools.mjs` prints what would be used — the launcher calls this.
|
|
87
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
88
|
+
for (const t of ["ffmpeg", "ffprobe"]) {
|
|
89
|
+
const r = resolveTool(t);
|
|
90
|
+
console.log(r.path ? `${t}\t${r.source}\t${r.path}` : `${t}\tmissing\t${r.why}`);
|
|
91
|
+
}
|
|
92
|
+
process.exit(["ffmpeg", "ffprobe"].every((t) => resolveTool(t).path) ? 0 : 1);
|
|
93
|
+
}
|