[object Object]

← back to Dw Photo Capture

batch: add Batch Shoot Mode (production-line capture for 400+ samples)

acf1bf8d56cd21288c0bd72d08dd9925514f94cc · 2026-09-18 10:21:49 -0700 · Steve Abrams

New additive PWA mode at /batch + /api/batch-shot — does not touch the
single-shot flow. Calibrate-once (gray-card WB + exposure target + crop),
two-resolution pipeline (gates/motion on ~1280px, full-res only on trigger),
quality/RETAKE gate (blur/clip/exposure/fill), software drift-lock forcing
re-cal, auto SKU via existing /api/identify, hands-free motion-stop + BT
shutter, thermal cadence governor, and a durable IndexedDB upload queue.
Server writes <SKU>_original/_master/_web.jpg under photos/batch/<session>/
with an append-only manifest; /photos/ generalized to serve the subtree
(traversal-guarded). No Shopify writes (cataloging step). v1.1: ColorChecker
CCM+ΔE, perspective warp, Shopify batch-attach. TK-11909.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uns3k4K15WBcFJNr3GcUd3

Files touched

Diff

commit acf1bf8d56cd21288c0bd72d08dd9925514f94cc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 10:21:49 2026 -0700

    batch: add Batch Shoot Mode (production-line capture for 400+ samples)
    
    New additive PWA mode at /batch + /api/batch-shot — does not touch the
    single-shot flow. Calibrate-once (gray-card WB + exposure target + crop),
    two-resolution pipeline (gates/motion on ~1280px, full-res only on trigger),
    quality/RETAKE gate (blur/clip/exposure/fill), software drift-lock forcing
    re-cal, auto SKU via existing /api/identify, hands-free motion-stop + BT
    shutter, thermal cadence governor, and a durable IndexedDB upload queue.
    Server writes <SKU>_original/_master/_web.jpg under photos/batch/<session>/
    with an append-only manifest; /photos/ generalized to serve the subtree
    (traversal-guarded). No Shopify writes (cataloging step). v1.1: ColorChecker
    CCM+ΔE, perspective warp, Shopify batch-attach. TK-11909.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Uns3k4K15WBcFJNr3GcUd3
---
 DEVICE-TEST.md    |  51 +++++
 public/batch.html | 549 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |  64 ++++++-
 3 files changed, 662 insertions(+), 2 deletions(-)

diff --git a/DEVICE-TEST.md b/DEVICE-TEST.md
index 8f63923..99bf99a 100644
--- a/DEVICE-TEST.md
+++ b/DEVICE-TEST.md
@@ -65,3 +65,54 @@ measure paths that only a real device can exercise.
 - Camera won't open → you're not in Safari, or camera permission denied.
 - Measure wildly off → Recalibrate (↺) and hold at a consistent distance.
 - Video "N failed" → clip too big (>150MB) or a transient upload error; retry.
