← 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
M public/index.htmlM server.jsA visual-search/train/__pycache__/finetune.cpython-312.pyc
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 →