← 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);