@broberg/lens-daemon 0.0.0-stage → 0.1.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 +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
package/src/video.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Lens video-liveness — verify a <video> actually PLAYS, not just exists.
|
|
2
|
+
//
|
|
3
|
+
// expectVisible/expectText/pixel-diff all pass on a frozen or decode-failed
|
|
4
|
+
// video — the element is in the DOM and a poster frame renders. "Works" for a
|
|
5
|
+
// video is a BEHAVIOUR: time advances, frames move, no MediaError. This module
|
|
6
|
+
// drives the element and checks exactly that, with two complementary signals:
|
|
7
|
+
//
|
|
8
|
+
// 1. Element state (deterministic): play(), then currentTime advanced,
|
|
9
|
+
// readyState >= HAVE_CURRENT_DATA, !error, videoWidth/Height > 0.
|
|
10
|
+
// 2. Frame motion (visual): screenshot the element, let it play, screenshot
|
|
11
|
+
// again, pixel-diff. Motion = genuinely rendering video; identical frames =
|
|
12
|
+
// frozen (the inverse of a normal baseline diff — here a diff is PASS).
|
|
13
|
+
//
|
|
14
|
+
// Audio cannot be verified headlessly (no output capture); muted/volume/
|
|
15
|
+
// audioTracks are reported as context, never the verdict. It also can't judge
|
|
16
|
+
// whether it's the RIGHT video — that's vision/human. Plugs into lens_verify as
|
|
17
|
+
// a `video` option, folding a not-playing verdict into status='fail'.
|
|
18
|
+
|
|
19
|
+
import type { Page } from 'playwright';
|
|
20
|
+
import { PNG } from 'pngjs';
|
|
21
|
+
import pixelmatch from 'pixelmatch';
|
|
22
|
+
|
|
23
|
+
export interface VideoCheckOptions {
|
|
24
|
+
/** [data-testid] / CSS selector of the <video> (or a container holding one).
|
|
25
|
+
* Omitted → the first <video> on the page. */
|
|
26
|
+
selector?: string;
|
|
27
|
+
/** How long to let it play before re-sampling (ms). Default 1200. */
|
|
28
|
+
playMs?: number;
|
|
29
|
+
/** Require visible frame motion between the two screenshots (default true).
|
|
30
|
+
* Motion is the strong signal a poster-frozen video can't fake. */
|
|
31
|
+
requireMotion?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VideoResult {
|
|
35
|
+
/** The verdict: the video is genuinely playing (or played to completion). */
|
|
36
|
+
playing: boolean;
|
|
37
|
+
reason: string;
|
|
38
|
+
found: boolean;
|
|
39
|
+
currentTimeStart: number | null;
|
|
40
|
+
currentTimeEnd: number | null;
|
|
41
|
+
advancedSec: number | null;
|
|
42
|
+
readyState: number | null; // 0..4 (HAVE_ENOUGH_DATA = 4)
|
|
43
|
+
paused: boolean | null;
|
|
44
|
+
ended: boolean | null;
|
|
45
|
+
duration: number | null;
|
|
46
|
+
videoWidth: number | null;
|
|
47
|
+
videoHeight: number | null;
|
|
48
|
+
errorCode: number | null; // MediaError.code, or null
|
|
49
|
+
muted: boolean | null;
|
|
50
|
+
volume: number | null;
|
|
51
|
+
audioTracks: number | null;
|
|
52
|
+
/** Fraction of pixels that changed between the two frames while playing. */
|
|
53
|
+
frameMotionRatio: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface VideoSample {
|
|
57
|
+
ct: number;
|
|
58
|
+
rs: number;
|
|
59
|
+
paused: boolean;
|
|
60
|
+
ended: boolean;
|
|
61
|
+
err: number | null;
|
|
62
|
+
dur: number | null;
|
|
63
|
+
vw: number;
|
|
64
|
+
vh: number;
|
|
65
|
+
muted: boolean;
|
|
66
|
+
vol: number;
|
|
67
|
+
at: number | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const empty = (reason: string, found = false): VideoResult => ({
|
|
71
|
+
playing: false,
|
|
72
|
+
reason,
|
|
73
|
+
found,
|
|
74
|
+
currentTimeStart: null,
|
|
75
|
+
currentTimeEnd: null,
|
|
76
|
+
advancedSec: null,
|
|
77
|
+
readyState: null,
|
|
78
|
+
paused: null,
|
|
79
|
+
ended: null,
|
|
80
|
+
duration: null,
|
|
81
|
+
videoWidth: null,
|
|
82
|
+
videoHeight: null,
|
|
83
|
+
errorCode: null,
|
|
84
|
+
muted: null,
|
|
85
|
+
volume: null,
|
|
86
|
+
audioTracks: null,
|
|
87
|
+
frameMotionRatio: null,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** Drive the <video> at `selector` and report whether it actually plays. */
|
|
91
|
+
export async function checkVideo(page: Page, opts: VideoCheckOptions = {}): Promise<VideoResult> {
|
|
92
|
+
const sel = opts.selector ?? null;
|
|
93
|
+
const playMs = opts.playMs ?? 1200;
|
|
94
|
+
const requireMotion = opts.requireMotion ?? true;
|
|
95
|
+
|
|
96
|
+
// Play + sample t0 (all in-page; resolve the <video> at/under the selector).
|
|
97
|
+
const start = (await page.evaluate((s) => {
|
|
98
|
+
const root = s ? document.querySelector(s) : document.querySelector('video');
|
|
99
|
+
const v = (root && root.tagName === 'VIDEO' ? root : root?.querySelector('video')) as HTMLVideoElement | null;
|
|
100
|
+
if (!v) return null;
|
|
101
|
+
try {
|
|
102
|
+
const p = v.play();
|
|
103
|
+
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
104
|
+
} catch {
|
|
105
|
+
/* autoplay policy / already playing */
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
ct: v.currentTime,
|
|
109
|
+
rs: v.readyState,
|
|
110
|
+
paused: v.paused,
|
|
111
|
+
ended: v.ended,
|
|
112
|
+
err: v.error ? v.error.code : null,
|
|
113
|
+
dur: Number.isFinite(v.duration) ? v.duration : null,
|
|
114
|
+
vw: v.videoWidth,
|
|
115
|
+
vh: v.videoHeight,
|
|
116
|
+
muted: v.muted,
|
|
117
|
+
vol: v.volume,
|
|
118
|
+
at: (v as HTMLVideoElement & { audioTracks?: { length: number } }).audioTracks?.length ?? null,
|
|
119
|
+
} as VideoSample;
|
|
120
|
+
}, sel)) as VideoSample | null;
|
|
121
|
+
|
|
122
|
+
if (!start) return empty(`no <video> found for ${sel ?? 'video'}`);
|
|
123
|
+
|
|
124
|
+
// Frame 1 (screenshot the selector's element — container or the video).
|
|
125
|
+
const loc = sel ? page.locator(sel).first() : page.locator('video').first();
|
|
126
|
+
const frame1 = await loc.screenshot().catch(() => null);
|
|
127
|
+
await page.waitForTimeout(playMs);
|
|
128
|
+
const frame2 = await loc.screenshot().catch(() => null);
|
|
129
|
+
|
|
130
|
+
// Sample t1.
|
|
131
|
+
const end = (await page.evaluate((s) => {
|
|
132
|
+
const root = s ? document.querySelector(s) : document.querySelector('video');
|
|
133
|
+
const v = (root && root.tagName === 'VIDEO' ? root : root?.querySelector('video')) as HTMLVideoElement | null;
|
|
134
|
+
if (!v) return null;
|
|
135
|
+
return {
|
|
136
|
+
ct: v.currentTime,
|
|
137
|
+
rs: v.readyState,
|
|
138
|
+
paused: v.paused,
|
|
139
|
+
ended: v.ended,
|
|
140
|
+
err: v.error ? v.error.code : null,
|
|
141
|
+
dur: Number.isFinite(v.duration) ? v.duration : null,
|
|
142
|
+
vw: v.videoWidth,
|
|
143
|
+
vh: v.videoHeight,
|
|
144
|
+
muted: v.muted,
|
|
145
|
+
vol: v.volume,
|
|
146
|
+
at: (v as HTMLVideoElement & { audioTracks?: { length: number } }).audioTracks?.length ?? null,
|
|
147
|
+
} as VideoSample;
|
|
148
|
+
}, sel)) as VideoSample | null;
|
|
149
|
+
|
|
150
|
+
const e = end ?? start;
|
|
151
|
+
const advancedSec = +(e.ct - start.ct).toFixed(3);
|
|
152
|
+
const frameMotionRatio = frame1 && frame2 ? motionRatio(frame1, frame2) : null;
|
|
153
|
+
|
|
154
|
+
// ── verdict ──
|
|
155
|
+
let playing = false;
|
|
156
|
+
let reason: string;
|
|
157
|
+
if (e.err !== null) {
|
|
158
|
+
reason = `MediaError code ${e.err} — source not playable / decode failed`;
|
|
159
|
+
} else if (e.vw === 0 || e.vh === 0) {
|
|
160
|
+
reason = 'video has 0×0 dimensions — nothing decoded';
|
|
161
|
+
} else if (e.rs < 2) {
|
|
162
|
+
reason = `readyState ${e.rs} (< HAVE_CURRENT_DATA) — stalled / no data`;
|
|
163
|
+
} else {
|
|
164
|
+
const advanced = advancedSec > 0.1;
|
|
165
|
+
const playedToEnd = e.ended && (start.ct > 0 || advancedSec > 0 || frameMotionRatio === null);
|
|
166
|
+
const moved = frameMotionRatio === null ? true : frameMotionRatio > 0.002;
|
|
167
|
+
if (!advanced && !playedToEnd) {
|
|
168
|
+
reason = `currentTime did not advance (stuck at ${e.ct.toFixed(2)}s) — not playing`;
|
|
169
|
+
} else if (requireMotion && !moved) {
|
|
170
|
+
reason = `currentTime advanced ${advancedSec}s but frames are identical — frozen (poster / decode stall)`;
|
|
171
|
+
} else {
|
|
172
|
+
playing = true;
|
|
173
|
+
reason = e.ended
|
|
174
|
+
? `played to end (${frameMotionRatio === null ? 'no frame diff' : `${(frameMotionRatio * 100).toFixed(1)}% frame motion`})`
|
|
175
|
+
: `playing — currentTime +${advancedSec}s, readyState ${e.rs}${frameMotionRatio === null ? '' : `, ${(frameMotionRatio * 100).toFixed(1)}% frame motion`}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
playing,
|
|
181
|
+
reason,
|
|
182
|
+
found: true,
|
|
183
|
+
currentTimeStart: start.ct,
|
|
184
|
+
currentTimeEnd: e.ct,
|
|
185
|
+
advancedSec,
|
|
186
|
+
readyState: e.rs,
|
|
187
|
+
paused: e.paused,
|
|
188
|
+
ended: e.ended,
|
|
189
|
+
duration: e.dur,
|
|
190
|
+
videoWidth: e.vw,
|
|
191
|
+
videoHeight: e.vh,
|
|
192
|
+
errorCode: e.err,
|
|
193
|
+
muted: e.muted,
|
|
194
|
+
volume: e.vol,
|
|
195
|
+
audioTracks: e.at,
|
|
196
|
+
frameMotionRatio,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Fraction of pixels that differ between two PNG frames. Dimension mismatch
|
|
201
|
+
* (the element resized between shots) counts as full motion. */
|
|
202
|
+
function motionRatio(a: Buffer, b: Buffer): number {
|
|
203
|
+
const pa = PNG.sync.read(a);
|
|
204
|
+
const pb = PNG.sync.read(b);
|
|
205
|
+
if (pa.width !== pb.width || pa.height !== pb.height) return 1;
|
|
206
|
+
const { width, height } = pa;
|
|
207
|
+
const total = width * height;
|
|
208
|
+
if (total === 0) return 0;
|
|
209
|
+
const changed = pixelmatch(pa.data, pb.data, null, width, height, { threshold: 0.1, includeAA: false });
|
|
210
|
+
return changed / total;
|
|
211
|
+
}
|