tauri-plugin-camera-kessdev 0.1.1-beta.2 → 0.1.1-beta.4
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 +87 -4
- package/dist-js/index.cjs +109 -11
- package/dist-js/index.d.ts +54 -6
- package/dist-js/index.js +107 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ photo gallery, and camera permission management.
|
|
|
17
17
|
> plus `<video>`/`<canvas>`. On mobile it uses a native preview (CameraX on Android,
|
|
18
18
|
> AVFoundation on iOS) for full control and quality.
|
|
19
19
|
|
|
20
|
+
> **Development note (AI assistance).** This plugin is being developed with the assistance of AI tools. AI may have generated parts of the code, tests and documentation. Every change is reviewed and verified by maintainers (automated tests and CI) before release, but we disclose this for transparency. Please report any issue you find.
|
|
21
|
+
|
|
20
22
|
## Install
|
|
21
23
|
|
|
22
24
|
Add the Rust crate to `src-tauri/Cargo.toml`:
|
|
@@ -105,6 +107,13 @@ and the real gate is the operating system:
|
|
|
105
107
|
"Let desktop apps access your camera".
|
|
106
108
|
- **Linux**: the desktop portal (e.g. `xdg-desktop-portal`) governs camera access.
|
|
107
109
|
|
|
110
|
+
> **Maintenance note:** the Windows handler uses the raw `webview2-com` API, whose
|
|
111
|
+
> version must match the one `wry` links against through `tauri`. When bumping
|
|
112
|
+
> `tauri`/`wry`, bump the `[target.'cfg(target_os = "windows")'.dependencies]`
|
|
113
|
+
> `webview2-com` version in `Cargo.toml` to the same minor line as wry; otherwise
|
|
114
|
+
> the build fails with an `E0277` trait-bound error. The `verify-windows` CI job
|
|
115
|
+
> guards this.
|
|
116
|
+
|
|
108
117
|
On **macOS**, `WKWebView` additionally requires `NSCameraUsageDescription` and
|
|
109
118
|
`NSMicrophoneUsageDescription` in the `Info.plist` (the same keys above).
|
|
110
119
|
|
|
@@ -119,7 +128,10 @@ import {
|
|
|
119
128
|
stopRecording,
|
|
120
129
|
flipCamera,
|
|
121
130
|
setFlash,
|
|
131
|
+
isFlashAvailable,
|
|
122
132
|
setZoom,
|
|
133
|
+
getZoom,
|
|
134
|
+
getZoomRange,
|
|
123
135
|
saveToGallery,
|
|
124
136
|
checkPermissions,
|
|
125
137
|
requestPermissions,
|
|
@@ -159,22 +171,88 @@ await startRecording()
|
|
|
159
171
|
// ... user records ...
|
|
160
172
|
const video = await stopRecording()
|
|
161
173
|
// video: { path, duration } (mobile) | { duration, blob, url } (desktop)
|
|
174
|
+
|
|
175
|
+
// Optionally request a base64 JPEG thumbnail of the recording:
|
|
176
|
+
const withThumbnail = await stopRecording({ thumbnail: true })
|
|
177
|
+
// withThumbnail.thumbnail: string | undefined
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Video thumbnail
|
|
181
|
+
|
|
182
|
+
Pass `{ thumbnail: true }` to `stopRecording()` to include a base64-encoded JPEG
|
|
183
|
+
poster frame in the `Video` result. It is opt-in because it costs a frame decode
|
|
184
|
+
and encode. When it cannot be generated the field is omitted (the recording still
|
|
185
|
+
succeeds).
|
|
186
|
+
|
|
187
|
+
- **Android**: extracted with `MediaMetadataRetriever` on the recording executor.
|
|
188
|
+
- **iOS**: extracted with `AVAssetImageGenerator` at ~0.1 s (avoids a black first frame).
|
|
189
|
+
- **Desktop**: captured from the live preview stream at stop time.
|
|
190
|
+
|
|
191
|
+
### Recording progress (telemetry)
|
|
192
|
+
|
|
193
|
+
Pass an `onProgress` channel to `startRecording()` to receive live telemetry while
|
|
194
|
+
the recording is in progress. The channel is optional: omit it and recording
|
|
195
|
+
behaves exactly as before.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { Channel } from '@tauri-apps/api/core'
|
|
199
|
+
import { startRecording, stopRecording, type RecordingProgress } from 'tauri-plugin-camera-kessdev'
|
|
200
|
+
|
|
201
|
+
const progress = new Channel<RecordingProgress>()
|
|
202
|
+
progress.onmessage = (p) => {
|
|
203
|
+
// p: { duration, bytesRecorded?, bitrate? }
|
|
204
|
+
console.log(`${p.duration.toFixed(1)}s`, p.bytesRecorded, 'bytes')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await startRecording({ onProgress: progress })
|
|
208
|
+
// ... user records ...
|
|
209
|
+
const video = await stopRecording()
|
|
162
210
|
```
|
|
163
211
|
|
|
212
|
+
`RecordingProgress` fields:
|
|
213
|
+
|
|
214
|
+
| Field | Type | Description |
|
|
215
|
+
|-------|------|-------------|
|
|
216
|
+
| `duration` | `number` | Elapsed recording time in seconds. Always present. |
|
|
217
|
+
| `bytesRecorded` | `number?` | Bytes written so far, when the platform reports it. |
|
|
218
|
+
| `bitrate` | `number?` | Average bitrate in bits per second, when derivable. |
|
|
219
|
+
|
|
220
|
+
Platform notes:
|
|
221
|
+
|
|
222
|
+
- **Android**: telemetry is derived from CameraX `VideoRecordEvent.Status`, emitted at roughly 1 Hz natively. `bytesRecorded` is accurate.
|
|
223
|
+
- **iOS**: telemetry is sampled from `AVCaptureMovieFileOutput` every 500 ms. `bytesRecorded` is omitted until `recordedFileSize` becomes available, so early messages may only carry `duration`.
|
|
224
|
+
- **Desktop**: `duration` is computed from `performance.now()` and `bytesRecorded` from `MediaRecorder` data chunks. Because `MediaRecorder` only emits a chunk every 1000 ms, `bytesRecorded` can lag by up to one second.
|
|
225
|
+
|
|
226
|
+
Messages are throttled to about 4 per second and stop as soon as the recording
|
|
227
|
+
ends. Telemetry is never emitted after `stopRecording()` resolves.
|
|
228
|
+
|
|
164
229
|
### Camera controls
|
|
165
230
|
|
|
166
231
|
```ts
|
|
167
232
|
await flipCamera()
|
|
168
233
|
await setFlash('on') // 'on' | 'off' | 'auto'
|
|
169
234
|
await setZoom(2.0)
|
|
235
|
+
|
|
236
|
+
// Query the active camera (mobile). Desktop has no zoom/flash control:
|
|
237
|
+
const zoom = await getZoom() // number
|
|
238
|
+
const range = await getZoomRange() // { min, max, current }
|
|
239
|
+
const hasFlash = await isFlashAvailable() // boolean (false on desktop)
|
|
170
240
|
```
|
|
171
241
|
|
|
242
|
+
On desktop `getZoom()` and `getZoomRange()` reject with a descriptive error
|
|
243
|
+
(consistent with `setZoom`), while `isFlashAvailable()` resolves to `false`.
|
|
244
|
+
|
|
172
245
|
### Gallery
|
|
173
246
|
|
|
174
247
|
```ts
|
|
175
|
-
await saveToGallery(photo.path)
|
|
248
|
+
await saveToGallery(photo.path) // image
|
|
249
|
+
await saveToGallery(video.path) // video
|
|
250
|
+
await saveToGallery(media.path, { type: 'video' }) // forced type override
|
|
176
251
|
```
|
|
177
252
|
|
|
253
|
+
Mobile only. The media type is inferred from the file extension (mp4, mov, m4v,
|
|
254
|
+
webm, 3gp, mkv are treated as video); pass `{ type }` to force it.
|
|
255
|
+
|
|
178
256
|
### Permissions
|
|
179
257
|
|
|
180
258
|
```ts
|
|
@@ -313,10 +391,15 @@ destination from the generated Xcode project (`src-tauri/gen/apple`).
|
|
|
313
391
|
| `capture(options?)` | ✓ `ImageCapture` / `AVCapturePhotoOutput` | ✓ canvas |
|
|
314
392
|
| `flipCamera()` | ✓ | ✗ (throws) |
|
|
315
393
|
| `setFlash(mode)` | ✓ | ✗ (throws) |
|
|
394
|
+
| `isFlashAvailable()` | ✓ | `false` |
|
|
316
395
|
| `setZoom(factor)` | ✓ | ✗ (throws) |
|
|
317
|
-
| `
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
396
|
+
| `getZoom()` | ✓ | ✗ (throws) |
|
|
397
|
+
| `getZoomRange()` | ✓ | ✗ (throws) |
|
|
398
|
+
| `startRecording(options?)` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
|
|
399
|
+
| `stopRecording(options?)` | ✓ | ✓ |
|
|
400
|
+
| `RecordingProgress` telemetry (`onProgress` channel) | ✓ | ✓ |
|
|
401
|
+
| `Video.thumbnail` (`stopRecording({ thumbnail: true })`) | ✓ | ✓ |
|
|
402
|
+
| `saveToGallery(path, options?)` | ✓ image & video | ✗ (throws) |
|
|
320
403
|
| `checkPermissions()` | ✓ | ✓ |
|
|
321
404
|
| `requestPermissions()` | ✓ | ✓ |
|
|
322
405
|
| `openAppSettings()` | ✓ | ✗ (throws) |
|
package/dist-js/index.cjs
CHANGED
|
@@ -19,7 +19,32 @@ let activeStream = null;
|
|
|
19
19
|
let activeVideo = null;
|
|
20
20
|
let mediaRecorder = null;
|
|
21
21
|
let recordedChunks = [];
|
|
22
|
+
let recordedBytes = 0;
|
|
22
23
|
let recordingStartedAt = 0;
|
|
24
|
+
let progressTimer = null;
|
|
25
|
+
/** Minimum interval between desktop progress messages (~4 Hz). */
|
|
26
|
+
const RECORDING_PROGRESS_INTERVAL_MS = 250;
|
|
27
|
+
/** Ask MediaRecorder for data chunks at least this often so size is known live. */
|
|
28
|
+
const RECORDING_TIMESLICE_MS = 1000;
|
|
29
|
+
function buildRecordingProgress(duration, bytes) {
|
|
30
|
+
const progress = { duration, bytesRecorded: bytes };
|
|
31
|
+
if (duration > 0) {
|
|
32
|
+
progress.bitrate = Math.round((bytes * 8) / duration);
|
|
33
|
+
}
|
|
34
|
+
return progress;
|
|
35
|
+
}
|
|
36
|
+
function progressesAreEqual(a, b) {
|
|
37
|
+
return (a != null &&
|
|
38
|
+
a.duration === b.duration &&
|
|
39
|
+
a.bytesRecorded === b.bytesRecorded &&
|
|
40
|
+
a.bitrate === b.bitrate);
|
|
41
|
+
}
|
|
42
|
+
function stopProgressTimer() {
|
|
43
|
+
if (progressTimer !== null) {
|
|
44
|
+
clearInterval(progressTimer);
|
|
45
|
+
progressTimer = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
23
48
|
async function ensureDesktopStream(options) {
|
|
24
49
|
if (activeStream)
|
|
25
50
|
return activeStream;
|
|
@@ -40,11 +65,13 @@ async function ensureDesktopStream(options) {
|
|
|
40
65
|
return stream;
|
|
41
66
|
}
|
|
42
67
|
function teardownDesktopStream() {
|
|
68
|
+
stopProgressTimer();
|
|
43
69
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
44
70
|
mediaRecorder.stop();
|
|
45
71
|
}
|
|
46
72
|
mediaRecorder = null;
|
|
47
73
|
recordedChunks = [];
|
|
74
|
+
recordedBytes = 0;
|
|
48
75
|
if (activeStream) {
|
|
49
76
|
activeStream.getTracks().forEach((t) => t.stop());
|
|
50
77
|
}
|
|
@@ -154,13 +181,38 @@ async function setZoom(factor) {
|
|
|
154
181
|
}
|
|
155
182
|
throw new Error('setZoom is not supported on desktop.');
|
|
156
183
|
}
|
|
184
|
+
/** Gets the current camera zoom factor (mobile). */
|
|
185
|
+
async function getZoom() {
|
|
186
|
+
if (isMobile()) {
|
|
187
|
+
return await core.invoke('plugin:camera-kessdev|get_zoom');
|
|
188
|
+
}
|
|
189
|
+
throw new Error('getZoom is not supported on desktop.');
|
|
190
|
+
}
|
|
191
|
+
/** Gets the supported zoom range of the active camera (mobile). */
|
|
192
|
+
async function getZoomRange() {
|
|
193
|
+
if (isMobile()) {
|
|
194
|
+
return await core.invoke('plugin:camera-kessdev|get_zoom_range');
|
|
195
|
+
}
|
|
196
|
+
throw new Error('getZoomRange is not supported on desktop.');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Returns whether the active camera has a flash unit.
|
|
200
|
+
*
|
|
201
|
+
* On desktop this always resolves to `false` (no flash control).
|
|
202
|
+
*/
|
|
203
|
+
async function isFlashAvailable() {
|
|
204
|
+
if (isMobile()) {
|
|
205
|
+
return await core.invoke('plugin:camera-kessdev|is_flash_available');
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
157
209
|
// ---------------------------------------------------------------------------
|
|
158
210
|
// Video recording
|
|
159
211
|
// ---------------------------------------------------------------------------
|
|
160
212
|
/** Starts video recording (with audio). */
|
|
161
|
-
async function startRecording() {
|
|
213
|
+
async function startRecording(options) {
|
|
162
214
|
if (isMobile()) {
|
|
163
|
-
await core.invoke('plugin:camera-kessdev|start_recording');
|
|
215
|
+
await core.invoke('plugin:camera-kessdev|start_recording', { ...options });
|
|
164
216
|
return;
|
|
165
217
|
}
|
|
166
218
|
if (activeStream) {
|
|
@@ -177,47 +229,90 @@ async function startRecording() {
|
|
|
177
229
|
await video.play();
|
|
178
230
|
activeVideo = video;
|
|
179
231
|
recordedChunks = [];
|
|
232
|
+
recordedBytes = 0;
|
|
180
233
|
mediaRecorder = new MediaRecorder(stream);
|
|
181
234
|
mediaRecorder.ondataavailable = (event) => {
|
|
182
|
-
if (event.data.size > 0)
|
|
235
|
+
if (event.data.size > 0) {
|
|
183
236
|
recordedChunks.push(event.data);
|
|
237
|
+
recordedBytes += event.data.size;
|
|
238
|
+
}
|
|
184
239
|
};
|
|
185
240
|
recordingStartedAt = performance.now();
|
|
186
|
-
mediaRecorder.start();
|
|
241
|
+
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
|
242
|
+
const onProgress = options?.onProgress;
|
|
243
|
+
if (onProgress) {
|
|
244
|
+
stopProgressTimer();
|
|
245
|
+
let last = null;
|
|
246
|
+
progressTimer = setInterval(() => {
|
|
247
|
+
const progress = buildRecordingProgress((performance.now() - recordingStartedAt) / 1000, recordedBytes);
|
|
248
|
+
if (!progressesAreEqual(last, progress)) {
|
|
249
|
+
last = progress;
|
|
250
|
+
onProgress.onmessage(progress);
|
|
251
|
+
}
|
|
252
|
+
}, RECORDING_PROGRESS_INTERVAL_MS);
|
|
253
|
+
}
|
|
187
254
|
}
|
|
188
255
|
/** Stops video recording and returns the recorded media. */
|
|
189
|
-
async function stopRecording() {
|
|
256
|
+
async function stopRecording(options) {
|
|
190
257
|
if (isMobile()) {
|
|
191
|
-
return await core.invoke('plugin:camera-kessdev|stop_recording');
|
|
258
|
+
return await core.invoke('plugin:camera-kessdev|stop_recording', { ...options });
|
|
192
259
|
}
|
|
193
260
|
if (!mediaRecorder) {
|
|
194
261
|
throw new Error('Recording is not active. Call startRecording() first.');
|
|
195
262
|
}
|
|
196
263
|
const duration = (performance.now() - recordingStartedAt) / 1000;
|
|
264
|
+
const thumbnail = options?.thumbnail ? captureDesktopThumbnail() : undefined;
|
|
265
|
+
stopProgressTimer();
|
|
197
266
|
return await new Promise((resolve, reject) => {
|
|
198
267
|
mediaRecorder.onstop = () => {
|
|
199
268
|
const type = mediaRecorder.mimeType || 'video/webm';
|
|
200
269
|
const blob = new Blob(recordedChunks, { type });
|
|
201
270
|
const url = URL.createObjectURL(blob);
|
|
202
271
|
recordedChunks = [];
|
|
272
|
+
recordedBytes = 0;
|
|
203
273
|
mediaRecorder = null;
|
|
204
|
-
|
|
274
|
+
const result = { duration, blob, url };
|
|
275
|
+
if (thumbnail)
|
|
276
|
+
result.thumbnail = thumbnail;
|
|
277
|
+
resolve(result);
|
|
205
278
|
};
|
|
206
279
|
mediaRecorder.onerror = () => reject(new Error('Video recording failed.'));
|
|
207
280
|
mediaRecorder.stop();
|
|
208
281
|
});
|
|
209
282
|
}
|
|
283
|
+
/** Captures the current desktop preview frame as a base64 JPEG thumbnail. */
|
|
284
|
+
function captureDesktopThumbnail() {
|
|
285
|
+
if (!activeVideo || !activeVideo.videoWidth || !activeVideo.videoHeight) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const canvas = document.createElement('canvas');
|
|
290
|
+
canvas.width = activeVideo.videoWidth;
|
|
291
|
+
canvas.height = activeVideo.videoHeight;
|
|
292
|
+
const ctx = canvas.getContext('2d');
|
|
293
|
+
if (!ctx)
|
|
294
|
+
return undefined;
|
|
295
|
+
ctx.drawImage(activeVideo, 0, 0, canvas.width, canvas.height);
|
|
296
|
+
return canvas.toDataURL('image/jpeg');
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
210
302
|
// ---------------------------------------------------------------------------
|
|
211
303
|
// Gallery
|
|
212
304
|
// ---------------------------------------------------------------------------
|
|
213
305
|
/**
|
|
214
|
-
* Saves a captured
|
|
306
|
+
* Saves a captured photo or video to the device gallery (mobile).
|
|
307
|
+
*
|
|
308
|
+
* The media type is inferred from the file extension; pass `options.type` to
|
|
309
|
+
* force it. On desktop this is not supported.
|
|
215
310
|
*
|
|
216
|
-
* @param path Absolute path to the
|
|
311
|
+
* @param path Absolute path to the media file.
|
|
217
312
|
*/
|
|
218
|
-
async function saveToGallery(path) {
|
|
313
|
+
async function saveToGallery(path, options) {
|
|
219
314
|
if (isMobile()) {
|
|
220
|
-
await core.invoke('plugin:camera-kessdev|save_to_gallery', { path });
|
|
315
|
+
await core.invoke('plugin:camera-kessdev|save_to_gallery', { path, ...options });
|
|
221
316
|
return;
|
|
222
317
|
}
|
|
223
318
|
throw new Error('saveToGallery is not supported on desktop.');
|
|
@@ -269,6 +364,9 @@ exports.capture = capture;
|
|
|
269
364
|
exports.checkPermissions = checkPermissions;
|
|
270
365
|
exports.flipCamera = flipCamera;
|
|
271
366
|
exports.getPreviewStream = getPreviewStream;
|
|
367
|
+
exports.getZoom = getZoom;
|
|
368
|
+
exports.getZoomRange = getZoomRange;
|
|
369
|
+
exports.isFlashAvailable = isFlashAvailable;
|
|
272
370
|
exports.openAppSettings = openAppSettings;
|
|
273
371
|
exports.requestPermissions = requestPermissions;
|
|
274
372
|
exports.saveToGallery = saveToGallery;
|
package/dist-js/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
|
2
|
-
import type { PermissionState } from '@tauri-apps/api/core';
|
|
2
|
+
import type { Channel, PermissionState } from '@tauri-apps/api/core';
|
|
3
3
|
export type { PermissionState } from '@tauri-apps/api/core';
|
|
4
4
|
export { convertFileSrc };
|
|
5
5
|
export type CameraDirection = 'front' | 'back';
|
|
@@ -34,6 +34,41 @@ export interface Video {
|
|
|
34
34
|
blob?: Blob;
|
|
35
35
|
/** Object URL for the recorded video (desktop). */
|
|
36
36
|
url?: string;
|
|
37
|
+
/** Optional base64-encoded thumbnail, when requested via `stopRecording`. */
|
|
38
|
+
thumbnail?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Supported zoom range of the active camera. */
|
|
41
|
+
export interface ZoomRange {
|
|
42
|
+
min: number;
|
|
43
|
+
max: number;
|
|
44
|
+
current: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Live telemetry emitted while a video recording is in progress.
|
|
48
|
+
*
|
|
49
|
+
* Delivered through the optional `onProgress` channel passed to
|
|
50
|
+
* `startRecording()`. The `bytesRecorded` and `bitrate` fields are omitted
|
|
51
|
+
* when the platform cannot determine them at that moment.
|
|
52
|
+
*/
|
|
53
|
+
export interface RecordingProgress {
|
|
54
|
+
/** Elapsed recording time in seconds. */
|
|
55
|
+
duration: number;
|
|
56
|
+
/** Bytes written to the output so far. */
|
|
57
|
+
bytesRecorded?: number;
|
|
58
|
+
/** Average bitrate in bits per second, derived from `bytesRecorded`. */
|
|
59
|
+
bitrate?: number;
|
|
60
|
+
}
|
|
61
|
+
export interface StartRecordingOptions {
|
|
62
|
+
/** Receives live recording telemetry while the recording is active. */
|
|
63
|
+
onProgress?: Channel<RecordingProgress>;
|
|
64
|
+
}
|
|
65
|
+
export interface StopRecordingOptions {
|
|
66
|
+
/** When true, the result includes a base64-encoded `thumbnail`. */
|
|
67
|
+
thumbnail?: boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface SaveToGalleryOptions {
|
|
70
|
+
/** Force the media type instead of inferring it from the file. */
|
|
71
|
+
type?: 'image' | 'video';
|
|
37
72
|
}
|
|
38
73
|
/**
|
|
39
74
|
* Starts the camera preview.
|
|
@@ -66,16 +101,29 @@ export declare function flipCamera(): Promise<void>;
|
|
|
66
101
|
export declare function setFlash(mode: FlashMode): Promise<void>;
|
|
67
102
|
/** Sets the camera zoom factor (mobile). */
|
|
68
103
|
export declare function setZoom(factor: number): Promise<void>;
|
|
104
|
+
/** Gets the current camera zoom factor (mobile). */
|
|
105
|
+
export declare function getZoom(): Promise<number>;
|
|
106
|
+
/** Gets the supported zoom range of the active camera (mobile). */
|
|
107
|
+
export declare function getZoomRange(): Promise<ZoomRange>;
|
|
108
|
+
/**
|
|
109
|
+
* Returns whether the active camera has a flash unit.
|
|
110
|
+
*
|
|
111
|
+
* On desktop this always resolves to `false` (no flash control).
|
|
112
|
+
*/
|
|
113
|
+
export declare function isFlashAvailable(): Promise<boolean>;
|
|
69
114
|
/** Starts video recording (with audio). */
|
|
70
|
-
export declare function startRecording(): Promise<void>;
|
|
115
|
+
export declare function startRecording(options?: StartRecordingOptions): Promise<void>;
|
|
71
116
|
/** Stops video recording and returns the recorded media. */
|
|
72
|
-
export declare function stopRecording(): Promise<Video>;
|
|
117
|
+
export declare function stopRecording(options?: StopRecordingOptions): Promise<Video>;
|
|
73
118
|
/**
|
|
74
|
-
* Saves a captured
|
|
119
|
+
* Saves a captured photo or video to the device gallery (mobile).
|
|
120
|
+
*
|
|
121
|
+
* The media type is inferred from the file extension; pass `options.type` to
|
|
122
|
+
* force it. On desktop this is not supported.
|
|
75
123
|
*
|
|
76
|
-
* @param path Absolute path to the
|
|
124
|
+
* @param path Absolute path to the media file.
|
|
77
125
|
*/
|
|
78
|
-
export declare function saveToGallery(path: string): Promise<void>;
|
|
126
|
+
export declare function saveToGallery(path: string, options?: SaveToGalleryOptions): Promise<void>;
|
|
79
127
|
/** Gets the current camera permission state. */
|
|
80
128
|
export declare function checkPermissions(): Promise<PermissionState>;
|
|
81
129
|
/** Requests camera permission. */
|
package/dist-js/index.js
CHANGED
|
@@ -18,7 +18,32 @@ let activeStream = null;
|
|
|
18
18
|
let activeVideo = null;
|
|
19
19
|
let mediaRecorder = null;
|
|
20
20
|
let recordedChunks = [];
|
|
21
|
+
let recordedBytes = 0;
|
|
21
22
|
let recordingStartedAt = 0;
|
|
23
|
+
let progressTimer = null;
|
|
24
|
+
/** Minimum interval between desktop progress messages (~4 Hz). */
|
|
25
|
+
const RECORDING_PROGRESS_INTERVAL_MS = 250;
|
|
26
|
+
/** Ask MediaRecorder for data chunks at least this often so size is known live. */
|
|
27
|
+
const RECORDING_TIMESLICE_MS = 1000;
|
|
28
|
+
function buildRecordingProgress(duration, bytes) {
|
|
29
|
+
const progress = { duration, bytesRecorded: bytes };
|
|
30
|
+
if (duration > 0) {
|
|
31
|
+
progress.bitrate = Math.round((bytes * 8) / duration);
|
|
32
|
+
}
|
|
33
|
+
return progress;
|
|
34
|
+
}
|
|
35
|
+
function progressesAreEqual(a, b) {
|
|
36
|
+
return (a != null &&
|
|
37
|
+
a.duration === b.duration &&
|
|
38
|
+
a.bytesRecorded === b.bytesRecorded &&
|
|
39
|
+
a.bitrate === b.bitrate);
|
|
40
|
+
}
|
|
41
|
+
function stopProgressTimer() {
|
|
42
|
+
if (progressTimer !== null) {
|
|
43
|
+
clearInterval(progressTimer);
|
|
44
|
+
progressTimer = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
22
47
|
async function ensureDesktopStream(options) {
|
|
23
48
|
if (activeStream)
|
|
24
49
|
return activeStream;
|
|
@@ -39,11 +64,13 @@ async function ensureDesktopStream(options) {
|
|
|
39
64
|
return stream;
|
|
40
65
|
}
|
|
41
66
|
function teardownDesktopStream() {
|
|
67
|
+
stopProgressTimer();
|
|
42
68
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
43
69
|
mediaRecorder.stop();
|
|
44
70
|
}
|
|
45
71
|
mediaRecorder = null;
|
|
46
72
|
recordedChunks = [];
|
|
73
|
+
recordedBytes = 0;
|
|
47
74
|
if (activeStream) {
|
|
48
75
|
activeStream.getTracks().forEach((t) => t.stop());
|
|
49
76
|
}
|
|
@@ -153,13 +180,38 @@ async function setZoom(factor) {
|
|
|
153
180
|
}
|
|
154
181
|
throw new Error('setZoom is not supported on desktop.');
|
|
155
182
|
}
|
|
183
|
+
/** Gets the current camera zoom factor (mobile). */
|
|
184
|
+
async function getZoom() {
|
|
185
|
+
if (isMobile()) {
|
|
186
|
+
return await invoke('plugin:camera-kessdev|get_zoom');
|
|
187
|
+
}
|
|
188
|
+
throw new Error('getZoom is not supported on desktop.');
|
|
189
|
+
}
|
|
190
|
+
/** Gets the supported zoom range of the active camera (mobile). */
|
|
191
|
+
async function getZoomRange() {
|
|
192
|
+
if (isMobile()) {
|
|
193
|
+
return await invoke('plugin:camera-kessdev|get_zoom_range');
|
|
194
|
+
}
|
|
195
|
+
throw new Error('getZoomRange is not supported on desktop.');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Returns whether the active camera has a flash unit.
|
|
199
|
+
*
|
|
200
|
+
* On desktop this always resolves to `false` (no flash control).
|
|
201
|
+
*/
|
|
202
|
+
async function isFlashAvailable() {
|
|
203
|
+
if (isMobile()) {
|
|
204
|
+
return await invoke('plugin:camera-kessdev|is_flash_available');
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
156
208
|
// ---------------------------------------------------------------------------
|
|
157
209
|
// Video recording
|
|
158
210
|
// ---------------------------------------------------------------------------
|
|
159
211
|
/** Starts video recording (with audio). */
|
|
160
|
-
async function startRecording() {
|
|
212
|
+
async function startRecording(options) {
|
|
161
213
|
if (isMobile()) {
|
|
162
|
-
await invoke('plugin:camera-kessdev|start_recording');
|
|
214
|
+
await invoke('plugin:camera-kessdev|start_recording', { ...options });
|
|
163
215
|
return;
|
|
164
216
|
}
|
|
165
217
|
if (activeStream) {
|
|
@@ -176,47 +228,90 @@ async function startRecording() {
|
|
|
176
228
|
await video.play();
|
|
177
229
|
activeVideo = video;
|
|
178
230
|
recordedChunks = [];
|
|
231
|
+
recordedBytes = 0;
|
|
179
232
|
mediaRecorder = new MediaRecorder(stream);
|
|
180
233
|
mediaRecorder.ondataavailable = (event) => {
|
|
181
|
-
if (event.data.size > 0)
|
|
234
|
+
if (event.data.size > 0) {
|
|
182
235
|
recordedChunks.push(event.data);
|
|
236
|
+
recordedBytes += event.data.size;
|
|
237
|
+
}
|
|
183
238
|
};
|
|
184
239
|
recordingStartedAt = performance.now();
|
|
185
|
-
mediaRecorder.start();
|
|
240
|
+
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
|
241
|
+
const onProgress = options?.onProgress;
|
|
242
|
+
if (onProgress) {
|
|
243
|
+
stopProgressTimer();
|
|
244
|
+
let last = null;
|
|
245
|
+
progressTimer = setInterval(() => {
|
|
246
|
+
const progress = buildRecordingProgress((performance.now() - recordingStartedAt) / 1000, recordedBytes);
|
|
247
|
+
if (!progressesAreEqual(last, progress)) {
|
|
248
|
+
last = progress;
|
|
249
|
+
onProgress.onmessage(progress);
|
|
250
|
+
}
|
|
251
|
+
}, RECORDING_PROGRESS_INTERVAL_MS);
|
|
252
|
+
}
|
|
186
253
|
}
|
|
187
254
|
/** Stops video recording and returns the recorded media. */
|
|
188
|
-
async function stopRecording() {
|
|
255
|
+
async function stopRecording(options) {
|
|
189
256
|
if (isMobile()) {
|
|
190
|
-
return await invoke('plugin:camera-kessdev|stop_recording');
|
|
257
|
+
return await invoke('plugin:camera-kessdev|stop_recording', { ...options });
|
|
191
258
|
}
|
|
192
259
|
if (!mediaRecorder) {
|
|
193
260
|
throw new Error('Recording is not active. Call startRecording() first.');
|
|
194
261
|
}
|
|
195
262
|
const duration = (performance.now() - recordingStartedAt) / 1000;
|
|
263
|
+
const thumbnail = options?.thumbnail ? captureDesktopThumbnail() : undefined;
|
|
264
|
+
stopProgressTimer();
|
|
196
265
|
return await new Promise((resolve, reject) => {
|
|
197
266
|
mediaRecorder.onstop = () => {
|
|
198
267
|
const type = mediaRecorder.mimeType || 'video/webm';
|
|
199
268
|
const blob = new Blob(recordedChunks, { type });
|
|
200
269
|
const url = URL.createObjectURL(blob);
|
|
201
270
|
recordedChunks = [];
|
|
271
|
+
recordedBytes = 0;
|
|
202
272
|
mediaRecorder = null;
|
|
203
|
-
|
|
273
|
+
const result = { duration, blob, url };
|
|
274
|
+
if (thumbnail)
|
|
275
|
+
result.thumbnail = thumbnail;
|
|
276
|
+
resolve(result);
|
|
204
277
|
};
|
|
205
278
|
mediaRecorder.onerror = () => reject(new Error('Video recording failed.'));
|
|
206
279
|
mediaRecorder.stop();
|
|
207
280
|
});
|
|
208
281
|
}
|
|
282
|
+
/** Captures the current desktop preview frame as a base64 JPEG thumbnail. */
|
|
283
|
+
function captureDesktopThumbnail() {
|
|
284
|
+
if (!activeVideo || !activeVideo.videoWidth || !activeVideo.videoHeight) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const canvas = document.createElement('canvas');
|
|
289
|
+
canvas.width = activeVideo.videoWidth;
|
|
290
|
+
canvas.height = activeVideo.videoHeight;
|
|
291
|
+
const ctx = canvas.getContext('2d');
|
|
292
|
+
if (!ctx)
|
|
293
|
+
return undefined;
|
|
294
|
+
ctx.drawImage(activeVideo, 0, 0, canvas.width, canvas.height);
|
|
295
|
+
return canvas.toDataURL('image/jpeg');
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
209
301
|
// ---------------------------------------------------------------------------
|
|
210
302
|
// Gallery
|
|
211
303
|
// ---------------------------------------------------------------------------
|
|
212
304
|
/**
|
|
213
|
-
* Saves a captured
|
|
305
|
+
* Saves a captured photo or video to the device gallery (mobile).
|
|
306
|
+
*
|
|
307
|
+
* The media type is inferred from the file extension; pass `options.type` to
|
|
308
|
+
* force it. On desktop this is not supported.
|
|
214
309
|
*
|
|
215
|
-
* @param path Absolute path to the
|
|
310
|
+
* @param path Absolute path to the media file.
|
|
216
311
|
*/
|
|
217
|
-
async function saveToGallery(path) {
|
|
312
|
+
async function saveToGallery(path, options) {
|
|
218
313
|
if (isMobile()) {
|
|
219
|
-
await invoke('plugin:camera-kessdev|save_to_gallery', { path });
|
|
314
|
+
await invoke('plugin:camera-kessdev|save_to_gallery', { path, ...options });
|
|
220
315
|
return;
|
|
221
316
|
}
|
|
222
317
|
throw new Error('saveToGallery is not supported on desktop.');
|
|
@@ -260,4 +355,4 @@ async function openAppSettings() {
|
|
|
260
355
|
throw new Error('openAppSettings is not supported on desktop.');
|
|
261
356
|
}
|
|
262
357
|
|
|
263
|
-
export { capture, checkPermissions, flipCamera, getPreviewStream, openAppSettings, requestPermissions, saveToGallery, setFlash, setZoom, startPreview, startRecording, stopPreview, stopRecording };
|
|
358
|
+
export { capture, checkPermissions, flipCamera, getPreviewStream, getZoom, getZoomRange, isFlashAvailable, openAppSettings, requestPermissions, saveToGallery, setFlash, setZoom, startPreview, startRecording, stopPreview, stopRecording };
|
package/package.json
CHANGED