@elizaos/capacitor-camera 1.0.0 → 2.0.3-beta.2

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shaw Walters and elizaOS Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @elizaos/capacitor-camera
2
+
3
+ Capacitor plugin for camera preview, photo capture, and video recording. Works across web (via the `MediaDevices` API), iOS (via AVFoundation), and Android (via Camera2).
4
+
5
+ ## What it does
6
+
7
+ - **Live preview** — stream a camera feed into any DOM element with configurable resolution, frame rate, and mirror mode.
8
+ - **Photo capture** — snapshot a JPEG, PNG, or WebP image as base64 from the active preview, with optional resize and quality control.
9
+ - **Video recording** — record video with optional audio, configurable bitrate, and automatic stop on max duration or file size.
10
+ - **Camera control** — zoom, focus point, exposure point, flash/torch mode, white balance.
11
+ - **Device enumeration** — list available cameras with capabilities (direction, zoom range, resolutions, frame rates).
12
+ - **Permissions** — check and request camera + microphone permissions.
13
+ - **Events** — subscribe to `frame`, `error`, and `recordingState` events.
14
+
15
+ ## Installation
16
+
17
+ This is a Capacitor plugin. Add it to a Capacitor project:
18
+
19
+ ```bash
20
+ npm install @elizaos/capacitor-camera
21
+ npx cap sync
22
+ ```
23
+
24
+ ### iOS
25
+
26
+ The plugin uses `AVFoundation`, `Photos`, and `UIKit`. Add the following keys to `Info.plist`:
27
+
28
+ ```xml
29
+ <key>NSCameraUsageDescription</key>
30
+ <string>Camera access is required for photo and video capture.</string>
31
+ <key>NSMicrophoneUsageDescription</key>
32
+ <string>Microphone access is required for video recording with audio.</string>
33
+ <key>NSPhotoLibraryAddUsageDescription</key>
34
+ <string>Photo library access is required to save captured media.</string>
35
+ ```
36
+
37
+ Minimum deployment target: iOS 15.0.
38
+
39
+ ### Android
40
+
41
+ Add the following permissions to `AndroidManifest.xml`:
42
+
43
+ ```xml
44
+ <uses-permission android:name="android.permission.CAMERA" />
45
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
46
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```typescript
52
+ import { Camera } from "@elizaos/capacitor-camera";
53
+
54
+ // Check and request permissions
55
+ const status = await Camera.checkPermissions();
56
+ if (status.camera !== "granted") {
57
+ await Camera.requestPermissions();
58
+ }
59
+
60
+ // List available cameras
61
+ const { devices } = await Camera.getDevices();
62
+
63
+ // Start a preview in a DOM element
64
+ const container = document.getElementById("camera-container");
65
+ const result = await Camera.startPreview({
66
+ element: container,
67
+ direction: "back",
68
+ resolution: { width: 1920, height: 1080 },
69
+ frameRate: 30,
70
+ });
71
+
72
+ // Capture a photo
73
+ const photo = await Camera.capturePhoto({ quality: 90, format: "jpeg" });
74
+ // photo.base64 contains the image data
75
+
76
+ // Record video
77
+ await Camera.startRecording({ audio: true, quality: "high", maxDuration: 60 });
78
+ // ... later ...
79
+ const video = await Camera.stopRecording();
80
+ // video.path is a blob: URL on web, or a file path on native
81
+
82
+ // Stop preview and release camera
83
+ await Camera.stopPreview();
84
+ ```
85
+
86
+ ### Camera settings
87
+
88
+ ```typescript
89
+ // Zoom (1.0 = no zoom)
90
+ await Camera.setZoom({ zoom: 2.0 });
91
+
92
+ // Manual focus / exposure (normalized 0–1 coordinates)
93
+ await Camera.setFocusPoint({ x: 0.5, y: 0.5 });
94
+ await Camera.setExposurePoint({ x: 0.5, y: 0.5 });
95
+
96
+ // Batch settings update
97
+ await Camera.setSettings({
98
+ settings: {
99
+ flash: "auto",
100
+ whiteBalance: "daylight",
101
+ focusMode: "continuous",
102
+ },
103
+ });
104
+ ```
105
+
106
+ ### Events
107
+
108
+ ```typescript
109
+ const frameHandle = await Camera.addListener("frame", (event) => {
110
+ console.log(event.timestamp, event.width, event.height);
111
+ });
112
+
113
+ const stateHandle = await Camera.addListener("recordingState", (state) => {
114
+ console.log(state.isRecording, state.duration, state.fileSize);
115
+ });
116
+
117
+ // Clean up
118
+ await Camera.removeAllListeners();
119
+ ```
120
+
121
+ ## API
122
+
123
+ Full TypeScript definitions are in `src/definitions.ts`. Key types:
124
+
125
+ | Type | Description |
126
+ |---|---|
127
+ | `CameraDevice` | Device info: id, label, direction, capabilities |
128
+ | `CameraPreviewOptions` | Options for `startPreview` |
129
+ | `PhotoCaptureOptions` | Options for `capturePhoto` (quality, format, size, gallery save) |
130
+ | `PhotoResult` | base64 image, format, dimensions, optional EXIF |
131
+ | `VideoCaptureOptions` | Options for `startRecording` (quality, duration, size, audio, bitrate) |
132
+ | `VideoResult` | Path (blob URL or file path), duration, dimensions, file size, mime type |
133
+ | `CameraSettings` | flash, zoom, focusMode, exposureMode, exposureCompensation, whiteBalance |
134
+ | `CameraPermissionStatus` | camera / microphone: `"granted"` \| `"denied"` \| `"prompt"`; photos additionally allows `"limited"` |
135
+
136
+ ## Platform notes
137
+
138
+ - **Web:** Flash/torch is not controllable via the `MediaDevices` API on most desktop browsers. Manual focus and exposure require the browser/device to report `"manual"` capability. Video is recorded as a `blob:` URL using `MediaRecorder`; preferred codec order is `vp9+opus` → `vp8+opus` → `webm` → `mp4`.
139
+ - **iOS:** AVFoundation-backed. Requires iOS 15.0+, Swift 5.9.
140
+ - **Android:** Camera2 API-backed.
141
+ - **Node (Electrobun desktop):** Supported via Electrobun native modules.
142
+
@@ -8,6 +8,16 @@ ext {
8
8
  }
9
9
 
10
10
  apply plugin: 'com.android.library'
11
+ // Explicitly apply the Kotlin Android plugin. The kotlin-gradle-plugin is on
12
+ // the root buildscript classpath, but without applying it here AGP 8.13 falls
13
+ // back to its "built-in Kotlin" compile path (build/intermediates/
14
+ // built_in_kotlinc), which compiles the .kt sources but does NOT bundle the
15
+ // resulting .class files into the *release* library jar. The app's
16
+ // :app:assembleRelease then links a library AAR with zero plugin classes, so
17
+ // the Capacitor plugin (and any manifest-declared component) is absent from
18
+ // the release dex. Applying the standard Kotlin plugin wires Kotlin
19
+ // compilation into both the debug and release jar-bundling tasks.
20
+ apply plugin: 'org.jetbrains.kotlin.android'
11
21
  android {
12
22
  namespace = "ai.eliza.plugins.camera"
13
23
  compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
@@ -26,8 +36,12 @@ android {
26
36
  }
27
37
 
28
38
  compileOptions {
29
- sourceCompatibility JavaVersion.VERSION_17
30
- targetCompatibility JavaVersion.VERSION_17
39
+ sourceCompatibility JavaVersion.VERSION_21
40
+ targetCompatibility JavaVersion.VERSION_21
41
+ }
42
+
43
+ kotlinOptions {
44
+ jvmTarget = "21"
31
45
  }
32
46
 
33
47
  }
@@ -35,7 +49,7 @@ android {
35
49
  repositories {
36
50
  google()
37
51
  maven {
38
- url = uri(rootProject.ext.mavenCentralMirrorUrl)
52
+ url = uri(rootProject.ext.has('mavenCentralMirrorUrl') ? rootProject.ext.mavenCentralMirrorUrl : 'https://repo.maven.apache.org/maven2')
39
53
  }
40
54
  mavenCentral()
41
55
  }
package/dist/esm/web.d.ts CHANGED
@@ -17,7 +17,6 @@ export declare class CameraWeb extends WebPlugin {
17
17
  devices: CameraDevice[];
18
18
  }>;
19
19
  private inferDirection;
20
- private getDeviceCapabilities;
21
20
  startPreview(options: CameraPreviewOptions): Promise<CameraPreviewResult>;
22
21
  stopPreview(): Promise<void>;
23
22
  switchCamera(options: {
@@ -37,6 +36,7 @@ export declare class CameraWeb extends WebPlugin {
37
36
  setZoom(options: {
38
37
  zoom: number;
39
38
  }): Promise<void>;
39
+ private assertValidZoom;
40
40
  private applyZoom;
41
41
  setFocusPoint(options: {
42
42
  x: number;
@@ -46,6 +46,7 @@ export declare class CameraWeb extends WebPlugin {
46
46
  x: number;
47
47
  y: number;
48
48
  }): Promise<void>;
49
+ private assertNormalizedPoint;
49
50
  checkPermissions(): Promise<CameraPermissionStatus>;
50
51
  requestPermissions(): Promise<CameraPermissionStatus>;
51
52
  addListener(eventName: string, listenerFunc: (event: CameraEventData) => void): Promise<{
@@ -1 +1 @@
1
- {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,EACV,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACnB,cAAc,EACd,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,mBAAmB,EACnB,WAAW,EACZ,MAAM,eAAe,CAAC;AAEvB,KAAK,eAAe,GAChB,gBAAgB,GAChB,gBAAgB,GAChB,mBAAmB,CAAC;AAYxB,qBAAa,SAAU,SAAQ,SAAS;IACtC,OAAO,CAAC,WAAW,CAA4B;IAC/C,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,aAAa,CAA8B;IACnD,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,sBAAsB,CAA+C;IAC7E,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,eAAe,CAOrB;IACF,OAAO,CAAC,eAAe,CAGf;IAEF,UAAU,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC;IA8BxD,OAAO,CAAC,cAAc;YAmBR,qBAAqB;IAmE7B,YAAY,CAChB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC;IAuDzB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB5B,YAAY,CAAC,OAAO,EAAE;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,eAAe,CAAC;KAC7B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAe1B,YAAY,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,CAAC;IAoDjE,cAAc,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkF5D,aAAa,IAAI,OAAO,CAAC,WAAW,CAAC;IA8DrC,iBAAiB,IAAI,OAAO,CAAC,mBAAmB,CAAC;IAgBjD,WAAW,IAAI,OAAO,CAAC;QAAE,QAAQ,EAAE,cAAc,CAAA;KAAE,CAAC;IAIpD,WAAW,CAAC,OAAO,EAAE;QACzB,QAAQ,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;KACnC,GAAG,OAAO,CAAC,IAAI,CAAC;IAQX,OAAO,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YAUzC,SAAS;IAwBjB,aAAa,CAAC,OAAO,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA6B/D,gBAAgB,CAAC,OAAO,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BlE,gBAAgB,IAAI,OAAO,CAAC,sBAAsB,CAAC;IAkCnD,kBAAkB,IAAI,OAAO,CAAC,sBAAsB,CAAC;IA+CrD,WAAW,CACf,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,GAC7C,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IAWrC,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IAIzC,SAAS,CAAC,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI;CAO1E"}
1
+ {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,EACV,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACnB,cAAc,EACd,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,mBAAmB,EACnB,WAAW,EACZ,MAAM,eAAe,CAAC;AAEvB,KAAK,eAAe,GAChB,gBAAgB,GAChB,gBAAgB,GAChB,mBAAmB,CAAC;AAiDxB,qBAAa,SAAU,SAAQ,SAAS;IACtC,OAAO,CAAC,WAAW,CAA4B;IAC/C,OAAO,CAAC,YAAY,CAAiC;IACrD,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,aAAa,CAA8B;IACnD,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,sBAAsB,CAA+C;IAC7E,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,eAAe,CAOrB;IACF,OAAO,CAAC,eAAe,CAGf;IAEF,UAAU,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,YAAY,EAAE,CAAA;KAAE,CAAC;IAgCxD,OAAO,CAAC,cAAc;IAmBhB,YAAY,CAChB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC;IAoEzB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAuB5B,YAAY,CAAC,OAAO,EAAE;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,eAAe,CAAC;KAC7B,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAe1B,YAAY,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,CAAC;IAsEjE,cAAc,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAoF5D,aAAa,IAAI,OAAO,CAAC,WAAW,CAAC;IA8DrC,iBAAiB,IAAI,OAAO,CAAC,mBAAmB,CAAC;IAgBjD,WAAW,IAAI,OAAO,CAAC;QAAE,QAAQ,EAAE,cAAc,CAAA;KAAE,CAAC;IAIpD,WAAW,CAAC,OAAO,EAAE;QACzB,QAAQ,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;KACnC,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBX,OAAO,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAOvD,OAAO,CAAC,eAAe;YAQT,SAAS;IAwBjB,aAAa,CAAC,OAAO,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA8B/D,gBAAgB,CAAC,OAAO,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BxE,OAAO,CAAC,qBAAqB;IAgBvB,gBAAgB,IAAI,OAAO,CAAC,sBAAsB,CAAC;IAkCnD,kBAAkB,IAAI,OAAO,CAAC,sBAAsB,CAAC;IA+CrD,WAAW,CACf,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,GAC7C,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC;IAWrC,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IAIzC,SAAS,CAAC,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI;CAO1E"}
package/dist/esm/web.js CHANGED
@@ -6,6 +6,37 @@ const VIDEO_MIME_TYPES = [
6
6
  "video/mp4",
7
7
  ];
8
8
  const getSupportedMimeType = () => VIDEO_MIME_TYPES.find((m) => MediaRecorder.isTypeSupported(m)) ?? null;
9
+ const getMediaDevices = () => {
10
+ if (!navigator.mediaDevices?.getUserMedia) {
11
+ throw new Error("Camera media devices API is not available");
12
+ }
13
+ return navigator.mediaDevices;
14
+ };
15
+ const assertPositiveFinite = (value, name) => {
16
+ if (!Number.isFinite(value) || value <= 0) {
17
+ throw new Error(`${name} must be a positive finite number`);
18
+ }
19
+ };
20
+ const assertRecordingOptions = (options) => {
21
+ if (!options)
22
+ return;
23
+ if (options.quality !== undefined &&
24
+ !["low", "medium", "high", "highest"].includes(options.quality)) {
25
+ throw new Error("quality must be one of low, medium, high, or highest");
26
+ }
27
+ if (options.maxDuration !== undefined) {
28
+ assertPositiveFinite(options.maxDuration, "maxDuration");
29
+ }
30
+ if (options.maxFileSize !== undefined) {
31
+ assertPositiveFinite(options.maxFileSize, "maxFileSize");
32
+ }
33
+ if (options.bitrate !== undefined) {
34
+ assertPositiveFinite(options.bitrate, "bitrate");
35
+ }
36
+ if (options.frameRate !== undefined) {
37
+ assertPositiveFinite(options.frameRate, "frameRate");
38
+ }
39
+ };
9
40
  export class CameraWeb extends WebPlugin {
10
41
  constructor() {
11
42
  super(...arguments);
@@ -29,27 +60,29 @@ export class CameraWeb extends WebPlugin {
29
60
  this.pluginListeners = [];
30
61
  }
31
62
  async getDevices() {
32
- // enumerateDevices() returns device stubs without labels unless the user
33
- // has already granted camera permission via a prior getUserMedia() call.
63
+ // enumerateDevices() returns unlabeled device records unless the user has
64
+ // already granted camera permission via a prior getUserMedia() call.
34
65
  // We intentionally do NOT call getUserMedia() here because it requires a
35
66
  // user gesture and would throw NotAllowedError if called programmatically.
36
- const allDevices = await navigator.mediaDevices.enumerateDevices();
67
+ const mediaDevices = getMediaDevices();
68
+ if (!mediaDevices.enumerateDevices) {
69
+ throw new Error("Camera device enumeration is not available");
70
+ }
71
+ const allDevices = await mediaDevices.enumerateDevices();
37
72
  const videoDevices = allDevices.filter((d) => d.kind === "videoinput");
38
- const devices = await Promise.all(videoDevices.map(async (device, index) => {
39
- const capabilities = await this.getDeviceCapabilities(device.deviceId);
40
- return {
41
- deviceId: device.deviceId,
42
- label: device.label || `Camera ${index + 1}`,
43
- direction: this.inferDirection(device.label),
44
- // Flash detection not available via MediaDevices API on web
45
- hasFlash: capabilities?.hasFlash ?? false,
46
- hasZoom: !!capabilities?.zoom,
47
- maxZoom: capabilities?.zoom?.max ?? 1,
48
- // Return actual capabilities or empty array to indicate unknown
49
- supportedResolutions: capabilities?.resolutions ?? [],
50
- supportedFrameRates: capabilities?.frameRates ?? [],
51
- };
52
- }));
73
+ const devices = await Promise.all(videoDevices.map(async (device, index) => ({
74
+ deviceId: device.deviceId,
75
+ label: device.label || `Camera ${index + 1}`,
76
+ direction: this.inferDirection(device.label),
77
+ // Capability probing requires getUserMedia(), which can prompt for
78
+ // camera permission. Enumeration stays prompt-free and reports
79
+ // unknown capabilities until preview/capture has explicit access.
80
+ hasFlash: false,
81
+ hasZoom: false,
82
+ maxZoom: 1,
83
+ supportedResolutions: [],
84
+ supportedFrameRates: [],
85
+ })));
53
86
  return { devices };
54
87
  }
55
88
  inferDirection(label) {
@@ -66,60 +99,17 @@ export class CameraWeb extends WebPlugin {
66
99
  }
67
100
  return "external";
68
101
  }
69
- async getDeviceCapabilities(deviceId) {
70
- let stream;
71
- try {
72
- stream = await navigator.mediaDevices.getUserMedia({
73
- video: { deviceId: { exact: deviceId } },
74
- });
102
+ async startPreview(options) {
103
+ if (!options?.element?.appendChild) {
104
+ throw new Error("Preview element is required");
75
105
  }
76
- catch {
77
- return null; // Device not accessible
106
+ if (options.resolution) {
107
+ assertPositiveFinite(options.resolution.width, "resolution.width");
108
+ assertPositiveFinite(options.resolution.height, "resolution.height");
78
109
  }
79
- const track = stream.getVideoTracks()[0];
80
- if (!track) {
81
- stream.getTracks().forEach((t) => {
82
- t.stop();
83
- });
84
- return null;
110
+ if (options.frameRate !== undefined) {
111
+ assertPositiveFinite(options.frameRate, "frameRate");
85
112
  }
86
- const capabilities = track.getCapabilities ? track.getCapabilities() : {};
87
- stream.getTracks().forEach((t) => {
88
- t.stop();
89
- });
90
- const caps = capabilities;
91
- // Build resolutions from actual device capabilities only
92
- const resolutions = [];
93
- if (caps.width?.max && caps.height?.max) {
94
- resolutions.push({ width: caps.width.max, height: caps.height.max });
95
- // Add common lower resolutions only if device supports them
96
- if (caps.width.max >= 1280 && caps.height.max >= 720) {
97
- resolutions.push({ width: 1280, height: 720 });
98
- }
99
- if (caps.width.max >= 640 && caps.height.max >= 480) {
100
- resolutions.push({ width: 640, height: 480 });
101
- }
102
- }
103
- // Build frameRates from actual device capabilities only
104
- const frameRates = [];
105
- if (caps.frameRate?.max) {
106
- if (caps.frameRate.max >= 60)
107
- frameRates.push(60);
108
- if (caps.frameRate.max >= 30)
109
- frameRates.push(30);
110
- if (caps.frameRate.max >= 24)
111
- frameRates.push(24);
112
- if (caps.frameRate.max >= 15)
113
- frameRates.push(15);
114
- }
115
- return {
116
- zoom: caps.zoom,
117
- resolutions: resolutions.length > 0 ? resolutions : undefined,
118
- frameRates: frameRates.length > 0 ? frameRates : undefined,
119
- hasFlash: caps.torch === true, // Torch capability indicates flash support
120
- };
121
- }
122
- async startPreview(options) {
123
113
  await this.stopPreview();
124
114
  const constraints = {
125
115
  video: {
@@ -141,7 +131,9 @@ export class CameraWeb extends WebPlugin {
141
131
  },
142
132
  audio: false,
143
133
  };
144
- this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
134
+ // Browser camera permission is requested by opening the stream. Native
135
+ // permission probing is handled outside this Capacitor web fallback.
136
+ this.mediaStream = await getMediaDevices().getUserMedia(constraints);
145
137
  this.previewElement = options.element;
146
138
  this.videoElement = document.createElement("video");
147
139
  this.videoElement.srcObject = this.mediaStream;
@@ -175,8 +167,10 @@ export class CameraWeb extends WebPlugin {
175
167
  });
176
168
  this.mediaStream = null;
177
169
  }
178
- if (this.videoElement && this.previewElement) {
179
- this.previewElement.removeChild(this.videoElement);
170
+ if (this.videoElement) {
171
+ if (this.previewElement?.contains(this.videoElement)) {
172
+ this.previewElement.removeChild(this.videoElement);
173
+ }
180
174
  this.videoElement = null;
181
175
  }
182
176
  this.previewElement = null;
@@ -202,8 +196,18 @@ export class CameraWeb extends WebPlugin {
202
196
  const settings = track.getSettings();
203
197
  const videoWidth = settings.width || this.videoElement.videoWidth;
204
198
  const videoHeight = settings.height || this.videoElement.videoHeight;
199
+ assertPositiveFinite(videoWidth, "videoWidth");
200
+ assertPositiveFinite(videoHeight, "videoHeight");
205
201
  const targetWidth = options?.width || videoWidth;
206
202
  const targetHeight = options?.height || videoHeight;
203
+ assertPositiveFinite(targetWidth, "width");
204
+ assertPositiveFinite(targetHeight, "height");
205
+ if (options?.quality !== undefined &&
206
+ (!Number.isFinite(options.quality) ||
207
+ options.quality < 0 ||
208
+ options.quality > 100)) {
209
+ throw new Error("quality must be a finite number between 0 and 100");
210
+ }
207
211
  const canvas = document.createElement("canvas");
208
212
  canvas.width = targetWidth;
209
213
  canvas.height = targetHeight;
@@ -226,7 +230,11 @@ export class CameraWeb extends WebPlugin {
226
230
  : format === "webp"
227
231
  ? "image/webp"
228
232
  : "image/jpeg";
229
- const base64 = canvas.toDataURL(mimeType, quality).split(",")[1];
233
+ const dataUrl = canvas.toDataURL(mimeType, quality);
234
+ const base64 = dataUrl.split(",")[1];
235
+ if (!base64) {
236
+ throw new Error("Failed to encode captured photo");
237
+ }
230
238
  return {
231
239
  base64,
232
240
  format,
@@ -241,9 +249,10 @@ export class CameraWeb extends WebPlugin {
241
249
  if (this.isRecording) {
242
250
  throw new Error("Recording already in progress");
243
251
  }
252
+ assertRecordingOptions(options);
244
253
  let streamToRecord = this.mediaStream;
245
254
  if (options?.audio !== false) {
246
- const audioStream = await navigator.mediaDevices.getUserMedia({
255
+ const audioStream = await getMediaDevices().getUserMedia({
247
256
  audio: true,
248
257
  });
249
258
  streamToRecord = new MediaStream([
@@ -365,17 +374,28 @@ export class CameraWeb extends WebPlugin {
365
374
  return { settings: { ...this.currentSettings } };
366
375
  }
367
376
  async setSettings(options) {
377
+ if (!options?.settings || typeof options.settings !== "object") {
378
+ throw new Error("settings object is required");
379
+ }
380
+ if (options.settings.zoom !== undefined) {
381
+ const zoom = options.settings.zoom;
382
+ this.assertValidZoom(zoom);
383
+ }
368
384
  this.currentSettings = { ...this.currentSettings, ...options.settings };
369
385
  if (this.mediaStream && options.settings.zoom !== undefined) {
370
386
  await this.applyZoom(options.settings.zoom);
371
387
  }
372
388
  }
373
389
  async setZoom(options) {
374
- if (!Number.isFinite(options.zoom) || options.zoom < 0) {
375
- throw new Error(`Invalid zoom value: ${options.zoom}. Must be a non-negative finite number.`);
390
+ const zoom = options?.zoom;
391
+ this.assertValidZoom(zoom);
392
+ await this.applyZoom(zoom);
393
+ this.currentSettings.zoom = zoom;
394
+ }
395
+ assertValidZoom(zoom) {
396
+ if (typeof zoom !== "number" || !Number.isFinite(zoom) || zoom < 0) {
397
+ throw new Error(`Invalid zoom value: ${zoom}. Must be a non-negative finite number.`);
376
398
  }
377
- await this.applyZoom(options.zoom);
378
- this.currentSettings.zoom = options.zoom;
379
399
  }
380
400
  async applyZoom(zoom) {
381
401
  if (!this.mediaStream)
@@ -395,6 +415,7 @@ export class CameraWeb extends WebPlugin {
395
415
  async setFocusPoint(options) {
396
416
  if (!this.mediaStream)
397
417
  throw new Error("Preview not started");
418
+ this.assertNormalizedPoint(options, "focus point");
398
419
  const track = this.mediaStream.getVideoTracks()[0];
399
420
  if (!track)
400
421
  throw new Error("No video track available");
@@ -420,6 +441,7 @@ export class CameraWeb extends WebPlugin {
420
441
  async setExposurePoint(options) {
421
442
  if (!this.mediaStream)
422
443
  throw new Error("Preview not started");
444
+ this.assertNormalizedPoint(options, "exposure point");
423
445
  const track = this.mediaStream.getVideoTracks()[0];
424
446
  if (!track)
425
447
  throw new Error("No video track available");
@@ -442,6 +464,16 @@ export class CameraWeb extends WebPlugin {
442
464
  throw new Error(`Failed to set exposure point: ${e instanceof Error ? e.message : "unknown error"}`);
443
465
  }
444
466
  }
467
+ assertNormalizedPoint(options, name) {
468
+ if (!Number.isFinite(options?.x) ||
469
+ !Number.isFinite(options?.y) ||
470
+ options.x < 0 ||
471
+ options.x > 1 ||
472
+ options.y < 0 ||
473
+ options.y > 1) {
474
+ throw new Error(`${name} must use finite x/y values between 0 and 1`);
475
+ }
476
+ }
445
477
  async checkPermissions() {
446
478
  let cameraStatus = "prompt";
447
479
  let microphoneStatus = "prompt";
@@ -475,7 +507,7 @@ export class CameraWeb extends WebPlugin {
475
507
  let cameraStatus = "denied";
476
508
  let microphoneStatus = "denied";
477
509
  try {
478
- const stream = await navigator.mediaDevices.getUserMedia({
510
+ const stream = await getMediaDevices().getUserMedia({
479
511
  video: true,
480
512
  audio: true,
481
513
  });
@@ -487,7 +519,7 @@ export class CameraWeb extends WebPlugin {
487
519
  }
488
520
  catch {
489
521
  try {
490
- const videoStream = await navigator.mediaDevices.getUserMedia({
522
+ const videoStream = await getMediaDevices().getUserMedia({
491
523
  video: true,
492
524
  });
493
525
  videoStream.getTracks().forEach((track) => {
@@ -499,7 +531,7 @@ export class CameraWeb extends WebPlugin {
499
531
  cameraStatus = "denied";
500
532
  }
501
533
  try {
502
- const audioStream = await navigator.mediaDevices.getUserMedia({
534
+ const audioStream = await getMediaDevices().getUserMedia({
503
535
  audio: true,
504
536
  });
505
537
  audioStream.getTracks().forEach((track) => {