@sekyro/web 0.1.0-rc.1 → 0.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { C as CameraErrorKind } from '../capture-session-v3-CAoWM8iT.cjs';
2
- export { a as CLIP_MS, b as CaptureArtifacts, c as CaptureErrorKind, d as CaptureSessionEvents, e as CaptureSessionV3, f as CaptureSessionV3Events, g as CaptureSessionV3Options, h as CaptureState, i as ClipError, j as ClipErrorKind, k as ClipRecorder, l as ClipResult, D as DEFAULT_CLIP_MIME, m as DEVICE_PROFILES, n as DeviceClass, o as DeviceProbe, p as DeviceProfile, q as DeviceProfiler, E as EscalationClipArtifact, r as EscalationHook, F as FaceGateV3, G as GateChecks, K as KEYFRAME_CANDIDATES, s as KEYFRAME_WINDOW_MS, M as MAX_CLIP_BYTES, t as MIME_CANDIDATES, S as STOP_GRACE_MS, T as TIMESLICE_MS, V as V3ChecksPayload, u as V3GateFactor, v as V3GateQuality, w as V3GateThresholds, x as V3GateTick, y as V3GateTickInput, z as V3TelemetryEvent, A as V3_GATE_DEFAULTS, B as VIDEO_BPS, H as buildLivenessV2FormData, I as cameraPreflight, J as classifyDevice, L as extForMime, N as isClipTooLarge, O as isMediaRecorderSupported, P as mapGetUserMediaError, Q as pickMimeType, R as probeDevice } from '../capture-session-v3-CAoWM8iT.cjs';
1
+ import { C as CameraErrorKind } from '../capture-session-v3-COIO_F2w.cjs';
2
+ export { a as CLIP_MS, b as CaptureArtifacts, c as CaptureErrorKind, d as CaptureSessionEvents, e as CaptureSessionV3, f as CaptureSessionV3Events, g as CaptureSessionV3Options, h as CaptureState, i as ClipError, j as ClipErrorKind, k as ClipRecorder, l as ClipResult, D as DEFAULT_CLIP_MIME, m as DEVICE_PROFILES, n as DeviceClass, o as DeviceProbe, p as DeviceProfile, q as DeviceProfiler, E as EscalationClipArtifact, r as EscalationHook, F as FaceGateV3, G as GateChecks, K as KEYFRAME_CANDIDATES, s as KEYFRAME_WINDOW_MS, M as MAX_CLIP_BYTES, t as MIME_CANDIDATES, S as STOP_GRACE_MS, T as TIMESLICE_MS, V as V3ChecksPayload, u as V3GateFactor, v as V3GateQuality, w as V3GateThresholds, x as V3GateTick, y as V3GateTickInput, z as V3TelemetryEvent, A as V3_GATE_DEFAULTS, B as VIDEO_BPS, H as buildLivenessV2FormData, I as cameraPreflight, J as classifyDevice, L as extForMime, N as isClipTooLarge, O as isMediaRecorderSupported, P as mapGetUserMediaError, Q as pickMimeType, R as probeDevice } from '../capture-session-v3-COIO_F2w.cjs';
3
3
  export { C as CENTERED_FACTOR, a as CIRCLE_RADIUS_FACTOR, I as IN_CIRCLE_FACTOR, b as buildCaptureConstraints, d as detectIsMobile } from '../capture-constraints-BujkZcfb.cjs';
4
4
  export { C as CreateDetectorOptions, D as DEFAULT_TIER_ORDER, a as DEFAULT_V3_ASSET_BASE_URL, b as Detector, c as DetectorLadderError, F as FaceObservation, d as createDetector, i as intrinsicSize, t as toStreamPixels } from '../detect-worker-CKXHlXx9.cjs';
5
5
 
