← 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 →