@scanly/browser 2.0.0

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.
Files changed (142) hide show
  1. package/README.md +3 -0
  2. package/dist/batch/batch-controller.d.ts +71 -0
  3. package/dist/batch/batch-controller.d.ts.map +1 -0
  4. package/dist/batch/batch-controller.js +422 -0
  5. package/dist/batch/batch-controller.js.map +1 -0
  6. package/dist/batch/batch-session.d.ts +53 -0
  7. package/dist/batch/batch-session.d.ts.map +1 -0
  8. package/dist/batch/batch-session.js +267 -0
  9. package/dist/batch/batch-session.js.map +1 -0
  10. package/dist/batch/index.d.ts +4 -0
  11. package/dist/batch/index.d.ts.map +1 -0
  12. package/dist/batch/index.js +4 -0
  13. package/dist/batch/index.js.map +1 -0
  14. package/dist/batch/types.d.ts +101 -0
  15. package/dist/batch/types.d.ts.map +1 -0
  16. package/dist/batch/types.js +2 -0
  17. package/dist/batch/types.js.map +1 -0
  18. package/dist/browser-session.d.ts +51 -0
  19. package/dist/browser-session.d.ts.map +1 -0
  20. package/dist/browser-session.js +168 -0
  21. package/dist/browser-session.js.map +1 -0
  22. package/dist/camera-source.d.ts +89 -0
  23. package/dist/camera-source.d.ts.map +1 -0
  24. package/dist/camera-source.js +366 -0
  25. package/dist/camera-source.js.map +1 -0
  26. package/dist/camera-strategy.d.ts +36 -0
  27. package/dist/camera-strategy.d.ts.map +1 -0
  28. package/dist/camera-strategy.js +94 -0
  29. package/dist/camera-strategy.js.map +1 -0
  30. package/dist/decode-upload.d.ts +13 -0
  31. package/dist/decode-upload.d.ts.map +1 -0
  32. package/dist/decode-upload.js +23 -0
  33. package/dist/decode-upload.js.map +1 -0
  34. package/dist/image-loader.d.ts +11 -0
  35. package/dist/image-loader.d.ts.map +1 -0
  36. package/dist/image-loader.js +63 -0
  37. package/dist/image-loader.js.map +1 -0
  38. package/dist/index.d.ts +13 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +13 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/runtime.d.ts +8 -0
  43. package/dist/runtime.d.ts.map +1 -0
  44. package/dist/runtime.js +14 -0
  45. package/dist/runtime.js.map +1 -0
  46. package/dist/scanner/camera-capabilities.d.ts +20 -0
  47. package/dist/scanner/camera-capabilities.d.ts.map +1 -0
  48. package/dist/scanner/camera-capabilities.js +181 -0
  49. package/dist/scanner/camera-capabilities.js.map +1 -0
  50. package/dist/scanner/camera-platform.d.ts +75 -0
  51. package/dist/scanner/camera-platform.d.ts.map +1 -0
  52. package/dist/scanner/camera-platform.js +145 -0
  53. package/dist/scanner/camera-platform.js.map +1 -0
  54. package/dist/scanner/decode-escalation.d.ts +20 -0
  55. package/dist/scanner/decode-escalation.d.ts.map +1 -0
  56. package/dist/scanner/decode-escalation.js +40 -0
  57. package/dist/scanner/decode-escalation.js.map +1 -0
  58. package/dist/scanner/frame-quality.d.ts +24 -0
  59. package/dist/scanner/frame-quality.d.ts.map +1 -0
  60. package/dist/scanner/frame-quality.js +90 -0
  61. package/dist/scanner/frame-quality.js.map +1 -0
  62. package/dist/scanner/frame-scheduler.d.ts +59 -0
  63. package/dist/scanner/frame-scheduler.d.ts.map +1 -0
  64. package/dist/scanner/frame-scheduler.js +148 -0
  65. package/dist/scanner/frame-scheduler.js.map +1 -0
  66. package/dist/scanner/frame-source.d.ts +70 -0
  67. package/dist/scanner/frame-source.d.ts.map +1 -0
  68. package/dist/scanner/frame-source.js +263 -0
  69. package/dist/scanner/frame-source.js.map +1 -0
  70. package/dist/scanner/index.d.ts +12 -0
  71. package/dist/scanner/index.d.ts.map +1 -0
  72. package/dist/scanner/index.js +12 -0
  73. package/dist/scanner/index.js.map +1 -0
  74. package/dist/scanner/repeat-suppressor.d.ts +23 -0
  75. package/dist/scanner/repeat-suppressor.d.ts.map +1 -0
  76. package/dist/scanner/repeat-suppressor.js +69 -0
  77. package/dist/scanner/repeat-suppressor.js.map +1 -0
  78. package/dist/scanner/scanner-session.d.ts +160 -0
  79. package/dist/scanner/scanner-session.d.ts.map +1 -0
  80. package/dist/scanner/scanner-session.js +766 -0
  81. package/dist/scanner/scanner-session.js.map +1 -0
  82. package/dist/scanner/temporal-confirmation.d.ts +30 -0
  83. package/dist/scanner/temporal-confirmation.d.ts.map +1 -0
  84. package/dist/scanner/temporal-confirmation.js +71 -0
  85. package/dist/scanner/temporal-confirmation.js.map +1 -0
  86. package/dist/scanner/temporal-roi.d.ts +27 -0
  87. package/dist/scanner/temporal-roi.d.ts.map +1 -0
  88. package/dist/scanner/temporal-roi.js +50 -0
  89. package/dist/scanner/temporal-roi.js.map +1 -0
  90. package/dist/scanner/types.d.ts +232 -0
  91. package/dist/scanner/types.d.ts.map +1 -0
  92. package/dist/scanner/types.js +2 -0
  93. package/dist/scanner/types.js.map +1 -0
  94. package/dist/tracking/barcode-tracker.d.ts +28 -0
  95. package/dist/tracking/barcode-tracker.d.ts.map +1 -0
  96. package/dist/tracking/barcode-tracker.js +279 -0
  97. package/dist/tracking/barcode-tracker.js.map +1 -0
  98. package/dist/tracking/geometry.d.ts +13 -0
  99. package/dist/tracking/geometry.d.ts.map +1 -0
  100. package/dist/tracking/geometry.js +51 -0
  101. package/dist/tracking/geometry.js.map +1 -0
  102. package/dist/tracking/index.d.ts +8 -0
  103. package/dist/tracking/index.d.ts.map +1 -0
  104. package/dist/tracking/index.js +8 -0
  105. package/dist/tracking/index.js.map +1 -0
  106. package/dist/tracking/scanner-tracking-runtime.d.ts +45 -0
  107. package/dist/tracking/scanner-tracking-runtime.d.ts.map +1 -0
  108. package/dist/tracking/scanner-tracking-runtime.js +93 -0
  109. package/dist/tracking/scanner-tracking-runtime.js.map +1 -0
  110. package/dist/tracking/track-association.d.ts +19 -0
  111. package/dist/tracking/track-association.d.ts.map +1 -0
  112. package/dist/tracking/track-association.js +191 -0
  113. package/dist/tracking/track-association.js.map +1 -0
  114. package/dist/tracking/track-overlay.d.ts +4 -0
  115. package/dist/tracking/track-overlay.d.ts.map +1 -0
  116. package/dist/tracking/track-overlay.js +16 -0
  117. package/dist/tracking/track-overlay.js.map +1 -0
  118. package/dist/tracking/track-roi-set.d.ts +17 -0
  119. package/dist/tracking/track-roi-set.d.ts.map +1 -0
  120. package/dist/tracking/track-roi-set.js +119 -0
  121. package/dist/tracking/track-roi-set.js.map +1 -0
  122. package/dist/tracking/types.d.ts +154 -0
  123. package/dist/tracking/types.d.ts.map +1 -0
  124. package/dist/tracking/types.js +2 -0
  125. package/dist/tracking/types.js.map +1 -0
  126. package/dist/worker/decode-worker.d.ts +2 -0
  127. package/dist/worker/decode-worker.d.ts.map +1 -0
  128. package/dist/worker/decode-worker.js +84 -0
  129. package/dist/worker/decode-worker.js.map +1 -0
  130. package/dist/worker/transferable-buffer.d.ts +18 -0
  131. package/dist/worker/transferable-buffer.d.ts.map +1 -0
  132. package/dist/worker/transferable-buffer.js +24 -0
  133. package/dist/worker/transferable-buffer.js.map +1 -0
  134. package/dist/worker/worker-client.d.ts +86 -0
  135. package/dist/worker/worker-client.d.ts.map +1 -0
  136. package/dist/worker/worker-client.js +286 -0
  137. package/dist/worker/worker-client.js.map +1 -0
  138. package/dist/worker/worker-messages.d.ts +75 -0
  139. package/dist/worker/worker-messages.d.ts.map +1 -0
  140. package/dist/worker/worker-messages.js +244 -0
  141. package/dist/worker/worker-messages.js.map +1 -0
  142. package/package.json +30 -0