@@ -1,5 +1,5 @@
1
- import { C as CameraErrorKind } from '../capture-session-v3-BJ8ciaWl.js';
2
- export { a as CLIP_MS, b as CaptureArtifacts, c as CaptureErrorKind, d as CaptureSessionEvents, e as CaptureSessionV3, f as CaptureSessionV3Events, g as CaptureSessionV3Options, h as CaptureState, i as ClipError, j as ClipErrorKind, k as ClipRecorder, l as ClipResult, D as DEFAULT_CLIP_MIME, m as DEVICE_PROFILES, n as DeviceClass, o as DeviceProbe, p as DeviceProfile, q as DeviceProfiler, E as EscalationClipArtifact, r as EscalationHook, F as FaceGateV3, G as GateChecks, K as KEYFRAME_CANDIDATES, s as KEYFRAME_WINDOW_MS, M as MAX_CLIP_BYTES, t as MIME_CANDIDATES, S as STOP_GRACE_MS, T as TIMESLICE_MS, V as V3ChecksPayload, u as V3GateFactor, v as V3GateQuality, w as V3GateThresholds, x as V3GateTick, y as V3GateTickInput, z as V3TelemetryEvent, A as V3_GATE_DEFAULTS, B as VIDEO_BPS, H as buildLivenessV2FormData, I as cameraPreflight, J as classifyDevice, L as extForMime, N as isClipTooLarge, O as isMediaRecorderSupported, P as mapGetUserMediaError, Q as pickMimeType, R as probeDevice } from '../capture-session-v3-BJ8ciaWl.js';
1
+ import { C as CameraErrorKind } from '../capture-session-v3-CvIfcwST.js';
2
+ export { a as CLIP_MS, b as CaptureArtifacts, c as CaptureErrorKind, d as CaptureSessionEvents, e as CaptureSessionV3, f as CaptureSessionV3Events, g as CaptureSessionV3Options, h as CaptureState, i as ClipError, j as ClipErrorKind, k as ClipRecorder, l as ClipResult, D as DEFAULT_CLIP_MIME, m as DEVICE_PROFILES, n as DeviceClass, o as DeviceProbe, p as DeviceProfile, q as DeviceProfiler, E as EscalationClipArtifact, r as EscalationHook, F as FaceGateV3, G as GateChecks, K as KEYFRAME_CANDIDATES, s as KEYFRAME_WINDOW_MS, M as MAX_CLIP_BYTES, t as MIME_CANDIDATES, S as STOP_GRACE_MS, T as TIMESLICE_MS, V as V3ChecksPayload, u as V3GateFactor, v as V3GateQuality, w as V3GateThresholds, x as V3GateTick, y as V3GateTickInput, z as V3TelemetryEvent, A as V3_GATE_DEFAULTS, B as VIDEO_BPS, H as buildLivenessV2FormData, I as cameraPreflight, J as classifyDevice, L as extForMime, N as isClipTooLarge, O as isMediaRecorderSupported, P as mapGetUserMediaError, Q as pickMimeType, R as probeDevice } from '../capture-session-v3-CvIfcwST.js';
3
3
  export { C as CENTERED_FACTOR, a as CIRCLE_RADIUS_FACTOR, I as IN_CIRCLE_FACTOR, b as buildCaptureConstraints, d as detectIsMobile } from '../capture-constraints-BujkZcfb.js';
4
4
  export { C as CreateDetectorOptions, D as DEFAULT_TIER_ORDER, a as DEFAULT_V3_ASSET_BASE_URL, b as Detector, c as DetectorLadderError, F as FaceObservation, d as createDetector, i as intrinsicSize, t as toStreamPixels } from '../detect-worker-CKXHlXx9.js';
5
5
 
