[object Object]

← back to Dw Photo Capture

photo-capture: live pre-shutter colour + ordered two-photo ghost capture (#twoShotCam)

480017e59f1e25cb40bf09286246b6623ed846f0 · 2026-09-20 11:44:45 -0700 · Steve Abrams

Replace the native front/back camera handoff in the "Add New" two-photo flow
with an in-page getUserMedia full-screen live view (#twoShotCam). Colour (white
balance + brightness/saturation/hue) is now corrected on the LIVE feed and BAKED
into the saved JPEG — one manual-getImageData bake (tsPipeline, same hue-rotation
constants as bakeAdjust) drives BOTH the ~80ms WYSIWYG preview canvas AND the
native-resolution shutter, so preview == export. Photo 1 (FRONT) then Photo 2
(BACK) are captured in order, with Photo 1 ghosted (cover-mapped canvas,
luminosity blend) over the live feed for alignment; the BACK tile refuses to
open the camera until FRONT exists. Live WB tap-to-sample reuses the wbSampleAt
object-fit:cover crop-math on #tsVideo. Captured dataURLs land via landShot()
(= shotAdd minus downscale/applyWB, since colour is already baked); autoExtract /
twoPhotoGate downstream is unchanged. Native frontInput/backInput kept as the
HTTP/no-getUserMedia fallback.

Ported from batch.html: WYSIWYG canvas.pv pattern, draggable+collapsible
toolpanel (collapsed by default to a "Adjust" pill; Brightness/Saturation/Hue,
Contrast dropped from the live strip), 78px gold shutter, 56px thumb, ordered
phase state machine. Native-res capture reuses cam.html's canvas=track-w×h,
never-upscale pattern + visibilitychange/wake-lock resume.

Mobile: user-scalable=no; .mbar bottom safe-area fix; #twoShotCam anchored
full-screen (mirrors the shipping .scanlive/#wbModal/#measureModal, provably
unaffected by html{zoom:1.35}); >=48px controls; phone-landscape rotate-to-
portrait overlay (keyed on max-height:500px so it fires on wide landscape
phones and excludes iPad, which keeps the draggable side-rail panel).

Retires the standalone #wbModal / "White Balance" button FOR THIS PATH only;
#editor/bakeAdjust/measure/gallery/other capture pickers untouched. TK-11947.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKdG2eRwfVYSA7CwxzYAXr

Files touched

Diff

commit 480017e59f1e25cb40bf09286246b6623ed846f0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 20 11:44:45 2026 -0700

    photo-capture: live pre-shutter colour + ordered two-photo ghost capture (#twoShotCam)
    
    Replace the native front/back camera handoff in the "Add New" two-photo flow
    with an in-page getUserMedia full-screen live view (#twoShotCam). Colour (white
    balance + brightness/saturation/hue) is now corrected on the LIVE feed and BAKED
    into the saved JPEG — one manual-getImageData bake (tsPipeline, same hue-rotation
    constants as bakeAdjust) drives BOTH the ~80ms WYSIWYG preview canvas AND the
    native-resolution shutter, so preview == export. Photo 1 (FRONT) then Photo 2
    (BACK) are captured in order, with Photo 1 ghosted (cover-mapped canvas,
    luminosity blend) over the live feed for alignment; the BACK tile refuses to
    open the camera until FRONT exists. Live WB tap-to-sample reuses the wbSampleAt
    object-fit:cover crop-math on #tsVideo. Captured dataURLs land via landShot()
    (= shotAdd minus downscale/applyWB, since colour is already baked); autoExtract /
    twoPhotoGate downstream is unchanged. Native frontInput/backInput kept as the
    HTTP/no-getUserMedia fallback.
    
    Ported from batch.html: WYSIWYG canvas.pv pattern, draggable+collapsible
    toolpanel (collapsed by default to a "Adjust" pill; Brightness/Saturation/Hue,
    Contrast dropped from the live strip), 78px gold shutter, 56px thumb, ordered
    phase state machine. Native-res capture reuses cam.html's canvas=track-w×h,
    never-upscale pattern + visibilitychange/wake-lock resume.
    
    Mobile: user-scalable=no; .mbar bottom safe-area fix; #twoShotCam anchored
    full-screen (mirrors the shipping .scanlive/#wbModal/#measureModal, provably
    unaffected by html{zoom:1.35}); >=48px controls; phone-landscape rotate-to-
    portrait overlay (keyed on max-height:500px so it fires on wide landscape
    phones and excludes iPad, which keeps the draggable side-rail panel).
    
    Retires the standalone #wbModal / "White Balance" button FOR THIS PATH only;
    #editor/bakeAdjust/measure/gallery/other capture pickers untouched. TK-11947.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QKdG2eRwfVYSA7CwxzYAXr
---
 public/index.html | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 313 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 8d5d1f0..ca1dfc0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,7 +26,7 @@
 })();
 </script>
 <meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
 <meta name="apple-mobile-web-app-capable" content="yes">
 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
 <meta name="apple-mobile-web-app-title" content="DW Photo">
