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