[object Object]

← back to Dw Photo Capture

Batch shoot: live WYSIWYG colour tools + PSku/Info two-shot flow + TWIL vendor

243f2678e88de85455b3c62542ce1c7c9560107b · 2026-09-19 10:02:00 -0700 · Steve Abrams

public/batch.html:
- Live WYSIWYG preview (#pv) runs the SAME colour pipeline as capture, so what you
  adjust on the open camera is exactly what's saved (no more silent after-capture fix).
- Movable + expand/contract photo-tools panel (center-bottom on load, position/collapsed
  remembered), holding Brightness / Warmth / Hue sliders. Slider values are sticky
  (localStorage dwbatch.tune) so the setting carries to the next photo. Auto-snap now
  defaults OFF — each shot is operator-paced (adjust colour, then take it).
- Two-shot flow: PSku Photo first (no SKU needed). If the code is already on that photo
  (live OCR reads it) -> ONE entry, all fields filled, no second photo. Otherwise the
  front is held and the app requests a MANUAL Info (label) photo whose OCR keys the pair;
  both saved sharing sku+seq with meta.side psku/info (enqueue key now includes side).
- Full-iPad-screen layout (100dvh).

server.js:
- /api/batch-shot writes <SKU>_<side>_<variant>.jpg when meta.side is psku/info/front/back
  so a front+back pair on one SKU no longer overwrites itself; a single unsided shot keeps
  the legacy <SKU>_<variant>.jpg (back-compat with the office tool).
- VENDOR_LEX: add TWIL (Fentucci grasscloth private-label) as a pickable vendor.

Verified locally: /healthz ok, TWIL seeded, batch-shot pair wrote psku+info variants with
no collision, single shot kept legacy name, /batch serves the new UI. node --check clean.

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

Files touched

Diff

commit 243f2678e88de85455b3c62542ce1c7c9560107b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 19 10:02:00 2026 -0700

    Batch shoot: live WYSIWYG colour tools + PSku/Info two-shot flow + TWIL vendor
    
    public/batch.html:
    - Live WYSIWYG preview (#pv) runs the SAME colour pipeline as capture, so what you
      adjust on the open camera is exactly what's saved (no more silent after-capture fix).
    - Movable + expand/contract photo-tools panel (center-bottom on load, position/collapsed
      remembered), holding Brightness / Warmth / Hue sliders. Slider values are sticky
      (localStorage dwbatch.tune) so the setting carries to the next photo. Auto-snap now
      defaults OFF — each shot is operator-paced (adjust colour, then take it).
    - Two-shot flow: PSku Photo first (no SKU needed). If the code is already on that photo
      (live OCR reads it) -> ONE entry, all fields filled, no second photo. Otherwise the
      front is held and the app requests a MANUAL Info (label) photo whose OCR keys the pair;
      both saved sharing sku+seq with meta.side psku/info (enqueue key now includes side).
    - Full-iPad-screen layout (100dvh).
    
    server.js:
    - /api/batch-shot writes <SKU>_<side>_<variant>.jpg when meta.side is psku/info/front/back
      so a front+back pair on one SKU no longer overwrites itself; a single unsided shot keeps
      the legacy <SKU>_<variant>.jpg (back-compat with the office tool).
    - VENDOR_LEX: add TWIL (Fentucci grasscloth private-label) as a pickable vendor.
    
    Verified locally: /healthz ok, TWIL seeded, batch-shot pair wrote psku+info variants with
    no collision, single shot kept legacy name, /batch serves the new UI. node --check clean.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_018Xadpnv4YPkFPKu6jDygAH
---
 public/batch.html | 293 +++++++++++++++++++++++++++++++++++++++++-------------
 server.js         |   8 +-
 2 files changed, 229 insertions(+), 72 deletions(-)

diff --git a/public/batch.html b/public/batch.html
index 3eb399f..1e3af51 100644
--- a/public/batch.html
+++ b/public/batch.html
@@ -35,7 +35,8 @@
 <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}
+  html,body{margin:0;height:100%;min-height:100dvh;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:100dvh} }
   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)}