@@ -151,6 +151,66 @@
   .scan-flip{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:18px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer}
   .scan-logo{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 16px;font-size:15px;font-weight:600;cursor:pointer}
   .scan-pat{position:absolute;top:max(20px,env(safe-area-inset-top));right:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:11px 15px;font-size:15px;font-weight:600;cursor:pointer}
+  /* ===== TWO-SHOT LIVE CAM — in-page getUserMedia; live WB + colour BAKED before the shutter (WYSIWYG).
+     Anchored full-screen (mirrors .scanlive / #wbModal / #measureModal) so it's provably unaffected by html{zoom}. ===== */
+  #twoShotCam{position:fixed;inset:0;z-index:450;background:#000}
+  #twoShotCam[hidden]{display:none}
+  #tsVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;z-index:1}
+  /* WYSIWYG corrected preview — same cover box as the video, so what you see IS what bakes into the JPEG */
+  #tsPreview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:5}
+  /* Photo-1 ghost over the live feed (step 2) — canvas, cover-mapped for pixel-accurate alignment */
+  #tsGhost{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:6;opacity:.36;mix-blend-mode:luminosity}
+  #tsGhost[hidden]{display:none}
+  #tsFlash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .12s;z-index:20}
+  #tsFlash.on{opacity:.85;transition:none}
+  #tsReticle{position:absolute;width:58px;height:58px;transform:translate(-50%,-50%);border:3px solid #fff;border-radius:50%;box-shadow:0 0 0 2px rgba(0,0,0,.5),0 0 12px rgba(0,0,0,.6);pointer-events:none;z-index:21;opacity:0;transition:opacity .3s}
+  #tsReticle.on{opacity:1;transition:none}
+  #twoShotCam .vendor-sticky{z-index:30}
+  #twoShotCam .vendor-sticky .vs-meta b{color:#f0e2b8}
+  .ts-step{position:absolute;left:50%;transform:translateX(-50%);top:calc(env(safe-area-inset-top,0px) + 74px);z-index:31;
+    display:inline-flex;align-items:center;gap:9px;background:#16140fe6;border:1px solid var(--line);border-radius:99px;
+    padding:7px 15px 7px 7px;backdrop-filter:blur(6px);font:700 15px/1 "SF Pro Display",sans-serif;color:var(--ink)}
+  .ts-step .n{width:28px;height:28px;border-radius:50%;background:var(--gold);color:#1b1407;font:800 15px/28px ui-monospace,Menlo,monospace;text-align:center}
+  .ts-step.back .n{background:#3f7d4f;color:#fff}
+  .ts-step small{color:var(--muted);font-weight:600;font-size:12px;margin-left:2px}
+  .ts-topbtn{position:absolute;top:calc(env(safe-area-inset-top,0px) + 10px);z-index:32;background:#16140fcc;border:1px solid var(--line);
+    color:var(--ink);border-radius:12px;min-height:48px;padding:11px 15px;font-size:15px;font-weight:600;cursor:pointer;backdrop-filter:blur(6px)}
+  #tsClose{right:12px}
+  #tsFlip{right:calc(96px + env(safe-area-inset-right,0px))}
+  .ts-hint{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(max(14px,env(safe-area-inset-bottom)) + 116px);z-index:31;
+    background:#16140fcc;border:1px solid var(--line);color:var(--muted);border-radius:12px;padding:8px 14px;font-size:12.5px;max-width:82%;text-align:center;backdrop-filter:blur(6px)}
+  /* bottom-third controls: 78px shutter + 56px thumb, one-handed reach; shutter kept >=12px above the home indicator */
+  .ts-bottom{position:absolute;left:0;right:0;bottom:0;z-index:33;display:flex;align-items:center;justify-content:center;gap:24px;
+    padding:14px 16px calc(max(14px,env(safe-area-inset-bottom)) + 12px);background:linear-gradient(transparent,#000d)}
+  .ts-shutter{width:78px;height:78px;border-radius:50%;border:4px solid var(--gold);background:var(--gold);cursor:pointer;flex:0 0 auto;box-shadow:0 0 0 3px rgba(0,0,0,.35)}
+  .ts-shutter:disabled{background:#3a3428;border-color:#3a3428}
+  .ts-thumb{width:56px;height:56px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f}
+  /* movable + collapsible colour tool panel (ported from batch.html .toolpanel) */
+  .ts-toolpanel{position:absolute;z-index:34;width:min(94vw,440px);left:50%;bottom:calc(max(14px,env(safe-area-inset-bottom)) + 108px);
+    transform:translateX(-50%);background:#14120eea;border:1px solid var(--line);border-radius:16px;backdrop-filter:blur(10px);box-shadow:0 10px 34px #000a;overflow:hidden}
+  .ts-toolpanel.dragging{transition:none;opacity:.96}
+  .ts-toolpanel.collapsed{width:auto}
+  .ts-tp-head{display:flex;align-items:center;gap:8px;padding:11px 12px;cursor:grab;user-select:none;touch-action:none;background:#1b1710;min-height:48px}
+  .ts-toolpanel:not(.collapsed) .ts-tp-head{border-bottom:1px solid var(--line)}
+  .ts-tp-head:active{cursor:grabbing}
+  .ts-tp-title{font:800 15px/1 "SF Pro Display",sans-serif;color:var(--gold);display:flex;align-items:center;gap:6px;white-space:nowrap}
+  .ts-tp-grip{margin-left:2px;color:var(--muted);font:700 14px/1 sans-serif;letter-spacing:2px}
+  .ts-tp-btn{margin-left:auto;width:42px;height:38px;border-radius:9px;border:1px solid var(--line);background:#16140f;color:var(--gold);font:700 16px/1 sans-serif;cursor:pointer}
+  .ts-tp-body{padding:12px 12px 13px;display:flex;flex-direction:column;gap:12px;max-height:46vh;overflow:auto}
+  .ts-toolpanel.collapsed .ts-tp-body{display:none}
+  .ts-sld{display:block}
+  .ts-sld>span{display:flex;justify-content:space-between;font:700 11px/1 ui-monospace,Menlo,monospace;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin:0 0 5px}
+  .ts-sld>span i{color:var(--gold);font-style:normal}
+  .ts-sld input[type=range]{width:100%;accent-color:var(--gold);height:34px}
+  .ts-reset{padding:12px 14px;font-size:13px;align-self:flex-start;min-height:48px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;cursor:pointer}
+  /* phone landscape → rotate-to-portrait overlay; iPad (>=768) landscape keeps the draggable side-rail panel */
+  .ts-rotate{position:absolute;inset:0;z-index:60;background:#0f0e0cf5;display:none;flex-direction:column;align-items:center;justify-content:center;gap:14px;text-align:center;padding:24px}
+  .ts-rotate h2{font:800 22px/1.2 "SF Pro Display",sans-serif;color:var(--gold);margin:0}
+  .ts-rotate p{color:var(--muted);margin:0;max-width:320px}
+  /* phone landscape = SHORT height (~<=500px). iPad landscape stays tall (>=744px) so it is excluded and
+     keeps the draggable side-rail panel. Keying on height (not width) is why this fires on a phone whose
+     landscape WIDTH is ~800-930px — a max-width query would never match it. */
+  @media (orientation:landscape) and (max-height:500px){ #twoShotCam:not([hidden]) .ts-rotate{display:flex} }
   /* 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}
@@ -326,7 +386,7 @@
   .mrz{position:absolute;right:-13px;bottom:-13px;width:26px;height:26px;background:rgba(255,255,255,.9);border-radius:50%;touch-action:none;cursor:nwse-resize}
   .mreadout{position:absolute;top:14px;left:0;right:0;text-align:center;color:#fff;font-size:17px;text-shadow:0 1px 4px #000;pointer-events:none}
   .mreadout b{color:#ffe08a}
-  .mbar{position:absolute;bottom:22px;left:0;right:0;display:flex;gap:12px;justify-content:center}
+  .mbar{position:absolute;bottom:max(22px,calc(env(safe-area-inset-bottom) + 12px));left:0;right:0;display:flex;gap:12px;justify-content:center}
   .mbar button{padding:13px 20px;border:none;border-radius:999px;font-size:15px;font-weight:700;cursor:pointer}
   #mUse{background:#2bd06a;color:#032}#mClose{background:#222;color:#fff}
   .add-spec{display:flex;justify-content:space-between;gap:12px;font-size:12.5px;padding:4px 0;border-bottom:1px solid rgba(255,255,255,.05)}
@@ -636,6 +696,46 @@
     <button id="wbClose">✕ Done</button>
   </div>
 </div>
+
+<!-- TWO-SHOT LIVE CAM — in-page getUserMedia; adjust White-Balance + colour on the LIVE feed, then the
+     shutter BAKES the correction into the saved JPEG (WYSIWYG). Photo 1 = FRONT (pattern face), Photo 2 =
+     BACK (label / SKU), in order, with Photo 1 ghosted over the live feed for alignment. Replaces the
+     native front/back camera handoff so the controls can overlay the live view. -->
+<div id="twoShotCam" hidden>
+  <video id="tsVideo" playsinline webkit-playsinline muted autoplay></video>
+  <canvas id="tsPreview"></canvas>
+  <canvas id="tsGhost" hidden></canvas>
+  <div id="tsFlash"></div>
+  <div id="tsReticle"></div>
+  <div class="vendor-sticky" id="tsVendorBar">
+    <span class="vs-lbl">Shooting for</span>
+    <div class="vs-meta" id="tsVendorName"><b>—</b></div>
+  </div>
+  <div class="ts-step" id="tsStep"><span class="n" id="tsStepNum">1</span><span>of 2 · <b id="tsStepName">FRONT</b></span><small id="tsStepHint">pattern face</small></div>
+  <button class="ts-topbtn" id="tsFlip" title="Switch camera">🔄</button>
+  <button class="ts-topbtn" id="tsClose">✕ Done</button>
+  <div class="ts-hint" id="tsHint">Tap a white / grey area to true the colour · adjust below · then shoot</div>
+  <div class="ts-toolpanel collapsed" id="tsTools">
+    <div class="ts-tp-head" id="tsTpHead">
+      <span class="ts-tp-title">🎚 <b>Adjust</b> <span class="ts-tp-grip">⠿</span></span>
+      <button class="ts-tp-btn" id="tsTpToggle" title="Open or close">▸</button>
+    </div>
+    <div class="ts-tp-body" id="tsTpBody">
+      <label class="ts-sld"><span>Brightness <i id="tsvBright">0</i></span><input type="range" id="tssBright" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Saturation <i id="tsvSat">0</i></span><input type="range" id="tssSat" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Hue <i id="tsvHue">0</i></span><input type="range" id="tssHue" min="-180" max="180" value="0"></label>
+      <button class="ts-reset" id="tsReset">↺ Reset colour + white balance</button>
+    </div>
+  </div>
+  <div class="ts-bottom">
+    <img class="ts-thumb" id="tsThumb" alt="Photo 1" hidden>
+    <button class="ts-shutter" id="tsShutterBtn" title="Take photo"></button>
+  </div>
+  <div class="ts-rotate">
+    <h2>↻ Turn your phone upright</h2>
+    <p>The two-photo camera works best held vertically. Rotate to portrait to keep shooting.</p>
+  </div>
+</div>
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -1962,6 +2062,209 @@ function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)re
   const d=c.getContext('2d').getImageData(x0,y0,w,h).data; let r=0,g=0,b=0,n=0;
   for(let i=0;i<d.length;i+=4){ r+=d[i];g+=d[i+1];b+=d[i+2];n++; } return {r:r/n,g:g/n,b:b/n}; }
 
+// ════════════════════ TWO-SHOT LIVE CAM: WYSIWYG pre-shutter colour + ordered front/back + ghost ════════
+// The colour is corrected on the LIVE feed and BAKED into the saved JPEG. ONE manual-getImageData bake
+// (tsPipeline) runs on BOTH the ~80ms live preview AND the full-res shutter, so preview == export.
+let _tsStream=null,_tsTrack=null,_tsPhase='front',_tsBusy=false,_tsLive=false,_tsFrontImg=null,_tsPvTimer=null,_tsWake=null;
+let _tsWB=null;                                              // {rGain,gGain,bGain} — live white-balance gains (from wbGet)
+let _tsTune={bright:0,sat:0,hue:0};                          // live colour tune (persisted per device)
+function tsLoadTune(){ try{ const o=JSON.parse(localStorage.getItem('dwTsTune')||'null'); if(o&&typeof o==='object') return {bright:+o.bright||0,sat:+o.sat||0,hue:+o.hue||0}; }catch(e){} return {bright:0,sat:0,hue:0}; }
+function tsSaveTune(){ try{ localStorage.setItem('dwTsTune',JSON.stringify(_tsTune)); }catch(e){} }
+// The ONE bake — WB × brightness, then saturation, then hue-rotation (identical hue constants to bakeAdjust).
+function tsPipeline(ctx,w,h){
+  const img=ctx.getImageData(0,0,w,h), d=img.data;
+  const wb=_tsWB||{rGain:1,gGain:1,bGain:1};
+  const bmul=Math.max(0.2,Math.min(2.2,1+(_tsTune.bright||0)/100));
+  const GR=wb.rGain*bmul, GG=wb.gGain*bmul, GB=wb.bGain*bmul;
+  const sat=Math.max(0,Math.min(2,1+(_tsTune.sat||0)/100));
+  const hueOn=!!(_tsTune.hue), a=(_tsTune.hue||0)*Math.PI/180, cos=Math.cos(a), sn=Math.sin(a);
+  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];
+  for(let i=0;i<d.length;i+=4){
+    let r=_cl(d[i]*GR), g=_cl(d[i+1]*GG), b=_cl(d[i+2]*GB);                                 // WB × brightness
+    const L=0.2126*r+0.7152*g+0.0722*b;                                                     // saturation
+    r=_cl(L+(r-L)*sat); g=_cl(L+(g-L)*sat); b=_cl(L+(b-L)*sat);
+    if(hueOn){ const nr=_cl(r*m[0]+g*m[1]+b*m[2]),ng=_cl(r*m[3]+g*m[4]+b*m[5]),nb=_cl(r*m[6]+g*m[7]+b*m[8]); r=nr;g=ng;b=nb; }
+    d[i]=r; d[i+1]=g; d[i+2]=b;
+  }
+  ctx.putImageData(img,0,0);
+}
+// live WYSIWYG preview: corrected frame drawn over the raw video (~80ms; capture stays full-res)
+function tsPreviewTick(){
+  if($('#twoShotCam').hidden||!_tsLive) return;
+  const v=$('#tsVideo'), w=v.videoWidth, h=v.videoHeight; if(!w||!h) return;
+  const scale=Math.min(1,640/Math.max(w,h)), pw=Math.round(w*scale), ph=Math.round(h*scale);
+  const pv=$('#tsPreview'); if(pv.width!==pw)pv.width=pw; if(pv.height!==ph)pv.height=ph;
+  const x=pv.getContext('2d',{willReadFrequently:true});
+  try{ x.drawImage(v,0,0,pw,ph); tsPipeline(x,pw,ph); }catch(e){}
+}
+// full-res capture: canvas = the track's real w×h (NEVER upscale) → run the SAME bake once → JPEG
+function tsCapture(){
+  const v=$('#tsVideo'); const s=(_tsTrack&&_tsTrack.getSettings)?_tsTrack.getSettings():{};
+  const w=s.width||v.videoWidth, h=s.height||v.videoHeight; if(!w||!h) return null;
+  const c=document.createElement('canvas'); c.width=w; c.height=h;
+  const ctx=c.getContext('2d',{willReadFrequently:true});
+  try{ ctx.drawImage(v,0,0,w,h); tsPipeline(ctx,w,h); }catch(e){ return null; }
+  return c.toDataURL('image/jpeg',0.92);
+}
+// Photo-1 ghost over the live feed, cover-mapped (SAME object-fit:cover math as the WB sampler) so it
+// aligns pixel-accurately to the live crop. Runs only in the 'back' phase.
+function drawTsGhost(){
+  if($('#twoShotCam').hidden || _tsPhase!=='back' || !_tsFrontImg) return;
+  const v=$('#tsVideo'), gc=$('#tsGhost'), rect=v.getBoundingClientRect();
+  const W=Math.round(rect.width), H=Math.round(rect.height);
+  if(!W||!H){ requestAnimationFrame(drawTsGhost); return; }
+  if(gc.width!==W)gc.width=W; if(gc.height!==H)gc.height=H;
+  const iw=_tsFrontImg.naturalWidth||_tsFrontImg.width, ih=_tsFrontImg.naturalHeight||_tsFrontImg.height;
+  if(iw&&ih){ const scale=Math.max(W/iw,H/ih), dw=iw*scale, dh=ih*scale, ox=(W-dw)/2, oy=(H-dh)/2;
+    const x=gc.getContext('2d'); x.clearRect(0,0,W,H); x.drawImage(_tsFrontImg,ox,oy,dw,dh); }
+  requestAnimationFrame(drawTsGhost);
+}
+// WB tap-to-sample on the LIVE feed — identical cover crop-math to wbSampleAt, on #tsVideo.
+function tsSampleAt(clientX,clientY){ const v=$('#tsVideo'); if(!v.videoWidth)return null; const rect=v.getBoundingClientRect();
+  const scale=Math.max(rect.width/v.videoWidth, rect.height/v.videoHeight);            // object-fit:cover
+  const offX=(rect.width-v.videoWidth*scale)/2, offY=(rect.height-v.videoHeight*scale)/2;
+  const px=Math.round((clientX-rect.left-offX)/scale), py=Math.round((clientY-rect.top-offY)/scale);
+  const c=document.createElement('canvas'); c.width=v.videoWidth; c.height=v.videoHeight; c.getContext('2d').drawImage(v,0,0);
+  const R=14, x0=Math.max(0,px-R), y0=Math.max(0,py-R), w=Math.max(1,Math.min(v.videoWidth-x0,R*2)), h=Math.max(1,Math.min(v.videoHeight-y0,R*2));
+  const d=c.getContext('2d').getImageData(x0,y0,w,h).data; let r=0,g=0,b=0,n=0;
+  for(let i=0;i<d.length;i+=4){ r+=d[i];g+=d[i+1];b+=d[i+2];n++; } return {r:r/n,g:g/n,b:b/n}; }
+function tsLoadImg(url){ return new Promise(res=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=url; }); }
+function tsFlash(){ const f=$('#tsFlash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),120); if(navigator.vibrate)try{navigator.vibrate(30);}catch(e){} }
+function tsSetStep(){
+  const back=_tsPhase==='back';
+  $('#tsStep').classList.toggle('back',back);
+  $('#tsStepNum').textContent = back?'2':'1';
+  $('#tsStepName').textContent = back?'BACK':'FRONT';
+  $('#tsStepHint').textContent = back?'label / SKU':'pattern face';
+  $('#tsGhost').hidden = !back;
+  $('#tsHint').textContent = back
+    ? 'Ghost of Photo 1 aligns the frame · shoot the LABEL side'
+    : 'Tap a white / grey area to true the colour · adjust below · then shoot';
+}
+function tsBindTune(){
+  const map=[['tssBright','bright','tsvBright'],['tssSat','sat','tsvSat'],['tssHue','hue','tsvHue']];
+  for(const [id,key,lbl] of map){ const el=$('#'+id); if(!el)continue; el.value=_tsTune[key]; $('#'+lbl).textContent=_tsTune[key];
+    el.oninput=()=>{ _tsTune[key]=parseInt(el.value,10)||0; $('#'+lbl).textContent=_tsTune[key]; tsSaveTune(); }; }
+}
+function tsResetColour(){ _tsTune={bright:0,sat:0,hue:0}; tsSaveTune(); _tsWB=null; wbSet(null);
+  for(const [id,lbl] of [['tssBright','tsvBright'],['tssSat','tsvSat'],['tssHue','tsvHue']]){ const e=$('#'+id); if(e)e.value=0; const l=$('#'+lbl); if(l)l.textContent='0'; }
+  toast('Colour + white balance reset'); }
+// draggable + collapsible tool panel; position/collapsed persisted (default: collapsed pill, iPad-landscape → right rail)
+function tsInitPanel(){
+  const tp=$('#tsTools'), head=$('#tsTpHead'), tog=$('#tsTpToggle'); if(!tp||tp._rigged) return; tp._rigged=true;
+  let st=null; try{ st=JSON.parse(localStorage.getItem('dwTsPanel')||'null'); }catch(e){}
+  const iPadLandscape = innerWidth>=768 && innerWidth>innerHeight;
+  if(st&&st.x!=null){ tp.style.left=st.x+'px'; tp.style.top=st.y+'px'; tp.style.bottom='auto'; tp.style.transform='none'; }
+  else if(iPadLandscape){ tp.style.left='auto'; tp.style.right='16px'; tp.style.top='96px'; tp.style.bottom='auto'; tp.style.transform='none'; }
+  const collapsed = st ? !!st.collapsed : true;                            // default collapsed to the "🎚 Adjust" pill
+  tp.classList.toggle('collapsed',collapsed); tog.textContent=collapsed?'▸':'▾';
+  const save=(o)=>{ try{ localStorage.setItem('dwTsPanel',JSON.stringify(Object.assign(tsPanelState(),o))); }catch(e){} };
+  tog.onclick=(e)=>{ e.stopPropagation(); const c=tp.classList.toggle('collapsed'); tog.textContent=c?'▸':'▾'; save({collapsed:c}); };
+  let sx,sy,ox,oy,drag=false;
+  const down=(e)=>{ if(e.target===tog) return; const p=e.touches?e.touches[0]:e; const r=tp.getBoundingClientRect();
+    tp.style.left=r.left+'px'; tp.style.top=r.top+'px'; tp.style.right='auto'; tp.style.bottom='auto'; tp.style.transform='none';
+    sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; drag=true; tp.classList.add('dragging'); e.preventDefault(); };
+  const move=(e)=>{ if(!drag)return; const p=e.touches?e.touches[0]:e;
+    let nx=ox+(p.clientX-sx), ny=oy+(p.clientY-sy);
+    nx=Math.max(4,Math.min(nx,innerWidth-tp.offsetWidth-4)); ny=Math.max(4,Math.min(ny,innerHeight-52));
+    tp.style.left=nx+'px'; tp.style.top=ny+'px'; };
+  const up=()=>{ if(!drag)return; drag=false; tp.classList.remove('dragging'); const r=tp.getBoundingClientRect(); save({x:Math.round(r.left),y:Math.round(r.top)}); };
+  head.addEventListener('mousedown',down); head.addEventListener('touchstart',down,{passive:false});
+  addEventListener('mousemove',move); addEventListener('touchmove',move,{passive:false});
+  addEventListener('mouseup',up); addEventListener('touchend',up);
+}
+function tsPanelState(){ const tp=$('#tsTools'); const r=tp.getBoundingClientRect(); return { x:Math.round(r.left), y:Math.round(r.top), collapsed:tp.classList.contains('collapsed') }; }
+async function startTsStream(){
+  if(_tsStream){ _tsStream.getTracks().forEach(t=>t.stop()); _tsStream=null; }
+  const attempts=[
+    { video:{ facingMode:{ ideal:camFacing }, width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
+    { video:{ facingMode:{ ideal:camFacing } }, audio:false },
+    { video:true, audio:false }
+  ];
+  let err=null;
+  for(const cst of attempts){ try{ _tsStream=await navigator.mediaDevices.getUserMedia(cst); break; }catch(e){ err=e; } }
+  if(!_tsStream) throw (err||new Error('no camera'));
+  const v=$('#tsVideo'); v.srcObject=_tsStream; _tsTrack=_tsStream.getVideoTracks()[0];
+  try{ await v.play(); }catch(e){}                                     // explicit play in the tap handler → video shows <1s
+  _tsLive=true;
+}
+async function tsRequestWake(){ try{ if('wakeLock' in navigator){ _tsWake=await navigator.wakeLock.request('screen'); } }catch(e){} }
+// Open the live view. startPhase 'front' | 'back'. Back is unreachable until Photo 1 exists (order enforced).
+async function openTwoShotCam(startPhase){
+  startPhase = startPhase||'front';
+  if(startPhase==='back' && !_frontPhoto){ toast('Take Photo 1 (the FRONT) first'); startPhase='front'; }
+  // no live camera (HTTP / unsupported) → fall back to the native <input capture> handoff
+  if(!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
+    return $(startPhase==='back'?'#backInput':'#frontInput').click();
+  }
+  try{ await startTsStream(); }
+  catch(e){ toast('Camera blocked — using snap'); return $(startPhase==='back'?'#backInput':'#frontInput').click(); }
+  _tsPhase=startPhase; _tsWB=wbGet(); _tsTune=tsLoadTune();
+  // vendor bar mirrors the sticky vendor (one place only — no duplicate select)
+  const ven=($('#addVendor')&&$('#addVendor').value)|| (typeof LS==='function'?LS('vendor'):'') ||'';
+  $('#tsVendorName').innerHTML = ven?('<b>'+ven+'</b>'):'<b style="color:#e0483a">— pick a vendor —</b>';
+  tsBindTune(); tsInitPanel();
+  $('#tsThumb').hidden = !_frontPhoto; if(_frontPhoto) $('#tsThumb').src=_frontPhoto;
+  if(startPhase==='back'){ _tsFrontImg=await tsLoadImg(_frontPhoto); }
+  tsSetStep();
+  $('#twoShotCam').hidden=false; document.body.style.overflow='hidden';
+  $('#tsShutterBtn').disabled=false;
+  if(_tsPvTimer) clearInterval(_tsPvTimer); _tsPvTimer=setInterval(tsPreviewTick,80);
+  requestAnimationFrame(drawTsGhost);
+  tsRequestWake();
+}
+function closeTwoShotCam(){
+  if(_tsPvTimer){ clearInterval(_tsPvTimer); _tsPvTimer=null; }
+  if(_tsStream){ _tsStream.getTracks().forEach(t=>t.stop()); _tsStream=null; } _tsTrack=null; _tsLive=false;
+  _tsFrontImg=null; $('#tsGhost').hidden=true;
+  try{ if(_tsWake){ _tsWake.release(); _tsWake=null; } }catch(e){}
+  $('#twoShotCam').hidden=true; document.body.style.overflow='hidden';   // #addModal is still open beneath
+}
+// LAND a baked capture into the two-photo state — this is shotAdd() WITHOUT downscale/applyWB, because
+// the colour (incl. white balance) is already baked into the pixels. Everything downstream is unchanged.
+async function landShot(side,url){
+  if(side==='front'){ _frontPhoto=url; _addPhoto=url; detectAndShowColor(); }
+  else { _backPhoto=url; if(!_addPhoto)_addPhoto=url; }
+  renderShots();
+  if(_addMode==='update'){ if(!_extracted){ _extracted=true; await addExtract(url); } return; }
+  if(_frontPhoto && _backPhoto) await autoExtract();
+}
+async function tsShutter(){
+  if(_tsBusy||!_tsLive) return; _tsBusy=true; $('#tsShutterBtn').disabled=true;
+  tsFlash();
+  const url=tsCapture();
+  if(!url){ toast('Capture failed — try again'); _tsBusy=false; $('#tsShutterBtn').disabled=false; return; }
+  if(_tsPhase==='front'){
+    await landShot('front',url);
+    _tsFrontImg=await tsLoadImg(url);                       // ghost source for step 2
+    $('#tsThumb').src=url; $('#tsThumb').hidden=false;
+    _tsPhase='back'; tsSetStep(); requestAnimationFrame(drawTsGhost);
+    toast('✓ Front captured — now the BACK (label)');
+    _tsBusy=false; $('#tsShutterBtn').disabled=false;
+  } else {
+    closeTwoShotCam();
+    await landShot('back',url);                             // runs autoExtract downstream
+    _tsBusy=false;
+  }
+}
+// wiring
+$('#tsShutterBtn').addEventListener('click',tsShutter);
+$('#tsClose').addEventListener('click',closeTwoShotCam);
+$('#tsReset').addEventListener('click',tsResetColour);
+$('#tsFlip').addEventListener('click',async()=>{ camFacing=(camFacing==='user')?'environment':'user'; setLS('camFacing',camFacing); applyCamCapture();
+  try{ await startTsStream(); }catch(e){ toast('Could not switch camera'); } });
+// tap the LIVE feed → white-balance to that white/grey point (baked into every subsequent shot)
+$('#tsVideo').addEventListener('pointerdown',ev=>{ if($('#twoShotCam').hidden) return; ev.preventDefault();
+  const rt=$('#tsReticle'); rt.style.left=ev.clientX+'px'; rt.style.top=ev.clientY+'px'; rt.classList.add('on'); setTimeout(()=>rt.classList.remove('on'),650);
+  const g=gainsFromRef(tsSampleAt(ev.clientX,ev.clientY));
+  if(!g){ $('#tsHint').textContent='Too dark there — tap a brighter white / grey area.'; return; }
+  _tsWB=g; wbSet(g); toast('✓ White balance set — colour trued'); });
+// returning to the tab: re-assert the stream + wake lock (iOS pauses on background)
+document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible' && !$('#twoShotCam').hidden){
+  const v=$('#tsVideo'); if(_tsStream){ v.play().catch(()=>{}); tsRequestWake(); } } });
+
 // ── COLOUR HUE: client-side dominant colour of the swatch centre → chip + HEX + nearest DW name ──
 const COLOR_LEX=[['Alabaster',237,234,224],['Oatmeal',214,205,187],['Greige',186,175,158],['Linen',232,223,205],['Ivory',245,240,225],['Cream',245,238,215],['Taupe',150,135,118],['Mushroom',160,150,138],['Sand',209,190,158],['Camel',175,140,95],['Almond',224,205,175],['Putty',188,178,160],['Beige',220,205,180],['Stone',180,172,160],['Wheat',225,205,150],['Charcoal',54,54,56],['Slate',96,105,115],['Graphite',70,72,76],['Onyx',30,30,32],['Black',20,20,20],['White',245,245,245],['Dove Grey',176,176,172],['Silver',190,190,190],['Pewter',132,134,130],['Ash',140,140,138],['Fog',200,200,198],['Celadon',172,196,168],['Sage',158,170,140],['Moss',110,120,80],['Olive',120,115,70],['Fern',90,120,80],['Forest',40,70,50],['Emerald',30,110,80],['Teal',40,110,110],['Aqua',120,190,190],['Sky Blue',150,190,220],['Powder Blue',180,205,225],['Cornflower',110,140,200],['Denim',70,100,140],['Navy',36,52,86],['Indigo',50,60,110],['Cobalt',40,80,160],['Periwinkle',150,150,210],['Blush',230,200,195],['Rose',210,150,150],['Dusty Rose',190,140,140],['Terracotta',195,110,80],['Rust',160,80,50],['Brick',150,70,60],['Clay',180,120,95],['Coral',235,140,120],['Mustard',200,160,60],['Ochre',190,140,70],['Gold',200,168,90],['Butter',240,225,150],['Lavender',190,175,210],['Plum',110,70,100],['Aubergine',80,50,70],['Mauve',175,140,160],['Chocolate',80,55,40],['Espresso',60,45,38],['Walnut',110,80,55],['Cognac',150,95,55],['Tan',195,160,120],['Burgundy',110,40,50],['Wine',95,40,55],['Cranberry',150,45,60],['Red',180,50,50]];
 function rgbHex(r,g,b){ const h=x=>('0'+Math.round(x).toString(16)).slice(-2); return '#'+h(r)+h(g)+h(b); }
@@ -1988,7 +2291,7 @@ function nextItem(){ addResetMedia();
   const cr=$('#colorRead'); if(cr){ cr.hidden=true; cr.classList.remove('show'); cr.innerHTML=''; }
   clearCandidates(); $('#addNote').textContent=''; $('#addResult').innerHTML='';
   renderShots(); toast('Ready for the next item — vendor + calibration kept.');
-  try{ $('#frontInput').click(); }catch(e){}   // jump straight to Photo 1
+  try{ openTwoShotCam('front'); }catch(e){}   // jump straight to Photo 1 (live view)
 }
 let _vreg=[];
 // One <option> set, applied to BOTH the sticky camera bar and the (mirrored) modal picker.
@@ -2041,7 +2344,8 @@ function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _add
     setStickyVendor(inherit, !!(pref&&pref.vendor));
     $('#addVendor').classList.toggle('need', _addMode!=='update' && !inherit);
   });
-  if(opts.camera) setTimeout(()=>$('#addPhotoInput').click(),250);  // onload → go straight to camera
+  // onload → go straight to camera. ADD → the live two-shot view (front); UPDATE → the label snap.
+  if(opts.camera){ if(_addMode==='update') setTimeout(()=>$('#addPhotoInput').click(),250); else setTimeout(()=>openTwoShotCam('front'),250); }
 }
 function addVideosList(){ return _media.filter(m=>m.type==='video'); }
 function addPayload(commit){ const v=id=>{ const el=$('#'+id); return el?el.value.trim():''; };
@@ -2171,9 +2475,11 @@ $('#addBtn').addEventListener('click',()=>openAddModal());
 $('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
 $('#addVendor').addEventListener('change',onVendorPick);
 $('#stickyVendor')&&$('#stickyVendor').addEventListener('change',onStickyPick);
-// Two-photo capture wiring: dedicated FRONT (pattern) + BACK (label) tiles.
-$('#frontBtn').addEventListener('click',()=>$('#frontInput').click());
-$('#backBtn').addEventListener('click',()=>$('#backInput').click());
+// Two-photo capture wiring: dedicated FRONT (pattern) + BACK (label) tiles → the in-page live view.
+// Order is enforced: the BACK tile refuses to open the camera until Photo 1 (front) exists.
+$('#frontBtn').addEventListener('click',()=>openTwoShotCam('front'));
+$('#backBtn').addEventListener('click',()=>{ if(!_frontPhoto){ toast('📷 Take Photo 1 (the FRONT) first'); return; } openTwoShotCam('back'); });
+// frontInput/backInput remain as the NATIVE fallback (HTTP / getUserMedia-unavailable) — shotAdd applies WB there.
 $('#frontInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; shotAdd('front',f); });
 $('#backInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; shotAdd('back',f); });
 // Typing the mfr# manually is a valid identity source → re-evaluate the gate live.

← fc97695 auto-data-snapshot: 2026-09-20T11:40:52 (1 data files) — dat  ·  back to Dw Photo Capture  ·  5x fix sweep: guard camera race + fail-safe liveness + cap c 1878910 →