[object Object]

← back to Dw Photo Capture

Visual pattern-ID in capture flow (front photo → identify-multi → bind SKU by design, even with unreadable label; tappable candidate chips) + live on-camera dimension readout (credit-card scale reference → real W×L overlaid live, writes to specs)

b87d402c89b4a17b784d57cada6249f34864f7b7 · 2026-07-06 21:13:35 -0700 · Steve Abrams

Files touched

Diff

commit b87d402c89b4a17b784d57cada6249f34864f7b7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 21:13:35 2026 -0700

    Visual pattern-ID in capture flow (front photo → identify-multi → bind SKU by design, even with unreadable label; tappable candidate chips) + live on-camera dimension readout (credit-card scale reference → real W×L overlaid live, writes to specs)
---
 public/index.html                                  | 109 ++++++++++++++++++---
 server.js                                          |  12 ++-
 .../train/__pycache__/finetune.cpython-312.pyc     | Bin 0 -> 9541 bytes
 3 files changed, 103 insertions(+), 18 deletions(-)

diff --git a/public/index.html b/public/index.html
index 5d6ac04..6ead998 100644
--- a/public/index.html
+++ b/public/index.html
@@ -176,6 +176,23 @@
   .media-btn{flex:1;padding:14px 6px;border:1px dashed var(--line);border-radius:12px;background:#0f0e0b;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
   .media-btn:active{transform:scale(.98)}.media-btn:disabled{opacity:.4;cursor:not-allowed}
   .media-btn span{opacity:.65;font-weight:400;font-size:12px}
+  .vid-cands{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
+  .vid-chip{padding:6px 10px;border:1px solid var(--line);border-radius:999px;background:#151310;color:#e7ddc7;font-size:12px;cursor:pointer}
+  .vid-chip:active{transform:scale(.96)}
+  #measureModal{position:fixed;inset:0;z-index:400;background:#000}
+  #measureModal[hidden]{display:none}
+  #mVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
+  .mbox{position:absolute;border:2px solid;border-radius:6px;touch-action:none;min-width:34px;min-height:34px}
+  .mbox.mcard{border-color:#ff5a5a;box-shadow:0 0 0 9999px rgba(0,0,0,.02)}
+  .mbox.mgoods{border-color:#2bd06a}
+  .mlbl{position:absolute;top:-9px;left:6px;font-size:11px;background:#000;padding:0 5px;color:#fff}
+  .mcard .mlbl{color:#ff9a9a}.mgoods .mlbl{color:#7cf0a6}
+  .mrz{position:absolute;right:-13px;bottom:-13px;width:26px;height:26px;background:rgba(255,255,255,.9);border-radius:50%;touch-action:none;cursor:nwse-resize}
+  .mreadout{position:absolute;top:14px;left:0;right:0;text-align:center;color:#fff;font-size:17px;text-shadow:0 1px 4px #000;pointer-events:none}
+  .mreadout b{color:#ffe08a}
+  .mbar{position:absolute;bottom:22px;left:0;right:0;display:flex;gap:12px;justify-content:center}
+  .mbar button{padding:13px 20px;border:none;border-radius:999px;font-size:15px;font-weight:700;cursor:pointer}
+  #mUse{background:#2bd06a;color:#032}#mClose{background:#222;color:#fff}
   .add-spec{display:flex;justify-content:space-between;gap:12px;font-size:12.5px;padding:4px 0;border-bottom:1px solid rgba(255,255,255,.05)}
   .add-spec span{color:#9a917d}.add-spec b{color:var(--ink);text-align:right}
   /* onload chooser — three white pills on black */
@@ -410,6 +427,7 @@
     </div>
     <input type="file" accept="image/*" capture="environment" id="addPhotoInput" hidden>
     <input type="file" accept="video/*" capture="environment" id="addVideoInput" hidden>
+    <button class="add-voice" id="addMeasure">📐 Measure real size (place a card by the goods)</button>
     <button class="add-voice" id="addVoice">🎤 Voice-fill everything that's missing</button>
     <div class="add-field"><label>Mfr # / SKU</label><div class="in-mic"><input id="addMfr" autocapitalize="characters" placeholder="e.g. TR2581"><button class="mic-btn" data-mic="addMfr" data-kind="sku" title="Speak the SKU / mfr number">🎤</button></div></div>
     <div class="add-field"><label>Vendor</label><div class="in-mic"><select id="addVendor"><option value="">Loading…</option></select><button class="mic-btn" data-mic="addVendor" data-kind="vendor" title="Speak the vendor name">🎤</button></div></div>
@@ -421,6 +439,15 @@
     <div id="addResult"></div>
   </div>
 </div>
+
+<!-- Live on-camera dimension measure: a credit card (3.375") in frame calibrates real inches -->
+<div id="measureModal" hidden>
+  <video id="mVideo" autoplay playsinline muted></video>
+  <div class="mbox mcard" id="mCard"><span class="mlbl">💳 card</span><i class="mrz"></i></div>
+  <div class="mbox mgoods" id="mSample"><span class="mlbl">📦 goods</span><i class="mrz"></i></div>
+  <div class="mreadout" id="mReadout">Size 💳 to a credit card, 📦 to the goods</div>
+  <div class="mbar"><button id="mUse">✓ Use W×L</button><button id="mClose">✕ Close</button></div>
+</div>
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -1474,10 +1501,10 @@ function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; addR
 function addPhotosList(){ return _media.filter(m=>m.type==='photo').map(m=>m.dataUrl); }
 function addVideosList(){ return _media.filter(m=>m.type==='video'); }
 function addPayload(commit){ return Object.assign({}, _addExtracted, { mfr:$('#addMfr').value.trim(), vendor:$('#addVendor').value, vid:$('#addVid').value.trim(), name:$('#addName').value.trim(), color:$('#addColor').value.trim(), price:$('#addPrice').value.trim(), dataUrl:_addPhoto, photos:addPhotosList(), commit:!!commit }); }
-async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0;
+async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0,failed=0;
   for(let i=0;i<vids.length;i++){ $('#addNote').textContent=`🎥 Uploading video ${i+1}/${vids.length}…`;
-    try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; }catch(e){} }
-  return ok; }
+    try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; else failed++; }catch(e){ failed++; } }
+  return { ok, failed }; }
 // Shopify transcodes video async — poll until every clip is READY (or a couple minutes pass).
 async function pollVideoStatus(product_id, baseMsg){ const started=Date.now();
   while(Date.now()-started < 150000){
@@ -1503,28 +1530,48 @@ async function addPreview(){ if(_addMode==='update') return addUpdate();
 async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
   const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(addPayload(true))})).json();
   if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
-  const hadVideos=addVideosList().length; let vmsg='';
-  if(hadVideos && r.product_id){ const n=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${n} video(s)`; }
+  const hadVideos=addVideosList().length; let vmsg='', vup={ok:0,failed:0};
+  if(hadVideos && r.product_id){ vup=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:''); }
   const baseMsg=`✅ Created DRAFT · ${r.dw_sku}${vmsg}`;
   $('#addNote').innerHTML=`${baseMsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
   if(hadVideos && r.product_id) pollVideoStatus(r.product_id, baseMsg);
 }
 // UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
+// resolve an mfr# / sku to a live Shopify product_id and bind it as the Update target
+async function bindProduct(mfr,sku){ const qs=mfr?('mfr='+encodeURIComponent(mfr)):('sku='+encodeURIComponent(sku||''));
+  try{ const r=await(await fetch('/api/resolve-product?'+qs)).json();
+    if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; return r; } }catch(e){} return null; }
 async function addResolveUpdate(){ const mfr=$('#addMfr').value.trim(); if(!mfr) return;
-  try{ const r=await(await fetch('/api/resolve-product?mfr='+encodeURIComponent(mfr))).json();
-    if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; $('#addTarget').innerHTML=`✓ Updating <b>${r.title||r.dw_sku}</b> <small>(${r.status||''} · ${r.dw_sku})</small>`; }
-    else { _updatePid=null; _updateSku=null; $('#addTarget').innerHTML='⚠ No existing SKU matched “'+mfr+'”. Fix the mfr# or use Add New SKU.'; }
-  }catch(e){} }
+  const r=await bindProduct(mfr,null);
+  if(r){ $('#addTarget').innerHTML=`✓ Updating <b>${r.title||r.dw_sku}</b> <small>(${r.status||''} · ${r.dw_sku})</small>`; }
+  else { _updatePid=null; _updateSku=null; $('#addTarget').innerHTML='⚠ No SKU matched “'+mfr+'”. Try a photo of the pattern, fix the mfr#, or use Add New SKU.'; } }
+// REALLY ID by DESIGN: front pattern (+ label) photo → /api/identify-multi (OCR+QR+vendor+CLIP visual)
+// → bind the matched product. Lets Update mode find a SKU even when the label is unreadable.
+async function visualIdentify(){ const photos=addPhotosList(); if(!photos.length) return;
+  $('#addTarget').innerHTML='🔍 Identifying by pattern…';
+  let r; try{ r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},
+    body:JSON.stringify({back:photos[0], front:photos[photos.length-1]})})).json(); }catch(e){ $('#addTarget').textContent='Visual ID failed.'; return; }
+  const cands=(r.visual||[]).slice(0,4), prim=r.primary; let bound=null;
+  if(prim) bound=await bindProduct(prim.mfr_sku, prim.dw_sku);
+  if(!bound) for(const c of cands){ bound=await bindProduct(c.mfr_sku, c.dw_sku); if(bound)break; }
+  if(bound){ if(prim&&prim.mfr_sku&&!$('#addMfr').value)$('#addMfr').value=prim.mfr_sku;
+    $('#addTarget').innerHTML=`🔍 Visually matched <b>${bound.title||_updateSku}</b> <small>(${r.confidence||'match'} · ${_updateSku})</small>`; }
+  else if(cands.length){ _vidCands=cands;
+    $('#addTarget').innerHTML='🔍 Closest patterns — tap to use:<div class="vid-cands">'+cands.map((c,i)=>`<button class="vid-chip" data-i="${i}">${((c.pattern||c.mfr_sku||'?')+'').slice(0,20)}</button>`).join('')+'</div>';
+    document.querySelectorAll('#addTarget .vid-chip').forEach(b=>b.addEventListener('click',async()=>{ const c=_vidCands[+b.dataset.i]; const bd=await bindProduct(c.mfr_sku,c.dw_sku); if(bd){ if(c.mfr_sku)$('#addMfr').value=c.mfr_sku; $('#addTarget').innerHTML=`🔍 Using <b>${bd.title||_updateSku}</b>`; } else $('#addTarget').innerHTML='That pattern has no live product — use Add New SKU.'; })); }
+  else $('#addTarget').innerHTML='No visual match — fix the mfr# or use Add New SKU.'; }
+let _vidCands=[];
 async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
