← 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
M public/batch.htmlM server.js
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 →