[object Object]

← back to Dw Photo Capture

auto-save: 2026-06-24T16:58:22 (3 files) — public/index.html server.js data/build.json

daf466a6b6cda1d3427da5b4980540e35639bbb9 · 2026-06-24 16:58:24 -0700 · Steve Abrams

Files touched

Diff

commit daf466a6b6cda1d3427da5b4980540e35639bbb9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 16:58:24 2026 -0700

    auto-save: 2026-06-24T16:58:22 (3 files) — public/index.html server.js data/build.json
---
 data/build.json   |  8 ++++++
 public/index.html | 80 ++++++++++++++++++++++++++++++++++++++++---------------
 server.js         | 35 +++++++++++++++++++++++-
 3 files changed, 101 insertions(+), 22 deletions(-)

diff --git a/data/build.json b/data/build.json
new file mode 100644
index 0000000..85c4d36
--- /dev/null
+++ b/data/build.json
@@ -0,0 +1,8 @@
+{
+  "next": 5,
+  "map": {
+    "578af86f": 2,
+    "bc95fdb0": 3,
+    "3a3b2a43": 4
+  }
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index ea73eb6..c600f7a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -14,6 +14,7 @@
   header{position:sticky;top:0;z-index:10;background:rgba(15,14,12,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:max(10px,env(safe-area-inset-top)) 14px 10px}
   h1{font:600 17px/1.1 "SF Pro Display",sans-serif;letter-spacing:.04em;margin:0 0 8px;text-transform:uppercase}
   h1 span{color:var(--gold)}
+  h1 .ver{font:600 10px/1 ui-monospace,Menlo,monospace;color:#1b1407;background:var(--green);border-radius:99px;padding:3px 7px;letter-spacing:.04em;vertical-align:middle;text-transform:none;font-style:normal;margin-left:6px}
   .bar{height:8px;border-radius:99px;background:#2a261f;overflow:hidden;margin:6px 0}
   .bar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),#e7c878);width:0;transition:width .3s}
   .meta{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
@@ -29,6 +30,7 @@
   .card{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
   .card.done{border-color:var(--green)}
   .thumb{aspect-ratio:4/3;background:#0c0b09 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#4a443b;font-size:34px;position:relative}
+  .thumb.tap{cursor:pointer} .thumb.tap:hover{filter:brightness(1.08)}
   .badge{position:absolute;top:8px;right:8px;font-size:11px;padding:3px 8px;border-radius:99px;font-weight:700;letter-spacing:.03em}
   .b-need{background:#3a2a14;color:#e7c878} .b-done{background:var(--green);color:#fff} .b-err{background:var(--red);color:#fff}
   .b-live{background:#1f7a48;color:#fff;box-shadow:0 0 0 2px rgba(63,160,106,.4)}
@@ -46,6 +48,7 @@
   .shoot{flex:1;position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border:none;border-radius:12px;padding:13px;font-weight:700;font-size:15px;text-align:center;cursor:pointer}
   .shoot input{position:absolute;inset:0;opacity:0;font-size:200px;cursor:pointer}
   .skip{background:#16140f;border:1px solid var(--line);color:var(--muted);border-radius:12px;padding:13px 14px;font-size:14px;cursor:pointer}
+  .enh{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 14px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
   .card.done .shoot{background:#16140f;color:var(--gold);border:1px solid var(--gold)}
   .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}
@@ -83,7 +86,7 @@
 </head>
 <body>
 <header>
-  <h1>Mobile DW SKU <span>Photos</span></h1>
+  <h1>Mobile DW SKU <span>Photos</span> <em class="ver">__VER__</em></h1>
   <div class="bar"><i id="prog"></i></div>
   <div class="meta"><span id="count">…</span><span id="remain"></span></div>
   <div class="controls">
@@ -174,8 +177,9 @@ function render(){
     const el=document.createElement('div'); el.className='card'+(x.done?' done':'');
     const badge=x.push_err?'<span class="badge b-err">PUSH ERR</span>':x.live?'<span class="badge b-live">● LIVE</span>':x.done?'<span class="badge b-done">✓ PHOTO</span>':'<span class="badge b-need">NEEDS</span>';
     const thumb=x.photo?`style="background-image:url('${x.photo}')"`:'';
+    const adjustable=x.done; // any done item can be re-opened in the editor (local photo or live Shopify image)
     el.innerHTML=`
-      <div class="thumb" ${thumb}>${x.photo?'':'▦'}${badge}</div>
+      <div class="thumb ${adjustable?'tap':''}" ${thumb} title="${adjustable?'click to adjust':''}">${x.photo?'':'▦'}${badge}</div>
       <div class="body">
         <div class="ttl">${(x.title||'').replace(/ \\| Fentucci$/,'')}</div>
         <div class="model" title="tap to copy model #" onclick="navigator.clipboard&&navigator.clipboard.writeText('${x.mfr||''}')"><small>Model&nbsp;#</small><b>${x.mfr||'—'}</b></div>
@@ -187,11 +191,15 @@ function render(){
         ${x.ts?`<div class="when">🕓 ${fmt(x.ts)}${x.live?' · 🟢 LIVE on store':x.shopify_pushed?(x.live_reason?(' · photo on Shopify ('+x.live_reason+')'):' · photo on Shopify'):''}</div>`:''}
         ${x.push_err?`<div class="when" style="color:var(--red)">${x.push_err}</div>`:''}
         <div class="actions">
+          ${adjustable?'<button class="enh">✨ Enhance</button>':''}
           <label class="shoot">${x.done?'📷 Retake':'📷 Open Camera'}<input type="file" accept="image/*" capture="environment"></label>
           ${x.done?'':'<button class="skip">Skip</button>'}
         </div>
       </div>`;
+    const editSrc=()=> (x.photo||('/api/current-image?pid='+x.product_id))+(x.photo?'?':'&')+'t='+Date.now();
     el.querySelector('.shoot input').addEventListener('change',e=>shoot(x,e.target.files[0]));
+    const enh=el.querySelector('.enh'); if(enh) enh.addEventListener('click',()=>openEditor(x,editSrc()));
+    const th=el.querySelector('.thumb.tap'); if(th) th.addEventListener('click',()=>openEditor(x,editSrc()));
     const sk=el.querySelector('.skip'); if(sk) sk.addEventListener('click',()=>skip(x));
     grid.appendChild(el);
   }
@@ -258,27 +266,54 @@ function syncLabels(){
 function signPct(v){const p=Math.round((+v-1)*100); return (p>0?'+':'')+p;}
 
 function scheduleRedraw(){ if(edRAF)return; edRAF=requestAnimationFrame(()=>{edRAF=0;edRedraw();}); }
+const _cl=v=>v<0?0:v>255?255:v;
+function _hsl2rgb(h,s,l){ // h 0..1
+  const q=l<.5?l*(1+s):l+s-l*s, p=2*l-q;
+  const f=t=>{t=(t+1)%1; return t<1/6?p+(q-p)*6*t:t<1/2?q:t<2/3?p+(q-p)*(2/3-t)*6:p;};
+  return [f(h+1/3)*255,f(h)*255,f(h-1/3)*255];
+}
+function curFilter(){ return `brightness(${+$('#edB').value}) contrast(${+$('#edC').value}) saturate(${+$('#edS').value}) hue-rotate(${+$('#edH').value}deg)`; }
+// Bake the slider adjustments into real pixels (reliable on every device — no ctx.filter).
+function bakeAdjust(src,w,h){
+  const c=document.createElement('canvas'); c.width=w; c.height=h;
+  const ctx=c.getContext('2d'); ctx.drawImage(src,0,0,w,h);
+  const id=ctx.getImageData(0,0,w,h), d=id.data;
+  const b=+$('#edB').value, con=+$('#edC').value, sat=+$('#edS').value, hue=(+$('#edH').value)*Math.PI/180;
+  const cos=Math.cos(hue), sn=Math.sin(hue);
+  const m=[0.213+cos*0.787-sn*0.213,0.715-cos*0.715-sn*0.715,0.072-cos*0.072+sn*0.928,
+           0.213-cos*0.213+sn*0.143,0.715+cos*0.285+sn*0.140,0.072-cos*0.072-sn*0.283,
+           0.213-cos*0.213-sn*0.787,0.715-cos*0.715+sn*0.715,0.072+cos*0.928+sn*0.072];
+  const castOn=ES&&ES.ts>0.04; let cr=0,cg=0,cb=0,ca=0;
+  if(castOn){ const t=_hsl2rgb((ES.th||0)/360,1,.5); cr=t[0];cg=t[1];cb=t[2]; ca=ES.ts*0.6; }
+  for(let i=0;i<d.length;i+=4){
+    let r=d[i],g=d[i+1],bl=d[i+2];
+    r*=b;g*=b;bl*=b;                                   // brightness
+    r=(r-128)*con+128;g=(g-128)*con+128;bl=(bl-128)*con+128; // contrast
+    const L=0.2126*r+0.7152*g+0.0722*bl;               // saturation
+    r=L+(r-L)*sat;g=L+(g-L)*sat;bl=L+(bl-L)*sat;
+    const nr=r*m[0]+g*m[1]+bl*m[2],ng=r*m[3]+g*m[4]+bl*m[5],nb=r*m[6]+g*m[7]+bl*m[8]; // hue
+    r=nr;g=ng;bl=nb;
+    if(castOn){ r=r*(1-ca)+cr*ca;g=g*(1-ca)+cg*ca;bl=bl*(1-ca)+cb*ca; } // color cast
+    d[i]=_cl(r);d[i+1]=_cl(g);d[i+2]=_cl(bl);
+  }
+  ctx.putImageData(id,0,0); return c;
+}
 function edRedraw(){
   if(!ES)return;
-  // 1) render the enhanced FLAT image to the offscreen canvas
-  const fc=ES.flat, fx=fc.getContext('2d');
   ES.b=+$('#edB').value; ES.c=+$('#edC').value; ES.s=+$('#edS').value; ES.h=+$('#edH').value;
-  fx.clearRect(0,0,fc.width,fc.height);
-  fx.filter=`brightness(${ES.b}) contrast(${ES.c}) saturate(${ES.s}) hue-rotate(${ES.h}deg)`;
-  fx.drawImage(ES.img,0,0);
-  fx.filter='none';
-  if(ES.ts>0.04){ // color-balance tint (Photoshop-style soft-light cast)
-    fx.globalCompositeOperation='soft-light';
-    fx.globalAlpha=ES.ts*0.6; fx.fillStyle=`hsl(${ES.th},100%,50%)`;
-    fx.fillRect(0,0,fc.width,fc.height);
-    fx.globalAlpha=1; fx.globalCompositeOperation='source-over';
-  }
-  // 2) blit to the visible canvas — flat, or wrapped around a tube
   const cv=$('#edcanvas'), ctx=cv.getContext('2d');
-  ctx.clearRect(0,0,cv.width,cv.height);
-  if(ES.rollMode==='tube') renderRoll(fc, ctx, cv.width, cv.height);
-  else if(ES.rollMode==='sheet') renderSheet(fc, ctx, cv.width, cv.height);
-  else ctx.drawImage(fc,0,0);
+  if(ES.rollMode==='tube'||ES.rollMode==='sheet'){
+    cv.style.filter='none';
+    const fc=bakeAdjust(ES.img,ES.img.width,ES.img.height);
+    ctx.clearRect(0,0,cv.width,cv.height);
+    (ES.rollMode==='tube'?renderRoll:renderSheet)(fc,ctx,cv.width,cv.height);
+  } else {
+    // FLAT: live preview via CSS filter (instant + iOS-reliable); cast painted in pixels
+    cv.style.filter=curFilter();
+    ctx.clearRect(0,0,cv.width,cv.height);
+    ctx.drawImage(ES.img,0,0);
+    if(ES.ts>0.04){ ctx.save(); ctx.globalCompositeOperation='soft-light'; ctx.globalAlpha=ES.ts*0.6; ctx.fillStyle=`hsl(${ES.th},100%,50%)`; ctx.fillRect(0,0,cv.width,cv.height); ctx.restore(); }
+  }
 }
 
 // Wrap the flat texture around a vertical cylinder ("roll around a tube,
@@ -397,8 +432,11 @@ $('#edReset').addEventListener('click',()=>{
 });
 $('#edCancel').addEventListener('click',()=>{toast('Edit cancelled');closeEditor();});
 $('#edUse').addEventListener('click',()=>{
-  if(!ES)return; const x=ES.x; edRedraw();
-  const dataUrl=$('#edcanvas').toDataURL('image/jpeg',0.9);
+  if(!ES)return; const x=ES.x;
+  // BAKE the adjustment into pixels (CSS preview filter does NOT survive toDataURL).
+  let dataUrl;
+  if(ES.rollMode==='tube'||ES.rollMode==='sheet'){ edRedraw(); dataUrl=$('#edcanvas').toDataURL('image/jpeg',0.9); }
+  else { dataUrl=bakeAdjust(ES.img,ES.img.width,ES.img.height).toDataURL('image/jpeg',0.9); }
   closeEditor(); uploadPhoto(x,dataUrl);
 });
 async function skip(x){await fetch('/api/skip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:x.dw_sku,skipped:true})});x.skipped=true;render();}
diff --git a/server.js b/server.js
index 4c90fde..fe78c37 100644
--- a/server.js
+++ b/server.js
@@ -37,6 +37,19 @@ fs.mkdirSync(PHOTOS, { recursive: true });
 const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return d; } };
 const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2));
 
+// Auto-version: derive a sequential build number from the actual file contents.
+// Any change to index.html or server.js → new hash → next build number. Never manual.
+const crypto = require('crypto');
+function buildLabel() {
+  let blob = '';
+  try { blob += fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'); } catch (e) {}
+  try { blob += fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8'); } catch (e) {}
+  const hash = crypto.createHash('md5').update(blob).digest('hex').slice(0, 8);
+  const reg = loadJSON(path.join(DATA, 'build.json'), { next: 2, map: {} });
+  if (!reg.map[hash]) { reg.map[hash] = reg.next++; saveJSON(path.join(DATA, 'build.json'), reg); }
+  return `v${reg.map[hash]} · editor`;
+}
+
 // progress.json: { "<dw_sku>": { done, skipped, photo, ts, shopify_pushed, push_err } }
 let progress = loadJSON(PROGRESS_FILE, {});
 const saveProgress = () => saveJSON(PROGRESS_FILE, progress);
@@ -141,8 +154,9 @@ const server = http.createServer((req, res) => {
   if (u.pathname === '/' || u.pathname === '/index.html') {
     // no-store: the UI evolves fast and is read fresh per request — never let a
     // browser serve a stale cached page (that hid the photo editor after a deploy).
+    let html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8').replace(/__VER__/g, buildLabel());
     return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' }),
-           res.end(fs.readFileSync(path.join(ROOT, 'public/index.html')));
+           res.end(html);
   }
 
   if (u.pathname === '/api/queue' && req.method === 'GET') {
@@ -156,6 +170,25 @@ const server = http.createServer((req, res) => {
     return send(res, 404, { err: 'not found' });
   }
 
+  // Same-origin proxy for a product's CURRENT Shopify image — lets the editor
+  // load + re-adjust a done item that has no local photo (e.g. shot on another
+  // device, or a pre-existing image), without a cross-origin canvas taint.
+  if (u.pathname === '/api/current-image' && req.method === 'GET') {
+    const pid = u.searchParams.get('pid');
+    if (!pid) return send(res, 400, { err: 'pid required' });
+    (async () => {
+      const r = await shopifyReq('GET', `/products/${pid}/images.json`);
+      const imgs = (r.body && r.body.images) || [];
+      const src = imgs.length ? imgs[imgs.length - 1].src : null; // featured/last
+      if (!src) return send(res, 404, { err: 'no image' });
+      https.get(src, (ir) => {
+        res.writeHead(200, { 'Content-Type': ir.headers['content-type'] || 'image/jpeg', 'Cache-Control': 'no-store' });
+        ir.pipe(res);
+      }).on('error', e => send(res, 502, { err: e.message }));
+    })();
+    return;
+  }
+
   if (u.pathname === '/api/photo' && req.method === 'POST') {
     let body = '';
     req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });

← 571e941 auto-save: 2026-06-24T16:28:13 (4 files) — data/queue.json r  ·  back to Dw Photo Capture  ·  Fix grid density slider + editor preview-equals-export bake 89c5686 →