← back to Dw Photo Capture

public/js/adjust-panel.js

186 lines

/*
 * adjust-panel.js — the pre-shot "Photoshop" panel for the simple-* capture pages (TK-12162).
 *
 * All 11 CapturePipeline adjustments as sliders in a bottom sheet, a live adjusted preview drawn
 * over the camera <video>, and a bake() that applies the SAME tune to the captured photo — so what
 * you set before the shot is what gets saved. Requires /js/capture-pipeline.js loaded first.
 *
 *   const panel = AdjustPanel.create({
 *     key,              // localStorage key for the tune (ignored when getTune is given)
 *     getTune,          // optional: page already owns a tune object (Pro Booth) — panel edits it in place
 *     onChange,         // optional: called after any slider move / reset
 *     toggleParent,     // optional element to put the "Adjust" button in (default: floating top-left)
 *     toggleClass, toggleText,
 *     showAutoSave      // default true — the "Auto-save after label read" switch
 *   });
 *   panel.attachPreview(videoEl)          // adjusted canvas drawn over the video (hidden when neutral)
 *   panel.bake(source, maxEdge, {mirror}) // -> JPEG dataUrl (0.92), full tune incl. sharpness
 *   panel.bakeDataUrl(url, maxEdge)       // -> Promise<dataUrl>; for the native full-res photo
 *   panel.autoSave()                      // -> boolean
 *   panel.sync()                          // re-read the tune into the sliders (after an outside reset)
 */
