#!/usr/bin/env node // MOTION QA GATE (K58.REAL.motionQA, 58k_cine3.js §7) — headless, one run per kill. Owner: A (KILLPAGES-A), 28 Oct. // bash _tools/render_slot.sh node _tools/motionqa.cjs [more keys] [--page 58k_x.html] [--gore 5] [--out dir] [--strip 10] // Prints the report; writes /.qa.txt, .qa.json and .qa.png (the overlay strip: a timeline of every flag across the // film, then the flagged frames full size with the marks). Exit code 1 when any kill FAILS. Every STYLE 3 kill passes it // before it is shown. Thresholds: K58.REAL.QA.TH (override with --th slide=50,balDur=0.3). // Judged from the kill's own start (phase fK); --lead 1 judges the shared lead-in from 0 as well. // --pre 'K58.GAIT58=false' runs a line in the page first (A/B a kit switch). --load /path/old_lane.js evaluates an older lane file first. // --selftest 1 (QA-GATE, 30 Oct): the gate's own self-test (_tools/motionqa_selftest.js: the rig's foot pad, a planted / swaying / // stepping body PASSES, a carried (sliding) body FAILS, the old 3.5 u test missed it, balance live); exit 1 when any case misbehaves. const fs = require('fs'), os = require('os'), path = require('path'); let chromium; try { ({ chromium } = require('playwright')); } catch (e) { ({ chromium } = require('/Users/basha/node_modules/playwright')); } const A = process.argv.slice(2), keys = [], opt = { gore: 5, strip: 10, out: '/Volumes/Workspace/Scratch/qa' }; for (let i = 0; i < A.length; i++) { const a = A[i]; if (a.startsWith('--')) opt[a.slice(2)] = A[++i]; else keys.push(a); } if (opt.selftest) { keys.length = 0; keys.push('__selftest__'); } if (!keys.length) { console.log('usage: node _tools/motionqa.cjs [--page 58k_w.html] [--gore 5] [--out dir] [--strip 10] [--th k=v,..]'); process.exit(2); } if (!fs.existsSync(opt.out)) { try { fs.mkdirSync(opt.out, { recursive: true }); } catch (e) { opt.out = os.tmpdir(); } } const CHROME = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium'].find((p) => fs.existsSync(p)); const ROOT = path.resolve(__dirname, '..'); (async () => { const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'k58qa-')); let bad = 0; const ctx = await chromium.launchPersistentContext(udd, Object.assign({ headless: true, args: ['--headless=new', '--allow-file-access-from-files'], viewport: { width: 1200, height: 900 } }, CHROME ? { executablePath: CHROME } : {})); try { if (opt.selftest) { // the gate's self-test (one page, no kill judged) const page = await ctx.newPage(), errs = []; page.on('pageerror', (e) => errs.push(e.message)); await page.goto('file://' + path.join(ROOT, opt.page || '58k_fists.html')); await page.waitForFunction(() => document.documentElement.classList.contains('k58-loaded'), null, { timeout: 120000 }); await page.addScriptTag({ content: fs.readFileSync(path.join(__dirname, 'motionqa_selftest.js'), 'utf8') }); const T = await page.evaluate(() => window.MQA_SELFTEST()); const lines = ['motionQA SELF-TEST ' + (T.pass ? 'PASS' : 'FAIL') + ' (page ' + (opt.page || '58k_fists.html') + ', TH.slide ' + (T.th ? T.th.slide : '?') + ' u/s, plantTol ' + (T.th ? T.th.plantTol : '?') + ' u, pad ' + (T.pad != null ? T.pad.toFixed(3) : '?') + ' u)']; for (const q of T.res) lines.push((q.pass ? ' ok ' : ' BAD ') + q.name + (q.detail ? ' [' + q.detail + ']' : '')); if (errs.length) lines.push(' (page errors: ' + errs.length + ': ' + errs[0].slice(0, 160) + ')'); console.log(lines.join('\n')); fs.writeFileSync(path.join(opt.out, 'motionqa_selftest.txt'), lines.join('\n') + '\n'); if (!T.pass) bad++; await Promise.race([page.close(), new Promise((r) => setTimeout(r, 8000))]); keys.length = 0; } const byPage = {}; for (const k of keys) { const pg = opt.page || '58k_' + k.split('.')[0] + '.html'; (byPage[pg] = byPage[pg] || []).push(k); } for (const pg of Object.keys(byPage)) { const page = await ctx.newPage(), errs = []; page.on('pageerror', (e) => errs.push(e.message)); await page.goto('file://' + path.join(ROOT, pg)); await page.waitForFunction(() => document.documentElement.classList.contains('k58-loaded'), null, { timeout: 120000 }); if (opt.load) for (const f of String(opt.load).split(',')) await page.addScriptTag({ content: fs.readFileSync(f, 'utf8') }); // WEAPON check (STYLE-PORT 29 Oct, _tools/coord/WEAPON_RULE.md): _tools/weaponqa_page.js + the kill's lines of the static audit // (_tools/coord/weapon_audit.json, `python3 _tools/weapon_audit.py`). --weapon 0 skips it. if (opt.weapon !== '0') { try { await page.addScriptTag({ content: fs.readFileSync(path.join(__dirname, 'weaponqa_page.js'), 'utf8') }); } catch (e) { console.log('weapon check not loaded: ' + e.message); } } // --load old.js: evaluate an older lane file over the live one (its kills re-register), for A/B and calibration let WA = null; try { WA = JSON.parse(fs.readFileSync(path.join(__dirname, 'coord', 'weapon_audit.json'), 'utf8')); } catch (e) { WA = null; } for (const key of byPage[pg]) { opt.wrows = WA ? WA.rows.filter((r) => r.key === key).map((r) => ({ cls: r.cls, what: r.what, at: r.at })) : []; const R = await page.evaluate(async ([key, o]) => { const REAL = K58.REAL; if (!REAL || !REAL.motionQA) return { err: 'no K58.REAL.motionQA (58k_cine3.js not loaded)' }; if (o.pre) { try { (0, eval)(o.pre); } catch (e) { return { err: 'bad --pre: ' + e.message }; } } if (o.th) for (const kv of String(o.th).split(',')) { const [k, v] = kv.split('='); REAL.QA.TH[k] = +v; } const def = K58.get(key); if (!def) return { err: 'no kill ' + key }; const R = REAL.motionQA(def, { gore: +o.gore, lead: !!o.lead }); if (window.WEAPONQA && o.weapon !== '0') { const WE = window.WEAPONQA(def, o.wrows || [], +o.gore); for (const e of WE) { R.list.push(e); if (e.sev === 'fail') { R.fails++; R.pass = false; } else if (e.sev === 'warn') R.warns++; else R.infos = (R.infos || 0) + 1; } R.count.weapon = WE.length; } const text = REAL.motionQAText(R); // the overlay strip: a timeline, then the flagged frames (fails first, spread over the film) const pick = [], seen = new Set(), ord = R.list.slice().sort((a, b) => (a.sev === b.sev ? a.f - b.f : a.sev === 'fail' ? -1 : 1)); for (const e of ord) { const slot = Math.round(e.f * 5); if (seen.has(slot)) continue; seen.add(slot); pick.push(e); if (pick.length >= +o.strip) break; } pick.sort((a, b) => a.f - b.f); const TW = 480, TH2 = 221, cols = Math.min(5, Math.max(1, pick.length)), rows = Math.ceil(pick.length / cols), W = cols * TW, LINE = 100; const cv = document.createElement('canvas'); cv.width = Math.max(W, 960); cv.height = LINE + rows * TH2 + 8; const g = cv.getContext('2d'); g.fillStyle = '#0E1018'; g.fillRect(0, 0, cv.width, cv.height); g.font = 'bold 15px sans-serif'; g.fillStyle = R.pass ? '#5CE08A' : '#FF3355'; g.fillText((R.pass ? 'PASS ' : 'FAIL ') + key + ' ' + R.fails + ' fail · ' + R.warns + ' warn', 10, 20); const kinds = ['slide', 'joint', 'pop', 'snap', 'balance', 'floor', 'blade', 'clip', 'face'], x0 = 70, x1 = cv.width - 12, tx = (f) => x0 + (x1 - x0) * (f - R.from) / (R.film - R.from); g.font = '9px sans-serif'; kinds.forEach((k, i) => { const y = 28 + i * 7; g.fillStyle = '#2A2E3C'; g.fillRect(x0, y, x1 - x0, 6); g.fillStyle = '#8890A8'; g.fillText(k, 10, y + 6); }); g.fillStyle = '#8890A8'; for (let s = Math.ceil(R.from); s <= R.film; s++) { g.fillRect(tx(s), 96, 1, -3); g.fillText(s + 's', tx(s) + 2, 96); } for (const e of R.list) { const i = kinds.indexOf(e.kind); g.fillStyle = e.sev === 'fail' ? '#FF3355' : '#FFB020'; g.fillRect(tx(e.f), 28 + i * 7, Math.max(2, tx(e.f1) - tx(e.f)), 5); } // the marks are drawn by a world layer of the scene, so they land right in the main view AND in every multicam insert const v = K58.offscreen(TW, TH2, { dpr: 1 }); v.load(def, { gore: +o.gore }); const sc = v.scene, FS = K58.FSTEP || 1 / 180; let cur = null; sc.layer('top', (c) => { if (!cur) return; const m = c.getTransform(), s = Math.hypot(m.a, m.b) || 1; for (const e2 of R.list) if (e2.f <= cur.f + 0.02 && e2.f1 >= cur.f - 0.02) REAL.QA.mark(c, sc, e2, Math.max(0.6, 1.6 / s)); }); for (let n = 0; n < pick.length; n++) { const e = pick[n]; cur = e; v.advance(Math.round(e.f / FS), 1e9); v.render(); const X = (n % cols) * TW, Y = LINE + Math.floor(n / cols) * TH2; g.drawImage(v.cv, X, Y, TW, TH2 - 4); g.fillStyle = 'rgba(0,0,0,.72)'; g.fillRect(X, Y, TW, 18); g.fillStyle = e.sev === 'fail' ? '#FF5570' : '#FFC040'; g.font = 'bold 11px sans-serif'; g.fillText(e.f.toFixed(2) + 's ' + e.who + ' · ' + e.msg.slice(0, 64), X + 5, Y + 13); } K58.offRelease(v); return { R, text, png: cv.toDataURL('image/png') }; }, [key, opt]); if (R.err) { console.log('ERROR ' + key + ': ' + R.err); bad++; continue; } const base = path.join(opt.out, key); fs.writeFileSync(base + '.qa.txt', R.text + '\n'); fs.writeFileSync(base + '.qa.json', JSON.stringify(R.R, null, 1)); fs.writeFileSync(base + '.qa.png', Buffer.from(R.png.split(',')[1], 'base64')); console.log(R.text); console.log(' -> ' + base + '.qa.png' + (errs.length ? ' (page errors: ' + errs.length + ': ' + errs[0].slice(0, 120) + ')' : '')); if (!R.R.pass) bad++; } await Promise.race([page.close(), new Promise((r) => setTimeout(r, 8000))]); // (29 Oct: a page with live audio can hang close(); never wait on it) } } finally { let closed = false; await Promise.race([ctx.close().then(() => { closed = true; }), new Promise((r) => setTimeout(r, 10000))]); if (!closed) { try { for (const ln of require('child_process').execSync('ps -axo pid=,command=', { encoding: 'utf8', maxBuffer: 1 << 26 }).split('\n')) if (ln.includes(udd)) { const pid = parseInt(ln, 10); if (pid && pid !== process.pid) try { process.kill(pid, 'SIGKILL'); } catch (e) {} } } catch (e) {} } /* our own Chrome only (its private profile dir is in its command line) */ try { fs.rmSync(udd, { recursive: true, force: true }); } catch (e) {} } process.exit(bad ? 1 : 0); })().catch((e) => { console.error(e); process.exit(3); });