@elizaos/capacitor-camera 2.0.0-beta.1 → 2.0.11-beta.7
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 +21 -0
- package/README.md +142 -0
- package/android/build.gradle +16 -2
- package/dist/esm/web.d.ts +2 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +113 -87
- package/dist/plugin.cjs.js +113 -87
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +113 -87
- package/dist/plugin.js.map +1 -1
- package/package.json +13 -9
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
|
+
|
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
30
|
-
targetCompatibility JavaVersion.
|
|
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<{
|
package/dist/esm/web.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
33
|
-
//
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
106
|
+
if (options.resolution) {
|
|
107
|
+
assertPositiveFinite(options.resolution.width, "resolution.width");
|
|
108
|
+
assertPositiveFinite(options.resolution.height, "resolution.height");
|
|
78
109
|
}
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
|
185
|
-
this.previewElement
|
|
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
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
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
|
|
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
|
|
534
|
+
const audioStream = await getMediaDevices().getUserMedia({
|
|
509
535
|
audio: true,
|
|
510
536
|
});
|
|
511
537
|
audioStream.getTracks().forEach((track) => {
|