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 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) // mobile only
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
- | `startRecording()` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
318
- | `stopRecording()` | ✓ | ✓ |
319
- | `saveToGallery(path)` | ✓ | ✗ (throws) |
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
- resolve({ duration, blob, url });
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 image to the device photo gallery (mobile).
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 image file.
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;
@@ -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 image to the device photo gallery (mobile).
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 image file.
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
- resolve({ duration, blob, url });
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 image to the device photo gallery (mobile).
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 image file.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tauri-plugin-camera-kessdev",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.1.1-beta.4",
4
4
  "author": "kess",
5
5
  "description": "Camera plugin for Tauri v2: photo capture, video recording and preview on Android, iOS and desktop.",
6
6
  "license": "MIT",