← 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
M public/index.htmlM server.js
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 →