[object Object]

← back to Dw Photo Capture

5x fix sweep: guard camera race + fail-safe liveness + cap capture res + lock lens on BACK (Cody FIX FIRST)

18789103a79029ac928ce04597e0ba589c924461 · 2026-09-20 11:58:44 -0700 · steve

- openTwoShotCam _tsOpening guard + startTsStream _tsAcquiring guard: fast double-tap can no longer race two getUserMedia (no orphaned hot camera stream)
- startTsStream sets _tsLive=false + disables shutter until a fresh live frame is confirmed; a failed camera-flip can no longer bake/save a frozen frame
- tsCapture caps longest edge to 2000px so a busy-pattern JPEG pair can't 413 the server 25MB /api/create-item body cap
- tsSetStep hides #tsFlip on BACK (lens locked for the pair — cross-lens ghost is meaningless); flip failure reverts to the known-good lens
- targeted fake-camera /5x harness: 44/44 PASS iPhone+iPad

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKdG2eRwfVYSA7CwxzYAXr

Files touched

Diff

commit 18789103a79029ac928ce04597e0ba589c924461
Author: steve <steve@designerwallcoverings.com>
Date:   Sun Sep 20 11:58:44 2026 -0700

    5x fix sweep: guard camera race + fail-safe liveness + cap capture res + lock lens on BACK (Cody FIX FIRST)
    
    - openTwoShotCam _tsOpening guard + startTsStream _tsAcquiring guard: fast double-tap can no longer race two getUserMedia (no orphaned hot camera stream)
    - startTsStream sets _tsLive=false + disables shutter until a fresh live frame is confirmed; a failed camera-flip can no longer bake/save a frozen frame
    - tsCapture caps longest edge to 2000px so a busy-pattern JPEG pair can't 413 the server 25MB /api/create-item body cap
    - tsSetStep hides #tsFlip on BACK (lens locked for the pair — cross-lens ghost is meaningless); flip failure reverts to the known-good lens
    - targeted fake-camera /5x harness: 44/44 PASS iPhone+iPad
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QKdG2eRwfVYSA7CwxzYAXr
---
 5x/twoshot-result.json | 266 +++++++++++++++++++++++++++++++++++++++++++++++++
 5x/verify-twoshot.mjs  | 101 +++++++++++++++++++
 public/index.html      |  48 ++++++---
 3 files changed, 399 insertions(+), 16 deletions(-)