@@ -56,7 +57,10 @@
   /* ── 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}
+  /* 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 ── */
@@ -114,6 +118,28 @@
   .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:62vh;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>
@@ -165,7 +191,7 @@
 
 <!-- ══════════ 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="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">
@@ -184,20 +210,35 @@
     <span class="chip" id="qmeta" style="white-space:normal;color:var(--muted)"></span>
   </div>
 
-  <div class="foot">
-    <div class="state wait" id="state">Put a sample in the box</div>
-    <div class="skuline">
-      <button class="btn ghost" id="bPrev" title="Show last code">◀</button>
-      <input id="skuInput" placeholder="sample code — fills in by itself" autocapitalize="characters" autocomplete="off" spellcheck="false">
-      <button class="btn ghost" id="bNext" title="Next sample">Next ▶</button>
+  <!-- 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="Expand / contract">▾</button>
     </div>
-    <div class="row">
-      <button class="btn ghost" id="detBtn" title="Technical details" style="padding:12px 15px">ⓘ</button>
-      <label class="chip" style="gap:6px;font-size:12px"><input type="checkbox" id="autoFire" style="width:auto" checked> Auto-snap</label>
-      <button class="shutter" id="shutter" title="Take photo"></button>
-      <img class="thumb" id="thumb" alt="last photo" hidden>
+    <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 itself from the Info 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 last code" style="padding:12px 14px">◀</button>
+        <button class="btn ghost" id="detBtn" title="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>
+        <img class="thumb" id="thumb" alt="last photo" hidden>
+      </div>
+      <div class="qmeta" id="queueTxt"></div>
     </div>
-    <div class="qmeta" id="queueTxt"></div>
   </div>
 
   <!-- big overlays -->
@@ -398,15 +439,92 @@ $('#cSave').onclick=()=>{
 // ════════════════════════ 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.
+let tune = loadJSON('dwbatch.tune', {bright:0, warm:0, hue:0});
+
 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(); } };
-  if(stream){ v.srcObject=stream; $('#gate').hidden=true; startGateLoop(); drawShootOverlayLoop(); }
+  $('#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(){                          // per-channel gains from cal WB + manual warmth/brightness
+  const wbR=cal?cal.wbR:1, wbG=cal?cal.wbG:1, wbB=cal?cal.wbB:1;
+  const bmul=clamp(1+(tune.bright/100),0.2,2.2);       // brightness slider
+  const warm=(tune.warm||0)/100; const rW=1+warm*0.30, bW=1-warm*0.30;   // warmth: R↑ / B↓
+  return { gr:wbR*bmul*rW, gg:wbG*bmul, gb:wbB*bmul*bW };
+}
+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){
+  const img=ctx.getImageData(0,0,w,h); const d=img.data;
+  const es=computeES(d); const g=pipeGains(); const GR=g.gr*es, GG=g.gg*es, GB=g.gb*es;
+  const hue=tune.hue||0;
+  if(hue){ const m=hueMatrix(hue);
+    for(let i=0;i<d.length;i+=4){ const r=clamp(d[i]*GR,0,255),gg2=clamp(d[i+1]*GG,0,255),b=clamp(d[i+2]*GB,0,255);
+      d[i]=clamp(m[0]*r+m[1]*gg2+m[2]*b,0,255); d[i+1]=clamp(m[3]*r+m[4]*gg2+m[5]*b,0,255); d[i+2]=clamp(m[6]*r+m[7]*gg2+m[8]*b,0,255); } }
+  else { 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);
+}
+// 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); }catch(e){}
+}
+// slider wiring — values persist so the setting carries to the next photo
+function bindTune(){
+  const map=[['sBright','bright','vBright'],['sWarm','warm','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={bright:0,warm:0,hue:0}; 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;
@@ -461,14 +579,20 @@ function gateTick(){
   // 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());
+  // 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
-  setState(ready?'ready':(fails.length?'retake':'wait'), ready?(lowSku?'✓ Ready — check the code first':'✓ Ready — hold still'):(fails.length?FRIENDLY_FAIL(fails[0]):'Put a sample in the box'));
-  $('#shutter').disabled = busy;
-  // hands-free auto-fire — never auto-save a low-confidence guessed SKU, and stop at the target
+  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(ready && !lowSku && !batchDone && $('#autoFire').checked && now-lastFireAt>CFG.minCadenceMs && now>backoffUntil){
+  if(!infoPhase && ready && !lowSku && !batchDone && $('#autoFire').checked && now-lastFireAt>CFG.minCadenceMs && now>backoffUntil){
     capture('auto', fails);
   }
 }
@@ -513,8 +637,7 @@ function setSkuChip(){
   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||'—')); };
+{ const bp=$('#bPrev'); if(bp) bp.onclick=()=>{ toast('Last: '+ (window._lastSku||'—')); }; }
 
 // ── manual shutter ──
 $('#shutter').onclick=()=>{ if(!busy) capture('manual', []); };
@@ -522,60 +645,88 @@ $('#shutter').onclick=()=>{ if(!busy) capture('manual', []); };
 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 ──
+// ── 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);                          // MASTER = crop → WB × exposure × manual tune (WYSIWYG)
+  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;
+  const url=URL.createObjectURL(photo.web); const th=$('#thumb'); th.src=url; th.hidden=false; setTimeout(()=>URL.revokeObjectURL(url),8000);
+  drainQueue();
+}
+// ── 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;
-  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();
+    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;
+        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();
+        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 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'}); }
 
@@ -589,7 +740,7 @@ 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;
+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; }); }
@@ -634,7 +785,7 @@ setInterval(()=>{ if(!$('#vShoot').hidden) drainQueue(); }, 6000);   // retry lo
 window.addEventListener('online', drainQueue);
 
 // setup-view help text
