Setting the file. One moment.
Seam Gate · Motion Doctrine · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page function velocity
— line 355
This file
Number 4.2
Position 2 of 3
Type JavaScript
Size 22 KB
Lines 618 scripts/ seam-gate.mjs
JavaScript · 618 lines · 22 KB
{ spawn }
from
"node:child_process"
;
15 import { readFileSync, existsSync, readdirSync } from "node:fs" ;
16 import { join } from "node:path" ;
17 import { homedir } from "node:os" ;
18
19 // ---------- args ----------
20 const argv = process.argv. slice ( 2 );
21 const mode = argv[ 0 ];
22 function flag ( name , dflt ) {
23 const i = argv. indexOf ( "--" + name);
24 return i >= 0 ? argv[i + 1 ] : dflt;
25 }
26 const has = ( name ) => argv. includes ( "--" + name);
27 if ( ! [ "verify" , "probe" ]. includes (mode)) {
28 console. error (
29 "usage: seam-gate.mjs verify --ledger ledger.json (--project <dir> | --url <preview-url>) [--json]" ,
30 );
31 console. error (
32 " seam-gate.mjs probe --t <seconds> (--project <dir> | --url <preview-url>) [--window 0.1]" ,
33 );
34 process. exit ( 2 );
35 }
36
37 const FPS = Number ( flag ( "fps" , 30 ));
38 const DT = 1 / FPS ;
39 const VIS = 0.04 ; // cumulative opacity below this = invisible
40 const EPS_XY = 15 ; // px/s — slower than this = "static"
41 const EPS_Z = 0.04 ; // effective-scale units/s
42 const SPEED_RATIO = 3 ; // entry/exit velocity ratio beyond this = WARN
43 const CARRIER_POS_TOL = 12 ; // px center offset
44 const CARRIER_SIZE_TOL = 0.05 ;
45
46 const cleanup = [];
47 process. on ( "exit" , () =>
48 cleanup. forEach (( fn ) => {
49 try {
50 fn ();
51 } catch {}
52 }),
53 );
54 for ( const sig of [ "SIGINT" , "SIGTERM" ]) process. on (sig, () => process. exit ( 130 ));
55
56 // ---------- preview server ----------
57 async function httpOk ( url ) {
58 try {
59 const r = await fetch (url, { signal: AbortSignal. timeout ( 2000 ) });
60 return r.ok;
61 } catch {
62 return false ;
63 }
64 }
65
66 async function ensureServer () {
67 let base = flag ( "url" , null );
68 if ( ! base) {
69 const project = flag ( "project" , null );
70 if ( ! project) throw new Error ( "need --url or --project" );
71 const port = 5380 + Math. floor (Math. random () * 20 );
72 const env = { ... process.env };
73 delete env. HYPERFRAME_RUNTIME_URL ; // wrong value fails silently as 200 HTML
74 // `preview` backgrounds itself when stdin/stdout aren't TTYs, which they never are here: the
75 // launcher would exit 0 before the server is up and detach it out of our process group.
76 // Default to the REPO-LOCAL CLI whenever this skill is running from its repo
77 // checkout. The gate this script backs is defined against that build, and the
78 // skill forbids `npx hyperframes@latest` for it, so defaulting to the published
79 // package here would let the two gates measure different runtimes. npx stays the
80 // fallback for a copy of the skill living outside the repo.
81 const repoCli = join (
82 import . meta .dirname,
83 ".." ,
84 ".." ,
85 ".." ,
86 ".." ,
87 "packages" ,
88 "cli" ,
89 "bin" ,
90 "hyperframes.mjs" ,
91 );
92 const cmd = flag (
93 "server-cmd" ,
94 existsSync (repoCli)
95 ? `node "${ repoCli }" preview --foreground --no-open --port ${ port }`
96 : `npx --yes hyperframes preview --foreground --no-open --port ${ port }` ,
97 );
98 const child = spawn ( "sh" , [ "-c" , cmd. replace ( / \{ port \} / g , String (port))], {
99 cwd: project,
100 env,
101 stdio: [ "ignore" , "pipe" , "pipe" ],
102 detached: true ,
103 });
104 cleanup. push (() => {
105 try {
106 process. kill ( - child.pid, "SIGTERM" );
107 } catch {}
108 });
109 base = `http://localhost:${ port }` ;
110 const deadline = Date. now () + 120_000 ;
111 while (Date. now () < deadline) {
112 if ( await httpOk (base + "/api/projects" )) break ;
113 if (child.exitCode !== null ) throw new Error ( "preview server exited early" );
114 await new Promise (( r ) => setTimeout (r, 500 ));
115 }
116 if ( ! ( await httpOk (base + "/api/projects" )))
117 throw new Error ( "preview server never became ready" );
118 }
119 base = base. replace ( / \/ $ / , "" );
120 let compUrl = flag ( "comp-url" , null );
121 if ( ! compUrl) {
122 const r = await fetch (base + "/api/projects" );
123 const j = await r. json ();
124 const id = j?.projects?.[ 0 ]?.id;
125 if ( ! id) throw new Error ( "could not resolve project id from /api/projects" );
126 compUrl = `${ base }/api/projects/${ id }/preview/comp/index.html` ;
127 }
128 return compUrl;
129 }
130
131 // ---------- chrome ----------
132 function findChrome () {
133 if (process.env. CHROME_PATH ) return { bin: process.env. CHROME_PATH , headlessFlag: true };
134 const cache = join ( homedir (), ".cache" , "puppeteer" );
135 for ( const kind of [ "chrome-headless-shell" , "chrome" ]) {
136 const root = join (cache, kind);
137 if ( ! existsSync (root)) continue ;
138 const versions = readdirSync (root). sort (). reverse ();
139 for ( const v of versions) {
140 const vdir = join (root, v);
141 for ( const plat of readdirSync (vdir)) {
142 const bin =
143 kind === "chrome-headless-shell"
144 ? join (vdir, plat, "chrome-headless-shell" )
145 : join (
146 vdir,
147 plat,
148 "Google Chrome for Testing.app" ,
149 "Contents" ,
150 "MacOS" ,
151 "Google Chrome for Testing" ,
152 );
153 if ( existsSync (bin)) return { bin, headlessFlag: kind !== "chrome-headless-shell" };
154 }
155 }
156 }
157 const sys = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ;
158 if ( existsSync (sys)) return { bin: sys, headlessFlag: true };
159 throw new Error ( "no Chrome found (set CHROME_PATH)" );
160 }
161
162 async function launchChrome () {
163 const { bin , headlessFlag } = findChrome ();
164 const args = [
165 "--remote-debugging-port=0" ,
166 "--no-first-run" ,
167 "--no-default-browser-check" ,
168 "--mute-audio" ,
169 "--hide-scrollbars" ,
170 "--disable-extensions" ,
171 "--window-size=1920,1080" ,
172 "about:blank" ,
173 ];
174 if (headlessFlag) args. unshift ( "--headless=new" );
175 const child = spawn (bin, args, { stdio: [ "ignore" , "pipe" , "pipe" ], detached: true });
176 cleanup. push (() => {
177 try {
178 process. kill ( - child.pid, "SIGKILL" );
179 } catch {}
180 });
181 const wsUrl = await new Promise (( resolve , reject ) => {
182 let buf = "" ;
183 const t = setTimeout (() => reject ( new Error ( "chrome DevTools endpoint timeout" )), 20_000 );
184 child.stderr. on ( "data" , ( d ) => {
185 buf += d;
186 const m = buf. match ( /DevTools listening on (ws: \/\/ \S + )/ );
187 if (m) {
188 clearTimeout (t);
189 resolve (m[ 1 ]);
190 }
191 });
192 child. on ( "exit" , () => reject ( new Error ( "chrome exited: " + buf. slice ( - 400 ))));
193 });
194 return wsUrl;
195 }
196
197 // ---------- minimal CDP client ----------
198 class CDP {
199 constructor ( ws ) {
200 this .ws = ws;
201 this .id = 0 ;
202 this .pending = new Map ();
203 this .listeners = [];
204 }
205 static async connect ( url ) {
206 const ws = new WebSocket (url);
207 await new Promise (( res , rej ) => {
208 ws.onopen = res;
209 ws. onerror = () => rej ( new Error ( "ws connect failed" ));
210 });
211 const c = new CDP (ws);
212 ws. onmessage = ( ev ) => {
213 const msg = JSON . parse (ev.data);
214 if (msg.id !== undefined && c.pending. has (msg.id)) {
215 const { res , rej } = c.pending. get (msg.id);
216 c.pending. delete (msg.id);
217 if (msg.error) rej ( new Error (msg.error.message));
218 else res (msg.result);
219 } else if (msg.method) {
220 c.listeners. forEach (( l ) => l (msg));
221 }
222 };
223 return c;
224 }
225 send ( method , params = {}, sessionId , timeoutMs = 30_000 ) {
226 const id = ++ this .id;
227 const payload = { id, method, params };
228 if (sessionId) payload.sessionId = sessionId;
229 this .ws. send ( JSON . stringify (payload));
230 return new Promise (( res , rej ) => {
231 this .pending. set (id, { res, rej });
232 setTimeout (() => {
233 if ( this .pending. has (id)) {
234 this .pending. delete (id);
235 rej ( new Error (method + " timeout" ));
236 }
237 }, timeoutMs);
238 });
239 }
240 waitEvent ( method , sessionId , timeoutMs = 30_000 ) {
241 return new Promise (( res , rej ) => {
242 const t = setTimeout (() => rej ( new Error ( "waiting " + method + " timeout" )), timeoutMs);
243 const l = ( msg ) => {
244 if (msg.method === method && ( ! sessionId || msg.sessionId === sessionId)) {
245 clearTimeout (t);
246 this .listeners = this .listeners. filter (( x ) => x !== l);
247 res (msg.params);
248 }
249 };
250 this .listeners. push (l);
251 });
252 }
253 }
254
255 async function openPage ( compUrl ) {
256 const cdp = await CDP . connect ( await launchChrome ());
257 const { targetId } = await cdp. send ( "Target.createTarget" , { url: "about:blank" });
258 const { sessionId } = await cdp. send ( "Target.attachToTarget" , { targetId, flatten: true });
259 await cdp. send ( "Page.enable" , {}, sessionId);
260 await cdp. send ( "Runtime.enable" , {}, sessionId);
261 await cdp. send (
262 "Emulation.setDeviceMetricsOverride" ,
263 { width: 1920 , height: 1080 , deviceScaleFactor: 1 , mobile: false },
264 sessionId,
265 );
266 const loaded = cdp. waitEvent ( "Page.loadEventFired" , sessionId, 60_000 );
267 await cdp. send ( "Page.navigate" , { url: compUrl }, sessionId);
268 await loaded;
269 const evalJs = async ( expr , awaitPromise = false ) => {
270 const r = await cdp. send (
271 "Runtime.evaluate" ,
272 { expression: expr, returnByValue: true , awaitPromise },
273 sessionId,
274 60_000 ,
275 );
276 if (r.exceptionDetails)
277 throw new Error (
278 "page error: " + (r.exceptionDetails.exception?.description || r.exceptionDetails.text),
279 );
280 return r.result.value;
281 };
282 // wait for the HF runtime player
283 const deadline = Date. now () + 45_000 ;
284 while (Date. now () < deadline) {
285 if ( await evalJs ( "!!(window.__playerReady && window.__renderReady && window.__player)" )) break ;
286 await new Promise (( r ) => setTimeout (r, 300 ));
287 }
288 if ( ! ( await evalJs ( "!!window.__player" )))
289 throw new Error ( "HF runtime player never appeared — is this a preview comp URL?" );
290 await evalJs ( "document.fonts.ready.then(()=>true)" , true );
291 await evalJs ( HARNESS );
292 return { evalJs };
293 }
294
295 // ---------- in-page harness ----------
296 const HARNESS = `window.__seamGate = {
297 seek(t){ __player.pause(); __player.seek(t); void document.body.offsetHeight; },
298 cumOp(el){
299 let op = 1, n = el;
300 while (n && n.nodeType === 1) {
301 const c = getComputedStyle(n);
302 if (c.display === "none" || c.visibility === "hidden") return 0;
303 op *= parseFloat(c.opacity || "1");
304 n = n.parentElement;
305 }
306 return op;
307 },
308 read(sel){
309 const el = document.querySelector(sel);
310 if (!el) return null;
311 const r = el.getBoundingClientRect();
312 const lw = el.offsetWidth || r.width || 1;
313 const onscreen = r.right > 0 && r.bottom > 0 && r.left < 1920 && r.top < 1080;
314 return { cx: r.x + r.width/2, cy: r.y + r.height/2, w: r.width, h: r.height,
315 op: this.cumOp(el), es: r.width / lw, onscreen };
316 },
317 sample(t, sels){ this.seek(t); const o = {}; for (const s of sels) o[s] = this.read(s); return o; },
318 pathOf(el){
319 if (el.id) return "#" + CSS.escape(el.id);
320 const hf = el.getAttribute && el.getAttribute("data-hf-id");
321 if (hf) return '[data-hf-id="' + hf + '"]';
322 let p = [], n = el, depth = 0;
323 while (n && n.nodeType === 1 && depth < 5) {
324 if (n.id) { p.unshift("#" + CSS.escape(n.id)); break; }
325 const h2 = n.getAttribute("data-hf-id");
326 if (h2) { p.unshift('[data-hf-id="' + h2 + '"]'); break; }
327 const kids = n.parentElement ? [...n.parentElement.children] : [n];
328 p.unshift(n.tagName.toLowerCase() + ":nth-child(" + (kids.indexOf(n) + 1) + ")");
329 n = n.parentElement; depth++;
330 }
331 return p.join(">");
332 },
333 scan(t, rootSel, cap){
334 this.seek(t);
335 const root = document.querySelector(rootSel || "#root");
336 if (!root) return [];
337 const els = [root, ...root.querySelectorAll("*")].slice(0, cap || 900);
338 const out = [];
339 for (const el of els) {
340 if (/^(SCRIPT|STYLE|AUDIO|LINK|META)$/.test(el.tagName)) continue;
341 const r = el.getBoundingClientRect();
342 if (r.width < 32 && r.height < 32) continue;
343 const op = this.cumOp(el);
344 const lw = el.offsetWidth || r.width || 1;
345 out.push({ sel: this.pathOf(el), cx: r.x + r.width/2, cy: r.y + r.height/2,
346 w: r.width, h: r.height, op, es: r.width / lw });
347 }
348 return out;
349 }
350 };true` ;
351
352 // ---------- measurement helpers ----------
353 const sgn = ( v ) => (v > 0 ? 1 : v < 0 ? - 1 : 0 );
354 const visible = ( m ) => !! m && m.op > VIS && m.w * m.h > 16 && m.onscreen !== false ;
355 function velocity ( m1 , m2 , dt , axis ) {
356 if ( ! m1 || ! m2) return null ;
357 if (axis === "x" ) return (m2.cx - m1.cx) / dt;
358 if (axis === "y" ) return (m2.cy - m1.cy) / dt;
359 return (m2.es - m1.es) / dt; // z
360 }
361 const eps = ( axis ) => (axis === "z" ? EPS_Z : EPS_XY );
362 const fmtV = ( v , axis ) =>
363 v === null ? "n/a" : axis === "z" ? v. toFixed ( 3 ) + " es/s" : v. toFixed ( 0 ) + " px/s" ;
364
365 // ---------- verify ----------
366 async function verify () {
367 const ledgerPath = flag ( "ledger" , "ledger.json" );
368 const ledger = JSON . parse ( readFileSync (ledgerPath, "utf8" ));
369 const fps = ledger.fps || FPS ,
370 dt = 1 / fps;
371 const compUrl = await ensureServer ();
372 const { evalJs } = await openPage (compUrl);
373 const results = [];
374
375 for ( const seam of ledger.seams) {
376 const rows = [];
377 const add = ( check , status , detail ) => rows. push ({ check, status, detail });
378 const cut = seam.cut;
379 const type = seam.type || "cut" ;
380 const tA1 = Math. max ( 0 , cut - 0.1 ),
381 tA2 = Math. max ( 0 , cut - dt);
382 const tB1 = cut + dt,
383 tB2 = cut + 0.1 ;
384 const sels = [
385 seam.exit?.selector,
386 seam.entry?.selector,
387 seam.carrier?.out,
388 seam.carrier?.in,
389 ]. filter (Boolean);
390 const S = {};
391 for ( const t of [tA1, tA2, tB1, tB2])
392 S [t] = await evalJs ( `__seamGate.sample(${ t }, ${ JSON . stringify ([ ...new Set ( sels )]) })` );
393
394 if (type === "cut" ) {
395 const ex = seam.exit,
396 en = seam.entry;
397 // 0 — ledger row itself
398 if (ex.axis !== en.axis || ex.dir !== en.dir)
399 add (
400 "ledger" ,
401 "FAIL" ,
402 `exit ${ ex . axis }${ ex . dir > 0 ? "+" : "-"} vs entry ${ en . axis }${ en . dir > 0 ? "+" : "-"} — mirrored/mixed vector in the PLAN` ,
403 );
404 else add ( "ledger" , "PASS" , `${ ex . axis }${ ex . dir > 0 ? "+" : "-"} both sides` );
405
406 for ( const [ side , cfg , m1 , m2 , t1 , t2 ] of [
407 [ "exit" , ex, S [tA1][ex.selector], S [tA2][ex.selector], tA1, tA2],
408 [ "entry" , en, S [tB1][en.selector], S [tB2][en.selector], tB1, tB2],
409 ]) {
410 if ( ! m1 || ! m2) {
411 add (side, "FAIL" , `selector ${ cfg . selector } not found` );
412 continue ;
413 }
414 const v = velocity (m1, m2, t2 - t1, cfg.axis);
415 const moving = Math. abs (v) >= eps (cfg.axis);
416 const vizOk = side === "exit" ? visible (m1) : visible (m2);
417 if ( ! vizOk)
418 add (
419 side + "-visible" ,
420 "FAIL" ,
421 `${ cfg . selector } not visible in its window (op ${ ( side === "exit" ? m1 : m2 )?. op ?. toFixed ( 2 ) })` ,
422 );
423 if ( ! moving)
424 add (
425 side + "-moving" ,
426 "FAIL" ,
427 `${ cfg . selector } static at the cut (${ fmtV ( v , cfg . axis ) }) — ${ side === "exit" ? "exit settled before the boundary" : "entry starts from rest"}` ,
428 );
429 else if ( sgn (v) !== cfg.dir)
430 add (
431 side + "-direction" ,
432 "FAIL" ,
433 `${ cfg . selector } moving ${ fmtV ( v , cfg . axis ) } — opposite of ledger dir ${ cfg . dir > 0 ? "+" : "-"}${ cfg . axis === "z" ? " (mirrored zoom)" : ""}` ,
434 );
435 else
436 add (
437 side + "-vector" ,
438 "PASS" ,
439 `${ fmtV ( v , cfg . axis ) } ${ cfg . axis }${ cfg . dir > 0 ? "+" : "-"}` ,
440 );
441 if (side === "exit" ) seam.__vExit = v;
442 else seam.__vEntry = v;
443 }
444
445 // speed match
446 if (seam.__vExit != null && seam.__vEntry != null && Math. abs (seam.__vExit) > 0 ) {
447 const ratio = Math. abs (seam.__vEntry) / Math. abs (seam.__vExit);
448 if (ratio > SPEED_RATIO || ratio < 1 / SPEED_RATIO )
449 add ( "speed-match" , "WARN" , `entry/exit velocity ratio ${ ratio . toFixed ( 2 ) } (want ~1)` );
450 else add ( "speed-match" , "PASS" , `ratio ${ ratio . toFixed ( 2 ) }` );
451 }
452
453 // zero overlap
454 const enPre = S [tA2][en.selector],
455 exPost = S [tB1][ex.selector];
456 if ( visible (enPre))
457 add (
458 "zero-overlap" ,
459 "FAIL" ,
460 `incoming ${ en . selector } already visible at cut-1f (op ${ enPre . op . toFixed ( 2 ) }) while outgoing still on screen — reads as a dissolve` ,
461 );
462 else if ( visible (exPost))
463 add (
464 "zero-overlap" ,
465 "FAIL" ,
466 `outgoing ${ ex . selector } still visible at cut+1f (op ${ exPost . op . toFixed ( 2 ) })` ,
467 );
468 else add ( "zero-overlap" , "PASS" , "one side visible per frame" );
469
470 // Z-sign scan: the incoming scene's OWN entrances must not fight the seam's Z sign
471 if (en.axis === "z" ) {
472 const scanRoot = en.scanRoot || en.selector;
473 const s1 = await evalJs ( `__seamGate.scan(${ tB1 }, ${ JSON . stringify ( scanRoot ) })` );
474 const s2 = await evalJs ( `__seamGate.scan(${ tB2 }, ${ JSON . stringify ( scanRoot ) })` );
475 const m1 = new Map (s1. map (( e ) => [e.sel, e]));
476 const offenders = [];
477 for ( const e2 of s2) {
478 const e1 = m1. get (e2.sel);
479 if ( ! e1 || e2.op <= 0.1 ) continue ;
480 const vs = (e2.es - e1.es) / (tB2 - tB1);
481 if (Math. abs (vs) >= EPS_Z && sgn (vs) !== en.dir)
482 offenders. push ( `${ e2 . sel } (${ vs . toFixed ( 3 ) } es/s)` );
483 }
484 if (offenders. length )
485 add (
486 "z-sign-scan" ,
487 "FAIL" ,
488 `elements scaling AGAINST the seam's Z sign in the entry window: ${ offenders . slice ( 0 , 5 ). join ( ", " ) }${ offenders . length > 5 ? ` +${ offenders . length - 5 } more` : ""}` ,
489 );
490 else add ( "z-sign-scan" , "PASS" , "no sign-fighting entrances" );
491 }
492 }
493
494 // carrier continuity (any seam type that declares one; the whole check for match-cut/morph)
495 if (seam.carrier) {
496 const out = S [tA2][seam.carrier.out],
497 inn = S [tB1][seam.carrier.in];
498 if ( ! out || ! inn) add ( "carrier" , "FAIL" , "carrier selector not found" );
499 else {
500 const dx = Math. abs (out.cx - inn.cx),
501 dy = Math. abs (out.cy - inn.cy);
502 const ds = Math. abs (out.w - inn.w) / Math. max (out.w, 1 );
503 if (dx > CARRIER_POS_TOL || dy > CARRIER_POS_TOL )
504 add (
505 "carrier-position" ,
506 "FAIL" ,
507 `center off by ${ dx . toFixed ( 0 ) },${ dy . toFixed ( 0 ) }px across the cut` ,
508 );
509 else if (ds > CARRIER_SIZE_TOL )
510 add (
511 "carrier-size" ,
512 "FAIL" ,
513 `size differs ${ ( ds * 100 ). toFixed ( 1 ) }% across the cut (ancestor scale?)` ,
514 );
515 else
516 add (
517 "carrier" ,
518 "PASS" ,
519 `Δpos ${ dx . toFixed ( 1 ) },${ dy . toFixed ( 1 ) }px Δsize ${ ( ds * 100 ). toFixed ( 1 ) }%` ,
520 );
521 }
522 }
523
524 if (type !== "cut" && ! seam.carrier)
525 add ( "carrier" , "WARN" , `type "${ type }" without a carrier — nothing to verify` );
526
527 results. push ({ id: seam.id, cut, type, rows });
528 }
529 return results;
530 }
531
532 // ---------- probe ----------
533 async function probe () {
534 const t = Number ( flag ( "t" ));
535 if ( ! Number. isFinite (t)) throw new Error ( "probe needs --t <seconds>" );
536 const win = Number ( flag ( "window" , 0.1 ));
537 const compUrl = await ensureServer ();
538 const { evalJs } = await openPage (compUrl);
539 const dt = DT ;
540 const scans = {};
541 for ( const tt of [t - win, t - dt, t + dt, t + win])
542 scans[tt] = await evalJs ( `__seamGate.scan(${ Math . max ( 0 , tt ) }, "#root")` );
543 const join = ( a , b ) => {
544 const m = new Map (a. map (( e ) => [e.sel, e]));
545 return b. map (( e2 ) => ({ e1: m. get (e2.sel), e2 })). filter (( p ) => p.e1);
546 };
547 const movers = ( a , b , span ) =>
548 join (a, b)
549 . map (({ e1 , e2 }) => ({
550 sel: e2.sel,
551 vx: (e2.cx - e1.cx) / span,
552 vy: (e2.cy - e1.cy) / span,
553 vs: (e2.es - e1.es) / span,
554 op1: e1.op,
555 op2: e2.op,
556 w: e2.w,
557 h: e2.h,
558 }))
559 . filter (
560 ( m ) =>
561 (m.op1 > VIS || m.op2 > VIS ) &&
562 (Math. abs (m.vx) > EPS_XY ||
563 Math. abs (m.vy) > EPS_XY ||
564 Math. abs (m.vs) > EPS_Z ||
565 Math. abs (m.op2 - m.op1) > 0.1 ),
566 )
567 . sort (
568 ( x , y ) =>
569 Math. abs (y.vx) +
570 Math. abs (y.vy) +
571 Math. abs (y.vs) * 800 -
572 (Math. abs (x.vx) + Math. abs (x.vy) + Math. abs (x.vs) * 800 ),
573 )
574 . slice ( 0 , 14 );
575 const fmt = ( m ) =>
576 ` ${ m . sel . padEnd ( 44 ) } vx ${ m . vx . toFixed ( 0 ). padStart ( 6 ) } vy ${ m . vy . toFixed ( 0 ). padStart ( 6 ) } vscale ${ m . vs . toFixed ( 3 ). padStart ( 7 ) } op ${ m . op1 . toFixed ( 2 ) }→${ m . op2 . toFixed ( 2 ) } (${ m . w . toFixed ( 0 ) }×${ m . h . toFixed ( 0 ) })` ;
577 console. log ( ` \n PROBE @ ${ t }s (window ±${ win }s, 1f = ${ dt . toFixed ( 3 ) }s)` );
578 console. log ( ` \n — OUTGOING side (${ ( t - win ). toFixed ( 2 ) } → ${ ( t - dt ). toFixed ( 2 ) }) — movers:` );
579 movers (scans[t - win], scans[t - dt], win - dt). forEach (( m ) => console. log ( fmt (m)));
580 console. log ( ` \n — INCOMING side (${ ( t + dt ). toFixed ( 2 ) } → ${ ( t + win ). toFixed ( 2 ) }) — movers:` );
581 movers (scans[t + dt], scans[t + win], win - dt). forEach (( m ) => console. log ( fmt (m)));
582 console. log (
583 ` \n Use these selectors + signs to write the ledger row (x-: left, y-: up, scale+: push, scale-: pull).` ,
584 );
585 }
586
587 // ---------- main ----------
588 try {
589 if (mode === "probe" ) {
590 await probe ();
591 } else {
592 const results = await verify ();
593 if ( has ( "json" )) {
594 console. log ( JSON . stringify (results, null , 2 ));
595 } else {
596 let fails = 0 ,
597 warns = 0 ;
598 for ( const r of results) {
599 const bad = r.rows. filter (( x ) => x.status === "FAIL" ). length ;
600 fails += bad;
601 warns += r.rows. filter (( x ) => x.status === "WARN" ). length ;
602 console. log (
603 ` \n ■ ${ r . id } (cut @${ r . cut }s, ${ r . type }) ${ bad ? "✗ " + bad + " FAIL" : "✓"}` ,
604 );
605 for ( const row of r.rows)
606 console. log ( ` ${ row . status . padEnd ( 4 ) } ${ row . check . padEnd ( 16 ) } ${ row . detail }` );
607 }
608 console. log (
609 ` \n ${ fails ? "SEAM GATE: FAILED" : "SEAM GATE: PASSED"} — ${ fails } fail, ${ warns } warn across ${ results . length } seams` ,
610 );
611 }
612 process. exit (results. some (( r ) => r.rows. some (( x ) => x.status === "FAIL" )) ? 1 : 0 );
613 }
614 process. exit ( 0 );
615 } catch (e) {
616 console. error ( "seam-gate error:" , e.message);
617 process. exit ( 2 );
618 }