tauri-plugin-camera-kessdev 0.1.1-beta.2 → 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 +49 -1
- package/dist-js/index.cjs +48 -4
- package/dist-js/index.d.ts +21 -2
- package/dist-js/index.js +48 -4
- 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
|
|
|
@@ -161,6 +170,44 @@ const video = await stopRecording()
|
|
|
161
170
|
// video: { path, duration } (mobile) | { duration, blob, url } (desktop)
|
|
162
171
|
```
|
|
163
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
|
+
|
|
164
211
|
### Camera controls
|
|
165
212
|
|
|
166
213
|
```ts
|
|
@@ -314,8 +361,9 @@ destination from the generated Xcode project (`src-tauri/gen/apple`).
|
|
|
314
361
|
| `flipCamera()` | ✓ | ✗ (throws) |
|
|
315
362
|
| `setFlash(mode)` | ✓ | ✗ (throws) |
|
|
316
363
|
| `setZoom(factor)` | ✓ | ✗ (throws) |
|
|
317
|
-
| `startRecording()` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
|
|
364
|
+
| `startRecording(options?)` | ✓ `VideoCapture` / `AVCaptureMovieFileOutput` | ✓ `MediaRecorder` |
|
|
318
365
|
| `stopRecording()` | ✓ | ✓ |
|
|
366
|
+
| `RecordingProgress` telemetry (`onProgress` channel) | ✓ | ✓ |
|
|
319
367
|
| `saveToGallery(path)` | ✓ | ✗ (throws) |
|
|
320
368
|
| `checkPermissions()` | ✓ | ✓ |
|
|
321
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
|
};
|
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';
|
|
@@ -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