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.
@@ -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)`);