← back to Dw Photo Capture
add shared capture-pipeline.js engine (full pre-capture photoshop set, manual canvas pixel math)
fdff137d2517cfd88c4fce79e54d054fdf9ec04a · 2026-09-23 16:17:53 -0700 · Steve Abrams
ONE shared engine (window.CapturePipeline) generalizing the two existing inline pipelines
(index.html tsPipeline, batch.html applyPipeline/pipeGains) and extending them to the full
Photoshop adjustment set: exposure, contrast, highlights, shadows, temp, tint, saturation,
vibrance, hue, sharpness (capture-only unsharp mask), straighten. Manual getImageData/loop/
putImageData only -- no WebGL, no ctx.filter, no CSS filters (survives toDataURL, iOS-safe).
Also ships histogram/drawHistogram, 3 starter presets, drawSource (cover-fit + straighten
bake), and a best-effort Hardware.probe/apply layer over track.applyConstraints (feature-
detected, never throws -- the software pipeline stays the source of truth for WYSIWYG).
TK-12115 (parent TK-12090)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
Files touched
A public/js/capture-pipeline.js
Diff
commit fdff137d2517cfd88c4fce79e54d054fdf9ec04a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 16:17:53 2026 -0700
add shared capture-pipeline.js engine (full pre-capture photoshop set, manual canvas pixel math)
ONE shared engine (window.CapturePipeline) generalizing the two existing inline pipelines
(index.html tsPipeline, batch.html applyPipeline/pipeGains) and extending them to the full
Photoshop adjustment set: exposure, contrast, highlights, shadows, temp, tint, saturation,
vibrance, hue, sharpness (capture-only unsharp mask), straighten. Manual getImageData/loop/
putImageData only -- no WebGL, no ctx.filter, no CSS filters (survives toDataURL, iOS-safe).
Also ships histogram/drawHistogram, 3 starter presets, drawSource (cover-fit + straighten
bake), and a best-effort Hardware.probe/apply layer over track.applyConstraints (feature-
detected, never throws -- the software pipeline stays the source of truth for WYSIWYG).
TK-12115 (parent TK-12090)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
public/js/capture-pipeline.js | 350 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 350 insertions(+)
diff --git a/public/js/capture-pipeline.js b/public/js/capture-pipeline.js
new file mode 100644
index 0000000..68a1a25
--- /dev/null
+++ b/public/js/capture-pipeline.js
@@ -0,0 +1,350 @@
+/* ════════════════════════════════════════════════════════════════════════════════════════
+ CapturePipeline — the ONE shared pre-capture "Photoshop" engine for photo.designerwallcoverings.com
+ TK-12115 (parent TK-12090). Vanilla JS, no build step, no deps.
+
+ HARD RULES enforced by this file (do not regress):
+ - Manual canvas pixel math ONLY: getImageData → loop the Uint8ClampedArray → putImageData.
+ NO WebGL, NO ctx.filter, NO CSS filters (ctx.filter doesn't survive toDataURL() and is
+ unreliable on iOS Safari).
+ - The SAME apply() runs on the ~80ms live preview AND the full-res capture, so preview ==
+ capture (WYSIWYG). The ONLY exception: unsharp-mask (sharpness) bakes at capture ONLY
+ (opts.capture===true), never on the preview — it's too slow to run every 80ms.
+ ════════════════════════════════════════════════════════════════════════════════════════ */
+(function (global) {
+ 'use strict';
+
+ // ── pixel clamp helpers ──
+ function cl(v) { return v < 0 ? 0 : v > 255 ? 255 : v; }
+ function clampNum(v, lo, hi) {
+ v = +v;
+ if (!isFinite(v)) v = 0;
+ return v < lo ? lo : v > hi ? hi : v;
+ }
+
+ // ── the full tune shape ──
+ function defaultTune() {
+ return {
+ exposure: 0, contrast: 0, highlights: 0, shadows: 0,
+ temp: 0, tint: 0, saturation: 0, vibrance: 0,
+ hue: 0, sharpness: 0, straighten: 0
+ };
+ }
+
+ // Coerce + clamp any partial/legacy object into a full tune. Legacy keys mapped:
+ // bright (index-cam legacy) -> exposure
+ // sat (batch-cam legacy) -> saturation
+ // warm (batch-cam legacy) -> temp
+ // hue -> hue (pass-through, same key in both legacy pipelines)
+ function normalizeTune(obj) {
+ const t = defaultTune();
+ if (!obj || typeof obj !== 'object') return t;
+ const src = {};
+ for (const k in obj) if (Object.prototype.hasOwnProperty.call(obj, k)) src[k] = obj[k];
+ if (src.exposure === undefined && src.bright !== undefined) src.exposure = src.bright;
+ if (src.saturation === undefined && src.sat !== undefined) src.saturation = src.sat;
+ if (src.temp === undefined && src.warm !== undefined) src.temp = src.warm;
+
+ t.exposure = clampNum(src.exposure !== undefined ? src.exposure : 0, -100, 100);
+ t.contrast = clampNum(src.contrast !== undefined ? src.contrast : 0, -100, 100);
+ t.highlights = clampNum(src.highlights !== undefined ? src.highlights : 0, -100, 100);
+ t.shadows = clampNum(src.shadows !== undefined ? src.shadows : 0, -100, 100);
+ t.temp = clampNum(src.temp !== undefined ? src.temp : 0, -100, 100);
+ t.tint = clampNum(src.tint !== undefined ? src.tint : 0, -100, 100);
+ t.saturation = clampNum(src.saturation !== undefined ? src.saturation : 0, -100, 100);
+ t.vibrance = clampNum(src.vibrance !== undefined ? src.vibrance : 0, -100, 100);
+ t.hue = clampNum(src.hue !== undefined ? src.hue : 0, -180, 180);
+ t.sharpness = clampNum(src.sharpness !== undefined ? src.sharpness : 0, 0, 100);
+ t.straighten = clampNum(src.straighten !== undefined ? src.straighten : 0, -15, 15);
+ return t;
+ }
+
+ function loadTune(key) {
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return defaultTune();
+ const obj = JSON.parse(raw);
+ return normalizeTune(obj);
+ } catch (e) { return defaultTune(); }
+ }
+
+ function saveTune(key, tune) {
+ try { localStorage.setItem(key, JSON.stringify(tune)); } catch (e) {}
+ }
+
+ // ── shared hue-rotation 3x3 (EXACT constants — identical across every legacy pipeline) ──
+ function hueMatrix(deg) {
+ const a = (deg || 0) * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
+ return [
+ 0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, 0.072 - c * 0.072 + s * 0.928,
+ 0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.140, 0.072 - c * 0.072 - s * 0.283,
+ 0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, 0.072 + c * 0.928 + s * 0.072
+ ];
+ }
+
+ // ── temp/tint -> per-channel white-balance gains ──
+ function tempTintToGains(temp, tint) {
+ temp = temp || 0; tint = tint || 0;
+ return {
+ rGain: 1 + (temp / 100) * 0.4,
+ gGain: 1 - (tint / 100) * 0.35,
+ bGain: 1 - (temp / 100) * 0.4
+ };
+ }
+
+ // ── separable 3x3 box blur (2 passes ~= gaussian) used only by the capture-time unsharp mask ──
+ function boxBlur3(src, w, h, out) {
+ const tmp = new Float32Array(src.length);
+ // horizontal pass
+ for (let y = 0; y < h; y++) {
+ const row = y * w;
+ for (let x = 0; x < w; x++) {
+ const x0 = x > 0 ? x - 1 : 0, x2 = x < w - 1 ? x + 1 : w - 1;
+ const i1 = (row + x) * 4, i0 = (row + x0) * 4, i2 = (row + x2) * 4;
+ tmp[i1] = (src[i0] + src[i1] + src[i2]) / 3;
+ tmp[i1 + 1] = (src[i0 + 1] + src[i1 + 1] + src[i2 + 1]) / 3;
+ tmp[i1 + 2] = (src[i0 + 2] + src[i1 + 2] + src[i2 + 2]) / 3;
+ }
+ }
+ // vertical pass
+ for (let y = 0; y < h; y++) {
+ const y0 = y > 0 ? y - 1 : 0, y2 = y < h - 1 ? y + 1 : h - 1;
+ for (let x = 0; x < w; x++) {
+ const i1 = (y * w + x) * 4, i0 = (y0 * w + x) * 4, i2 = (y2 * w + x) * 4;
+ out[i1] = (tmp[i0] + tmp[i1] + tmp[i2]) / 3;
+ out[i1 + 1] = (tmp[i0 + 1] + tmp[i1 + 1] + tmp[i2 + 1]) / 3;
+ out[i1 + 2] = (tmp[i0 + 2] + tmp[i1 + 2] + tmp[i2 + 2]) / 3;
+ }
+ }
+ }
+
+ // Unsharp mask — CAPTURE ONLY (called from apply() when opts.capture && tune.sharpness>0).
+ // out = orig + (sharpness/100)*1.2*(orig - blur), per channel, O(n).
+ function unsharpMask(ctx, w, h, sharpness) {
+ const img = ctx.getImageData(0, 0, w, h);
+ const d = img.data;
+ const blur = new Float32Array(d.length);
+ boxBlur3(d, w, h, blur);
+ const amt = (sharpness / 100) * 1.2;
+ for (let i = 0; i < d.length; i += 4) {
+ d[i] = cl(d[i] + amt * (d[i] - blur[i]));
+ d[i + 1] = cl(d[i + 1] + amt * (d[i + 1] - blur[i + 1]));
+ d[i + 2] = cl(d[i + 2] + amt * (d[i + 2] - blur[i + 2]));
+ }
+ ctx.putImageData(img, 0, 0);
+ }
+
+ // ── THE BAKE — one getImageData/loop/putImageData. See order below (STEP numbers match the brief). ──
+ function apply(ctx, w, h, tune, opts) {
+ opts = opts || {};
+ tune = tune || defaultTune();
+ const capture = !!opts.capture;
+ const pg = opts.preGain || { rGain: 1, gGain: 1, bGain: 1 };
+ const exMul = opts.exposureMul || 1;
+
+ const tt = tempTintToGains(tune.temp, tune.tint);
+ const expSlider = Math.pow(2, (tune.exposure || 0) / 50); // ±50 -> ±1EV, ±100 -> ±2EV
+ const GR = pg.rGain * tt.rGain * exMul * expSlider;
+ const GG = pg.gGain * tt.gGain * exMul * expSlider;
+ const GB = pg.bGain * tt.bGain * exMul * expSlider;
+
+ const shAmt = (tune.shadows || 0) / 100 * 60;
+ const hiAmt = (tune.highlights || 0) / 100 * 60;
+ const cf = 1 + (tune.contrast || 0) / 100;
+ const satF = 1 + (tune.saturation || 0) / 100;
+ const vibAmt = (tune.vibrance || 0) / 100;
+ const hueOn = !!(tune.hue);
+ const m = hueOn ? hueMatrix(tune.hue) : null;
+
+ const img = ctx.getImageData(0, 0, w, h);
+ const d = img.data;
+ for (let i = 0; i < d.length; i += 4) {
+ // 1. WB/temp/exposure gains
+ let r = cl(d[i] * GR), g = cl(d[i + 1] * GG), b = cl(d[i + 2] * GB);
+
+ // 2. luma
+ let L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+ // 3. shadows/highlights (luma-masked)
+ let ws = 1 - L / 128; ws = ws < 0 ? 0 : ws > 1 ? 1 : ws;
+ let wh = (L - 128) / 127; wh = wh < 0 ? 0 : wh > 1 ? 1 : wh;
+ const add = shAmt * ws + hiAmt * wh;
+ r = cl(r + add); g = cl(g + add); b = cl(b + add);
+
+ // 4. contrast (pivot 128)
+ r = cl((r - 128) * cf + 128); g = cl((g - 128) * cf + 128); b = cl((b - 128) * cf + 128);
+
+ // 5. saturation (luma-preserving; recompute L post-contrast)
+ L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ r = cl(L + (r - L) * satF); g = cl(L + (g - L) * satF); b = cl(L + (b - L) * satF);
+
+ // 6. vibrance (neutral-protected — multiplies (channel-L) so true neutrals stay neutral,
+ // and muted colours get boosted more than already-saturated ones)
+ if (vibAmt) {
+ L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
+ const sat01 = (mx - mn) / 255;
+ const vf = 1 + vibAmt * (1 - sat01);
+ r = cl(L + (r - L) * vf); g = cl(L + (g - L) * vf); b = cl(L + (b - L) * vf);
+ }
+
+ // 7. hue
+ if (hueOn) {
+ const nr = cl(r * m[0] + g * m[1] + b * m[2]);
+ const ng = cl(r * m[3] + g * m[4] + b * m[5]);
+ const nb = cl(r * m[6] + g * m[7] + b * m[8]);
+ r = nr; g = ng; b = nb;
+ }
+
+ // 8. write back
+ d[i] = r; d[i + 1] = g; d[i + 2] = b;
+ }
+ ctx.putImageData(img, 0, 0);
+
+ // Sharpness (unsharp mask) — CAPTURE ONLY, never on the 80ms preview.
+ if (capture && tune.sharpness > 0) {
+ unsharpMask(ctx, w, h, tune.sharpness);
+ }
+ }
+
+ // ── luma histogram + clip stats, for the on-preview histogram panel ──
+ function histogram(ctx, w, h) {
+ const img = ctx.getImageData(0, 0, w, h);
+ const d = img.data;
+ const lum = new Int32Array(256);
+ let n = 0, hiClip = 0, loClip = 0;
+ for (let i = 0; i < d.length; i += 4) {
+ let L = Math.round(0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]);
+ L = L < 0 ? 0 : L > 255 ? 255 : L;
+ lum[L]++;
+ if (L >= 253) hiClip++;
+ if (L <= 2) loClip++;
+ n++;
+ }
+ return { lum: lum, clipHigh: n ? hiClip / n : 0, clipLow: n ? loClip / n : 0 };
+ }
+
+ // ── render the histogram into a small 2d canvas ctx (the panel's <canvas>) ──
+ function drawHistogram(hctx, hist) {
+ const c = hctx.canvas, w = c.width, h = c.height;
+ hctx.clearRect(0, 0, w, h);
+ hctx.fillStyle = '#1b1710';
+ hctx.fillRect(0, 0, w, h);
+ if (!hist || !hist.lum) return;
+ let max = 1;
+ for (let i = 0; i < 256; i++) if (hist.lum[i] > max) max = hist.lum[i];
+ const bw = w / 256;
+ hctx.fillStyle = '#c8a24a'; // matches --gold
+ for (let i = 0; i < 256; i++) {
+ const v = hist.lum[i];
+ if (!v) continue;
+ const bh = Math.max(1, (v / max) * h);
+ hctx.fillRect(i * bw, h - bh, Math.max(1, bw), bh);
+ }
+ // clip markers
+ if (hist.clipLow > 0.02) { hctx.fillStyle = 'rgba(224,72,58,.85)'; hctx.fillRect(0, 0, 2, h); }
+ if (hist.clipHigh > 0.02) { hctx.fillStyle = 'rgba(224,72,58,.85)'; hctx.fillRect(w - 2, 0, 2, h); }
+ }
+
+ // ── presets: sensible starting points, shallow-merged over defaults ──
+ const PRESETS = {
+ 'showroom': { exposure: 4, contrast: 8, temp: -6, saturation: 6, vibrance: 10, sharpness: 20 },
+ 'daylight': { exposure: 0, contrast: 4, temp: 0, tint: 0, saturation: 4, sharpness: 15 },
+ 'label-closeup': { exposure: 6, contrast: 22, shadows: 15, highlights: -10, saturation: -10, sharpness: 45 }
+ };
+
+ function applyPreset(tune, name) {
+ const p = PRESETS[name];
+ if (!p) return normalizeTune(tune);
+ return normalizeTune(Object.assign({}, defaultTune(), p));
+ }
+
+ // ── draw video/image into ctx, cover-fit, with a straighten rotation baked at draw time.
+ // Small overscale keeps corners covered at the small ±15° straighten range so rotating
+ // never reveals transparent/blank edges. ──
+ function drawSource(ctx, source, w, h, straightenDeg) {
+ const deg = straightenDeg || 0;
+ const sw = source.videoWidth || source.naturalWidth || source.width;
+ const sh = source.videoHeight || source.naturalHeight || source.height;
+ if (!sw || !sh) return;
+ ctx.save();
+ ctx.clearRect(0, 0, w, h);
+ if (deg) {
+ ctx.translate(w / 2, h / 2);
+ ctx.rotate(deg * Math.PI / 180);
+ ctx.translate(-w / 2, -h / 2);
+ }
+ let scale = Math.max(w / sw, h / sh); // cover-fit
+ if (deg) {
+ const rad = Math.abs(deg) * Math.PI / 180;
+ const c = Math.cos(rad), s = Math.sin(rad);
+ // over-scale so the rotated cover-fit content still fully covers w×h at the edges
+ const rotScale = Math.max(c + (h / w) * s, c + (w / h) * s);
+ scale *= rotScale;
+ }
+ const dw = sw * scale, dh = sh * scale;
+ const dx = (w - dw) / 2, dy = (h - dh) / 2;
+ ctx.drawImage(source, dx, dy, dw, dh);
+ ctx.restore();
+ }
+
+ // ── STEP 3: best-effort hardware assist on top of the software pipeline (never authoritative,
+ // never throws — an unsupported capability just falls through to the software bake). ──
+ const Hardware = {
+ probe: function (track) {
+ const out = {};
+ try {
+ const caps = (track && track.getCapabilities) ? track.getCapabilities() : {};
+ ['exposureCompensation', 'exposureMode', 'whiteBalanceMode', 'zoom', 'focusDistance', 'focusMode']
+ .forEach(function (k) { if (caps[k] !== undefined) out[k] = caps[k]; });
+ } catch (e) {}
+ return out;
+ },
+ apply: function (track, tune) {
+ if (!track || typeof track.applyConstraints !== 'function') return;
+ tune = tune || defaultTune();
+ let caps = {};
+ try { caps = track.getCapabilities ? track.getCapabilities() : {}; } catch (e) {}
+
+ // exposure compensation, feature-detected range
+ try {
+ if (caps.exposureCompensation && typeof tune.exposure === 'number' && tune.exposure) {
+ const range = caps.exposureCompensation;
+ const min = (range.min != null) ? range.min : -2, max = (range.max != null) ? range.max : 2;
+ const bound = Math.max(Math.abs(min), Math.abs(max));
+ let ev = (tune.exposure / 100) * bound;
+ ev = ev < min ? min : ev > max ? max : ev;
+ const adv = { exposureCompensation: ev };
+ if (caps.exposureMode && caps.exposureMode.indexOf && caps.exposureMode.indexOf('manual') > -1) {
+ adv.exposureMode = 'manual';
+ }
+ track.applyConstraints({ advanced: [adv] });
+ }
+ } catch (e) { /* unsupported -> software pipeline covers it */ }
+
+ // white balance: lock to manual so the camera's auto-WB doesn't fight the software temp/tint bake
+ try {
+ if (caps.whiteBalanceMode && caps.whiteBalanceMode.indexOf && caps.whiteBalanceMode.indexOf('manual') > -1
+ && (tune.temp || tune.tint)) {
+ track.applyConstraints({ advanced: [{ whiteBalanceMode: 'manual' }] });
+ }
+ } catch (e) { /* unsupported -> software pipeline covers it */ }
+ }
+ };
+
+ global.CapturePipeline = {
+ defaultTune: defaultTune,
+ normalizeTune: normalizeTune,
+ loadTune: loadTune,
+ saveTune: saveTune,
+ hueMatrix: hueMatrix,
+ tempTintToGains: tempTintToGains,
+ apply: apply,
+ histogram: histogram,
+ drawHistogram: drawHistogram,
+ PRESETS: PRESETS,
+ applyPreset: applyPreset,
+ drawSource: drawSource,
+ Hardware: Hardware
+ };
+
+})(window);
← 94c8d60 server.js: bounded /js/ static route to serve the shared cap
·
back to Dw Photo Capture
·
index.html two-shot: adopt shared capture-pipeline + full ad af5702a →