@@ -31,7 +31,7 @@ import {
31
31
  mapGetUserMediaError,
32
32
  pickMimeType,
33
33
  probeDevice
34
- } from "../chunk-LOTAJDRE.js";
34
+ } from "../chunk-7TC6TOZ4.js";
35
35
  import {
36
36
  DEFAULT_TIER_ORDER,
37
37
  DEFAULT_V3_ASSET_BASE_URL,
@@ -1,4 +1,4 @@
1
- import { h as CaptureState$1, f as CaptureSessionV3Events, g as CaptureSessionV3Options, c as CaptureErrorKind$1 } from './capture-session-v3-CAoWM8iT.cjs';
1
+ import { h as CaptureState$1, f as CaptureSessionV3Events, g as CaptureSessionV3Options, c as CaptureErrorKind$1 } from './capture-session-v3-COIO_F2w.cjs';
2
2
  import { C as CheckReason, c as HostedSessionView, g as HostedClient, A as AttestationInput, h as SessionView, b as CheckType } from './hosted-client-BpvBqu9f.cjs';
3
3
 
4
4
  /** Gate guidance code. `'hold'` is emitted while the clip records. */
@@ -274,11 +274,13 @@ interface LivenessFlowDeps extends SubmitRuntime {
274
274
  getAttestation(): Promise<AttestationInput>;
275
275
  /** Latest hosted view (frontier decisions). */
276
276
  getView(): HostedSessionView;
277
+ /** Re-GET the view from the server (after a RESYNC code) and fold it in. */
278
+ refreshView(): Promise<HostedSessionView>;
277
279
  /** Fold a submit response into the view. */
278
280
  applySubmit(view: SessionView, submitted: CheckType): void;
279
281
  /** Flow-state hook for the capture handle. */
280
282
  onPhase(phase: "uploading" | "checking"): void;
281
- /** A newer capture / destroy superseded this flow. */
283
+ /** The verification was destroyed (the only thing that cancels a chain). */
282
284
  isCancelled(): boolean;
283
285
  }
284
286
  type LivenessFlowOutcome = {
@@ -292,13 +294,18 @@ type LivenessFlowOutcome = {
292
294
  kind: "cancelled";
293
295
  };
294
296
  /**
295
- * Run the biometric submissions that are still executable: `liveness` when its
296
- * step is `pending | input_required`, then `face_match` when face_match is
297
- * executable and liveness either was NOT part of this capture (it had already
298
- * settled — any state, incl. `failed`: the selfie-fallback case, where this
299
- * capture only feeds face_match) or settled not-failed from this capture
300
- * (passed / review / processing / skipped — or absent). Returns `settled` for
301
- * the caller to map the view.
297
+ * Run the biometric submissions the CURRENT frontier allows, re-evaluating
298
+ * after every submit and after every RESYNC re-GET:
299
+ * - frontier `liveness` → clip upload + attestation + `liveness` (once per capture);
300
+ * - frontier `face_match` → keyframe upload + `face_match` (once per capture),
301
+ * allowed when liveness was NOT part of this capture (it had already
302
+ * settled — any state, incl. `failed`: the selfie-fallback case, where this
303
+ * capture only feeds face_match) or settled chainable from this capture
304
+ * (passed / review / processing / skipped — or absent);
305
+ * - anything else (terminal view, a non-biometric frontier, a check this
306
+ * capture already submitted) → `settled` for the caller to map the view.
307
+ * A RESYNC code re-GETs the view and re-enters (at most {@link RESYNC_REENTRY_MAX}
308
+ * times); the final RESYNC surfaces as `resync` for the caller to map.
302
309
  */
303
310
  declare function runLivenessFlow(deps: LivenessFlowDeps): Promise<LivenessFlowOutcome>;
304
311
 
@@ -1,4 +1,4 @@
1
- import { h as CaptureState$1, f as CaptureSessionV3Events, g as CaptureSessionV3Options, c as CaptureErrorKind$1 } from './capture-session-v3-BJ8ciaWl.js';
1
+ import { h as CaptureState$1, f as CaptureSessionV3Events, g as CaptureSessionV3Options, c as CaptureErrorKind$1 } from './capture-session-v3-CvIfcwST.js';
2
2
  import { C as CheckReason, c as HostedSessionView, g as HostedClient, A as AttestationInput, h as SessionView, b as CheckType } from './hosted-client-BpvBqu9f.js';
3
3
 
4
4
  /** Gate guidance code. `'hold'` is emitted while the clip records. */
@@ -274,11 +274,13 @@ interface LivenessFlowDeps extends SubmitRuntime {
274
274
  getAttestation(): Promise<AttestationInput>;
275
275
  /** Latest hosted view (frontier decisions). */
276
276
  getView(): HostedSessionView;
277
+ /** Re-GET the view from the server (after a RESYNC code) and fold it in. */
278
+ refreshView(): Promise<HostedSessionView>;
277
279
  /** Fold a submit response into the view. */
278
280
  applySubmit(view: SessionView, submitted: CheckType): void;
279
281
  /** Flow-state hook for the capture handle. */
280
282
  onPhase(phase: "uploading" | "checking"): void;
281
- /** A newer capture / destroy superseded this flow. */
283
+ /** The verification was destroyed (the only thing that cancels a chain). */
282
284
  isCancelled(): boolean;
283
285
  }
284
286
  type LivenessFlowOutcome = {
@@ -292,13 +294,18 @@ type LivenessFlowOutcome = {
292
294
  kind: "cancelled";
293
295
  };
294
296
  /**
295
- * Run the biometric submissions that are still executable: `liveness` when its
296
- * step is `pending | input_required`, then `face_match` when face_match is
297
- * executable and liveness either was NOT part of this capture (it had already
298
- * settled — any state, incl. `failed`: the selfie-fallback case, where this
299
- * capture only feeds face_match) or settled not-failed from this capture
300
- * (passed / review / processing / skipped — or absent). Returns `settled` for
301
- * the caller to map the view.
297
+ * Run the biometric submissions the CURRENT frontier allows, re-evaluating
298
+ * after every submit and after every RESYNC re-GET:
299
+ * - frontier `liveness` → clip upload + attestation + `liveness` (once per capture);
300
+ * - frontier `face_match` → keyframe upload + `face_match` (once per capture),
301
+ * allowed when liveness was NOT part of this capture (it had already
302
+ * settled — any state, incl. `failed`: the selfie-fallback case, where this
303
+ * capture only feeds face_match) or settled chainable from this capture
304
+ * (passed / review / processing / skipped — or absent);
305
+ * - anything else (terminal view, a non-biometric frontier, a check this
306
+ * capture already submitted) → `settled` for the caller to map the view.
307
+ * A RESYNC code re-GETs the view and re-enters (at most {@link RESYNC_REENTRY_MAX}
308
+ * times); the final RESYNC surfaces as `resync` for the caller to map.
302
309
  */
303
310
  declare function runLivenessFlow(deps: LivenessFlowDeps): Promise<LivenessFlowOutcome>;
304
311
 
package/dist/index.cjs CHANGED
@@ -862,10 +862,12 @@ var V3_GATE_DEFAULTS = Object.freeze({
862
862
  MOTION_TAU_S: 0.4,
863
863
  MOTION_DEADBAND_IOD_PER_S: 0.1,
864
864
  READY_WINDOW_MS: 500,
865
- // 0.7→0.75 with the trimmed window: trimming forgives exactly ONE flaky
866
- // tick; the raised mean keeps a SECOND bad tick (a real sustained wobble)
867
- // from diluting through at 10 Hz cadences.
868
- READY_MEAN_Q: 0.75,
865
+ // (READY_MEAN_Q, the former 0.75 window MEAN, is gone — 2026-10-17 field
866
+ // bug: a steady q anywhere in [Q_GOOD, 0.75) saturated p at 1, passed every
867
+ // guide ("hold still") and could never reach the mean, so the ring sat full
868
+ // and green with nothing recording. Readiness now checks the window against
869
+ // Q_GOOD, which keeps the one-flaky-tick forgiveness and the second-bad-tick
870
+ // block without a dead band above the rise threshold.)
869
871
  GUIDE_SIZE: 0.6,
870
872
  GUIDE_CENTERING: 0.6,
871
873
  GUIDE_POSE: 0.6,
@@ -943,8 +945,7 @@ var FaceGateV3 = class {
943
945
  const worstIndex = windowSamples.indexOf(worst);
944
946
  windowSamples = windowSamples.filter((_, index) => index !== worstIndex);
945
947
  }
946
- const windowMean = windowSamples.reduce((sum, value) => sum + value, 0) / Math.max(1, windowSamples.length);
947
- const ready = this.p >= 1 && windowMean >= this.t.READY_MEAN_Q;
948
+ const ready = this.p >= 1 && windowSamples.every((sample) => sample >= this.t.Q_GOOD);
948
949
  return {
949
950
  at: timestampMs,
950
951
  q,
@@ -1562,6 +1563,14 @@ function createLivenessCaptureInternal(options, internals = {}) {
1562
1563
  let animations = "full";
1563
1564
  let recordingStartedAt = null;
1564
1565
  let frozenAt = null;
1566
+ let frameWidth = 0;
1567
+ let frameHeight = 0;
1568
+ const latchFrameSize = () => {
1569
+ if (options.video.videoWidth > 0 && options.video.videoHeight > 0) {
1570
+ frameWidth = options.video.videoWidth;
1571
+ frameHeight = options.video.videoHeight;
1572
+ }
1573
+ };
1565
1574
  const setState = (next) => {
1566
1575
  state = next;
1567
1576
  emitter.emit("state", next);
@@ -1582,10 +1591,13 @@ function createLivenessCaptureInternal(options, internals = {}) {
1582
1591
  faceDetected = false;
1583
1592
  recordingStartedAt = null;
1584
1593
  frozenAt = null;
1594
+ frameWidth = 0;
1595
+ frameHeight = 0;
1585
1596
  }
1586
1597
  setState(next);
1587
1598
  syncProfile();
1588
1599
  if (next === "recording") {
1600
+ latchFrameSize();
1589
1601
  recordingStartedAt = now();
1590
1602
  hint = "hold";
1591
1603
  progress = 1;
@@ -1593,6 +1605,7 @@ function createLivenessCaptureInternal(options, internals = {}) {
1593
1605
  }
1594
1606
  }),
1595
1607
  engine.on("checks", (payload) => {
1608
+ latchFrameSize();
1596
1609
  progress = payload.progress;
1597
1610
  faceDetected = payload.factors.presence > 0;
1598
1611
  emitGate();
@@ -1612,8 +1625,8 @@ function createLivenessCaptureInternal(options, internals = {}) {
1612
1625
  keyframe: artifacts.keyframe,
1613
1626
  ...artifacts.clip !== void 0 ? { clip: artifacts.clip } : {},
1614
1627
  ...artifacts.clipMimeType !== void 0 ? { clipMime: artifacts.clipMimeType } : {},
1615
- width: options.video.videoWidth,
1616
- height: options.video.videoHeight,
1628
+ width: options.video.videoWidth || frameWidth,
1629
+ height: options.video.videoHeight || frameHeight,
1617
1630
  ...clipMs !== void 0 ? { clipMs } : {},
1618
1631
  captureChannel: detectIsMobile() ? "mobile" : "desktop",
1619
1632
  ...degraded ? { degraded: "NO_RECORDER" } : {}
@@ -2064,6 +2077,7 @@ var KEYFRAME_MIME = "image/jpeg";
2064
2077
  var TRANSPORT_REPLAY_ATTEMPTS = 2;
2065
2078
  var TRANSPORT_REPLAY_DELAY_MS = 1e3;
2066
2079
  var MAX_RETRY_AFTER_MS = 1e4;
2080
+ var RESYNC_REENTRY_MAX = 3;
2067
2081
  var TRANSPORT_CODES = /* @__PURE__ */ new Set([
2068
2082
  "network_error",
2069
2083
  "timeout",
@@ -2124,6 +2138,7 @@ async function runSubmission(rt, executor, hooks = {}) {
2124
2138
  }
2125
2139
  }
2126
2140
  }
2141
+ var LIVENESS_CHAINABLE = /* @__PURE__ */ new Set(["passed", "review", "processing", "skipped"]);
2127
2142
  async function runLivenessFlow(deps) {
2128
2143
  const { artifacts } = deps;
2129
2144
  const clip = artifacts.clip;
@@ -2131,67 +2146,87 @@ async function runLivenessFlow(deps) {
2131
2146
  if (artifacts.degraded === "NO_RECORDER" || clip === void 0 || !CLIP_MIME_ALLOWLIST.includes(bareMime)) {
2132
2147
  return { kind: "unavailable", reason: "no_recorder" };
2133
2148
  }
2134
- try {
2135
- const livenessWasExecutable = isExecutable(stepOf(deps.getView(), "liveness"));
2136
- if (livenessWasExecutable) {
2137
- let clipKey = null;
2138
- let attestation = null;
2139
- const response = await runSubmission(
2140
- deps,
2141
- async (client, idempotencyKey) => {
2142
- if (clipKey === null) {
2143
- deps.onPhase("uploading");
2144
- const presigned = await client.presign("clip", bareMime, clip.size);
2145
- await client.uploadToSpaces(presigned.uploadUrl, clip, bareMime);
2146
- clipKey = (await client.completeMedia(presigned.key)).key;
2147
- }
2148
- if (attestation === null) {
2149
- try {
2150
- attestation = await deps.getAttestation();
2151
- } catch (err) {
2152
- throw new AttestationError(err instanceof Error ? err.message : String(err));
2153
- }
2154
- }
2155
- deps.onPhase("checking");
2156
- return client.submitCheck(
2157
- "liveness",
2158
- { clipKey, attestation, captureChannel: artifacts.captureChannel },
2159
- { idempotencyKey }
2160
- );
2161
- },
2162
- {
2163
- onAttestationInvalid: () => {
2164
- attestation = null;
2165
- }
2166
- }
2167
- );
2168
- if (deps.isCancelled()) return { kind: "cancelled" };
2169
- deps.applySubmit(response, "liveness");
2170
- }
2149
+ const submitLiveness = buildLivenessSubmit(deps, clip, bareMime);
2150
+ const submitFaceMatch = buildFaceMatchSubmit(deps, artifacts.keyframe);
2151
+ let livenessSubmitted = false;
2152
+ let faceMatchSubmitted = false;
2153
+ let livenessChainable = true;
2154
+ let resyncs = 0;
2155
+ for (; ; ) {
2156
+ if (deps.isCancelled()) return { kind: "cancelled" };
2171
2157
  const view = deps.getView();
2172
- const liveness = stepOf(view, "liveness");
2173
- const livenessOk = !livenessWasExecutable || liveness === void 0 || liveness.state === "passed" || liveness.state === "review" || liveness.state === "processing" || liveness.state === "skipped";
2174
- if (!isTerminalView(view) && livenessOk && isExecutable(stepOf(view, "face_match"))) {
2175
- const keyframe = artifacts.keyframe;
2176
- let imageKey = null;
2177
- const response = await runSubmission(deps, async (client, idempotencyKey) => {
2178
- if (imageKey === null) {
2179
- deps.onPhase("uploading");
2180
- const presigned = await client.presign("image", KEYFRAME_MIME, keyframe.size);
2181
- await client.uploadToSpaces(presigned.uploadUrl, keyframe, KEYFRAME_MIME);
2182
- imageKey = (await client.completeMedia(presigned.key)).key;
2183
- }
2184
- deps.onPhase("checking");
2185
- return client.submitCheck("face_match", { imageKey }, { idempotencyKey });
2186
- });
2158
+ if (isTerminalView(view)) return { kind: "settled" };
2159
+ const frontier = frontierStep(view)?.checkType;
2160
+ const next = frontier === "liveness" && !livenessSubmitted ? "liveness" : frontier === "face_match" && !faceMatchSubmitted && livenessChainable ? "face_match" : null;
2161
+ if (next === null) return { kind: "settled" };
2162
+ try {
2163
+ if (next === "liveness") {
2164
+ livenessSubmitted = true;
2165
+ const response = await submitLiveness();
2166
+ if (deps.isCancelled()) return { kind: "cancelled" };
2167
+ deps.applySubmit(response, "liveness");
2168
+ const state = stepOf(deps.getView(), "liveness")?.state;
2169
+ livenessChainable = state === void 0 || LIVENESS_CHAINABLE.has(state);
2170
+ } else {
2171
+ faceMatchSubmitted = true;
2172
+ const response = await submitFaceMatch();
2173
+ if (deps.isCancelled()) return { kind: "cancelled" };
2174
+ deps.applySubmit(response, "face_match");
2175
+ }
2176
+ } catch (err) {
2187
2177
  if (deps.isCancelled()) return { kind: "cancelled" };
2188
- deps.applySubmit(response, "face_match");
2178
+ const outcome = classifyFailure(err);
2179
+ if (outcome.kind !== "resync" || resyncs >= RESYNC_REENTRY_MAX) return outcome;
2180
+ resyncs += 1;
2181
+ await deps.refreshView();
2182
+ }
2183
+ }
2184
+ }
2185
+ function buildLivenessSubmit(deps, clip, bareMime) {
2186
+ let clipKey = null;
2187
+ let attestation = null;
2188
+ return () => runSubmission(
2189
+ deps,
2190
+ async (client, idempotencyKey) => {
2191
+ if (clipKey === null) {
2192
+ deps.onPhase("uploading");
2193
+ const presigned = await client.presign("clip", bareMime, clip.size);
2194
+ await client.uploadToSpaces(presigned.uploadUrl, clip, bareMime);
2195
+ clipKey = (await client.completeMedia(presigned.key)).key;
2196
+ }
2197
+ if (attestation === null) {
2198
+ try {
2199
+ attestation = await deps.getAttestation();
2200
+ } catch (err) {
2201
+ throw new AttestationError(err instanceof Error ? err.message : String(err));
2202
+ }
2203
+ }
2204
+ deps.onPhase("checking");
2205
+ return client.submitCheck(
2206
+ "liveness",
2207
+ { clipKey, attestation, captureChannel: deps.artifacts.captureChannel },
2208
+ { idempotencyKey }
2209
+ );
2210
+ },
2211
+ {
2212
+ onAttestationInvalid: () => {
2213
+ attestation = null;
2214
+ }
2189
2215
  }
2190
- return { kind: "settled" };
2191
- } catch (err) {
2192
- if (deps.isCancelled()) return { kind: "cancelled" };
2193
- return classifyFailure(err);
2194
- }
2216
+ );
2217
+ }
2218
+ function buildFaceMatchSubmit(deps, keyframe) {
2219
+ let imageKey = null;
2220
+ return () => runSubmission(deps, async (client, idempotencyKey) => {
2221
+ if (imageKey === null) {
2222
+ deps.onPhase("uploading");
2223
+ const presigned = await client.presign("image", KEYFRAME_MIME, keyframe.size);
2224
+ await client.uploadToSpaces(presigned.uploadUrl, keyframe, KEYFRAME_MIME);
2225
+ imageKey = (await client.completeMedia(presigned.key)).key;
2226
+ }
2227
+ deps.onPhase("checking");
2228
+ return client.submitCheck("face_match", { imageKey }, { idempotencyKey });
2229
+ });
2195
2230
  }
2196
2231
  function classifyFailure(err) {
2197
2232
  if (err instanceof AttestationError) return { kind: "unavailable", reason: "attestation" };
@@ -2427,7 +2462,8 @@ function createVerificationInternal(options, internals) {
2427
2462
  };
2428
2463
  };
2429
2464
  let active = null;
2430
- let flowToken = 0;
2465
+ let chain = Promise.resolve();
2466
+ let chainDepth = 0;
2431
2467
  const settleProcessing = async () => {
2432
2468
  let current = await ensureView();
2433
2469
  for (let i = 0; i < PROCESSING_POLL_MAX && !destroyed && !isTerminalView(current) && frontierStep(current) === void 0 && current.steps.some((step) => step.state === "processing"); i += 1) {
@@ -2437,11 +2473,17 @@ function createVerificationInternal(options, internals) {
2437
2473
  }
2438
2474
  return current;
2439
2475
  };
2440
- const runFlow = async (capture, artifacts, token) => {
2441
- const isCancelled = () => destroyed || token !== flowToken || active !== capture;
2476
+ const runFlow = async (bound, artifacts, capturedAttempt) => {
2477
+ const { capture } = bound;
2478
+ const isCancelled = () => destroyed;
2479
+ const ownsHandle = () => bound.alive && bound.attempt === capturedAttempt;
2480
+ const setPhase = (phase) => {
2481
+ if (ownsHandle()) capture.setFlowState(phase);
2482
+ };
2442
2483
  const finish = (result) => {
2443
2484
  if (isCancelled()) return;
2444
2485
  emitResult(result);
2486
+ if (!ownsHandle()) return;
2445
2487
  if (result.status === "retry" && rearm === "auto") {
2446
2488
  capture.handle.retry();
2447
2489
  return;
@@ -2453,7 +2495,7 @@ function createVerificationInternal(options, internals) {
2453
2495
  finish(unavailable(sessionId, "no_recorder"));
2454
2496
  return;
2455
2497
  }
2456
- capture.setFlowState("uploading");
2498
+ setPhase("uploading");
2457
2499
  try {
2458
2500
  await startRegistry();
2459
2501
  if (isCancelled()) return;
@@ -2475,10 +2517,9 @@ function createVerificationInternal(options, internals) {
2475
2517
  artifacts,
2476
2518
  getAttestation,
2477
2519
  getView: () => view ?? current,
2520
+ refreshView,
2478
2521
  applySubmit,
2479
- onPhase: (phase) => {
2480
- if (!isCancelled()) capture.setFlowState(phase);
2481
- },
2522
+ onPhase: setPhase,
2482
2523
  isCancelled
2483
2524
  });
2484
2525
  if (isCancelled()) return;
@@ -2495,18 +2536,43 @@ function createVerificationInternal(options, internals) {
2495
2536
  finish(unavailable(view?.sessionId ?? "", "network"));
2496
2537
  }
2497
2538
  };
2539
+ const enqueueFlow = (bound, artifacts) => {
2540
+ const capturedAttempt = bound.attempt;
2541
+ if (chainDepth > 0 && bound.alive) {
2542
+ bound.capture.setFlowState("uploading");
2543
+ }
2544
+ chainDepth += 1;
2545
+ chain = chain.then(() => runFlow(bound, artifacts, capturedAttempt)).finally(() => {
2546
+ chainDepth -= 1;
2547
+ }).catch(() => void 0);
2548
+ };
2498
2549
  const liveness = (captureOptions) => {
2499
- active?.handle.destroy();
2550
+ active?.capture.handle.destroy();
2551
+ if (active !== null) active.alive = false;
2500
2552
  const capture = createLivenessCaptureInternal(captureOptions, internals.capture);
2501
- active = capture;
2553
+ const bound = { capture, alive: true, attempt: 0 };
2554
+ active = bound;
2502
2555
  capture.handle.on("state", (state) => {
2503
- if (state === "starting") flowToken += 1;
2556
+ if (state === "starting") bound.attempt += 1;
2504
2557
  });
2505
2558
  capture.handle.on("captured", (artifacts) => {
2506
- const token = ++flowToken;
2507
- void Promise.resolve().then(() => runFlow(capture, artifacts, token));
2559
+ void Promise.resolve().then(() => enqueueFlow(bound, artifacts));
2508
2560
  });
2509
- return capture.handle;
2561
+ const inner = capture.handle;
2562
+ return {
2563
+ get state() {
2564
+ return inner.state;
2565
+ },
2566
+ on: (event, handler) => inner.on(event, handler),
2567
+ start: () => inner.start(),
2568
+ retry: () => inner.retry(),
2569
+ switchCamera: () => inner.switchCamera(),
2570
+ destroy: () => {
2571
+ bound.alive = false;
2572
+ if (active === bound) active = null;
2573
+ inner.destroy();
2574
+ }
2575
+ };
2510
2576
  };
2511
2577
  return {
2512
2578
  consent: () => ensureConsent(),
@@ -2516,8 +2582,11 @@ function createVerificationInternal(options, internals) {
2516
2582
  destroy: () => {
2517
2583
  destroyed = true;
2518
2584
  lifetime.abort();
2519
- active?.handle.destroy();
2520
- active = null;
2585
+ if (active !== null) {
2586
+ active.alive = false;
2587
+ active.capture.handle.destroy();
2588
+ active = null;
2589
+ }
2521
2590
  emitter.clear();
2522
2591
  }
2523
2592
  };