[object Object]

← back to Dw Photo Capture

index.html two-shot: adopt shared capture-pipeline + full adjust panel + presets + histogram (TK-12090)

af5702a5ed1124fc80f002406ee40f8b6a32c492 · 2026-09-23 16:21:07 -0700 · Steve Abrams

Delegates the two-shot live-cam bake (tsPipeline) to window.CapturePipeline instead of the
old inline WB×brightness/saturation/hue-only bake. tsPreviewTick calls apply() with
capture:false (no unsharp mask, ~80ms budget); tsCapture calls with capture:true (unsharp
mask bakes here only) -- preview/capture parity preserved. _tsWB (tap-sample white-balance
gains) is passed as opts.preGain so it is not double-applied against the engine's own
temp/tint gains. _tsTune is now the full CapturePipeline tune shape, persisted via
CapturePipeline.loadTune/saveTune('dwTsTune') with automatic back-compat for the old
{bright,sat,hue} shape.

Extends the #tsTools panel with: a Preset <select> (Showroom / Daylight / Label close-up /
Custom, persisted as dwTsPreset -- any manual slider move flips it back to Custom), a live
histogram canvas + clipping-warning badge (updated every preview tick), and the full slider
set (Exposure, Contrast, Highlights, Shadows, Temp, Tint, Saturation, Vibrance, Hue,
Sharpness, Straighten) replacing the old 3-slider (Brightness/Saturation/Hue) set. Straighten
now feeds CapturePipeline.drawSource() in both tsPreviewTick and tsCapture in place of the
raw drawImage, so the rotation bakes identically in preview and capture. Panel body was
already max-height:46vh + overflow:auto (scrollable on phone). Native frontInput/backInput
fallback path is untouched.

TK-12115 (parent TK-12090)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY

Files touched

Diff

commit af5702a5ed1124fc80f002406ee40f8b6a32c492
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 16:21:07 2026 -0700

    index.html two-shot: adopt shared capture-pipeline + full adjust panel + presets + histogram (TK-12090)
    
    Delegates the two-shot live-cam bake (tsPipeline) to window.CapturePipeline instead of the
    old inline WB×brightness/saturation/hue-only bake. tsPreviewTick calls apply() with
    capture:false (no unsharp mask, ~80ms budget); tsCapture calls with capture:true (unsharp
    mask bakes here only) -- preview/capture parity preserved. _tsWB (tap-sample white-balance
    gains) is passed as opts.preGain so it is not double-applied against the engine's own
    temp/tint gains. _tsTune is now the full CapturePipeline tune shape, persisted via
    CapturePipeline.loadTune/saveTune('dwTsTune') with automatic back-compat for the old
    {bright,sat,hue} shape.
    
    Extends the #tsTools panel with: a Preset <select> (Showroom / Daylight / Label close-up /
    Custom, persisted as dwTsPreset -- any manual slider move flips it back to Custom), a live
    histogram canvas + clipping-warning badge (updated every preview tick), and the full slider
    set (Exposure, Contrast, Highlights, Shadows, Temp, Tint, Saturation, Vibrance, Hue,
    Sharpness, Straighten) replacing the old 3-slider (Brightness/Saturation/Hue) set. Straighten
    now feeds CapturePipeline.drawSource() in both tsPreviewTick and tsCapture in place of the
    raw drawImage, so the rotation bakes identically in preview and capture. Panel body was
    already max-height:46vh + overflow:auto (scrollable on phone). Native frontInput/backInput
    fallback path is untouched.
    
    TK-12115 (parent TK-12090)
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
 public/index.html | 123 ++++++++++++++++++++++++++++++++++++++----------------
 1 file changed, 88 insertions(+), 35 deletions(-)