+
+---
+
+# Batch Shoot Mode — real-device test checklist (iPad, TK-11909)
+
+Production-line capture for 400+ samples on a **fixed copy-stand with fixed light**. Open
+`photo.designerwallcoverings.com/batch` in **Safari** (login `admin` / `DW2024!`), iPad plugged in,
+auto-lock set to Never.
+
+> **Headless-verified already (server side):** `/batch` serves · `/api/batch-shot` writes
+> `<SKU>_original/_master/_web.jpg` under `photos/batch/<session>/` · idempotent re-shoot ·
+> per-session `data/batch-sessions/<session>.jsonl` manifest · nested `/photos/…` read-back ·
+> path-traversal guard (404) · existing `/api/photo` + `/cam` untouched.
+> **Everything below needs a real iPad camera — it cannot be exercised headless.**
+
+## 1. Session + calibrate
+- [ ] `/batch` → fill **Vendor / Collection / Date / target count** → **Start batch**.
+- [ ] Calibrate screen: lay a **neutral gray card** filling the green box, gray patch in the center
+      square → **Capture calibration**. Toast shows WB gains + luma. (Re-open the batch → it skips
+      straight to shooting; calibration persisted per session in localStorage.)
+
+## 2. Continuous shoot
+- [ ] Place sample #1 → within a beat the top shows **BATCH: <vendor> <collection>**, the **N / count**
+      counter, and status chips (CAM / WB·LOCK / EXP·LOCK / FOCUS / CAL / SKU / Δ).
+- [ ] **SKU auto-fill:** the label OCR (`/api/identify`, Gemini ≈ $0.0006/read) fills the SKU; low
+      confidence shows `?` — type/scan to override. The **queue · cost $** line climbs.
+- [ ] **Hands-free:** with *hands-free* ticked, removing one sample and placing the next auto-fires
+      once the frame settles + passes the gate (≥1.5 s cadence governor). Toggle off → use the big
+      shutter or a **BT shutter** (volume/space/enter).
+- [ ] **Quality gate:** deliberately shoot blurry / too-dark / empty-stand → a big red **RETAKE**
+      appears and it does **not** enqueue. A good shot flips the state to green **READY**.
+- [ ] **Software lock / drift:** nudge the light or the stand → CAL chip flips **DRIFT** and a
+      **RE-CALIBRATE** overlay blocks capture until you re-shoot the gray card. *(This is the
+      substitute for the hardware WB/exposure lock iOS Safari can't give.)*
+
+## 3. Outputs + durability
+- [ ] After ~10 shots, confirm on the server: `photos/batch/<session>/` holds
+      `<SKU>_original.jpg` (untouched), `_master.jpg` (WB+exposure-corrected, cropped),
+      `_web.jpg` (~1600px), and the session `.jsonl` manifest has one line per shot.
+- [ ] **Reload mid-batch** (durable queue): pull-to-refresh while a few are still uploading →
+      the queue count survives the reload and keeps draining (IndexedDB). Nothing is lost offline.
+- [ ] **Thermal:** during a long run, if the device warms the cadence should visibly back off
+      (toast "Slowing cadence") rather than stutter/crash.
+
+## Batch — known limits (v1)
+- **No RAW/DNG, no 16-bit TIF** — Safari gives only 8-bit JPEG frames; masters are corrected JPEGs.
+- **WB/exposure/focus "LOCK" is software** (enforced against the calibration frame), not hardware.
+- **Master crop is axis-aligned** (copy-stand square-on). True 4-corner perspective warp + 24-patch
+  ColorChecker CCM/ΔE + a Shopify batch-attach are the **v1.1** backlog.
+- Two samples that OCR to the **same SKU** in one session overwrite each other's files (the manifest
+  still records both by seq+timestamp) — resolve duplicate SKUs at the keypad.
diff --git a/public/batch.html b/public/batch.html
new file mode 100644
index 0000000..1aa61bf
--- /dev/null
+++ b/public/batch.html
@@ -0,0 +1,549 @@
+<!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>
+<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">
+<title>DW Batch Shoot</title>
+<style>
+  :root{ --bg:#0f0e0c; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; --amber:#d8a53a; }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:#000;color:var(--ink);font:15px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;-webkit-text-size-adjust:100%;overscroll-behavior:none}
+  button{font-family:inherit}
+  .btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:13px 22px;font-weight:700;font-size:15px;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}
+  canvas.ov{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
+  #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}
+  .state{font:800 15px/1 "SF Pro Display",sans-serif;letter-spacing:.06em;text-transform:uppercase;min-height:18px;text-align:center}
+  .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}
+</style>
+</head>
+<body>
+
+<!-- ══════════ VIEW 1 · SESSION SETUP ══════════ -->
+<div class="view" id="vSetup">
+  <div class="pad">
+    <h1><b>DW</b> Batch Shoot <span class="ver">__VER__</span></h1>
+    <p class="sub">Production-line capture — calibrate once, shoot the whole line. Fixed stand + fixed light.</p>
+    <div class="note">iPad Safari can't hardware-lock white-balance / exposure / focus, so this app <b>software-locks</b>: it stores a calibration frame and checks every shot against it, auto-pausing on drift. No RAW/DNG in a web app — masters are color-corrected JPEGs. Keep the lights fixed and the iPad plugged in.</div>
+    <label class="fld"><span>Vendor</span><input id="fVendor" placeholder="Phillip Jeffries" autocapitalize="words"></label>
+    <label class="fld"><span>Collection</span><input id="fCollection" placeholder="Grasscloth" autocapitalize="words"></label>
+    <label class="fld"><span>Date</span><input id="fDate" type="date"></label>
+    <label class="fld"><span>Target count (for the N / count display)</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>↩ Resume last batch</button>
+      <button class="btn primary" id="bStart" style="margin-left:auto">Start batch →</button>
+    </div>
+    <p class="qmeta" id="setupQ" style="margin-top:16px;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>Calibrate</b> · gray card</span></div>
+  <div class="foot">
+    <div class="state wait" id="cState">Lay a NEUTRAL GRAY CARD flat, filling the stand. Sample-fill box = green, gray patch = the small square.</div>
+    <div class="row">
+      <button class="btn ghost" id="cBack">← Back</button>
+      <button class="btn primary" id="cSave">✓ Capture calibration</button>
+    </div>
+  </div>
+  <div class="overlay" id="cGate">
+    <h2>Start the camera</h2>
+    <p>Tap to allow camera access, then point at the copy-stand.</p>
+    <button class="btn primary" id="cStartCam">📷 Start 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="ov" id="ov"></canvas></div>
+  <div id="flash"></div>
+
+  <div class="bar">
+    <span class="title">BATCH: <b id="hBatch">—</b></span>
+    <span class="count"><span id="hN">0</span> / <span id="hTotal">—</span></span>
+  </div>
+  <div class="chips">
+    <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>
+  </div>
+
+  <div class="foot">
+    <div class="qmeta" id="qmeta"></div>
+    <div class="state wait" id="state">WAITING</div>
+    <div class="skuline">
+      <button class="btn ghost" id="bPrev" title="Previous">◀</button>
+      <input id="skuInput" placeholder="SKU (auto / type)" autocapitalize="characters" autocomplete="off" spellcheck="false">
+      <button class="btn ghost" id="bNext" title="Next SKU">Next ▶</button>
+    </div>
+    <div class="row">
+      <label class="chip" style="gap:6px"><input type="checkbox" id="autoFire" style="width:auto" checked> hands-free</label>
+      <button class="shutter" id="shutter" title="Capture"></button>
+      <img class="thumb" id="thumb" alt="last" hidden>
+    </div>
+    <div class="qmeta" id="queueTxt"></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>RE-CALIBRATE</h2><p>The scene drifted from the calibration frame (light or stand moved). Re-shoot the gray card to keep color consistent.</p><small id="recalMeta"></small><button class="btn primary" id="recalGo">Re-calibrate</button></div>
+  <div class="overlay" id="gate"><h2>Start the camera</h2><p>Tap to allow camera access. Point at the first sample on the stand.</p><button class="btn primary" id="startCam">📷 Start Camera</button></div>
+  <div class="toast" id="toast"></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
+};
+
+// ── 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, 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) ════════════════════════
+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 err=null;
+  for(const c of attempts){ try{ stream=await navigator.mediaDevices.getUserMedia(c); break; }catch(e){ err=e; } }
+  if(!stream){ toast('Camera blocked ('+((err&&err.name)||'error')+') — needs HTTPS on iOS'); return false; }
+  track=stream.getVideoTracks()[0];
+  videoEl.srcObject=stream;
+  videoEl.onloadedmetadata=()=>{ videoEl.play().catch(()=>{}); };
+  track.addEventListener('ended',()=>{ camLive=false; setCamDot(); });
+  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){ const {data}=img; let n=0,c=0; for(let i=0;i<data.length;i+=16){ 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){         // sharpness: variance of 4-neighbour Laplacian on grayscale
+  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]; };
+  for(let y=1;y<height-1;y+=2){ for(let x=1;x<width-1;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; enterShoot(); }; }
+})();
+$('#bStart').onclick=()=>{
+  const vendor=$('#fVendor').value.trim(), 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)
+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_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; }
+  // 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 exposure anchor
+    grayLuma: gray.luma,
+    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;
+function enterShoot(){
+  show('vShoot');
+  $('#hBatch').textContent=(session.vendor+' '+(session.collection||'')).trim();
+  $('#hTotal').textContent=session.total||'—'; $('#hN').textContent=session.n||0;
+  const v=$('#v'); $('#gate').hidden=false;
+  $('#startCam').onclick=async()=>{ if(await startCameraInto(v)){ $('#gate').hidden=true; startGateLoop(); drawShootOverlayLoop(); } };
+  if(stream){ v.srcObject=stream; $('#gate').hidden=true; startGateLoop(); drawShootOverlayLoop(); }
+  drainQueue();
+}
+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);
+  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) return;
+  const v=$('#v'); const g=gateGrab(v); if(!g){ return; } lastGate=g;
+  const whole=stats(g.data,null);
+  const tgt=stats(g.data, CAL_TARGET);
+  const clip=clipFrac(g.data);
+  const blur=varLaplacian(g.data);
+  const expDev=cal?Math.abs(whole.luma-cal.lumaTarget):0;
+  // WB drift: compare current per-channel ratios (to green) against cal's
+  let wbDrift=0;
+  if(cal){ const cr=(whole.g||1)/(whole.r||1), cb=(whole.g||1)/(whole.b||1);
+    wbDrift=Math.max(Math.abs(cr-cal.wbR)/cal.wbR, Math.abs(cb-cal.wbB)/cal.wbB); }
+  // motion / stillness
+  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');
+  const drift = cal && (expDev>CFG.driftLumaMax || wbDrift>CFG.driftWBMax);
+  // status chips
+  setChip('#dCal', drift?'err':(cal?'ok':'warn')); $('#calTxt').textContent = 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='Δluma '+expDev.toFixed(0)+' · ΔWB '+(wbDrift*100).toFixed(0)+'%'; $('#bigRecal').hidden=false; setState('wait','RE-CAL'); 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?
+  const ready = still && fails.length===0 && (curSku||$('#skuInput').value.trim());
+  setState(ready?'ready':(fails.length?'retake':'wait'), ready?'READY':(fails.length?('CHECK: '+fails[0]):'WAITING'));
+  $('#shutter').disabled = busy;
+  // hands-free auto-fire
+  const now=Date.now();
+  if(ready && $('#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; }
+
+// ── 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 r=await fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})});
+    const j=await r.json();
+    batchCost += (j.cost_usd||0.0006);           // $ cost tracking (Gemini ≈ $0.0006/call)
+    if(j.code){ curSku=j.code; skuConf=(j.confidence??0.6); $('#skuInput').value=curSku; }
+    else { skuConf=0; }
+    setSkuChip();
+  }catch(e){ setChip('#dSku','err'); $('#skuTxt').textContent='err'; }
+  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(); });
+$('#bNext').onclick=()=>{ curSku=''; skuConf=1; lastOcrKey=''; $('#skuInput').value=''; setSkuChip(); toast('Ready for next SKU'); };
+$('#bPrev').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', []); } });
+
+// ── CAPTURE: full-res grab (only on trigger) → original + master + web → durable queue ──
+async function capture(via, fails){
+  if(busy) return;
+  const sku=(curSku||$('#skuInput').value.trim()||'').replace(/[^A-Za-z0-9._-]/g,'').toUpperCase();
+  if(!sku){ toast('No SKU — type or scan one'); 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){}
+  const v=$('#v');
+  try{
+    const t0=performance.now();
+    const bmp=await createImageBitmap(v);            // full sensor frame
+    // ORIGINAL (untouched, native res)
+    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);
+    // MASTER = crop → WB gains → exposure normalize (8-bit; v1 crop, not full-warp)
+    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);
+    if(cal){ applyCorrection(mx, sw, sh); }
+    const master=await blobOf(mc, CFG.jpegQ);
+    // WEB = downscale master
+    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);
+    bmp.close&&bmp.close();
+    const dt=performance.now()-t0;
+    if(dt>CFG.toBlobSlowMs){ backoffUntil=Date.now()+3000; toast('Slowing cadence (device warm)'); }   // thermal governor
+    // enqueue durably (survives reload); uploader drains async
+    session.n=(session.n||0)+1; saveJSON(LS_SESSION,session); $('#hN').textContent=session.n;
+    const seq=session.n;
+    await enqueue({ sessionId:session.id, sku, seq, vendor:session.vendor, collection:session.collection,
+                    original, master, web, meta:{ via, at:new Date().toISOString(), skuConf } });
+    window._lastSku=sku;
+    const url=URL.createObjectURL(web); const th=$('#thumb'); th.src=url; th.hidden=false; setTimeout(()=>URL.revokeObjectURL(url),8000);
+    lastFireAt=Date.now(); lastOcrKey='';           // arm for the next sample
+    if($('#autoFire').checked){ curSku=''; $('#skuInput').value=''; setSkuChip(); }  // auto-advance in hands-free
+    toast('✓ '+sku+' captured ('+via+')'); drainQueue();
+  }catch(e){ toast('Capture failed: '+e.message); }
+  busy=false; $('#shutter').disabled=false;
+}
+function applyCorrection(ctx,w,h){
+  const img=ctx.getImageData(0,0,w,h); const d=img.data;
+  // exposure scale so this master's overall luma matches the calibration target
+  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 curLuma=n?sl/n:cal.lumaTarget; const es=clamp(cal.lumaTarget/(curLuma||1),0.5,2);
+  const gr=cal.wbR*es, gg=cal.wbG*es, gb=cal.wbB*es;
+  for(let i=0;i<d.length;i+=4){ d[i]=clamp(d[i]*gr,0,255); d[i+1]=clamp(d[i+1]*gg,0,255); d[i+2]=clamp(d[i+2]*gb,0,255); }
+  ctx.putImageData(img,0,0);
+}
+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;
+  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{
+    let rows=(await allQ()).filter(r=>r.state!=='done');
+    // concurrency 1–2; keep it 1 to stay gentle on iPad + prod
+    for(const r of rows){
+      if(r.state==='uploading') continue;
+      await markQ(r.k,'uploading',r.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 { await markQ(r.k,'queued',(r.tries||0)+1); }
+      }catch(e){ await markQ(r.k,'queued',(r.tries||0)+1); }
+      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.tries||0)>=3).length;
+  $('#queueTxt').textContent = `queue ${q} · uploading ${up}${fail?(' · '+fail+' stuck'):''} · cost $${batchCost.toFixed(3)}`; }catch(e){} }
+setInterval(()=>{ if(!$('#vShoot').hidden) drainQueue(); }, 6000);   // retry loop for offline/failed
+window.addEventListener('online', drainQueue);
+
+// setup-view help text
+$('#setupQ').textContent='v1 ships: gray-card WB + exposure normalize · quality gate (blur/clip/exposure/fill) · auto SKU via label OCR · hands-free motion-stop + BT shutter · durable upload queue.\nv1.1: 24-patch ColorChecker CCM + ΔE, Shopify batch-attach, true perspective warp, thermal auto-tune.';
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 3336bb3..e02adc2 100644
--- a/server.js
+++ b/server.js
@@ -1279,6 +1279,60 @@ const appHandler = (req, res) => {
     return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
   }
 