-$('#setupQ').textContent='Tip: with Auto-snap on, just hold each sample still — the app checks the photo is sharp and well-lit, reads the code, and saves it by itself. A red message means fix what it says, then try again.';
+$('#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>
diff --git a/server.js b/server.js
index d6977db..21bc71a 100644
--- a/server.js
+++ b/server.js
@@ -438,6 +438,7 @@ const VENDOR_LEX = [
   { name: 'Phillip Jeffries', re: /PHILLIP\s*JEFFRIES/,         pfx: /^(WC|PJ)/ },
   { name: 'Thibaut',          re: /THIBAUT|ANNA\s*FRENCH/,      pfx: /^(T|AT|AF)\d/ },
   { name: 'Schumacher',       re: /SCHUMACHER/,                 pfx: /^\d{4,}/ },
+  { name: 'TWIL',             re: /\bTWIL\b/,                   pfx: /^TWIL/ },
 ];
 const STRONG_PFX = /^(GRS-|WD[A-Z]*\d|DWNAT|DW[A-Z]{2})/;   // self-evidently a SKU, no brand needed
 const CODE_TOKEN = /[A-Z0-9][A-Z0-9-]{2,13}/g;             // candidate token shape
@@ -1326,13 +1327,18 @@ const appHandler = (req, res) => {
       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 };
+      // Front/back pairing: a sample can be two shots sharing ONE SKU. Without a side in the
+      // filename the second shot overwrites the first (<SKU>_original.jpg collides). When meta.side
+      // is a known side, name files <SKU>_<side>_<variant>.jpg; a single unsided shot keeps the
+      // legacy <SKU>_<variant>.jpg (back-compat with the office tool).
+      const sidePart = (meta && typeof meta.side === 'string' && /^(psku|info|front|back)$/.test(meta.side)) ? meta.side + '_' : '';
       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`;
+        const fname = `${safeSku}_${sidePart}${k}.jpg`;
         try { fs.writeFileSync(path.join(dir, fname), buf); paths[k] = `/photos/batch/${safeSession}/${fname}`; }
         catch (e) { errors.push(k + ': write'); }
       }

← 7a758b5 auto-data-snapshot: 2026-09-19T09:59:20 (4 data files) — dat  ·  back to Dw Photo Capture  ·  5x: add Batch Shoot Mode (/batch) coverage — side-named batc 1d4b604 →