← 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);