[object Object]

← back to Dw Photo Capture

iOS freeze — close the contrarian gap: ALL photo flows now decode-free. downscale() itself = FileReader raw (fixes Add New SKU / Update SKU / gallery-batch, which still froze via createImageBitmap that v99 proved unsafe). Server normalizes at every ingest: create-item + /api/photo + /api/photos via normB64 (HEIC→JPEG for Shopify; box IM has libheif). Scan regression green

dd49fa4b09c8e320bb41a713484040f5ea29137d · 2026-07-08 15:59:51 -0700 · Steve Abrams

Files touched

Diff

commit dd49fa4b09c8e320bb41a713484040f5ea29137d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 8 15:59:51 2026 -0700

    iOS freeze — close the contrarian gap: ALL photo flows now decode-free. downscale() itself = FileReader raw (fixes Add New SKU / Update SKU / gallery-batch, which still froze via createImageBitmap that v99 proved unsafe). Server normalizes at every ingest: create-item + /api/photo + /api/photos via normB64 (HEIC→JPEG for Shopify; box IM has libheif). Scan regression green
---
 public/index.html | 22 ++++++++--------------
 server.js         | 10 +++++++---
 2 files changed, 15 insertions(+), 17 deletions(-)

diff --git a/public/index.html b/public/index.html
index 52a4c34..ad03f1e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -831,24 +831,18 @@ function lazyThumbs(){
   _thumbObs=new IntersectionObserver((es,o)=>{ for(const e of es){ if(e.isIntersecting){ const t=e.target,u=t.getAttribute('data-bg'); if(u){ t.style.backgroundImage=`url('${u}')`; t.removeAttribute('data-bg'); } o.unobserve(t); } } },{rootMargin:'400px'});
   grid.querySelectorAll('.thumb[data-bg]').forEach(t=>_thumbObs.observe(t));
 }
-// iOS FREEZE FIX: a full 12MP camera photo decoded via <img>+canvas.toDataURL is a memory hog that
-// hangs/OOM-crashes iOS Safari. createImageBitmap decodes OFF the main thread + low-memory, so try it
-// first; fall back to <img> only if unavailable. Also cap smaller (OCR needs ~1200px, not 2000).
-async function downscale(file,max=1200){
-  try{
-    if('createImageBitmap' in window){
-      const bmp=await createImageBitmap(file);
-      const s=Math.min(1,max/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));
-      c.getContext('2d').drawImage(bmp,0,0,c.width,c.height); if(bmp.close)bmp.close();
-      return c.toDataURL('image/jpeg',0.82);
-    }
-  }catch(e){ /* fall through to <img> path */ }
+// iOS FREEZE FIX (DTD verdict B): NO client-side decode anywhere. v99 proved even createImageBitmap
+// OOM-freezes iOS on a 12MP camera photo. downscale() now just reads the RAW file to a data URL via
+// FileReader (no <img>/canvas/bitmap) — the SERVER (normalizeImage: ImageMagick → auto-orient → ≤1600px
+// JPEG, handles HEIC) resizes at every ingest endpoint. `max` is ignored (kept for call-site compat).
+async function downscale(file,max){
+  const raw=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=()=>r(null); fr.readAsDataURL(file); });
+  if(raw) return raw;
   return new Promise((res)=>{
     const img=new Image(), url=URL.createObjectURL(file);
     let done=false; const finish=v=>{ if(done)return; done=true; try{URL.revokeObjectURL(url);}catch(e){} res(v); };
     img.onload=()=>{
-      let{width:w,height:h}=img; const s=Math.min(1,max/Math.max(w,h));
+      let{width:w,height:h}=img; const s=Math.min(1,(max||1200)/Math.max(w,h));
       const c=document.createElement('canvas'); c.width=Math.round(w*s); c.height=Math.round(h*s);
       c.getContext('2d').drawImage(img,0,0,c.width,c.height); finish(c.toDataURL('image/jpeg',0.82));
     };
diff --git a/server.js b/server.js
index d0e8e60..227c1d1 100644
--- a/server.js
+++ b/server.js
@@ -637,6 +637,9 @@ function normalizeImage(buf) {
     try { cp.stdin.on('error', () => {}); cp.stdin.write(buf); cp.stdin.end(); } catch (e) { resolve(buf); }
   });
 }
+// normalize a base64 image (any format/size, incl. iOS HEIC) → clean ≤1600px JPEG base64. The client now
+// sends RAW camera photos (no client decode — iOS freeze fix), so EVERY ingest point normalizes here.
+async function normB64(b64) { if (!b64) return b64; try { return (await normalizeImage(Buffer.from(b64, 'base64'))).toString('base64'); } catch (e) { return b64; } }
 const OCR_FUSION_ORDER = ['gcv', 'gemini', 'macvision'];
 async function backOcrFused(buf) {
   const engines = OCR_FUSION_ORDER.filter(engineAvailable);
@@ -1359,8 +1362,9 @@ const appHandler = (req, res) => {
       if (!TOKEN) return send(res, 200, { ok: false, err: 'no Shopify token' });
       const strip = s => s ? s.replace(/^data:image\/\w+;base64,/, '') : null;
       // up to 3 photos (front pattern / back label / detail); front is the primary Shopify image.
-      const photos64 = Array.isArray(p.photos) && p.photos.length ? p.photos.map(strip).filter(Boolean)
+      const rawPhotos64 = Array.isArray(p.photos) && p.photos.length ? p.photos.map(strip).filter(Boolean)
         : (p.dataUrl ? [strip(p.dataUrl)].filter(Boolean) : []);
+      const photos64 = await Promise.all(rawPhotos64.map(normB64));   // raw camera photos → clean JPEG for Shopify
       p._photos64 = photos64;
       const b64 = photos64[0] || null;
       try { send(res, 200, await createNewItem(p, b64, p.commit !== true)); }
@@ -1870,7 +1874,7 @@ load();
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
       if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
-      const b64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
+      const b64 = await normB64(dataUrl.replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
       const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
       const fname = `${safe}-${Date.now()}.jpg`;
       fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
@@ -1923,7 +1927,7 @@ load();
       const urls = dataUrls.slice(0, 12); // cap a single batch
       let added = 0; const errors = []; let lastPhoto = null;
       for (let i = 0; i < urls.length; i++) {
-        const b64 = String(urls[i]).replace(/^data:image\/\w+;base64,/, '');
+        const b64 = await normB64(String(urls[i]).replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
         const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
         const fname = `${safe}-${Date.now()}-${i}.jpg`;
         try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`; } catch (e) {}

← 71b8574 iOS freeze — eliminate ALL client-side image decode (DTD ver  ·  back to Dw Photo Capture  ·  Scan upload-progress (final DTD FIX-THEN-SHIP): identify-mul ab7c20f →