diff --git a/5x/twoshot-result.json b/5x/twoshot-result.json
new file mode 100644
index 0000000..501e9f9
--- /dev/null
+++ b/5x/twoshot-result.json
@@ -0,0 +1,266 @@
+[
+  {
+    "vp": "iPhone",
+    "name": "twoShotCam element present",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "hidden until opened",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "BACK request downgraded to FRONT (order enforced)",
+    "ok": true,
+    "detail": "phase=front"
+  },
+  {
+    "vp": "iPhone",
+    "name": "double-tap → single getUserMedia (no orphan stream)",
+    "ok": true,
+    "detail": "1 calls"
+  },
+  {
+    "vp": "iPhone",
+    "name": "opens in FRONT phase",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "live camera playing (fake device)",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "_tsLive true only after live frame",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "3 live color sliders (Bri/Sat/Hue)",
+    "ok": true,
+    "detail": "3"
+  },
+  {
+    "vp": "iPhone",
+    "name": "shutter >=44px",
+    "ok": true,
+    "detail": "105x105"
+  },
+  {
+    "vp": "iPhone",
+    "name": "shutter enabled when live",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "flip visible on FRONT",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "step chip shows FRONT/1",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "FRONT captured → _frontPhoto set",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "advances to BACK phase",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "ghost source loaded for BACK",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "captured photo longest edge <=2000 (server 25MB cap safe)",
+    "ok": true,
+    "detail": "2000px"
+  },
+  {
+    "vp": "iPhone",
+    "name": "flip HIDDEN on BACK (lens locked for pair)",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "ghost canvas rendered on BACK",
+    "ok": true,
+    "detail": "true"
+  },
+  {
+    "vp": "iPhone",
+    "name": "BACK captured → _backPhoto set",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "view closes after pair",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPhone",
+    "name": "camera stream stopped on close (no leak)",
+    "ok": true,
+    "detail": "null=true ended=true"
+  },
+  {
+    "vp": "iPhone",
+    "name": "zero console/page errors",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "twoShotCam element present",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "hidden until opened",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "BACK request downgraded to FRONT (order enforced)",
+    "ok": true,
+    "detail": "phase=front"
+  },
+  {
+    "vp": "iPad",
+    "name": "double-tap → single getUserMedia (no orphan stream)",
+    "ok": true,
+    "detail": "1 calls"
+  },
+  {
+    "vp": "iPad",
+    "name": "opens in FRONT phase",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "live camera playing (fake device)",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "_tsLive true only after live frame",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "3 live color sliders (Bri/Sat/Hue)",
+    "ok": true,
+    "detail": "3"
+  },
+  {
+    "vp": "iPad",
+    "name": "shutter >=44px",
+    "ok": true,
+    "detail": "105x105"
+  },
+  {
+    "vp": "iPad",
+    "name": "shutter enabled when live",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "flip visible on FRONT",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "step chip shows FRONT/1",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "FRONT captured → _frontPhoto set",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "advances to BACK phase",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "ghost source loaded for BACK",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "captured photo longest edge <=2000 (server 25MB cap safe)",
+    "ok": true,
+    "detail": "2000px"
+  },
+  {
+    "vp": "iPad",
+    "name": "flip HIDDEN on BACK (lens locked for pair)",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "ghost canvas rendered on BACK",
+    "ok": true,
+    "detail": "true"
+  },
+  {
+    "vp": "iPad",
+    "name": "BACK captured → _backPhoto set",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "view closes after pair",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "iPad",
+    "name": "camera stream stopped on close (no leak)",
+    "ok": true,
+    "detail": "null=true ended=true"
+  },
+  {
+    "vp": "iPad",
+    "name": "zero console/page errors",
+    "ok": true,
+    "detail": ""
+  }
+]
\ No newline at end of file
diff --git a/5x/verify-twoshot.mjs b/5x/verify-twoshot.mjs
new file mode 100644
index 0000000..7197523
--- /dev/null
+++ b/5x/verify-twoshot.mjs
@@ -0,0 +1,101 @@
+// Targeted /5x verification for #twoShotCam live-adjust ordered two-photo capture (TK-11947)
+// Fake camera so getUserMedia grants; drives the real global functions the buttons call.
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+const pw = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
+const fs = require('fs');
+
+const CHROME = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+  '/Applications/Chromium.app/Contents/MacOS/Chromium'].find(p => fs.existsSync(p));
+const APP = 'http://localhost:9890/';
+const CREDS = { username: 'admin', password: 'DW2024!' };
+
+const viewports = [
+  { name: 'iPhone', width: 390, height: 844, dsf: 3 },
+  { name: 'iPad',   width: 1024, height: 1366, dsf: 2 },
+];
+const results = [];
+const rec = (vp, name, ok, detail='') => { results.push({ vp, name, ok, detail }); console.log(`  [${ok?'PASS':'FAIL'}] ${vp} · ${name}${detail?' — '+detail:''}`); };
+
+for (const vp of viewports) {
+  console.log(`\n=== ${vp.name} (${vp.width}x${vp.height}) ===`);
+  const browser = await pw.chromium.launch({
+    executablePath: CHROME,
+    args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'],
+  });
+  const ctx = await browser.newContext({
+    httpCredentials: CREDS, viewport: { width: vp.width, height: vp.height },
+    deviceScaleFactor: vp.dsf, isMobile: true, hasTouch: true, permissions: ['camera'],
+  });
+  const errs = [];
+  const page = await ctx.newPage();
+  page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); });
+  page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
+
+  try {
+    await page.goto(APP, { waitUntil: 'networkidle', timeout: 20000 });
+    // instrument getUserMedia to count real acquisitions
+    await page.evaluate(() => {
+      window.__gum = 0; const orig = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+      navigator.mediaDevices.getUserMedia = (...a) => { window.__gum++; return orig(...a); };
+    });
+
+    rec(vp.name, 'twoShotCam element present', !!(await page.$('#twoShotCam')));
+    rec(vp.name, 'hidden until opened', (await page.$eval('#twoShotCam', el => el.hidden)) === true);
+
+    // ORDER GATE: request BACK with no FRONT → must downgrade to FRONT phase, never open a back capture
+    const gate = await page.evaluate(async () => { _frontPhoto=null; _backPhoto=null; await openTwoShotCam('back'); return { phase:(()=>_tsPhase)(), visible: !document.querySelector('#twoShotCam').hidden }; });
+    rec(vp.name, 'BACK request downgraded to FRONT (order enforced)', gate.phase === 'front' && gate.visible === true, `phase=${gate.phase}`);
+    await page.evaluate(() => closeTwoShotCam());
+    await page.waitForTimeout(200);
+
+    // DOUBLE-TAP RACE (Cody fix #1): two openTwoShotCam in the same tick → exactly ONE getUserMedia (guard holds, no orphan hot stream)
+    const gumBefore = await page.evaluate(() => window.__gum);
+    await page.evaluate(async () => { _frontPhoto=null; const a=openTwoShotCam('front'); const b=openTwoShotCam('front'); await Promise.all([a,b]); });
+    await page.waitForTimeout(500);
+    const gumAfter = await page.evaluate(() => window.__gum);
+    rec(vp.name, 'double-tap → single getUserMedia (no orphan stream)', (gumAfter - gumBefore) === 1, `${gumAfter-gumBefore} calls`);
+
+    // live camera playing + controls
+    rec(vp.name, 'opens in FRONT phase', (await page.evaluate(() => (()=>_tsPhase)())) === 'front');
+    rec(vp.name, 'live camera playing (fake device)', (await page.$eval('#tsVideo', v => v.readyState>=2 && v.videoWidth>0).catch(()=>false)) === true);
+    rec(vp.name, '_tsLive true only after live frame', (await page.evaluate(() => (()=>_tsLive)())) === true);
+    const sliders = await page.$$eval('#twoShotCam input[type=range]', els => els.length);
+    rec(vp.name, '3 live color sliders (Bri/Sat/Hue)', sliders >= 3, `${sliders}`);
+    const shut = await page.$eval('#tsShutterBtn', el => { const r=el.getBoundingClientRect(); return {w:r.width,h:r.height}; });
+    rec(vp.name, 'shutter >=44px', shut.w>=44 && shut.h>=44, `${Math.round(shut.w)}x${Math.round(shut.h)}`);
+    rec(vp.name, 'shutter enabled when live', (await page.$eval('#tsShutterBtn', el => !el.disabled)) === true);
+    rec(vp.name, 'flip visible on FRONT', (await page.$eval('#tsFlip', el => getComputedStyle(el).display !== 'none')) === true);
+    rec(vp.name, 'step chip shows FRONT/1', /front|1 of 2/i.test(await page.$eval('#twoShotCam', el => el.innerText)));
+
+    // capture FRONT → advance to BACK, ghost loaded, resolution capped
+    const front = await page.evaluate(async () => { await tsShutter(); return { phase:(()=>_tsPhase)(), front:!!(()=>_frontPhoto)(), ghostImg:!!(()=>_tsFrontImg)() }; });
+    rec(vp.name, 'FRONT captured → _frontPhoto set', front.front === true);
+    rec(vp.name, 'advances to BACK phase', front.phase === 'back');
+    rec(vp.name, 'ghost source loaded for BACK', front.ghostImg === true);
+    // resolution cap (Cody fix #2): decode the captured JPEG, longest edge must be <=2000
+    const cap = await page.evaluate(() => new Promise(res => { const im=new Image(); im.onload=()=>res(Math.max(im.naturalWidth,im.naturalHeight)); im.onerror=()=>res(-1); im.src=(()=>_frontPhoto)(); }));
+    rec(vp.name, 'captured photo longest edge <=2000 (server 25MB cap safe)', cap>0 && cap<=2000, `${cap}px`);
+    // flip hidden on BACK (Cody edge: lens locked for the pair)
+    rec(vp.name, 'flip HIDDEN on BACK (lens locked for pair)', (await page.$eval('#tsFlip', el => getComputedStyle(el).display === 'none')) === true);
+    // ghost canvas painted
+    const ghost = await page.evaluate(() => { const c=document.querySelector('#tsGhost'); if(!c) return null; try{ const d=c.getContext('2d').getImageData(0,0,Math.min(c.width,40),Math.min(c.height,40)).data; return d.some(v=>v!==0);}catch(e){return 'err:'+e.message;} });
+    rec(vp.name, 'ghost canvas rendered on BACK', ghost === true, String(ghost));
+
+    // capture BACK → closes + stream stopped (no leaked camera)
+    const back = await page.evaluate(async () => { await tsShutter(); const v=document.querySelector('#tsVideo'); const tr=v&&v.srcObject?v.srcObject.getTracks():[]; return { back:!!(()=>_backPhoto)(), streamNull:(()=>_tsStream)()===null, ended:tr.every(t=>t.readyState==='ended'), hidden:document.querySelector('#twoShotCam').hidden }; });
+    rec(vp.name, 'BACK captured → _backPhoto set', back.back === true);
+    rec(vp.name, 'view closes after pair', back.hidden === true);
+    rec(vp.name, 'camera stream stopped on close (no leak)', back.streamNull === true && back.ended === true, `null=${back.streamNull} ended=${back.ended}`);
+
+    rec(vp.name, 'zero console/page errors', errs.length === 0, errs.slice(0,3).join(' | '));
+  } catch (e) {
+    rec(vp.name, 'RUN', false, e.message);
+  } finally { await browser.close(); }
+}
+
+const fails = results.filter(r => !r.ok);
+console.log(`\n==== ${results.length - fails.length}/${results.length} PASS · ${fails.length} FAIL ====`);
+if (fails.length) fails.forEach(f => console.log(`  FAIL: ${f.vp} · ${f.name}${f.detail?' ('+f.detail+')':''}`));
+fs.writeFileSync('5x/twoshot-result.json', JSON.stringify(results, null, 2));
+process.exit(fails.length ? 1 : 0);
diff --git a/public/index.html b/public/index.html
index ca1dfc0..d4095a0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2065,7 +2065,7 @@ function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)re
 // ════════════════════ 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 _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}; }
