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