[object Object]

← back to Dw Photo Capture

Live SKU scanner: HTTPS listener (:9891, self-signed cert) enables getUserMedia; πŸ”’ Scan streams the camera, OCRs each frame, LOCKS IN when a SKU repeats 2x -> auto-lookup. Falls back to tap-snap over HTTP

ae3352bf671debeafcfcc06288db4dd17a1c36c3 Β· 2026-06-25 09:27:19 -0700 Β· steve@designerwallcoverings.com

Files touched

Diff

commit ae3352bf671debeafcfcc06288db4dd17a1c36c3
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Thu Jun 25 09:27:19 2026 -0700

    Live SKU scanner: HTTPS listener (:9891, self-signed cert) enables getUserMedia; πŸ”’ Scan streams the camera, OCRs each frame, LOCKS IN when a SKU repeats 2x -> auto-lookup. Falls back to tap-snap over HTTP
---
 .gitignore        |  1 +
 data/build.json   |  5 +++--
 public/index.html | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js         | 10 ++++++++--
 4 files changed, 68 insertions(+), 5 deletions(-)

diff --git a/.gitignore b/.gitignore
index 043ae50..3cbca28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ photos/
 data/progress.json
 *.log
 .DS_Store
+certs/
diff --git a/data/build.json b/data/build.json
index b56cef0..723a6d3 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 30,
+  "next": 31,
   "map": {
     "63863152": 27,
     "578af86f": 2,
@@ -28,6 +28,7 @@
     "3c666b73": 25,
     "ce9df522": 26,
     "8a1a955f": 28,
-    "f4a0bf6d": 29
+    "f4a0bf6d": 29,
+    "86060b11": 30
   }
 }
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 193f092..e1db94b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -71,6 +71,15 @@
   .empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}
   .toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:11px 18px;border-radius:12px;font-size:14px;z-index:50;opacity:0;transition:opacity .2s;pointer-events:none}
   .toast.show{opacity:1}
+  /* live SKU scanner */
+  .scanlive{position:fixed;inset:0;z-index:110;background:#000;display:flex;align-items:center;justify-content:center}
+  .scanlive[hidden]{display:none}
+  #scanvideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
+  .scan-frame{position:relative;width:78%;max-width:520px;aspect-ratio:5/2;border:2px solid rgba(255,255,255,.85);border-radius:16px;box-shadow:0 0 0 4000px rgba(0,0,0,.45);overflow:hidden}
+  .scan-line{position:absolute;left:0;right:0;height:2px;background:var(--gold);box-shadow:0 0 10px var(--gold);animation:scan 1.6s ease-in-out infinite}
+  @keyframes scan{0%{top:8%}50%{top:90%}100%{top:8%}}
+  .scan-status{position:absolute;bottom:max(90px,22%);left:50%;transform:translateX(-50%);background:rgba(8,7,6,.8);color:#fff;font:700 18px/1 ui-monospace,Menlo,monospace;letter-spacing:.04em;padding:11px 18px;border-radius:99px;border:1px solid rgba(255,255,255,.2)}
+  .scan-cancel{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 26px;font-size:15px;font-weight:600;cursor:pointer}
   /* photo gallery */
   .gallery{position:fixed;inset:0;z-index:90;background:rgba(10,9,7,.98);display:flex;flex-direction:column}
   .gallery[hidden]{display:none}
@@ -127,7 +136,8 @@
   <div class="meta"><span id="count">…</span><span id="remain"></span></div>
   <div class="controls">
     <input type="search" id="q" placeholder="πŸ”Ž Search name or model #…" autocapitalize="characters">
-    <label class="scanbtn" title="Scan a printed SKU/model #">πŸ”’ Scan<input type="file" accept="image/*" capture="environment" id="scanInput"></label>
+    <button class="scanbtn" id="scanBtn" title="Scan a printed SKU/model #">πŸ”’ Scan</button>
+    <input type="file" accept="image/*" capture="environment" id="scanInput" hidden>
     <select id="sort">
       <option value="ready">Priced first</option>
       <option value="title">Title A→Z</option>
@@ -152,6 +162,14 @@
 <main id="grid"></main>
 <div class="toast" id="toast"></div>
 
+<!-- ===== Live SKU scanner: camera auto-locks on a printed SKU, then looks it up ===== -->
+<div class="scanlive" id="scanlive" hidden>
+  <video id="scanvideo" playsinline muted></video>
+  <div class="scan-frame"><div class="scan-line"></div></div>
+  <div class="scan-status" id="scanStatus">Point at the printed SKU…</div>
+  <button class="scan-cancel" id="scanCancel">βœ• Cancel</button>
+</div>
+
 <!-- ===== Photo gallery (all photos for a SKU: keep / remove / feature / add) ===== -->
 <div class="gallery" id="gallery" hidden>
   <div class="gal-head">
@@ -672,6 +690,43 @@ document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
   else { $('#q').placeholder='πŸ”Ž Search name or model #…'; render(); }
 }));
 // πŸ”’ Scan a printed mfr#/SKU with the camera β†’ OCR (server Vision) β†’ jump to that product.