diff --git a/public/index.html b/public/index.html
index 0c17dc4..07ea50d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -202,6 +202,10 @@
   .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-preset-sel{width:100%;padding:9px 10px;border-radius:9px;background:#1b1710;color:#eee;border:1px solid var(--line);font:700 13px/1 ui-monospace,Menlo,monospace}
+  .ts-hist-wrap{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#1b1710}
+  .ts-hist-wrap canvas{width:100%;height:46px;display:block}
+  .ts-clip-warn{position:absolute;top:4px;right:5px;font:800 10px/1 ui-monospace,Menlo,monospace;color:#fff;background:#e0483ae0;padding:3px 6px;border-radius:5px;letter-spacing:.02em}
   .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}
@@ -721,9 +725,29 @@
       <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>Preset</span>
+        <select class="ts-preset-sel" id="tsPreset">
+          <option value="showroom">Showroom</option>
+          <option value="daylight">Daylight</option>
+          <option value="label-closeup">Label close-up</option>
+          <option value="custom">Custom</option>
+        </select>
+      </label>
+      <div class="ts-hist-wrap">
+        <canvas id="tsHistCanvas" width="220" height="46"></canvas>
+        <span class="ts-clip-warn" id="tsClipWarn" hidden>⚠ clipping</span>
+      </div>
+      <label class="ts-sld"><span>Exposure <i id="tsvExposure">0</i></span><input type="range" id="tssExposure" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Contrast <i id="tsvContrast">0</i></span><input type="range" id="tssContrast" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Highlights <i id="tsvHighlights">0</i></span><input type="range" id="tssHighlights" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Shadows <i id="tsvShadows">0</i></span><input type="range" id="tssShadows" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Temp <i id="tsvTemp">0</i></span><input type="range" id="tssTemp" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Tint <i id="tsvTint">0</i></span><input type="range" id="tssTint" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Saturation <i id="tsvSaturation">0</i></span><input type="range" id="tssSaturation" min="-100" max="100" value="0"></label>
+      <label class="ts-sld"><span>Vibrance <i id="tsvVibrance">0</i></span><input type="range" id="tssVibrance" 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>
+      <label class="ts-sld"><span>Sharpness <i id="tsvSharpness">0</i></span><input type="range" id="tssSharpness" min="0" max="100" value="0"></label>
+      <label class="ts-sld"><span>Straighten <i id="tsvStraighten">0</i></span><input type="range" id="tssStraighten" min="-15" max="15" value="0"></label>
       <button class="ts-reset" id="tsReset">↺ Reset colour + white balance</button>
     </div>
   </div>
@@ -736,6 +760,8 @@
     <p>The two-photo camera works best held vertically. Rotate to portrait to keep shooting.</p>
   </div>
 </div>
+<!-- shared pre-capture "Photoshop" engine — manual canvas pixel math, must load before the main script (TK-12115) -->
+<script src="/js/capture-pipeline.js"></script>
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -2103,39 +2129,34 @@ function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)re
 // (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,_tsOpening=false,_tsAcquiring=false;
 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)
+let _tsTune=CapturePipeline.defaultTune();                   // live colour tune (full Photoshop set), persisted per device
+let _tsPreset='custom';                                      // last-picked preset name, persisted (dwTsPreset)
+function tsLoadTune(){ return CapturePipeline.loadTune('dwTsTune'); }         // back-compat: normalizes legacy {bright,sat,hue}
+function tsSaveTune(){ CapturePipeline.saveTune('dwTsTune',_tsTune); }
+function tsLoadPreset(){ try{ return localStorage.getItem('dwTsPreset')||'custom'; }catch(e){ return 'custom'; } }
+function tsSavePreset(){ try{ localStorage.setItem('dwTsPreset',_tsPreset); }catch(e){} }
+// The ONE bake — delegates to the shared CapturePipeline engine (public/js/capture-pipeline.js, TK-12115).
+// _tsWB (tap-sample white-balance gains) is passed as opts.preGain so it is NOT double-applied on top of
+// the engine's own temp/tint gains. Sharpness (unsharp mask) bakes ONLY when capture===true.
+function tsPipeline(ctx,w,h,capture){
+  CapturePipeline.apply(ctx,w,h,_tsTune,{capture:!!capture,preGain:_tsWB||null});
+}
+// live WYSIWYG preview: corrected frame drawn over the raw video (~80ms; capture stays full-res).
+// capture:false — sharpness never bakes here (too slow at 80ms). Straighten is baked at draw time via drawSource.
 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){}
+  try{
+    CapturePipeline.drawSource(x,v,pw,ph,_tsTune.straighten);
+    tsPipeline(x,pw,ph,false);
+    tsUpdateHistogram(x,pw,ph);
+  }catch(e){}
 }
-// full-res capture: canvas = the track's real w×h (NEVER upscale) → run the SAME bake once → JPEG
+// full-res capture: canvas = the track's real w×h (NEVER upscale) → run the SAME bake once (+ unsharp) → JPEG.
+// capture:true — this is the ONLY place sharpness (unsharp mask) bakes.
 function tsCapture(){
   const v=$('#tsVideo'); const s=(_tsTrack&&_tsTrack.getSettings)?_tsTrack.getSettings():{};
   let w=s.width||v.videoWidth, h=s.height||v.videoHeight; if(!w||!h) return null;
@@ -2144,9 +2165,21 @@ function tsCapture(){
   const TS_MAX=2000, _m=Math.max(w,h); if(_m>TS_MAX){ const _k=TS_MAX/_m; w=Math.round(w*_k); h=Math.round(h*_k); }
   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; }
+  try{
+    CapturePipeline.drawSource(ctx,v,w,h,_tsTune.straighten);
+    tsPipeline(ctx,w,h,true);
+  }catch(e){ return null; }
   return c.toDataURL('image/jpeg',0.92);
 }