@@ -2102,7 +2102,10 @@ function tsPreviewTick(){
 // 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;
+  let w=s.width||v.videoWidth, h=s.height||v.videoHeight; if(!w||!h) return null;
+  // Cap longest edge so a busy high-frequency wallcovering JPEG (worst case for compression) can't
+  // 413 the server's 25MB /api/create-item body cap when two full-res shots + extras are posted.
+  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; }
@@ -2138,6 +2141,7 @@ function tsSetStep(){
   $('#tsStepNum').textContent = back?'2':'1';
   $('#tsStepName').textContent = back?'BACK':'FRONT';
   $('#tsStepHint').textContent = back?'label / SKU':'pattern face';
+  $('#tsFlip').style.display = back?'none':'';   // lock the lens for the pair — Back must be shot on the SAME camera as Front (ghost alignment is meaningless across lenses)
   $('#tsGhost').hidden = !back;
   $('#tsHint').textContent = back
     ? 'Ghost of Photo 1 aligns the frame · shoot the LABEL side'
@@ -2177,22 +2181,30 @@ function tsInitPanel(){
 }
 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;
+  if(_tsAcquiring) return;                     // race guard: never two getUserMedia at once (double-tap / flip) → orphaned hot stream
+  _tsAcquiring=true; _tsLive=false;            // not live until a fresh frame is confirmed → shutter can't bake a frozen frame
+  const _sb=$('#tsShutterBtn'); if(_sb) _sb.disabled=true;
+  try{
+    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; if(_sb) _sb.disabled=false;                          // only NOW is the shutter armed
+  } finally { _tsAcquiring=false; }
 }
 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){
+  if(_tsOpening) return;                          // ignore fast double-tap → would race a 2nd getUserMedia (hot orphan stream)
+  _tsOpening=true;
+  try{
   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
@@ -2214,6 +2226,7 @@ async function openTwoShotCam(startPhase){
   if(_tsPvTimer) clearInterval(_tsPvTimer); _tsPvTimer=setInterval(tsPreviewTick,80);
   requestAnimationFrame(drawTsGhost);
   tsRequestWake();
+  } finally { _tsOpening=false; }
 }
 function closeTwoShotCam(){
   if(_tsPvTimer){ clearInterval(_tsPvTimer); _tsPvTimer=null; }
@@ -2253,8 +2266,11 @@ async function tsShutter(){
 $('#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'); } });
+$('#tsFlip').addEventListener('click',async()=>{ const _prev=camFacing; camFacing=(camFacing==='user')?'environment':'user'; setLS('camFacing',camFacing); applyCamCapture();
+  try{ await startTsStream(); }
+  catch(e){ // switch failed → revert to the known-good lens and re-acquire so the feed never stays frozen (shutter stays disabled until live)
+    camFacing=_prev; setLS('camFacing',_prev); applyCamCapture();
+    try{ await startTsStream(); }catch(_){ 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);

← 480017e photo-capture: live pre-shutter colour + ordered two-photo g  ·  back to Dw Photo Capture  ·  Cody re-gate #2 hardening: conditional shutter-arm + flip do 635c96e →