β 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
M .gitignoreM data/build.jsonM public/index.htmlM server.js
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 β