tauri-plugin-camera-kessdev 0.1.1-beta.1 → 0.1.1-beta.3

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`:
@@ -95,9 +97,25 @@ Add the following usage descriptions to the app `Info.plist`
95
97
 
96
98
  ### Desktop
97
99
 
98
- Desktop uses the browser's `getUserMedia`. On macOS, `WKWebView` additionally requires
99
- `NSCameraUsageDescription` and `NSMicrophoneUsageDescription` in the `Info.plist` (the
100
- same keys above).
100
+ Desktop uses the browser's `getUserMedia`. The plugin installs a webview permission
101
+ handler on **Windows** (WebView2 `PermissionRequested`) and **Linux** (webkitgtk
102
+ `permission-request`) that allows camera and microphone, so `startPreview()` /
103
+ `requestPermissions()` work without extra configuration. The webview asks the plugin,
104
+ and the real gate is the operating system:
105
+
106
+ - **Windows**: Settings → Privacy & security → Camera → turn on camera access and
107
+ "Let desktop apps access your camera".
108
+ - **Linux**: the desktop portal (e.g. `xdg-desktop-portal`) governs camera access.
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
+
117
+ On **macOS**, `WKWebView` additionally requires `NSCameraUsageDescription` and
118
+ `NSMicrophoneUsageDescription` in the `Info.plist` (the same keys above).
101
119
 
102
120
  ## Usage
103
121
 
@@ -152,6 +170,44 @@ const video = await stopRecording()
152
170
  // video: { path, duration } (mobile) | { duration, blob, url } (desktop)
153
171
  ```
154
172
 
173
+ ### Recording progress (telemetry)
174
+
175
+ Pass an `onProgress` channel to `startRecording()` to receive live telemetry while
176
+ the recording is in progress. The channel is optional: omit it and recording
177
+ behaves exactly as before.
178
+
179
+ ```ts
180
+ import { Channel } from '@tauri-apps/api/core'
181
+ import { startRecording, stopRecording, type RecordingProgress } from 'tauri-plugin-camera-kessdev'
182
+
183
+ const progress = new Channel<RecordingProgress>()
184
+ progress.onmessage = (p) => {
185
+ // p: { duration, bytesRecorded?, bitrate? }
186
+ console.log(`${p.duration.toFixed(1)}s`, p.bytesRecorded, 'bytes')
187
+ }
188
+
189
+ await startRecording({ onProgress: progress })
190
+ // ... user records ...
191
+ const video = await stopRecording()
192
+ ```
193
+
194
+ `RecordingProgress` fields:
195
+
196
+ | Field | Type | Description |
197
+ |-------|------|-------------|
198
+ | `duration` | `number` | Elapsed recording time in seconds. Always present. |
199
+ | `bytesRecorded` | `number?` | Bytes written so far, when the platform reports it. |
200
+ | `bitrate` | `number?` | Average bitrate in bits per second, when derivable. |
201
+
202
+ Platform notes:
203
+
204
+ - **Android**: telemetry is derived from CameraX `VideoRecordEvent.Status`, emitted at roughly 1 Hz natively. `bytesRecorded` is accurate.
205
+ - **iOS**: telemetry is sampled from `AVCaptureMovieFileOutput` every 500 ms. `bytesRecorded` is omitted until `recordedFileSize` becomes available, so early messages may only carry `duration`.
206
+ - **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.
207
+
208
+ Messages are throttled to about 4 per second and stop as soon as the recording
209
+ ends. Telemetry is never emitted after `stopRecording()` resolves.
210
+
155
211
  ### Camera controls
156
212
 
