drawface-live / js /main.js
ingon1's picture
sync from bd61e564bfa012b93c3fabe3190d66575174a898
102a1b6 verified
Raw
History Blame Contribute Delete
31.1 kB
// DrawFace Live web — UI wiring and the render loop (browser twin of app/main.py).
import { CANVAS, CONFIG, SOURCE_MAX } from "./config.js?v=20260729.6";
import { fit512, fitTo, expandBoxToInk, newCanvas } from "./imageops.js?v=20260729.6";
import { buildCharacter } from "./onboard.js?v=20260729.6";
import { deriveAll } from "./derive.js?v=20260729.6";
import { listCharacters, saveCharacter, deleteCharacter, loadCharacter } from "./store.js?v=20260729.6";
import {
SMOOTH_KEYS, OneEuro, IdleMotion, TriStateEye, Calibration,
pickMouth, eyeKeyForUserSide,
} from "./pipeline.js?v=20260729.6";
import { createTracker, createWorkerTracker, detectOnImage } from "./tracker.js?v=20260729.6";
import { prepareCharacter, composeCharacter, drawScene } from "./compositor.js?v=20260729.6";
import { buildWarpRig, renderWarp } from "./warp.js?v=20260729.6";
import { StickerFx } from "./effects.js?v=20260729.6";
import { RenderPerformance } from "./performance.js?v=20260729.6";
const $ = (id) => document.getElementById(id);
const status = (msg) => { $("status").textContent = msg; };
// Surface every failure in the status line — the user has no console open.
window.addEventListener("error", (e) => status(`Error: ${e.message}`));
window.addEventListener("unhandledrejection", (e) => status(`Error: ${e.reason?.message ?? e.reason}`));
const VIZ_BARS = [
["eyeL", "eyeBlinkLeft"], ["eyeR", "eyeBlinkRight"], ["jaw", "jawOpen"],
["smile", "mouthSmileLeft"], ["pucker", "mouthPucker"],
];
const CLICK_STEPS = [
"centre of the eye on the screen's left",
"centre of the eye on the screen's right",
"mouth box: top-left corner",
"mouth box: bottom-right corner",
];
const PERF_MODE_KEY = "drawface-live:performance-mode";
const PERF_MODES = new Set(["auto", "full", "economy"]);
// In warp mode the mesh rolls the face itself, so the canvas keeps only this
// share of the roll (body sways a little, face leads) — full canvas roll on top
// of the mesh roll would rotate the face twice.
const CANVAS_ROLL_SHARE = 0.35;
// ---------- camera list ----------
// RealSense-class devices expose several video inputs (RGB/depth/IR) — the
// browser's default pick can be the wrong one, so let the user choose.
async function refreshCameras() {
const sel = $("camSelect");
const cur = sel.value;
sel.innerHTML = "";
try {
const devs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === "videoinput");
devs.forEach((d, i) => sel.add(new Option(d.label || `Camera ${i + 1}`, d.deviceId)));
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
const rgb = [...sel.options].find((o) => /rgb|color/i.test(o.text));
if (rgb && !cur) sel.value = rgb.value;
} catch { /* enumerate unavailable — default device will be used */ }
}
navigator.mediaDevices?.addEventListener?.("devicechange", refreshCameras);
const CAM_ERRORS = {
NotAllowedError: "Camera permission is blocked — set it to 'Allow' from the lock/camera icon in the address bar and reload the page",
NotFoundError: "No camera found — check that it is connected (a camera attached to WSL is not visible to Windows)",
NotReadableError: "Another program is using the camera — close that app and try again",
OverconstrainedError: "The selected camera does not support the requested resolution — try a different camera",
};
function showTrackerRetry(message) {
status(`${message} — check your network connection and try again`);
$("trackerRetryBtn").hidden = false;
}
function clearTrackerRetry() {
$("trackerRetryBtn").hidden = true;
}
function restorePerformanceMode() {
try {
const saved = localStorage.getItem(PERF_MODE_KEY);
if (PERF_MODES.has(saved)) $("perfMode").value = saved;
} catch { /* storage may be disabled in private browsing */ }
}
function savePerformanceMode(mode) {
try { localStorage.setItem(PERF_MODE_KEY, mode); } catch { /* preference remains session-only */ }
}
// ---------- character list ----------
function refreshList(selectName) {
const sel = $("charSelect");
sel.innerHTML = "";
for (const name of listCharacters()) sel.add(new Option(name, name));
if (selectName) sel.value = selectName;
$("startBtn").disabled = sel.options.length === 0;
}
// ---------- onboarding ----------
const BOX_HANDLE_RADIUS = 12;
const BOX_MIN_SIZE = 12;
const ob = { img: null, points: [], draft: null, previewChar: null, drag: null, landmarks: null, src: null };
function obStatus() {
const n = ob.points.length;
$("onboardStatus").textContent = n < 4
? `Click ${n + 1}/4: ${CLICK_STEPS[n]}`
: "All 4 points are set — check the name, then press [Preview]";
$("obGenerate").disabled = n < 4 || !$("charName").value.trim();
}
function obRedraw() {
const ctx = $("onboardCanvas").getContext("2d");
ctx.drawImage(ob.img, 0, 0);
ctx.strokeStyle = "#e33";
ctx.lineWidth = 2;
ob.points.forEach(([x, y]) => { ctx.beginPath(); ctx.arc(x, y, 4, 0, 7); ctx.stroke(); });
if (ob.points.length === 4) {
const [, , [x0, y0], [x1, y1]] = ob.points;
const left = Math.min(x0, x1), top = Math.min(y0, y1);
const right = Math.max(x0, x1), bottom = Math.max(y0, y1);
ctx.strokeRect(left, top, right - left, bottom - top);
ctx.fillStyle = "#fff";
for (const [x, y] of [[left, top], [right, top], [right, bottom], [left, bottom]]) {
ctx.fillRect(x - 4, y - 4, 8, 8);
ctx.strokeRect(x - 4, y - 4, 8, 8);
}
}
}
function onboardPoint(e) {
const r = e.currentTarget.getBoundingClientRect();
return {
x: Math.max(0, Math.min(CANVAS, Math.round((e.clientX - r.left) * CANVAS / r.width))),
y: Math.max(0, Math.min(CANVAS, Math.round((e.clientY - r.top) * CANVAS / r.height))),
};
}
function mouthBox() {
const [, , [ax, ay], [bx, by]] = ob.points;
return { left: Math.min(ax, bx), top: Math.min(ay, by), right: Math.max(ax, bx), bottom: Math.max(ay, by) };
}
function setMouthBox({ left, top, right, bottom }) {
ob.points[2] = [Math.round(left), Math.round(top)];
ob.points[3] = [Math.round(right), Math.round(bottom)];
}
function boxHit(x, y) {
if (ob.points.length !== 4) return null;
const box = mouthBox();
const corners = [
["nw", box.left, box.top], ["ne", box.right, box.top],
["se", box.right, box.bottom], ["sw", box.left, box.bottom],
];
for (const [handle, cx, cy] of corners) {
if (Math.abs(x - cx) <= BOX_HANDLE_RADIUS && Math.abs(y - cy) <= BOX_HANDLE_RADIUS) return handle;
}
return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom ? "move" : null;
}
function cursorForBoxHit(hit) {
if (hit === "move") return "move";
if (hit === "nw" || hit === "se") return "nwse-resize";
if (hit === "ne" || hit === "sw") return "nesw-resize";
return "crosshair";
}
function invalidateOnboardingPreview() {
ob.draft = null;
ob.previewChar = null;
$("onboardReview").hidden = true;
}
async function openOnboarding(file) {
const bmp = await createImageBitmap(file);
ob.img = fit512(bmp);
// Keep a hi-res copy when the original beats 512 — 원본 보존용 메타데이터
// (r6 부터 warp rig 는 사용 안 함; hi-res 재설계·신경망 트랙 대비 저장만).
ob.src = (bmp.width > CANVAS || bmp.height > CANVAS) ? fitTo(bmp, SOURCE_MAX) : null;
ob.points = [];
ob.drag = null;
ob.draft = null;
ob.previewChar = null;
$("onboardReview").hidden = true;
$("charName").value = file.name.replace(/\.[^.]+$/, "").replace(/[^\w-]+/g, "-").toLowerCase();
$("onboardDlg").showModal();
obRedraw();
$("onboardStatus").textContent = "Trying to detect the face automatically…";
// Photo-trained models often miss hand drawings — prefill when it works,
// fall back to manual clicks when it doesn't (see outputs/benchmark.md).
const auto = await detectOnImage(ob.img);
ob.landmarks = auto?.landmarks ?? null; // 메타데이터로 저장만 — rig 는 사용 안 함(r6)
if (auto && ob.points.length === 0) {
ob.points = [auto.eyes.L, auto.eyes.R,
[auto.mouthBox[0], auto.mouthBox[1]], [auto.mouthBox[2], auto.mouthBox[3]]];
expandMouthPoints(); // landmark lip box misses deep open-mouth interiors
$("eyeHalf").value = auto.eyeHalf;
obRedraw();
obStatus();
$("onboardStatus").textContent = "Face detected — drag a corner to resize, inside to move, then [Preview]";
} else if (ob.points.length === 0) {
obStatus(); // manual flow from step ①
}
}
// After the 4th click (or auto-detect), grow the mouth box to cover the whole
// drawn mouth — deep open mouths otherwise leave leftovers ("second mouth").
function expandMouthPoints() {
const [, , [mx0, my0], [mx1, my1]] = ob.points;
const box = expandBoxToInk(ob.img,
[Math.min(mx0, mx1), Math.min(my0, my1), Math.max(mx0, mx1), Math.max(my0, my1)]);
ob.points[2] = [box[0], box[1]];
ob.points[3] = [box[2], box[3]];
}
$("onboardCanvas").addEventListener("click", (e) => {
if (ob.points.length >= 4) return;
const { x, y } = onboardPoint(e);
ob.points.push([x, y]);
if (ob.points.length === 4) {
expandMouthPoints();
$("onboardStatus").textContent = "Mouth box auto-expanded to the ink — drag a corner to resize, inside to move";
}
obRedraw();
if (ob.points.length !== 4) obStatus();
else $("obGenerate").disabled = !$("charName").value.trim();
});
$("onboardCanvas").addEventListener("pointerdown", (e) => {
const { x, y } = onboardPoint(e);
const handle = boxHit(x, y);
if (!handle) return;
invalidateOnboardingPreview();
ob.drag = { handle, startX: x, startY: y, box: mouthBox() };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
});
$("onboardCanvas").addEventListener("pointermove", (e) => {
const { x, y } = onboardPoint(e);
const canvas = e.currentTarget;
if (!ob.drag) {
canvas.style.cursor = cursorForBoxHit(boxHit(x, y));
return;
}
const { handle, startX, startY, box } = ob.drag;
let { left, top, right, bottom } = box;
if (handle === "move") {
const width = right - left, height = bottom - top;
left = Math.max(0, Math.min(CANVAS - width, left + x - startX));
top = Math.max(0, Math.min(CANVAS - height, top + y - startY));
right = left + width;
bottom = top + height;
} else if (handle === "nw") {
left = Math.max(0, Math.min(right - BOX_MIN_SIZE, x));
top = Math.max(0, Math.min(bottom - BOX_MIN_SIZE, y));
} else if (handle === "ne") {
right = Math.min(CANVAS, Math.max(left + BOX_MIN_SIZE, x));
top = Math.max(0, Math.min(bottom - BOX_MIN_SIZE, y));
} else if (handle === "se") {
right = Math.min(CANVAS, Math.max(left + BOX_MIN_SIZE, x));
bottom = Math.min(CANVAS, Math.max(top + BOX_MIN_SIZE, y));
} else if (handle === "sw") {
left = Math.max(0, Math.min(right - BOX_MIN_SIZE, x));
bottom = Math.min(CANVAS, Math.max(top + BOX_MIN_SIZE, y));
}
setMouthBox({ left, top, right, bottom });
obRedraw();
canvas.style.cursor = cursorForBoxHit(handle);
e.preventDefault();
});
function endBoxDrag(e) {
if (!ob.drag) return;
ob.drag = null;
e.currentTarget.style.cursor = "crosshair";
$("onboardStatus").textContent = "Mouth area adjusted — press [Preview] to check the result";
}
$("onboardCanvas").addEventListener("pointerup", endBoxDrag);
$("onboardCanvas").addEventListener("pointercancel", endBoxDrag);
$("charName").addEventListener("input", obStatus);
$("obReset").onclick = () => {
ob.points = [];
ob.drag = null;
invalidateOnboardingPreview();
obRedraw();
obStatus();
};
$("obCancel").onclick = () => $("onboardDlg").close();
$("obGenerate").onclick = () => {
const name = $("charName").value.trim();
const [L, R, [mx0, my0], [mx1, my1]] = ob.points;
const mouth = [Math.min(mx0, mx1), Math.min(my0, my1), Math.max(mx0, mx1), Math.max(my0, my1)];
try {
const { manifest, canvases } = buildCharacter(ob.img, name, { L, R },
Number($("eyeHalf").value) || 16, mouth);
if (ob.landmarks) manifest.landmarks = ob.landmarks; // 저장만 — rig 는 박스 기하 고정(r6)
if (ob.src) canvases["source.png"] = ob.src; // 원본 보존용 — rig 는 사용 안 함(r6)
deriveAll(canvases, manifest);
ob.draft = { name, manifest, canvases };
ob.previewChar = prepareCharacter(ob.draft);
$("onboardReview").hidden = false;
renderOnboardingPreview();
$("onboardStatus").textContent = "Check each expression, then save it or adjust the positions";
} catch (err) {
$("onboardStatus").textContent = `Failed to generate the preview: ${err.message}`;
}
};
function renderOnboardingPreview() {
if (!ob.previewChar) return;
const state = $("reviewState").value;
const expressions = {
neutral: ["open", "open", "closed"],
blink: ["closed", "closed", "closed"],
smile: ["open", "open", "smile"],
A: ["open", "open", "A"],
};
const [eyeL, eyeR, mouth] = expressions[state] ?? expressions.neutral;
const ctx = $("reviewCanvas").getContext("2d");
ctx.clearRect(0, 0, CANVAS, CANVAS);
ctx.drawImage(composeCharacter(ob.previewChar, eyeL, eyeR, mouth), 0, 0);
}
$("reviewState").onchange = renderOnboardingPreview;
$("obEdit").onclick = () => {
invalidateOnboardingPreview();
$("onboardStatus").textContent = "Drag a corner of the red box to resize, or drag inside it to move";
};
$("obSave").onclick = () => {
if (!ob.draft) return;
try {
saveCharacter(ob.draft.name, ob.draft.manifest, ob.draft.canvases);
refreshList(ob.draft.name);
$("onboardDlg").close();
status(`Character '${ob.draft.name}' saved — press Start`);
} catch (err) {
$("onboardStatus").textContent = `Failed to save: ${err.message}`;
}
};
// ---------- drop zone ----------
const dz = $("dropZone");
dz.onclick = () => $("fileInput").click();
$("fileInput").onchange = (e) => e.target.files[0] && openOnboarding(e.target.files[0]);
dz.ondragover = (e) => { e.preventDefault(); dz.classList.add("drag"); };
dz.ondragleave = () => dz.classList.remove("drag");
dz.ondrop = (e) => {
e.preventDefault();
dz.classList.remove("drag");
const f = [...e.dataTransfer.files].find((f) => f.type.startsWith("image/"));
if (f) openOnboarding(f);
};
// Clean flat avatar — solid colours, eyes/mouth on a solid skin field so the
// erased regions vanish. This is the ideal input shape for the sprite pipeline
// (unlike a textured hand drawing, where the erased patch is hard to hide).
function exampleDrawing() {
const c = newCanvas(CANVAS, CANVAS);
const ctx = c.getContext("2d");
const SKIN = "#f6c9a0", SKIN_SH = "#e8b488", HAIR = "#4a3328", SHIRT = "#4c8bc4", SHIRT_D = "#3b71a3";
const ell = (x0, y0, x1, y1, a0 = 0, a1 = Math.PI * 2) => {
ctx.beginPath();
ctx.ellipse((x0 + x1) / 2, (y0 + y1) / 2, (x1 - x0) / 2, (y1 - y0) / 2, 0, a0, a1);
ctx.fill();
};
const rad = (deg) => (deg * Math.PI) / 180;
ctx.fillStyle = "#f2e9de"; ctx.fillRect(0, 0, CANVAS, CANVAS); // background
ctx.fillStyle = SHIRT; ctx.beginPath(); ctx.roundRect(150, 360, 212, 152, 40); ctx.fill();
ctx.fillStyle = SHIRT_D;
ctx.beginPath(); ctx.moveTo(180, 360); ctx.lineTo(256, 430); ctx.lineTo(332, 360); ctx.closePath(); ctx.fill();
ctx.fillStyle = SKIN_SH; ctx.fillRect(228, 320, 56, 52); // neck
ctx.fillStyle = SKIN; ell(150, 96, 362, 350); // head
ell(140, 200, 172, 250); ell(340, 200, 372, 250); // ears
ctx.fillStyle = HAIR;
ell(150, 70, 362, 300, rad(180), rad(360)); // hair top
ctx.fillRect(150, 150, 212, 22);
ell(150, 96, 200, 210); ell(312, 96, 362, 210); // side hair
ctx.beginPath(); ctx.roundRect(196, 205, 48, 9, 4); ctx.fill(); // brows
ctx.beginPath(); ctx.roundRect(268, 205, 48, 9, 4); ctx.fill();
for (const ex of [220, 292]) { // eyes
ctx.fillStyle = "#2b2420"; ell(ex - 16, 232, ex + 16, 272);
ctx.fillStyle = "#ffffff"; ell(ex - 6, 240, ex + 6, 256);
}
ctx.strokeStyle = SKIN_SH; ctx.lineWidth = 4; // nose
ctx.beginPath(); ctx.moveTo(256, 272); ctx.lineTo(250, 292); ctx.stroke();
ctx.strokeStyle = "#b04a54"; ctx.lineWidth = 7; // mouth
ctx.beginPath(); ctx.ellipse(256, 311, 32, 19, 0, rad(15), rad(165)); ctx.stroke();
return c;
}
// 소년 프리셋: talking-drawing-avatar 의 렌더 방법(벡터 입·WebGL 워핑·시선)을 그대로 쓰는
// 전용 페이지로 연결 — drawface 파이프라인에 대입하면 어색해서 원본 방식 그대로 구동한다.
$("boyBtn").onclick = () => { window.location.href = "boy.html"; };
$("exampleBtn").onclick = () => {
try {
const name = "example-character";
const { manifest, canvases } = buildCharacter(exampleDrawing(), name,
{ L: [220, 252], R: [292, 252] }, 20, [222, 290, 290, 332]);
deriveAll(canvases, manifest);
saveCharacter(name, manifest, canvases);
refreshList(name);
status("Example character loaded — press Start and try mirroring your webcam expressions");
} catch (err) {
status(`Failed to create the example character: ${err.message}`);
}
};
$("deleteBtn").onclick = () => {
const name = $("charSelect").value;
if (name && confirm(`Delete the character '${name}'?`)) { deleteCharacter(name); refreshList(); }
};
// ---------- live loop ----------
const run = {
on: false, stream: null, tracker: null, workerTracker: null, trackerLoading: null,
video: null, raf: 0, videoFrame: 0, recording: null, perf: null,
};
async function start() {
const name = $("charSelect").value;
if (!name) return;
$("startBtn").disabled = true;
clearTrackerRetry();
let loadingTracker = true;
try {
status("Loading the tracking model…");
run.workerTracker = await createWorkerTracker(); // null → silent sync fallback
if (run.workerTracker) console.log("[tracker] worker mode");
else run.tracker ??= await createTracker();
loadingTracker = false;
status("Opening the webcam…");
const video_c = { width: CONFIG.camera.width, height: CONFIG.camera.height };
if ($("camSelect").value) video_c.deviceId = { exact: $("camSelect").value };
try {
run.stream = await navigator.mediaDevices.getUserMedia({ video: video_c });
} catch (err) {
throw new Error(CAM_ERRORS[err.name] ?? `${err.name}: ${err.message}`);
}
refreshCameras(); // labels become visible after the first grant
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.srcObject = run.stream;
await video.play();
run.video = video;
const char = prepareCharacter(await loadCharacter(name));
try {
char.warp = buildWarpRig(char);
} catch (err) {
char.warp = null; // sprite path still works — warp is an upgrade, not a gate
console.warn("warp rig unavailable:", err);
}
$("warpChk").disabled = !char.warp;
// rig 출력 크기에 backing store 를 맞춤 (r6 부터 항상 512 — CSS 가 표시 크기 유지).
const outSize = char.warp ? char.warp.out.width : CANVAS;
$("output").width = $("output").height = outSize;
const st = {
mirror: $("mirrorChk").checked,
calib: new Calibration(CONFIG.calibration.frames),
emas: Object.fromEntries(SMOOTH_KEYS.map((k) => [k,
new OneEuro(CONFIG.smoothing.minCutoff, CONFIG.smoothing.beta)])),
headEmas: Object.fromEntries(["yaw", "pitch", "roll"].map((k) => [k,
new OneEuro(CONFIG.smoothing.headMinCutoff, CONFIG.smoothing.headBeta)])),
eyes: { left: new TriStateEye(CONFIG.eyes), right: new TriStateEye(CONFIG.eyes) },
smoothed: Object.fromEntries(SMOOTH_KEYS.map((k) => [k, 0])),
head: { yaw: 0, pitch: 0, roll: 0 },
lastSeen: performance.now(), fps: 0, tPrev: performance.now(), workerTs: -1,
perf: new RenderPerformance(CONFIG.performance, $("perfMode").value),
outCtx: $("output").getContext("2d"), // hoisted out of the frame loop
prevCtx: $("preview").getContext("2d"),
fx: new StickerFx(outSize),
idle: new IdleMotion(CONFIG.idle),
};
run.perf = st.perf;
run.on = true;
$("startBtn").textContent = "Stop";
$("startBtn").disabled = false;
$("calibBtn").disabled = false;
$("recordBtn").disabled = !("MediaRecorder" in window && "captureStream" in $("output"));
$("calibBtn").onclick = () => st.calib.restart();
$("mirrorChk").onchange = () => { st.mirror = $("mirrorChk").checked; };
window.onkeydown = (e) => {
if (e.key === "c") st.calib.restart();
if (e.key === "m") { $("mirrorChk").checked = !$("mirrorChk").checked; st.mirror = $("mirrorChk").checked; }
};
loop(video, char, st);
} catch (err) {
stop();
if (loadingTracker) showTrackerRetry(`Failed to load the tracking model: ${err.message}`);
else status(`Failed to start: ${err.message}`);
}
}
function loop(video, char, st) {
const render = (now) => {
if (!run.on) return;
try {
const began = performance.now();
loopBody(video, char, st, now);
st.perf.record(now, performance.now() - began);
} catch (err) {
status(`Render loop error: ${err.message}`);
console.error(err);
stop();
return;
}
schedule();
};
const schedule = () => {
if (!run.on) return;
if (typeof video.requestVideoFrameCallback === "function") {
run.videoFrame = video.requestVideoFrameCallback((now) => render(now));
} else {
const tick = (now) => {
if (!run.on) return;
if (video.currentTime !== st.lastVideoTime) {
st.lastVideoTime = video.currentTime;
render(now);
} else {
run.raf = requestAnimationFrame(tick);
}
};
run.raf = requestAnimationFrame(tick);
}
};
schedule();
}
// Worker-mode observation: send the current frame (dropped if the worker is
// busy) and integrate the newest result. Results repeat across render frames
// until the worker posts a fresher one, so each is integrated exactly once,
// keyed by its capture timestamp; smoothing uses that capture ts while
// lost-face decay is keyed on when the last non-null obs ARRIVED.
function workerObserve(video, st, now) {
if (run.workerTracker.failed()) {
// A worker-side MediaPipe fault must not leave the app with a permanently
// busy frame gate. Load the normal tracker lazily and keep rendering neutral
// frames while it initializes.
run.workerTracker.close();
run.workerTracker = null;
if (!run.tracker && !run.trackerLoading) {
status("Recovering the tracking worker…");
run.trackerLoading = createTracker()
.then((tracker) => { run.tracker = tracker; })
.catch((err) => {
if (run.on) {
stop();
showTrackerRetry(`Failed to recover face tracking: ${err.message}`);
}
})
.finally(() => { run.trackerLoading = null; });
}
return null;
}
run.workerTracker.sendFrame(video, now);
const res = run.workerTracker.latest();
const obs = res?.obs ?? null;
const fresh = !!res && res.ts !== st.workerTs;
if (fresh) st.workerTs = res.ts;
if (fresh && obs) {
st.lastSeen = now;
if (st.calib.active) {
st.calib.feed(obs.blend);
} else {
const values = st.calib.apply(obs.blend);
const tSec = (res.ts ?? now) / 1000;
for (const k of SMOOTH_KEYS) st.smoothed[k] = st.emas[k].update(values[k], tSec);
for (const k of ["yaw", "pitch", "roll"]) st.head[k] = st.headEmas[k].update(obs[k], tSec);
}
} else {
const lost = now - st.lastSeen;
if (lost > CONFIG.lostFace.holdMs) {
const decay = Math.min(1, (lost - CONFIG.lostFace.holdMs) / CONFIG.lostFace.decayMs);
for (const k of SMOOTH_KEYS) st.smoothed[k] *= (1 - decay);
for (const k of ["yaw", "pitch", "roll"]) st.head[k] *= (1 - decay);
}
}
return obs;
}
function loopBody(video, char, st, now) {
let obs = null;
if (run.workerTracker) {
obs = workerObserve(video, st, now);
} else if (run.tracker) {
obs = run.tracker.detect(video, now);
if (obs) {
st.lastSeen = now;
if (st.calib.active) {
st.calib.feed(obs.blend);
} else {
const values = st.calib.apply(obs.blend);
const tSec = now / 1000;
for (const k of SMOOTH_KEYS) st.smoothed[k] = st.emas[k].update(values[k], tSec);
for (const k of ["yaw", "pitch", "roll"]) st.head[k] = st.headEmas[k].update(obs[k], tSec);
}
} else {
const lost = now - st.lastSeen;
if (lost > CONFIG.lostFace.holdMs) {
const decay = Math.min(1, (lost - CONFIG.lostFace.holdMs) / CONFIG.lostFace.decayMs);
for (const k of SMOOTH_KEYS) st.smoothed[k] *= (1 - decay);
for (const k of ["yaw", "pitch", "roll"]) st.head[k] *= (1 - decay);
}
}
}
const eyeStates = {};
for (const side of ["left", "right"]) {
const key = side === "left" ? "eyeBlinkLeft" : "eyeBlinkRight";
eyeStates[eyeKeyForUserSide(side, st.mirror)] = st.eyes[side].update(st.smoothed[key]);
}
const mouth = pickMouth(st.smoothed, CONFIG.mouth);
const economy = st.perf.useEconomy(now);
const useWarp = char.warp && $("warpChk").checked && !economy;
if (useWarp) {
const g = CONFIG.warp;
const blink = {};
for (const side of ["left", "right"]) {
const key = side === "left" ? "eyeBlinkLeft" : "eyeBlinkRight";
blink[eyeKeyForUserSide(side, st.mirror)] = st.smoothed[key] * g.blinkGain;
}
const ch = {
blinkL: blink.L, blinkR: blink.R,
smile: ((st.smoothed.mouthSmileLeft + st.smoothed.mouthSmileRight) / 2) * g.smileGain,
jaw: st.smoothed.jawOpen * g.jawGain,
// Mesh parallax reuses the canvas-shift gains for direction/normalization.
yaw: (st.head.yaw * CONFIG.head.yawGainPx / CONFIG.head.maxShiftPx) * g.headParallax,
pitch: (st.head.pitch * CONFIG.head.pitchGainPx / CONFIG.head.maxShiftPx) * g.headParallax,
roll: (st.head.roll * CONFIG.head.rollGain / CONFIG.head.maxRollDeg) * g.headParallax,
};
if (!st.calib.active) {
st.idle.apply(ch, now, Math.max(st.smoothed.eyeBlinkLeft, st.smoothed.eyeBlinkRight));
}
const frame = renderWarp(char.warp, ch);
// The mesh already rolled the face — the canvas keeps only a share.
drawScene(st.outCtx, frame, { ...st.head, roll: st.head.roll * CANVAS_ROLL_SHARE }, CONFIG.head);
} else {
drawScene(st.outCtx, composeCharacter(char, eyeStates.L, eyeStates.R, mouth), st.head, CONFIG.head);
}
if ($("fxChk").checked && !economy) {
if (!st.calib.active) st.fx.update(st.smoothed, now);
st.fx.draw(st.outCtx, now);
}
drawPreview(video, obs, st);
st.fps = 0.9 * st.fps + 0.1 * (1000 / Math.max(1, now - st.tPrev));
st.tPrev = now;
// The recording cue must survive this per-frame status overwrite (spec §9).
const rec = run.recording ? " ● REC" : "";
status((st.calib.active
? "Calibrating — face the camera straight on and keep a neutral expression"
: `${st.fps.toFixed(0)} FPS · ${st.perf.label()}${run.workerTracker ? " · worker" : ""} · ${useWarp ? "warp" : "sprite"} · ${obs ? "face:OK" : "face:LOST"}`
+ (useWarp ? "" : ` · L:${eyeStates.L} R:${eyeStates.R} mouth:${mouth}`)) + rec);
}
function drawPreview(video, obs, st) {
const ctx = st.prevCtx;
const { width: w, height: h } = ctx.canvas;
ctx.save();
ctx.scale(-1, 1); // mirror ONLY the user-facing preview
ctx.drawImage(video, -w, 0, w, h);
ctx.restore();
if (!$("vizChk").checked) return;
if (obs?.landmarks) {
ctx.fillStyle = "#50ff78";
for (const lm of obs.landmarks) ctx.fillRect((1 - lm.x) * w, lm.y * h, 2, 2);
}
ctx.font = "11px monospace";
VIZ_BARS.forEach(([label, key], i) => {
const y = h - 14 * VIZ_BARS.length - 8 + 14 * i;
ctx.fillStyle = "#fff";
ctx.fillText(label, 6, y + 9);
ctx.strokeStyle = "#666";
ctx.strokeRect(58, y, 80, 10);
ctx.fillStyle = "#50ff78";
ctx.fillRect(58, y, 80 * Math.min(1, st.smoothed[key] ?? 0), 10);
});
}
function stop() {
run.on = false;
cancelAnimationFrame(run.raf);
if (run.videoFrame && run.video?.cancelVideoFrameCallback) run.video.cancelVideoFrameCallback(run.videoFrame);
run.videoFrame = 0;
stopRecording();
run.workerTracker?.close();
run.workerTracker = null;
run.stream?.getTracks().forEach((t) => t.stop());
run.stream = null;
run.video = null;
run.perf = null;
window.onkeydown = null;
$("startBtn").textContent = "Start";
$("startBtn").disabled = $("charSelect").options.length === 0;
$("calibBtn").disabled = true;
$("recordBtn").disabled = true;
$("recordBtn").textContent = "Start recording";
status("Stopped");
}
function recordMimeType() {
return ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]
.find((type) => MediaRecorder.isTypeSupported(type));
}
function startRecording() {
if (!run.on || run.recording || !("MediaRecorder" in window) || !("captureStream" in $("output"))) return;
const mimeType = recordMimeType();
if (!mimeType) { status("This browser does not support WebM recording"); return; }
const stream = $("output").captureStream(30);
const recording = { stream, chunks: [], recorder: null };
const recorder = recording.recorder = new MediaRecorder(stream, { mimeType });
recorder.ondataavailable = (e) => { if (e.data.size) recording.chunks.push(e.data); };
recorder.onerror = () => status("An error occurred while recording");
recorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
if (recording.chunks.length) {
const blob = new Blob(recording.chunks, { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `drawface-live-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
status("Recording finished — the WebM download has started");
}
if (run.recording === recording) run.recording = null;
$("recordBtn").textContent = "Start recording";
};
run.recording = recording;
recorder.start(1000);
$("recordBtn").textContent = "Stop recording";
status("Recording — only the output canvas is saved as WebM");
}
function stopRecording() {
const recording = run.recording;
if (!recording) return;
if (recording.recorder.state !== "inactive") recording.recorder.stop();
}
$("recordBtn").onclick = () => (run.recording ? stopRecording() : startRecording());
$("startBtn").onclick = () => (run.on ? stop() : start());
$("trackerRetryBtn").onclick = () => start();
$("perfMode").onchange = () => {
const mode = $("perfMode").value;
savePerformanceMode(mode);
run.perf?.setPreference(mode);
};
restorePerformanceMode();
refreshList();
refreshCameras();