[object Object]

← 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

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 →