+// histogram + clipping-warning badge — recomputed off the just-baked preview frame each tick.
+function tsUpdateHistogram(ctx,w,h){
+  const hc=$('#tsHistCanvas'); if(!hc) return;
+  try{
+    const hist=CapturePipeline.histogram(ctx,w,h);
+    CapturePipeline.drawHistogram(hc.getContext('2d'),hist);
+    const warn=$('#tsClipWarn'); if(warn) warn.hidden = !(hist.clipHigh>0.02 || hist.clipLow>0.02);
+  }catch(e){}
+}
 // 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(){
@@ -2183,13 +2216,33 @@ function tsSetStep(){
     ? 'Ghost of Photo 1 aligns the frame · shoot the LABEL side'
     : 'Tap a white / grey area to true the colour · adjust below · then shoot';
 }
+// full Photoshop slider set — id / tune-key / value-label-id (STEP 4a, TK-12115)
+const TS_SLIDERS=[
+  ['tssExposure','exposure','tsvExposure'],['tssContrast','contrast','tsvContrast'],
+  ['tssHighlights','highlights','tsvHighlights'],['tssShadows','shadows','tsvShadows'],
+  ['tssTemp','temp','tsvTemp'],['tssTint','tint','tsvTint'],
+  ['tssSaturation','saturation','tsvSaturation'],['tssVibrance','vibrance','tsvVibrance'],
+  ['tssHue','hue','tsvHue'],['tssSharpness','sharpness','tsvSharpness'],['tssStraighten','straighten','tsvStraighten']
+];
+function tsSyncSliders(){
+  for(const [id,key,lbl] of TS_SLIDERS){ const el=$('#'+id); if(!el)continue; el.value=_tsTune[key]; const l=$('#'+lbl); if(l)l.textContent=_tsTune[key]; }
+}
+function tsSetPresetSelect(name){                              // mark the select + persist, without touching _tsTune
+  _tsPreset=name||'custom'; tsSavePreset();
+  const psel=$('#tsPreset'); if(psel) psel.value=_tsPreset;
+}
+function tsApplyPreset(name){                                  // STEP 5: preset select -> full tune, sliders + save follow
+  if(name==='custom'){ tsSetPresetSelect('custom'); return; }
+  _tsTune=CapturePipeline.applyPreset(_tsTune,name);
+  tsSaveTune(); tsSetPresetSelect(name); tsSyncSliders();
+}
 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(); }; }
+  for(const [id,key,lbl] of TS_SLIDERS){ const el=$('#'+id); if(!el)continue; el.value=_tsTune[key]; const l=$('#'+lbl); if(l)l.textContent=_tsTune[key];
+    el.oninput=()=>{ _tsTune[key]=parseInt(el.value,10)||0; if(l)l.textContent=_tsTune[key]; tsSaveTune(); tsSetPresetSelect('custom'); }; }
+  const psel=$('#tsPreset'); if(psel){ psel.value=_tsPreset; psel.onchange=()=>tsApplyPreset(psel.value); }
 }
-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'; }
+function tsResetColour(){ _tsTune=CapturePipeline.defaultTune(); tsSaveTune(); tsSetPresetSelect('custom'); _tsWB=null; wbSet(null);
+  tsSyncSliders();
   toast('Colour + white balance reset'); }
 // draggable + collapsible tool panel; position/collapsed persisted (default: collapsed pill, iPad-landscape → right rail)
 function tsInitPanel(){
@@ -2249,7 +2302,7 @@ async function openTwoShotCam(startPhase){
   }
   try{ await startTsStream(); }
   catch(e){ toast('Camera blocked — using snap'); return $(startPhase==='back'?'#backInput':'#frontInput').click(); }
-  _tsPhase=startPhase; _tsWB=wbGet(); _tsTune=tsLoadTune();
+  _tsPhase=startPhase; _tsWB=wbGet(); _tsTune=tsLoadTune(); _tsPreset=tsLoadPreset();
   // 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>';

← fdff137 add shared capture-pipeline.js engine (full pre-capture phot  ·  back to Dw Photo Capture  ·  cam.html + desk.html: full pre-capture photoshop camera (zer 880e4d8 →