← 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
A 5x/twoshot-result.jsonA 5x/verify-twoshot.mjsM public/index.html
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 →