+/* ===== LIVE scanner: stream the camera, OCR each frame, LOCK IN when a SKU repeats ===== */
+let scanStream=null,scanTimer=null,scanBusy=false,_lastTop=null,_lockHits=0;
+async function openLiveScan(){
+  try{ scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'},width:{ideal:1280}}}); }
+  catch(e){ toast('Camera blocked β€” using snap'); return $('#scanInput').click(); }
+  const v=$('#scanvideo'); v.srcObject=scanStream; try{await v.play();}catch(e){}
+  _lastTop=null;_lockHits=0; $('#scanStatus').textContent='Point at the printed SKU…';
+  $('#scanlive').hidden=false; document.body.style.overflow='hidden';
+  scanTimer=setInterval(scanTick,600);
+}
+function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null;} $('#scanlive').hidden=true; document.body.style.overflow=''; }
+async function scanTick(){
+  if(scanBusy)return; scanBusy=true;
+  try{
+    const v=$('#scanvideo'); if(!v.videoWidth){scanBusy=false;return;}
+    const s=Math.min(1,1280/Math.max(v.videoWidth,v.videoHeight));
+    const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
+    c.getContext('2d').drawImage(v,0,0,c.width,c.height);
+    const r=await fetch('/api/ocr',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:c.toDataURL('image/jpeg',0.7)})});
+    const d=await r.json();
+    if(d&&d.top){
+      if(d.top===_lastTop) _lockHits++; else {_lastTop=d.top;_lockHits=1;}
+      $('#scanStatus').textContent=(_lockHits>=2?'πŸ”’ LOCKED Β· ':'πŸ‘€ ')+d.top;
+      if(_lockHits>=2){                       // seen the same SKU twice β†’ lock in
+        try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}
+        closeLiveScan();
+        if(filter!=='any'){document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on'));const sc=document.querySelector('.chip[data-f="any"]');if(sc)sc.classList.add('on');filter='any';setLS('filter','any');$('#q').placeholder='πŸ”Ž SKU, model #, name, or collection…';}
+        collapsed=false; $('#q').value=d.top; toast('πŸ”’ Locked: '+d.top); doLookup();
+      }
+    } else if(_lockHits<2){ $('#scanStatus').textContent='Point at the printed SKU…'; }
+  }catch(e){} finally{ scanBusy=false; }
+}
+$('#scanCancel').addEventListener('click',closeLiveScan);
+$('#scanBtn').addEventListener('click',()=>{
+  if(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) openLiveScan();
+  else $('#scanInput').click();   // HTTP (no secure context) β†’ fall back to single-snap
+});
 $('#scanInput').addEventListener('change',async e=>{
   const f=e.target.files[0]; if(!f)return;
   collapsed=false;
diff --git a/server.js b/server.js
index 7c235f5..2b11626 100644
--- a/server.js
+++ b/server.js
@@ -229,7 +229,7 @@ function send(res, code, body, headers = {}) {
   res.end(typeof body === 'string' ? body : JSON.stringify(body));
 }
 
-const server = http.createServer((req, res) => {
+const appHandler = (req, res) => {
   // public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
   const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest'];
   const _p = req.url.split('?')[0];
@@ -619,7 +619,13 @@ const server = http.createServer((req, res) => {
   }
 
   send(res, 404, { err: 'not found' });
-});
+};
+const server = http.createServer(appHandler);
+// HTTPS (self-signed) on PORT+1 so the live camera scanner (getUserMedia needs a secure context) works on the LAN.
+try {
+  const tls = { key: fs.readFileSync(path.join(ROOT, 'certs/key.pem')), cert: fs.readFileSync(path.join(ROOT, 'certs/cert.pem')) };
+  https.createServer(tls, appHandler).listen(Number(PORT) + 1, '0.0.0.0', () => console.log(`HTTPS (live-scan) on https://0.0.0.0:${Number(PORT) + 1}`));
+} catch (e) { console.log('HTTPS off (no cert):', e.message); }
 
 // ── Sheet GRS catalog (every GRS item on the TWIL spreadsheet, incl. not-yet-created) ──
 let SHEET = loadJSON(path.join(DATA, 'sheet_grs.json'), []);

← 5047a83 App starts collapsed (clean landing until filter/search/scan  Β·  back to Dw Photo Capture  Β·  auto-save: 2026-06-25T09:33:15 (1 files) β€” server.js ae60fb4 β†’