@elizaos/capacitor-camera 2.0.0-beta.1 → 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
  }
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;IA6DzB,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,13 +131,9 @@ export class CameraWeb extends WebPlugin {
141
131
  },
142
132
  audio: false,
143
133
  };
144
- // PERMISSIONS_MIGRATION: this getUserMedia() call triggers the OS
145
- // camera-permission dialog implicitly. New flow probes via
146
- // `cameraProber` from
147
- // `packages/agent/src/services/permissions/probers/camera.ts` first,
148
- // then asks the user, only then opens the stream. Will be retired by
149
- // the chat-surface migration agent.
150
- 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);
151
137
  this.previewElement = options.element;
152
138
  this.videoElement = document.createElement("video");
153
139
  this.videoElement.srcObject = this.mediaStream;
@@ -181,8 +167,10 @@ export class CameraWeb extends WebPlugin {
181
167
  });
182
168
  this.mediaStream = null;
183
169
  }
184
- if (this.videoElement && this.previewElement) {
185
- this.previewElement.removeChild(this.videoElement);
170
+ if (this.videoElement) {
171
+ if (this.previewElement?.contains(this.videoElement)) {
172
+ this.previewElement.removeChild(this.videoElement);
173
+ }
186
174
  this.videoElement = null;
187
175
  }
188
176
  this.previewElement = null;
@@ -208,8 +196,18 @@ export class CameraWeb extends WebPlugin {
208
196
  const settings = track.getSettings();
209
197
  const videoWidth = settings.width || this.videoElement.videoWidth;
210
198
  const videoHeight = settings.height || this.videoElement.videoHeight;
199
+ assertPositiveFinite(videoWidth, "videoWidth");
200
+ assertPositiveFinite(videoHeight, "videoHeight");
211
201
  const targetWidth = options?.width || videoWidth;
212
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
+ }
213
211
  const canvas = document.createElement("canvas");
214
212
  canvas.width = targetWidth;
215
213
  canvas.height = targetHeight;
@@ -232,7 +230,11 @@ export class CameraWeb extends WebPlugin {
232
230
  : format === "webp"
233
231
  ? "image/webp"
234
232
  : "image/jpeg";
235
- 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
+ }
236
238
  return {
237
239
  base64,
238
240
  format,
@@ -247,9 +249,10 @@ export class CameraWeb extends WebPlugin {
247
249
  if (this.isRecording) {
248
250
  throw new Error("Recording already in progress");
249
251
  }
252
+ assertRecordingOptions(options);
250
253
  let streamToRecord = this.mediaStream;
251
254
  if (options?.audio !== false) {
252
- const audioStream = await navigator.mediaDevices.getUserMedia({
255
+ const audioStream = await getMediaDevices().getUserMedia({
253
256
  audio: true,
254
257
  });
255
258
  streamToRecord = new MediaStream([
@@ -371,17 +374,28 @@ export class CameraWeb extends WebPlugin {
371
374
  return { settings: { ...this.currentSettings } };
372
375
  }
373
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
+ }
374
384
  this.currentSettings = { ...this.currentSettings, ...options.settings };
375
385
  if (this.mediaStream && options.settings.zoom !== undefined) {
376
386
  await this.applyZoom(options.settings.zoom);
377
387
  }
378
388
  }
379
389
  async setZoom(options) {
380
- if (!Number.isFinite(options.zoom) || options.zoom < 0) {
381
- 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.`);
382
398
  }
383
- await this.applyZoom(options.zoom);
384
- this.currentSettings.zoom = options.zoom;
385
399
  }
386
400
  async applyZoom(zoom) {
387
401
  if (!this.mediaStream)
@@ -401,6 +415,7 @@ export class CameraWeb extends WebPlugin {
401
415
  async setFocusPoint(options) {
402
416
  if (!this.mediaStream)
403
417
  throw new Error("Preview not started");
418
+ this.assertNormalizedPoint(options, "focus point");
404
419
  const track = this.mediaStream.getVideoTracks()[0];
405
420
  if (!track)
406
421
  throw new Error("No video track available");
@@ -426,6 +441,7 @@ export class CameraWeb extends WebPlugin {
426
441
  async setExposurePoint(options) {
427
442
  if (!this.mediaStream)
428
443
  throw new Error("Preview not started");
444
+ this.assertNormalizedPoint(options, "exposure point");
429
445
  const track = this.mediaStream.getVideoTracks()[0];
430
446
  if (!track)
431
447
  throw new Error("No video track available");
@@ -448,6 +464,16 @@ export class CameraWeb extends WebPlugin {
448
464
  throw new Error(`Failed to set exposure point: ${e instanceof Error ? e.message : "unknown error"}`);
449
465
  }
450
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
+ }
451
477
  async checkPermissions() {
452
478
  let cameraStatus = "prompt";
453
479
  let microphoneStatus = "prompt";
@@ -481,7 +507,7 @@ export class CameraWeb extends WebPlugin {
481
507
  let cameraStatus = "denied";
482
508
  let microphoneStatus = "denied";
483
509
  try {
484
- const stream = await navigator.mediaDevices.getUserMedia({
510
+ const stream = await getMediaDevices().getUserMedia({
485
511
  video: true,
486
512
  audio: true,
487
513
  });
@@ -493,7 +519,7 @@ export class CameraWeb extends WebPlugin {
493
519
  }
494
520
  catch {
495
521
  try {
496
- const videoStream = await navigator.mediaDevices.getUserMedia({
522
+ const videoStream = await getMediaDevices().getUserMedia({
497
523
  video: true,
498
524
  });
499
525
  videoStream.getTracks().forEach((track) => {
@@ -505,7 +531,7 @@ export class CameraWeb extends WebPlugin {
505
531
  cameraStatus = "denied";
506
532
  }
507
533
  try {
508
- const audioStream = await navigator.mediaDevices.getUserMedia({
534
+ const audioStream = await getMediaDevices().getUserMedia({
509
535
  audio: true,
510
536
  });
511
537
  audioStream.getTracks().forEach((track) => {