157
213
  ```ts
@@ -305,8 +361,9 @@ destination from the generated Xcode project (`src-tauri/gen/apple`).
305
361
  | `flipCamera()` | ✓ | ✗ (throws) |
306
362
  | `setFlash(mode)` | ✓ | ✗ (throws) |
307
363
  | `setZoom(factor)` | ✓ | ✗ (throws) |
308
- | `startRecording()` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
364
+ | `startRecording(options?)` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
309
365
  | `stopRecording()` | ✓ | ✓ |
366
+ | `RecordingProgress` telemetry (`onProgress` channel) | ✓ | ✓ |
310
367
  | `saveToGallery(path)` | ✓ | ✗ (throws) |
311
368
  | `checkPermissions()` | ✓ | ✓ |
312
369
  | `requestPermissions()` | ✓ | ✓ |
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
  }
@@ -158,9 +185,9 @@ async function setZoom(factor) {
158
185
  // Video recording
159
186
  // ---------------------------------------------------------------------------
160
187
  /** Starts video recording (with audio). */
161
- async function startRecording() {
188
+ async function startRecording(options) {
162
189
  if (isMobile()) {
163
- await core.invoke('plugin:camera-kessdev|start_recording');
190
+ await core.invoke('plugin:camera-kessdev|start_recording', { ...options });
164
191
  return;
165
192
  }
166
193
  if (activeStream) {
@@ -177,13 +204,28 @@ async function startRecording() {
177
204
  await video.play();
178
205
  activeVideo = video;
179
206
  recordedChunks = [];
207
+ recordedBytes = 0;
180
208
  mediaRecorder = new MediaRecorder(stream);
181
209
  mediaRecorder.ondataavailable = (event) => {
182
- if (event.data.size > 0)
210
+ if (event.data.size > 0) {
183
211
  recordedChunks.push(event.data);
212
+ recordedBytes += event.data.size;
213
+ }
184
214
  };
185
215
  recordingStartedAt = performance.now();
186
- mediaRecorder.start();
216
+ mediaRecorder.start(RECORDING_TIMESLICE_MS);
217
+ const onProgress = options?.onProgress;
218
+ if (onProgress) {
219
+ stopProgressTimer();
220
+ let last = null;
221
+ progressTimer = setInterval(() => {
222
+ const progress = buildRecordingProgress((performance.now() - recordingStartedAt) / 1000, recordedBytes);
223
+ if (!progressesAreEqual(last, progress)) {
224
+ last = progress;
225
+ onProgress.onmessage(progress);
226
+ }
227
+ }, RECORDING_PROGRESS_INTERVAL_MS);
228
+ }
187
229
  }
188
230
  /** Stops video recording and returns the recorded media. */
189
231
  async function stopRecording() {
@@ -194,12 +236,14 @@ async function stopRecording() {
194
236
  throw new Error('Recording is not active. Call startRecording() first.');
195
237
  }
196
238
  const duration = (performance.now() - recordingStartedAt) / 1000;
239
+ stopProgressTimer();
197
240
  return await new Promise((resolve, reject) => {
198
241
  mediaRecorder.onstop = () => {
199
242
  const type = mediaRecorder.mimeType || 'video/webm';
200
243
  const blob = new Blob(recordedChunks, { type });
201
244
  const url = URL.createObjectURL(blob);
202
245
  recordedChunks = [];
246
+ recordedBytes = 0;
203
247
  mediaRecorder = null;
204
248
  resolve({ duration, blob, url });
205
249
  };
@@ -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';
@@ -35,6 +35,25 @@ export interface Video {
35
35
  /** Object URL for the recorded video (desktop). */
36
36
  url?: string;
37
37
  }
38
+ /**
39
+ * Live telemetry emitted while a video recording is in progress.
40
+ *
41
+ * Delivered through the optional `onProgress` channel passed to
42
+ * `startRecording()`. The `bytesRecorded` and `bitrate` fields are omitted
43
+ * when the platform cannot determine them at that moment.
44
+ */
45
+ export interface RecordingProgress {
46
+ /** Elapsed recording time in seconds. */
47
+ duration: number;
48
+ /** Bytes written to the output so far. */
49
+ bytesRecorded?: number;
50
+ /** Average bitrate in bits per second, derived from `bytesRecorded`. */
51
+ bitrate?: number;
52
+ }
53
+ export interface StartRecordingOptions {
54
+ /** Receives live recording telemetry while the recording is active. */
55
+ onProgress?: Channel<RecordingProgress>;
56
+ }
38
57
  /**
39
58
  * Starts the camera preview.
40
59
  *
@@ -67,7 +86,7 @@ export declare function setFlash(mode: FlashMode): Promise<void>;
67
86
  /** Sets the camera zoom factor (mobile). */
68
87
  export declare function setZoom(factor: number): Promise<void>;
69
88
  /** Starts video recording (with audio). */
70
- export declare function startRecording(): Promise<void>;
89
+ export declare function startRecording(options?: StartRecordingOptions): Promise<void>;
71
90
  /** Stops video recording and returns the recorded media. */
72
91
  export declare function stopRecording(): Promise<Video>;
73
92
  /**
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
  }
@@ -157,9 +184,9 @@ async function setZoom(factor) {
157
184
  // Video recording
158
185
  // ---------------------------------------------------------------------------
159
186
  /** Starts video recording (with audio). */
160
- async function startRecording() {
187
+ async function startRecording(options) {
161
188
  if (isMobile()) {
162
- await invoke('plugin:camera-kessdev|start_recording');
189
+ await invoke('plugin:camera-kessdev|start_recording', { ...options });
163
190
  return;
164
191
  }
165
192
  if (activeStream) {
@@ -176,13 +203,28 @@ async function startRecording() {
176
203
  await video.play();
177
204
  activeVideo = video;
178
205
  recordedChunks = [];
206
+ recordedBytes = 0;
179
207
  mediaRecorder = new MediaRecorder(stream);
180
208
  mediaRecorder.ondataavailable = (event) => {
181
- if (event.data.size > 0)
209
+ if (event.data.size > 0) {
182
210
  recordedChunks.push(event.data);
211
+ recordedBytes += event.data.size;
212
+ }
183
213
  };
184
214
  recordingStartedAt = performance.now();
185
- mediaRecorder.start();
215
+ mediaRecorder.start(RECORDING_TIMESLICE_MS);
216
+ const onProgress = options?.onProgress;
217
+ if (onProgress) {
218
+ stopProgressTimer();
219
+ let last = null;
220
+ progressTimer = setInterval(() => {
221
+ const progress = buildRecordingProgress((performance.now() - recordingStartedAt) / 1000, recordedBytes);
222
+ if (!progressesAreEqual(last, progress)) {
223
+ last = progress;
224
+ onProgress.onmessage(progress);
225
+ }
226
+ }, RECORDING_PROGRESS_INTERVAL_MS);
227
+ }
186
228
  }
187
229
  /** Stops video recording and returns the recorded media. */
188
230
  async function stopRecording() {
@@ -193,12 +235,14 @@ async function stopRecording() {
193
235
  throw new Error('Recording is not active. Call startRecording() first.');
194
236
  }
195
237
  const duration = (performance.now() - recordingStartedAt) / 1000;
238
+ stopProgressTimer();
196
239
  return await new Promise((resolve, reject) => {
197
240
  mediaRecorder.onstop = () => {
198
241
  const type = mediaRecorder.mimeType || 'video/webm';
199
242
  const blob = new Blob(recordedChunks, { type });
200
243
  const url = URL.createObjectURL(blob);
201
244
  recordedChunks = [];
245
+ recordedBytes = 0;
202
246
  mediaRecorder = null;
203
247
  resolve({ duration, blob, url });
204
248
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tauri-plugin-camera-kessdev",
3
- "version": "0.1.1-beta.1",
3
+ "version": "0.1.1-beta.3",
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",