tauri-plugin-camera-kessdev 0.2.0-beta.0 → 0.2.0-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/README.md +154 -8
- package/dist-js/index.cjs +177 -29
- package/dist-js/index.d.ts +61 -0
- package/dist-js/index.js +172 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A [Tauri v2](https://v2.tauri.app) plugin that provides a **unified JavaScript API** for
|
|
4
4
|
camera access across Android, iOS and desktop. It supports live preview, photo capture,
|
|
5
|
-
video recording (with audio), camera controls (front/back, flash, zoom),
|
|
5
|
+
video recording (with audio), camera controls (front/back, flash, zoom), camera
|
|
6
|
+
introspection (list the available cameras and query their capabilities), saving to the
|
|
6
7
|
photo gallery, and camera permission management.
|
|
7
8
|
|
|
8
9
|
| Platform | Supported |
|
|
@@ -25,7 +26,7 @@ Add the Rust crate to `src-tauri/Cargo.toml`:
|
|
|
25
26
|
|
|
26
27
|
```toml
|
|
27
28
|
[dependencies]
|
|
28
|
-
tauri-plugin-camera-kessdev = "0.
|
|
29
|
+
tauri-plugin-camera-kessdev = "0.2.0-beta.0" # beta
|
|
29
30
|
# or from a git/path source:
|
|
30
31
|
# tauri-plugin-camera-kessdev = { path = "../tauri-plugin-camera" }
|
|
31
32
|
```
|
|
@@ -61,15 +62,16 @@ Add the default permission set to `src-tauri/capabilities/default.json`:
|
|
|
61
62
|
{
|
|
62
63
|
"permissions": [
|
|
63
64
|
"core:default",
|
|
64
|
-
"camera:default"
|
|
65
|
+
"camera-kessdev:default"
|
|
65
66
|
]
|
|
66
67
|
}
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
To allow only specific commands, use the granular permissions instead of
|
|
70
|
-
`camera:default` (e.g. `camera:allow-capture`,
|
|
71
|
-
`camera:allow-check-permissions`, …).
|
|
72
|
-
`permissions/autogenerated/commands/` as
|
|
71
|
+
`camera-kessdev:default` (e.g. `camera-kessdev:allow-capture`,
|
|
72
|
+
`camera-kessdev:allow-start-recording`, `camera-kessdev:allow-check-permissions`, …).
|
|
73
|
+
The full list is generated under `permissions/autogenerated/commands/` as
|
|
74
|
+
`allow-<command>` / `deny-<command>`.
|
|
73
75
|
|
|
74
76
|
### Android
|
|
75
77
|
|
|
@@ -135,6 +137,7 @@ import {
|
|
|
135
137
|
saveToGallery,
|
|
136
138
|
checkPermissions,
|
|
137
139
|
requestPermissions,
|
|
140
|
+
openAppSettings,
|
|
138
141
|
getPreviewStream,
|
|
139
142
|
convertFileSrc
|
|
140
143
|
} from 'tauri-plugin-camera-kessdev'
|
|
@@ -146,6 +149,10 @@ import {
|
|
|
146
149
|
// windowed: true keeps the webview transparent so your UI renders over the camera.
|
|
147
150
|
await startPreview({ camera: 'back', windowed: true })
|
|
148
151
|
|
|
152
|
+
// Or pick a specific lens by id (from getAvailableCameras()); cameraId takes precedence.
|
|
153
|
+
const cameras = await getAvailableCameras()
|
|
154
|
+
await startPreview({ cameraId: cameras[0].id })
|
|
155
|
+
|
|
149
156
|
// Desktop: attach the stream to a <video> element.
|
|
150
157
|
const stream = await getPreviewStream()
|
|
151
158
|
if (stream) videoEl.srcObject = stream
|
|
@@ -164,6 +171,17 @@ const photo = await capture({ flash: 'auto' })
|
|
|
164
171
|
imageEl.src = convertFileSrc(photo.path)
|
|
165
172
|
```
|
|
166
173
|
|
|
174
|
+
Configurable (mobile and desktop):
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
await capture({
|
|
178
|
+
resolution: { width: 1920, height: 1080 }, // nearest supported size (best-effort)
|
|
179
|
+
aspectRatio: '16:9', // '4:3' | '16:9' | '1:1' | ...
|
|
180
|
+
format: 'jpeg', // 'jpeg' | 'png' | 'heif' (mobile jpeg; desktop jpeg/png)
|
|
181
|
+
quality: 0.9 // 0..1 for lossy formats
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
167
185
|
### Video recording (with audio)
|
|
168
186
|
|
|
169
187
|
```ts
|
|
@@ -175,8 +193,16 @@ const video = await stopRecording()
|
|
|
175
193
|
// Optionally request a base64 JPEG thumbnail of the recording:
|
|
176
194
|
const withThumbnail = await stopRecording({ thumbnail: true })
|
|
177
195
|
// withThumbnail.thumbnail: string | undefined
|
|
196
|
+
|
|
197
|
+
// Configure quality/fps/bitrate (mobile and desktop):
|
|
198
|
+
await startRecording({ quality: 'fhd', fps: 30, bitrate: 2_000_000 }) // quality: 'sd' | 'hd' | 'fhd' | 'uhd'
|
|
178
199
|
```
|
|
179
200
|
|
|
201
|
+
On desktop the preview stays alive during recording: the recording reuses the
|
|
202
|
+
preview's video track (only the microphone is acquired separately), so the
|
|
203
|
+
`<video>` element bound to `getPreviewStream()` keeps playing. The recording
|
|
204
|
+
mime type is chosen from the browser's supported types (WebM or MP4).
|
|
205
|
+
|
|
180
206
|
### Video thumbnail
|
|
181
207
|
|
|
182
208
|
Pass `{ thumbnail: true }` to `stopRecording()` to include a base64-encoded JPEG
|
|
@@ -240,6 +266,31 @@ const zoom = await getZoom() // number
|
|
|
240
266
|
On desktop `getZoom()` rejects with a descriptive error (consistent with
|
|
241
267
|
`setZoom`).
|
|
242
268
|
|
|
269
|
+
### Fine controls
|
|
270
|
+
|
|
271
|
+
Torch, tap-to-focus, exposure and white balance (mobile; desktop throws):
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
await setTorch(true) // continuous light (independent from capture flash)
|
|
275
|
+
await setFocusPoint(0.5, 0.5) // normalized 0..1, top-left origin
|
|
276
|
+
await setFocusMode('continuous') // 'auto' | 'continuous' | 'locked'
|
|
277
|
+
await setExposureCompensation(1.5) // EV, clamped to getCapabilities().exposureRange
|
|
278
|
+
await setExposureMode('locked') // 'auto' | 'locked'
|
|
279
|
+
await setWhiteBalance({ mode: 'locked', temperatureK: 5000 }) // Kelvin; 'auto' | 'locked'
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Details:
|
|
283
|
+
|
|
284
|
+
- **Torch** is independent from the capture flash (`setFlash`); query `hasTorch`.
|
|
285
|
+
- **Focus** uses normalized coordinates (0..1, top-left origin); query `supportsFocus` / `focusModes`.
|
|
286
|
+
- **Exposure**: `ev` is clamped to `exposureRange`; `exposureModes` is `['auto', 'locked']`.
|
|
287
|
+
- **White balance** is uniform across platforms: `mode: 'auto' | 'locked'`. In `locked`, `temperatureK` (Kelvin) is applied where the hardware supports it (`supportsWhiteBalanceTemperature`); when unsupported, the plugin locks the current gains (or rejects) instead of inventing a temperature. `auto` uses the platform's continuous AWB.
|
|
288
|
+
|
|
289
|
+
On desktop all of these controls reject with a descriptive error. Query the
|
|
290
|
+
supported values with `getCapabilities()` first: `hasTorch`,
|
|
291
|
+
`supportsFocus`/`focusModes`, `exposureRange`/`exposureModes`,
|
|
292
|
+
`whiteBalanceModes`/`supportsWhiteBalanceTemperature`.
|
|
293
|
+
|
|
243
294
|
### Camera introspection
|
|
244
295
|
|
|
245
296
|
Use `getAvailableCameras()` to list the cameras (physical/logical lenses) and
|
|
@@ -252,7 +303,8 @@ const cameras = await getAvailableCameras()
|
|
|
252
303
|
// No argument: the active camera, or the default (back) camera without a preview.
|
|
253
304
|
const caps = await getCapabilities(cameras[0].id)
|
|
254
305
|
// { cameraId, hasFlash, hasTorch, zoomRange?, supportsFocus, focusModes?,
|
|
255
|
-
//
|
|
306
|
+
// exposureRange?, exposureModes?, whiteBalanceModes?, supportsWhiteBalanceTemperature?,
|
|
307
|
+
// photoResolutions?, photoAspects?, videoQualities?, videoFps?, photoFormats?, supportsPause }
|
|
256
308
|
```
|
|
257
309
|
|
|
258
310
|
The plugin follows one rule to avoid ambiguity:
|
|
@@ -282,6 +334,9 @@ webm, 3gp, mkv are treated as video); pass `{ type }` to force it.
|
|
|
282
334
|
```ts
|
|
283
335
|
const state = await checkPermissions() // 'granted' | 'denied' | 'prompt'
|
|
284
336
|
await requestPermissions()
|
|
337
|
+
|
|
338
|
+
// Mobile: open the OS settings screen (e.g. after a permanent denial).
|
|
339
|
+
await openAppSettings()
|
|
285
340
|
```
|
|
286
341
|
|
|
287
342
|
## Image return pattern
|
|
@@ -305,7 +360,7 @@ hardware, emulator or device is required.
|
|
|
305
360
|
| Rust | `src/models.rs` (inline tests) | `cargo test` | plugin root | Rust + Cargo |
|
|
306
361
|
| JS | `guest-js/index.test.ts` | `npm test` | plugin root | Node.js 18+ + npm |
|
|
307
362
|
| Android | `android/src/test/java/CameraPluginTest.kt`| `gradle test` | `android/` (or generated app) | JDK 17+, Android SDK, Gradle |
|
|
308
|
-
| iOS | `ios/Tests/PluginTests/PluginTests.swift` | `
|
|
363
|
+
| iOS | `ios/Tests/PluginTests/PluginTests.swift` | `xcodebuild test` | iOS Simulator (generated app) | macOS + Xcode |
|
|
309
364
|
|
|
310
365
|
### Prerequisites
|
|
311
366
|
|
|
@@ -417,6 +472,12 @@ destination from the generated Xcode project (`src-tauri/gen/apple`).
|
|
|
417
472
|
| `setFlash(mode)` | ✓ | ✗ (throws) |
|
|
418
473
|
| `setZoom(factor)` | ✓ | ✗ (throws) |
|
|
419
474
|
| `getZoom()` | ✓ | ✗ (throws) |
|
|
475
|
+
| `setTorch(on)` | ✓ | ✗ (throws) |
|
|
476
|
+
| `setFocusPoint(x, y)` | ✓ | ✗ (throws) |
|
|
477
|
+
| `setFocusMode(mode)` | ✓ | ✗ (throws) |
|
|
478
|
+
| `setExposureCompensation(ev)` | ✓ | ✗ (throws) |
|
|
479
|
+
| `setExposureMode(mode)` | ✓ | ✗ (throws) |
|
|
480
|
+
| `setWhiteBalance(options)` | ✓ | ✗ (throws) |
|
|
420
481
|
| `getAvailableCameras()` | ✓ | ✓ |
|
|
421
482
|
| `getCapabilities(cameraId?)` | ✓ | ✓ (partial) |
|
|
422
483
|
| `startRecording(options?)` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
|
|
@@ -428,3 +489,88 @@ destination from the generated Xcode project (`src-tauri/gen/apple`).
|
|
|
428
489
|
| `requestPermissions()` | ✓ | ✓ |
|
|
429
490
|
| `openAppSettings()` | ✓ | ✗ (throws) |
|
|
430
491
|
| `getPreviewStream()` | `null` | `MediaStream` |
|
|
492
|
+
|
|
493
|
+
## Types
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
type CameraDirection = 'front' | 'back'
|
|
497
|
+
type FlashMode = 'on' | 'off' | 'auto'
|
|
498
|
+
type CameraFacing = 'front' | 'back' | 'external'
|
|
499
|
+
type CameraLens = 'wide' | 'ultraWide' | 'telephoto' | (string & {})
|
|
500
|
+
type FocusMode = 'auto' | 'continuous' | 'locked'
|
|
501
|
+
type ExposureMode = 'auto' | 'locked'
|
|
502
|
+
type WhiteBalanceMode = 'auto' | 'locked'
|
|
503
|
+
type PhotoFormat = 'jpeg' | 'png' | 'heif'
|
|
504
|
+
type VideoQuality = 'sd' | 'hd' | 'fhd' | 'uhd'
|
|
505
|
+
type PermissionState = 'granted' | 'denied' | 'prompt' // re-exported from @tauri-apps/api/core
|
|
506
|
+
|
|
507
|
+
interface StartPreviewOptions { camera?: CameraDirection; cameraId?: string; windowed?: boolean }
|
|
508
|
+
interface CaptureOptions {
|
|
509
|
+
flash?: FlashMode
|
|
510
|
+
resolution?: Resolution
|
|
511
|
+
aspectRatio?: string
|
|
512
|
+
format?: PhotoFormat
|
|
513
|
+
quality?: number
|
|
514
|
+
}
|
|
515
|
+
interface Resolution { width: number; height: number }
|
|
516
|
+
interface ExposureRange { minEv: number; maxEv: number }
|
|
517
|
+
interface WhiteBalanceOptions { mode: WhiteBalanceMode; temperatureK?: number }
|
|
518
|
+
|
|
519
|
+
interface Photo {
|
|
520
|
+
path?: string // mobile: absolute path to the captured file
|
|
521
|
+
dataUrl?: string // desktop: base64 data URL
|
|
522
|
+
blob?: Blob // desktop
|
|
523
|
+
width: number
|
|
524
|
+
height: number
|
|
525
|
+
orientation: number // EXIF orientation
|
|
526
|
+
format: string
|
|
527
|
+
thumbnail?: string // base64 JPEG thumbnail (mobile; desktop reuses dataUrl)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
interface Video {
|
|
531
|
+
path?: string // mobile
|
|
532
|
+
duration: number
|
|
533
|
+
blob?: Blob // desktop
|
|
534
|
+
url?: string // desktop object URL
|
|
535
|
+
thumbnail?: string // base64 JPEG when requested via stopRecording({ thumbnail: true })
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
interface ZoomRange { min: number; max: number }
|
|
539
|
+
|
|
540
|
+
interface CameraInfo {
|
|
541
|
+
id: string // native, session-stable identifier
|
|
542
|
+
facing?: CameraFacing
|
|
543
|
+
lens?: CameraLens
|
|
544
|
+
label?: string
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
interface CameraCapabilities {
|
|
548
|
+
cameraId?: string
|
|
549
|
+
hasFlash: boolean
|
|
550
|
+
hasTorch: boolean
|
|
551
|
+
zoomRange?: ZoomRange
|
|
552
|
+
supportsFocus: boolean
|
|
553
|
+
focusModes?: string[]
|
|
554
|
+
exposureRange?: ExposureRange
|
|
555
|
+
exposureModes?: ExposureMode[]
|
|
556
|
+
whiteBalanceModes?: WhiteBalanceMode[]
|
|
557
|
+
supportsWhiteBalanceTemperature?: boolean
|
|
558
|
+
photoResolutions?: Resolution[]
|
|
559
|
+
photoAspects?: string[]
|
|
560
|
+
videoQualities?: VideoQuality[]
|
|
561
|
+
videoFps?: number[]
|
|
562
|
+
photoFormats?: string[]
|
|
563
|
+
supportsPause: boolean
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
interface RecordingProgress { duration: number; bytesRecorded?: number; bitrate?: number }
|
|
567
|
+
|
|
568
|
+
interface StartRecordingOptions {
|
|
569
|
+
onProgress?: Channel<RecordingProgress>
|
|
570
|
+
quality?: VideoQuality
|
|
571
|
+
fps?: number
|
|
572
|
+
bitrate?: number
|
|
573
|
+
}
|
|
574
|
+
interface StopRecordingOptions { thumbnail?: boolean }
|
|
575
|
+
interface SaveToGalleryOptions { type?: 'image' | 'video' }
|
|
576
|
+
```
|
package/dist-js/index.cjs
CHANGED
|
@@ -18,6 +18,7 @@ function isMobile() {
|
|
|
18
18
|
let activeStream = null;
|
|
19
19
|
let activeVideo = null;
|
|
20
20
|
let mediaRecorder = null;
|
|
21
|
+
let recordingAudioStream = null;
|
|
21
22
|
let recordedChunks = [];
|
|
22
23
|
let recordedBytes = 0;
|
|
23
24
|
let recordingStartedAt = 0;
|
|
@@ -26,10 +27,28 @@ let progressTimer = null;
|
|
|
26
27
|
const RECORDING_PROGRESS_INTERVAL_MS = 250;
|
|
27
28
|
/** Ask MediaRecorder for data chunks at least this often so size is known live. */
|
|
28
29
|
const RECORDING_TIMESLICE_MS = 1000;
|
|
30
|
+
/** Picks an explicit recording mime type supported by this browser, if any. */
|
|
31
|
+
function pickDesktopRecordingMimeType() {
|
|
32
|
+
if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const candidates = [
|
|
36
|
+
'video/webm;codecs=vp9,opus',
|
|
37
|
+
'video/webm;codecs=vp8,opus',
|
|
38
|
+
'video/webm',
|
|
39
|
+
'video/mp4'
|
|
40
|
+
];
|
|
41
|
+
return candidates.find((type) => MediaRecorder.isTypeSupported(type));
|
|
42
|
+
}
|
|
29
43
|
function buildRecordingProgress(duration, bytes) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
// Omit the size-derived fields until a real chunk has arrived, matching the
|
|
45
|
+
// mobile backends (do not report a misleading 0).
|
|
46
|
+
const progress = { duration };
|
|
47
|
+
if (bytes > 0) {
|
|
48
|
+
progress.bytesRecorded = bytes;
|
|
49
|
+
if (duration > 0) {
|
|
50
|
+
progress.bitrate = Math.round((bytes * 8) / duration);
|
|
51
|
+
}
|
|
33
52
|
}
|
|
34
53
|
return progress;
|
|
35
54
|
}
|
|
@@ -48,12 +67,14 @@ function stopProgressTimer() {
|
|
|
48
67
|
async function ensureDesktopStream(options) {
|
|
49
68
|
if (activeStream)
|
|
50
69
|
return activeStream;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
const videoConstraints = {};
|
|
71
|
+
if (options?.cameraId) {
|
|
72
|
+
videoConstraints.deviceId = { exact: options.cameraId };
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
videoConstraints.facingMode = options?.camera === 'front' ? 'user' : 'environment';
|
|
76
|
+
}
|
|
77
|
+
const constraints = { video: videoConstraints, audio: false };
|
|
57
78
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
58
79
|
activeStream = stream;
|
|
59
80
|
const video = document.createElement('video');
|
|
@@ -70,6 +91,8 @@ function teardownDesktopStream() {
|
|
|
70
91
|
mediaRecorder.stop();
|
|
71
92
|
}
|
|
72
93
|
mediaRecorder = null;
|
|
94
|
+
recordingAudioStream?.getTracks().forEach((t) => t.stop());
|
|
95
|
+
recordingAudioStream = null;
|
|
73
96
|
recordedChunks = [];
|
|
74
97
|
recordedBytes = 0;
|
|
75
98
|
if (activeStream) {
|
|
@@ -78,12 +101,55 @@ function teardownDesktopStream() {
|
|
|
78
101
|
activeStream = null;
|
|
79
102
|
activeVideo = null;
|
|
80
103
|
}
|
|
81
|
-
|
|
104
|
+
/** Parses an aspect ratio like '4:3' into a number (4/3), or null. */
|
|
105
|
+
function parseAspectRatio(value) {
|
|
106
|
+
const [w, h] = value.split(':').map((n) => Number(n));
|
|
107
|
+
if (!w || !h)
|
|
108
|
+
return null;
|
|
109
|
+
return w / h;
|
|
110
|
+
}
|
|
111
|
+
/** Target capture height (px) for a video quality preset (desktop best-effort). */
|
|
112
|
+
function qualityHeight(quality) {
|
|
113
|
+
switch (quality) {
|
|
114
|
+
case 'sd':
|
|
115
|
+
return 480;
|
|
116
|
+
case 'hd':
|
|
117
|
+
return 720;
|
|
118
|
+
case 'fhd':
|
|
119
|
+
return 1080;
|
|
120
|
+
case 'uhd':
|
|
121
|
+
return 2160;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** MIME type for a photo format on desktop, or null when unsupported. */
|
|
125
|
+
function desktopPhotoMime(format) {
|
|
126
|
+
switch (format) {
|
|
127
|
+
case 'jpeg':
|
|
128
|
+
return 'image/jpeg';
|
|
129
|
+
case 'png':
|
|
130
|
+
return 'image/png';
|
|
131
|
+
default:
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function captureDesktopFrame(options) {
|
|
82
136
|
if (!activeVideo) {
|
|
83
137
|
throw new Error('Camera preview is not active. Call startPreview() first.');
|
|
84
138
|
}
|
|
85
|
-
const
|
|
86
|
-
const
|
|
139
|
+
const format = options?.format ?? 'jpeg';
|
|
140
|
+
const mime = desktopPhotoMime(format);
|
|
141
|
+
if (!mime) {
|
|
142
|
+
throw new Error(`${format} is not supported on desktop.`);
|
|
143
|
+
}
|
|
144
|
+
const sourceWidth = activeVideo.videoWidth;
|
|
145
|
+
const sourceHeight = activeVideo.videoHeight;
|
|
146
|
+
let width = options?.resolution?.width ?? sourceWidth;
|
|
147
|
+
let height = options?.resolution?.height ?? sourceHeight;
|
|
148
|
+
if (!options?.resolution && options?.aspectRatio) {
|
|
149
|
+
const ratio = parseAspectRatio(options.aspectRatio);
|
|
150
|
+
if (ratio)
|
|
151
|
+
height = Math.round(width / ratio);
|
|
152
|
+
}
|
|
87
153
|
const canvas = document.createElement('canvas');
|
|
88
154
|
canvas.width = width;
|
|
89
155
|
canvas.height = height;
|
|
@@ -92,13 +158,13 @@ function captureDesktopFrame() {
|
|
|
92
158
|
throw new Error('Failed to create canvas context for capture.');
|
|
93
159
|
}
|
|
94
160
|
ctx.drawImage(activeVideo, 0, 0, width, height);
|
|
95
|
-
const dataUrl = canvas.toDataURL(
|
|
161
|
+
const dataUrl = canvas.toDataURL(mime, options?.quality);
|
|
96
162
|
return {
|
|
97
163
|
dataUrl,
|
|
98
164
|
width,
|
|
99
165
|
height,
|
|
100
166
|
orientation: 1,
|
|
101
|
-
format
|
|
167
|
+
format,
|
|
102
168
|
thumbnail: dataUrl
|
|
103
169
|
};
|
|
104
170
|
}
|
|
@@ -152,7 +218,7 @@ async function capture(options) {
|
|
|
152
218
|
if (isMobile()) {
|
|
153
219
|
return await core.invoke('plugin:camera-kessdev|capture', { ...options });
|
|
154
220
|
}
|
|
155
|
-
return captureDesktopFrame();
|
|
221
|
+
return captureDesktopFrame(options);
|
|
156
222
|
}
|
|
157
223
|
// ---------------------------------------------------------------------------
|
|
158
224
|
// Camera controls
|
|
@@ -273,6 +339,57 @@ async function getCapabilities(cameraId) {
|
|
|
273
339
|
return desktopCapabilities(cameraId);
|
|
274
340
|
}
|
|
275
341
|
// ---------------------------------------------------------------------------
|
|
342
|
+
// Fine controls (torch, focus, exposure, white balance)
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
/** Turns the torch (continuous light) on or off (mobile). */
|
|
345
|
+
async function setTorch(on) {
|
|
346
|
+
if (isMobile()) {
|
|
347
|
+
await core.invoke('plugin:camera-kessdev|set_torch', { on });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
throw new Error('setTorch is not supported on desktop.');
|
|
351
|
+
}
|
|
352
|
+
/** Sets the focus point in normalized coordinates (0..1, top-left origin) (mobile). */
|
|
353
|
+
async function setFocusPoint(x, y) {
|
|
354
|
+
if (isMobile()) {
|
|
355
|
+
await core.invoke('plugin:camera-kessdev|set_focus_point', { x, y });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
throw new Error('setFocusPoint is not supported on desktop.');
|
|
359
|
+
}
|
|
360
|
+
/** Sets the focus mode (mobile). */
|
|
361
|
+
async function setFocusMode(mode) {
|
|
362
|
+
if (isMobile()) {
|
|
363
|
+
await core.invoke('plugin:camera-kessdev|set_focus_mode', { mode });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
throw new Error('setFocusMode is not supported on desktop.');
|
|
367
|
+
}
|
|
368
|
+
/** Sets the exposure compensation in EV (mobile). */
|
|
369
|
+
async function setExposureCompensation(ev) {
|
|
370
|
+
if (isMobile()) {
|
|
371
|
+
await core.invoke('plugin:camera-kessdev|set_exposure_compensation', { ev });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
throw new Error('setExposureCompensation is not supported on desktop.');
|
|
375
|
+
}
|
|
376
|
+
/** Sets the exposure mode (mobile). */
|
|
377
|
+
async function setExposureMode(mode) {
|
|
378
|
+
if (isMobile()) {
|
|
379
|
+
await core.invoke('plugin:camera-kessdev|set_exposure_mode', { mode });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
throw new Error('setExposureMode is not supported on desktop.');
|
|
383
|
+
}
|
|
384
|
+
/** Sets the white balance mode (with an optional Kelvin temperature when `locked`) (mobile). */
|
|
385
|
+
async function setWhiteBalance(options) {
|
|
386
|
+
if (isMobile()) {
|
|
387
|
+
await core.invoke('plugin:camera-kessdev|set_white_balance', { ...options });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
throw new Error('setWhiteBalance is not supported on desktop.');
|
|
391
|
+
}
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
276
393
|
// Video recording
|
|
277
394
|
// ---------------------------------------------------------------------------
|
|
278
395
|
/** Starts video recording (with audio). */
|
|
@@ -281,22 +398,44 @@ async function startRecording(options) {
|
|
|
281
398
|
await core.invoke('plugin:camera-kessdev|start_recording', { ...options });
|
|
282
399
|
return;
|
|
283
400
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
401
|
+
// Reuse the active preview video track when possible so the preview is not
|
|
402
|
+
// invalidated (which would freeze the app's <video> element).
|
|
403
|
+
let videoTrack = activeStream?.getVideoTracks()[0];
|
|
404
|
+
if (!videoTrack) {
|
|
405
|
+
const videoConstraints = {};
|
|
406
|
+
if (options?.quality)
|
|
407
|
+
videoConstraints.height = { ideal: qualityHeight(options.quality) };
|
|
408
|
+
if (options?.fps)
|
|
409
|
+
videoConstraints.frameRate = { ideal: options.fps };
|
|
410
|
+
const videoStream = await navigator.mediaDevices.getUserMedia({
|
|
411
|
+
video: videoConstraints,
|
|
412
|
+
audio: false
|
|
413
|
+
});
|
|
414
|
+
activeStream = videoStream;
|
|
415
|
+
videoTrack = videoStream.getVideoTracks()[0];
|
|
416
|
+
}
|
|
417
|
+
if (!videoTrack) {
|
|
418
|
+
throw new Error('No video track available for recording.');
|
|
288
419
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
420
|
+
if (!activeVideo) {
|
|
421
|
+
const video = document.createElement('video');
|
|
422
|
+
video.muted = true;
|
|
423
|
+
video.playsInline = true;
|
|
424
|
+
video.srcObject = activeStream;
|
|
425
|
+
await video.play();
|
|
426
|
+
activeVideo = video;
|
|
427
|
+
}
|
|
428
|
+
recordingAudioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
429
|
+
const recordStream = new MediaStream([videoTrack, ...recordingAudioStream.getAudioTracks()]);
|
|
297
430
|
recordedChunks = [];
|
|
298
431
|
recordedBytes = 0;
|
|
299
|
-
|
|
432
|
+
const recorderOptions = {};
|
|
433
|
+
if (options?.bitrate)
|
|
434
|
+
recorderOptions.videoBitsPerSecond = options.bitrate;
|
|
435
|
+
const mimeType = pickDesktopRecordingMimeType();
|
|
436
|
+
if (mimeType)
|
|
437
|
+
recorderOptions.mimeType = mimeType;
|
|
438
|
+
mediaRecorder = new MediaRecorder(recordStream, recorderOptions);
|
|
300
439
|
mediaRecorder.ondataavailable = (event) => {
|
|
301
440
|
if (event.data.size > 0) {
|
|
302
441
|
recordedChunks.push(event.data);
|
|
@@ -331,9 +470,12 @@ async function stopRecording(options) {
|
|
|
331
470
|
stopProgressTimer();
|
|
332
471
|
return await new Promise((resolve, reject) => {
|
|
333
472
|
mediaRecorder.onstop = () => {
|
|
334
|
-
const
|
|
473
|
+
const mimeType = mediaRecorder.mimeType || 'video/webm';
|
|
474
|
+
const type = mimeType.split(';')[0];
|
|
335
475
|
const blob = new Blob(recordedChunks, { type });
|
|
336
476
|
const url = URL.createObjectURL(blob);
|
|
477
|
+
recordingAudioStream?.getTracks().forEach((t) => t.stop());
|
|
478
|
+
recordingAudioStream = null;
|
|
337
479
|
recordedChunks = [];
|
|
338
480
|
recordedBytes = 0;
|
|
339
481
|
mediaRecorder = null;
|
|
@@ -436,7 +578,13 @@ exports.getZoom = getZoom;
|
|
|
436
578
|
exports.openAppSettings = openAppSettings;
|
|
437
579
|
exports.requestPermissions = requestPermissions;
|
|
438
580
|
exports.saveToGallery = saveToGallery;
|
|
581
|
+
exports.setExposureCompensation = setExposureCompensation;
|
|
582
|
+
exports.setExposureMode = setExposureMode;
|
|
439
583
|
exports.setFlash = setFlash;
|
|
584
|
+
exports.setFocusMode = setFocusMode;
|
|
585
|
+
exports.setFocusPoint = setFocusPoint;
|
|
586
|
+
exports.setTorch = setTorch;
|
|
587
|
+
exports.setWhiteBalance = setWhiteBalance;
|
|
440
588
|
exports.setZoom = setZoom;
|
|
441
589
|
exports.startPreview = startPreview;
|
|
442
590
|
exports.startRecording = startRecording;
|
package/dist-js/index.d.ts
CHANGED
|
@@ -6,10 +6,27 @@ export type CameraDirection = 'front' | 'back';
|
|
|
6
6
|
export type FlashMode = 'on' | 'off' | 'auto';
|
|
7
7
|
export interface StartPreviewOptions {
|
|
8
8
|
camera?: CameraDirection;
|
|
9
|
+
/** Specific camera/lens id from `getAvailableCameras()`. Takes precedence over `camera`. */
|
|
10
|
+
cameraId?: string;
|
|
9
11
|
windowed?: boolean;
|
|
10
12
|
}
|
|
13
|
+
export type PhotoFormat = 'jpeg' | 'png' | 'heif';
|
|
14
|
+
export type VideoQuality = 'sd' | 'hd' | 'fhd' | 'uhd';
|
|
15
|
+
/** A width/height pair in pixels. */
|
|
16
|
+
export interface Resolution {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
11
20
|
export interface CaptureOptions {
|
|
12
21
|
flash?: FlashMode;
|
|
22
|
+
/** Target resolution (best-effort: the nearest supported is used). */
|
|
23
|
+
resolution?: Resolution;
|
|
24
|
+
/** Target aspect ratio (e.g. '4:3', '16:9', '1:1'). */
|
|
25
|
+
aspectRatio?: string;
|
|
26
|
+
/** Image format. Mobile supports `jpeg`; desktop `jpeg`/`png`. */
|
|
27
|
+
format?: PhotoFormat;
|
|
28
|
+
/** Compression quality (0..1) for lossy formats. */
|
|
29
|
+
quality?: number;
|
|
13
30
|
}
|
|
14
31
|
export interface Photo {
|
|
15
32
|
/** Absolute path to the captured image (mobile). */
|
|
@@ -49,6 +66,23 @@ export type CameraFacing = 'front' | 'back' | 'external';
|
|
|
49
66
|
* determine it (notably desktop) omit the field.
|
|
50
67
|
*/
|
|
51
68
|
export type CameraLens = 'wide' | 'ultraWide' | 'telephoto' | (string & {});
|
|
69
|
+
/** Camera focus mode. */
|
|
70
|
+
export type FocusMode = 'auto' | 'continuous' | 'locked';
|
|
71
|
+
/** Camera exposure mode. */
|
|
72
|
+
export type ExposureMode = 'auto' | 'locked';
|
|
73
|
+
/** White balance mode. */
|
|
74
|
+
export type WhiteBalanceMode = 'auto' | 'locked';
|
|
75
|
+
/** Supported exposure compensation range (EV). */
|
|
76
|
+
export interface ExposureRange {
|
|
77
|
+
minEv: number;
|
|
78
|
+
maxEv: number;
|
|
79
|
+
}
|
|
80
|
+
/** Options for `setWhiteBalance`. */
|
|
81
|
+
export interface WhiteBalanceOptions {
|
|
82
|
+
mode: WhiteBalanceMode;
|
|
83
|
+
/** Color temperature in Kelvin; only meaningful when `mode` is `locked`. */
|
|
84
|
+
temperatureK?: number;
|
|
85
|
+
}
|
|
52
86
|
/** A camera (physical or logical lens) available on the device. */
|
|
53
87
|
export interface CameraInfo {
|
|
54
88
|
/** Native, session-stable identifier (usable to reference this camera). */
|
|
@@ -75,6 +109,15 @@ export interface CameraCapabilities {
|
|
|
75
109
|
zoomRange?: ZoomRange;
|
|
76
110
|
supportsFocus: boolean;
|
|
77
111
|
focusModes?: string[];
|
|
112
|
+
exposureRange?: ExposureRange;
|
|
113
|
+
exposureModes?: ExposureMode[];
|
|
114
|
+
whiteBalanceModes?: WhiteBalanceMode[];
|
|
115
|
+
/** Whether an arbitrary color temperature (Kelvin) can be applied. */
|
|
116
|
+
supportsWhiteBalanceTemperature?: boolean;
|
|
117
|
+
photoResolutions?: Resolution[];
|
|
118
|
+
photoAspects?: string[];
|
|
119
|
+
videoQualities?: VideoQuality[];
|
|
120
|
+
videoFps?: number[];
|
|
78
121
|
photoFormats?: string[];
|
|
79
122
|
supportsPause: boolean;
|
|
80
123
|
}
|
|
@@ -96,6 +139,12 @@ export interface RecordingProgress {
|
|
|
96
139
|
export interface StartRecordingOptions {
|
|
97
140
|
/** Receives live recording telemetry while the recording is active. */
|
|
98
141
|
onProgress?: Channel<RecordingProgress>;
|
|
142
|
+
/** Video quality preset. */
|
|
143
|
+
quality?: VideoQuality;
|
|
144
|
+
/** Target frame rate. */
|
|
145
|
+
fps?: number;
|
|
146
|
+
/** Target bitrate in bits per second. */
|
|
147
|
+
bitrate?: number;
|
|
99
148
|
}
|
|
100
149
|
export interface StopRecordingOptions {
|
|
101
150
|
/** When true, the result includes a base64-encoded `thumbnail`. */
|
|
@@ -154,6 +203,18 @@ export declare function getAvailableCameras(): Promise<CameraInfo[]>;
|
|
|
154
203
|
* Live values are exposed by dedicated getters (e.g. {@link getZoom}).
|
|
155
204
|
*/
|
|
156
205
|
export declare function getCapabilities(cameraId?: string): Promise<CameraCapabilities>;
|
|
206
|
+
/** Turns the torch (continuous light) on or off (mobile). */
|
|
207
|
+
export declare function setTorch(on: boolean): Promise<void>;
|
|
208
|
+
/** Sets the focus point in normalized coordinates (0..1, top-left origin) (mobile). */
|
|
209
|
+
export declare function setFocusPoint(x: number, y: number): Promise<void>;
|
|
210
|
+
/** Sets the focus mode (mobile). */
|
|
211
|
+
export declare function setFocusMode(mode: FocusMode): Promise<void>;
|
|
212
|
+
/** Sets the exposure compensation in EV (mobile). */
|
|
213
|
+
export declare function setExposureCompensation(ev: number): Promise<void>;
|
|
214
|
+
/** Sets the exposure mode (mobile). */
|
|
215
|
+
export declare function setExposureMode(mode: ExposureMode): Promise<void>;
|
|
216
|
+
/** Sets the white balance mode (with an optional Kelvin temperature when `locked`) (mobile). */
|
|
217
|
+
export declare function setWhiteBalance(options: WhiteBalanceOptions): Promise<void>;
|
|
157
218
|
/** Starts video recording (with audio). */
|
|
158
219
|
export declare function startRecording(options?: StartRecordingOptions): Promise<void>;
|
|
159
220
|
/** Stops video recording and returns the recorded media. */
|
package/dist-js/index.js
CHANGED
|
@@ -17,6 +17,7 @@ function isMobile() {
|
|
|
17
17
|
let activeStream = null;
|
|
18
18
|
let activeVideo = null;
|
|
19
19
|
let mediaRecorder = null;
|
|
20
|
+
let recordingAudioStream = null;
|
|
20
21
|
let recordedChunks = [];
|
|
21
22
|
let recordedBytes = 0;
|
|
22
23
|
let recordingStartedAt = 0;
|
|
@@ -25,10 +26,28 @@ let progressTimer = null;
|
|
|
25
26
|
const RECORDING_PROGRESS_INTERVAL_MS = 250;
|
|
26
27
|
/** Ask MediaRecorder for data chunks at least this often so size is known live. */
|
|
27
28
|
const RECORDING_TIMESLICE_MS = 1000;
|
|
29
|
+
/** Picks an explicit recording mime type supported by this browser, if any. */
|
|
30
|
+
function pickDesktopRecordingMimeType() {
|
|
31
|
+
if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const candidates = [
|
|
35
|
+
'video/webm;codecs=vp9,opus',
|
|
36
|
+
'video/webm;codecs=vp8,opus',
|
|
37
|
+
'video/webm',
|
|
38
|
+
'video/mp4'
|
|
39
|
+
];
|
|
40
|
+
return candidates.find((type) => MediaRecorder.isTypeSupported(type));
|
|
41
|
+
}
|
|
28
42
|
function buildRecordingProgress(duration, bytes) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
// Omit the size-derived fields until a real chunk has arrived, matching the
|
|
44
|
+
// mobile backends (do not report a misleading 0).
|
|
45
|
+
const progress = { duration };
|
|
46
|
+
if (bytes > 0) {
|
|
47
|
+
progress.bytesRecorded = bytes;
|
|
48
|
+
if (duration > 0) {
|
|
49
|
+
progress.bitrate = Math.round((bytes * 8) / duration);
|
|
50
|
+
}
|
|
32
51
|
}
|
|
33
52
|
return progress;
|
|
34
53
|
}
|
|
@@ -47,12 +66,14 @@ function stopProgressTimer() {
|
|
|
47
66
|
async function ensureDesktopStream(options) {
|
|
48
67
|
if (activeStream)
|
|
49
68
|
return activeStream;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
const videoConstraints = {};
|
|
70
|
+
if (options?.cameraId) {
|
|
71
|
+
videoConstraints.deviceId = { exact: options.cameraId };
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
videoConstraints.facingMode = options?.camera === 'front' ? 'user' : 'environment';
|
|
75
|
+
}
|
|
76
|
+
const constraints = { video: videoConstraints, audio: false };
|
|
56
77
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
57
78
|
activeStream = stream;
|
|
58
79
|
const video = document.createElement('video');
|
|
@@ -69,6 +90,8 @@ function teardownDesktopStream() {
|
|
|
69
90
|
mediaRecorder.stop();
|
|
70
91
|
}
|
|
71
92
|
mediaRecorder = null;
|
|
93
|
+
recordingAudioStream?.getTracks().forEach((t) => t.stop());
|
|
94
|
+
recordingAudioStream = null;
|
|
72
95
|
recordedChunks = [];
|
|
73
96
|
recordedBytes = 0;
|
|
74
97
|
if (activeStream) {
|
|
@@ -77,12 +100,55 @@ function teardownDesktopStream() {
|
|
|
77
100
|
activeStream = null;
|
|
78
101
|
activeVideo = null;
|
|
79
102
|
}
|
|
80
|
-
|
|
103
|
+
/** Parses an aspect ratio like '4:3' into a number (4/3), or null. */
|
|
104
|
+
function parseAspectRatio(value) {
|
|
105
|
+
const [w, h] = value.split(':').map((n) => Number(n));
|
|
106
|
+
if (!w || !h)
|
|
107
|
+
return null;
|
|
108
|
+
return w / h;
|
|
109
|
+
}
|
|
110
|
+
/** Target capture height (px) for a video quality preset (desktop best-effort). */
|
|
111
|
+
function qualityHeight(quality) {
|
|
112
|
+
switch (quality) {
|
|
113
|
+
case 'sd':
|
|
114
|
+
return 480;
|
|
115
|
+
case 'hd':
|
|
116
|
+
return 720;
|
|
117
|
+
case 'fhd':
|
|
118
|
+
return 1080;
|
|
119
|
+
case 'uhd':
|
|
120
|
+
return 2160;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** MIME type for a photo format on desktop, or null when unsupported. */
|
|
124
|
+
function desktopPhotoMime(format) {
|
|
125
|
+
switch (format) {
|
|
126
|
+
case 'jpeg':
|
|
127
|
+
return 'image/jpeg';
|
|
128
|
+
case 'png':
|
|
129
|
+
return 'image/png';
|
|
130
|
+
default:
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function captureDesktopFrame(options) {
|
|
81
135
|
if (!activeVideo) {
|
|
82
136
|
throw new Error('Camera preview is not active. Call startPreview() first.');
|
|
83
137
|
}
|
|
84
|
-
const
|
|
85
|
-
const
|
|
138
|
+
const format = options?.format ?? 'jpeg';
|
|
139
|
+
const mime = desktopPhotoMime(format);
|
|
140
|
+
if (!mime) {
|
|
141
|
+
throw new Error(`${format} is not supported on desktop.`);
|
|
142
|
+
}
|
|
143
|
+
const sourceWidth = activeVideo.videoWidth;
|
|
144
|
+
const sourceHeight = activeVideo.videoHeight;
|
|
145
|
+
let width = options?.resolution?.width ?? sourceWidth;
|
|
146
|
+
let height = options?.resolution?.height ?? sourceHeight;
|
|
147
|
+
if (!options?.resolution && options?.aspectRatio) {
|
|
148
|
+
const ratio = parseAspectRatio(options.aspectRatio);
|
|
149
|
+
if (ratio)
|
|
150
|
+
height = Math.round(width / ratio);
|
|
151
|
+
}
|
|
86
152
|
const canvas = document.createElement('canvas');
|
|
87
153
|
canvas.width = width;
|
|
88
154
|
canvas.height = height;
|
|
@@ -91,13 +157,13 @@ function captureDesktopFrame() {
|
|
|
91
157
|
throw new Error('Failed to create canvas context for capture.');
|
|
92
158
|
}
|
|
93
159
|
ctx.drawImage(activeVideo, 0, 0, width, height);
|
|
94
|
-
const dataUrl = canvas.toDataURL(
|
|
160
|
+
const dataUrl = canvas.toDataURL(mime, options?.quality);
|
|
95
161
|
return {
|
|
96
162
|
dataUrl,
|
|
97
163
|
width,
|
|
98
164
|
height,
|
|
99
165
|
orientation: 1,
|
|
100
|
-
format
|
|
166
|
+
format,
|
|
101
167
|
thumbnail: dataUrl
|
|
102
168
|
};
|
|
103
169
|
}
|
|
@@ -151,7 +217,7 @@ async function capture(options) {
|
|
|
151
217
|
if (isMobile()) {
|
|
152
218
|
return await invoke('plugin:camera-kessdev|capture', { ...options });
|
|
153
219
|
}
|
|
154
|
-
return captureDesktopFrame();
|
|
220
|
+
return captureDesktopFrame(options);
|
|
155
221
|
}
|
|
156
222
|
// ---------------------------------------------------------------------------
|
|
157
223
|
// Camera controls
|
|
@@ -272,6 +338,57 @@ async function getCapabilities(cameraId) {
|
|
|
272
338
|
return desktopCapabilities(cameraId);
|
|
273
339
|
}
|
|
274
340
|
// ---------------------------------------------------------------------------
|
|
341
|
+
// Fine controls (torch, focus, exposure, white balance)
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
/** Turns the torch (continuous light) on or off (mobile). */
|
|
344
|
+
async function setTorch(on) {
|
|
345
|
+
if (isMobile()) {
|
|
346
|
+
await invoke('plugin:camera-kessdev|set_torch', { on });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
throw new Error('setTorch is not supported on desktop.');
|
|
350
|
+
}
|
|
351
|
+
/** Sets the focus point in normalized coordinates (0..1, top-left origin) (mobile). */
|
|
352
|
+
async function setFocusPoint(x, y) {
|
|
353
|
+
if (isMobile()) {
|
|
354
|
+
await invoke('plugin:camera-kessdev|set_focus_point', { x, y });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
throw new Error('setFocusPoint is not supported on desktop.');
|
|
358
|
+
}
|
|
359
|
+
/** Sets the focus mode (mobile). */
|
|
360
|
+
async function setFocusMode(mode) {
|
|
361
|
+
if (isMobile()) {
|
|
362
|
+
await invoke('plugin:camera-kessdev|set_focus_mode', { mode });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
throw new Error('setFocusMode is not supported on desktop.');
|
|
366
|
+
}
|
|
367
|
+
/** Sets the exposure compensation in EV (mobile). */
|
|
368
|
+
async function setExposureCompensation(ev) {
|
|
369
|
+
if (isMobile()) {
|
|
370
|
+
await invoke('plugin:camera-kessdev|set_exposure_compensation', { ev });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
throw new Error('setExposureCompensation is not supported on desktop.');
|
|
374
|
+
}
|
|
375
|
+
/** Sets the exposure mode (mobile). */
|
|
376
|
+
async function setExposureMode(mode) {
|
|
377
|
+
if (isMobile()) {
|
|
378
|
+
await invoke('plugin:camera-kessdev|set_exposure_mode', { mode });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
throw new Error('setExposureMode is not supported on desktop.');
|
|
382
|
+
}
|
|
383
|
+
/** Sets the white balance mode (with an optional Kelvin temperature when `locked`) (mobile). */
|
|
384
|
+
async function setWhiteBalance(options) {
|
|
385
|
+
if (isMobile()) {
|
|
386
|
+
await invoke('plugin:camera-kessdev|set_white_balance', { ...options });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
throw new Error('setWhiteBalance is not supported on desktop.');
|
|
390
|
+
}
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
275
392
|
// Video recording
|
|
276
393
|
// ---------------------------------------------------------------------------
|
|
277
394
|
/** Starts video recording (with audio). */
|
|
@@ -280,22 +397,44 @@ async function startRecording(options) {
|
|
|
280
397
|
await invoke('plugin:camera-kessdev|start_recording', { ...options });
|
|
281
398
|
return;
|
|
282
399
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
400
|
+
// Reuse the active preview video track when possible so the preview is not
|
|
401
|
+
// invalidated (which would freeze the app's <video> element).
|
|
402
|
+
let videoTrack = activeStream?.getVideoTracks()[0];
|
|
403
|
+
if (!videoTrack) {
|
|
404
|
+
const videoConstraints = {};
|
|
405
|
+
if (options?.quality)
|
|
406
|
+
videoConstraints.height = { ideal: qualityHeight(options.quality) };
|
|
407
|
+
if (options?.fps)
|
|
408
|
+
videoConstraints.frameRate = { ideal: options.fps };
|
|
409
|
+
const videoStream = await navigator.mediaDevices.getUserMedia({
|
|
410
|
+
video: videoConstraints,
|
|
411
|
+
audio: false
|
|
412
|
+
});
|
|
413
|
+
activeStream = videoStream;
|
|
414
|
+
videoTrack = videoStream.getVideoTracks()[0];
|
|
415
|
+
}
|
|
416
|
+
if (!videoTrack) {
|
|
417
|
+
throw new Error('No video track available for recording.');
|
|
287
418
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
419
|
+
if (!activeVideo) {
|
|
420
|
+
const video = document.createElement('video');
|
|
421
|
+
video.muted = true;
|
|
422
|
+
video.playsInline = true;
|
|
423
|
+
video.srcObject = activeStream;
|
|
424
|
+
await video.play();
|
|
425
|
+
activeVideo = video;
|
|
426
|
+
}
|
|
427
|
+
recordingAudioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
428
|
+
const recordStream = new MediaStream([videoTrack, ...recordingAudioStream.getAudioTracks()]);
|
|
296
429
|
recordedChunks = [];
|
|
297
430
|
recordedBytes = 0;
|
|
298
|
-
|
|
431
|
+
const recorderOptions = {};
|
|
432
|
+
if (options?.bitrate)
|
|
433
|
+
recorderOptions.videoBitsPerSecond = options.bitrate;
|
|
434
|
+
const mimeType = pickDesktopRecordingMimeType();
|
|
435
|
+
if (mimeType)
|
|
436
|
+
recorderOptions.mimeType = mimeType;
|
|
437
|
+
mediaRecorder = new MediaRecorder(recordStream, recorderOptions);
|
|
299
438
|
mediaRecorder.ondataavailable = (event) => {
|
|
300
439
|
if (event.data.size > 0) {
|
|
301
440
|
recordedChunks.push(event.data);
|
|
@@ -330,9 +469,12 @@ async function stopRecording(options) {
|
|
|
330
469
|
stopProgressTimer();
|
|
331
470
|
return await new Promise((resolve, reject) => {
|
|
332
471
|
mediaRecorder.onstop = () => {
|
|
333
|
-
const
|
|
472
|
+
const mimeType = mediaRecorder.mimeType || 'video/webm';
|
|
473
|
+
const type = mimeType.split(';')[0];
|
|
334
474
|
const blob = new Blob(recordedChunks, { type });
|
|
335
475
|
const url = URL.createObjectURL(blob);
|
|
476
|
+
recordingAudioStream?.getTracks().forEach((t) => t.stop());
|
|
477
|
+
recordingAudioStream = null;
|
|
336
478
|
recordedChunks = [];
|
|
337
479
|
recordedBytes = 0;
|
|
338
480
|
mediaRecorder = null;
|
|
@@ -421,4 +563,4 @@ async function openAppSettings() {
|
|
|
421
563
|
throw new Error('openAppSettings is not supported on desktop.');
|
|
422
564
|
}
|
|
423
565
|
|
|
424
|
-
export { capture, checkPermissions, flipCamera, getAvailableCameras, getCapabilities, getPreviewStream, getZoom, openAppSettings, requestPermissions, saveToGallery, setFlash, setZoom, startPreview, startRecording, stopPreview, stopRecording };
|
|
566
|
+
export { capture, checkPermissions, flipCamera, getAvailableCameras, getCapabilities, getPreviewStream, getZoom, openAppSettings, requestPermissions, saveToGallery, setExposureCompensation, setExposureMode, setFlash, setFocusMode, setFocusPoint, setTorch, setWhiteBalance, setZoom, startPreview, startRecording, stopPreview, stopRecording };
|
package/package.json
CHANGED