[object Object]

← back to Dw Photo Capture

iOS freeze — eliminate ALL client-side image decode (DTD verdict B): fbRead uses FileReader.readAsDataURL (raw file, zero decode/canvas/bitmap), server normalizeImage() (ImageMagick: any format→auto-orient→≤1600px JPEG) handles it before OCR + visual. decodeQR capped at 1000px (a full-12MP getImageData is a ~48MB array that froze iOS). Verified: raw 3000px photo → resolves CHC217203. /5x 10/10

71b8574b4df5e1205d56471c4b6b876ccc9cdc10 · 2026-07-08 15:53:14 -0700 · Steve Abrams

Files touched

Diff

commit 71b8574b4df5e1205d56471c4b6b876ccc9cdc10
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 8 15:53:14 2026 -0700

    iOS freeze — eliminate ALL client-side image decode (DTD verdict B): fbRead uses FileReader.readAsDataURL (raw file, zero decode/canvas/bitmap), server normalizeImage() (ImageMagick: any format→auto-orient→≤1600px JPEG) handles it before OCR + visual. decodeQR capped at 1000px (a full-12MP getImageData is a ~48MB array that froze iOS). Verified: raw 3000px photo → resolves CHC217203. /5x 10/10
---
 public/index.html | 16 ++++++++++++++--
 server.js         | 18 ++++++++++++++++--
 2 files changed, 30 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index 7333262..52a4c34 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1531,10 +1531,22 @@ $('#sampPrintAddr').addEventListener('click',()=>sampPrint('address'));
 let _fbBack=null,_fbFront=null,_fbQR=null,_jsqr=null,_fbLookup=false;
 function loadJsQR(){ if(_jsqr) return Promise.resolve(_jsqr); return new Promise((res)=>{ const s=document.createElement('script'); s.src='https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'; s.onload=()=>{_jsqr=window.jsQR;res(_jsqr);}; s.onerror=()=>res(null); document.head.appendChild(s); }); }
 async function decodeQR(dataUrl){ const jsQR=await loadJsQR(); if(!jsQR) return null;
-  return new Promise(r=>{ const im=new Image(); im.onload=()=>{ try{ const c=document.createElement('canvas'); c.width=im.naturalWidth; c.height=im.naturalHeight; const x=c.getContext('2d'); x.drawImage(im,0,0); const d=x.getImageData(0,0,c.width,c.height); const q=jsQR(d.data,c.width,c.height); r(q?q.data:null);}catch(e){r(null);} }; im.onerror=()=>r(null); im.src=dataUrl; }); }
+  // iOS-safe: cap the QR canvas at ≤1000px (a full-12MP getImageData is a ~48MB array that freezes iOS).
+  // createImageBitmap decodes off-main-thread + low-memory; QR reads fine at this size.
+  try{
+    const blob=await(await fetch(dataUrl)).blob();
+    const bmp=await createImageBitmap(blob);
+    const s=Math.min(1,1000/Math.max(bmp.width,bmp.height));
+    const c=document.createElement('canvas'); c.width=Math.max(1,Math.round(bmp.width*s)); c.height=Math.max(1,Math.round(bmp.height*s));
+    const x=c.getContext('2d'); x.drawImage(bmp,0,0,c.width,c.height); if(bmp.close)bmp.close();
+    const d=x.getImageData(0,0,c.width,c.height); const q=jsQR(d.data,c.width,c.height); return q?q.data:null;
+  }catch(e){ return null; }
+}
 async function fbRead(file,isBack){ if(!file)return;
   try{ $('#fbNote').textContent='📷 Processing photo…'; }catch(e){}
-  const url=await downscale(file,1200);
+  // iOS FREEZE FIX (DTD verdict B): do ZERO client-side decode. FileReader reads the raw file to a
+  // data URL without ever decoding the 12MP image into a bitmap/canvas — the server downscales+converts.
+  const url=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=()=>r(null); fr.readAsDataURL(file); });
   if(!url){ try{ $('#fbNote').textContent='Could not read that photo — try again.'; }catch(e){} return; }
   if(isBack){ _fbBack=url; const t=$('#fbBackThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbBackTile').classList.add('set');
     $('#fbNote').textContent='Reading label + QR…'; _fbQR=await decodeQR(url).catch(()=>null); $('#fbNote').textContent=_fbQR?'🔗 QR code read':''; }