@@ -0,0 +1,766 @@
1
+ import { CaptureRouter, IndustrialRecoveryPipeline, ScannerDiagnostics, SdkException, createRecoveryProbeScenario, sdkError, toDecodedBarcode, } from "@scanly/core";
2
+ import { getBuiltinScenario } from "@scanly/scenario-schema";
3
+ import { createBrowserCaptureRouter } from "../runtime.js";
4
+ import { DecodeWorkerClient, markDecodePath } from "../worker/worker-client.js";
5
+ import { ScannerTrackingRuntime, } from "../tracking/scanner-tracking-runtime.js";
6
+ import { CameraCapabilityController } from "./camera-capabilities.js";
7
+ import { CameraRecoveryController, cameraError } from "./camera-platform.js";
8
+ import { BoundedDecodeEscalation } from "./decode-escalation.js";
9
+ import { FrameQualityAnalyzer } from "./frame-quality.js";
10
+ import { FrameScheduler } from "./frame-scheduler.js";
11
+ import { RepeatSuppressor } from "./repeat-suppressor.js";
12
+ import { TemporalCandidateStore } from "./temporal-confirmation.js";
13
+ import { TemporalROI } from "./temporal-roi.js";
14
+ /** Default decoder composition: persistent Worker with an explicit main-thread fallback. */
15
+ export class BrowserScannerFrameDecoder {
16
+ router;
17
+ ownsRouter;
18
+ worker;
19
+ useWorker;
20
+ baseScenario;
21
+ recovery;
22
+ recoveryPipeline = new IndustrialRecoveryPipeline();
23
+ mainRecoveryRunCount = 0;
24
+ mainRecoveryTemporaryBytes = 0;
25
+ mainRecoveryPeakTemporaryBytes = 0;
26
+ mainRecoveryRouteStateCount = 0;
27
+ disposed = false;
28
+ constructor(options = {}) {
29
+ this.router = options.router ?? createBrowserCaptureRouter();
30
+ this.ownsRouter = options.disposeRouter ?? !options.router;
31
+ this.worker = new DecodeWorkerClient(options.workerFactory);
32
+ this.useWorker = options.useWorker ?? typeof Worker !== "undefined";
33
+ this.baseScenario = options.scenario;
34
+ this.recovery = options.recovery ?? {};
35
+ }
36
+ async decode(frame, request) {
37
+ if (this.disposed) {
38
+ return { ok: false, error: sdkError("session_disposed", "Scanner decoder has been disposed."), frameId: frame.id, scenarioId: "scanner-disposed", attemptCount: 0, timing: { totalMs: 0 } };
39
+ }
40
+ const profile = getBuiltinScenario(request.profile);
41
+ const source = this.baseScenario ? {
42
+ ...profile,
43
+ acceptedFormats: [...this.baseScenario.acceptedFormats],
44
+ multiCode: { ...this.baseScenario.multiCode },
45
+ validation: this.baseScenario.validation.map((entry) => ({ ...entry })),
46
+ semanticParsers: [...this.baseScenario.semanticParsers],
47
+ output: { ...this.baseScenario.output },
48
+ } : profile;
49
+ const rawTrackingMaxResults = request.maxResults ?? 32;
50
+ const requestedTrackingMaxResults = request.mode === "tracking"
51
+ ? Number.isFinite(rawTrackingMaxResults)
52
+ ? Math.max(1, Math.min(32, Math.floor(rawTrackingMaxResults)))
53
+ : 32
54
+ : undefined;
55
+ // A profile's attempt budget is also a hard upper bound on how many
56
+ // results one decode can produce. Keep the Beta 1 profile budget intact;
57
+ // uncovered-region and periodic full-frame passes can discover additional
58
+ // tracks across subsequent frames without creating an invalid scenario.
59
+ const trackingMaxResults = requestedTrackingMaxResults === undefined
60
+ ? undefined
61
+ : Math.min(requestedTrackingMaxResults, source.budgets.maxAttempts);
62
+ const decodeSource = trackingMaxResults === undefined ? source : {
63
+ ...source,
64
+ localization: {
65
+ ...source.localization,
66
+ maxCandidates: Math.max(source.localization.maxCandidates, trackingMaxResults),
67
+ },
68
+ multiCode: {
69
+ enabled: true,
70
+ maxResults: trackingMaxResults,
71
+ // Identity is assigned after decode by BarcodeTracker. Spatial
72
+ // deduplication keeps equal-payload objects at distinct geometries.
73
+ deduplication: "payload-format-spatial",
74
+ },
75
+ budgets: {
76
+ ...source.budgets,
77
+ maxCandidates: Math.max(source.budgets.maxCandidates, trackingMaxResults),
78
+ },
79
+ };
80
+ const scenario = request.roi
81
+ ? { ...decodeSource, input: { ...decodeSource.input, roi: { mode: "relative", x: request.roi.x, y: request.roi.y, width: request.roi.width, height: request.roi.height } } }
82
+ : { ...decodeSource, input: { ...decodeSource.input, roi: { mode: "full-frame" } } };
83
+ if (this.useWorker) {
84
+ const started = Date.now();
85
+ markDecodePath("worker");
86
+ const workerOutcome = await this.worker.scan(frame, scenario, { signal: request.signal, generation: request.generation, preserveSourceForFallback: true, ...(this.workerRecovery(request) ? { recovery: this.workerRecovery(request) } : {}) });
87
+ if (workerOutcome.ok || request.signal.aborted || !["worker_initialization_failure", "engine_execution_failure"].includes(workerOutcome.error.code))
88
+ return workerOutcome;
89
+ const elapsed = Math.max(Date.now() - started, workerOutcome.timing.totalMs);
90
+ const remainingExecutionMs = Math.floor(scenario.budgets.maxExecutionMs - elapsed);
91
+ const remainingAttempts = scenario.budgets.maxAttempts - workerOutcome.attemptCount;
92
+ if (remainingExecutionMs <= 0 || remainingAttempts <= 0)
93
+ return workerOutcome;
94
+ markDecodePath("main-thread");
95
+ const fallbackScenario = { ...scenario, multiCode: { ...scenario.multiCode, maxResults: Math.min(scenario.multiCode.maxResults, remainingAttempts) }, budgets: { ...scenario.budgets, maxAttempts: remainingAttempts, maxExecutionMs: remainingExecutionMs } };
96
+ const fallback = await this.decodeOnMain(frame, fallbackScenario, request);
97
+ return { ...fallback, attemptCount: workerOutcome.attemptCount + fallback.attemptCount, timing: { ...fallback.timing, totalMs: Math.max(Date.now() - started, elapsed + fallback.timing.totalMs), ...(workerOutcome.timing.workerSetupMs === undefined ? {} : { workerSetupMs: workerOutcome.timing.workerSetupMs }), ...(workerOutcome.timing.workerTransferMs === undefined ? {} : { workerTransferMs: workerOutcome.timing.workerTransferMs }) } };
98
+ }
99
+ markDecodePath("main-thread");
100
+ return this.decodeOnMain(frame, scenario, request);
101
+ }
102
+ workerRecovery(request) {
103
+ if (this.recovery === false)
104
+ return undefined;
105
+ return {
106
+ profile: this.recovery.profile ?? request.profile,
107
+ sourceMode: "camera",
108
+ ...(this.recovery.budget ? { budget: this.recovery.budget } : {}),
109
+ dpmExperimental: this.recovery.dpmExperimental === true,
110
+ ...(this.recovery.excludedRoutes ? { excludedRoutes: [...this.recovery.excludedRoutes] } : {}),
111
+ };
112
+ }
113
+ async decodeOnMain(frame, scenario, request) {
114
+ const recovery = this.workerRecovery(request);
115
+ if (!recovery)
116
+ return this.router.scan(frame, { signal: request.signal, scenario });
117
+ const result = await this.recoveryPipeline.run(frame, (candidate, recoveryRequest) => this.router.scan({ ...candidate, ownership: "borrowed", dispose: undefined }, { signal: recoveryRequest.signal, scenario: recoveryRequest.routeId === "general" ? scenario : createRecoveryProbeScenario(scenario, recoveryRequest.routeId) }), { profile: recovery.profile, sourceMode: recovery.sourceMode, ...(recovery.budget ? { budget: recovery.budget } : {}), signal: request.signal, dpmExperimental: recovery.dpmExperimental, excludedRoutes: recovery.excludedRoutes });
118
+ this.mainRecoveryRunCount += 1;
119
+ this.mainRecoveryTemporaryBytes = result.memory.currentBytes;
120
+ this.mainRecoveryPeakTemporaryBytes = Math.max(this.mainRecoveryPeakTemporaryBytes, result.memory.peakBytes);
121
+ this.mainRecoveryRouteStateCount = 0;
122
+ return result.outcome;
123
+ }
124
+ cancel() { this.worker.cancel(); }
125
+ async dispose() {
126
+ if (this.disposed)
127
+ return;
128
+ this.disposed = true;
129
+ this.worker.dispose();
130
+ if (this.ownsRouter)
131
+ await this.router.dispose();
132
+ }
133
+ getStatistics() {
134
+ const worker = this.worker.getStatistics();
135
+ const wasm = this.router.engines.get("zxing-cpp-wasm");
136
+ const memory = wasm?.getMemoryObservation?.();
137
+ return {
138
+ ...worker,
139
+ wasmInputAllocationBytes: (worker.wasmInputAllocationBytes ?? 0) + (memory?.inputAllocationBytes ?? 0),
140
+ wasmActiveNativeResultCount: (worker.wasmActiveNativeResultCount ?? 0) + (memory?.activeNativeResultCount ?? 0),
141
+ wasmCurrentLinearMemoryBytes: (worker.wasmCurrentLinearMemoryBytes ?? 0) + (memory?.currentLinearMemoryBytes ?? 0),
142
+ wasmPeakLinearMemoryBytes: (worker.wasmPeakLinearMemoryBytes ?? 0) + (memory?.peakLinearMemoryBytes ?? 0),
143
+ wasmReleasedNativeResultCount: (worker.wasmReleasedNativeResultCount ?? 0) + (memory?.releasedNativeResultCount ?? 0),
144
+ recoveryRunCount: (worker.recoveryRunCount ?? 0) + this.mainRecoveryRunCount,
145
+ recoveryTemporaryBytes: (worker.recoveryTemporaryBytes ?? 0) + this.mainRecoveryTemporaryBytes,
146
+ recoveryPeakTemporaryBytes: Math.max(worker.recoveryPeakTemporaryBytes ?? 0, this.mainRecoveryPeakTemporaryBytes),
147
+ recoveryRouteStateCount: (worker.recoveryRouteStateCount ?? 0) + this.mainRecoveryRouteStateCount,
148
+ };
149
+ }
150
+ }
151
+ const once = (fn) => {
152
+ let called = false;
153
+ return (() => { if (!called) {
154
+ called = true;
155
+ fn();
156
+ } });
157
+ };
158
+ function geometryFor(result, frame) {
159
+ const points = result.cornerPoints;
160
+ if (!points?.length)
161
+ return undefined;
162
+ const xs = points.map((point) => point.x);
163
+ const ys = points.map((point) => point.y);
164
+ const x = Math.max(0, Math.min(...xs));
165
+ const y = Math.max(0, Math.min(...ys));
166
+ const right = Math.min(frame.width, Math.max(...xs));
167
+ const bottom = Math.min(frame.height, Math.max(...ys));
168
+ if (right <= x || bottom <= y)
169
+ return undefined;
170
+ return { cornerPoints: points, boundingBox: { x, y, width: right - x, height: bottom - y }, frameWidth: frame.width, frameHeight: frame.height };
171
+ }
172
+ function isNoResult(outcome) {
173
+ return !outcome.ok && ["no_symbol_found", "timeout", "cancelled", "unsupported_format"].includes(outcome.error.code);
174
+ }
175
+ export class ScannerSession {
176
+ state = "idle";
177
+ source;
178
+ decoder;
179
+ ownsDecoder;
180
+ quality;
181
+ scheduler;
182
+ escalation;
183
+ candidates;
184
+ repeats;
185
+ roi;
186
+ decodeMode;
187
+ trackingRuntime;
188
+ capabilityController;
189
+ autoZoom;
190
+ cameraRecovery;
191
+ qualityProbeInterval;
192
+ resultListeners = new Set();
193
+ observationSetListeners = new Set();
194
+ stateListeners = new Set();
195
+ diagnosticListeners = new Set();
196
+ generation = 0;
197
+ lifecycleGeneration = 0;
198
+ stopPromise = null;
199
+ recoveryPromise = null;
200
+ backgroundPaused = false;
201
+ activeDecodeController = null;
202
+ frameSequence = 0;
203
+ eventSequence = 0;
204
+ startedAt = 0;
205
+ firstDecodeAt;
206
+ firstConfirmedAt;
207
+ decodeLatencies = [];
208
+ peakControlledMemory = 0;
209
+ currentWorkerMemory = 0;
210
+ counters = {
211
+ capturedFrames: 0, admittedFrames: 0, droppedFrames: 0, qualityRejectedFrames: 0, periodicProbeFrames: 0,
212
+ fastAttempts: 0, balancedAttempts: 0, robustAttempts: 0, decodeSuccesses: 0, confirmedEvents: 0,
213
+ emittedEvents: 0, suppressedRepeats: 0, staleResultsDiscarded: 0, staleEvents: 0, lostEvents: 0,
214
+ cameraTrackEndings: 0, cameraGenerationInvalidations: 0,
215
+ };
216
+ constructor(options) {
217
+ this.source = options.source;
218
+ this.decoder = options.decoder ?? new BrowserScannerFrameDecoder(options.decoderOptions);
219
+ this.ownsDecoder = !options.decoder;
220
+ this.quality = new FrameQualityAnalyzer(options.quality);
221
+ this.escalation = new BoundedDecodeEscalation(options.escalation);
222
+ this.candidates = new TemporalCandidateStore(options.confirmation);
223
+ this.repeats = new RepeatSuppressor(options.repeatPolicy ?? { mode: "cooldown", cooldownMs: 1_500 });
224
+ this.roi = new TemporalROI(options.roi);
225
+ this.decodeMode = options.decodeMode ?? "single";
226
+ this.trackingRuntime = this.decodeMode === "tracking"
227
+ ? new ScannerTrackingRuntime(options.tracking)
228
+ : undefined;
229
+ this.autoZoom = options.autoZoom ?? { enabled: true };
230
+ this.cameraRecovery = new CameraRecoveryController(options.cameraRecovery);
231
+ const trackSource = options.source;
232
+ this.capabilityController = options.capabilityController ?? (typeof trackSource.currentTrack === "function" ? new CameraCapabilityController(() => trackSource.currentTrack?.(), this.autoZoom) : undefined);
233
+ this.qualityProbeInterval = Math.max(1, Math.floor(options.qualityProbeInterval ?? 10));
234
+ this.scheduler = new FrameScheduler((frame) => this.processFrame(frame), {
235
+ initialDecodeFps: 10, minimumDecodeFps: 5, maximumDecodeFps: 15, ...options.scheduler,
236
+ onAdmitted: () => { this.counters.admittedFrames += 1; },
237
+ onDropped: () => { this.counters.droppedFrames += 1; },
238
+ });
239
+ }
240
+ getState() { return this.state; }
241
+ async start() {
242
+ if (this.state === "scanning" || this.state === "starting")
243
+ return;
244
+ if (this.state === "stopping")
245
+ throw new Error("Scanner session is stopping.");
246
+ if (this.state === "failed" || this.state === "stopped")
247
+ this.reset();
248
+ this.setState("starting");
249
+ const lifecycleGeneration = ++this.lifecycleGeneration;
250
+ this.generation += 1;
251
+ this.startedAt = Date.now();
252
+ this.quality.reset();
253
+ this.escalation.reset();
254
+ this.scheduler.reset();
255
+ this.trackingRuntime?.reset();
256
+ this.scheduler.start();
257
+ try {
258
+ await this.source.start((frame) => this.acceptFrame(frame, lifecycleGeneration), (error) => this.handleSourceError(error, lifecycleGeneration), () => this.handleSourceEnded(lifecycleGeneration), (event) => this.handleCameraLifecycle(event, lifecycleGeneration));
259
+ if (lifecycleGeneration === this.lifecycleGeneration && this.getState() === "starting")
260
+ this.setState("scanning");
261
+ }
262
+ catch (error) {
263
+ if (lifecycleGeneration !== this.lifecycleGeneration || this.getState() !== "starting")
264
+ return;
265
+ this.setState("failed");
266
+ const typed = cameraError(error);
267
+ this.emitDiagnostic({ type: "error", timestamp: Date.now(), error: typed });
268
+ throw new SdkException(typed);
269
+ }
270
+ }
271
+ pause() {
272
+ if (this.state !== "scanning")
273
+ return;
274
+ this.source.pause?.();
275
+ this.scheduler.pause();
276
+ this.activeDecodeController?.abort();
277
+ this.decoder.cancel();
278
+ this.generation += 1;
279
+ this.setState("paused");
280
+ }
281
+ resume() {
282
+ if (this.state !== "paused")
283
+ return;
284
+ this.generation += 1;
285
+ this.source.resume?.();
286
+ this.scheduler.resume();
287
+ this.setState("scanning");
288
+ }
289
+ async switchSource(source, capabilityController) {
290
+ const restart = this.state === "scanning" || this.state === "paused" || this.state === "starting";
291
+ await this.stop();
292
+ // stop() drains the active scheduler task. A decoder may complete after
293
+ // generation invalidation and increment stale counters while that drain is
294
+ // in progress, so take the cumulative snapshot only after it has settled.
295
+ const switchInvalidations = this.counters.cameraGenerationInvalidations + 1;
296
+ const staleResultsDiscarded = this.counters.staleResultsDiscarded;
297
+ const staleEvents = this.counters.staleEvents;
298
+ this.source = source;
299
+ const trackSource = source;
300
+ this.capabilityController = capabilityController ?? (typeof trackSource.currentTrack === "function" ? new CameraCapabilityController(() => trackSource.currentTrack?.(), this.autoZoom) : undefined);
301
+ this.reset();
302
+ // reset() intentionally starts fresh run-level metrics. Preserve the
303
+ // cumulative switch/stale counts so every source generation change and
304
+ // rejected late result remains auditable across rear/front/rear switches.
305
+ this.counters.cameraGenerationInvalidations = switchInvalidations;
306
+ this.counters.staleResultsDiscarded = staleResultsDiscarded;
307
+ this.counters.staleEvents = staleEvents;
308
+ if (restart)
309
+ await this.start();
310
+ }
311
+ async stop() {
312
+ if (["idle", "stopped"].includes(this.state))
313
+ return;
314
+ if (this.state === "stopping") {
315
+ await this.stopPromise;
316
+ return;
317
+ }
318
+ this.lifecycleGeneration += 1;
319
+ this.generation += 1;
320
+ this.activeDecodeController?.abort();
321
+ this.decoder.cancel();
322
+ this.recoveryPromise = null;
323
+ this.backgroundPaused = false;
324
+ const stopping = Promise.resolve().then(async () => {
325
+ try {
326
+ await this.source.stop();
327
+ }
328
+ finally {
329
+ await this.scheduler.stop();
330
+ this.candidates.reset();
331
+ this.repeats.reset();
332
+ this.roi.reset();
333
+ this.trackingRuntime?.reset();
334
+ this.setState("stopped");
335
+ this.emitDiagnostic({ type: "scheduler", timestamp: Date.now(), detail: "session stopped; pending frames and active decode are zero" });
336
+ }
337
+ });
338
+ this.stopPromise = stopping;
339
+ this.setState("stopping");
340
+ try {
341
+ await stopping;
342
+ }
343
+ finally {
344
+ if (this.stopPromise === stopping)
345
+ this.stopPromise = null;
346
+ }
347
+ }
348
+ reset() {
349
+ if (this.state === "scanning" || this.state === "starting" || this.state === "paused")
350
+ throw new Error("Stop the scanner session before reset().");
351
+ this.lifecycleGeneration += 1;
352
+ this.generation += 1;
353
+ this.candidates.reset();
354
+ this.repeats.reset();
355
+ this.roi.reset();
356
+ this.trackingRuntime?.reset();
357
+ this.escalation.reset();
358
+ this.quality.reset();
359
+ this.scheduler.reset();
360
+ this.cameraRecovery.reset();
361
+ this.backgroundPaused = false;
362
+ this.decodeLatencies = [];
363
+ this.firstDecodeAt = undefined;
364
+ this.firstConfirmedAt = undefined;
365
+ this.startedAt = 0;
366
+ this.peakControlledMemory = 0;
367
+ this.currentWorkerMemory = 0;
368
+ this.counters = { capturedFrames: 0, admittedFrames: 0, droppedFrames: 0, qualityRejectedFrames: 0, periodicProbeFrames: 0, fastAttempts: 0, balancedAttempts: 0, robustAttempts: 0, decodeSuccesses: 0, confirmedEvents: 0, emittedEvents: 0, suppressedRepeats: 0, staleResultsDiscarded: 0, staleEvents: 0, lostEvents: 0, cameraTrackEndings: 0, cameraGenerationInvalidations: 0 };
369
+ this.setState("idle");
370
+ }
371
+ async dispose() { await this.stop(); if (this.ownsDecoder)
372
+ await this.decoder.dispose(); this.resultListeners.clear(); this.observationSetListeners.clear(); this.stateListeners.clear(); this.diagnosticListeners.clear(); }
373
+ getStatistics() {
374
+ const scheduler = this.scheduler.getStatistics();
375
+ const decoder = this.decoder.getStatistics?.();
376
+ const now = Date.now();
377
+ const elapsed = Math.max(1, now - (this.startedAt || now));
378
+ const sorted = [...this.decodeLatencies].sort((a, b) => a - b);
379
+ const p50 = sorted.length ? sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.5) - 1)] : 0;
380
+ const p95 = sorted.length ? sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] : 0;
381
+ const currentWorkerMemory = decoder?.wasmCurrentLinearMemoryBytes ?? this.currentWorkerMemory;
382
+ const wasmInputAllocationBytes = decoder?.wasmInputAllocationBytes ?? 0;
383
+ const recoveryTemporaryBytes = decoder?.recoveryTemporaryBytes ?? 0;
384
+ const recoveryRouteStateCount = decoder?.recoveryRouteStateCount ?? 0;
385
+ const decoderControlledMemory = Math.max(currentWorkerMemory, wasmInputAllocationBytes, recoveryTemporaryBytes);
386
+ const temporalControlledState = this.candidates.size + this.repeats.size + Number(this.roi.active) + (this.trackingRuntime?.controlledSize ?? 0);
387
+ return {
388
+ ...this.counters,
389
+ cameraRecovery: this.cameraRecovery.getStatistics(),
390
+ averageDecodeMs: this.decodeLatencies.length ? this.decodeLatencies.reduce((a, b) => a + b, 0) / this.decodeLatencies.length : 0,
391
+ p50DecodeMs: p50,
392
+ p95DecodeMs: p95,
393
+ effectiveDecodeFps: scheduler.effectiveDecodeFps || (this.counters.admittedFrames * 1_000 / elapsed),
394
+ frameDropRate: this.counters.capturedFrames ? this.counters.droppedFrames / this.counters.capturedFrames : 0,
395
+ ...(this.firstDecodeAt === undefined ? {} : { timeToFirstDecodeMs: this.firstDecodeAt - this.startedAt }),
396
+ ...(this.firstConfirmedAt === undefined ? {} : { timeToFirstConfirmedScanMs: this.firstConfirmedAt - this.startedAt }),
397
+ currentWorkerMemory,
398
+ peakControlledMemory: Math.max(this.peakControlledMemory, decoder?.wasmPeakLinearMemoryBytes ?? 0, decoder?.recoveryPeakTemporaryBytes ?? 0),
399
+ activeDecodeCount: scheduler.active,
400
+ pendingFrameCount: scheduler.pending,
401
+ peakPendingFrameCount: scheduler.peakPending,
402
+ workerCreatedCount: decoder?.workerCreatedCount ?? 0,
403
+ workerTerminatedCount: decoder?.workerTerminatedCount ?? 0,
404
+ activeTaskCount: decoder?.activeTaskCount ?? 0,
405
+ peakActiveTaskCount: decoder?.peakActiveTaskCount ?? 0,
406
+ workerWasmDecodeCount: decoder?.workerWasmDecodeCount ?? 0,
407
+ wasmInputAllocationBytes,
408
+ wasmActiveNativeResultCount: decoder?.wasmActiveNativeResultCount ?? 0,
409
+ wasmPeakLinearMemoryBytes: decoder?.wasmPeakLinearMemoryBytes ?? 0,
410
+ wasmReleasedNativeResultCount: decoder?.wasmReleasedNativeResultCount ?? 0,
411
+ recoveryRunCount: decoder?.recoveryRunCount ?? 0,
412
+ recoveryTemporaryBytes,
413
+ recoveryPeakTemporaryBytes: decoder?.recoveryPeakTemporaryBytes ?? 0,
414
+ recoveryRouteStateCount,
415
+ finalControlledMemory: scheduler.active + scheduler.pending + decoderControlledMemory + temporalControlledState + recoveryRouteStateCount,
416
+ };
417
+ }
418
+ onResult(listener) { this.resultListeners.add(listener); return () => this.resultListeners.delete(listener); }
419
+ onObservations(listener) { this.observationSetListeners.add(listener); return () => this.observationSetListeners.delete(listener); }
420
+ onStateChange(listener) { this.stateListeners.add(listener); return () => this.stateListeners.delete(listener); }
421
+ onDiagnostics(listener) { this.diagnosticListeners.add(listener); return () => this.diagnosticListeners.delete(listener); }
422
+ getTracks() { return this.trackingRuntime?.getTracks() ?? []; }
423
+ getTrackingStatistics() { return this.trackingRuntime?.getStatistics(); }
424
+ getDeviceDiagnostics() { return this.source.getDeviceDiagnostics?.(); }
425
+ getCameraCapabilities() { return this.capabilityController?.getCapabilities() ?? { torch: false, focusMode: false }; }
426
+ setTorch(enabled) { return this.capabilityController?.setTorch(enabled) ?? Promise.resolve({ ok: false, error: sdkError("camera_capability_unsupported", "Torch is not available for this scanner source.") }); }
427
+ setZoom(value, manual = true) { return this.capabilityController?.setZoom(value, manual) ?? Promise.resolve({ ok: false, error: sdkError("camera_capability_unsupported", "Zoom is not available for this scanner source.") }); }
428
+ requestFocus() { return this.capabilityController?.requestFocus() ?? Promise.resolve({ ok: false, error: sdkError("camera_capability_unsupported", "Focus is not available for this scanner source.") }); }
429
+ async acceptFrame(frame, lifecycleGeneration) {
430
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
431
+ releaseFrame(frame);
432
+ return;
433
+ }
434
+ this.counters.capturedFrames += 1;
435
+ const release = once(() => { if (frame.ownership !== "borrowed")
436
+ frame.dispose?.(); });
437
+ const owned = { ...frame, dispose: release };
438
+ this.scheduler.submit(owned);
439
+ await this.scheduler.waitForIdle();
440
+ }
441
+ async processFrame(frame) {
442
+ const frameId = ++this.frameSequence;
443
+ const now = frame.timestampMs || Date.now();
444
+ const quality = this.quality.analyze(frame);
445
+ this.emitDiagnostic({ type: "frame-quality", timestamp: now, frameId, quality });
446
+ this.emitQualityHint(quality, frameId, now);
447
+ const periodicProbe = this.counters.admittedFrames % this.qualityProbeInterval === 0;
448
+ if (!quality.usable && !periodicProbe) {
449
+ this.counters.qualityRejectedFrames += 1;
450
+ this.roi.miss();
451
+ this.emitObservationSet({ frameId, timestamp: now, frameWidth: frame.width, frameHeight: frame.height, generation: this.generation, quality, decodeMode: this.decodeMode, observations: [] });
452
+ this.emitLost(now, frameId);
453
+ releaseFrame(frame);
454
+ return { quality, success: false };
455
+ }
456
+ if (periodicProbe)
457
+ this.counters.periodicProbeFrames += 1;
458
+ const trackingSelection = this.trackingRuntime?.selectDecode(frameId, frame);
459
+ const roi = trackingSelection ? trackingSelection.roi : this.roi.hint(frame, now);
460
+ const roiPhase = trackingSelection?.phase ?? (roi ? "temporal" : "full-frame");
461
+ const profile = this.trackingRuntime?.profile
462
+ ?? this.escalation.select(quality, trackingSelection ? trackingSelection.plan.trackedROIs.length > 0 : this.roi.active, now);
463
+ this.counters[`${profile}Attempts`] += 1;
464
+ const generation = this.generation;
465
+ const controller = new AbortController();
466
+ this.activeDecodeController = controller;
467
+ const started = Date.now();
468
+ let outcome;
469
+ try {
470
+ outcome = await this.decoder.decode(frame, {
471
+ profile,
472
+ quality,
473
+ mode: this.decodeMode,
474
+ ...(this.trackingRuntime ? { maxResults: this.trackingRuntime.maxResults } : {}),
475
+ roiPhase,
476
+ ...(roi ? { roi } : {}),
477
+ signal: controller.signal,
478
+ generation,
479
+ });
480
+ }
481
+ catch (error) {
482
+ outcome = { ok: false, error: sdkError("engine_execution_failure", error instanceof Error ? error.message : String(error), undefined, error), frameId: frame.id, scenarioId: profile, attemptCount: 0, timing: { totalMs: Date.now() - started } };
483
+ }
484
+ finally {
485
+ if (this.activeDecodeController === controller)
486
+ this.activeDecodeController = null;
487
+ }
488
+ const elapsed = Math.max(0, Date.now() - started);
489
+ this.decodeLatencies.push(elapsed);
490
+ if (this.decodeLatencies.length > 256)
491
+ this.decodeLatencies.shift();
492
+ this.currentWorkerMemory = outcome.timing.controlledMemory?.currentControlledBytes ?? 0;
493
+ this.peakControlledMemory = Math.max(this.peakControlledMemory, outcome.timing.controlledMemory?.peakControlledBytes ?? 0);
494
+ try {
495
+ if (generation !== this.generation) {
496
+ this.counters.staleResultsDiscarded += 1;
497
+ return { quality, decodeMs: elapsed, success: false };
498
+ }
499
+ if (outcome.ok) {
500
+ const recoveryDiagnostic = ScannerDiagnostics.fromResult(outcome.primary);
501
+ if (recoveryDiagnostic)
502
+ this.emitDiagnostic({ type: "recovery", timestamp: now, frameId, profile, decodeMs: elapsed, recovery: recoveryDiagnostic, detail: ScannerDiagnostics.explain(recoveryDiagnostic).join(" ") });
503
+ const observations = outcome.results.map((result) => {
504
+ const geometry = geometryFor(result, frame);
505
+ return { barcode: toDecodedBarcode(result), frameId, timestamp: now, ...(geometry ? { geometry } : {}) };
506
+ });
507
+ this.emitObservationSet({ frameId, timestamp: now, frameWidth: frame.width, frameHeight: frame.height, generation, quality, decodeMode: this.decodeMode, roiPhase, profile, decodeMs: elapsed, observations });
508
+ this.emitLost(now, frameId);
509
+ this.counters.decodeSuccesses += outcome.results.length;
510
+ if (this.firstDecodeAt === undefined)
511
+ this.firstDecodeAt = Date.now();
512
+ this.escalation.observe(true, now);
513
+ if (!this.trackingRuntime)
514
+ this.roi.update(outcome.primary, frame, now);
515
+ for (const result of outcome.results)
516
+ this.observeResult(result, frame, quality, now, frameId, generation);
517
+ }
518
+ else {
519
+ this.emitObservationSet({ frameId, timestamp: now, frameWidth: frame.width, frameHeight: frame.height, generation, quality, decodeMode: this.decodeMode, roiPhase, profile, decodeMs: elapsed, observations: [] });
520
+ this.escalation.observe(false, now);
521
+ if (!this.trackingRuntime)
522
+ this.roi.miss();
523
+ if (!isNoResult(outcome))
524
+ this.emitDiagnostic({ type: "decode", timestamp: now, frameId, profile, decodeMs: elapsed, error: outcome.error });
525
+ this.emitLost(now, frameId);
526
+ }
527
+ return { quality, decodeMs: elapsed, success: outcome.ok };
528
+ }
529
+ finally {
530
+ releaseFrame(frame);
531
+ }
532
+ }
533
+ observeResult(result, frame, quality, now, frameId, generation) {
534
+ if (!this.canPublishGeneration(generation)) {
535
+ this.counters.staleEvents += 1;
536
+ return;
537
+ }
538
+ const barcode = toDecodedBarcode(result);
539
+ const geometry = geometryFor(result, frame);
540
+ const detected = this.event("detected", barcode, frameId, now, 1, geometry);
541
+ this.emitDiagnostic({ type: "event", timestamp: now, frameId, event: detected });
542
+ const observation = this.candidates.observe(barcode, geometry, quality, now);
543
+ if (!observation.confirmed)
544
+ return;
545
+ // Capability control follows confirmed camera observations, even when the
546
+ // public repeat policy suppresses a duplicate event. That makes auto-zoom
547
+ // cooldown behavior observable without publishing duplicate scan results.
548
+ if (this.capabilityController && this.autoZoom && geometry) {
549
+ const beforeZoom = this.capabilityController.getCapabilities().zoom?.current;
550
+ const autoZoomGeneration = this.generation;
551
+ void this.capabilityController.considerAutoZoom(geometry, frame, now).then((result) => {
552
+ if (!result || autoZoomGeneration !== this.generation || !this.canPublishGeneration(autoZoomGeneration))
553
+ return;
554
+ const afterZoom = this.capabilityController?.getCapabilities().zoom?.current;
555
+ this.emitDiagnostic({
556
+ type: "camera",
557
+ timestamp: Date.now(),
558
+ frameId,
559
+ detail: `auto-zoom-result:${JSON.stringify({ result, triggeredAt: now, ...(beforeZoom === undefined ? {} : { beforeZoom }), ...(afterZoom === undefined ? {} : { afterZoom }) })}`,
560
+ });
561
+ }).catch((error) => this.emitDiagnostic({ type: "error", timestamp: Date.now(), frameId, error: cameraError(error, "Auto-zoom observation failed.") }));
562
+ }
563
+ if (observation.newlyConfirmed) {
564
+ this.counters.confirmedEvents += 1;
565
+ if (this.firstConfirmedAt === undefined)
566
+ this.firstConfirmedAt = Date.now();
567
+ const confirmed = this.event("confirmed", barcode, frameId, now, observation.candidate.observationCount, geometry);
568
+ this.emitDiagnostic({ type: "event", timestamp: now, frameId, event: confirmed });
569
+ }
570
+ const decision = this.repeats.evaluate(barcode, geometry, now);
571
+ if (!decision.emit) {
572
+ this.counters.suppressedRepeats += 1;
573
+ const suppressed = this.event("suppressed", barcode, frameId, now, observation.candidate.observationCount, geometry, decision.physicalInstanceId, decision.reason);
574
+ this.emitDiagnostic({ type: "event", timestamp: now, frameId, event: suppressed });
575
+ return;
576
+ }
577
+ const emitted = this.event("emitted", barcode, frameId, now, observation.candidate.observationCount, geometry, decision.physicalInstanceId);
578
+ if (!this.canPublishGeneration(generation)) {
579
+ this.counters.staleEvents += 1;
580
+ return;
581
+ }
582
+ this.counters.emittedEvents += 1;
583
+ for (const listener of this.resultListeners) {
584
+ try {
585
+ listener(emitted);
586
+ }
587
+ catch (error) {
588
+ this.emitDiagnostic({ type: "error", timestamp: now, frameId, error: sdkError("internal_invariant_failure", error instanceof Error ? error.message : String(error), undefined, error) });
589
+ }
590
+ }
591
+ this.emitDiagnostic({ type: "event", timestamp: now, frameId, event: emitted });
592
+ }
593
+ emitLost(now, frameId) {
594
+ for (const lost of this.candidates.lost(now)) {
595
+ this.counters.lostEvents += 1;
596
+ const event = this.event("lost", lost.barcode, frameId, now, lost.candidate.observationCount, lost.candidate.latestGeometry);
597
+ this.emitDiagnostic({ type: "event", timestamp: now, frameId, event });
598
+ }
599
+ }
600
+ event(type, barcode, frameId, timestamp, observationCount, geometry, physicalInstanceId, suppressionReason) {
601
+ return { id: `scan-event-${++this.eventSequence}`, type, barcode, frameId, timestamp, observationCount, ...(geometry ? { geometry } : {}), ...(physicalInstanceId ? { physicalInstanceId } : {}), ...(suppressionReason ? { suppressionReason } : {}) };
602
+ }
603
+ canPublishGeneration(generation) { return generation === this.generation && (this.state === "starting" || this.state === "scanning"); }
604
+ emitObservationSet(set) {
605
+ if (!this.canPublishGeneration(set.generation))
606
+ return;
607
+ this.trackingRuntime?.observe(set);
608
+ for (const listener of this.observationSetListeners) {
609
+ try {
610
+ listener(set);
611
+ }
612
+ catch (error) {
613
+ this.emitDiagnostic({ type: "error", timestamp: set.timestamp, frameId: set.frameId, error: sdkError("internal_invariant_failure", error instanceof Error ? error.message : String(error), undefined, error) });
614
+ }
615
+ }
616
+ }
617
+ emitQualityHint(quality, frameId, timestamp) {
618
+ const hint = quality.underexposed ? { type: "increase_light", confidence: 1 - quality.brightness } : quality.overexposed || quality.glareDominated ? { type: "reduce_glare", confidence: Math.max(quality.glareRatio, quality.brightness) } : quality.blurred ? { type: "hold_steady", confidence: 1 - quality.blurScore } : { type: "searching", confidence: quality.usable ? 0.45 : 0.8 };
619
+ this.emitDiagnostic({ type: "hint", timestamp, frameId, hint });
620
+ }
621
+ handleSourceEnded(lifecycleGeneration) {
622
+ queueMicrotask(() => {
623
+ if (lifecycleGeneration !== this.lifecycleGeneration || !["starting", "scanning", "paused"].includes(this.state))
624
+ return;
625
+ if (this.state === "starting" || !this.source.restart) {
626
+ void this.stop();
627
+ return;
628
+ }
629
+ this.counters.cameraTrackEndings += 1;
630
+ this.emitDiagnostic({ type: "error", timestamp: Date.now(), error: sdkError("camera_track_ended", "The active camera track ended.") });
631
+ void this.recoverCamera("track-ended", lifecycleGeneration);
632
+ });
633
+ }
634
+ handleSourceError(error, lifecycleGeneration) {
635
+ if (lifecycleGeneration !== this.lifecycleGeneration || this.state === "stopping" || this.state === "stopped")
636
+ return;
637
+ const typed = cameraError(error);
638
+ this.emitDiagnostic({ type: "error", timestamp: Date.now(), error: typed });
639
+ if (["camera_busy", "camera_constraint_failed", "camera_track_ended", "source_disconnected"].includes(typed.code)) {
640
+ void this.recoverCamera(typed.code, lifecycleGeneration);
641
+ return;
642
+ }
643
+ this.failCamera(typed, lifecycleGeneration);
644
+ }
645
+ handleCameraLifecycle(event, lifecycleGeneration) {
646
+ if (lifecycleGeneration !== this.lifecycleGeneration || ["stopping", "stopped", "failed"].includes(this.state))
647
+ return;
648
+ this.emitDiagnostic({ type: "camera", timestamp: event.timestamp, cameraLifecycle: event, deviceDiagnostics: this.source.getDeviceDiagnostics?.(), detail: event.detail });
649
+ if (event.reason === "background-suspended") {
650
+ this.emitDiagnostic({ type: "error", timestamp: event.timestamp, error: sdkError("browser_background_suspended", "Browser visibility changed to hidden; camera publication is suspended until foreground recovery.") });
651
+ if (this.state === "scanning" || this.state === "starting") {
652
+ this.invalidateCameraGeneration(event.reason);
653
+ this.source.pause?.();
654
+ this.scheduler.pause();
655
+ this.backgroundPaused = true;
656
+ this.setState("paused");
657
+ }
658
+ return;
659
+ }
660
+ if (event.reason === "foreground-resumed") {
661
+ if (!this.backgroundPaused)
662
+ return;
663
+ this.invalidateCameraGeneration(event.reason);
664
+ this.backgroundPaused = false;
665
+ const track = this.source.currentTrack?.();
666
+ if (!track || track.readyState === "ended") {
667
+ void this.recoverCamera("foreground-track-not-live", lifecycleGeneration);
668
+ return;
669
+ }
670
+ this.source.resume?.();
671
+ this.scheduler.resume();
672
+ this.setState("scanning");
673
+ return;
674
+ }
675
+ this.invalidateCameraGeneration(event.reason);
676
+ }
677
+ invalidateCameraGeneration(reason) {
678
+ this.generation += 1;
679
+ this.counters.cameraGenerationInvalidations += 1;
680
+ this.activeDecodeController?.abort();
681
+ this.decoder.cancel();
682
+ this.candidates.reset();
683
+ this.repeats.reset();
684
+ this.roi.reset();
685
+ this.trackingRuntime?.reset();
686
+ this.escalation.reset();
687
+ this.quality.reset();
688
+ this.emitDiagnostic({ type: "camera", timestamp: Date.now(), detail: `scanner generation invalidated: ${reason}` });
689
+ }
690
+ async recoverCamera(reason, lifecycleGeneration) {
691
+ if (this.recoveryPromise)
692
+ return this.recoveryPromise;
693
+ const recovery = (async () => {
694
+ const decision = this.cameraRecovery.next(reason);
695
+ this.invalidateCameraGeneration(`recovery:${reason}`);
696
+ this.source.pause?.();
697
+ this.scheduler.pause();
698
+ this.emitDiagnostic({ type: "camera", timestamp: Date.now(), detail: decision.retry ? `camera recovery ${decision.attempt} scheduled after ${decision.delayMs}ms` : "camera recovery budget exhausted" });
699
+ if (!decision.retry || !this.source.restart) {
700
+ this.cameraRecovery.markFailed();
701
+ this.failCamera(sdkError("camera_recovery_failed", "Camera recovery budget was exhausted or the source cannot restart.", { reason, attempts: decision.attempt }), lifecycleGeneration);
702
+ return;
703
+ }
704
+ await this.cameraRecovery.wait(decision);
705
+ if (lifecycleGeneration !== this.lifecycleGeneration || ["stopping", "stopped"].includes(this.state))
706
+ return;
707
+ try {
708
+ await this.source.restart();
709
+ if (lifecycleGeneration !== this.lifecycleGeneration || ["stopping", "stopped"].includes(this.state))
710
+ return;
711
+ this.cameraRecovery.markRestarted();
712
+ this.backgroundPaused = false;
713
+ this.scheduler.resume();
714
+ this.setState("scanning");
715
+ this.emitDiagnostic({ type: "camera", timestamp: Date.now(), deviceDiagnostics: this.source.getDeviceDiagnostics?.(), detail: `camera recovery ${decision.attempt} completed` });
716
+ }
717
+ catch (error) {
718
+ this.cameraRecovery.markFailed();
719
+ const typed = cameraError(error);
720
+ this.emitDiagnostic({ type: "error", timestamp: Date.now(), error: typed });
721
+ if (typed.retryable) {
722
+ this.recoveryPromise = null;
723
+ await this.recoverCamera(reason, lifecycleGeneration);
724
+ }
725
+ else
726
+ this.failCamera(typed, lifecycleGeneration);
727
+ }
728
+ })();
729
+ this.recoveryPromise = recovery;
730
+ try {
731
+ await recovery;
732
+ }
733
+ finally {
734
+ if (this.recoveryPromise === recovery)
735
+ this.recoveryPromise = null;
736
+ }
737
+ }
738
+ failCamera(error, lifecycleGeneration) {
739
+ if (lifecycleGeneration !== this.lifecycleGeneration)
740
+ return;
741
+ this.lifecycleGeneration += 1;
742
+ this.generation += 1;
743
+ this.backgroundPaused = false;
744
+ this.setState("failed");
745
+ this.activeDecodeController?.abort();
746
+ this.decoder.cancel();
747
+ void this.scheduler.stop();
748
+ void this.source.stop();
749
+ this.emitDiagnostic({ type: "error", timestamp: Date.now(), error });
750
+ }
751
+ setState(state) { if (this.state === state)
752
+ return; this.state = state; for (const listener of this.stateListeners) {
753
+ try {
754
+ listener(state);
755
+ }
756
+ catch { /* listener failures do not own session state */ }
757
+ } }
758
+ emitDiagnostic(diagnostic) { for (const listener of this.diagnosticListeners) {
759
+ try {
760
+ listener(diagnostic);
761
+ }
762
+ catch { /* diagnostics are observational */ }
763
+ } }
764
+ }
765
+ function releaseFrame(frame) { frame.dispose?.(); }
766
+ //# sourceMappingURL=scanner-session.js.map