@elizaos/capacitor-camera 1.0.0 → 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 +17 -3
- package/dist/esm/web.d.ts +2 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +113 -81
- package/dist/plugin.cjs.js +113 -81
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +113 -81
- package/dist/plugin.js.map +1 -1
- package/package.json +16 -12
- package/electrobun/src/index.ts +0 -13
- package/electrobun/tsconfig.json +0 -18
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
|
}
|
|
@@ -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<{
|
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,7 +131,9 @@ export class CameraWeb extends WebPlugin {
|
|
|
141
131
|
},
|
|
142
132
|
audio: false,
|
|
143
133
|
};
|
|
144
|
-
|
|
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
|
|
179
|
-
this.previewElement
|
|
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
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
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
|
|
534
|
+
const audioStream = await getMediaDevices().getUserMedia({
|
|
503
535
|
audio: true,
|
|
504
536
|
});
|
|
505
537
|
audioStream.getTracks().forEach((track) => {
|