@chipmobilesdk/rn-camera 0.1.1 → 0.2.1

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.
@@ -3,7 +3,6 @@ name: sdk-rn-camera
3
3
  description: "Camera capture via @chipmobilesdk/rn-camera: controller, VisionCamera engine, permissions, artifact storage, hardware-free testing. Use when adding capture/preview, handling camera permissions, storing captures, or writing camera tests."
4
4
  metadata:
5
5
  package: "@chipmobilesdk/rn-camera"
6
- packageVersion: "0.1.0"
7
6
  user-invocable: true
8
7
  disable-model-invocation: false
9
8
  ---
@@ -25,13 +24,17 @@ artifact storage for captures, and a test double that needs no hardware.
25
24
 
26
25
  The camera is a hardware resource. It MUST be released when the screen loses focus or
27
26
  unmounts — a retained camera drains battery and blocks other screens from acquiring it.
28
- Verify teardown explicitly; this is the most common defect in camera code.
27
+ Verify teardown explicitly; this is the most common defect in camera code. Prefer
28
+ `useCameraController`: it disposes on unmount and survives StrictMode remounts. A
29
+ disposed controller rejects `start`/`capture` with `notReady`.
29
30
 
30
31
  ## Captures expire
31
32
 
32
33
  `DEFAULT_TEMPORARY_TTL_MS` exists because cache-stored captures are temporary. If a
33
- capture must survive, move it out of the cache store deliberately — do not assume
34
- persistence. See `## Data behaviour` for what this means for App Privacy disclosures.
34
+ capture must survive, pass a `destination` (app-owned) or move it out deliberately —
35
+ do not assume persistence. Give the controller a `store` whose `FileSystemBridge`
36
+ implements `move()`, or VisionCamera captures stay outside it and `cleanup()` cannot
37
+ delete them. See `## Data behaviour` for what this means for App Privacy disclosures.
35
38
 
36
39
  ## Test without hardware
37
40
 
@@ -39,7 +42,7 @@ persistence. See `## Data behaviour` for what this means for App Privacy disclos
39
42
  flows be unit-tested with no device. Use these rather than mocking VisionCamera by hand —
40
43
  see `## Testing without hardware`.
41
44
 
42
- ## Public API (v0.1.0)
45
+ ## Public API
43
46
 
44
47
  Runtime:
45
48
 