+  // ── Batch Shoot Mode (production-line capture for 400+ samples) — additive, self-contained page.
+  //    Runs the iPad's OWN camera (not the SSE-slaved /cam remote flow). See public/batch.html.
+  if (u.pathname === '/batch') {
+    const f = path.join(ROOT, 'public/batch.html');
+    if (!fs.existsSync(f)) return send(res, 404, { err: 'batch.html missing' });
+    let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
+    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
+  }
+
+  // ── /api/batch-shot: accept one batch capture's three variants and write them to disk under a
+  //    per-session subdir as <SKU>_original.jpg / _master.jpg / _web.jpg. Idempotent by the filename
+  //    (a re-shoot of the same SKU in the same session overwrites). Does NOT write to Shopify — batch
+  //    capture is a cataloging step; a Shopify batch-attach is a separate v1.1 action. Body ≤ 90MB
+  //    (3 JPEGs, base64-inflated). A per-session manifest.jsonl records every accepted shot.
+  if (u.pathname === '/api/batch-shot' && req.method === 'POST') {
+    let body = '';
+    let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 90 * 1024 * 1024) _big = true; });
+    req.on('end', () => {
+      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
+      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
+      const { sessionId, sku, seq, vendor, collection, original, master, web, meta } = p || {};
+      if (!sessionId || !sku) return send(res, 400, { err: 'sessionId + sku required' });
+      if (!original && !master && !web) return send(res, 400, { err: 'at least one image variant required' });
+      // sanitize to safe path segments (basename-only; reject traversal)
+      const safeSession = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 120);
+      const safeSku = String(sku).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 80);
+      if (!safeSession || !safeSku) return send(res, 400, { err: 'bad sessionId/sku' });
+      const dir = path.join(PHOTOS, 'batch', safeSession);
+      if (!dir.startsWith(path.join(PHOTOS, 'batch') + path.sep)) return send(res, 400, { err: 'path' });
+      try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return send(res, 500, { err: 'mkdir failed' }); }
+      const variants = { original, master, web };
+      const paths = {}; const errors = [];
+      for (const [k, v] of Object.entries(variants)) {
+        if (!v) continue;
+        let buf; try { buf = Buffer.from(String(v).replace(/^data:image\/\w+;base64,/, ''), 'base64'); }
+        catch (e) { errors.push(k + ': decode'); continue; }
+        if (buf.length < 200 || buf.length > 40 * 1024 * 1024) { errors.push(k + ': size ' + buf.length); continue; }
+        const fname = `${safeSku}_${k}.jpg`;
+        try { fs.writeFileSync(path.join(dir, fname), buf); paths[k] = `/photos/batch/${safeSession}/${fname}`; }
+        catch (e) { errors.push(k + ': write'); }
+      }
+      // durable per-session manifest (append-only) so a session is reconstructable off-device
+      try {
+        const mdir = path.join(DATA, 'batch-sessions'); fs.mkdirSync(mdir, { recursive: true });
+        const rec = { at: new Date().toISOString(), sessionId: safeSession, sku: safeSku, seq: seq ?? null,
+          vendor: vendor || null, collection: collection || null, paths, meta: meta || null };
+        fs.appendFileSync(path.join(mdir, safeSession + '.jsonl'), JSON.stringify(rec) + '\n');
+      } catch (e) { /* manifest is best-effort; the files are the source of truth */ }
+      const ok = Object.keys(paths).length > 0;
+      return send(res, ok ? 200 : 500, { ok, sku: safeSku, seq: seq ?? null, paths, errors });
+    });
+    return;
+  }
+
   // ── Vendor-profile learning surface ──
   // What the app has learned about how each vendor labels their samples.
   if (u.pathname === '/api/vendor-profiles' && req.method === 'GET') {
@@ -1990,8 +2044,14 @@ load();
   }
 
   if (u.pathname.startsWith('/photos/')) {
-    const f = path.join(PHOTOS, path.basename(u.pathname));
-    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(fs.readFileSync(f)); }
+    // Serve any file under PHOTOS, including the batch/<session>/ subtree, with a strict
+    // path-traversal guard (resolved path must stay inside PHOTOS). Backward-compatible with
+    // the old basename-only form (a bare /photos/<file>.jpg still resolves).
+    let rel; try { rel = decodeURIComponent(u.pathname.slice('/photos/'.length)); } catch (e) { rel = ''; }
+    const f = path.join(PHOTOS, rel);
+    if (f.startsWith(PHOTOS + path.sep) && fs.existsSync(f) && fs.statSync(f).isFile()) {
+      res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(fs.readFileSync(f));
+    }
     return send(res, 404, { err: 'not found' });
   }
 

← 84755d9 auto-data-snapshot: 2026-09-18T04:37:40 (1 data files) — dat  ·  back to Dw Photo Capture  ·  auto-data-snapshot: 2026-09-18T10:30:02 (1 data files) — dat 374d995 →