[object Object]

← 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

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 →