@@ -1,149 +1,143 @@
1
- /**
2
- * Public type contract for @chipmobilesdk/rn-camera.
3
- *
4
- * Every type here is re-exported from `src/index.ts` (LIB-002/LIB-003). See
5
- * `specs/006-rn-camera-package/contracts/public-api.md` for the authoritative
6
- * contract and `data-model.md` for the rules behind each field.
7
- */
8
-
9
- /** Camera authorization status. Seven distinguishable outcomes (FR-004). */
10
- export type CameraPermissionState =
11
- | 'undetermined'
12
- | 'requesting'
13
- | 'granted'
14
- | 'denied'
15
- | 'deniedPermanently'
16
- | 'unavailable'
17
- | 'unsupported';
18
-
19
- export type CameraPosition = 'front' | 'back';
20
- export type FlashMode = 'auto' | 'on' | 'off';
21
- export type ResizeMode = 'cover' | 'contain';
22
- export type ArtifactOwnership = 'packageTemporary' | 'appOwned';
23
-
24
- export type SessionStatus =
25
- | 'idle'
26
- | 'starting'
27
- | 'active'
28
- | 'paused'
29
- | 'stopping'
30
- | 'stopped'
31
- | 'error';
32
-
33
- /**
34
- * A metadata field the platform could not determine (FR-022). Distinct from an
35
- * absent property so consumers can tell "not reported" from "not asked for".
36
- */
37
- export interface Unavailable {
38
- readonly available: false;
39
- }
40
-
41
- export interface Known<T> {
42
- readonly available: true;
43
- readonly value: T;
44
- }
45
-
46
- export type Maybe<T> = Known<T> | Unavailable;
47
-
48
- export function known<T>(value: T): Known<T> {
49
- return { available: true, value };
50
- }
51
-
52
- export function unavailable(): Unavailable {
53
- return { available: false };
54
- }
55
-
56
- /** What this device and platform actually support (FR-033). */
57
- export interface CapabilityProfile {
58
- readonly availablePositions: readonly CameraPosition[];
59
- readonly hasFlash: boolean;
60
- readonly hasTorch: boolean;
61
- readonly supportsOrientationChange: boolean;
62
- readonly hasCameraHardware: boolean;
63
- }
64
-
65
- /** Instruction to take one still photo. Carries no business context (FR-002). */
66
- export interface CaptureRequest {
67
- /** 0..1, clamped to what the device supports. */
68
- readonly quality?: number;
69
- /** Longest edge in pixels, applied within device limits. */
70
- readonly maxDimension?: number;
71
- readonly cameraPosition?: CameraPosition;
72
- readonly flashMode?: FlashMode;
73
- /**
74
- * Supplying a destination makes the result `appOwned` and the package writes
75
- * directly there. Omitting it makes the result `packageTemporary` (FR-026a).
76
- * This is the only way ownership is decided.
77
- */
78
- readonly destination?: string;
79
- }
80
-
81
- /** Result of one successful capture. Always a file reference (FR-021/FR-021a). */
82
- export interface CapturedImage {
83
- /** File URI or handle. Never a base64 string, never an in-memory buffer. */
84
- readonly uri: string;
85
- readonly mimeType: string;
86
- readonly width: number;
87
- readonly height: number;
88
- readonly timestamp: number;
89
- readonly cameraPosition: CameraPosition;
90
- readonly ownership: ArtifactOwnership;
91
- readonly effectiveQuality: number;
92
- readonly effectiveMaxDimension: Maybe<number>;
93
- readonly fileSize: Maybe<number>;
94
- readonly orientation: Maybe<number>;
95
- }
96
-
97
- /** Options accepted when starting a session. */
98
- export interface StartOptions {
99
- readonly cameraPosition?: CameraPosition;
100
- }
101
-
102
- /**
103
- * Relationship between captured-image space and on-screen preview space.
104
- * Consumed by the pure mapping functions in `geometry/coordinateMapping`.
105
- */
106
- export interface PreviewGeometry {
107
- readonly imageWidth: number;
108
- readonly imageHeight: number;
109
- readonly previewWidth: number;
110
- readonly previewHeight: number;
111
- readonly resizeMode: ResizeMode;
112
- /** Clockwise degrees: 0, 90, 180, or 270. */
113
- readonly orientation: number;
114
- readonly isMirrored: boolean;
115
- }
116
-
117
- export interface Point {
118
- readonly x: number;
119
- readonly y: number;
120
- }
121
-
122
- export interface Rect {
123
- readonly x: number;
124
- readonly y: number;
125
- readonly width: number;
126
- readonly height: number;
127
- }
128
-
129
- /** Aggregate observable state exposed by the controller. */
130
- export interface CameraState {
131
- readonly session: SessionStatus;
132
- readonly permission: CameraPermissionState;
133
- readonly capabilities: CapabilityProfile | null;
134
- readonly cameraPosition: CameraPosition;
135
- readonly flashMode: FlashMode;
136
- readonly torchEnabled: boolean;
137
- readonly capturing: boolean;
138
- readonly error: CameraErrorLike | null;
139
- }
140
-
141
- /**
142
- * Structural view of a CameraError, kept here so `CameraState` does not create
143
- * a circular import with the diagnostics module.
144
- */
145
- export interface CameraErrorLike {
146
- readonly code: string;
147
- readonly recoverable: boolean;
148
- readonly message: string;
149
- }
1
+ /**
2
+ * Public type contract for @chipmobilesdk/rn-camera.
3
+ *
4
+ * Every type here is re-exported from `src/index.ts` (LIB-002/LIB-003). See
5
+ * `specs/006-rn-camera-package/contracts/public-api.md` for the authoritative
6
+ * contract and `data-model.md` for the rules behind each field.
7
+ */
8
+
9
+ /** Camera authorization status. Seven distinguishable outcomes (FR-004). */
10
+ export type CameraPermissionState =
11
+ | 'undetermined'
12
+ | 'requesting'
13
+ | 'granted'
14
+ | 'denied'
15
+ | 'deniedPermanently'
16
+ | 'unavailable'
17
+ | 'unsupported';
18
+
19
+ export type CameraPosition = 'front' | 'back';
20
+ export type FlashMode = 'auto' | 'on' | 'off';
21
+ export type ResizeMode = 'cover' | 'contain';
22
+ export type ArtifactOwnership = 'packageTemporary' | 'appOwned';
23
+
24
+ export type SessionStatus =
25
+ 'idle' | 'starting' | 'active' | 'paused' | 'stopping' | 'stopped' | 'error';
26
+
27
+ /**
28
+ * A metadata field the platform could not determine (FR-022). Distinct from an
29
+ * absent property so consumers can tell "not reported" from "not asked for".
30
+ */
31
+ export interface Unavailable {
32
+ readonly available: false;
33
+ }
34
+
35
+ export interface Known<T> {
36
+ readonly available: true;
37
+ readonly value: T;
38
+ }
39
+
40
+ export type Maybe<T> = Known<T> | Unavailable;
41
+
42
+ export function known<T>(value: T): Known<T> {
43
+ return { available: true, value };
44
+ }
45
+
46
+ export function unavailable(): Unavailable {
47
+ return { available: false };
48
+ }
49
+
50
+ /** What this device and platform actually support (FR-033). */
51
+ export interface CapabilityProfile {
52
+ readonly availablePositions: readonly CameraPosition[];
53
+ readonly hasFlash: boolean;
54
+ readonly hasTorch: boolean;
55
+ readonly supportsOrientationChange: boolean;
56
+ readonly hasCameraHardware: boolean;
57
+ }
58
+
59
+ /** Instruction to take one still photo. Carries no business context (FR-002). */
60
+ export interface CaptureRequest {
61
+ /** 0..1, clamped to what the device supports. */
62
+ readonly quality?: number;
63
+ /** Longest edge in pixels, applied within device limits. */
64
+ readonly maxDimension?: number;
65
+ readonly cameraPosition?: CameraPosition;
66
+ readonly flashMode?: FlashMode;
67
+ /**
68
+ * Supplying a destination makes the result `appOwned` and the package writes
69
+ * directly there. Omitting it makes the result `packageTemporary` (FR-026a).
70
+ * This is the only way ownership is decided.
71
+ */
72
+ readonly destination?: string;
73
+ }
74
+
75
+ /** Result of one successful capture. Always a file reference (FR-021/FR-021a). */
76
+ export interface CapturedImage {
77
+ /** File URI or handle. Never a base64 string, never an in-memory buffer. */
78
+ readonly uri: string;
79
+ readonly mimeType: string;
80
+ readonly width: number;
81
+ readonly height: number;
82
+ readonly timestamp: number;
83
+ readonly cameraPosition: CameraPosition;
84
+ readonly ownership: ArtifactOwnership;
85
+ readonly effectiveQuality: number;
86
+ readonly effectiveMaxDimension: Maybe<number>;
87
+ readonly fileSize: Maybe<number>;
88
+ readonly orientation: Maybe<number>;
89
+ }
90
+
91
+ /** Options accepted when starting a session. */
92
+ export interface StartOptions {
93
+ readonly cameraPosition?: CameraPosition;
94
+ }
95
+
96
+ /**
97
+ * Relationship between captured-image space and on-screen preview space.
98
+ * Consumed by the pure mapping functions in `geometry/coordinateMapping`.
99
+ */
100
+ export interface PreviewGeometry {
101
+ readonly imageWidth: number;
102
+ readonly imageHeight: number;
103
+ readonly previewWidth: number;
104
+ readonly previewHeight: number;
105
+ readonly resizeMode: ResizeMode;
106
+ /** Clockwise degrees: 0, 90, 180, or 270. */
107
+ readonly orientation: number;
108
+ readonly isMirrored: boolean;
109
+ }
110
+
111
+ export interface Point {
112
+ readonly x: number;
113
+ readonly y: number;
114
+ }
115
+
116
+ export interface Rect {
117
+ readonly x: number;
118
+ readonly y: number;
119
+ readonly width: number;
120
+ readonly height: number;
121
+ }
122
+
123
+ /** Aggregate observable state exposed by the controller. */
124
+ export interface CameraState {
125
+ readonly session: SessionStatus;
126
+ readonly permission: CameraPermissionState;
127
+ readonly capabilities: CapabilityProfile | null;
128
+ readonly cameraPosition: CameraPosition;
129
+ readonly flashMode: FlashMode;
130
+ readonly torchEnabled: boolean;
131
+ readonly capturing: boolean;
132
+ readonly error: CameraErrorLike | null;
133
+ }
134
+
135
+ /**
136
+ * Structural view of a CameraError, kept here so `CameraState` does not create
137
+ * a circular import with the diagnostics module.
138
+ */
139
+ export interface CameraErrorLike {
140
+ readonly code: string;
141
+ readonly recoverable: boolean;
142
+ readonly message: string;
143
+ }
@@ -1,44 +1,41 @@
1
- /**
2
- * Capability gating (FR-033, FR-034).
3
- *
4
- * An unsupported capability must produce an `unsupportedCapability` result, not
5
- * an unexpected failure — that is what lets an app hide a control instead of
6
- * discovering at tap time that the device has no flash.
7
- */
8
-
9
- import type { CameraPosition, CapabilityProfile, FlashMode } from '../config/types';
10
- import { CameraError } from '../diagnostics/errors';
11
-
12
- export const EMPTY_CAPABILITIES: CapabilityProfile = {
13
- availablePositions: [],
14
- hasFlash: false,
15
- hasTorch: false,
16
- supportsOrientationChange: false,
17
- hasCameraHardware: false,
18
- };
19
-
20
- export function supportsPosition(
21
- profile: CapabilityProfile | null,
22
- position: CameraPosition,
23
- ): boolean {
24
- return profile?.availablePositions.includes(position) ?? false;
25
- }
26
-
27
- /** `off` is always legal — it is the absence of flash, not a use of it. */
28
- export function supportsFlashMode(
29
- profile: CapabilityProfile | null,
30
- mode: FlashMode,
31
- ): boolean {
32
- return mode === 'off' ? true : profile?.hasFlash === true;
33
- }
34
-
35
- export function supportsTorch(profile: CapabilityProfile | null): boolean {
36
- return profile?.hasTorch === true;
37
- }
38
-
39
- /** Throws the uniform unsupported error so every gate reads the same way. */
40
- export function assertSupported(supported: boolean, what: string): void {
41
- if (!supported) {
42
- throw new CameraError('unsupportedCapability', `Not supported on this device: ${what}`);
43
- }
44
- }
1
+ /**
2
+ * Capability gating (FR-033, FR-034).
3
+ *
4
+ * An unsupported capability must produce an `unsupportedCapability` result, not
5
+ * an unexpected failure — that is what lets an app hide a control instead of
6
+ * discovering at tap time that the device has no flash.
7
+ */
8
+
9
+ import type { CameraPosition, CapabilityProfile, FlashMode } from '../config/types';
10
+ import { CameraError } from '../diagnostics/errors';
11
+
12
+ export const EMPTY_CAPABILITIES: CapabilityProfile = {
13
+ availablePositions: [],
14
+ hasFlash: false,
15
+ hasTorch: false,
16
+ supportsOrientationChange: false,
17
+ hasCameraHardware: false,
18
+ };
19
+
20
+ export function supportsPosition(
21
+ profile: CapabilityProfile | null,
22
+ position: CameraPosition,
23
+ ): boolean {
24
+ return profile?.availablePositions.includes(position) ?? false;
25
+ }
26
+
27
+ /** `off` is always legal — it is the absence of flash, not a use of it. */
28
+ export function supportsFlashMode(profile: CapabilityProfile | null, mode: FlashMode): boolean {
29
+ return mode === 'off' ? true : profile?.hasFlash === true;
30
+ }
31
+
32
+ export function supportsTorch(profile: CapabilityProfile | null): boolean {
33
+ return profile?.hasTorch === true;
34
+ }
35
+
36
+ /** Throws the uniform unsupported error so every gate reads the same way. */
37
+ export function assertSupported(supported: boolean, what: string): void {
38
+ if (!supported) {
39
+ throw new CameraError('unsupportedCapability', `Not supported on this device: ${what}`);
40
+ }
41
+ }
@@ -1,118 +1,120 @@
1
- /**
2
- * Capture guards, single-flight enforcement, and metadata assembly
3
- * (FR-020..FR-026b).
4
- *
5
- * The single-flight guard rejects rather than queues or coalesces. A queue
6
- * turns a double-tapped shutter into two photos the user never asked for, and
7
- * coalescing hides a deliberate second capture behind an accidental one — so a
8
- * fast, explicit `captureInProgress` error is the only option that lets the app
9
- * decide (clarification session 2026-07-26).
10
- */
11
-
12
- import {
13
- known,
14
- unavailable,
15
- type CameraPosition,
16
- type CapabilityProfile,
17
- type CapturedImage,
18
- type CaptureRequest,
19
- type FlashMode,
20
- type Maybe,
21
- type SessionStatus,
22
- } from '../config/types';
23
- import { CameraError } from '../diagnostics/errors';
24
- import type { CameraEngine } from '../native/cameraEngine';
25
- import { assertSupported, supportsFlashMode, supportsPosition } from './capabilities';
26
- import { canCapture } from './sessionMachine';
27
-
28
- export const DEFAULT_QUALITY = 0.9;
29
-
30
- export function clampQuality(quality: number | undefined): number {
31
- if (quality === undefined || Number.isNaN(quality)) {
32
- return DEFAULT_QUALITY;
33
- }
34
- return Math.min(1, Math.max(0, quality));
35
- }
36
-
37
- export function clampMaxDimension(maxDimension: number | undefined): Maybe<number> {
38
- if (maxDimension === undefined || !Number.isFinite(maxDimension) || maxDimension <= 0) {
39
- return unavailable();
40
- }
41
- return known(Math.floor(maxDimension));
42
- }
43
-
44
- export interface CaptureContext {
45
- readonly engine: CameraEngine;
46
- readonly sessionStatus: SessionStatus;
47
- readonly permissionGranted: boolean;
48
- readonly capabilities: CapabilityProfile | null;
49
- readonly cameraPosition: CameraPosition;
50
- readonly flashMode: FlashMode;
51
- }
52
-
53
- /**
54
- * Validates a capture request against permission, readiness, and capability.
55
- * Throws before any artifact could be created, so a rejected capture never
56
- * leaves a file behind (FR-020).
57
- */
58
- export function assertCaptureAllowed(context: CaptureContext, request: CaptureRequest): void {
59
- if (!context.permissionGranted) {
60
- throw new CameraError('permissionDenied', 'Camera permission is not granted');
61
- }
62
- if (!canCapture(context.sessionStatus)) {
63
- throw new CameraError('notReady', `Camera is not ready (session: ${context.sessionStatus})`);
64
- }
65
- const flashMode = request.flashMode ?? context.flashMode;
66
- assertSupported(supportsFlashMode(context.capabilities, flashMode), `flash mode "${flashMode}"`);
67
-
68
- const position = request.cameraPosition ?? context.cameraPosition;
69
- assertSupported(supportsPosition(context.capabilities, position), `${position} camera`);
70
- }
71
-
72
- /**
73
- * Normalizes whatever the engine returned into the published contract, filling
74
- * in effective hint values and marking undeterminable fields explicitly
75
- * unavailable rather than guessing them (FR-022, FR-023).
76
- */
77
- export function finalizeResult(
78
- raw: CapturedImage,
79
- request: CaptureRequest,
80
- context: CaptureContext,
81
- ): CapturedImage {
82
- const ownership = request.destination ? 'appOwned' : 'packageTemporary';
83
- return {
84
- ...raw,
85
- ownership,
86
- cameraPosition: raw.cameraPosition ?? request.cameraPosition ?? context.cameraPosition,
87
- timestamp: raw.timestamp ?? Date.now(),
88
- effectiveQuality: raw.effectiveQuality ?? clampQuality(request.quality),
89
- effectiveMaxDimension: raw.effectiveMaxDimension ?? clampMaxDimension(request.maxDimension),
90
- fileSize: raw.fileSize ?? unavailable(),
91
- orientation: raw.orientation ?? unavailable(),
92
- };
93
- }
94
-
95
- /**
96
- * Serializes captures. Holding the in-flight promise rather than a boolean
97
- * means the guard cannot be defeated by two synchronous calls in the same tick.
98
- */
99
- export class SingleFlight {
100
- private inFlight: Promise<CapturedImage> | null = null;
101
-
102
- get busy(): boolean {
103
- return this.inFlight !== null;
104
- }
105
-
106
- async run(operation: () => Promise<CapturedImage>): Promise<CapturedImage> {
107
- if (this.inFlight) {
108
- throw new CameraError('captureInProgress', 'A capture is already in progress');
109
- }
110
- const promise = operation();
111
- this.inFlight = promise;
112
- try {
113
- return await promise;
114
- } finally {
115
- this.inFlight = null;
116
- }
117
- }
118
- }
1
+ /**
2
+ * Capture guards, single-flight enforcement, and metadata assembly
3
+ * (FR-020..FR-026b).
4
+ *
5
+ * The single-flight guard rejects rather than queues or coalesces. A queue
6
+ * turns a double-tapped shutter into two photos the user never asked for, and
7
+ * coalescing hides a deliberate second capture behind an accidental one — so a
8
+ * fast, explicit `captureInProgress` error is the only option that lets the app
9
+ * decide (clarification session 2026-07-26).
10
+ */
11
+
12
+ import {
13
+ known,
14
+ unavailable,
15
+ type CameraPosition,
16
+ type CapabilityProfile,
17
+ type CapturedImage,
18
+ type CaptureRequest,
19
+ type FlashMode,
20
+ type Maybe,
21
+ type SessionStatus,
22
+ } from '../config/types';
23
+ import { CameraError } from '../diagnostics/errors';
24
+ import type { CameraEngine } from '../native/cameraEngine';
25
+ import { assertSupported, supportsFlashMode, supportsPosition } from './capabilities';
26
+ import { canCapture } from './sessionMachine';
27
+
28
+ export const DEFAULT_QUALITY = 0.9;
29
+
30
+ export function clampQuality(quality: number | undefined): number {
31
+ if (quality === undefined || Number.isNaN(quality)) {
32
+ return DEFAULT_QUALITY;
33
+ }
34
+ return Math.min(1, Math.max(0, quality));
35
+ }
36
+
37
+ export function clampMaxDimension(maxDimension: number | undefined): Maybe<number> {
38
+ if (maxDimension === undefined || !Number.isFinite(maxDimension) || maxDimension <= 0) {
39
+ return unavailable();
40
+ }
41
+ return known(Math.floor(maxDimension));
42
+ }
43
+
44
+ export interface CaptureContext {
45
+ readonly engine: CameraEngine;
46
+ readonly sessionStatus: SessionStatus;
47
+ readonly permissionGranted: boolean;
48
+ readonly capabilities: CapabilityProfile | null;
49
+ readonly cameraPosition: CameraPosition;
50
+ readonly flashMode: FlashMode;
51
+ }
52
+
53
+ /**
54
+ * Validates a capture request against permission, readiness, and capability.
55
+ * Throws before any artifact could be created, so a rejected capture never
56
+ * leaves a file behind (FR-020).
57
+ */
58
+ export function assertCaptureAllowed(context: CaptureContext, request: CaptureRequest): void {
59
+ if (!context.permissionGranted) {
60
+ throw new CameraError('permissionDenied', 'Camera permission is not granted');
61
+ }
62
+ if (!canCapture(context.sessionStatus)) {
63
+ throw new CameraError('notReady', `Camera is not ready (session: ${context.sessionStatus})`);
64
+ }
65
+ const flashMode = request.flashMode ?? context.flashMode;
66
+ assertSupported(supportsFlashMode(context.capabilities, flashMode), `flash mode "${flashMode}"`);
67
+
68
+ const position = request.cameraPosition ?? context.cameraPosition;
69
+ assertSupported(supportsPosition(context.capabilities, position), `${position} camera`);
70
+ }
71
+
72
+ /**
73
+ * Normalizes whatever the engine returned into the published contract, filling
74
+ * in effective hint values and marking undeterminable fields explicitly
75
+ * unavailable rather than guessing them (FR-022, FR-023).
76
+ */
77
+ export function finalizeResult(
78
+ raw: CapturedImage,
79
+ request: CaptureRequest,
80
+ context: CaptureContext,
81
+ ): CapturedImage {
82
+ const ownership = request.destination ? 'appOwned' : 'packageTemporary';
83
+ return {
84
+ ...raw,
85
+ ownership,
86
+ cameraPosition: raw.cameraPosition ?? request.cameraPosition ?? context.cameraPosition,
87
+ timestamp: raw.timestamp ?? Date.now(),
88
+ effectiveQuality: raw.effectiveQuality ?? clampQuality(request.quality),
89
+ effectiveMaxDimension: raw.effectiveMaxDimension ?? clampMaxDimension(request.maxDimension),
90
+ fileSize: raw.fileSize ?? unavailable(),
91
+ orientation: raw.orientation ?? unavailable(),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Serializes captures. The slot is taken synchronously, before the operation
97
+ * runs, so two calls in the same tick cannot both pass the check — and the
98
+ * operation's own first `emit()` already sees `busy`. Holding the operation's
99
+ * promise instead set the slot only after that first emit, so `capturing: true`
100
+ * was never published at all.
101
+ */
102
+ export class SingleFlight {
103
+ private active = false;
104
+
105
+ get busy(): boolean {
106
+ return this.active;
107
+ }
108
+
109
+ async run(operation: () => Promise<CapturedImage>): Promise<CapturedImage> {
110
+ if (this.active) {
111
+ throw new CameraError('captureInProgress', 'A capture is already in progress');
112
+ }
113
+ this.active = true;
114
+ try {
115
+ return await operation();
116
+ } finally {
117
+ this.active = false;
118
+ }
119
+ }
120
+ }