demobite 0.0.1
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/LICENSE +21 -0
- package/README.md +58 -0
- package/launcher/index.mjs +84 -0
- package/package.json +38 -0
- 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 +81 -0
- package/recorder/scripts/post.sh +26 -0
- package/recorder/scripts/tts.mjs +78 -0
- package/scripts/calibrate.mjs +267 -0
- package/scripts/record.mjs +706 -0
- package/scripts/trim.mjs +72 -0
- package/skill/SKILL.md +289 -0
- package/skill/scripts/login.mjs +150 -0
- package/skill/scripts/manifest.mjs +321 -0
- package/skill/scripts/upload.mjs +259 -0
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
|
|
32
|
+
const dir = process.argv[2];
|
|
33
|
+
if (!dir) { console.error("Usage: node calibrate.mjs <takeDir>"); process.exit(2); }
|
|
34
|
+
|
|
35
|
+
const manPath = path.join(dir, "manifest.json");
|
|
36
|
+
const clean = path.join(dir, "clean.mp4");
|
|
37
|
+
if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
|
|
38
|
+
if (!fs.existsSync(clean)) { console.error(`${clean} not found. Run trim.mjs first.`); process.exit(1); }
|
|
39
|
+
const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
|
|
40
|
+
const tb = man.timebase;
|
|
41
|
+
if (!tb || typeof tb.a !== "number" || typeof tb.b !== "number") {
|
|
42
|
+
console.error("manifest.json has no timebase stamp. Run trim.mjs first.");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const FRAME = { w: man.frame?.width ?? 1920, h: man.frame?.height ?? 1080 };
|
|
47
|
+
const clicks = (man.steps ?? [])
|
|
48
|
+
.filter((s) => typeof s.click_at === "number")
|
|
49
|
+
.map((s) => ({ wall: s.click_at, label: s.label ?? "click" }))
|
|
50
|
+
.sort((x, y) => x.wall - y.wall);
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Phase 1: hover anchors — frame-exact, latency-free.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const track = (man.interactions?.mouseEvents ?? []).filter((e) => e.type === "move" || e.type === "click");
|
|
57
|
+
|
|
58
|
+
function bboxCrossing(step) {
|
|
59
|
+
// The wall time the cursor track ENTERS the target bbox (last entry before
|
|
60
|
+
// the dwell settles). Entry edge, not center arrival: the hover style flips
|
|
61
|
+
// at the edge. No crossing means the cursor started inside — unusable.
|
|
62
|
+
const bb = step.target?.bbox;
|
|
63
|
+
if (!bb || track.length === 0) return null;
|
|
64
|
+
const until = (typeof step.click_at === "number" ? step.click_at : step.t_end) + 0.2;
|
|
65
|
+
const from = (step.t_start ?? 0) - 0.5;
|
|
66
|
+
const inside = (e) => e.x >= bb.x && e.x <= bb.x + bb.w && e.y >= bb.y && e.y <= bb.y + bb.h;
|
|
67
|
+
let cross = null, prevIn = null;
|
|
68
|
+
for (const e of track) {
|
|
69
|
+
if (e.time < from) { prevIn = inside(e); continue; }
|
|
70
|
+
if (e.time > until) break;
|
|
71
|
+
const now = inside(e);
|
|
72
|
+
if (now && prevIn === false) cross = e.time;
|
|
73
|
+
prevIn = now;
|
|
74
|
+
}
|
|
75
|
+
return cross;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function roiChanges(bb) {
|
|
79
|
+
// ONSETS of visible change inside the padded bbox crop, in video time.
|
|
80
|
+
// Not threshold crossings: hover styles animate (LinkedIn fades its row
|
|
81
|
+
// background over ~150ms), and a threshold detector fires mid-fade —
|
|
82
|
+
// measured +150ms late against the hover-ladder ground truth. So: score
|
|
83
|
+
// EVERY frame (select='gte(scene,0)' + metadata), find peaks, and walk
|
|
84
|
+
// each peak back to the first frame of its rising run. The onset frame is
|
|
85
|
+
// when the real cursor crossed the edge.
|
|
86
|
+
const pad = 6;
|
|
87
|
+
const x0 = Math.max(0, bb.x - pad), y0 = Math.max(0, bb.y - pad);
|
|
88
|
+
const w = Math.min(FRAME.w - x0, bb.w + 2 * pad), h = Math.min(FRAME.h - y0, bb.h + 2 * pad);
|
|
89
|
+
const res = spawnSync("ffmpeg", [
|
|
90
|
+
"-loglevel", "info", "-i", clean,
|
|
91
|
+
"-vf", `crop=${w}:${h}:${x0}:${y0},select='gte(scene,0)',metadata=print`, "-f", "null", "-",
|
|
92
|
+
], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
93
|
+
const err = `${res.stderr ?? ""}`;
|
|
94
|
+
const rows = [];
|
|
95
|
+
const re = /pts_time:([0-9.]+)[\s\S]*?lavfi\.scene_score=([0-9.]+)/g;
|
|
96
|
+
for (let m; (m = re.exec(err)); ) rows.push({ t: parseFloat(m[1]), s: parseFloat(m[2]) });
|
|
97
|
+
const onsets = [];
|
|
98
|
+
for (let i = 1; i < rows.length; i++) {
|
|
99
|
+
if (rows[i].s < 0.004) continue; // not a peak-worthy change
|
|
100
|
+
let j = i; // walk back over the rising run
|
|
101
|
+
while (j > 1 && rows[j - 1].s > Math.max(0.0008, rows[i].s * 0.12)) j--;
|
|
102
|
+
const onset = rows[j].t;
|
|
103
|
+
if (onsets.length === 0 || onset - onsets[onsets.length - 1] > 0.1) onsets.push(onset);
|
|
104
|
+
}
|
|
105
|
+
return onsets;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const anchors = [];
|
|
109
|
+
for (const s of man.steps ?? []) {
|
|
110
|
+
if (s.action !== "click" && s.action !== "hover") continue;
|
|
111
|
+
const wallCross = bboxCrossing(s);
|
|
112
|
+
if (wallCross == null) continue;
|
|
113
|
+
const pred = tb.a * wallCross + tb.b;
|
|
114
|
+
const changes = roiChanges(s.target.bbox).filter((c) => c >= pred - 3.2 && c <= pred + 1.2);
|
|
115
|
+
if (changes.length === 0) continue;
|
|
116
|
+
anchors.push({ label: s.label ?? s.action, pred, changes, weight: 1 / Math.sqrt(changes.length) });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`calibrate: map video = ${tb.a} * wall + ${tb.b.toFixed(3)} (${tb.method ?? "unstamped"})`);
|
|
120
|
+
console.log(`calibrate: ${anchors.length} hover anchor${anchors.length === 1 ? "" : "s"} usable`);
|
|
121
|
+
|
|
122
|
+
let anchored = false;
|
|
123
|
+
if (anchors.length >= 2) {
|
|
124
|
+
const TOL = 0.067; // two frames
|
|
125
|
+
let best = { delta: 0, score: -1, resid: Infinity };
|
|
126
|
+
for (let d = -3.0; d <= 1.0001; d += 1 / 60) {
|
|
127
|
+
let score = 0, resid = 0, hits = 0;
|
|
128
|
+
for (const a of anchors) {
|
|
129
|
+
const target = a.pred + d;
|
|
130
|
+
const nearest = a.changes.reduce((p, c) => (Math.abs(c - target) < Math.abs(p - target) ? c : p));
|
|
131
|
+
const r = Math.abs(nearest - target);
|
|
132
|
+
if (r <= TOL) { score += a.weight; resid += r; hits++; }
|
|
133
|
+
}
|
|
134
|
+
if (score > best.score + 1e-9 || (Math.abs(score - best.score) <= 1e-9 && resid < best.resid)) {
|
|
135
|
+
best = { delta: d, score, resid, hits };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (best.hits >= 2) {
|
|
139
|
+
// Snap to the median residual of the hit anchors: kills the sweep's
|
|
140
|
+
// quantization and lets one honest majority outvote a stray anchor.
|
|
141
|
+
{
|
|
142
|
+
const rs = [];
|
|
143
|
+
for (const a of anchors) {
|
|
144
|
+
const target = a.pred + best.delta;
|
|
145
|
+
const nearest = a.changes.reduce((p, c) => (Math.abs(c - target) < Math.abs(p - target) ? c : p));
|
|
146
|
+
if (Math.abs(nearest - target) <= TOL) rs.push(nearest - target);
|
|
147
|
+
}
|
|
148
|
+
rs.sort((p, q) => p - q);
|
|
149
|
+
if (rs.length) best.delta += rs[Math.floor(rs.length / 2)];
|
|
150
|
+
}
|
|
151
|
+
const residuals = [];
|
|
152
|
+
for (const a of anchors) {
|
|
153
|
+
const target = a.pred + best.delta;
|
|
154
|
+
const nearest = a.changes.reduce((p, c) => (Math.abs(c - target) < Math.abs(p - target) ? c : p));
|
|
155
|
+
const r = nearest - target;
|
|
156
|
+
if (Math.abs(r) <= TOL) {
|
|
157
|
+
residuals.push(r);
|
|
158
|
+
console.log(` anchor ${a.label}: flip at ${nearest.toFixed(3)}s, residual ${r >= 0 ? "+" : ""}${(r * 1000).toFixed(0)}ms`);
|
|
159
|
+
} else {
|
|
160
|
+
console.log(` anchor ${a.label}: no flip within tolerance at this offset (skipped)`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const rms = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / residuals.length);
|
|
164
|
+
if (rms > 0.08) {
|
|
165
|
+
console.error(`CALIBRATE FAILED: hover-anchor rms ${(rms * 1000).toFixed(0)}ms exceeds 80ms — footage disagrees with itself. Do not upload.`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
if (Math.abs(best.delta) > 0.005) {
|
|
169
|
+
tb.b += best.delta;
|
|
170
|
+
tb.videoRecordFrom = -tb.b;
|
|
171
|
+
tb.method += ` + hover-anchor ${best.delta.toFixed(3)}s (${best.hits} anchors, rms ${(rms * 1000).toFixed(0)}ms)`;
|
|
172
|
+
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
173
|
+
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)`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log(`calibrate: hover anchors confirm the stamp as-is (rms ${(rms * 1000).toFixed(0)}ms)`);
|
|
176
|
+
}
|
|
177
|
+
anchored = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Phase 2: fallback offset search (only when anchors could not decide) and
|
|
183
|
+
// the latency sanity report. With anchors adopted, latency is advisory only:
|
|
184
|
+
// a slow app legitimately stretches click-to-consequence gaps, and a measured
|
|
185
|
+
// clock outranks a heuristic about them.
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
if (clicks.length === 0) {
|
|
189
|
+
console.log("verify: no clicks in this take, nothing further to check.");
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sceneChanges(threshold) {
|
|
194
|
+
const res = spawnSync("ffmpeg", [
|
|
195
|
+
"-loglevel", "info", "-i", clean,
|
|
196
|
+
"-vf", `select='gt(scene,${threshold})',showinfo`, "-f", "null", "-",
|
|
197
|
+
], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
198
|
+
return [...`${res.stderr ?? ""}`.matchAll(/pts_time:([0-9.]+)/g)].map((m) => parseFloat(m[1]));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let scenes = [];
|
|
202
|
+
for (const th of [0.15, 0.08, 0.04, 0.02, 0.01]) {
|
|
203
|
+
scenes = sceneChanges(th);
|
|
204
|
+
if (scenes.length >= clicks.length) break;
|
|
205
|
+
}
|
|
206
|
+
if (scenes.length === 0) {
|
|
207
|
+
console.log(anchored
|
|
208
|
+
? "verify: no whole-frame scene changes — anchors already decided, done."
|
|
209
|
+
: "verify: no scene changes detected — app too static to verify, map stays as stamped.");
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!anchored) {
|
|
214
|
+
// Old plausible-latency search. Kept ONLY as the no-anchor fallback; its
|
|
215
|
+
// known failure mode (plausible != true, 0.237s off on a real take) is why
|
|
216
|
+
// hover anchors exist. Rate stays PINNED at 1.
|
|
217
|
+
const score = (delta) => {
|
|
218
|
+
let total = 0;
|
|
219
|
+
for (const c of clicks) {
|
|
220
|
+
const pred = tb.a * c.wall + tb.b + delta;
|
|
221
|
+
const after = scenes.filter((sc) => sc >= pred - 0.1);
|
|
222
|
+
if (after.length === 0) { total += 2; continue; }
|
|
223
|
+
const lat = after.reduce((a2, b2) => (Math.abs(a2 - pred) <= Math.abs(b2 - pred) ? a2 : b2)) - pred;
|
|
224
|
+
total += lat >= -0.1 && lat <= 0.9 ? Math.abs(lat - 0.1) : 2;
|
|
225
|
+
}
|
|
226
|
+
return total;
|
|
227
|
+
};
|
|
228
|
+
let bestDelta = 0, bestScore = score(0);
|
|
229
|
+
for (let d = -4; d <= 1.0001; d += 1 / 30) {
|
|
230
|
+
const sc = score(d);
|
|
231
|
+
if (sc < bestScore - 1e-9) { bestScore = sc; bestDelta = d; }
|
|
232
|
+
}
|
|
233
|
+
if (Math.abs(bestDelta) > 0.005 && bestScore < clicks.length * 0.9) {
|
|
234
|
+
tb.b += bestDelta;
|
|
235
|
+
tb.videoRecordFrom = -tb.b;
|
|
236
|
+
tb.method += ` + offset search ${bestDelta.toFixed(3)}s (NO ANCHORS — estimate, not measurement)`;
|
|
237
|
+
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
238
|
+
console.log(`offset search (fallback): track shifted ${bestDelta.toFixed(3)}s — b adjusted to ${tb.b.toFixed(3)}`);
|
|
239
|
+
console.log("WARNING: no hover anchors were usable; this offset is an estimate. Prefer storyboards whose targets have hover styles.");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const latencies = [];
|
|
244
|
+
for (const c of clicks) {
|
|
245
|
+
const pred = tb.a * c.wall + tb.b;
|
|
246
|
+
const after = scenes.filter((s) => s >= pred - 0.15);
|
|
247
|
+
if (after.length === 0) {
|
|
248
|
+
console.log(` ${c.label}: predicted ${pred.toFixed(2)}s, no visible change after it`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const nearest = after.reduce((a, b) => (Math.abs(a - pred) <= Math.abs(b - pred) ? a : b));
|
|
252
|
+
latencies.push(nearest - pred);
|
|
253
|
+
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`);
|
|
254
|
+
}
|
|
255
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
256
|
+
const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
|
|
257
|
+
if (!anchored && (median < -0.15 || median > 0.9)) {
|
|
258
|
+
console.error(
|
|
259
|
+
`VERIFY FAILED: median click latency ${median.toFixed(3)}s is outside [-0.15, +0.9]. ` +
|
|
260
|
+
"The stamped timebase looks wrong for this take. Do not upload — investigate record_from.",
|
|
261
|
+
);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
if (anchored && (median < -0.15 || median > 0.9)) {
|
|
265
|
+
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.`);
|
|
266
|
+
}
|
|
267
|
+
console.log(`verify: PASS (${anchored ? "anchor-measured" : "estimated"}, median latency ${median >= 0 ? "+" : ""}${median.toFixed(3)}s)`);
|