(function () {
  var CP = window.CapturePipeline;
  var AUTO_KEY = 'dwSimpleAutoSave';
  var CONTROLS = [
    ['exposure', 'Exposure', -100, 100], ['contrast', 'Contrast', -100, 100],
    ['highlights', 'Highlights', -100, 100], ['shadows', 'Shadows', -100, 100],
    ['temp', 'Warmth', -100, 100], ['tint', 'Tint', -100, 100],
    ['saturation', 'Saturation', -100, 100], ['vibrance', 'Vibrance', -100, 100],
    ['hue', 'Hue', -180, 180], ['sharpness', 'Sharpness', 0, 100], ['straighten', 'Straighten', -15, 15]
  ];
  var CSS =
    '.ap-sheet{position:fixed;left:0;right:0;bottom:0;z-index:9000;max-height:46vh;overflow:auto;' +
    'background:rgba(16,14,10,.94);color:#f2ede1;border-top:1px solid #3a3428;border-radius:18px 18px 0 0;' +
    'padding:10px 16px calc(14px + env(safe-area-inset-bottom));font:14px/1.3 -apple-system,system-ui,sans-serif;' +
    'transform:translateY(105%);transition:transform .22s ease}' +
    '.ap-sheet.open{transform:none}' +
    '.ap-head{display:flex;align-items:center;gap:10px;padding:4px 0 8px;position:sticky;top:-10px;background:inherit}' +
    '.ap-head b{flex:1;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:#c8a24a}' +
    '.ap-head button,.ap-toggle-float{border:1px solid #c8a24a;color:#c8a24a;background:rgba(0,0,0,.55);' +
    'border-radius:999px;padding:8px 14px;font:600 13px/1 -apple-system,system-ui,sans-serif;cursor:pointer}' +
    '.ap-row{display:grid;grid-template-columns:92px 1fr 40px;align-items:center;gap:10px;padding:0}' +
    '.ap-row span{font-size:12px;color:#b3a98f}.ap-row output{text-align:right;color:#e2bd66;font-variant-numeric:tabular-nums}' +
    '.ap-row input{width:100%;height:28px;accent-color:#c8a24a}' +
    // host pages style bare button/input globally (Wizard: 70px-tall buttons) — keep the sheet immune
    '.ap-sheet button{min-width:0!important;min-height:0!important;width:auto;flex:0 0 auto;box-shadow:none;' +
    'font:600 13px/1 -apple-system,system-ui,sans-serif!important;padding:8px 14px!important;border-radius:999px!important}' +
    '.ap-toggle,.ap-toggle-float{white-space:nowrap}' +
    '.ap-auto{display:flex;align-items:center;gap:10px;padding:10px 0 2px;color:#b3a98f;font-size:13px}' +
    '.ap-auto input{width:20px;height:20px;accent-color:#c8a24a}' +
    '.ap-toggle-float{position:fixed;z-index:8999;left:12px;top:calc(env(safe-area-inset-top,0px) + 14px)}' +
    '.ap-toggle.on,.ap-toggle-float.on{background:#c8a24a;color:#171308}' +
    '.ap-preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;display:none}';

  function isNeutral(t) {
    for (var i = 0; i < CONTROLS.length; i++) if (t[CONTROLS[i][0]]) return false;
    return true;
  }
  function readAuto() { try { var v = localStorage.getItem(AUTO_KEY); return v === null ? true : v === '1'; } catch (e) { return true; } }
  function writeAuto(on) { try { localStorage.setItem(AUTO_KEY, on ? '1' : '0'); } catch (e) {} }

  function create(opts) {
    opts = opts || {};
    var ownTune = opts.getTune ? null : CP.loadTune(opts.key || 'dwSimpleTune');
    var getTune = opts.getTune || function () { return ownTune; };
    var inputs = {}, outs = {};

    if (!document.getElementById('ap-style')) {
      var st = document.createElement('style'); st.id = 'ap-style'; st.textContent = CSS; document.head.appendChild(st);
    }
    var sheet = document.createElement('div'); sheet.className = 'ap-sheet';
    var head = document.createElement('div'); head.className = 'ap-head';
    head.innerHTML = '<b>Adjust before the shot</b>';
    var resetB = document.createElement('button'); resetB.type = 'button'; resetB.textContent = '↺ Reset';
    var doneB = document.createElement('button'); doneB.type = 'button'; doneB.textContent = 'Done';
    head.appendChild(resetB); head.appendChild(doneB); sheet.appendChild(head);

    CONTROLS.forEach(function (c) {
      var row = document.createElement('label'); row.className = 'ap-row';
      var name = document.createElement('span'); name.textContent = c[1];
      var inp = document.createElement('input'); inp.type = 'range'; inp.min = c[2]; inp.max = c[3]; inp.step = 1;
      var out = document.createElement('output');
      inp.addEventListener('input', function () {
        getTune()[c[0]] = parseInt(inp.value, 10) || 0; out.textContent = inp.value; changed();
      });
      inp.addEventListener('dblclick', function () { getTune()[c[0]] = 0; sync(); changed(); });
      row.appendChild(name); row.appendChild(inp); row.appendChild(out); sheet.appendChild(row);
      inputs[c[0]] = inp; outs[c[0]] = out;
    });

    var auto = null;
    if (opts.showAutoSave !== false) {
      var ar = document.createElement('label'); ar.className = 'ap-auto';
      auto = document.createElement('input'); auto.type = 'checkbox'; auto.checked = readAuto();
      auto.addEventListener('change', function () { writeAuto(auto.checked); });
      ar.appendChild(auto); ar.appendChild(document.createTextNode('Auto-save as a draft once the label is read'));
      sheet.appendChild(ar);
    }
    document.body.appendChild(sheet);

    var toggle = document.createElement('button'); toggle.type = 'button';
    toggle.textContent = opts.toggleText || '🎚 Adjust';
    if (opts.toggleParent) { toggle.className = 'ap-toggle ' + (opts.toggleClass || ''); opts.toggleParent.appendChild(toggle); }
    else { toggle.className = 'ap-toggle-float'; document.body.appendChild(toggle); }
    toggle.addEventListener('click', function (e) { e.stopPropagation(); sheet.classList.toggle('open'); });
    doneB.addEventListener('click', function () { sheet.classList.remove('open'); });
    resetB.addEventListener('click', function () {
      var t = getTune(); CONTROLS.forEach(function (c) { t[c[0]] = 0; }); sync(); changed();
    });
    sheet.addEventListener('click', function (e) { e.stopPropagation(); });   // pages close drawers on outside clicks

    function sync() {
      var t = getTune();
      CONTROLS.forEach(function (c) { inputs[c[0]].value = t[c[0]] || 0; outs[c[0]].textContent = t[c[0]] || 0; });
      toggle.classList.toggle('on', !isNeutral(t));
    }
    function changed() {
      if (ownTune) CP.saveTune(opts.key || 'dwSimpleTune', ownTune);
      toggle.classList.toggle('on', !isNeutral(getTune()));
      if (opts.onChange) opts.onChange();
    }
    sync();

    // ── live preview: a canvas over the <video>, same apply() as the bake (sharpness is capture-only
    //    by the engine's contract). Hidden while the tune is neutral so the raw video shows. ──
    var previews = [];
    function tick() {
      var t = getTune(), neutral = isNeutral(t);
      previews.forEach(function (p) {
        var v = p.video, cv = p.canvas;
        var show = !neutral && v.videoWidth && v.offsetParent !== null;
        cv.style.display = show ? 'block' : 'none';
        if (!show) return;
        var k = Math.min(1, 640 / Math.max(v.videoWidth, v.videoHeight));
        var w = Math.round(v.videoWidth * k), h = Math.round(v.videoHeight * k);
        if (cv.width !== w) cv.width = w; if (cv.height !== h) cv.height = h;
        cv.className = 'ap-preview ' + v.className;   // carries e.g. the selfie .mirror transform
        try {
          var x = cv.getContext('2d', { willReadFrequently: true });
          CP.drawSource(x, v, w, h, t.straighten);
          CP.apply(x, w, h, t, { capture: false });
        } catch (e) {}
      });
    }
    var timer = null;
    function attachPreview(video) {
      var cv = document.createElement('canvas'); cv.className = 'ap-preview';
      if (getComputedStyle(video.parentNode).position === 'static') video.parentNode.style.position = 'relative';
      video.parentNode.insertBefore(cv, video.nextSibling);
      previews.push({ video: video, canvas: cv });
      if (!timer) timer = setInterval(tick, 80);
    }

    function bake(source, maxEdge, bo) {
      var sw = source.videoWidth || source.naturalWidth || source.width;
      var sh = source.videoHeight || source.naturalHeight || source.height;
      var k = Math.min(1, (maxEdge || 2000) / Math.max(sw, sh));
      var w = Math.round(sw * k), h = Math.round(sh * k), t = getTune();
      var c = document.createElement('canvas'); c.width = w; c.height = h;
      var x = c.getContext('2d', { willReadFrequently: true });
      if (bo && bo.mirror) x.setTransform(-1, 0, 0, 1, w, 0);
      CP.drawSource(x, source, w, h, t.straighten);
      x.setTransform(1, 0, 0, 1, 0, 0);
      if (!isNeutral(t)) CP.apply(x, w, h, t, { capture: true });
      return c.toDataURL('image/jpeg', 0.92);
    }
    function bakeDataUrl(url, maxEdge) {
      if (isNeutral(getTune())) return Promise.resolve(url);
      return new Promise(function (res) {
        var im = new Image();
        im.onload = function () { try { res(bake(im, maxEdge)); } catch (e) { console.error('[adjust-panel] bake failed', e); res(url); } };
        im.onerror = function () { res(url); };
        im.src = url;
      });
    }

    return {
      attachPreview: attachPreview, bake: bake, bakeDataUrl: bakeDataUrl, sync: sync,
      autoSave: function () { return auto ? auto.checked : false; },
      toggle: toggle, tune: getTune, isNeutral: function () { return isNeutral(getTune()); }
    };
  }

  window.AdjustPanel = { create: create, CONTROLS: CONTROLS };
})();