← back to Dw Photo Capture
public/js/capture-pipeline.js
351 lines
/* ════════════════════════════════════════════════════════════════════════════════════════
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);