← back to Dw Photo Capture
public/js/acquire-camera.js
141 lines
/*
* 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];
// TK-12343: "ideal" constraints are a hint, not a guarantee — surface what the browser ACTUALLY
// negotiated so a caller's resolution fallback chain (and any regression in it) is verifiable.
try { if (curTrack && curTrack.getSettings) { var _s = curTrack.getSettings(); if (typeof console !== 'undefined' && console.debug) console.debug('[AcquireCamera] track settings', _s); } } catch (e) {}
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);