diff --git a/server.js b/server.js
index 63d6a90..d0e8e60 100644
--- a/server.js
+++ b/server.js
@@ -624,6 +624,19 @@ function resolveEngines(req) {
 // trim-tolerant search then recovers a mostly-right read, so more engines = more shots on goal. Engine
 // order is GCV → gemini → macvision (literal transcribers first). Plug-and-play: keying GCV_API_KEY auto-
 // enables it here with no code change. Cost = sum of the engines that run (shown per scan).
+// Client now sends the RAW camera photo (no client-side decode — iOS freeze fix). Normalize server-side:
+// ImageMagick converts ANY format (HEIC/JPEG/PNG), auto-orients, and downscales to ≤1600px JPEG so OCR
+// engines (GCV needs JPEG/PNG, not HEIC) + storage get a sane image. Best-effort — original on failure.
+function normalizeImage(buf) {
+  return new Promise(resolve => {
+    let out = [];
+    const cp = execFile('/usr/bin/convert', ['-', '-auto-orient', '-resize', '1600x1600>', '-quality', '85', 'jpg:-'],
+      { maxBuffer: 48 * 1024 * 1024, timeout: 12000, encoding: 'buffer' }, (err, stdout) => {
+        resolve(err || !stdout || !stdout.length ? buf : stdout);   // fall back to the original bytes
+      });
+    try { cp.stdin.on('error', () => {}); cp.stdin.write(buf); cp.stdin.end(); } catch (e) { resolve(buf); }
+  });
+}
 const OCR_FUSION_ORDER = ['gcv', 'gemini', 'macvision'];
 async function backOcrFused(buf) {
   const engines = OCR_FUSION_ORDER.filter(engineAvailable);
@@ -1401,7 +1414,7 @@ const appHandler = (req, res) => {
         // BACK → OCR the code → resolve to a canonical identity (exact)
         let code = null, codeId = null, backOcr = null;
         if (p.back) {
-          const buf = Buffer.from(strip(p.back), 'base64');
+          const buf = await normalizeImage(Buffer.from(strip(p.back), 'base64'));   // raw camera photo → JPEG ≤1600px
           backOcr = await backOcrFused(buf).catch(() => null);   // GCV+Gemini fusion (literal codes + layout)
           const cands = [backOcr && backOcr.barcode, backOcr && backOcr.top, ...((backOcr && backOcr.candidates) || [])].filter(Boolean);
           for (const c of cands) { codeId = await identifyUnified(c).catch(() => null); if (codeId) { code = c; break; } }
@@ -1418,7 +1431,8 @@ const appHandler = (req, res) => {
         // FRONT → CLIP visual search. MULTI-PHOTO FUSION: real handheld photos are noisy (28% single-shot
         // top-1), so if several views of the same sample are sent, fuse them — a product matching across
         // multiple views is corroborated and outranks a spurious single-view match.
-        const fronts = (Array.isArray(p.fronts) && p.fronts.length ? p.fronts : (p.front ? [p.front] : [])).slice(0, 5).map(strip).filter(Boolean);
+        const rawFronts = (Array.isArray(p.fronts) && p.fronts.length ? p.fronts : (p.front ? [p.front] : [])).slice(0, 5).map(strip).filter(Boolean);
+        const fronts = await Promise.all(rawFronts.map(async b => { try { return (await normalizeImage(Buffer.from(b, 'base64'))).toString('base64'); } catch (e) { return b; } }));
         let visual = [];
         if (fronts.length === 1) {
           visual = await visualSearch(fronts[0], 8);

← d5ff7da iOS photo-processing freeze fix: downscale() now uses create  ·  back to Dw Photo Capture  ·  iOS freeze — close the contrarian gap: ALL photo flows now d dd49fa4 →