-  if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label / fix the mfr#.'; return; }
+  if(!_updatePid) await visualIdentify();   // last resort: match by the pattern itself
+  if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label/pattern or fix the mfr#.'; return; }
   const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
   $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
   if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
   const hadVideos=addVideosList().length;
-  const vn=hadVideos ? await uploadVideos(_updatePid,_updateSku) : 0;
-  const baseMsg=`✅ LIVE: ${added} photo(s) + ${vn} video(s) → ${_updateSku}`;
+  const vup=hadVideos ? await uploadVideos(_updatePid,_updateSku) : {ok:0,failed:0};
+  const baseMsg=`✅ LIVE: ${added} photo(s) + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:'')+` → ${_updateSku}`;
   $('#addNote').innerHTML=baseMsg; toast('✅ Updated '+_updateSku+' live');
-  if(hadVideos) pollVideoStatus(_updatePid, baseMsg);
+  if(hadVideos && vup.ok) pollVideoStatus(_updatePid, baseMsg);
 }
 $('#addBtn').addEventListener('click',()=>openAddModal());
 $('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
@@ -1544,7 +1591,7 @@ async function addExtract(){ if(!_addPhoto)return; $('#addNote').textContent='
     const specs=[['Material',f.material],['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate / contents',f.substrate],['How sold',f.how_sold],['Price code',f.price_code]].filter(x=>x[1]);
     $('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">Captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
     $('#addNote').textContent='✓ Imported from label';
-    if(_addMode==='update'){ await addResolveUpdate(); }   // find the existing product to update
+    if(_addMode==='update'){ await addResolveUpdate(); if(!_updatePid) await visualIdentify(); }   // label first, then match by design
     else addPromptMissing();
   }catch(e){ $('#addNote').textContent=''; }
 }
@@ -1562,7 +1609,39 @@ function addPromptMissing(){
   $('#addNote').textContent='✓ Ready — Preview';
 }
 $('#addVoice').addEventListener('click',addPromptMissing);   // manual re-trigger of the voice fill
-$('#addMfr').addEventListener('input',()=>$('#addMfr').classList.remove('need'));
+
+// ── Live dimension measure: a credit card (3.375" wide) in frame calibrates pixels→inches ──
+const CARD_IN=3.375;   // ISO/IEC 7810 ID-1 (credit card) width
+let _mStream=null,_measW=0,_measL=0;
+function mRecompute(){ const c=$('#mCard'),s=$('#mSample'); const ppi=c.offsetWidth/CARD_IN;
+  _measW=s.offsetWidth/ppi; _measL=s.offsetHeight/ppi;
+  $('#mReadout').innerHTML=`W <b>${_measW.toFixed(1)}"</b> · L <b>${_measL.toFixed(1)}"</b> <small>· card=3.375"</small>`; }
+function mMakeBox(el){ let mode=null,sx,sy,ox,oy,ow,oh; const rz=el.querySelector('.mrz');
+  const start=(e,m)=>{ mode=m; sx=e.clientX; sy=e.clientY; ox=el.offsetLeft; oy=el.offsetTop; ow=el.offsetWidth; oh=el.offsetHeight; (m==='resize'?rz:el).setPointerCapture(e.pointerId); e.preventDefault(); e.stopPropagation(); };
+  const move=e=>{ if(!mode)return; const dx=e.clientX-sx,dy=e.clientY-sy;
+    if(mode==='move'){ el.style.left=(ox+dx)+'px'; el.style.top=(oy+dy)+'px'; }
+    else { el.style.width=Math.max(34,ow+dx)+'px'; el.style.height=Math.max(34,oh+dy)+'px'; } mRecompute(); };
+  const end=()=>{ mode=null; };
+  el.addEventListener('pointerdown',e=>{ if(e.target!==rz) start(e,'move'); });
+  rz.addEventListener('pointerdown',e=>start(e,'resize'));
+  el.addEventListener('pointermove',move); rz.addEventListener('pointermove',move);
+  el.addEventListener('pointerup',end); rz.addEventListener('pointerup',end); }
+async function openMeasure(){ try{ _mStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}}}); }
+  catch(e){ toast('Camera unavailable for measuring'); return; }
+  $('#mVideo').srcObject=_mStream; $('#measureModal').hidden=false; document.body.style.overflow='hidden';
+  const C=$('#mCard'),S=$('#mSample'); Object.assign(C.style,{left:'26px',top:'96px',width:'135px',height:'85px'});
+  Object.assign(S.style,{left:'56px',top:'220px',width:'210px',height:'270px'}); mRecompute(); }
+function closeMeasure(){ if(_mStream){ _mStream.getTracks().forEach(t=>t.stop()); _mStream=null; } $('#measureModal').hidden=true; document.body.style.overflow=''; }
+mMakeBox($('#mCard')); mMakeBox($('#mSample'));
+$('#addMeasure').addEventListener('click',openMeasure);
+$('#mClose').addEventListener('click',closeMeasure);
+$('#mUse').addEventListener('click',()=>{ const w=_measW.toFixed(1)+'"',l=_measL.toFixed(1)+'"';
+  _addExtracted.width=w; _addExtracted.roll_length=l;
+  $('#addSpecs').insertAdjacentHTML('afterbegin',`<div class="add-spec"><span>📐 Measured</span><b>${w} × ${l}</b></div>`);
+  toast(`📐 ${w} × ${l} saved`); closeMeasure(); });
+$('#addMfr').addEventListener('input',()=>{ $('#addMfr').classList.remove('need');
+  // editing the mfr# unbinds any resolved Update target so we never push to a stale/wrong product
+  if(_addMode==='update' && _updatePid){ _updatePid=null; _updateSku=null; $('#addTarget').textContent='mfr# changed — re-scan or press Push to re-find the SKU.'; } });
 $('#addVendor').addEventListener('change',()=>$('#addVendor').classList.remove('need'));
 
 // Per-field mic: tap the 🎤 on any field and speak straight into THAT field. kind tunes the parse.
