← back to Dw Photo Capture
Extract shared acquireCamera() helper, adopt across 5 unbounded surfaces (TK-12127)
a1b8ee35db99a977fbbb0156ac6f95ba9426558c · 2026-09-24 09:39:11 -0700 · Steve Abrams
New public/js/acquire-camera.js ports the proven TK-12124/TK-12128 semantics
(bounded 8s getUserMedia race, a per-acquirer GENERATION counter, late-arrival
hardware release, bounded 3s play() wait, optional ended/mute hooks scoped by
generation) into one reusable, script-tag-included module. The generation
counter alone is what prevents "a re-tap during a hang orphans a live stream"
— no separate re-entrancy boolean latch is needed, since a superseded older
acquire is detected and its hardware released the moment it resolves late.
The helper owns HOW a stream is acquired; every adopted surface keeps its own
existing constraint preferences (resolution/facingMode) unchanged.
ADOPTED (5 surfaces, all previously unbounded):
- cam.html startCamera() — "Starting…" could hang forever with no honest
retry path; a re-tap (nothing disabled #gBtn) could orphan a stream.
Fixed message: "Camera took too long to respond" + the existing "Try
Again" tap target (the gate's only affordance — no new UI needed). Also
now gets the bounded play() wait cam.html never had at all.
- batch.html startCameraInto() — same orphan-on-retap risk across its two
call sites; boolean return contract (true/false) preserved unchanged.
- index.html scan (startScanStream), white-balance (openWB), measure
(openMeasure) — three more unbounded getUserMedia awaits outside the
two-shot flow, none of which had ANY timeout at all.
DEFERRED: index.html's two-shot flow (startTsStream) is left AS-IS, not
migrated onto the shared helper. It already has this exact proven logic
inline (TK-12124/12128), the diff to swap it over would touch the most
heavily-tested code path in the app for zero functional gain, and it isn't
part of "surfaces that currently have unbounded acquires" — it was already
fixed. Its 44 existing assertions are confirmed unaffected by this change.
PREMISE NOTES:
- cam.html's 'ended' handler and batch.html's onDead callback are ported
verbatim into the onDead hook — cam.html's bounded-play gap was a bonus
fix, not previously reported.
- Line numbers in the original report (cam.html:278-290, batch.html:297-305)
had drifted slightly from prior edits but pointed at the correct functions.
Negative-tested: a 6-case core suite against a blank fixture proves the
module itself (hang→bounded settle, late-arrival tracks STOPPED not just
dereferenced, a newer generation supersedes a hung older one, bounded
play()-timeout, mute/ended hooks correctly scoped by generation, and an
unaffected fast success path) — 20/20 pass. A second suite exercises all 5
adopted call sites directly (bounded settle, sane UI state on timeout, a
clean retry afterward) — 20/20 pass. Confirmed genuine pre-fix hangs via
git-stash on all 5 surfaces with a targeted 4s-vs-11s settle-window probe
(each hung indefinitely pre-fix, each settled within its bound post-fix).
The pre-existing 44 TK-12124/12125/12128 assertions remain green throughout.
One commit for the whole refactor rather than one per surface: the helper
module has no standalone meaning without a consumer, all 5 adoptions share
one bug class and one fix pattern, and splitting them would fragment a
single coherent change for no review benefit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
Files touched
M public/batch.htmlM public/cam.htmlM public/index.htmlA public/js/acquire-camera.js
Diff
commit a1b8ee35db99a977fbbb0156ac6f95ba9426558c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 09:39:11 2026 -0700
Extract shared acquireCamera() helper, adopt across 5 unbounded surfaces (TK-12127)
New public/js/acquire-camera.js ports the proven TK-12124/TK-12128 semantics
(bounded 8s getUserMedia race, a per-acquirer GENERATION counter, late-arrival
hardware release, bounded 3s play() wait, optional ended/mute hooks scoped by
generation) into one reusable, script-tag-included module. The generation
counter alone is what prevents "a re-tap during a hang orphans a live stream"
— no separate re-entrancy boolean latch is needed, since a superseded older
acquire is detected and its hardware released the moment it resolves late.
The helper owns HOW a stream is acquired; every adopted surface keeps its own
existing constraint preferences (resolution/facingMode) unchanged.
ADOPTED (5 surfaces, all previously unbounded):
- cam.html startCamera() — "Starting…" could hang forever with no honest
retry path; a re-tap (nothing disabled #gBtn) could orphan a stream.
Fixed message: "Camera took too long to respond" + the existing "Try
Again" tap target (the gate's only affordance — no new UI needed). Also
now gets the bounded play() wait cam.html never had at all.
- batch.html startCameraInto() — same orphan-on-retap risk across its two
call sites; boolean return contract (true/false) preserved unchanged.
- index.html scan (startScanStream), white-balance (openWB), measure
(openMeasure) — three more unbounded getUserMedia awaits outside the
two-shot flow, none of which had ANY timeout at all.
DEFERRED: index.html's two-shot flow (startTsStream) is left AS-IS, not
migrated onto the shared helper. It already has this exact proven logic
inline (TK-12124/12128), the diff to swap it over would touch the most
heavily-tested code path in the app for zero functional gain, and it isn't
part of "surfaces that currently have unbounded acquires" — it was already
fixed. Its 44 existing assertions are confirmed unaffected by this change.
PREMISE NOTES:
- cam.html's 'ended' handler and batch.html's onDead callback are ported
verbatim into the onDead hook — cam.html's bounded-play gap was a bonus
fix, not previously reported.
- Line numbers in the original report (cam.html:278-290, batch.html:297-305)
had drifted slightly from prior edits but pointed at the correct functions.
Negative-tested: a 6-case core suite against a blank fixture proves the
module itself (hang→bounded settle, late-arrival tracks STOPPED not just
dereferenced, a newer generation supersedes a hung older one, bounded
play()-timeout, mute/ended hooks correctly scoped by generation, and an
unaffected fast success path) — 20/20 pass. A second suite exercises all 5
adopted call sites directly (bounded settle, sane UI state on timeout, a
clean retry afterward) — 20/20 pass. Confirmed genuine pre-fix hangs via
git-stash on all 5 surfaces with a targeted 4s-vs-11s settle-window probe
(each hung indefinitely pre-fix, each settled within its bound post-fix).
The pre-existing 44 TK-12124/12125/12128 assertions remain green throughout.
One commit for the whole refactor rather than one per surface: the helper
module has no standalone meaning without a consumer, all 5 adoptions share
one bug class and one fix pattern, and splitting them would fragment a
single coherent change for no review benefit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
public/batch.html | 25 +++++---
public/cam.html | 64 +++++++++++++--------
public/index.html | 36 +++++++-----
public/js/acquire-camera.js | 137 ++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 216 insertions(+), 46 deletions(-)
diff --git a/public/batch.html b/public/batch.html
index 11dd85f..a1cb348 100644
--- a/public/batch.html
+++ b/public/batch.html
@@ -25,6 +25,7 @@
})();
</script>
<script src="/js/capture-pipeline.js"></script>
+<script src="/js/acquire-camera.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">
@@ -294,6 +295,14 @@ function saveJSON(k,o){ try{ localStorage.setItem(k, JSON.stringify(o)); }catch(
function show(view){ for(const id of ['vSetup','vCalib','vShoot']){ const el=$('#'+id); el.hidden = (id!==view); } }
// ════════════════════════ CAMERA (hardened for iOS Safari) ════════════════════════
+// TK-12127: unbounded getUserMedia() here had no bound and no generation guard — can't brick a latch
+// flag (there isn't one), but a re-tap of "Turn on camera" mid-hang could fire a second getUserMedia
+// and orphan whichever stream resolved first. Ported the two-shot flow's proven acquireCamera helper.
+const camAcquirer=AcquireCamera.createCameraAcquirer();
+function onCamTrackDead(){ // same body as the original inline 'ended' handler
+ camLive=false; setCamDot();
+ if(!$('#vShoot').hidden){ toast('⚠ Camera stopped — tap “Turn on camera”'); $('#gate').hidden=false; setState('retake','Camera stopped — tap to restart'); }
+}
async function startCameraInto(videoEl){
if(stream){ videoEl.srcObject=stream; return true; }
const attempts=[
@@ -301,14 +310,14 @@ async function startCameraInto(videoEl){
{ video:{ facingMode:'environment', width:{ideal:4096}, height:{ideal:3072} }, audio:false },
{ video:true, audio:false }
];
- let err=null;
- for(const c of attempts){ try{ stream=await navigator.mediaDevices.getUserMedia(c); break; }catch(e){ err=e; } }
- if(!stream){ toast('Camera blocked ('+((err&&err.name)||'error')+') — needs HTTPS on iOS'); return false; }
- track=stream.getVideoTracks()[0];
- videoEl.srcObject=stream;
- videoEl.onloadedmetadata=()=>{ videoEl.play().catch(()=>{}); };
- track.addEventListener('ended',()=>{ camLive=false; setCamDot();
- if(!$('#vShoot').hidden){ toast('⚠ Camera stopped — tap “Turn on camera”'); $('#gate').hidden=false; setState('retake','Camera stopped — tap to restart'); } });
+ let res;
+ try{
+ res=await camAcquirer.acquire(attempts,{videoEl,onDead:onCamTrackDead});
+ }catch(e){
+ toast(e&&e.message==='camera-timeout' ? '⚠ Camera took too long to respond — tap to try again' : 'Camera blocked ('+((e&&e.name)||'error')+') — needs HTTPS on iOS');
+ return false;
+ }
+ stream=res.stream; track=res.track;
camLive=true; setCamDot();
requestWake();
return true;
diff --git a/public/cam.html b/public/cam.html
index 7adb70d..199d9f2 100644
--- a/public/cam.html
+++ b/public/cam.html
@@ -25,6 +25,7 @@
})();
</script>
<script src="/js/capture-pipeline.js"></script>
+<script src="/js/acquire-camera.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">
@@ -277,6 +278,17 @@ function previewTick(){
}
// ── getUserMedia: rear camera, hardened for iOS Safari ──
+// TK-12127: unbounded getUserMedia() left "Starting…" showing forever on a WebKit hang, and a re-tap
+// (nothing here disabled #gBtn) could fire a SECOND getUserMedia while the first was still pending —
+// an orphaned hot stream if it ever resolved late. Ported the two-shot flow's proven acquireCamera
+// helper (bounded 8s acquire race + generation-guarded late-release + bounded play()).
+const camAcquirer = AcquireCamera.createCameraAcquirer();
+function onCamTrackDead(){ // moved out of startCamera() only so it reads as one thing — same body as before
+ 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';
+}
async function startCamera(){
if(stream){ return; }
$('#gBtn').textContent='Starting…';
@@ -285,36 +297,38 @@ async function startCamera(){
{ video:{ facingMode:'environment', width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
{ video:true, audio:false }
];
- let err=null;
- for(const c of attempts){
- try{ stream = await navigator.mediaDevices.getUserMedia(c); break; }
- catch(e){ err=e; }
- }
- if(!stream){
+ const v=$('#v');
+ let res;
+ try{
+ res = await camAcquirer.acquire(attempts, { videoEl:v, onDead:onCamTrackDead });
+ }catch(e){
+ if(e && e.message==='camera-timeout'){
+ // this IS the honest retry message — #gBtn is the only affordance this gate has (no separate
+ // cancel control), so re-using its existing "Try Again" tap target is the correct fix, not a new UI.
+ $('#gTitle').textContent='Camera took too long to respond';
+ $('#gMsg').textContent='The camera didn’t start in time. Tap below to try again.';
+ $('#gBtn').textContent='Try Again';
+ return;
+ }
$('#gTitle').textContent='Camera blocked';
- $('#gMsg').textContent=(err&&err.name==='NotAllowedError')
+ $('#gMsg').textContent=(e&&e.name==='NotAllowedError')
? 'Camera permission was denied. In Safari: aA menu → Website Settings → Camera → Allow, then reload.'
- : 'Could not start the camera ('+((err&&err.name)||'error')+'). This page needs HTTPS (or localhost) for the camera on iOS Safari.';
+ : 'Could not start the camera ('+((e&&e.name)||'error')+'). This page needs HTTPS (or localhost) for the camera on iOS Safari.';
$('#gBtn').textContent='Try Again';
return;
}
- const v=$('#v'); v.srcObject=stream;
- videoTrack = stream.getVideoTracks()[0];
- 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'; });
+ stream = res.stream;
+ videoTrack = res.track;
+ // Start preview tick + hardware layer — no longer gated on onloadedmetadata: the helper already
+ // awaited a bounded play() above, so by the time acquire() resolves the video is confirmed playing.
+ clearInterval(_previewTicker);
+ _previewTicker = setInterval(previewTick, 80);
+ const caps = CapturePipeline.Hardware.probe(videoTrack);
+ if(Object.keys(caps).length>0){
+ 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));
+ }
$('#gate').hidden=true;
camLive=true;
reportLive();
diff --git a/public/index.html b/public/index.html
index 6a186bc..82e78e1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -765,6 +765,8 @@
</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>
+<!-- shared hardened camera-acquisition helper (bounded timeout + generation guard + bounded play), TK-12127 -->
+<script src="/js/acquire-camera.js"></script>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
@@ -1526,10 +1528,13 @@ function applyCamCapture(){ // point all <input capture> pickers at the chosen
const v=(camFacing==='user')?'user':'environment';
document.querySelectorAll('input[type=file][accept^="image"]').forEach(i=>{ if(i.hasAttribute('capture')) i.setAttribute('capture',v); });
}
+// TK-12127: bounded getUserMedia + generation-guarded late-release, ported from the two-shot flow's
+// proven acquireCamera helper — this surface had an unbounded await with no orphan protection on a re-tap.
+const scanAcquirer=AcquireCamera.createCameraAcquirer();
async function startScanStream(){ // (re)acquire the live stream on the current facing
- if(scanStream){ scanStream.getTracks().forEach(t=>t.stop()); scanStream=null; }
- scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:camFacing},width:{ideal:1280}}});
- const v=$('#scanvideo'); v.srcObject=scanStream; try{await v.play();}catch(e){}
+ const v=$('#scanvideo');
+ const res=await scanAcquirer.acquire([{video:{facingMode:{ideal:camFacing},width:{ideal:1280}}}],{videoEl:v});
+ scanStream=res.stream;
}
// ── lock-in "yoo-hoo" cue (Web Audio, zero asset; must be unlocked by a user gesture on iOS) ──
let _audio=null;
@@ -1620,7 +1625,7 @@ async function flipCam(){ // toggle back<->front, persist, re-acquire live + r
if(!$('#scanlive').hidden){ try{ await startScanStream(); }catch(e){ toast('Could not switch camera'); } }
else toast(camFacing==='user'?'📷 Front camera':'📷 Back camera');
}
-function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null;} _frontHits=0; resetScanFields(); $('#scanlive').hidden=true; $('#scanlive').classList.remove('locked'); document.body.style.overflow=''; }
+function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; scanAcquirer.stop(); scanStream=null; _frontHits=0; resetScanFields(); $('#scanlive').hidden=true; $('#scanlive').classList.remove('locked'); document.body.style.overflow=''; }
// Live field readout: fill SKU#/Model#/Name/Color and flip each chip GREEN the moment it's detected.
// Sticky — once a field is read it stays green (a later blank frame won't wipe a good read).
let _sfSeen={};
@@ -2109,15 +2114,18 @@ function grayWorldFromVideo(v){ if(!v||!v.videoWidth)return null; const c=docume
const ctx=c.getContext('2d'); ctx.drawImage(v,0,0,c.width,c.height); const d=ctx.getImageData(0,0,c.width,c.height).data;
let r=0,g=0,b=0,n=0; for(let i=0;i<d.length;i+=4){ r+=d[i];g+=d[i+1];b+=d[i+2];n++; } return gainsFromRef({r:r/n,g:g/n,b:b/n}); }
let _wbStream=null;
+const wbAcquirer=AcquireCamera.createCameraAcquirer(); // TK-12127: bounded timeout + generation-guarded late-release
async function openWB(){
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){ toast('Live camera needs Safari on iPhone/iPad.'); return; }
- try{ _wbStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}}}); }
+ const v=$('#wbVideo'); v.setAttribute('playsinline',''); v.muted=true;
+ let res;
+ try{ res=await wbAcquirer.acquire([{video:{facingMode:{ideal:'environment'}}}],{videoEl:v}); }
catch(e){ toast('Camera blocked — allow camera access, or open in Safari.'); return; }
- const v=$('#wbVideo'); v.srcObject=_wbStream; v.setAttribute('playsinline',''); v.muted=true;
- $('#wbModal').hidden=false; document.body.style.overflow='hidden'; try{ await v.play(); }catch(e){}
+ _wbStream=res.stream;
+ $('#wbModal').hidden=false; document.body.style.overflow='hidden';
$('#wbClear').hidden=!wbGet();
$('#wbReadout').innerHTML = wbGet()? '✓ White balance is SET — tap a white/grey area to re-set, or ↺ Clear.' : 'Tap a WHITE or GREY area (or hold a white card) to set the white point.'; }
-function closeWB(){ if(_wbStream){ _wbStream.getTracks().forEach(t=>t.stop()); _wbStream=null; } $('#wbModal').hidden=true; document.body.style.overflow=''; }
+function closeWB(){ wbAcquirer.stop(); _wbStream=null; $('#wbModal').hidden=true; document.body.style.overflow=''; }
function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)return null; const rect=v.getBoundingClientRect();
const scale=Math.max(rect.width/v.videoWidth, rect.height/v.videoHeight); // object-fit:cover
const offX=(rect.width-v.videoWidth*scale)/2, offY=(rect.height-v.videoHeight*scale)/2;
@@ -2729,17 +2737,19 @@ function mMakeBox(el){ let mode=null,sx,sy,ox,oy,ow,oh; const rz=el.querySelecto
rz.addEventListener('pointerdown',e=>start(e,'resize'));
el.addEventListener('pointermove',move); rz.addEventListener('pointermove',move);
el.addEventListener('pointerup',end); rz.addEventListener('pointerup',end); }
+const measureAcquirer=AcquireCamera.createCameraAcquirer(); // TK-12127: bounded timeout + generation-guarded late-release
async function openMeasure(){
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
toast('📷 Live camera needs Safari on iPhone — Chrome/other apps block it. Open in Safari.'); return; }
- try{ _mStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}}}); }
+ const v=$('#mVideo'); v.setAttribute('playsinline',''); v.muted=true;
+ let res;
+ try{ res=await measureAcquirer.acquire([{video:{facingMode:{ideal:'environment'}}}],{videoEl:v}); }
catch(e){ toast('Camera blocked — allow camera access, or open in Safari.'); return; }
+ _mStream=res.stream;
// LOCK ZOOM to minimum so the focal length / field-of-view stays constant (scale depends on it)
- try{ const tr=_mStream.getVideoTracks()[0], caps=tr.getCapabilities?tr.getCapabilities():{};
+ try{ const tr=res.track, caps=tr.getCapabilities?tr.getCapabilities():{};
if(caps.zoom) await tr.applyConstraints({advanced:[{zoom:caps.zoom.min||1}]}); }catch(e){}
- const v=$('#mVideo'); v.srcObject=_mStream; v.setAttribute('playsinline',''); v.muted=true;
$('#measureModal').hidden=false; document.body.style.overflow='hidden';
- try{ await v.play(); }catch(e){} // iOS Safari: autoplay attr isn't always enough — explicitly play inline
Object.assign($('#mCard').style,{left:'26px',top:'96px',width:'135px',height:'85px'});
Object.assign($('#mSample').style,{left:'56px',top:'200px',width:'210px',height:'270px'});
const cal=calGet();
@@ -2749,7 +2759,7 @@ async function openMeasure(){
else { if(cal&&cal.ppi) toast('Screen changed — quick one-time set-scale'); mSetMode('calibrate'); } };
// defer to the next frame so the just-unhidden #mCard has a real (non-zero) offsetWidth before ppi is read
requestAnimationFrame(()=>requestAnimationFrame(applyMode)); }
-function closeMeasure(){ if(_mStream){ _mStream.getTracks().forEach(t=>t.stop()); _mStream=null; } $('#measureModal').hidden=true; document.body.style.overflow=''; }
+function closeMeasure(){ measureAcquirer.stop(); _mStream=null; $('#measureModal').hidden=true; document.body.style.overflow=''; }
// White Balance wiring: tap the video to set the white point; auto = gray-world; clear = remove.
$('#addWB').addEventListener('click',openWB);
$('#wbClose').addEventListener('click',closeWB);
diff --git a/public/js/acquire-camera.js b/public/js/acquire-camera.js
new file mode 100644
index 0000000..a31dcb9
--- /dev/null
+++ b/public/js/acquire-camera.js
@@ -0,0 +1,137 @@
+/*
+ * acquire-camera.js — shared, hardened camera-acquisition helper (TK-12127).
+ *
+ * Ports the PROVEN semantics from dw-photo-capture's two-shot flow (TK-12124/TK-12128 — index.html
+ * startTsStream()) into one reusable module so every camera surface in this app gets the same
+ * robustness instead of each hand-rolling its own unbounded getUserMedia() await:
+ *
+ * 1. A bounded getUserMedia() race (default 8s) — WebKit/Safari can leave a permission prompt's
+ * promise pending forever (denied-but-not-rejected, backgrounded, MDM restriction); without a
+ * bound, that hangs the caller's acquire forever.
+ * 2. A per-acquirer GENERATION counter — a late-arriving stream (one that resolves AFTER a newer
+ * acquire() call already started, e.g. a re-tap during a hang) is detected and its hardware
+ * released immediately (tracks stopped), never silently adopted as an orphaned hot camera. This
+ * generation counter alone is what prevents the "retap orphans a live stream" class Cody found
+ * in cam.html/batch.html — no separate boolean re-entrancy latch is needed.
+ * 3. A bounded v.play() wait (default 3s, only when a <video> element is supplied) — a track that
+ * CONNECTS but never produces a frame can leave play() pending forever on WebKit too.
+ * 4. Optional 'ended'/'mute' hooks, scoped to the SAME generation + track identity, so a stale
+ * old track's late event can never kill a newer, genuinely-live stream.
+ *
+ * The helper owns HOW a stream is acquired (timeouts, generation bookkeeping, cleanup). Callers own
+ * WHAT to ask for (constraints — resolution, facingMode) and what UI to update on success/failure.
+ *
+ * Usage:
+ * const acquirer = AcquireCamera.createCameraAcquirer();
+ * const { stream, track } = await acquirer.acquire(
+ * [{ video:{facingMode:{ideal:'environment'}} }], // constraint attempts, tried in order
+ * { videoEl: myVideoEl, onDead: () => { ... } } // both optional
+ * );
+ * // ... later, on close:
+ * acquirer.stop();
+ *
+ * On failure acquire() rejects with an Error whose .message is 'camera-timeout' (the getUserMedia
+ * race OR the play() race timed out) or the underlying DOMException (permission denied, no device,
+ * etc — same shape callers already handle today via e.name).
+ */
+(function (global) {
+ 'use strict';
+
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 8000; // see TK-12124: comfortably above a real permission-prompt tap
+ var DEFAULT_PLAY_TIMEOUT_MS = 3000; // see TK-12124/12128 hole 1: play() should be near-instant once a stream exists
+
+ function stopStreamTracks(s) {
+ try { if (s) s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) { /* ignore */ }
+ }
+
+ function createCameraAcquirer(opts) {
+ opts = opts || {};
+ var acquireTimeoutMs = opts.acquireTimeoutMs || DEFAULT_ACQUIRE_TIMEOUT_MS;
+ var playTimeoutMs = (opts.playTimeoutMs != null) ? opts.playTimeoutMs : DEFAULT_PLAY_TIMEOUT_MS;
+ var gen = 0, curStream = null, curTrack = null;
+
+ // constraints: array of getUserMedia constraint objects, tried in order (first success wins) —
+ // pass a single-element array for a surface that never had a multi-attempt fallback chain.
+ // videoOpts: { videoEl, onDead } — both optional.
+ function acquire(constraints, videoOpts) {
+ videoOpts = videoOpts || {};
+ var videoEl = videoOpts.videoEl, onDead = videoOpts.onDead;
+ var myGen = ++gen;
+
+ if (curStream) { stopStreamTracks(curStream); curStream = null; curTrack = null; }
+
+ var err = null, timedOut = false;
+
+ var attempt = (function () {
+ var i = 0;
+ function next() {
+ if (timedOut || myGen !== gen) return Promise.resolve(null); // a newer acquire (or our own timeout) already superseded this one
+ if (i >= constraints.length) return Promise.resolve(null);
+ var c = constraints[i++];
+ return navigator.mediaDevices.getUserMedia(c).then(function (s) {
+ if (timedOut || myGen !== gen) { stopStreamTracks(s); return null; } // late arrival — release the hardware, never adopt
+ return s;
+ }, function (e) { err = e; return next(); });
+ }
+ return next();
+ })();
+
+ var timeout = new Promise(function (res) {
+ setTimeout(function () { timedOut = true; res('timeout'); }, acquireTimeoutMs);
+ });
+
+ return Promise.race([attempt, timeout]).then(function (winner) {
+ if (winner === 'timeout') throw new Error('camera-timeout');
+ if (!winner) throw (err || new Error('no-camera'));
+
+ curStream = winner;
+ curTrack = curStream.getVideoTracks()[0];
+
+ var afterPlay = Promise.resolve();
+ if (videoEl) {
+ videoEl.srcObject = curStream;
+ var playTimedOut = false;
+ afterPlay = Promise.race([
+ videoEl.play().catch(function () {}),
+ new Promise(function (r) { setTimeout(function () { playTimedOut = true; r(); }, playTimeoutMs); }),
+ ]).then(function () {
+ if (playTimedOut) {
+ stopStreamTracks(curStream); curStream = null; curTrack = null; videoEl.srcObject = null;
+ throw new Error('camera-timeout');
+ }
+ });
+ }
+
+ return afterPlay.then(function () {
+ if (curTrack && onDead) {
+ var trackGen = myGen, trackRef = curTrack;
+ var dead = function () {
+ if (trackGen !== gen || curTrack !== trackRef) return; // stale — a newer generation already took over
+ if (curStream) { stopStreamTracks(curStream); curStream = null; }
+ if (videoEl) videoEl.srcObject = null;
+ onDead();
+ };
+ curTrack.addEventListener('ended', dead);
+ curTrack.addEventListener('mute', dead);
+ }
+ return { stream: curStream, track: curTrack };
+ });
+ });
+ }
+
+ function stop() {
+ if (curStream) { stopStreamTracks(curStream); curStream = null; curTrack = null; }
+ gen++; // invalidate any still-in-flight acquire from this instance
+ }
+ function getStream() { return curStream; }
+ function getTrack() { return curTrack; }
+
+ return { acquire: acquire, stop: stop, getStream: getStream, getTrack: getTrack };
+ }
+
+ global.AcquireCamera = {
+ createCameraAcquirer: createCameraAcquirer,
+ DEFAULT_ACQUIRE_TIMEOUT_MS: DEFAULT_ACQUIRE_TIMEOUT_MS,
+ DEFAULT_PLAY_TIMEOUT_MS: DEFAULT_PLAY_TIMEOUT_MS,
+ };
+})(typeof window !== 'undefined' ? window : globalThis);
← 0e42baf Wire ended + mute listeners on _tsTrack, scoped by generatio
·
back to Dw Photo Capture
·
TK-12126: add camera commit:true round-trip proof script (mo d9c9612 →