2026-09-24 14:38:07 -04:00
|
|
|
|
import { FaceLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/vision_bundle.mjs';
|
|
|
|
|
|
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL } from './landmarks.js';
|
2026-09-24 14:51:15 -04:00
|
|
|
|
import { stabilize, toRasterRing, smoothContours, suggestPlateFrames, heldFrame } from './pipeline.js';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
import { IndexedRaster } from './raster.js';
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
import { drawRegistered, posterizeInto } from './underlay.js';
|
2026-09-24 15:12:09 -04:00
|
|
|
|
import { measureInterior, clipPolyAbove } from './interior.js';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
import { writeTake } from './take.js';
|
|
|
|
|
|
import { synthDense } from './synth.js';
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const PALETTE = [
|
|
|
|
|
|
{ name: 'bg', hex: '#12141c' },
|
|
|
|
|
|
{ name: 'skin_base', hex: '#b07a5a' },
|
|
|
|
|
|
{ name: 'skin_dark', hex: '#7a4f3a' },
|
|
|
|
|
|
{ name: 'mouth_dark', hex: '#24161a' },
|
2026-09-24 15:12:09 -04:00
|
|
|
|
{ name: 'teeth', hex: '#d9cfc2' },
|
2026-09-24 14:38:07 -04:00
|
|
|
|
];
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const IDX = { bg: 0, base: 1, dark: 2, mouth: 3, teeth: 4 };
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
dense: null, images: [], stab: null, xform: null,
|
2026-09-24 14:51:15 -04:00
|
|
|
|
outer: null, inner: null, plates: null, hidden: null,
|
|
|
|
|
|
keep: new Set(), // frames that get their own plate drawing
|
|
|
|
|
|
frame: 0, playing: false, faceBox: null,
|
2026-09-24 14:53:09 -04:00
|
|
|
|
fps: 12, audio: null, // fps comes from manifest.json, never guessed
|
2026-09-24 15:03:12 -04:00
|
|
|
|
aspect: 1, // imgW/imgH; converts MediaPipe's anisotropic space
|
2026-09-24 15:12:09 -04:00
|
|
|
|
interior: null, // per-frame teeth measurement from image content
|
|
|
|
|
|
teeth: null, // resolved per-frame {show, t} after knobs
|
2026-09-24 14:38:07 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
|
|
|
|
const opts = () => ({
|
|
|
|
|
|
verts: +el('verts').value,
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
teethOn: +el('teethOn').value / 100, // minimum Otsu class separation
|
2026-09-24 15:12:09 -04:00
|
|
|
|
teethDwell: +el('teethDwell').value,
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
teethErode: +el('teethErode').value / 100,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
smoothWin: +el('smoothWin').value,
|
2026-09-24 14:51:15 -04:00
|
|
|
|
contourSmooth: +el('contourSmooth').value,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
apertureThresh: +el('apertureThresh').value / 1000,
|
2026-09-24 14:51:15 -04:00
|
|
|
|
tol: +el('tol').value / 1000,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function status(msg, kind = '') {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el('status').textContent = msg;
|
|
|
|
|
|
el('status').className = kind;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- loading ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const loadImage = (src) => new Promise((r) => {
|
|
|
|
|
|
const im = new Image();
|
|
|
|
|
|
im.onload = () => r(im); im.onerror = () => r(null); im.src = src;
|
|
|
|
|
|
});
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:53:09 -04:00
|
|
|
|
// The extraction rate is read, not assumed. Guessing it would desynchronise
|
|
|
|
|
|
// audio from picture, which is the one thing this view exists to show.
|
|
|
|
|
|
async function loadManifest() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await fetch('./manifest.json', { cache: 'no-store' });
|
|
|
|
|
|
if (!r.ok) return null;
|
|
|
|
|
|
return await r.json();
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function attachAudio(name) {
|
|
|
|
|
|
const a = el('audio');
|
|
|
|
|
|
if (!name) { a.removeAttribute('src'); a.hidden = true; state.audio = null; return; }
|
|
|
|
|
|
a.src = './' + name;
|
|
|
|
|
|
a.hidden = false;
|
|
|
|
|
|
state.audio = a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
async function loadFrameSequence() {
|
|
|
|
|
|
const dir = el('framedir').value.replace(/\/$/, '');
|
|
|
|
|
|
const imgs = [];
|
|
|
|
|
|
for (let i = 1; i <= 900; i++) {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const im = await loadImage(`${dir}/${String(i).padStart(4, '0')}.png`);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
if (!im) break;
|
|
|
|
|
|
imgs.push(im);
|
|
|
|
|
|
if (i % 10 === 0) status(`loading frames… ${i}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return imgs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let landmarker = null;
|
|
|
|
|
|
async function initLandmarker() {
|
|
|
|
|
|
if (landmarker) return landmarker;
|
|
|
|
|
|
status('loading MediaPipe wasm…');
|
|
|
|
|
|
const fileset = await FilesetResolver.forVisionTasks(
|
|
|
|
|
|
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// GPU is faster but unavailable in some contexts; fall back rather than fail.
|
|
|
|
|
|
for (const delegate of ['GPU', 'CPU']) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
landmarker = await FaceLandmarker.createFromOptions(fileset, {
|
|
|
|
|
|
baseOptions: { modelAssetPath: './face_landmarker.task', delegate },
|
|
|
|
|
|
runningMode: 'IMAGE', numFaces: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
status(`landmarker ready (${delegate})`);
|
|
|
|
|
|
return landmarker;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (delegate === 'CPU') throw e;
|
|
|
|
|
|
console.warn('GPU delegate failed, falling back to CPU:', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function detectAll(images) {
|
|
|
|
|
|
const lm = await initLandmarker();
|
|
|
|
|
|
const cv = document.createElement('canvas');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const dense = [], missing = [];
|
2026-09-24 14:38:07 -04:00
|
|
|
|
for (let i = 0; i < images.length; i++) {
|
|
|
|
|
|
const im = images[i];
|
|
|
|
|
|
cv.width = im.naturalWidth; cv.height = im.naturalHeight;
|
|
|
|
|
|
cv.getContext('2d').drawImage(im, 0, 0);
|
|
|
|
|
|
const out = lm.detect(cv);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (out.faceLandmarks && out.faceLandmarks.length) dense.push(out.faceLandmarks[0]);
|
|
|
|
|
|
else { missing.push(i); dense.push(dense.length ? dense[dense.length - 1] : null); }
|
|
|
|
|
|
if (i % 4 === 0) status(`detecting… ${i + 1}/${images.length}`);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
if (dense[0] === null) throw new Error('no face found in the first frame');
|
|
|
|
|
|
return { dense, missing };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 15:12:09 -04:00
|
|
|
|
// Interior measurement is a function of pixels alone, so it runs once with
|
|
|
|
|
|
// detection and the knobs re-resolve it instantly afterwards.
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
function measureAll(images, dense, o) {
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
|
|
|
|
return dense.map((lm, i) =>
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
measureInterior(images[i], LIPS_INNER.map((k) => lm[k]), ctx,
|
|
|
|
|
|
{ minContrast: 0, erode: o.teethErode })); // gate applied later, in resolveTeeth
|
2026-09-24 15:12:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- build ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function makeXform(stab, neutral) {
|
|
|
|
|
|
const oval = stab.oval[neutral];
|
2026-09-24 14:38:07 -04:00
|
|
|
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
|
|
|
|
for (const p of oval) {
|
|
|
|
|
|
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
|
|
|
|
|
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
|
|
|
|
|
}
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const s = (RH * 0.80) / (y1 - y0);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
|
|
|
|
return (p) => ({ x: (p.x - cx) * s + RW / 2, y: (p.y - cy) * s + RH / 2 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function rebuild(resetKeep) {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
if (!state.dense) return;
|
|
|
|
|
|
const o = opts();
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const N = state.dense.length;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 15:03:12 -04:00
|
|
|
|
state.stab = stabilize(state.dense, o.smoothWin, state.aspect);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const ap = state.stab.aperture;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const head = Math.max(1, Math.floor(N / 4));
|
|
|
|
|
|
let neutral = 0;
|
|
|
|
|
|
for (let i = 0; i < head; i++) if (ap[i] < ap[neutral]) neutral = i;
|
|
|
|
|
|
state.neutral = neutral;
|
|
|
|
|
|
state.xform = makeXform(state.stab, neutral);
|
|
|
|
|
|
|
|
|
|
|
|
// Mouth is traced, so it costs nothing: a key on EVERY frame. Only the plate,
|
|
|
|
|
|
// which a human draws, gets decimated.
|
|
|
|
|
|
state.outer = smoothContours(
|
|
|
|
|
|
state.stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, o.verts, state.xform)), o.contourSmooth);
|
|
|
|
|
|
state.inner = smoothContours(
|
|
|
|
|
|
state.stab.inner.map((r) => toRasterRing(r, LIPS_INNER, o.verts, state.xform)), o.contourSmooth);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const apMax = Math.max(...ap);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
state.hidden = ap.map((v) => v / apMax < o.apertureThresh);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
if (state.images.length && (!state.interior || state.erodeUsed !== o.teethErode)) {
|
|
|
|
|
|
state.interior = measureAll(state.images, state.dense, o);
|
|
|
|
|
|
state.erodeUsed = o.teethErode;
|
|
|
|
|
|
}
|
2026-09-24 15:12:09 -04:00
|
|
|
|
state.teeth = resolveTeeth(o);
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// Plate outline per frame, so a kept frame shows its own head shape.
|
|
|
|
|
|
state.plates = state.stab.oval.map((r) => r.map(state.xform));
|
|
|
|
|
|
|
|
|
|
|
|
if (resetKeep || !state.keep.size) {
|
|
|
|
|
|
state.keep = new Set(Array.from({ length: N }, (_, i) => i));
|
|
|
|
|
|
}
|
|
|
|
|
|
// Frame 0 must always be kept: something has to be on screen at the start.
|
|
|
|
|
|
state.keep.add(0);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
state.faceBox = faceBoxes();
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Face bounding box per frame in image space, for legible strip thumbnails.
|
|
|
|
|
|
function faceBoxes() {
|
|
|
|
|
|
return state.dense.map((lm) => {
|
|
|
|
|
|
let x0 = 1, y0 = 1, x1 = 0, y1 = 0;
|
|
|
|
|
|
for (const i of FACE_OVAL) {
|
|
|
|
|
|
x0 = Math.min(x0, lm[i].x); y0 = Math.min(y0, lm[i].y);
|
|
|
|
|
|
x1 = Math.max(x1, lm[i].x); y1 = Math.max(y1, lm[i].y);
|
|
|
|
|
|
}
|
|
|
|
|
|
const mx = (x1 - x0) * 0.18, my = (y1 - y0) * 0.14;
|
|
|
|
|
|
return { x0: x0 - mx, y0: y0 - my, x1: x1 + mx, y1: y1 + my };
|
|
|
|
|
|
});
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 15:12:09 -04:00
|
|
|
|
// Presence gets hysteresis and a minimum dwell, the same treatment plate
|
|
|
|
|
|
// selection gets: a teeth block that blinks on and off for single frames is
|
|
|
|
|
|
// worse than one that is simply absent. Appearing needs a clear signal, staying
|
|
|
|
|
|
// needs only a weak one.
|
|
|
|
|
|
function resolveTeeth(o) {
|
|
|
|
|
|
const N = state.dense.length;
|
|
|
|
|
|
if (!state.interior) return new Array(N).fill({ show: false, t: 0 });
|
|
|
|
|
|
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
// Gate on the separation between Otsu's two class means, not on how much of
|
|
|
|
|
|
// the cavity is bright: coverage is high both when the mouth is full of teeth
|
|
|
|
|
|
// and when the region is uniformly dark and Otsu has split noise.
|
|
|
|
|
|
const raw = state.interior.map((m, f) =>
|
|
|
|
|
|
(state.hidden[f] || m.teethT <= 0 ? 0 : m.contrast));
|
|
|
|
|
|
const on = o.teethOn, off = o.teethOn * 0.7;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const shown = new Array(N).fill(false);
|
|
|
|
|
|
let live = false, since = 0;
|
|
|
|
|
|
for (let f = 0; f < N; f++) {
|
|
|
|
|
|
const want = live ? raw[f] > off : raw[f] > on;
|
|
|
|
|
|
if (want !== live && since >= o.teethDwell) { live = want; since = 0; }
|
|
|
|
|
|
else since++;
|
|
|
|
|
|
shown[f] = live && !state.hidden[f];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Smooth the line height only across frames where it is actually shown, so a
|
|
|
|
|
|
// gap does not drag the band toward zero on either side of it.
|
|
|
|
|
|
const t = state.interior.map((m) => m.teethT);
|
|
|
|
|
|
const sm = t.map((_, f) => {
|
|
|
|
|
|
let acc = 0, c = 0;
|
|
|
|
|
|
for (let j = f - o.contourSmooth; j <= f + o.contourSmooth; j++) {
|
|
|
|
|
|
const k = Math.min(N - 1, Math.max(0, j));
|
|
|
|
|
|
if (!shown[k]) continue;
|
|
|
|
|
|
acc += t[k]; c++;
|
|
|
|
|
|
}
|
|
|
|
|
|
return c ? acc / c : t[f];
|
|
|
|
|
|
});
|
|
|
|
|
|
return shown.map((show, f) => ({ show, t: sm[f] }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- render ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
// The plate layer has several representations because its job changes: a flat
|
|
|
|
|
|
// shape to judge the mouth against, or a registered photograph to draw over.
|
|
|
|
|
|
// Only the latter is any use as reference art, and the generated oval is only a
|
|
|
|
|
|
// stand-in until a drawing exists.
|
|
|
|
|
|
function renderFrame(f, mode = plateMode()) {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const r = new IndexedRaster(RW, RH);
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
const pf = heldFrame(keptSorted(), f); // the plate frame on screen
|
|
|
|
|
|
|
|
|
|
|
|
if (mode === 'posterize' && state.images[pf]) {
|
|
|
|
|
|
posterizeInto(r, state.images[pf], state.stab.transforms[pf], state.xform,
|
|
|
|
|
|
PALETTE.map((p) => p.hex));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
r.clear(IDX.bg);
|
|
|
|
|
|
if (mode === 'oval' || mode === 'oval+photo') r.fillPoly(state.plates[pf], IDX.base);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
r.fillPoly(state.outer[f], IDX.dark); // mouth keeps every frame
|
2026-09-24 15:12:09 -04:00
|
|
|
|
if (!state.hidden[f]) {
|
|
|
|
|
|
r.fillPoly(state.inner[f], IDX.mouth);
|
|
|
|
|
|
const te = state.teeth[f];
|
|
|
|
|
|
if (te.show) {
|
|
|
|
|
|
// The band is the mouth's own silhouette clipped to a height, so the shape
|
|
|
|
|
|
// can never disagree with the lips around it.
|
|
|
|
|
|
const ys = state.inner[f].map((p) => p.y);
|
|
|
|
|
|
const top = Math.min(...ys), bot = Math.max(...ys);
|
|
|
|
|
|
const poly = clipPolyAbove(state.inner[f], top + te.t * (bot - top));
|
|
|
|
|
|
if (poly.length >= 3) r.fillPoly(poly, IDX.teeth);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
return r;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
const plateMode = () => el('plateMode').value;
|
|
|
|
|
|
|
|
|
|
|
|
// Photo modes composite under the indexed layer, so the flat shapes stay exactly
|
|
|
|
|
|
// as they render while the reference sits behind them.
|
|
|
|
|
|
function compositeRender(canvas, f, zoom) {
|
|
|
|
|
|
const mode = plateMode();
|
|
|
|
|
|
const pf = heldFrame(keptSorted(), f);
|
|
|
|
|
|
const img = state.images[pf];
|
|
|
|
|
|
const showPhoto = img && (mode === 'photo' || mode === 'photo-dim' || mode === 'oval+photo');
|
|
|
|
|
|
|
|
|
|
|
|
canvas.width = RW * zoom; canvas.height = RH * zoom;
|
|
|
|
|
|
const g = canvas.getContext('2d');
|
|
|
|
|
|
g.fillStyle = PALETTE[IDX.bg].hex;
|
|
|
|
|
|
g.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
if (showPhoto) {
|
|
|
|
|
|
drawRegistered(g, img, state.stab.transforms[pf], state.xform, zoom,
|
|
|
|
|
|
mode === 'photo-dim' ? 0.34 : 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const r = renderFrame(f, mode === 'oval+photo' ? 'oval' : (showPhoto ? 'off' : mode));
|
|
|
|
|
|
const img2 = r.toImageData(PALETTE.map((p) => p.hex), zoom);
|
|
|
|
|
|
if (showPhoto) {
|
|
|
|
|
|
// Keep the photo visible wherever the indexed layer is background.
|
|
|
|
|
|
const bg = PALETTE[IDX.bg].hex.replace('#', '');
|
|
|
|
|
|
const br = parseInt(bg.slice(0, 2), 16), bgn = parseInt(bg.slice(2, 4), 16), bb = parseInt(bg.slice(4, 6), 16);
|
|
|
|
|
|
const d = img2.data;
|
|
|
|
|
|
for (let i = 0; i < d.length; i += 4) {
|
|
|
|
|
|
if (d[i] === br && d[i + 1] === bgn && d[i + 2] === bb) d[i + 3] = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
const tmp = document.createElement('canvas');
|
|
|
|
|
|
tmp.width = img2.width; tmp.height = img2.height;
|
|
|
|
|
|
tmp.getContext('2d').putImageData(img2, 0, 0);
|
|
|
|
|
|
g.drawImage(tmp, 0, 0);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
g.putImageData(img2, 0, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const keptSorted = () => [...state.keep].sort((a, b) => a - b);
|
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
function blit(canvas, raster, zoom) {
|
|
|
|
|
|
canvas.width = RW * zoom; canvas.height = RH * zoom;
|
|
|
|
|
|
canvas.getContext('2d').putImageData(raster.toImageData(PALETTE.map((p) => p.hex), zoom), 0, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function drawAll() {
|
|
|
|
|
|
drawPanes();
|
|
|
|
|
|
drawStrip();
|
|
|
|
|
|
drawWorksheet();
|
|
|
|
|
|
drawReadout();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drawReadout() {
|
|
|
|
|
|
const kept = keptSorted();
|
|
|
|
|
|
const runs = kept.map((k, i) => (i + 1 < kept.length ? kept[i + 1] : state.dense.length) - k);
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const teethFrames = state.teeth ? state.teeth.filter((t) => t.show).length : 0;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el('readout').textContent =
|
|
|
|
|
|
`${state.dense.length} frames → ${kept.length} drawings · ` +
|
2026-09-24 15:12:09 -04:00
|
|
|
|
`teeth on ${teethFrames}f · ` +
|
2026-09-24 14:51:15 -04:00
|
|
|
|
`holds ${Math.min(...runs)}–${Math.max(...runs)} frames · ` +
|
|
|
|
|
|
`neutral f${state.neutral} · residual ` +
|
|
|
|
|
|
`${(state.stab.residual.reduce((a, b) => a + b, 0) / state.dense.length).toFixed(4)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drawPanes() {
|
|
|
|
|
|
const f = state.frame, kept = keptSorted();
|
|
|
|
|
|
const pf = heldFrame(kept, f);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('framelabel').textContent =
|
2026-09-24 14:53:09 -04:00
|
|
|
|
`f ${f} / ${state.dense.length - 1} · ${(f / state.fps).toFixed(2)}s · plate f${pf}` +
|
2026-09-24 14:51:15 -04:00
|
|
|
|
(state.keep.has(f) ? ' · KEPT' : ' · held');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const c1 = el('cv-source'), g1 = c1.getContext('2d');
|
|
|
|
|
|
c1.width = RW * ZOOM; c1.height = RH * ZOOM;
|
|
|
|
|
|
g1.fillStyle = '#000'; g1.fillRect(0, 0, c1.width, c1.height);
|
|
|
|
|
|
const im = state.images[f];
|
|
|
|
|
|
if (im) {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const b = state.faceBox[f];
|
|
|
|
|
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
|
|
|
|
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
|
|
|
|
|
const s = Math.min(c1.width / sw, c1.height / sh);
|
|
|
|
|
|
const dw = sw * s, dh = sh * s, dx = (c1.width - dw) / 2, dy = (c1.height - dh) / 2;
|
|
|
|
|
|
g1.drawImage(im, sx, sy, sw, sh, dx, dy, dw, dh);
|
|
|
|
|
|
const map = (p) => ({ x: dx + (p.x * im.naturalWidth - sx) * s, y: dy + (p.y * im.naturalHeight - sy) * s });
|
|
|
|
|
|
strokePts(g1, LIPS_OUTER.map((i) => map(state.dense[f][i])), '#4ade80');
|
|
|
|
|
|
strokePts(g1, LIPS_INNER.map((i) => map(state.dense[f][i])), '#f87171');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
g1.fillStyle = '#555'; g1.font = '13px system-ui';
|
|
|
|
|
|
g1.fillText('synthetic — no source frames', 14, 24);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const sc = (p) => ({ x: p.x * c1.width, y: p.y * c1.height });
|
|
|
|
|
|
strokePts(g1, LIPS_OUTER.map((i) => sc(state.dense[f][i])), '#4ade80');
|
|
|
|
|
|
strokePts(g1, LIPS_INNER.map((i) => sc(state.dense[f][i])), '#f87171');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const c2 = el('cv-stab'), g2 = c2.getContext('2d');
|
|
|
|
|
|
c2.width = RW * ZOOM; c2.height = RH * ZOOM;
|
|
|
|
|
|
g2.fillStyle = '#0d0f16'; g2.fillRect(0, 0, c2.width, c2.height);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
g2.strokeStyle = '#2a2f3e'; g2.lineWidth = 1; g2.beginPath();
|
2026-09-24 14:38:07 -04:00
|
|
|
|
g2.moveTo(c2.width / 2, 0); g2.lineTo(c2.width / 2, c2.height);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
g2.moveTo(0, c2.height / 2); g2.lineTo(c2.width, c2.height / 2); g2.stroke();
|
|
|
|
|
|
const z = (pts) => pts.map((p) => ({ x: p.x * ZOOM, y: p.y * ZOOM }));
|
|
|
|
|
|
strokePts(g2, z(state.plates[pf]), '#3b4a63');
|
|
|
|
|
|
strokePts(g2, z(state.outer[f]), '#4ade80');
|
|
|
|
|
|
if (!state.hidden[f]) strokePts(g2, z(state.inner[f]), '#f87171');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
compositeRender(el('cv-render'), f, ZOOM);
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
drawInteriorDebug(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// What the teeth measurement actually saw: sampled region, pixels above
|
|
|
|
|
|
// threshold in green, the resolved line in amber. Recomputed for the current
|
|
|
|
|
|
// frame only, so it costs nothing to keep on screen.
|
|
|
|
|
|
function drawInteriorDebug(f) {
|
|
|
|
|
|
const host = el('cv-teeth');
|
|
|
|
|
|
const img = state.images[f];
|
|
|
|
|
|
if (!img || state.hidden[f]) { host.innerHTML = ''; el('teethinfo').textContent = state.images.length ? 'mouth closed' : 'no source frames'; return; }
|
|
|
|
|
|
const o = opts();
|
|
|
|
|
|
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
|
|
|
|
const m = measureInterior(img, LIPS_INNER.map((k) => state.dense[f][k]), ctx,
|
|
|
|
|
|
{ minContrast: 0, erode: o.teethErode }, true);
|
|
|
|
|
|
host.innerHTML = '';
|
|
|
|
|
|
if (m.debug) {
|
|
|
|
|
|
m.debug.style.width = '150px';
|
|
|
|
|
|
m.debug.style.imageRendering = 'pixelated';
|
|
|
|
|
|
host.append(m.debug);
|
|
|
|
|
|
}
|
|
|
|
|
|
const te = state.teeth[f];
|
|
|
|
|
|
el('teethinfo').textContent =
|
|
|
|
|
|
`contrast ${m.contrast.toFixed(3)} (gate ${o.teethOn.toFixed(2)}) · ` +
|
|
|
|
|
|
`line ${m.teethT.toFixed(2)} · ${te.show ? 'SHOWN' : 'hidden'}`;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function strokePts(g, pts, color, lw = 1) {
|
|
|
|
|
|
g.strokeStyle = color; g.lineWidth = lw;
|
|
|
|
|
|
g.beginPath();
|
|
|
|
|
|
pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y)));
|
|
|
|
|
|
g.closePath(); g.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- the frame strip: this is the editing surface ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function drawStrip() {
|
|
|
|
|
|
const host = el('strip');
|
|
|
|
|
|
host.innerHTML = '';
|
|
|
|
|
|
const N = state.dense.length;
|
|
|
|
|
|
for (let f = 0; f < N; f++) {
|
|
|
|
|
|
const cell = document.createElement('div');
|
|
|
|
|
|
cell.className = 'fr' + (state.keep.has(f) ? ' keep' : ' drop') + (f === state.frame ? ' cur' : '');
|
|
|
|
|
|
cell.dataset.f = f;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const cv = document.createElement('canvas');
|
|
|
|
|
|
const im = state.images[f];
|
|
|
|
|
|
cv.width = THUMB; cv.height = THUMB;
|
|
|
|
|
|
const g = cv.getContext('2d');
|
|
|
|
|
|
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, THUMB, THUMB);
|
|
|
|
|
|
if (im) {
|
|
|
|
|
|
const b = state.faceBox[f];
|
|
|
|
|
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
|
|
|
|
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
|
|
|
|
|
const s = Math.min(THUMB / sw, THUMB / sh);
|
|
|
|
|
|
g.drawImage(im, sx, sy, sw, sh, (THUMB - sw * s) / 2, (THUMB - sh * s) / 2, sw * s, sh * s);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const r = new IndexedRaster(RW, RH);
|
|
|
|
|
|
r.clear(IDX.bg); r.fillPoly(state.plates[f], IDX.base);
|
|
|
|
|
|
const tmp = document.createElement('canvas');
|
|
|
|
|
|
blit(tmp, r, 1);
|
|
|
|
|
|
g.drawImage(tmp, 0, 0, RW, RH, 0, 0, THUMB, THUMB * (RH / RW));
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
2026-09-24 14:51:15 -04:00
|
|
|
|
|
|
|
|
|
|
const tag = document.createElement('span');
|
|
|
|
|
|
tag.textContent = f;
|
|
|
|
|
|
cell.append(cv, tag);
|
|
|
|
|
|
cell.onclick = (ev) => {
|
2026-09-24 14:53:09 -04:00
|
|
|
|
seekTo(f);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (ev.shiftKey) toggle(f);
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
};
|
|
|
|
|
|
cell.ondblclick = () => { toggle(f); drawAll(); };
|
|
|
|
|
|
host.append(cell);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function toggle(f) {
|
|
|
|
|
|
if (f === 0) return; // frame 0 always has a drawing
|
|
|
|
|
|
if (state.keep.has(f)) state.keep.delete(f); else state.keep.add(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- worksheet: the frames a human must draw ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
function drawWorksheet() {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const host = el('sheet');
|
|
|
|
|
|
host.innerHTML = '';
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const kept = keptSorted();
|
|
|
|
|
|
kept.forEach((f, i) => {
|
|
|
|
|
|
const until = (i + 1 < kept.length ? kept[i + 1] : state.dense.length) - 1;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cell = document.createElement('div');
|
|
|
|
|
|
cell.className = 'cell';
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
// Registered, not raw-cropped: the worksheet frame is in raster space, so a
|
|
|
|
|
|
// drawing traced from it is already aligned to the mouth.
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cv = document.createElement('canvas');
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
compositeRender(cv, f, 1);
|
|
|
|
|
|
cv.style.width = '176px';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cap = document.createElement('span');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
cap.textContent = `f${f}` + (until > f ? ` → ${until}` : '') + ` (${until - f + 1}f)`;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
cell.append(cv, cap);
|
2026-09-24 14:53:09 -04:00
|
|
|
|
cell.onclick = () => { seekTo(f); drawAll(); };
|
2026-09-24 14:38:07 -04:00
|
|
|
|
host.append(cell);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- export ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
function exportTake() {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const kept = keptSorted();
|
|
|
|
|
|
const N = state.dense.length;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const take = {
|
|
|
|
|
|
name: el('takename').value || 'line_01',
|
2026-09-24 14:53:09 -04:00
|
|
|
|
frames: N, width: RW, height: RH, exposure: 1, fps: state.fps,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
palette: PALETTE,
|
|
|
|
|
|
slot: { x: RW / 2, y: RH / 2 },
|
|
|
|
|
|
parts: [
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// Sparse: one plate key per frame a human draws.
|
|
|
|
|
|
{ name: 'head', kind: 'plate', z: 0, interp: 'hold',
|
|
|
|
|
|
keys: kept.map((f, i) => ({ f: i, plate: i, src: f })) },
|
|
|
|
|
|
// Dense: the traced mouth keeps every frame.
|
2026-09-24 14:38:07 -04:00
|
|
|
|
{ name: 'mouth', kind: 'poly', z: 30, color: 'skin_dark', interp: 'hold',
|
2026-09-24 14:51:15 -04:00
|
|
|
|
keys: state.outer.map((pts, f) => ({ f, src: f, pts })) },
|
2026-09-24 14:38:07 -04:00
|
|
|
|
{ name: 'mouth_in', kind: 'poly', z: 31, color: 'mouth_dark', interp: 'hold', parent: 'mouth',
|
2026-09-24 14:51:15 -04:00
|
|
|
|
keys: state.inner.map((pts, f) => (state.hidden[f] ? { f, hidden: true } : { f, src: f, pts })) },
|
2026-09-24 15:12:09 -04:00
|
|
|
|
{ name: 'teeth', kind: 'poly', z: 32, color: 'teeth', interp: 'hold', parent: 'mouth_in',
|
|
|
|
|
|
keys: state.inner.map((pts, f) => {
|
|
|
|
|
|
const te = state.teeth[f];
|
|
|
|
|
|
if (state.hidden[f] || !te.show) return { f, hidden: true };
|
|
|
|
|
|
const ys = pts.map((p) => p.y);
|
|
|
|
|
|
const top = Math.min(...ys), bot = Math.max(...ys);
|
|
|
|
|
|
return { f, src: f, pts: clipPolyAbove(pts, top + te.t * (bot - top)) };
|
|
|
|
|
|
}) },
|
2026-09-24 14:38:07 -04:00
|
|
|
|
],
|
|
|
|
|
|
};
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const text = writeTake(take)
|
|
|
|
|
|
+ `\n# plate drawings needed: ${kept.length} of ${N} frames\n`
|
|
|
|
|
|
+ kept.map((f, i) => {
|
|
|
|
|
|
const until = (i + 1 < kept.length ? kept[i + 1] : N) - 1;
|
|
|
|
|
|
return `# plate ${i} = source frame ${f}, holds f${f}..${until}`;
|
|
|
|
|
|
}).join('\n') + '\n';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
|
|
|
|
|
a.download = `${take.name}.take`;
|
|
|
|
|
|
a.click();
|
2026-09-24 14:51:15 -04:00
|
|
|
|
status(`exported — ${kept.length} plate drawings, ${N} mouth frames`, 'ok');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- wiring ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
async function runFrames() {
|
|
|
|
|
|
try {
|
2026-09-24 14:53:09 -04:00
|
|
|
|
const man = await loadManifest();
|
|
|
|
|
|
if (man) {
|
|
|
|
|
|
state.fps = man.fps;
|
|
|
|
|
|
el('framedir').value = man.dir || 'frames';
|
|
|
|
|
|
attachAudio(man.audio);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
attachAudio(null);
|
|
|
|
|
|
status('no manifest.json — assuming 12fps, no audio. Re-run extract.sh', 'warn');
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const images = await loadFrameSequence();
|
|
|
|
|
|
if (!images.length) {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
status(`no frames in ${el('framedir').value}/ — run extract.sh first`, 'err');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const { dense, missing } = await detectAll(images);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
state.images = images; state.dense = dense;
|
2026-09-24 15:03:12 -04:00
|
|
|
|
state.aspect = images[0].naturalWidth / images[0].naturalHeight;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
status('measuring mouth interiors…');
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
state.interior = measureAll(images, dense, opts());
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('scrub').max = dense.length - 1;
|
|
|
|
|
|
state.frame = 0;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
rebuild(true);
|
2026-09-24 14:53:09 -04:00
|
|
|
|
const dur = (dense.length / state.fps).toFixed(2);
|
2026-09-24 15:03:12 -04:00
|
|
|
|
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
|
|
|
|
|
|
`${state.fps}fps · ${dur}s` +
|
2026-09-24 14:53:09 -04:00
|
|
|
|
(state.audio ? ' · audio loaded' : ' · no audio') +
|
|
|
|
|
|
(missing.length ? ` · no face on ${missing.length} (held previous)` : ''),
|
|
|
|
|
|
missing.length ? 'warn' : 'ok');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
} catch (e) { status(e.message, 'err'); console.error(e); }
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runSynthetic() {
|
|
|
|
|
|
state.images = [];
|
2026-09-24 14:53:09 -04:00
|
|
|
|
attachAudio(null);
|
|
|
|
|
|
state.fps = 12;
|
2026-09-24 15:03:12 -04:00
|
|
|
|
state.aspect = 1; // synthetic landmarks are generated square
|
2026-09-24 15:12:09 -04:00
|
|
|
|
state.interior = null; // no pixels, so no teeth
|
2026-09-24 15:03:12 -04:00
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
state.dense = synthDense(72);
|
|
|
|
|
|
el('scrub').max = 71;
|
|
|
|
|
|
state.frame = 0;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
rebuild(true);
|
|
|
|
|
|
status('synthetic — exercises everything below detection', 'ok');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol', 'teethOn', 'teethDwell', 'teethErode']) {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el(id).addEventListener('input', () => {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el(id + 'v').textContent = id === 'apertureThresh' || id === 'tol'
|
2026-09-24 15:12:09 -04:00
|
|
|
|
? (+el(id).value / 1000).toFixed(3)
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
|
: id === 'teethOn' || id === 'teethErode' ? (+el(id).value / 100).toFixed(2)
|
|
|
|
|
|
: el(id).value;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (id === 'tol') return; // tol only matters when you ask for a suggestion
|
|
|
|
|
|
rebuild(false);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
});
|
|
|
|
|
|
el(id + 'v').textContent = el(id).value;
|
|
|
|
|
|
}
|
2026-09-24 14:51:15 -04:00
|
|
|
|
|
2026-09-24 14:53:09 -04:00
|
|
|
|
function seekTo(f) {
|
|
|
|
|
|
state.frame = f;
|
|
|
|
|
|
if (state.audio) state.audio.currentTime = f / state.fps;
|
|
|
|
|
|
el('scrub').value = f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
el('scrub').addEventListener('input', (e) => { seekTo(+e.target.value); drawAll(); });
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('btn-frames').onclick = runFrames;
|
|
|
|
|
|
el('btn-synth').onclick = runSynthetic;
|
|
|
|
|
|
el('btn-export').onclick = exportTake;
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
el('plateMode').addEventListener('change', () => { if (state.dense) drawAll(); });
|
|
|
|
|
|
el('btn-saveframe').onclick = () => {
|
|
|
|
|
|
if (!state.dense) return;
|
|
|
|
|
|
const cv = document.createElement('canvas');
|
|
|
|
|
|
compositeRender(cv, state.frame, 4); // 1280x800: enough to draw on
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = cv.toDataURL('image/png');
|
|
|
|
|
|
a.download = `${el('takename').value || 'take'}_f${String(state.frame).padStart(4, '0')}.png`;
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
status(`saved registered frame f${state.frame} at 4x`, 'ok');
|
|
|
|
|
|
};
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el('btn-keepall').onclick = () => { if (state.dense) { rebuild(true); } };
|
|
|
|
|
|
el('btn-suggest').onclick = () => {
|
|
|
|
|
|
if (!state.dense) return;
|
|
|
|
|
|
const kept = suggestPlateFrames(state.stab.rigid, opts().tol);
|
|
|
|
|
|
state.keep = new Set(kept);
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
status(`suggested ${kept.length} drawings at tolerance ${opts().tol.toFixed(3)} — now hand-correct`, 'ok');
|
|
|
|
|
|
};
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('btn-play').onclick = () => {
|
2026-09-24 14:53:09 -04:00
|
|
|
|
if (!state.dense) return;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
state.playing = !state.playing;
|
|
|
|
|
|
el('btn-play').textContent = state.playing ? 'Stop' : 'Play';
|
2026-09-24 14:53:09 -04:00
|
|
|
|
if (state.playing) {
|
|
|
|
|
|
if (state.audio) {
|
|
|
|
|
|
state.audio.playbackRate = +el('speed').value;
|
|
|
|
|
|
// Restart from the top if we are sitting at the end.
|
|
|
|
|
|
if (state.frame >= state.dense.length - 1) state.frame = 0;
|
|
|
|
|
|
state.audio.currentTime = state.frame / state.fps;
|
|
|
|
|
|
state.audio.play().catch((e) => status('audio blocked: ' + e.message, 'warn'));
|
|
|
|
|
|
}
|
|
|
|
|
|
last = 0;
|
|
|
|
|
|
tick();
|
|
|
|
|
|
} else if (state.audio) {
|
|
|
|
|
|
state.audio.pause();
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-24 14:53:09 -04:00
|
|
|
|
el('speed').addEventListener('change', () => {
|
|
|
|
|
|
// playbackRate retimes the clock, and the picture follows it for free.
|
|
|
|
|
|
if (state.audio) state.audio.playbackRate = +el('speed').value;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// Keyboard is the point: stepping and deleting 37 frames by mouse is miserable.
|
|
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
|
|
|
|
if (!state.dense || e.target.tagName === 'INPUT') return;
|
|
|
|
|
|
const N = state.dense.length;
|
2026-09-24 14:53:09 -04:00
|
|
|
|
if (e.key === 'ArrowRight') { seekTo(Math.min(N - 1, state.frame + 1)); drawAll(); }
|
|
|
|
|
|
else if (e.key === 'ArrowLeft') { seekTo(Math.max(0, state.frame - 1)); drawAll(); }
|
2026-09-24 14:51:15 -04:00
|
|
|
|
else if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'x') {
|
|
|
|
|
|
state.keep.delete(state.frame === 0 ? -1 : state.frame); drawAll();
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
|
} else if (e.key === 'b') {
|
|
|
|
|
|
const sel = el('plateMode');
|
|
|
|
|
|
sel.selectedIndex = (sel.selectedIndex + 1) % sel.options.length;
|
|
|
|
|
|
drawAll();
|
2026-09-24 14:51:15 -04:00
|
|
|
|
} else if (e.key === 'k' || e.key === ' ') {
|
|
|
|
|
|
if (state.frame !== 0) state.keep.add(state.frame);
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
} else return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
let last = 0;
|
|
|
|
|
|
function tick(ts = 0) {
|
|
|
|
|
|
if (!state.playing) return;
|
2026-09-24 14:53:09 -04:00
|
|
|
|
const N = state.dense.length;
|
|
|
|
|
|
let f;
|
|
|
|
|
|
if (state.audio) {
|
|
|
|
|
|
// Audio is the clock. Deriving the frame from currentTime rather than
|
|
|
|
|
|
// counting means a slow render loop drops frames instead of drifting out
|
|
|
|
|
|
// of sync, which is the behaviour you want when judging lip sync.
|
|
|
|
|
|
f = Math.floor(state.audio.currentTime * state.fps);
|
|
|
|
|
|
if (f >= N || state.audio.ended) {
|
|
|
|
|
|
state.audio.currentTime = 0;
|
|
|
|
|
|
state.audio.play().catch(() => {});
|
|
|
|
|
|
f = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const rate = state.fps * +el('speed').value;
|
|
|
|
|
|
if (ts - last < 1000 / rate) { requestAnimationFrame(tick); return; }
|
2026-09-24 14:38:07 -04:00
|
|
|
|
last = ts;
|
2026-09-24 14:53:09 -04:00
|
|
|
|
f = (state.frame + 1) % N;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (f !== state.frame) {
|
|
|
|
|
|
state.frame = f;
|
|
|
|
|
|
el('scrub').value = f;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
drawPanes(); drawStrip();
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(tick);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PALETTE.forEach((p) => {
|
|
|
|
|
|
const sw = document.createElement('label');
|
|
|
|
|
|
sw.className = 'sw';
|
|
|
|
|
|
const inp = document.createElement('input');
|
|
|
|
|
|
inp.type = 'color'; inp.value = p.hex;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
inp.oninput = () => { p.hex = inp.value; if (state.dense) drawAll(); };
|
2026-09-24 14:38:07 -04:00
|
|
|
|
sw.append(inp, document.createTextNode(p.name));
|
|
|
|
|
|
el('palette').append(sw);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
|
|
|
|
|
|
// and deep-linked. Detection needs WebGL; the synthetic path does not.
|
|
|
|
|
|
if (location.hash === '#synth') runSynthetic();
|
|
|
|
|
|
else if (location.hash === '#frames') runFrames();
|
|
|
|
|
|
else status('ready — Load frames, then step with \u2190 \u2192 and delete with X');
|
|
|
|
|
|
|
|
|
|
|
|
window.__roto = state; // headless smoke test reads this
|