diff --git a/index.html b/index.html
index c25ba54..31b4e0c 100644
--- a/index.html
+++ b/index.html
@@ -252,6 +252,12 @@
+
@@ -269,6 +275,11 @@
edit click a shape to select · drag a vertex or the shape itself ·
Shift-click an edge inserts a vertex · Alt-click a vertex
removes it · Del deletes the layer
+ opacity 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. Copy previous takes the last enabled frame that actually has a drawing
— skipping empty ones, so it behaves the same before and after you thin the
strip out. Frames carrying a drawing are marked ▣.
diff --git a/js/app.js b/js/app.js
index 8338a3b..79e9a0e 100644
--- a/js/app.js
+++ b/js/app.js
@@ -586,23 +586,31 @@ function compositeRender(canvas, f, 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);
+ blitIndexed(g, renderFrame(f, mode === 'oval+photo' ? 'oval' : (showPhoto ? 'off' : mode)),
+ zoom, showPhoto);
+}
+
+// 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 br = parseInt(bg.slice(0, 2), 16), bgn = parseInt(bg.slice(2, 4), 16), bb = parseInt(bg.slice(4, 6), 16);
+ const d = img.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 = img.width; tmp.height = img.height;
+ tmp.getContext('2d').putImageData(img, 0, 0);
+ g.drawImage(tmp, 0, 0);
}
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);
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
// from inside commit(), which renders immediately afterwards anyway.
@@ -1305,6 +1317,11 @@ el('paintTool').addEventListener('change', () => {
paint.render();
});
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 = () => {
if (!state.dense) return;
state.cels.set(celFrame(), newCel());
@@ -1362,6 +1379,32 @@ function drawPaint() {
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() {
if (!state.dense) return;
const kept = keptSorted(), cf = celFrame();