← back to Dw Photo Capture

public/batch.html

835 lines

<!doctype html>
<html lang="en">
<head><script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<script src="/js/capture-pipeline.js"></script>
<script src="/js/acquire-camera.js"></script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DW Batch">
<meta name="theme-color" content="#0f0e0c">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<title>DW Batch Shoot</title>
<style>
  :root{ --ui-scale:1.35; /* "make all larger" — single dial for control/label size (1 = original). */
         --bg:#0f0e0c; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; --amber:#d8a53a; }
  *{box-sizing:border-box}
  /* Magnify controls/labels; full-screen views are position:fixed;inset:0 so they stay edge-to-edge
     under zoom. Only viewport-relative sizes are divided back by the scale so nothing overflows. */
  html{ zoom: var(--ui-scale); }
  html,body{margin:0;height:100%;min-height:calc(100dvh / var(--ui-scale));background:#000;color:var(--ink);font:15px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;-webkit-text-size-adjust:100%;overscroll-behavior:none}
  @supports(height:100dvh){ .view{height:calc(100dvh / var(--ui-scale))} }
  button{font-family:inherit}
  .btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:15px 22px;font-weight:700;font-size:16px;min-height:54px;display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}
  .btn.primary{background:var(--gold);color:#1b1407;border-color:var(--gold)}
  .btn.ghost{border-color:var(--line);color:var(--ink);background:#16140f}
  .btn:disabled{opacity:.4}
  input,select{font:15px/1.2 -apple-system,sans-serif;background:#16140f;color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:11px 12px;width:100%}
  label.fld{display:block;margin:0 0 12px}
  label.fld span{display:block;font-size:12px;color:var(--muted);margin:0 0 5px;text-transform:uppercase;letter-spacing:.05em}
  /* ── view scaffolding ── */
  .view{position:fixed;inset:0;display:flex;flex-direction:column}
  .view[hidden]{display:none}
  .pad{padding:max(18px,env(safe-area-inset-top)) 18px 18px;max-width:520px;margin:0 auto;width:100%;overflow:auto}
  h1{font:700 20px/1.2 "SF Pro Display",sans-serif;margin:6px 0 2px}
  h1 b{color:var(--gold)}
  .sub{color:var(--muted);font-size:13px;margin:0 0 18px}
  .ver{font:600 9px/1 ui-monospace,Menlo,monospace;background:#1b1407;color:var(--gold);border-radius:99px;padding:3px 6px;vertical-align:middle}
  .note{font-size:12px;color:var(--muted);background:#16140f;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin:6px 0 16px}
  /* ── camera stage (calib + shoot) ── */
  #stage,#cstage{position:absolute;inset:0;background:#000;overflow:hidden}
  video{width:100%;height:100%;object-fit:contain;background:#000}
  /* WYSIWYG live-corrected preview: overlays the raw video, same object-fit box, so the operator
     sees EXACTLY the colour that gets baked into the capture (same pixel pipeline). */
  canvas.pv{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none;z-index:5}
  canvas.ov{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:6}
  #flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .12s;z-index:40}
  #flash.on{opacity:.85;transition:none}
  /* ── top bar ── */
  .bar{position:absolute;left:0;right:0;top:0;z-index:30;display:flex;align-items:center;gap:8px;flex-wrap:wrap;
       padding:max(10px,env(safe-area-inset-top)) 12px 8px;background:linear-gradient(#000d,transparent)}
  .bar .title{font:700 13px/1.1 "SF Pro Display",sans-serif;letter-spacing:.03em}
  .bar .title b{color:var(--gold)}
  .count{margin-left:auto;font:700 15px/1 ui-monospace,Menlo,monospace;color:var(--gold)}
  .chips{position:absolute;left:0;right:0;top:44px;z-index:30;display:flex;gap:6px;flex-wrap:wrap;padding:0 12px}
  .chip{display:inline-flex;align-items:center;gap:5px;font:600 11px/1 ui-monospace,Menlo,monospace;
        background:#16140fdd;border:1px solid var(--line);border-radius:99px;padding:5px 9px;backdrop-filter:blur(6px)}
  .chip .d{width:8px;height:8px;border-radius:50%;background:var(--muted)}
  .chip .d.ok{background:var(--green);box-shadow:0 0 8px var(--green)} .chip .d.warn{background:var(--amber)} .chip .d.err{background:var(--red)}
  /* ── bottom controls ── */
  .foot{position:absolute;left:0;right:0;bottom:0;z-index:30;padding:12px 12px max(14px,env(safe-area-inset-bottom));
        background:linear-gradient(transparent,#000e);display:flex;flex-direction:column;gap:10px;align-items:center}
  .skuline{display:flex;align-items:center;gap:8px;width:100%;max-width:460px}
  .skuline input{flex:1;text-align:center;font:700 16px/1 ui-monospace,Menlo,monospace;letter-spacing:.05em}
  .row{display:flex;gap:10px;align-items:center;justify-content:center;width:100%;max-width:460px}
  .shutter{width:78px;height:78px;border-radius:50%;border:4px solid var(--gold);background:var(--gold);cursor:pointer;flex:0 0 auto}
  .shutter:disabled{background:#3a3428;border-color:#3a3428}
  .thumb{width:56px;height:56px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f}
  /* TK-12228: the last sample's FRONT (PSku) and BACK (Info) photos, side by side, tap to enlarge */
  .thumbs{display:flex;gap:6px;flex:0 0 auto}
  .thumbs figure{margin:0;position:relative;cursor:zoom-in}
  .thumbs figure[hidden]{display:none}
  .thumbs figcaption{position:absolute;left:3px;bottom:3px;font:800 9px/1 -apple-system,sans-serif;letter-spacing:.04em;background:#000b;color:var(--ink);padding:2px 4px;border-radius:4px}
  #bLb{position:absolute;inset:0;z-index:70;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
  #bLb[hidden]{display:none}
  #bLb img{max-width:100%;max-height:80%;object-fit:contain;border-radius:8px}
  .state{font:800 20px/1.2 "SF Pro Display",sans-serif;letter-spacing:.01em;min-height:26px;text-align:center;padding:0 10px}
  .state.ready{color:var(--green)} .state.wait{color:var(--muted)} .state.retake{color:var(--red)}
  /* ── big RETAKE / re-cal overlay ── */
  .big{position:absolute;inset:0;z-index:55;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:24px;background:#0f0e0cd8}
  .big[hidden]{display:none}
  .big h2{font:900 40px/1 "SF Pro Display",sans-serif;margin:0}
  .big.retake h2{color:var(--red)} .big.recal h2{color:var(--amber)}
  .big p{color:var(--ink);margin:0;max-width:360px}
  .big small{color:var(--muted);font:600 12px/1.4 ui-monospace,Menlo,monospace}
  /* gate/permission overlay */
  .overlay{position:absolute;inset:0;z-index:50;background:#0f0e0cf2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;text-align:center}
  .overlay[hidden]{display:none}
  .overlay h2{font:700 19px/1.2 "SF Pro Display",sans-serif;margin:0;color:var(--gold)}
  .overlay p{color:var(--muted);margin:0;max-width:340px}
  .toast{position:absolute;left:50%;bottom:170px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:10px 16px;border-radius:12px;font-size:14px;z-index:60;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88%;text-align:center}
  .toast.show{opacity:1}
  .qmeta{font:600 10px/1.4 ui-monospace,Menlo,monospace;color:var(--muted);text-align:center;white-space:pre}
  /* ── 1·2·3 wizard chrome (added: simple, explained flow) ── */
  .stepbadge{display:flex;align-items:center;gap:10px;margin:2px 0 6px}
  .stepbadge .num{width:38px;height:38px;border-radius:50%;background:var(--gold);color:#1b1407;font:900 20px/38px "SF Pro Display",sans-serif;text-align:center;flex:0 0 auto}
  .stepbadge .lbl{font:800 16px/1.1 "SF Pro Display",sans-serif;letter-spacing:.02em;color:var(--gold)}
  .explain{font-size:15px;line-height:1.5;color:var(--ink);background:#16140f;border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:10px;padding:12px 14px;margin:6px 0 16px}
  .explain b{color:var(--gold)}
  .steps3{display:flex;gap:6px;margin:12px 0 18px}
  .steps3 .s{flex:1;text-align:center;font:700 11px/1.3 -apple-system,sans-serif;color:var(--muted);background:#16140f;border:1px solid var(--line);border-radius:10px;padding:9px 4px}
  .steps3 .s b{display:block;font:900 17px/1.2 "SF Pro Display",sans-serif;color:var(--gold)}
  .foot .btn.primary,#vCalib .foot .btn{font-size:17px;padding:16px 24px}
  /* the "nerd panel" — technical chips + debug numbers, hidden until you tap ⓘ */
  .detBtn{position:absolute;top:max(10px,env(safe-area-inset-top));right:12px;z-index:33;width:36px;height:36px;border-radius:50%;
          border:1px solid var(--line);background:#16140fcc;color:var(--muted);font:700 16px/1 sans-serif;backdrop-filter:blur(6px)}
  #details[hidden]{display:none}
  /* on-camera instruction card (calibrate) */
  .instr{font-size:16px;line-height:1.45;color:var(--ink);text-align:center;max-width:440px;margin:0 auto;
         background:#16140fcc;border:1px solid var(--line);border-radius:12px;padding:12px 14px;backdrop-filter:blur(6px)}
  .instr b{color:var(--gold)}
  /* ── movable / collapsible photo-tools panel (all controls live here; center-bottom on load) ── */
  .toolpanel{position:absolute;z-index:35;width:min(94vw,460px);left:50%;bottom:max(14px,env(safe-area-inset-bottom));transform:translateX(-50%);
             background:#14120edd;border:1px solid var(--line);border-radius:16px;backdrop-filter:blur(10px);box-shadow:0 10px 34px #000a;overflow:hidden}
  .toolpanel.dragging{transition:none;opacity:.96}
  .tp-head{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:grab;user-select:none;touch-action:none;background:#1b1710;border-bottom:1px solid var(--line)}
  .tp-head:active{cursor:grabbing}
  .tp-title{font:800 14px/1 "SF Pro Display",sans-serif;color:var(--gold);display:flex;align-items:center;gap:6px}
  .tp-title b{color:var(--gold)}
  .tp-grip{margin-left:2px;color:var(--muted);font:700 14px/1 sans-serif;letter-spacing:2px}
  .tp-btn{margin-left:auto;width:34px;height:30px;border-radius:9px;border:1px solid var(--line);background:#16140f;color:var(--gold);font:700 15px/1 sans-serif;cursor:pointer}
  .tp-body{padding:11px 12px 12px;display:flex;flex-direction:column;gap:9px;max-height:calc(62vh / var(--ui-scale));overflow:auto}
  .toolpanel.collapsed .tp-body{display:none}
  /* sliders */
  .sld{display:block}
  .sld>span{display:flex;justify-content:space-between;font:700 11px/1 ui-monospace,Menlo,monospace;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:0 0 4px}
  .sld>span i{color:var(--gold);font-style:normal}
  .sld input[type=range]{width:100%;accent-color:var(--gold);height:26px}
  .tp-reset{padding:7px 12px;font-size:12px;align-self:flex-start}
  .fields{display:grid;grid-template-columns:1fr 1fr;gap:7px}
  .fields input{font-size:13px;padding:9px 10px}
  .fields input.wide{grid-column:1 / -1}
  .sideBadge.info{color:var(--green)}
</style>
</head>
<body>

<!-- ══════════ VIEW 1 · SESSION SETUP ══════════ -->
<div class="view" id="vSetup">
  <div class="pad">
    <h1>📸 <b>Sample</b> Photo Booth <span class="ver">__VER__</span></h1>
    <p class="sub">Take one perfect photo of every sample — the app does the hard part.</p>
    <div class="explain">Here's the whole job. You'll do it <b>three times only</b>: set up once, calibrate once, then photograph every sample.</div>
    <div class="steps3">
      <div class="s"><b>1</b>Calibrate<br>the color</div>
      <div class="s"><b>2</b>Photograph<br>each sample</div>
      <div class="s"><b>3</b>Done —<br>all saved</div>
    </div>
    <label class="fld"><span>Who made these? (pick the brand)</span><select id="fVendorSel"><option value="">— pick a brand —</option><option value="__other__">Not listed — type it…</option></select></label>
    <label class="fld" id="fVendorOtherWrap" hidden><span>Brand name (type it)</span><input id="fVendor" placeholder="Phillip Jeffries" autocapitalize="words"></label>
    <label class="fld"><span>What are they called?</span><input id="fCollection" placeholder="Grasscloth" autocapitalize="words"></label>
    <label class="fld"><span>Today's date</span><input id="fDate" type="date"></label>
    <label class="fld"><span>About how many samples?</span><input id="fCount" type="number" inputmode="numeric" value="425" min="1"></label>
    <div class="row" style="justify-content:space-between;margin-top:8px">
      <button class="btn ghost" id="bResume" hidden>↩ Pick up where I left off</button>
      <button class="btn primary" id="bStart" style="margin-left:auto;font-size:17px;padding:16px 26px">Start → Step 1</button>
    </div>
    <p class="sub" style="margin:14px 0 0;font-size:12px">Keep the light and the stand still the whole time, and keep the iPad plugged in.</p>
    <p class="qmeta" id="setupQ" style="margin-top:12px;text-align:left"></p>
  </div>
</div>

<!-- ══════════ VIEW 2 · CALIBRATE ══════════ -->
<div class="view" id="vCalib" hidden>
  <div id="cstage"><video id="cv" playsinline autoplay muted></video><canvas class="ov" id="cov"></canvas></div>
  <div id="cflash" class="flash"></div>
  <div class="bar"><span class="title"><b>STEP 1</b> · Calibrate the color</span></div>
  <div class="foot">
    <div class="instr">Lay the <b>gray card</b> flat so it fills the <b>green box</b>. Hold still, then tap the gold button. This teaches the camera true color so every photo matches.</div>
    <div class="state wait" id="cState">Put the gray card in the green box</div>
    <div class="row">
      <button class="btn ghost" id="cBack">← Back</button>
      <button class="btn primary" id="cSave">✓ Capture the gray card</button>
    </div>
  </div>
  <div class="overlay" id="cGate">
    <h2>Turn on the camera</h2>
    <p>Tap the button, then allow the camera. Point it straight down at the stand.</p>
    <button class="btn primary" id="cStartCam">📷 Turn on camera</button>
  </div>
</div>

<!-- ══════════ VIEW 3 · SHOOT ══════════ -->
<div class="view" id="vShoot" hidden>
  <div id="stage"><video id="v" playsinline autoplay muted></video><canvas class="pv" id="pv"></canvas><canvas class="ov" id="ov"></canvas></div>
  <div id="flash"></div>

  <div class="bar">
    <span class="title"><b>STEP 2</b> · <span id="hBatch">—</span></span>
    <span class="count"><span id="hN">0</span> / <span id="hTotal">—</span></span>
  </div>
  <!-- technical readouts — hidden until you tap ⓘ; JS keeps updating them either way -->
  <div class="chips" id="details" hidden>
    <span class="chip"><span class="d" id="dCam"></span>CAM</span>
    <span class="chip"><span class="d ok"></span>WB·LOCK<sup style="color:var(--muted)">sw</sup></span>
    <span class="chip"><span class="d ok"></span>EXP·LOCK<sup style="color:var(--muted)">sw</sup></span>
    <span class="chip"><span class="d ok"></span>FOCUS<sup style="color:var(--muted)">auto</sup></span>
    <span class="chip"><span class="d" id="dCal"></span>CAL <span id="calTxt">—</span></span>
    <span class="chip"><span class="d" id="dSku"></span>SKU <span id="skuTxt">—</span></span>
    <span class="chip">Δ <span id="deltaTxt">—</span> <span style="color:var(--muted)">ΔE:v1.1</span></span>
    <span class="chip" id="qmeta" style="white-space:normal;color:var(--muted)"></span>
  </div>

  <!-- ALL photo tools live in one movable + expand/contract panel; placed center-bottom on load -->
  <div class="toolpanel" id="tools">
    <div class="tp-head" id="tpHead">
      <span class="tp-title">📷 <b class="sideBadge" id="sideBadge">PSku Photo</b> <span class="tp-grip">⠿</span></span>
      <button class="tp-btn" id="tpToggle" title="Open or close this">▾</button>
    </div>
    <div class="tp-body" id="tpBody">
      <div class="state wait" id="state">Adjust the colour, then take the PSku photo</div>
      <!-- live colour tools (WYSIWYG; sticky to the next photo) -->
      <label class="sld"><span>Brightness <i id="vBright">0</i></span><input type="range" id="sBright" min="-100" max="100" value="0"></label>
      <label class="sld"><span>Warmth <i id="vWarm">0</i></span><input type="range" id="sWarm" min="-100" max="100" value="0"></label>
      <label class="sld"><span>Hue <i id="vHue">0</i></span><input type="range" id="sHue" min="-180" max="180" value="0"></label>
      <button class="btn ghost tp-reset" id="sReset">↺ Reset colour</button>
      <!-- fields — auto-filled from the label OCR; editable -->
      <div class="skuline"><input id="skuInput" placeholder="code — fills in from the label" autocapitalize="characters" autocomplete="off" spellcheck="false"></div>
      <div class="fields">
        <input id="fVendorField" class="wide" placeholder="vendor" autocapitalize="words" autocomplete="off">
        <input id="fPattern" placeholder="pattern" autocapitalize="words" autocomplete="off">
        <input id="fColor" placeholder="colour" autocapitalize="words" autocomplete="off">
      </div>
      <div class="row">
        <button class="btn ghost" id="bPrev" title="Show the last code" style="padding:12px 14px">◀</button>
        <button class="btn ghost" id="detBtn" title="Show the technical details" style="padding:12px 14px">ⓘ</button>
        <label class="chip" style="gap:6px;font-size:12px"><input type="checkbox" id="autoFire" style="width:auto"> Auto-snap</label>
        <button class="shutter" id="shutter" title="Take photo"></button>
        <div class="thumbs">
          <figure id="thumbFWrap" hidden><img class="thumb" id="thumb" alt="last FRONT photo"><figcaption>FRONT</figcaption></figure>
          <figure id="thumbBWrap" hidden><img class="thumb" id="thumbB" alt="last BACK photo"><figcaption>BACK</figcaption></figure>
        </div>
      </div>
      <div class="qmeta" id="queueTxt"></div>
    </div>
  </div>

  <!-- big overlays -->
  <div class="big retake" id="bigRetake" hidden><h2>RETAKE</h2><p id="retakeWhy">—</p><small id="retakeMeta"></small><button class="btn primary" id="retakeOk">OK</button></div>
  <div class="big recal" id="bigRecal" hidden><h2>CHECK COLOR</h2><p>The light or stand moved, so colors won't match. Let's re-shoot the gray card — it only takes a second.</p><small id="recalMeta"></small><button class="btn primary" id="recalGo">Re-shoot gray card</button></div>
  <div class="overlay" id="gate"><h2>Turn on the camera</h2><p>Tap the button, then put your first sample on the stand.</p><button class="btn primary" id="startCam">📷 Turn on camera</button></div>
  <div class="toast" id="toast"></div>
  <div id="bLb" hidden><b id="bLbCap" style="letter-spacing:.05em"></b><img id="bLbImg" alt="captured photo"><button class="btn ghost" id="bLbClose">✕ Close</button> <a href="/captures" style="color:var(--gold);font-size:13px">All captures →</a></div>
</div>

<script>
"use strict";
const $ = s => document.querySelector(s);
const clamp = (x,a,b)=>Math.max(a,Math.min(b,x));

// ── App config (tunable on-device; thresholds calibrated for a downscaled ~1280px gate canvas) ──
const CFG = {
  gateLong: 1280,          // long edge of the gate/motion canvas
  webLong: 1600,           // long edge of the web derivative
  jpegQ: 0.92,
  motionThresh: 6,         // mean |Δluma| above this = movement
  stillThresh: 2.2,        // below this for stableFrames = settled
  stableFrames: 6,         // ~ gate ticks of stillness before auto-fire (200ms tick → ~1.2s)
  tickMs: 200,
  minCadenceMs: 1500,      // thermal/cadence governor: never auto-fire faster than this
  toBlobSlowMs: 900,       // if a full-res toBlob takes longer than this, back off (thermal)
  blurMin: 55,             // variance-of-Laplacian floor (sharpness)
  clipMax: 0.06,           // max fraction of clipped (0/255) pixels
  expDevMax: 42,           // max |meanLuma - cal luma target|
  fillVarMin: 45,          // min luma variance inside the target rect (a sample has texture)
  driftLumaMax: 38,        // Δluma vs cal that forces re-cal
  driftWBMax: 0.16,        // per-channel WB ratio drift vs cal that forces re-cal
  skuConfMin: 0.55,        // OCR confidence below this → keep, but flag for manual confirm
  maxTries: 6,             // upload attempts before a shot is PARKED as failed (never dropped)
  ocrTimeoutMs: 7000       // auto-SKU OCR: give up after this and fall back to manual entry (never hang)
};

// ── Session + calibration state (persisted) ──
const LS_SESSION = 'dwbatch.session', LS_CAL = 'dwbatch.cal.';   // cal keyed by sessionId
let session = null;        // {id, vendor, collection, date, total, n}
let cal = null;            // {wbR,wbG,wbB, lumaTarget, refLuma,refCR,refCB, crop:{x,y,w,h}(0-1), quad, settings, at}
let stream=null, track=null, camLive=false;

function toast(m){ const t=$('#toast'); if(!t)return; t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2400); }
function loadJSON(k,d){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):d; }catch(e){ return d; } }
function saveJSON(k,o){ try{ localStorage.setItem(k, JSON.stringify(o)); }catch(e){} }

// ════════════════════════ VIEW ROUTER ════════════════════════
function show(view){ for(const id of ['vSetup','vCalib','vShoot']){ const el=$('#'+id); el.hidden = (id!==view); } }

// ════════════════════════ CAMERA (hardened for iOS Safari) ════════════════════════
// TK-12127: unbounded getUserMedia() here had no bound and no generation guard — can't brick a latch
// flag (there isn't one), but a re-tap of "Turn on camera" mid-hang could fire a second getUserMedia
// and orphan whichever stream resolved first. Ported the two-shot flow's proven acquireCamera helper.
const camAcquirer=AcquireCamera.createCameraAcquirer();
function onCamTrackDead(){   // same body as the original inline 'ended' handler
  camLive=false; setCamDot();
  if(!$('#vShoot').hidden){ toast('⚠ Camera stopped — tap “Turn on camera”'); $('#gate').hidden=false; setState('retake','Camera stopped — tap to restart'); }
}
async function startCameraInto(videoEl){
  if(stream){ videoEl.srcObject=stream; return true; }
  const attempts=[
    { video:{ facingMode:{exact:'environment'}, width:{ideal:4096}, height:{ideal:3072} }, audio:false },
    { video:{ facingMode:'environment', width:{ideal:4096}, height:{ideal:3072} }, audio:false },
    { video:true, audio:false }
  ];
  let res;
  try{
    res=await camAcquirer.acquire(attempts,{videoEl,onDead:onCamTrackDead});
  }catch(e){
    toast(e&&e.message==='camera-timeout' ? '⚠ Camera took too long to respond — tap to try again' : 'Camera blocked ('+((e&&e.name)||'error')+') — needs HTTPS on iOS');
    return false;
  }
  stream=res.stream; track=res.track;
  camLive=true; setCamDot();
  requestWake();
  return true;
}
function camRes(v){ const s=track&&track.getSettings?track.getSettings():{}; return { w:s.width||v.videoWidth||0, h:s.height||v.videoHeight||0 }; }
function setCamDot(){ const d=$('#dCam'); if(d){ d.className='d '+(camLive?'ok':'err'); } }
let wakeLock=null;
async function requestWake(){ try{ if('wakeLock' in navigator){ wakeLock=await navigator.wakeLock.request('screen'); } }catch(e){} }
document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible'){ requestWake(); const v=$('#v'); if(v&&stream){ v.play().catch(()=>{}); } } });

// ── draw the live video into a downscaled work canvas (returns {cnv,ctx,w,h}) ──
const _work=document.createElement('canvas'); const _wctx=_work.getContext('2d',{willReadFrequently:true});
function gateGrab(videoEl){
  const r=camRes(videoEl); const w=r.w||videoEl.videoWidth, h=r.h||videoEl.videoHeight;
  if(!w||!h) return null;
  const scale=Math.min(1, CFG.gateLong/Math.max(w,h));
  const gw=Math.round(w*scale), gh=Math.round(h*scale);
  if(_work.width!==gw)_work.width=gw; if(_work.height!==gh)_work.height=gh;
  _wctx.drawImage(videoEl,0,0,gw,gh);
  return { w:gw, h:gh, data:_wctx.getImageData(0,0,gw,gh) };
}

// ════════════════════════ IMAGE MATH ════════════════════════
function stats(img, rect){          // mean R/G/B/luma + luma variance over a (0-1) rect of img
  const {data,width,height}=img;
  const x0=Math.floor((rect?rect.x:0)*width), y0=Math.floor((rect?rect.y:0)*height);
  const x1=Math.min(width, Math.ceil(((rect?rect.x+rect.w:1))*width)), y1=Math.min(height, Math.ceil(((rect?rect.y+rect.h:1))*height));
  let n=0,sr=0,sg=0,sb=0,sl=0,sl2=0;
  for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){ const i=(y*width+x)*4;
    const r=data[i],g=data[i+1],b=data[i+2]; const l=0.299*r+0.587*g+0.114*b;
    sr+=r;sg+=g;sb+=b;sl+=l;sl2+=l*l;n++; } }
  if(!n) return {r:0,g:0,b:0,luma:0,var:0,n:0};
  const mr=sr/n,mg=sg/n,mb=sb/n,ml=sl/n;
  return { r:mr,g:mg,b:mb,luma:ml, var:Math.max(0,sl2/n-ml*ml), n };
}
function clipFrac(img, rect){       // fraction of clipped (0/255) pixels within a (0-1) rect
  const {data,width,height}=img;
  const x0=Math.floor((rect?rect.x:0)*width), y0=Math.floor((rect?rect.y:0)*height);
  const x1=Math.min(width, Math.ceil((rect?rect.x+rect.w:1)*width)), y1=Math.min(height, Math.ceil((rect?rect.y+rect.h:1)*height));
  let n=0,c=0; for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){ const i=(y*width+x)*4;
    const l=0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; if(l<=2||l>=253)c++; n++; } } return n?c/n:0;
}
function varLaplacian(img, rect){   // sharpness: variance of 4-neighbour Laplacian on grayscale, within a (0-1) rect
  const {data,width,height}=img; let s=0,s2=0,n=0;
  const L=(x,y)=>{ const i=(y*width+x)*4; return 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; };
  const x0=Math.max(1,Math.floor((rect?rect.x:0)*width)), y0=Math.max(1,Math.floor((rect?rect.y:0)*height));
  const x1=Math.min(width-1, Math.ceil((rect?rect.x+rect.w:1)*width)), y1=Math.min(height-1, Math.ceil((rect?rect.y+rect.h:1)*height));
  for(let y=y0;y<y1;y+=2){ for(let x=x0;x<x1;x+=2){
    const v=(-4*L(x,y)+L(x-1,y)+L(x+1,y)+L(x,y-1)+L(x,y+1)); s+=v;s2+=v*v;n++; } }
  return n?Math.max(0,s2/n-(s/n)*(s/n)):0;
}

// ════════════════════════ SETUP VIEW ════════════════════════
$('#fDate').value = new Date().toISOString().slice(0,10);
(function initSetup(){
  const last=loadJSON(LS_SESSION,null);
  if(last){ $('#bResume').hidden=false; $('#bResume').onclick=()=>{ session=last;
    cal=loadJSON(LS_CAL+session.id, null);          // resume MUST restore calibration (else WB/exposure/crop are lost)
    if(cal){ enterShoot(); } else { enterCalib(); }  // no stored cal → recalibrate first, same as the Start path
  }; }
})();
// ── vendor dropdown, populated from the catalog vendor profiles (with a type-it escape) ──
function toggleVendorOther(){ const other=$('#fVendorSel').value==='__other__'; $('#fVendorOtherWrap').hidden=!other; if(other) setTimeout(()=>$('#fVendor').focus(),50); }
$('#fVendorSel').addEventListener('change', toggleVendorOther);
(async function loadVendors(){
  try{
    const r=await fetch('/api/vendor-profiles'); const d=await r.json();
    const names=[...new Set([...(d.seeded||[]), ...((d.vendors||[]).map(v=>v&&v.name))])].filter(Boolean).sort((a,b)=>a.localeCompare(b));
    const sel=$('#fVendorSel'), other=sel.querySelector('option[value="__other__"]');
    for(const n of names){ const o=document.createElement('option'); o.value=n; o.textContent=n; sel.insertBefore(o,other); }
  }catch(e){ $('#fVendorSel').value='__other__'; toggleVendorOther(); }   // offline → typing still works
})();
$('#bStart').onclick=()=>{
  const vsel=$('#fVendorSel').value;
  const vendor=(vsel && vsel!=='__other__') ? vsel : $('#fVendor').value.trim();
  const collection=$('#fCollection').value.trim();
  if(!vendor){ toast('Vendor is required'); return; }
  const date=$('#fDate').value||new Date().toISOString().slice(0,10);
  const id=(vendor+'-'+(collection||'')+'-'+date).replace(/[^A-Za-z0-9._-]+/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'');
  session={ id, vendor, collection, date, total:parseInt($('#fCount').value,10)||0, n:0 };
  saveJSON(LS_SESSION, session);
  cal=loadJSON(LS_CAL+session.id, null);
  if(cal){ enterShoot(); } else { enterCalib(); }
};

// ════════════════════════ CALIBRATE VIEW ════════════════════════
// v1: gray-card WB + exposure target + an axis-aligned crop rect. The 4-corner
// perspective quad is stored (= the crop corners) for the v1.1 true-warp; v1 applies CROP.
const CAL_TARGET={x:0.10,y:0.10,w:0.80,h:0.80};   // sample-fill box (green)
const CAL_GRAY={x:0.42,y:0.42,w:0.16,h:0.16};     // gray-card patch (center square)
// Drift reference = a fixed background band ABOVE the sample box (empty stand in every shot).
// The gate reads luma/WB drift HERE — product-independent — instead of over the whole frame.
const CAL_REF={x:0.0,y:0.0,w:1.0,h:0.08};
function drawCalOverlay(){
  const cnv=$('#cov'), v=$('#cv'); const rect=v.getBoundingClientRect();
  cnv.width=rect.width; cnv.height=rect.height; const c=cnv.getContext('2d'); c.clearRect(0,0,cnv.width,cnv.height);
  const R=(r,col,lab)=>{ c.strokeStyle=col; c.lineWidth=2; c.setLineDash(col===getCss('--gold')?[6,5]:[]);
    c.strokeRect(r.x*cnv.width,r.y*cnv.height,r.w*cnv.width,r.h*cnv.height);
    if(lab){ c.fillStyle=col; c.font='600 12px ui-monospace,monospace'; c.fillText(lab, r.x*cnv.width+6, r.y*cnv.height+16); } };
  R(CAL_REF, '#4a90d9', 'light ref — keep clear');
  R(CAL_TARGET, getCss('--green'), 'sample fill');
  R(CAL_GRAY, getCss('--gold'), 'gray patch');
}
function getCss(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); }
function enterCalib(){
  show('vCalib');
  const v=$('#cv'); $('#cGate').hidden=false;
  $('#cStartCam').onclick=async()=>{ if(await startCameraInto(v)){ $('#cGate').hidden=true; const loop=()=>{ if(!$('#vCalib').hidden){ drawCalOverlay(); requestAnimationFrame(loop); } }; loop(); } };
  if(stream){ v.srcObject=stream; $('#cGate').hidden=true; const loop=()=>{ if(!$('#vCalib').hidden){ drawCalOverlay(); requestAnimationFrame(loop); } }; loop(); }
}
$('#cBack').onclick=()=>{ show('vSetup'); };
$('#cSave').onclick=()=>{
  const v=$('#cv'); const g=gateGrab(v); if(!g){ toast('Camera not ready'); return; }
  const gray=stats(g.data, CAL_GRAY);
  if(gray.n<10||gray.luma<8){ toast('Can’t read the gray patch — check light'); return; }
  const ref=stats(g.data, CAL_REF);          // fixed background band = the drift/exposure reference
  // von Kries: normalize each channel to green so a neutral card reads neutral
  const g0=gray.g||1;
  cal={
    wbR: clamp(g0/(gray.r||1),0.3,3), wbG:1, wbB: clamp(g0/(gray.b||1),0.3,3),
    lumaTarget: stats(g.data,null).luma,     // whole-frame mean luma = the MASTER exposure-normalize anchor
    grayLuma: gray.luma,
    // product-independent drift reference — re-read from CAL_REF each tick and compared to these:
    refLuma: ref.luma, refCR: (ref.g||1)/(ref.r||1), refCB: (ref.g||1)/(ref.b||1),
    crop: {...CAL_TARGET},
    quad: [ {x:CAL_TARGET.x,y:CAL_TARGET.y},{x:CAL_TARGET.x+CAL_TARGET.w,y:CAL_TARGET.y},
            {x:CAL_TARGET.x+CAL_TARGET.w,y:CAL_TARGET.y+CAL_TARGET.h},{x:CAL_TARGET.x,y:CAL_TARGET.y+CAL_TARGET.h} ],
    settings: (track&&track.getSettings)?track.getSettings():{},
    at: new Date().toISOString()
  };
  saveJSON(LS_CAL+session.id, cal);
  toast('Calibration saved · WB '+cal.wbR.toFixed(2)+'/'+cal.wbB.toFixed(2)+' · luma '+cal.lumaTarget.toFixed(0));
  enterShoot();
};

// ════════════════════════ SHOOT VIEW ════════════════════════
let gateTimer=null, prevLuma=null, stillCount=0, lastFireAt=0, busy=false, backoffUntil=0;
let lastGate=null, curSku='', skuConf=1, lastOcrKey='', ocrPending=false, batchCost=0, batchDone=false;
// TWO-SHOT flow: phase 'psku' (the pattern/product photo, shot first) → 'info' (the label side, shot
// manually) UNLESS the first photo already carries the code (info-on-front → one entry, no 2nd shot).
let phase='psku', pendingFront=null;
// Live colour tools — sticky (carry to the next photo). Manual nudges on top of the gray-card cal.
// Back-compat: loadJSON retrieves old {bright,warm,hue} format; normalizeTune maps bright→exposure, warm→temp
let tune = CapturePipeline.normalizeTune(loadJSON('dwbatch.tune', null)) || CapturePipeline.defaultTune();

function enterShoot(){
  show('vShoot');
  $('#hBatch').textContent=(session.vendor+' '+(session.collection||'')).trim();
  $('#hTotal').textContent=session.total||'—'; $('#hN').textContent=session.n||0;
  phase='psku'; pendingFront=null; setSideBadge();
  bindTune(); initToolPanel();
  const v=$('#v'); $('#gate').hidden=false;
  $('#startCam').onclick=async()=>{ if(await startCameraInto(v)){ $('#gate').hidden=true; startGateLoop(); drawShootOverlayLoop(); startPreview(); } };
  if(stream){ v.srcObject=stream; $('#gate').hidden=true; startGateLoop(); drawShootOverlayLoop(); startPreview(); }
  drainQueue();
}
function setSideBadge(){ const b=$('#sideBadge'); if(!b)return; const info=phase==='info';
  b.textContent=info?'Info Photo':'PSku Photo'; b.classList.toggle('info',info); }

// ── shared colour pipeline: gray-card WB × auto-exposure × manual (brightness/warmth/hue).
//    The LIVE preview and the CAPTURE run the SAME function, so what you see IS what's saved. ──
function hueMatrix(deg){ const a=deg*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 ]; }
function pipeGains(){                          // gray-card WB ratio ONLY (brightness/temp now in the shared engine)
  const wbR=cal?cal.wbR:1, wbG=cal?cal.wbG:1, wbB=cal?cal.wbB:1;
  return { rGain:wbR, gGain:wbG, bGain:wbB };  // per-channel WB gains; shared engine applies exposure/temp on top
}
function computeES(d){                          // auto exposure-normalize toward the cal luma target
  if(!cal) return 1; let sl=0,n=0; for(let i=0;i<d.length;i+=64){ sl+=0.299*d[i]+0.587*d[i+1]+0.114*d[i+2]; n++; }
  const cur=n?sl/n:cal.lumaTarget; return clamp(cal.lumaTarget/(cur||1),0.5,2); }
function applyPipeline(ctx,w,h,capture){
  // Compute auto-exposure on the RAW pixels (before any baking)
  const img=ctx.getImageData(0,0,w,h);
  const es=computeES(img.data);  // auto-exposure multiplier toward cal.lumaTarget
  const g=pipeGains();           // gray-card WB gains (rGain, gGain, bGain)
  // Delegate the full bake to the shared engine, passing WB gains + auto-exposure multiplier
  CapturePipeline.apply(ctx, w, h, tune, {capture: capture||false, preGain:g, exposureMul:es});
}
// live WYSIWYG preview — corrected frame drawn over the raw video (throttled; capture stays full-res)
let pvTimer=null;
function startPreview(){ if(pvTimer) clearInterval(pvTimer); pvTimer=setInterval(previewTick,80); }
function previewTick(){
  if($('#vShoot').hidden||!camLive){ return; }
  const v=$('#v'); const w=v.videoWidth,h=v.videoHeight; if(!w||!h) return;
  const scale=Math.min(1, 640/Math.max(w,h)); const pw=Math.round(w*scale), ph=Math.round(h*scale);
  const pv=$('#pv'); if(pv.width!==pw)pv.width=pw; if(pv.height!==ph)pv.height=ph;
  const x=pv.getContext('2d',{willReadFrequently:true});
  try{ x.drawImage(v,0,0,pw,ph); applyPipeline(x,pw,ph,false); }catch(e){}
}
// slider wiring — values persist so the setting carries to the next photo
// Map legacy HTML ids (sBright/sWarm) to new tune field names (exposure/temp/hue)
function bindTune(){
  const map=[['sBright','exposure','vBright'],['sWarm','temp','vWarm'],['sHue','hue','vHue']];
  for(const [id,key,lbl] of map){ const el=$('#'+id); if(!el)continue; el.value=tune[key]; $('#'+lbl).textContent=tune[key];
    el.oninput=()=>{ tune[key]=parseInt(el.value,10)||0; $('#'+lbl).textContent=tune[key]; saveJSON('dwbatch.tune',tune); }; }
  const r=$('#sReset'); if(r) r.onclick=()=>{ tune=CapturePipeline.defaultTune(); saveJSON('dwbatch.tune',tune);
    for(const [id,,lbl] of map){ const e=$('#'+id); if(e)e.value=0; $('#'+lbl).textContent=0; } };
}
// draggable + collapsible tool panel; position/collapsed remembered, default center-bottom
function initToolPanel(){
  const tp=$('#tools'), head=$('#tpHead'), tog=$('#tpToggle'); if(!tp||tp._rigged) return; tp._rigged=true;
  const st=loadJSON('dwbatch.toolpanel',null);
  if(st&&st.x!=null){ tp.style.left=st.x+'px'; tp.style.top=st.y+'px'; tp.style.bottom='auto'; tp.style.transform='none'; }
  if(st&&st.collapsed){ tp.classList.add('collapsed'); tog.textContent='▸'; }
  tog.onclick=()=>{ const c=tp.classList.toggle('collapsed'); tog.textContent=c?'▸':'▾';
    const cur=loadJSON('dwbatch.toolpanel',{}); cur.collapsed=c; saveJSON('dwbatch.toolpanel',cur); };
  let sx,sy,ox,oy,drag=false;
  const down=(e)=>{ if(e.target===tog) return; const p=e.touches?e.touches[0]:e; const r=tp.getBoundingClientRect();
    tp.style.left=r.left+'px'; tp.style.top=r.top+'px'; tp.style.bottom='auto'; tp.style.transform='none';
    sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; drag=true; tp.classList.add('dragging'); e.preventDefault(); };
  const move=(e)=>{ if(!drag)return; const p=e.touches?e.touches[0]:e;
    let nx=ox+(p.clientX-sx), ny=oy+(p.clientY-sy);
    nx=clamp(nx,4,innerWidth-tp.offsetWidth-4); ny=clamp(ny,4,innerHeight-44);
    tp.style.left=nx+'px'; tp.style.top=ny+'px'; };
  const up=()=>{ if(!drag)return; drag=false; tp.classList.remove('dragging'); const r=tp.getBoundingClientRect();
    const cur=loadJSON('dwbatch.toolpanel',{}); cur.x=Math.round(r.left); cur.y=Math.round(r.top); saveJSON('dwbatch.toolpanel',cur); };
  head.addEventListener('mousedown',down); head.addEventListener('touchstart',down,{passive:false});
  addEventListener('mousemove',move); addEventListener('touchmove',move,{passive:false});
  addEventListener('mouseup',up); addEventListener('touchend',up);
}
function drawShootOverlayLoop(){
  const cnv=$('#ov'), v=$('#v'); if($('#vShoot').hidden) return;
  const rect=v.getBoundingClientRect(); cnv.width=rect.width; cnv.height=rect.height;
  const c=cnv.getContext('2d'); c.clearRect(0,0,cnv.width,cnv.height);
  const r=CAL_TARGET; c.strokeStyle=getCss('--gold'); c.lineWidth=2; c.setLineDash([6,5]);
  c.strokeRect(r.x*cnv.width,r.y*cnv.height,r.w*cnv.width,r.h*cnv.height);
  // fixed light-reference band — the drift signal reads here, so the operator must keep it clear
  c.strokeStyle='#4a90d9'; c.lineWidth=1; c.setLineDash([4,4]);
  c.strokeRect(CAL_REF.x*cnv.width,CAL_REF.y*cnv.height,CAL_REF.w*cnv.width,CAL_REF.h*cnv.height);
  c.fillStyle='#4a90d9'; c.font='600 10px ui-monospace,monospace'; c.fillText('light ref — keep clear', 6, Math.max(12,CAL_REF.h*cnv.height-4));
  requestAnimationFrame(drawShootOverlayLoop);
}

// ── the gate loop: motion + quality + drift, every tickMs on the downscaled canvas ──
function startGateLoop(){ if(gateTimer) clearInterval(gateTimer); gateTimer=setInterval(gateTick, CFG.tickMs); }
function gateTick(){
  if(busy||$('#vShoot').hidden||!camLive) return;   // camera dead → stop scoring (no frozen-frame captures)
  const v=$('#v'); const g=gateGrab(v); if(!g){ return; } lastGate=g;
  const whole=stats(g.data,null);        // whole-frame: motion detection ONLY
  const tgt=stats(g.data, CAL_TARGET);   // the sample region
  const ref=stats(g.data, CAL_REF);      // fixed background band: product-independent drift reference
  const clip=clipFrac(g.data, CAL_TARGET);     // clipping OF THE SAMPLE, not the background
  const blur=varLaplacian(g.data, CAL_TARGET); // sharpness OF THE SAMPLE
  const haveRef = !!(cal && cal.refLuma!=null);
  // exposure + WB drift measured on the FIXED reference band, so a dark / light / saturated
  // sample can't be misread as a lighting change (was: whole-frame vs cal — the core v1 bug).
  const expDev = haveRef ? Math.abs(ref.luma-cal.refLuma) : 0;
  let wbDrift=0;
  if(haveRef){ const cr=(ref.g||1)/(ref.r||1), cb=(ref.g||1)/(ref.b||1);
    wbDrift=Math.max(Math.abs(cr-cal.refCR)/cal.refCR, Math.abs(cb-cal.refCB)/cal.refCB); }
  // motion / stillness (whole-frame delta — a sample swap is legitimately "motion")
  let moving=true;
  if(prevLuma!=null){ const d=Math.abs(whole.luma-prevLuma); moving=d>CFG.stillThresh;
    if(d<CFG.stillThresh) stillCount++; else if(d>CFG.motionThresh) stillCount=0; }
  prevLuma=whole.luma;
  // gate verdict
  const fails=[];
  if(blur<CFG.blurMin) fails.push('blurry');
  if(clip>CFG.clipMax) fails.push('clipping');
  if(expDev>CFG.expDevMax) fails.push('exposure');
  if(tgt.var<CFG.fillVarMin) fails.push('no sample in frame');
  // a cal saved before this fix lacks the reference band → force a re-cal rather than run blind
  const needRecal = !!(cal && !haveRef);
  const drift = needRecal || (haveRef && (expDev>CFG.driftLumaMax || wbDrift>CFG.driftWBMax));
  // status chips
  setChip('#dCal', drift?'err':(cal?'ok':'warn')); $('#calTxt').textContent = needRecal?'RE-CAL':(drift?'DRIFT':(cal?'ok':'none'));
  $('#deltaTxt').textContent = 'L'+expDev.toFixed(0)+' W'+(wbDrift*100).toFixed(0)+'%';
  $('#qmeta').textContent = `blur ${blur.toFixed(0)}  clip ${(clip*100).toFixed(1)}%  exp ${expDev.toFixed(0)}  fill ${tgt.var.toFixed(0)}`;
  // drift → force re-cal (the software "lock")
  if(drift){ $('#recalMeta').textContent = needRecal ? 'Calibration predates this build — please recalibrate.' : ('Δluma '+expDev.toFixed(0)+' · ΔWB '+(wbDrift*100).toFixed(0)+'%'); $('#bigRecal').hidden=false; setState('wait','Color check needed'); return; }
  else { $('#bigRecal').hidden=true; }
  // OCR the SKU on stillness (debounced) if no manual sku typed for this scene
  const still = stillCount>=CFG.stableFrames && !moving;
  if(still && !ocrPending && !fails.includes('no sample in frame')){ maybeOcr(g); }
  // ready? — operator-paced: quality/stillness is a HINT; the operator adjusts colour then taps.
  // PSku photo needs NO code (the code is on the label); the Info photo reads it.
  const infoPhase = (phase==='info');
  const ready = still && fails.length===0;
  const lowSku = !!curSku && skuConf<CFG.skuConfMin;   // OCR guessed but wasn't sure → operator must confirm
  const okMsg   = infoPhase ? '✓ Looks good — take the INFO photo (label)'
                            : (lowSku?'✓ Adjust colour — check the code, then shoot':'✓ Adjust the colour, then take the PSku photo');
  const waitMsg = infoPhase ? 'Flip the sample — put the LABEL in the box'
                            : 'Adjust the colour, then take the PSku photo';
  setState(ready?'ready':(fails.length?'retake':'wait'), ready?okMsg:(fails.length?FRIENDLY_FAIL(fails[0]):waitMsg));
  $('#shutter').disabled = busy;   // always operator-tappable when idle
  // optional hands-free auto-fire — PSku phase ONLY (the Info photo is always a manual, operator-paced shot)
  const now=Date.now();
  if(!infoPhase && ready && !lowSku && !batchDone && $('#autoFire').checked && now-lastFireAt>CFG.minCadenceMs && now>backoffUntil){
    capture('auto', fails);
  }
}
function setChip(sel,cls){ const d=$(sel); if(d) d.className='d '+cls; }
function setState(cls,txt){ const s=$('#state'); s.className='state '+(cls==='ready'?'ready':cls==='retake'?'retake':'wait'); s.textContent=txt; }
// plain-English versions of the quality-gate failure keys (for the big status line)
const FRIENDLY_FAIL_MSG={ blurry:'Hold steady — it looks blurry', clipping:'Light too harsh — soften it', exposure:'Light changed — check it', 'no sample in frame':'Put a sample in the box' };
function FRIENDLY_FAIL(k){ return FRIENDLY_FAIL_MSG[k]||('Check: '+k); }
// ⓘ details toggle — reveal/hide the technical readouts (chips + debug numbers)
{ const b=$('#detBtn'); if(b) b.onclick=()=>{ const d=$('#details'); if(d) d.hidden=!d.hidden; }; }

// ── OCR the label → SKU (uses existing /api/identify; debounced + scene-cached) ──
async function maybeOcr(g){
  const key = Math.round(g.data.width/32)+'x'+Math.round(stats(g.data,null).luma/6);  // crude scene bucket
  if(key===lastOcrKey) return; lastOcrKey=key;
  const manual=$('#skuInput').value.trim(); if(manual){ curSku=manual; skuConf=1; setSkuChip(); return; }
  ocrPending=true; setChip('#dSku','warn'); $('#skuTxt').textContent='reading…';
  try{
    const cnv=document.createElement('canvas'); const scale=Math.min(1,1000/Math.max(g.data.width,g.data.height));
    cnv.width=Math.round(g.data.width); cnv.height=Math.round(g.data.height);
    cnv.getContext('2d').putImageData(g.data,0,0);
    const small=document.createElement('canvas'); small.width=Math.round(cnv.width*scale); small.height=Math.round(cnv.height*scale);
    small.getContext('2d').drawImage(cnv,0,0,small.width,small.height);
    const dataUrl=small.toDataURL('image/jpeg',0.8);
    const ac=new AbortController(); const to=setTimeout(()=>ac.abort(), CFG.ocrTimeoutMs);
    let j;
    try{
      const r=await fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl}),signal:ac.signal});
      j=await r.json();
    } finally { clearTimeout(to); }
    batchCost += (j.cost_usd||0);                // $ cost (0 = local; external engines report their own)
    if(j.code){ curSku=j.code; skuConf=(j.confidence??0.6); $('#skuInput').value=curSku; }
    else { skuConf=0; }
    setSkuChip();
  }catch(e){ setChip('#dSku','warn'); $('#skuTxt').textContent='type SKU'; }   // OCR slow/unavailable → manual entry
  ocrPending=false;
}
function setSkuChip(){
  const s=curSku||$('#skuInput').value.trim();
  if(!s){ setChip('#dSku','err'); $('#skuTxt').textContent='—'; return; }
  const ok = skuConf>=CFG.skuConfMin;
  setChip('#dSku', ok?'ok':'warn'); $('#skuTxt').textContent = s + (ok?' ✓':' ?');
}
$('#skuInput').addEventListener('input', ()=>{ curSku=$('#skuInput').value.trim(); skuConf=1; setSkuChip(); });
{ const bp=$('#bPrev'); if(bp) bp.onclick=()=>{ toast('Last: '+ (window._lastSku||'—')); }; }

// ── manual shutter ──
$('#shutter').onclick=()=>{ if(!busy) capture('manual', []); };
// BT shutter / keyboard: volume keys, space, enter (don't rely on it — big on-screen button too)
document.addEventListener('keydown',(e)=>{ if($('#vShoot').hidden) return;
  if([' ','Enter','AudioVolumeUp','AudioVolumeDown'].includes(e.key)){ e.preventDefault(); if(!busy) capture('bt', []); } });

// ── grab one full-res photo, baking in the SAME colour pipeline the live preview shows ──
async function grabPhoto(){
  const v=$('#v'); const t0=performance.now();
  const bmp=await createImageBitmap(v);              // full sensor frame
  const oc=document.createElement('canvas'); oc.width=bmp.width; oc.height=bmp.height;
  oc.getContext('2d').drawImage(bmp,0,0);
  const original=await blobOf(oc, CFG.jpegQ);         // ORIGINAL (untouched, native res)
  const cr=cal?cal.crop:{x:0,y:0,w:1,h:1};
  const sx=Math.round(cr.x*bmp.width), sy=Math.round(cr.y*bmp.height), sw=Math.round(cr.w*bmp.width), sh=Math.round(cr.h*bmp.height);
  const mc=document.createElement('canvas'); mc.width=sw; mc.height=sh; const mx=mc.getContext('2d',{willReadFrequently:true});
  mx.drawImage(bmp, sx,sy,sw,sh, 0,0, sw,sh);
  applyPipeline(mx, sw, sh, true);                    // MASTER = crop → WB × exposure × manual tune (WYSIWYG) + unsharp
  const master=await blobOf(mc, CFG.jpegQ);
  const scale=Math.min(1, CFG.webLong/Math.max(sw,sh));
  const wc=document.createElement('canvas'); wc.width=Math.round(sw*scale); wc.height=Math.round(sh*scale);
  wc.getContext('2d').drawImage(mc,0,0,wc.width,wc.height);
  const web=await blobOf(wc, 0.85);                   // WEB = downscale master
  bmp.close&&bmp.close();
  return { original, master, web, dt:performance.now()-t0 };
}
// one-shot label identify (fills every field it can read)
async function identifyBlob(blob){
  try{
    const dataUrl=await b2d(blob);
    const ac=new AbortController(); const to=setTimeout(()=>ac.abort(), CFG.ocrTimeoutMs); let j;
    try{ const r=await fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl}),signal:ac.signal}); j=await r.json(); }
    finally{ clearTimeout(to); }
    batchCost += (j&&j.cost_usd||0); return j||{};
  }catch(e){ return {}; }
}
function fillFields(info){
  if(!info) return;
  if(info.code){ curSku=String(info.code).toUpperCase(); skuConf=(info.confidence??0.6); $('#skuInput').value=curSku; setSkuChip(); }
  const ven=info.vendor||info.brand; const vf=$('#fVendorField'); if(ven && vf && !vf.value.trim()) vf.value=ven;
}
function metaFields(){ return { vendor_field:($('#fVendorField').value.trim()||null), pattern:($('#fPattern').value.trim()||null), color:($('#fColor').value.trim()||null) }; }
function resetForNextSample(){ curSku=''; skuConf=1; lastOcrKey=''; $('#skuInput').value=''; $('#fPattern').value=''; $('#fColor').value=''; setSkuChip(); phase='psku'; pendingFront=null; setSideBadge(); }
async function saveShot(photo, sku, m){
  await enqueue({ sessionId:session.id, sku, seq:m._seq, vendor:session.vendor, collection:session.collection,
    original:photo.original, master:photo.master, web:photo.web,
    meta:{ via:m.via||'manual', at:new Date().toISOString(), skuConf, side:m.side||null, single:!!m.single, ...metaFields() } });
  window._lastSku=sku;
  showThumb(m.side==='info'?'back':'front', photo.web);
  drainQueue();
}
// TK-12228: keep the last sample's FRONT (PSku) + BACK (Info) photos on screen. The object URL lives until
// that slot is replaced (the old 8s revoke could blank a thumb before it was opened full-size).
const _thumbUrl={front:null,back:null};
function showThumb(side, blob){
  const wrap=$(side==='back'?'#thumbBWrap':'#thumbFWrap'), img=$(side==='back'?'#thumbB':'#thumb'); if(!wrap||!img||!blob) return;
  if(_thumbUrl[side]) URL.revokeObjectURL(_thumbUrl[side]);
  _thumbUrl[side]=URL.createObjectURL(blob); img.src=_thumbUrl[side]; wrap.hidden=false;
}
function clearThumb(side){ const wrap=$(side==='back'?'#thumbBWrap':'#thumbFWrap'); if(wrap) wrap.hidden=true;
  if(_thumbUrl[side]){ URL.revokeObjectURL(_thumbUrl[side]); _thumbUrl[side]=null; } const img=$(side==='back'?'#thumbB':'#thumb'); if(img) img.removeAttribute('src'); }
['thumbFWrap','thumbBWrap'].forEach(id=>{ const w=document.getElementById(id); if(w) w.addEventListener('click',()=>{ const im=w.querySelector('img');
  if(!im||!im.src) return; $('#bLbImg').src=im.src; $('#bLbCap').textContent=id==='thumbBWrap'?'BACK · label':'FRONT · pattern'; $('#bLb').hidden=false; }); });
$('#bLbClose').addEventListener('click',()=>{ $('#bLb').hidden=true; });
// ── CAPTURE: PSku photo first; if the code is already on it → one entry, else request the Info photo ──
async function capture(via, fails){
  if(busy) return;
  if(fails&&fails.length){ showRetake(fails[0], fails.join(', ')); return; }
  busy=true; $('#shutter').disabled=true;
  const f=$('#flash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),120);
  if(navigator.vibrate) try{navigator.vibrate(30);}catch(e){}
  try{
    const photo=await grabPhoto();
    if(photo.dt>CFG.toBlobSlowMs){ backoffUntil=Date.now()+3000; toast('Slowing cadence (device warm)'); }  // thermal governor
    if(phase==='psku'){
      // is the code already on THIS photo? live OCR sets curSku when it can read one; else ask once.
      const known=(curSku||$('#skuInput').value.trim());
      let info = known ? {} : await identifyBlob(photo.web);
      const code=String(known||info.code||'').replace(/[^A-Za-z0-9._-]/g,'').toUpperCase();
      if(code){                                    // info-on-front → ONE entry, no second photo
        fillFields(info.code?info:{code, confidence:skuConf});
        session.n=(session.n||0)+1; saveJSON(LS_SESSION,session); $('#hN').textContent=session.n;
        clearThumb('back');                                // single-photo sample: no back to show
        await saveShot(photo, code, { via, side:null, single:true, _seq:session.n });
        toast('✓ '+code+' saved — all fields filled'); lastFireAt=Date.now(); resetForNextSample();
      } else {                                     // pattern-only front → hold it, request the Info photo
        pendingFront=photo; phase='info'; setSideBadge();
        showThumb('front', photo.web); clearThumb('back');   // TK-12228: the FRONT shows the moment it's taken
        setState('wait','Flip the sample — put the LABEL in the box, then take the INFO photo');
        toast('Now the Info photo (the label side)');
      }
    } else {                                       // INFO photo (manual) → read code+fields, key the pair
      const info=await identifyBlob(photo.web); fillFields(info);
      const sku=String(curSku||$('#skuInput').value.trim()||info.code||'').replace(/[^A-Za-z0-9._-]/g,'').toUpperCase();
      if(!sku){ toast('No code found — type it in, then take the Info photo again'); busy=false; $('#shutter').disabled=false; return; }
      session.n=(session.n||0)+1; saveJSON(LS_SESSION,session); $('#hN').textContent=session.n; const seq=session.n;
      if(pendingFront){ await saveShot(pendingFront, sku, { via, side:'psku', _seq:seq }); }
      await saveShot(photo, sku, { via, side:'info', _seq:seq });
      toast('✓ '+sku+' saved (PSku + Info)'); lastFireAt=Date.now(); resetForNextSample();
    }
  }catch(e){ toast('Capture failed: '+e.message); }
  busy=false; $('#shutter').disabled=false;
}
function blobOf(canvas,q){ return new Promise(res=>{ if(canvas.toBlob) canvas.toBlob(b=>res(b),'image/jpeg',q); else res(dataURLtoBlob(canvas.toDataURL('image/jpeg',q))); }); }
function dataURLtoBlob(u){ const [h,b]=u.split(','); const bin=atob(b); const a=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)a[i]=bin.charCodeAt(i); return new Blob([a],{type:(h.match(/:(.*?);/)||[])[1]||'image/jpeg'}); }

function showRetake(why,meta){ $('#retakeWhy').textContent=RETAKE_MSG[why]||('Quality check failed: '+why); $('#retakeMeta').textContent=meta||''; $('#bigRetake').hidden=false; setState('retake','RETAKE'); }
const RETAKE_MSG={ blurry:'Too blurry — hold the stand steady / let focus settle.', clipping:'Blown highlights or crushed shadows — adjust the light.', exposure:'Exposure drifted from calibration — check the light.', 'no sample in frame':'No sample detected in the fill box — place the next sample.' };
$('#retakeOk').onclick=()=>{ $('#bigRetake').hidden=true; };
$('#recalGo').onclick=()=>{ $('#bigRecal').hidden=true; enterCalib(); };

// ════════════════════════ DURABLE QUEUE (IndexedDB) ════════════════════════
let _db=null;
function db(){ return new Promise((res,rej)=>{ if(_db)return res(_db); const q=indexedDB.open('dwbatch',1);
  q.onupgradeneeded=()=>{ q.result.createObjectStore('q',{keyPath:'k'}); };
  q.onsuccess=()=>{ _db=q.result; res(_db); }; q.onerror=()=>rej(q.error); }); }
async function enqueue(item){ const d=await db(); const k=item.sessionId+'|'+item.sku+'|'+item.seq+'|'+((item.meta&&item.meta.side)||'x');
  return new Promise((res,rej)=>{ const tx=d.transaction('q','readwrite'); tx.objectStore('q').put({k, item, state:'queued', tries:0}); tx.oncomplete=()=>{ updateQueueTxt(); res(); }; tx.onerror=()=>rej(tx.error); }); }
async function allQ(){ const d=await db(); return new Promise((res)=>{ const out=[]; const tx=d.transaction('q','readonly'); const cur=tx.objectStore('q').openCursor(); cur.onsuccess=e=>{ const c=e.target.result; if(c){ out.push(c.value); c.continue(); } else res(out); }; }); }
async function delQ(k){ const d=await db(); return new Promise(res=>{ const tx=d.transaction('q','readwrite'); tx.objectStore('q').delete(k); tx.oncomplete=res; }); }
async function markQ(k,state,tries){ const d=await db(); return new Promise(res=>{ const tx=d.transaction('q','readwrite'); const st=tx.objectStore('q'); const g=st.get(k); g.onsuccess=()=>{ const val=g.result; if(val){ val.state=state; val.tries=tries; st.put(val); } }; tx.oncomplete=res; }); }
let draining=false;
async function drainQueue(){
  if(draining) return; draining=true;
  try{
    // RELOAD RECOVERY: a row left 'uploading' by a killed/reloaded/backgrounded pass would
    // otherwise be skipped forever (a silently-dropped shot). Reclaim any to 'queued' first.
    const snap=await allQ();
    for(const r of snap){ if(r.state==='uploading'){ await markQ(r.k,'queued',r.tries||0); } }
    let rows=(await allQ()).filter(r=>r.state==='queued');   // 'done' + 'failed' excluded
    // concurrency 1 — gentle on iPad + prod
    for(const r of rows){
      const tries=r.tries||0;
      if(tries>=CFG.maxTries){ await markQ(r.k,'failed',tries); updateQueueTxt(); continue; }  // parked, NEVER deleted
      await markQ(r.k,'uploading',tries);
      try{
        const body={ sessionId:r.item.sessionId, sku:r.item.sku, seq:r.item.seq, vendor:r.item.vendor, collection:r.item.collection,
          original:await b2d(r.item.original), master:await b2d(r.item.master), web:await b2d(r.item.web), meta:r.item.meta };
        const resp=await fetch('/api/batch-shot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
        const j=await resp.json();
        if(j&&j.ok){ await delQ(r.k); }
        else { const nt=tries+1; await markQ(r.k, nt>=CFG.maxTries?'failed':'queued', nt); }
      }catch(e){ const nt=tries+1; await markQ(r.k, nt>=CFG.maxTries?'failed':'queued', nt); }
      updateQueueTxt();
    }
  }finally{ draining=false; updateQueueTxt(); }
}
function b2d(blob){ return new Promise((res,rej)=>{ const fr=new FileReader(); fr.onload=()=>res(fr.result); fr.onerror=rej; fr.readAsDataURL(blob); }); }
async function updateQueueTxt(){ try{ const rows=await allQ();
  const q=rows.filter(r=>r.state==='queued').length, up=rows.filter(r=>r.state==='uploading').length, fail=rows.filter(r=>r.state==='failed').length;
  const el=$('#queueTxt'); if(!el) return;
  el.textContent = `queue ${q} · uploading ${up}${fail?(' · ⚠ '+fail+' FAILED — tap to retry'):''} · cost $${batchCost.toFixed(3)}`;
  el.style.cursor = fail?'pointer':''; el.onclick = fail?requeueFailed:null;
}catch(e){} }
async function requeueFailed(){ const rows=await allQ(); let m=0;
  for(const r of rows){ if(r.state==='failed'){ await markQ(r.k,'queued',0); m++; } }
  if(m){ toast('Retrying '+m+' failed upload'+(m>1?'s':'')); drainQueue(); } }
setInterval(()=>{ if(!$('#vShoot').hidden) drainQueue(); }, 6000);   // retry loop for offline/failed
window.addEventListener('online', drainQueue);

// setup-view help text
$('#setupQ').textContent='How each sample works: 1) put the sample in the box, 2) adjust the colour on screen (Brightness / Warmth / Hue — your setting sticks), 3) take the PSku photo. If the code isn’t on that photo, the app asks for the Info (label) photo and reads the code + fields from it. Drag the tools panel anywhere; tap ▾ to shrink it.';
</script>
</body>
</html>