diff --git a/server.js b/server.js
index 5842d9a..8b754d7 100644
--- a/server.js
+++ b/server.js
@@ -1183,8 +1183,10 @@ const appHandler = (req, res) => {
     // 2) fall back to a LIVE Shopify search (covers the whole ~169k store, not just the queue)
     shopifySearch(q).then(items => {
       const n = nmfr(q), s = q.toUpperCase();
+      // EXACT match only — never fall back to items[0]. A wrong bind here would push media
+      // LIVE to the wrong customer product (Update mode writes immediately, no approval step).
       const best = items.find(x => x.mfr && nmfr(x.mfr) === n)
-        || items.find(x => (x.dw_sku || '').toUpperCase() === s) || items[0] || null;
+        || items.find(x => (x.dw_sku || '').toUpperCase() === s) || null;
       done(best);
     }).catch(() => done(null));
     return;
@@ -1445,9 +1447,13 @@ const appHandler = (req, res) => {
 
   // Upload a VIDEO to a Shopify product (staged upload → product media).
   if (u.pathname === '/api/video' && req.method === 'POST') {
-    let body = '';
-    req.on('data', c => { body += c; if (body.length > 160 * 1024 * 1024) req.destroy(); });
+    // base64 inflates the file ~33%, so a 150MB clip is ~200MB on the wire → cap at 230MB.
+    // DON'T req.destroy() (that leaves the client fetch hanging with no response, swallowed by
+    // its catch → false-green "0 videos"); drain past the cap and reply 413 cleanly on end.
+    let body = '', tooBig = false;
+    req.on('data', c => { if (tooBig) return; body += c; if (body.length > 230 * 1024 * 1024) tooBig = true; });
     req.on('end', async () => {
+      if (tooBig) return send(res, 413, { ok: false, err: 'video too large (max ~150MB)' });
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       if (!p.product_id || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl required' });
       const m = /^data:(video\/\w+);base64,/.exec(p.dataUrl);
diff --git a/visual-search/train/__pycache__/finetune.cpython-312.pyc b/visual-search/train/__pycache__/finetune.cpython-312.pyc
new file mode 100644
index 0000000..0052d80
Binary files /dev/null and b/visual-search/train/__pycache__/finetune.cpython-312.pyc differ

← 5126f02 Overnight DW CLIP fine-tune pipeline (MPS): build_manifest +  ·  back to Dw Photo Capture  ·  Card-free measurement groundwork: lock camera zoom + /caps c da94b6f →