arthur/js/selftest.js
Your Name f827757f7d Become arthur: a standalone suite, not an Animator Pro front-end
The test renderer turned out to be the product. Everything that decides how the
work looks - stabilisation, reduction, timing, frame removal, palette - already
happens here, and the flat indexed output already reads the way it should.

The reason to leave is in the original design's own rule: never make a timing
decision that requires a full render to evaluate. Honouring that moved every
judgement out of Animator Pro, which left the host doing nothing but writing a
file, in exchange for modal UI, minutes-long renders, one-level undo, FLX delta
invariants, a single tween state and a cel singleton.

What does NOT change is the constraint. 320x200, indexed palette, flat fills,
no antialiasing - inherited, but load-bearing rather than accidental. The
rasteriser writes palette indices and expands to RGBA only at the end precisely
so nothing can soften an edge. Modern conveniences belong in the workflow.

Adds docs/design.md: the principles, carried over without the Poco/FLX/cel
machinery, plus architecture and an honest list of what is missing - the
largest gap being that plates still have nowhere to be drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:47:41 -04:00

315 lines
15 KiB
JavaScript

// Assertions over the stages below detection. Runs in the browser so the exact
// module graph the tool uses is what gets tested.
//
// The ring-simplicity check exists because "fixed topology" is load-bearing in
// docs/design.md: because hold parts CUT between poses rather than
// interpolating, a ring whose vertex order is wrong self-intersects and renders
// as blocks meeting at corners. It is invisible at some vertex counts and obvious
// at others, so it needs an assertion rather than an eyeball.
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL, RIGID, subsampleSlots, subsampleRing } from './landmarks.js';
import { fitSimilarity, applySim, procrustesMean, smoothTransforms } from './mathutil.js';
import { stabilize, toRasterRing, selectKeys, activeKey, shiftIndex } from './pipeline.js';
import { IndexedRaster, hexToRgb } from './raster.js';
import { writeTake } from './take.js';
import { otsuForTest, scaleRing } from './interior.js';
import { synthDense } from './synth.js';
const results = [];
const ok = (name, cond, detail = '') => results.push({ name, pass: !!cond, detail });
/* ---- geometry helpers ---- */
function segmentsCross(a, b, c, d) {
const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
const o1 = o(a, b, c), o2 = o(a, b, d), o3 = o(c, d, a), o4 = o(c, d, b);
return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;
}
// A closed ring is simple if no pair of non-adjacent edges crosses.
function ringSelfIntersections(pts) {
const n = pts.length, hits = [];
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
if (i === j || (j + 1) % n === i || (i + 1) % n === j) continue;
if (segmentsCross(pts[i], pts[(i + 1) % n], pts[j], pts[(j + 1) % n])) hits.push([i, j]);
}
}
return hits;
}
const spreadX = (frames, slot) => {
const xs = frames.map((f) => f[slot].x);
return Math.max(...xs) - Math.min(...xs);
};
/* ---- the tests ---- */
// Cross-check every el('id') in app.js against the ids in index.html.
//
// This bug class has bitten twice: a knob wired in app.js but absent from the
// markup throws during wiring, which aborts the rest of the module and leaves a
// blank page. The symptom ("nothing happens") points nowhere near the cause, so
// it is worth an automated check rather than vigilance.
export async function runWiring() {
const out = [];
try {
const [app, html] = await Promise.all([
fetch('./js/app.js').then((r) => r.text()),
fetch('./index.html').then((r) => r.text()),
]);
const ids = new Set([...app.matchAll(/\bel\(\s*['"]([\w-]+)['"]\s*\)/g)].map((m) => m[1]));
const list = app.match(/for \(const id of \[([\s\S]*?)\]\)/);
if (list) {
for (const m of list[1].matchAll(/'([\w]+)'/g)) { ids.add(m[1]); ids.add(m[1] + 'v'); }
}
// Duplicate keys in an object literal are silent in JS - the last one wins.
// In opts() that meant the teeth vertex slider was quietly driving the lip
// vertex count while the lip slider did nothing at all.
const lit = app.match(/const opts = \(\) => \(\{([\s\S]*?)\n\}\);/);
if (lit) {
const keys = [...lit[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]);
const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
out.push({ name: `opts() has no duplicate keys (${keys.length} checked)`,
pass: dupes.length === 0, detail: [...new Set(dupes)].join(', ') });
} else {
out.push({ name: 'opts() literal found for duplicate-key check', pass: false, detail: '' });
}
const have = new Set([...html.matchAll(/id="([\w-]+)"/g)].map((m) => m[1]));
const missing = [...ids].filter((i) => !have.has(i));
out.push({ name: `every el() id exists in index.html (${ids.size} checked)`,
pass: missing.length === 0, detail: missing.join(', ') });
const unused = [...have].filter((i) => !ids.has(i));
out.push({ name: 'no orphaned ids in index.html', pass: unused.length === 0,
detail: unused.join(', ') });
} catch (e) {
out.push({ name: 'wiring check ran', pass: false, detail: e.message });
}
return out;
}
export function run() {
results.length = 0;
// tables
ok('LIPS_OUTER has 20 distinct ids', new Set(LIPS_OUTER).size === 20);
ok('LIPS_INNER has 20 distinct ids', new Set(LIPS_INNER).size === 20);
ok('FACE_OVAL has 36 distinct ids', new Set(FACE_OVAL).size === 36);
ok('RIGID excludes every lip vertex',
!RIGID.some((i) => LIPS_OUTER.includes(i) || LIPS_INNER.includes(i)),
'a moving feature in the rigid set bleeds performance into stabilisation');
// subsampling preserves order and count at every budget
for (let n = 4; n <= 16; n += 2) {
const s = subsampleSlots(20, n);
const mono = s.every((v, i) => i === 0 || v > s[i - 1]);
ok(`subsampleSlots(20,${n}) is strictly increasing, n=${n}`, mono && s.length === n, s.join(','));
}
ok('subsampleRing agrees with subsampleSlots',
subsampleRing(LIPS_OUTER, 8).join(',') === subsampleSlots(20, 8).map((s) => LIPS_OUTER[s]).join(','));
const dense = synthDense(72);
// rings must be simple at EVERY vertex budget, on every frame
for (const [label, table] of [['outer', LIPS_OUTER], ['inner', LIPS_INNER]]) {
let worst = null;
for (let n = 4; n <= 16 && !worst; n += 2) {
const slots = subsampleSlots(table.length, n);
for (let f = 0; f < dense.length; f++) {
const pts = slots.map((s) => dense[f][table[s]]);
const hits = ringSelfIntersections(pts);
if (hits.length) { worst = `verts=${n} frame=${f} edges ${JSON.stringify(hits[0])}`; break; }
}
}
ok(`${label} ring is simple at every vertex budget`, !worst, worst || '');
}
// FACE_OVAL traversal: never checked before, and a wrong ordering here shows up
// as a lumpy plate rather than an obvious bowtie, so it needs asserting.
{
let bad = null;
for (let f = 0; f < dense.length && !bad; f++) {
const h = ringSelfIntersections(FACE_OVAL.map((i) => dense[f][i]));
if (h.length) bad = `frame ${f} edges ${JSON.stringify(h[0])}`;
}
ok('FACE_OVAL is a simple ring on every frame', !bad, bad || '');
}
// similarity fit recovers a known transform
const src = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 3 }];
const truth = { s: 1.7, theta: 0.6, tx: 4, ty: -2 };
const dst = src.map((p) => applySim(truth, p));
const got = fitSimilarity(src, dst);
ok('fitSimilarity recovers a known transform',
Math.abs(got.s - truth.s) < 1e-9 && Math.abs(got.theta - truth.theta) < 1e-9 &&
Math.abs(got.tx - truth.tx) < 1e-9 && Math.abs(got.ty - truth.ty) < 1e-9,
`s=${got.s.toFixed(6)} th=${got.theta.toFixed(6)}`);
// stabilisation: head motion out, mouth motion kept
const stab = stabilize(dense, 0);
const rawSpread = spreadX(dense, 133);
const stabSpread = (() => {
const xs = stab.eyes.map((e) => e[0].x);
return Math.max(...xs) - Math.min(...xs);
})();
ok('stabilisation removes >90% of head translation',
stabSpread < rawSpread * 0.1, `raw ${rawSpread.toFixed(4)} -> ${stabSpread.toFixed(4)}`);
const apRange = Math.max(...stab.aperture) - Math.min(...stab.aperture);
ok('stabilisation preserves mouth motion', apRange > 0.05, `aperture range ${apRange.toFixed(4)}`);
// ASPECT: a shape that is circular in PIXEL space must stay circular in raster
// space. MediaPipe normalises x by width and y by height, so for a portrait
// frame equal normalised numbers are unequal pixel distances; feeding those
// straight through stretches everything horizontally by H/W. This asserts the
// isotropic conversion, and fails at ~1.78 for a 1080x1920 clip without it.
for (const [W, H] of [[1080, 1920], [1920, 1080], [640, 640]]) {
const aspect = W / H;
const N = 24, cx = 0.5, cy = 0.5, rPx = 200;
// a true circle of radius rPx, expressed in MediaPipe normalised coords
const circleFrames = [];
for (let t = 0; t < 4; t++) {
const pts = new Array(478).fill(null).map(() => ({ x: 0.5, y: 0.5, z: 0 }));
RIGID.forEach((id, k) => {
const a = (k / RIGID.length) * Math.PI * 2;
pts[id] = { x: cx + (120 * Math.cos(a)) / W, y: cy + (120 * Math.sin(a)) / H, z: 0 };
});
LIPS_OUTER.forEach((id, k) => {
const a = -(k / LIPS_OUTER.length) * Math.PI * 2;
pts[id] = { x: cx + (rPx * Math.cos(a)) / W, y: cy + (rPx * Math.sin(a)) / H, z: 0 };
});
FACE_OVAL.forEach((id, k) => {
const a = -(k / FACE_OVAL.length) * Math.PI * 2;
pts[id] = { x: cx + (420 * Math.cos(a)) / W, y: cy + (420 * Math.sin(a)) / H, z: 0 };
});
circleFrames.push(pts);
}
const st2 = stabilize(circleFrames, 0, aspect);
const ring = toRasterRing(st2.outer[0], LIPS_OUTER, 16, (p) => p);
const xs = ring.map((p) => p.x), ys = ring.map((p) => p.y);
const ratio = (Math.max(...xs) - Math.min(...xs)) / (Math.max(...ys) - Math.min(...ys));
ok(`circle stays circular at ${W}x${H}`, Math.abs(ratio - 1) < 0.02,
`w/h ratio ${ratio.toFixed(4)}`);
}
// key selection
const xf = (p) => ({ x: p.x * 320, y: p.y * 200 });
const shapes = stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, 8, xf));
const sel = selectKeys(shapes, { minHold: 2, distThresh: 0.6, velSmooth: 3, exposure: 2 });
ok('keys are strictly increasing in f', sel.keys.every((k, i) => i === 0 || k.f > sel.keys[i - 1].f));
ok('keys respect the minimum hold',
sel.keys.every((k, i) => i === 0 || k.src - sel.keys[i - 1].src >= 2));
ok('keys land on the exposure grid', sel.keys.every((k) => k.f % 2 === 0));
ok('selection reduces candidates', sel.keys.length < sel.candidates.length,
`${sel.candidates.length} candidates -> ${sel.keys.length} keys`);
ok('first key is frame 0', sel.keys[0].f === 0);
ok('activeKey holds between keys',
activeKey(sel.keys, sel.keys[1].f - 1).f === sel.keys[0].f);
// mouth lead: a shift that "feels like it does nothing" is indistinguishable
// from one that does nothing, so assert the arithmetic directly.
ok('lead 0 is identity', [0, 5, 71].every((f) => shiftIndex(f, 0, 72) === f));
ok('positive lead moves the source frame forward', shiftIndex(10, 2, 72) === 12);
ok('negative lead moves it back', shiftIndex(10, -3, 72) === 7);
ok('lead clamps at the start', shiftIndex(1, -6, 72) === 0);
ok('lead clamps at the end', shiftIndex(70, 6, 72) === 71);
{
// ...and that it selects different POSES, not merely different indices.
// Checked across the whole track rather than at one pair: synthetic poses
// hold for nine-frame beats, so any single pair can legitimately be
// identical while the shift works perfectly.
const N = shapes.length;
let moved = 0, total = 0;
for (let f = 0; f < N; f++) {
const a = shapes[shiftIndex(f, 0, N)], b = shapes[shiftIndex(f, 3, N)];
let d = 0;
for (let i = 0; i < a.length; i++) d += Math.hypot(a[i].x - b[i].x, a[i].y - b[i].y);
total++;
if (d / a.length > 0.5) moved++;
}
ok('a lead of 3 changes the pose on a good share of frames', moved / total > 0.2,
`${moved}/${total} frames differ`);
}
// rasteriser: indexed, hard-edged, no blending
const r = new IndexedRaster(64, 48);
r.clear(0);
r.fillPoly([{ x: 8, y: 8 }, { x: 56, y: 8 }, { x: 56, y: 40 }, { x: 8, y: 40 }], 2);
const present = new Set(r.buf);
ok('raster contains only written indices', present.size === 2 && present.has(0) && present.has(2),
`indices ${[...present].join(',')}`);
let count = 0;
for (const v of r.buf) if (v === 2) count++;
ok('axis-aligned rect fills the exact pixel count', count === 48 * 32, `${count} vs ${48 * 32}`);
const pal = ['#000000', '#ffffff', '#ff8800'];
const img = r.toImageData(pal, 2);
const seen = new Set();
for (let i = 0; i < img.data.length; i += 4) {
seen.add(`${img.data[i]},${img.data[i + 1]},${img.data[i + 2]}`);
}
const allowed = new Set(pal.map((h) => hexToRgb(h).join(',')));
ok('palette expansion introduces no intermediate colours',
[...seen].every((c) => allowed.has(c)), `${seen.size} distinct colours`);
// scaleRing is what pulls the sampled region in from MediaPipe's inner lip
// landmarks, which sit slightly outside the real opening.
{
const ring = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }];
const small = scaleRing(ring, 0.5);
const w = Math.max(...small.map((p) => p.x)) - Math.min(...small.map((p) => p.x));
ok('scaleRing(0.5) halves the extent', Math.abs(w - 5) < 1e-9, `width ${w}`);
const same = scaleRing(ring, 1);
ok('scaleRing(1) is identity', same.every((p, i) => Math.abs(p.x - ring[i].x) < 1e-9));
let cx = 0; for (const p of small) cx += p.x;
ok('scaleRing keeps the centroid', Math.abs(cx / 4 - 5) < 1e-9);
}
// Otsu on a uniform region must report near-zero class separation. It will
// still return a threshold - that is what Otsu does - so the separation is the
// only thing that distinguishes "found teeth" from "split noise in a dark
// mouth", which is what made the band fill the whole cavity.
{
const flat = new Uint32Array(256); flat[40] = 500;
const f = otsuForTest(flat, 500);
ok('uniform region yields ~no class separation',
Math.abs(f.mBright - f.mDark) / 255 < 0.02, `sep ${((f.mBright - f.mDark) / 255).toFixed(4)}`);
const noisy = new Uint32Array(256);
for (let i = 30; i <= 60; i++) noisy[i] = 20; // dark cavity, some spread
const nz = otsuForTest(noisy, 31 * 20);
ok('dark-but-noisy region stays below a sane gate',
(nz.mBright - nz.mDark) / 255 < 0.14, `sep ${((nz.mBright - nz.mDark) / 255).toFixed(4)}`);
const teeth = new Uint32Array(256);
for (let i = 20; i <= 45; i++) teeth[i] = 40; // cavity
for (let i = 180; i <= 220; i++) teeth[i] = 30; // teeth
const tt = otsuForTest(teeth, 26 * 40 + 41 * 30);
ok('real bright/dark split clears the gate',
(tt.mBright - tt.mDark) / 255 > 0.4, `sep ${((tt.mBright - tt.mDark) / 255).toFixed(4)}`);
}
// take writer round-trip
const take = {
name: 'test', frames: 72, width: 320, height: 200, exposure: 2,
palette: [{ name: 'bg' }, { name: 'skin' }],
slot: { x: 160, y: 100 },
parts: [
{ name: 'head', kind: 'plate', z: 0, interp: 'hold', keys: [{ f: 0, plate: 0 }] },
{ name: 'mouth', kind: 'poly', z: 30, color: 'skin', interp: 'hold',
keys: sel.keys.map((k) => ({ f: k.f, src: k.src, pts: shapes[k.src] })) },
],
};
const text = writeTake(take);
const keyLines = text.split('\n').filter((l) => l.startsWith('key') && l.includes('n='));
ok('every key line declares n= matching its point count',
keyLines.every((l) => {
const n = +l.match(/n=(\d+)/)[1];
const pts = l.split(/n=\d+\s+/)[1].trim().split(/\s+/);
return pts.length === n;
}), `${keyLines.length} key lines`);
ok('take declares a plate and a part table',
/^plate\s+0/m.test(text) && /^part\s+mouth/m.test(text));
ok('coordinates are integers', !/-?\d+\.\d/.test(text.split('\n').filter((l) => l.startsWith('key')).join('')));
return results;
}