← back to Dw Photo Capture
cam.html + desk.html: full pre-capture photoshop camera (zero→full parity) (TK-12090)
880e4d8b124acd28629071e55bc097d62c271b2e · 2026-09-23 17:50:30 -0700 · Steve Abrams
cam.html: add WYSIWYG preview canvas + WB tap-to-sample + full 11-control adjust panel
(exposure/contrast/highlights+shadows/temp+tint/saturation+vibrance/hue/sharpness) +
presets (showroom/daylight/label-closeup) + live histogram + clipping badge + hardware
applyConstraints layer. Full-res baked capture replaces raw drawImage. Tune POSTed to
/api/pair/cam-status so desktop can show readout. Preview tick at 80ms (capture:false),
full-res shutter capture at TS_MAX longest edge (capture:true, unsharp enabled).
desk.html: add readonly adjustment tune readout (preset + key slider values from phone status).
Displays live as phone adjusts (via SSE tune field in /desk/events status update).
Both files preserve existing fetch-guard + SSE pairing + shot result flows unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKaojLnVW8hyxsVriY5f9a
Files touched
M public/cam.htmlM public/desk.html
Diff
commit 880e4d8b124acd28629071e55bc097d62c271b2e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 17:50:30 2026 -0700
cam.html + desk.html: full pre-capture photoshop camera (zero→full parity) (TK-12090)
cam.html: add WYSIWYG preview canvas + WB tap-to-sample + full 11-control adjust panel
(exposure/contrast/highlights+shadows/temp+tint/saturation+vibrance/hue/sharpness) +
presets (showroom/daylight/label-closeup) + live histogram + clipping badge + hardware
applyConstraints layer. Full-res baked capture replaces raw drawImage. Tune POSTed to
/api/pair/cam-status so desktop can show readout. Preview tick at 80ms (capture:false),
full-res shutter capture at TS_MAX longest edge (capture:true, unsharp enabled).
desk.html: add readonly adjustment tune readout (preset + key slider values from phone status).
Displays live as phone adjusts (via SSE tune field in /desk/events status update).
Both files preserve existing fetch-guard + SSE pairing + shot result flows unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKaojLnVW8hyxsVriY5f9a
---
public/cam.html | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++-----
public/desk.html | 16 ++++
2 files changed, 256 insertions(+), 24 deletions(-)
diff --git a/public/cam.html b/public/cam.html
index 99736fb..0442892 100644
--- a/public/cam.html
+++ b/public/cam.html
@@ -24,6 +24,7 @@
};
})();
</script>
+<script src="/js/capture-pipeline.js"></script>
<meta charset="utf-8">
<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">
@@ -70,10 +71,29 @@
.overlay p{color:var(--muted);margin:0;max-width:340px}
.toast{position:fixed;left:50%;bottom:120px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:10px 16px;border-radius:12px;font-size:14px;z-index:60;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.show{opacity:1}
+ /* Preview canvas (WYSIWYG overlay on video) */
+ #preview{position:fixed;inset:0;object-fit:contain;pointer-events:none;z-index:2}
+ /* Adjust panel — bottom sheet, collapsible */
+ #adjPanelWrap{position:fixed;bottom:0;left:0;right:0;z-index:25;max-height:70vh;background:linear-gradient(#000,#1b1407);border-top:1px solid var(--line);display:flex;flex-direction:column}
+ #adjHead{padding:12px 14px;display:flex;align-items:center;gap:10px;font-weight:600;cursor:pointer;background:#0f0e0c}
+ #adjHead:active{background:#1b1407}
+ #adjIcon{font-size:20px;transition:transform .2s}
+ #adjIcon.open{transform:rotate(180deg)}
+ #adjBody{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:12px}
+ #adjBody[hidden]{display:none}
+ .adj-group{display:flex;flex-direction:column;gap:8px}
+ .adj-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;font-weight:600}
+ .adj-sld{display:flex;align-items:center;gap:8px;height:28px}
+ .adj-sld input[type=range]{flex:1;accent-color:var(--gold);height:6px;border-radius:3px}
+ .adj-sld .val{font-size:12px;min-width:32px;text-align:right;color:var(--gold);font-weight:600}
+ select{appearance:none;background:#16140f;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:14px;font-weight:600;cursor:pointer;flex:1}
+ select:focus{outline:1px solid var(--gold)}
+ #adjHist{width:100%;height:40px;border:1px solid var(--line);border-radius:4px;background:#0a0905}
+ #adjClip{display:inline-block;font-size:11px;color:var(--red);margin-top:4px;font-weight:600}
</style>
</head>
<body>
- <div id="stage"><video id="v" playsinline autoplay muted></video></div>
+ <div id="stage"><video id="v" playsinline autoplay muted></video><canvas id="preview"></canvas></div>
<div id="flash"></div>
<div class="bar">
@@ -96,11 +116,81 @@
<div class="toast" id="toast"></div>
+ <!-- Adjust panel: preset select, sliders, histogram, controls -->
+ <div id="adjPanelWrap">
+ <div id="adjHead">
+ <span id="adjIcon" class="open">▼</span>
+ <span>Adjustments</span>
+ </div>
+ <div id="adjBody">
+ <div class="adj-group">
+ <label class="adj-label">Preset</label>
+ <select id="adjPreset">
+ <option value="custom">Custom</option>
+ <option value="showroom">Showroom</option>
+ <option value="daylight">Daylight</option>
+ <option value="label-closeup">Label Close-up</option>
+ </select>
+ </div>
+
+ <canvas id="adjHist" width="220" height="40"></canvas>
+ <span id="adjClip" hidden>⚠ Clipping</span>
+
+ <div class="adj-group">
+ <label class="adj-label">Exposure</label>
+ <div class="adj-sld"><input type="range" id="adjExposure" min="-100" max="100" value="0" step="1"><span class="val" id="valExposure">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Contrast</label>
+ <div class="adj-sld"><input type="range" id="adjContrast" min="-100" max="100" value="0" step="1"><span class="val" id="valContrast">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Highlights</label>
+ <div class="adj-sld"><input type="range" id="adjHighlights" min="-100" max="100" value="0" step="1"><span class="val" id="valHighlights">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Shadows</label>
+ <div class="adj-sld"><input type="range" id="adjShadows" min="-100" max="100" value="0" step="1"><span class="val" id="valShadows">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Temperature</label>
+ <div class="adj-sld"><input type="range" id="adjTemp" min="-100" max="100" value="0" step="1"><span class="val" id="valTemp">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Tint</label>
+ <div class="adj-sld"><input type="range" id="adjTint" min="-100" max="100" value="0" step="1"><span class="val" id="valTint">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Saturation</label>
+ <div class="adj-sld"><input type="range" id="adjSaturation" min="-100" max="100" value="0" step="1"><span class="val" id="valSaturation">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Vibrance</label>
+ <div class="adj-sld"><input type="range" id="adjVibrance" min="-100" max="100" value="0" step="1"><span class="val" id="valVibrance">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Hue</label>
+ <div class="adj-sld"><input type="range" id="adjHue" min="-180" max="180" value="0" step="1"><span class="val" id="valHue">0</span></div>
+ </div>
+ <div class="adj-group">
+ <label class="adj-label">Sharpness</label>
+ <div class="adj-sld"><input type="range" id="adjSharpness" min="0" max="100" value="0" step="1"><span class="val" id="valSharpness">0</span></div>
+ </div>
+
+ <button class="btn" id="adjReset" style="margin-top:8px">Reset to Defaults</button>
+ </div>
+ </div>
+
<script>
const $ = s => document.querySelector(s);
let stream = null, videoTrack = null, camLive = false, busy = false;
let curTarget = null; // { dw_sku, product_id, keep_images, meta }
let lastNonce = 0;
+let _tune = CapturePipeline.defaultTune();
+let _preset = 'custom';
+let _wbGains = null; // { rGain, gGain, bGain }
+let _previewTicker = null;
+const TS_MAX = 2000; // max longest edge for capture (same as index.html)
function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2200); }
function setCam(state, label){
@@ -108,8 +198,83 @@ function setCam(state, label){
$('#sCam').textContent=label;
}
+// Load/save tune from localStorage
+function loadTune(){
+ const saved = CapturePipeline.loadTune('dwCamTune');
+ _tune = saved || CapturePipeline.defaultTune();
+ _preset = localStorage.getItem('dwCamPreset') || 'custom';
+ syncSliders();
+}
+function saveTune(){
+ CapturePipeline.saveTune('dwCamTune', _tune);
+ localStorage.setItem('dwCamPreset', _preset);
+}
+
+function syncSliders(){
+ document.querySelectorAll('.adj-sld input').forEach(inp=>{
+ const key = inp.id.replace('adj','').toLowerCase();
+ const val = _tune[key];
+ if(val!==undefined){ inp.value=val; $('#val'+inp.id.slice(3)).textContent=val; }
+ });
+ $('#adjPreset').value = _preset;
+}
+
+// WB tap-to-sample (same math as index.html)
+$('#preview').addEventListener('click', (e)=>{
+ const v=$('#v');
+ const rect = v.getBoundingClientRect();
+ const objFit = v.style.objectFit || 'contain';
+ let sx=0,sy=0,sw=v.videoWidth,sh=v.videoHeight;
+ if(objFit==='cover' || objFit==='contain'){
+ const vAR = rect.width/rect.height, vidAR = sw/sh;
+ if(objFit==='contain' ? vAR>vidAR : vAR<vidAR){
+ const scaledH = rect.width/vidAR;
+ sy += (rect.height-scaledH)/2/rect.height*sh;
+ sh = scaledH/rect.height*sh;
+ }else{
+ const scaledW = rect.height*vidAR;
+ sx += (rect.width-scaledW)/2/rect.width*sw;
+ sw = scaledW/rect.width*sw;
+ }
+ }
+ const px = (e.clientX-rect.left)/rect.width, py = (e.clientY-rect.top)/rect.height;
+ const vx = sx + px*sw, vy = sy + py*sh;
+ // Sample a 28×28 region
+ const c = document.createElement('canvas');
+ c.width = c.height = 28;
+ c.getContext('2d').drawImage(v, Math.max(0,vx-14), Math.max(0,vy-14), 28, 28, 0, 0, 28, 28);
+ const d = c.getContext('2d').getImageData(0,0,28,28).data;
+ let r=0,g=0,b=0;
+ for(let i=0;i<d.length;i+=4){ r+=d[i]; g+=d[i+1]; b+=d[i+2]; }
+ const n = d.length/4;
+ const avg = { r:Math.round(r/n), g:Math.round(g/n), b:Math.round(b/n) };
+ const g0 = avg.g || 1;
+ _wbGains = { rGain: Math.max(0.3, Math.min(3, g0/(avg.r||1))), gGain: 1, bGain: Math.max(0.3, Math.min(3, g0/(avg.b||1))) };
+ toast(`WB tapped`);
+});
+
+// Preview tick: draw + bake preview canvas
+function previewTick(){
+ if(!camLive) return;
+ const v=$('#v');
+ const pc = $('#preview');
+ const pw = Math.min(640, Math.max(v.videoWidth, v.videoHeight));
+ const scale = pw / Math.max(v.videoWidth, v.videoHeight);
+ pc.width = v.videoWidth * scale;
+ pc.height = v.videoHeight * scale;
+ const pctx = pc.getContext('2d', {willReadFrequently:true});
+ CapturePipeline.drawSource(pctx, v, pc.width, pc.height, _tune.straighten);
+ CapturePipeline.apply(pctx, pc.width, pc.height, _tune, {capture:false, preGain:_wbGains||null});
+ // Update histogram
+ const hist = CapturePipeline.histogram(pctx, pc.width, pc.height);
+ const hc = $('#adjHist');
+ CapturePipeline.drawHistogram(hc.getContext('2d'), hist);
+ const clipWarn = $('#adjClip');
+ if(hist.clipHigh > 0.02 || hist.clipLow > 0.02){ clipWarn.hidden=false; }
+ else{ clipWarn.hidden=true; }
+}
+
// ── getUserMedia: rear camera, hardened for iOS Safari ──
-// 1) try exact environment (true rear), 2) fall back to environment, 3) any camera.
async function startCamera(){
if(stream){ return; }
$('#gBtn').textContent='Starting…';
@@ -133,17 +298,27 @@ async function startCamera(){
}
const v=$('#v'); v.srcObject=stream;
videoTrack = stream.getVideoTracks()[0];
- // keep it playing (iOS pauses on backgrounding) + reflect resolution
- v.onloadedmetadata=()=>{ v.play().catch(()=>{}); reportLive(); };
- // surface track ending (OS reclaimed the camera, e.g. another app)
- videoTrack.addEventListener('ended', ()=>{ camLive=false; setCam('err','camera stopped'); reportLive(); $('#gate').hidden=false; $('#gTitle').textContent='Camera stopped'; $('#gMsg').textContent='The camera was released (phone slept or another app grabbed it). Tap to resume.'; $('#gBtn').textContent='📷 Resume Camera'; });
+ v.onloadedmetadata=()=>{ v.play().catch(()=>{});
+ // Start preview tick + hardware layer
+ clearInterval(_previewTicker);
+ _previewTicker = setInterval(previewTick, 80);
+ // Probe hardware capabilities
+ const caps = CapturePipeline.Hardware.probe(videoTrack);
+ if(Object.keys(caps).length>0){
+ // Apply constraints when tune changes (debounced)
+ let hwDelay=null;
+ const updateHW=()=>{ clearTimeout(hwDelay); hwDelay=setTimeout(()=>{ CapturePipeline.Hardware.apply(videoTrack, _tune); }, 300); };
+ document.querySelectorAll('.adj-sld input').forEach(inp=>inp.addEventListener('input', updateHW));
+ }
+ reportLive();
+ };
+ videoTrack.addEventListener('ended', ()=>{ camLive=false; setCam('err','camera stopped'); reportLive(); clearInterval(_previewTicker); $('#gate').hidden=false; $('#gTitle').textContent='Camera stopped'; $('#gMsg').textContent='The camera was released (phone slept or another app grabbed it). Tap to resume.'; $('#gBtn').textContent='📷 Resume Camera'; });
$('#gate').hidden=true;
camLive=true;
reportLive();
}
function camResolution(){
const v=$('#v');
- // settings is most reliable for true sensor res; fall back to the video element
const s = videoTrack && videoTrack.getSettings ? videoTrack.getSettings() : {};
const w = s.width || v.videoWidth || 0, h = s.height || v.videoHeight || 0;
const fm = s.facingMode || '';
@@ -154,17 +329,26 @@ function reportLive(){
camLive = !!(stream && videoTrack && videoTrack.readyState==='live' && r.w>0);
setCam(camLive?'ok':'warn', camLive?('camera live · '+r.label):'camera idle');
fetch('/api/pair/cam-status',{method:'POST',headers:{'Content-Type':'application/json'},
- body:JSON.stringify({ live:camLive, info:r.label })}).catch(()=>{});
+ body:JSON.stringify({ live:camLive, info:r.label, tune:_tune })}).catch(()=>{});
}
-// ── Capture a frame at native resolution (NEVER upscale) ──
+// ── Capture a frame at native resolution (NEVER upscale) — NOW WITH FULL BAKE ──
function captureDataUrl(){
const v=$('#v');
const r=camResolution();
- const w = r.w || v.videoWidth, h = r.h || v.videoHeight;
+ let w = r.w || v.videoWidth, h = r.h || v.videoHeight;
if(!w||!h) return null;
- const c=document.createElement('canvas'); c.width=w; c.height=h; // exact native res, no scaling
- c.getContext('2d').drawImage(v, 0, 0, w, h);
+ // Cap longest edge at TS_MAX
+ const maxEdge = Math.max(w, h);
+ if(maxEdge > TS_MAX){
+ const scale = TS_MAX / maxEdge;
+ w = Math.round(w * scale);
+ h = Math.round(h * scale);
+ }
+ const c=document.createElement('canvas'); c.width=w; c.height=h;
+ const ctx = c.getContext('2d', {willReadFrequently:true});
+ CapturePipeline.drawSource(ctx, v, w, h, _tune.straighten);
+ CapturePipeline.apply(ctx, w, h, _tune, {capture:true, preGain:_wbGains||null});
return c.toDataURL('image/jpeg', 0.92);
}
@@ -172,28 +356,23 @@ function captureDataUrl(){
async function onShoot(target){
if(busy) return;
if(!camLive){ toast('Camera not live'); reportLive(); return; }
- if(target.nonce && target.nonce===lastNonce) return; // de-dupe repeated SSE delivery
+ if(target.nonce && target.nonce===lastNonce) return;
lastNonce = target.nonce || Date.now();
busy=true;
- // visual + capture
const f=$('#flash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),120);
if(navigator.vibrate) try{ navigator.vibrate(40);}catch(e){}
const dataUrl = captureDataUrl();
if(!dataUrl){ toast('Capture failed'); busy=false; return; }
toast('Captured — uploading…');
- // Route into the SAME existing attach pipeline as a manual capture: /api/photo.
- // keep_images defaults true (preserve existing imagery — safe for live SKUs).
- // If no target SKU was selected, the photo still lands locally (push:false).
let res;
try{
const payload = target.dw_sku
? { dw_sku:target.dw_sku, product_id:target.product_id||null, dataUrl,
keep_images: target.keep_images!==false, meta: target.meta||null }
- : { dw_sku:'REMOTE-'+Date.now(), dataUrl, push:false }; // no SKU → local only, no Shopify write
+ : { dw_sku:'REMOTE-'+Date.now(), dataUrl, push:false };
const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
res=await r.json();
}catch(e){ res={ ok:false, err:'upload failed — check connection' }; }
- // tell the desktop what happened so it can preview + show the attach outcome
fetch('/api/pair/shot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({
dw_sku: target.dw_sku||null, product_id: res&&res.product_id||target.product_id||null
}, res||{}))}).catch(()=>{});
@@ -201,14 +380,14 @@ async function onShoot(target){
busy=false;
}
-// ── SSE link to the server (auto-reconnects natively) ──
+// ── SSE + status ──
let es=null;
function connect(){
try{ if(es) es.close(); }catch(e){}
es=new EventSource('/cam/events');
es.addEventListener('shoot', e=>{ try{ onShoot(JSON.parse(e.data)); }catch(err){} });
es.addEventListener('status', e=>{ try{ applyStatus(JSON.parse(e.data)); }catch(err){} });
- es.onerror=()=>{ /* EventSource auto-reconnects per the server's retry: hint */ };
+ es.onerror=()=>{};
}
function applyStatus(s){
$('#target').innerHTML = s.desk_connected
@@ -216,19 +395,56 @@ function applyStatus(s){
: 'Desktop not connected yet';
}
-// ── Lifecycle hardening: re-assert the stream when the page comes back to foreground ──
+// ── Panel collapse/expand ──
+$('#adjHead').addEventListener('click', ()=>{
+ const body=$('#adjBody');
+ const icon=$('#adjIcon');
+ body.hidden = !body.hidden;
+ icon.classList.toggle('open');
+});
+
+// ── Slider + preset wiring ──
+document.querySelectorAll('.adj-sld input').forEach(inp=>{
+ inp.addEventListener('input', ()=>{
+ const key = inp.id.replace('adj','').toLowerCase();
+ const val = parseInt(inp.value);
+ _tune[key] = val;
+ $('#val'+inp.id.slice(3)).textContent = val;
+ _preset = 'custom';
+ $('#adjPreset').value = 'custom';
+ saveTune();
+ });
+});
+
+$('#adjPreset').addEventListener('change', (e)=>{
+ if(e.target.value==='custom'){ _preset='custom'; saveTune(); return; }
+ _tune = CapturePipeline.applyPreset(_tune, e.target.value);
+ _preset = e.target.value;
+ syncSliders();
+ saveTune();
+});
+
+$('#adjReset').addEventListener('click', ()=>{
+ _tune = CapturePipeline.defaultTune();
+ _preset = 'custom';
+ syncSliders();
+ $('#adjPreset').value = 'custom';
+ saveTune();
+ toast('Reset to defaults');
+});
+
+// ── Lifecycle ──
document.addEventListener('visibilitychange', ()=>{
if(document.visibilityState==='visible'){
const v=$('#v');
if(stream){ v.play().catch(()=>{}); reportLive(); }
}
});
-// re-report camera state periodically so the desktop's "camera live" pill stays honest
setInterval(()=>{ if(stream) reportLive(); }, 7000);
$('#gBtn').addEventListener('click', startCamera);
+loadTune();
connect();
-// Wake Lock (where supported) keeps the screen on so the stream doesn't pause.
let wakeLock=null;
async function requestWake(){ try{ if('wakeLock' in navigator){ wakeLock=await navigator.wakeLock.request('screen'); } }catch(e){} }
document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible') requestWake(); });
diff --git a/public/desk.html b/public/desk.html
index 01f0136..3495e6f 100644
--- a/public/desk.html
+++ b/public/desk.html
@@ -80,6 +80,8 @@
.biglink{color:var(--gold);font-size:13px;text-decoration:none}
.camlinkbox{font-size:13px;color:var(--muted);margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
.camlinkbox code{background:#0c0b09;border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--gold)}
+ /* Adjustment tune readout (readonly on phone status) */
+ #tuneReadout{display:inline-block;font-size:12px;color:var(--muted);margin-top:-4px;font-weight:500;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
</style>
</head>
<body>
@@ -89,6 +91,7 @@
<span class="pill"><span class="dot" id="dPhone"></span><span id="sPhone">phone: …</span></span>
<span class="pill"><span class="dot warn" id="dDesk"></span>this desktop</span>
</div>
+ <div id="tuneReadout"></div>
</header>
<main>
@@ -176,6 +179,19 @@ function applyStatus(s){
if(s.cam_live){ d.className='dot ok'; t.textContent='phone: camera live'+(s.cam_info?(' · '+s.cam_info):''); }
else if(s.cam_connected){ d.className='dot warn'; t.textContent='phone: connected, camera idle'; }
else { d.className='dot err'; t.textContent='phone: not connected'; }
+ // Show the phone's current tune settings (readonly readout)
+ const tr=$('#tuneReadout');
+ if(s.tune){
+ const tune=s.tune;
+ let parts=[];
+ // Preset name (if we could infer it, for now just show key values)
+ if(tune.exposure!==0) parts.push('Exp '+(tune.exposure>0?'+':'')+tune.exposure);
+ if(tune.contrast!==0) parts.push('Contr '+(tune.contrast>0?'+':'')+tune.contrast);
+ if(tune.saturation!==0) parts.push('Sat '+(tune.saturation>0?'+':'')+tune.saturation);
+ if(tune.temp!==0) parts.push('Temp '+(tune.temp>0?'+':'')+tune.temp);
+ if(parts.length>0) tr.textContent='📸 '+parts.join(' · ');
+ else tr.textContent='';
+ }else{ tr.textContent=''; }
updateShoot();
}
function updateShoot(){
← af5702a index.html two-shot: adopt shared capture-pipeline + full ad
·
back to Dw Photo Capture
·
batch.html: adopt shared capture-pipeline engine, full Photo 2a2801e →