This commit is contained in:
Your Name 2026-09-25 12:55:01 -04:00
parent b68deb838a
commit 082d8561d2
2 changed files with 71 additions and 17 deletions

View file

@ -252,6 +252,12 @@
<option value="edit">edit</option> <option value="edit">edit</option>
</select> </select>
<select id="paintColor" title="palette colour — indices only, never an RGB value"></select> <select id="paintColor" title="palette colour — indices only, never an RGB value"></select>
<label style="display:flex;align-items:center;gap:6px;color:var(--dim)"
title="dim the drawing to trace the frame underneath — editing aid only, never exported">
opacity
<input type="range" id="paintOpacity" min="10" max="100" value="100" style="width:96px">
<output id="paintOpacityv" style="color:var(--accent);min-width:34px"></output>
</label>
<button id="btn-celprev">Copy previous</button> <button id="btn-celprev">Copy previous</button>
<button id="btn-celclear">Clear cel</button> <button id="btn-celclear">Clear cel</button>
<span class="legend" id="paintframe" style="margin:0"></span> <span class="legend" id="paintframe" style="margin:0"></span>
@ -269,6 +275,11 @@
<b>edit</b> click a shape to select · drag a vertex or the shape itself · <b>edit</b> click a shape to select · drag a vertex or the shape itself ·
<kbd>Shift</kbd>-click an edge inserts a vertex · <kbd>Alt</kbd>-click a vertex <kbd>Shift</kbd>-click an edge inserts a vertex · <kbd>Alt</kbd>-click a vertex
removes it · <kbd>Del</kbd> deletes the layer<br> removes it · <kbd>Del</kbd> deletes the layer<br>
<b>opacity</b> dims the drawing over the registered source frame so you can
trace it. It is an editing aid only — never rasterised, never exported, and
the flat render is untouched. At 100% the view is exactly the normal
composite; below it the placeholder oval is dropped, since it covers the
face you turned the slider down to see.<br>
<b>Copy previous</b> takes the last enabled frame that actually has a drawing <b>Copy previous</b> takes the last enabled frame that actually has a drawing
— skipping empty ones, so it behaves the same before and after you thin the — skipping empty ones, so it behaves the same before and after you thin the
strip out. Frames carrying a drawing are marked <b style="color:#c084fc">▣</b>.<br> strip out. Frames carrying a drawing are marked <b style="color:#c084fc">▣</b>.<br>

View file

@ -586,23 +586,31 @@ function compositeRender(canvas, f, zoom) {
mode === 'photo-dim' ? 0.34 : 1); mode === 'photo-dim' ? 0.34 : 1);
} }
const r = renderFrame(f, mode === 'oval+photo' ? 'oval' : (showPhoto ? 'off' : mode)); blitIndexed(g, renderFrame(f, mode === 'oval+photo' ? 'oval' : (showPhoto ? 'off' : mode)),
const img2 = r.toImageData(PALETTE.map((p) => p.hex), zoom); zoom, showPhoto);
if (showPhoto) { }
// Keep the photo visible wherever the indexed layer is background.
// Put an indexed raster onto a 2D context. With `keyBg`, background pixels go
// transparent instead of opaque, so whatever was painted underneath - a
// registered photograph, usually - stays visible through them.
//
// Split out of compositeRender so the paint canvas can draw the same pixels at
// a reduced globalAlpha. Nothing else is different about that path, which is
// what keeps the editing view honest: you are dimming the render, not looking
// at a second renderer that might disagree with it.
function blitIndexed(g, raster, zoom, keyBg) {
const img = raster.toImageData(PALETTE.map((p) => p.hex), zoom);
if (!keyBg) { g.putImageData(img, 0, 0); return; }
const bg = PALETTE[IDX.bg].hex.replace('#', ''); 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 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; const d = img.data;
for (let i = 0; i < d.length; i += 4) { 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; if (d[i] === br && d[i + 1] === bgn && d[i + 2] === bb) d[i + 3] = 0;
} }
const tmp = document.createElement('canvas'); const tmp = document.createElement('canvas');
tmp.width = img2.width; tmp.height = img2.height; tmp.width = img.width; tmp.height = img.height;
tmp.getContext('2d').putImageData(img2, 0, 0); tmp.getContext('2d').putImageData(img, 0, 0);
g.drawImage(tmp, 0, 0); g.drawImage(tmp, 0, 0);
} else {
g.putImageData(img2, 0, 0);
}
} }
const keptSorted = () => [...state.keep].sort((a, b) => a - b); const keptSorted = () => [...state.keep].sort((a, b) => a - b);
@ -1292,7 +1300,11 @@ const paint = new PaintUI({
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, cv.width, cv.height); g.fillStyle = '#0d0f16'; g.fillRect(0, 0, cv.width, cv.height);
return; return;
} }
compositeRender(cv, state.frame, z); const op = +el('paintOpacity').value / 100;
// At 100% this is exactly the normal composite, so the slider changes
// nothing at all until you reach for it.
if (op >= 1) { compositeRender(cv, state.frame, z); return; }
paintGhost(cv, z, op);
}, },
// paintLabels, not drawPaint: drawPaint re-renders the canvas, and this runs // paintLabels, not drawPaint: drawPaint re-renders the canvas, and this runs
// from inside commit(), which renders immediately afterwards anyway. // from inside commit(), which renders immediately afterwards anyway.
@ -1305,6 +1317,11 @@ el('paintTool').addEventListener('change', () => {
paint.render(); paint.render();
}); });
el('paintColor').addEventListener('change', () => { paint.color = +el('paintColor').value; }); el('paintColor').addEventListener('change', () => { paint.color = +el('paintColor').value; });
el('paintOpacity').addEventListener('input', () => {
el('paintOpacityv').textContent = `${el('paintOpacity').value}%`;
paint.render();
});
el('paintOpacityv').textContent = `${el('paintOpacity').value}%`;
el('btn-celclear').onclick = () => { el('btn-celclear').onclick = () => {
if (!state.dense) return; if (!state.dense) return;
state.cels.set(celFrame(), newCel()); state.cels.set(celFrame(), newCel());
@ -1362,6 +1379,32 @@ function drawPaint() {
paint.render(); paint.render();
} }
// The drawing dimmed over the source frame, for tracing.
//
// An EDITING AID ONLY - opacity is never written to the raster, never exported,
// and the flat render is untouched. Nothing here may reach the output: a
// translucent fill is the one thing this format cannot express, so if it ever
// leaked into the rasteriser it would have to be flattened against a background
// and would silently become a colour that is not in the ramp.
//
// The plate oval is deliberately not drawn. It is a stand-in for art that does
// not exist yet, and it covers the performer's face in flat skin - which is
// exactly the part of the frame you turned the opacity down to look at.
function paintGhost(cv, z, op) {
cv.width = RW * z; cv.height = RH * z;
const g = cv.getContext('2d');
g.fillStyle = PALETTE[IDX.bg].hex;
g.fillRect(0, 0, cv.width, cv.height);
const f = state.frame, pf = plateIndex(f);
const img = state.images[pf];
if (img) drawRegistered(g, img, state.stab.transforms[pf], state.xform, z, 1);
g.globalAlpha = op;
blitIndexed(g, renderFrame(f, 'off'), z, !!img);
g.globalAlpha = 1;
}
function paintLabels() { function paintLabels() {
if (!state.dense) return; if (!state.dense) return;
const kept = keptSorted(), cf = celFrame(); const kept = keptSorted(), cf = celFrame();