Paint: vector background cels on the kept frames
A sketch, and labelled as one. It exists to test whether the aesthetic holds when a human draws the background rather than the tracker deriving it, and it is meant to be replaced by a real paint surface with onion skin and undo. Kept to one dependency-free module so throwing it away is a delete, not surgery. Cels are drawn on the frames that get their own drawing and hold until the next one - the same rule the plate follows, and literally the same lookup, so the two cannot disagree about what is on screen. Editing always targets the cel you can see, so you can scrub anywhere and keep drawing. Pen places vertices and closes on the first one. Edit drags vertices or whole shapes, shift-click inserts, alt-click removes. Layers stack front-at-top with per-layer colour, show/hide and reorder. Drag a strip thumbnail onto the canvas to seed this cel from that one, every layer, as a deep copy - sharing the point arrays would make two cels silently edit each other. Two rules are enforced rather than left to discipline: Colours are PALETTE INDICES, never RGB. Sampling colour from the source is the one move docs/design.md calls irrecoverable, and a paint tool is exactly where that discipline would leak, so the picker cannot express a colour outside the ramp. Vertices snap to the 320x200 grid. On a hard-edged indexed rasteriser a shape nudged by 0.4px moves an edge by a whole pixel or not at all depending on where it happens to land, so sub-pixel vertices shimmer instead of holding still. Drawings autosave to localStorage per take name. They are the only thing here a person made by hand; everything else regenerates. Not in the .take export yet. Also adds serve.py, a no-store dev server. python3 -m http.server sends Last-Modified and browsers cache ES modules on it hard enough that a reload serves a stale app.js against a fresh index.html: the new knobs appear, nothing wires them, no error fires, and it reads as "your feature does not work". That cost real time this session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
03df81fa0b
commit
9d516a03ac
5 changed files with 558 additions and 3 deletions
102
js/app.js
102
js/app.js
|
|
@ -11,6 +11,7 @@ import { extractTeeth } from './interior.js';
|
|||
import { applySim, offsetRing } from './mathutil.js';
|
||||
import { writeTake } from './take.js';
|
||||
import { synthDense } from './synth.js';
|
||||
import { PaintUI, drawCel, cloneCel, newCel } from './paint.js';
|
||||
|
||||
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ const state = {
|
|||
teeth: null, // resolved per-frame {show, t} after knobs
|
||||
eyes: null, // resolved per-frame lid rings, shut flags, iris discs
|
||||
brows: null, // resolved per-frame brow rings after quantised raise
|
||||
cels: new Map(), // kept frame -> hand-painted background layers
|
||||
eyeSig: null, // raw eye measurement, kept for the gaze readout
|
||||
};
|
||||
|
||||
|
|
@ -510,7 +512,14 @@ function renderFrame(f, mode = plateMode()) {
|
|||
PALETTE.map((p) => p.hex));
|
||||
} else {
|
||||
r.clear(IDX.bg);
|
||||
if (mode === 'oval' || mode === 'oval+photo') r.fillPoly(state.plates[pf], IDX.base);
|
||||
}
|
||||
// Painted cels sit BEHIND the face and hold on the same frames the plate
|
||||
// does - pf is already "the most recent kept frame at or before f", which is
|
||||
// exactly the rule the user draws against: a cel holds until the next frame
|
||||
// that has its own drawing.
|
||||
drawCel(r, state.cels.get(pf), (i) => i);
|
||||
if (mode !== 'posterize' && (mode === 'oval' || mode === 'oval+photo')) {
|
||||
r.fillPoly(state.plates[pf], IDX.base);
|
||||
}
|
||||
|
||||
// Eyes run on the CLOCK, not on the mouth lead. The lead is a lip-sync
|
||||
|
|
@ -641,6 +650,7 @@ function drawAll() {
|
|||
drawStrip();
|
||||
drawWorksheet();
|
||||
drawReadout();
|
||||
drawPaint();
|
||||
}
|
||||
|
||||
function drawReadout() {
|
||||
|
|
@ -885,6 +895,8 @@ function drawStrip() {
|
|||
const tag = document.createElement('span');
|
||||
tag.textContent = f;
|
||||
cell.append(cv, tag);
|
||||
cell.draggable = true;
|
||||
cell.ondragstart = (ev) => ev.dataTransfer.setData('text/plain', String(f));
|
||||
cell.onclick = (ev) => {
|
||||
seekTo(f);
|
||||
if (ev.shiftKey) toggle(f);
|
||||
|
|
@ -1056,6 +1068,7 @@ async function runFrames() {
|
|||
el('scrub').max = dense.length - 1;
|
||||
state.frame = 0;
|
||||
labelExposure();
|
||||
loadCels();
|
||||
rebuild(true);
|
||||
const dur = (dense.length / state.fps).toFixed(2);
|
||||
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
|
||||
|
|
@ -1078,6 +1091,7 @@ function runSynthetic() {
|
|||
el('scrub').max = 71;
|
||||
state.frame = 0;
|
||||
labelExposure();
|
||||
loadCels();
|
||||
rebuild(true);
|
||||
status('synthetic — exercises everything below detection', 'ok');
|
||||
}
|
||||
|
|
@ -1254,6 +1268,92 @@ PALETTE.forEach((p) => {
|
|||
el('palette').append(sw);
|
||||
});
|
||||
|
||||
/* ---------- paint ---------- */
|
||||
|
||||
// The cel being edited is the one ON SCREEN, which is the most recent kept
|
||||
// frame at or before the playhead. You can scrub anywhere and keep drawing on
|
||||
// the cel you can see, rather than having to land exactly on a kept frame.
|
||||
const celFrame = () => (state.dense ? plateIndex(state.frame) : 0);
|
||||
|
||||
const paint = new PaintUI({
|
||||
canvas: el('cv-paint'), list: el('paintlayers'), info: el('paintinfo'),
|
||||
zoom: 3, RW, RH, palette: PALETTE,
|
||||
getCel: () => (state.dense ? (state.cels.get(celFrame()) || []) : []),
|
||||
setCel: (cel) => { if (state.dense) state.cels.set(celFrame(), cel); },
|
||||
celAt: (f) => (state.dense ? state.cels.get(heldFrame(keptSorted(), f)) : null),
|
||||
backing: (cv, z) => {
|
||||
if (!state.dense) {
|
||||
cv.width = RW * z; cv.height = RH * z;
|
||||
const g = cv.getContext('2d');
|
||||
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, cv.width, cv.height);
|
||||
return;
|
||||
}
|
||||
compositeRender(cv, state.frame, z);
|
||||
},
|
||||
onChange: () => { saveCels(); drawPanes(); drawStrip(); drawWorksheet(); },
|
||||
});
|
||||
|
||||
el('paintTool').addEventListener('change', () => {
|
||||
paint.tool = el('paintTool').value;
|
||||
paint.draft = null;
|
||||
paint.render();
|
||||
});
|
||||
el('paintColor').addEventListener('change', () => { paint.color = +el('paintColor').value; });
|
||||
el('btn-celclear').onclick = () => {
|
||||
if (!state.dense) return;
|
||||
state.cels.set(celFrame(), newCel());
|
||||
paint.sel = -1;
|
||||
paint.commit();
|
||||
};
|
||||
el('btn-celprev').onclick = () => {
|
||||
// Copy the previous kept frame's finished drawing onto this one. The same
|
||||
// thing the drag-and-drop does, for the case you reach for constantly.
|
||||
if (!state.dense) return;
|
||||
const kept = keptSorted(), here = celFrame();
|
||||
const prev = kept.filter((k) => k < here).pop();
|
||||
if (prev === undefined) { paint.say('no earlier drawing to copy'); return; }
|
||||
const src = state.cels.get(prev);
|
||||
if (!src || !src.length) { paint.say(`f${prev} has nothing to copy`); return; }
|
||||
state.cels.set(here, cloneCel(src));
|
||||
paint.sel = -1;
|
||||
paint.commit();
|
||||
paint.say(`copied f${prev} — ${src.length} layers`);
|
||||
};
|
||||
|
||||
PALETTE.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = i; o.textContent = p.name;
|
||||
el('paintColor').append(o);
|
||||
});
|
||||
el('paintColor').value = 1;
|
||||
paint.color = 1;
|
||||
|
||||
// Drawings are the only thing here a person made by hand, so losing them to a
|
||||
// reload would be the worst failure in the tool. Everything else regenerates.
|
||||
const celKey = () => `arthur.cels.${el('takename').value || 'line_01'}`;
|
||||
function saveCels() {
|
||||
try {
|
||||
localStorage.setItem(celKey(), JSON.stringify([...state.cels]));
|
||||
} catch { /* private window, quota - not worth failing a brush stroke over */ }
|
||||
}
|
||||
function loadCels() {
|
||||
state.cels = new Map();
|
||||
try {
|
||||
const raw = localStorage.getItem(celKey());
|
||||
if (raw) state.cels = new Map(JSON.parse(raw).map(([k, v]) => [+k, v]));
|
||||
} catch { /* corrupt or absent: start empty */ }
|
||||
}
|
||||
|
||||
function drawPaint() {
|
||||
if (!state.dense) { paint.render(); return; }
|
||||
const kept = keptSorted(), cf = celFrame();
|
||||
const until = (kept[kept.indexOf(cf) + 1] ?? state.dense.length) - 1;
|
||||
el('paintframe').textContent =
|
||||
`drawing cel f${cf}` + (until > cf ? ` — holds to f${until}` : '') +
|
||||
(state.frame !== cf ? ` (playhead f${state.frame})` : '');
|
||||
paint.render();
|
||||
}
|
||||
|
||||
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
|
||||
// and deep-linked. Detection needs WebGL; the synthetic path does not.
|
||||
window.addEventListener('error', (e) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue