@vidtreo/recorder 1.7.13 → 1.8.1

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.
Files changed (4) hide show
  1. package/README.md +234 -10
  2. package/dist/index.d.ts +3460 -2913
  3. package/dist/index.js +13029 -7239
  4. package/package.json +6 -4
package/README.md CHANGED
@@ -8,6 +8,32 @@ Vidtreo SDK for browser-based video recording and transcoding. Similar to Ziggeo
8
8
  npm install @vidtreo/recorder
9
9
  ```
10
10
 
11
+ ## Direct Upload Transport
12
+
13
+ Starting with `@vidtreo/recorder` 1.8.0, video bytes upload directly from the browser to Vidtreo-managed R2 storage through a resumable multipart session. The part size scales with the file (`calculateDirectUploadPartSize`: about 64 parts, whole MiB, clamped to 5–64 MiB) and the backend returns the authoritative geometry plus the parts already verified by R2. The SDK uploads up to four missing parts at once, computes a SHA-256 manifest checksum plus an R2-supported MD5 transport checksum, obtains a short-lived URL whose Signature V4 headers bind `Content-MD5` to that part, and confirms only after all parts succeed.
14
+
15
+ - The API key is sent only to the configured Vidtreo backend and is never sent to R2.
16
+ - Upload progress reaches `100%` only after the backend accepts confirmation.
17
+ - `onProgress` is a phased, eased value: preparation and initiation fill 0–5%, transferred bytes fill 5–90%, confirmation eases from 90% toward 99%. It never moves backwards. `onProgressDetails` keeps the raw `loadedBytes`/`totalBytes` and reports the current `phase` (`UploadProgressPhase`: `"preparing" | "transferring" | "finalizing" | "completed"`).
18
+ - Each part is retried up to five times with exponential backoff on network errors, inactivity, and HTTP 408/429/5xx. Retries pause while the browser is offline, and an expired part URL is re-signed once.
19
+ - Pending uploads keep one idempotency identity across offline recovery and retries.
20
+ - Retries resume provider-verified parts and never store multipart URLs or checksums in IndexedDB.
21
+ - Aggregate progress remains monotonic across parts and reaches `100%` only after confirmation.
22
+ - If confirmation is retried after the backend already accepted the upload, the SDK returns the existing video without uploading the Blob again.
23
+ - Signed upload URLs remain in memory for one attempt and are not stored in IndexedDB or included in SDK errors.
24
+
25
+ ### Persistent Upload Queue and Resume
26
+
27
+ Recordings and native-camera uploads are written to an IndexedDB upload queue before they are sent. If the page is reloaded, the tab crashes, or the network drops, the next recorder instance on the same origin picks the upload up again:
28
+
29
+ - The queued upload keeps its idempotency key, so the backend returns the same video and the SDK uploads only the parts R2 has not verified yet.
30
+ - Failed attempts are retried by the queue with exponential backoff (up to 10 attempts) on top of the per-part retries. Retries wait while the browser is offline.
31
+ - Uploads authenticated with `apiKey` store the key with the queued item. Uploads authenticated with `getAccessToken` never persist a token: the provider is called again when the upload resumes, so the page must create the recorder (or native-camera handler) with the same `getAccessToken` after a reload.
32
+ - Errors that cannot succeed on retry (such as `upload.insufficient-credits`) are reported immediately and are not retried.
33
+ - When IndexedDB is unavailable (private browsing, blocked storage) the SDK uploads directly without persistence.
34
+
35
+ Version 1.8.0 does not fall back to the legacy Worker-proxied upload endpoint. Applications that need the previous transport must remain on an earlier SDK version while the backend continues to support those clients. Custom Vidtreo backend deployments must expose initiation, part authorization, and confirmation endpoints. Managed R2 CORS must allow browser `PUT` plus the signed `Content-MD5` header and expose `ETag`.
36
+
11
37
  ## Quick Start
12
38
 
13
39
  ```typescript
@@ -46,7 +72,8 @@ Creates a new recorder instance with the specified configuration.
46
72
 
47
73
  **Parameters:**
48
74
 
49
- - `config.apiKey` (required): Your API key for backend authentication
75
+ - `config.apiKey` (required unless `getAccessToken` is set): Your API key for backend authentication
76
+ - `config.getAccessToken` (optional): `() => Promise<string>` that returns a short-lived desktop access token. Use it instead of `apiKey` when your server mints temporary tokens, so no long-lived key is exposed in the page. The SDK calls it whenever it needs a fresh token, including when a queued upload resumes after a reload. Provide exactly one of `apiKey` or `getAccessToken`
50
77
  - `config.apiUrl` (optional): Your backend API URL endpoint. Defaults to `https://core.vidtreo.com` if not provided
51
78
  - `config.enableSourceSwitching` (optional): Enable switching between camera and screen during recording. Default: `false`
52
79
  - `config.enableMute` (optional): Enable mute/unmute functionality. When disabled, `muteAudio()`, `unmuteAudio()`, and `toggleMute()` will throw errors. Default: `true`
@@ -55,15 +82,15 @@ Creates a new recorder instance with the specified configuration.
55
82
  - `config.maxRecordingTime` (optional): Maximum recording duration in milliseconds. When the maximum time is reached, recording automatically stops. If not set, recording can continue indefinitely until manually stopped. Examples: `300000` (5 minutes), `600000` (10 minutes), `1800000` (30 minutes)
56
83
  - `config.countdownDuration` (optional): Countdown duration in milliseconds before recording starts. Default: `0` (no countdown)
57
84
  - `config.userMetadata` (optional): Custom metadata object to include with uploads
58
- - `config.onUploadComplete` (optional): Callback invoked when upload completes successfully
85
+ - `config.onUploadComplete` (optional): Callback invoked when upload completes successfully. Receives a [`RecordingUploadResult`](#recordinguploadresult)
59
86
  - `config.onUploadProgress` (optional): Callback invoked during upload with progress value (0-1)
60
- - `config.onUploadError` (optional): Callback invoked when upload fails
87
+ - `config.onUploadError` (optional): Callback invoked when upload fails. Read the error code with [`getUploadErrorCode`](#upload-errors)
61
88
  - `config.onAudioWarning` (optional): Callback invoked when an audio issue is detected during recording. Receives an `AudioWarning` object. See [Audio Warnings](#audio-warnings) for details
62
89
  - `config.onRecordingStart` (optional): Callback invoked when recording starts
63
90
  - `config.onRecordingStop` (optional): Callback invoked when recording stops
64
91
  - `config.onError` (optional): Callback invoked when a stream error occurs
65
92
 
66
- **Throws:** `Error` if `apiKey` is missing
93
+ **Throws:** `Error` if neither `apiKey` nor `getAccessToken` is provided. If both are provided it throws the shared configuration error `"Provide either apiKey or getAccessToken, not both"` with `code: CONFIG_ERROR_CREDENTIAL_CONFLICT` (`"config.credential-conflict"`). The same error is thrown by `RecorderController.initialize`, `ConfigManager`, `ConfigService`, `NativeCameraHandler` and `VideoUploadService`, and by the React, Vue and Web Component packages. Use `isCredentialConflictError(error)`, `hasCredentialConflict(apiKey, getAccessToken)` or `assertSingleCredential(apiKey, getAccessToken)` to check it yourself.
67
94
 
68
95
  #### Methods
69
96
 
@@ -123,6 +150,14 @@ Switches the recording source between camera and screen during an active recordi
123
150
 
124
151
  **Throws:** `Error` if source switching is not enabled or if not currently recording
125
152
 
153
+ ##### `reselectScreenSource(): Promise<boolean>`
154
+
155
+ Opens the screen picker again while the source is `'screen'` or `'camera-screen'` and swaps the newly selected screen or window into the running preview or recording. The source type, the microphone and the camera bubble are kept.
156
+
157
+ **Returns:** `Promise<boolean>` - `true` when a new screen was applied, `false` when the picker was cancelled
158
+
159
+ **Throws:** `Error` if source switching is not enabled or no screen capture is active
160
+
126
161
  ##### `getAvailableDevices(): Promise<AvailableDevices>`
127
162
 
128
163
  Retrieves the list of available camera and microphone devices.
@@ -200,7 +235,8 @@ Cleans up all resources, stops active streams, and cancels any pending operation
200
235
 
201
236
  ```typescript
202
237
  interface VidtreoRecorderConfig {
203
- apiKey: string;
238
+ apiKey?: string;
239
+ getAccessToken?: () => Promise<string>;
204
240
  apiUrl?: string;
205
241
  enableSourceSwitching?: boolean;
206
242
  enableMute?: boolean;
@@ -211,10 +247,8 @@ interface VidtreoRecorderConfig {
211
247
  userMetadata?: Record<string, unknown>;
212
248
  enableTabVisibilityOverlay?: boolean;
213
249
  tabVisibilityOverlayText?: string;
214
- onUploadComplete?: (result: {
215
- recordingId: string;
216
- uploadUrl: string;
217
- }) => void;
250
+ captureSystemAudio?: boolean;
251
+ onUploadComplete?: (result: RecordingUploadResult) => void;
218
252
  onUploadProgress?: (progress: number) => void;
219
253
  onUploadError?: (error: Error) => void;
220
254
  onRecordingStart?: () => void;
@@ -238,6 +272,32 @@ interface RecordingStartOptions {
238
272
  }
239
273
  ```
240
274
 
275
+ ### RecordingUploadResult
276
+
277
+ Payload passed to every upload-complete callback (`VidtreoRecorder` `onUploadComplete`, `UploadCallbacks.onSuccess`, the upload queue, native-camera uploads, and the React/Vue/Web Component packages). It contains every field of the backend `VideoUploadResult` plus the backward-compatible `recordingId` and `uploadUrl` fields.
278
+
279
+ ```typescript
280
+ type RecordingUploadResult = {
281
+ recordingId: string; // same value as id
282
+ uploadUrl: string; // "" when the backend has no URL yet
283
+ id: string;
284
+ publicId: string;
285
+ filename: string;
286
+ fileSize: number;
287
+ mimeType: string;
288
+ duration: number | null;
289
+ status: string;
290
+ createdAt: string;
291
+ transferStatus: "pending" | "transferring" | "transferred" | "failed" | null;
292
+ transferError: string | null;
293
+ transferredAt: string | null;
294
+ r2DeleteAfter: string | null;
295
+ r2DeletedAt: string | null;
296
+ };
297
+ ```
298
+
299
+ Use `createRecordingUploadResult(videoUploadResult)` to build this payload from a raw `VideoUploadService` result.
300
+
241
301
  ### RecordingStopResult
242
302
 
243
303
  ```typescript
@@ -251,9 +311,11 @@ interface RecordingStopResult {
251
311
  ### SourceType
252
312
 
253
313
  ```typescript
254
- type SourceType = 'camera' | 'screen';
314
+ type SourceType = 'camera' | 'screen' | 'camera-screen';
255
315
  ```
256
316
 
317
+ `'camera-screen'` is opt-in and only used when passed explicitly. See [Screen-only capture and camera + screen](#screen-only-capture-and-camera--screen-advanced).
318
+
257
319
  ### RecordingState
258
320
 
259
321
  ```typescript
@@ -661,6 +723,34 @@ try {
661
723
  }
662
724
  ```
663
725
 
726
+ ### Upload Errors
727
+
728
+ Upload failures that need user-facing handling carry a stable code. Read it with `getUploadErrorCode`:
729
+
730
+ ```typescript
731
+ import {
732
+ getUploadErrorCode,
733
+ UPLOAD_ERROR_INSUFFICIENT_CREDITS,
734
+ type UploadErrorCode,
735
+ } from '@vidtreo/recorder';
736
+
737
+ const recorder = new VidtreoRecorder({
738
+ apiKey: 'your-api-key',
739
+ onUploadError: (error) => {
740
+ const code: UploadErrorCode | null = getUploadErrorCode(error);
741
+ if (code === UPLOAD_ERROR_INSUFFICIENT_CREDITS) {
742
+ showMessage('This video could not be uploaded. Please contact the site administrator.');
743
+ }
744
+ },
745
+ });
746
+ ```
747
+
748
+ | Code | Meaning |
749
+ |------|---------|
750
+ | `upload.insufficient-credits` | The backend rejected the upload with HTTP 402 because the account has no credits left. The upload is not retried. |
751
+
752
+ `getUploadErrorCode` returns `null` for every other error (network failures, timeouts, and so on). Those errors are retried automatically before they are reported.
753
+
664
754
  ### Recording with Disabled Features
665
755
 
666
756
  ```typescript
@@ -701,6 +791,91 @@ For web component installation, usage, and examples, please refer to the [@vidtr
701
791
 
702
792
  ## Advanced Usage
703
793
 
794
+ ### Screen-only capture and camera + screen (advanced)
795
+
796
+ These capabilities are opt-in. Without the options below the recorder behaves exactly as before: screen capture starts from the camera preview and `'camera-screen'` is never used.
797
+
798
+ #### Screen-only capture
799
+
800
+ Set `startScreenWithoutCamera: true` to start `startPreview('screen')` / `startRecording({}, 'screen')` with `getDisplayMedia` for video and a microphone-only `getUserMedia` request. The camera is never opened. Mute, audio levels, microphone changes and stopping work as with the camera. Switching to `'camera'` or `'camera-screen'` opens the camera at that moment. If the user cancels the screen picker, the start rejects with an error whose `code` is `ERROR_SCREEN_SELECTION_CANCELLED` (`'screen.selection-cancelled'`).
801
+
802
+ #### Camera + screen (`'camera-screen'`)
803
+
804
+ `'camera-screen'` records the screen as the full frame with the camera as a picture-in-picture bubble. Preview and recording use the same composed video track; audio is the microphone.
805
+
806
+ ```typescript
807
+ import { isPictureInPictureSupported, VidtreoRecorder } from '@vidtreo/recorder';
808
+
809
+ const recorder = new VidtreoRecorder({
810
+ apiKey: 'your-api-key',
811
+ enableSourceSwitching: true,
812
+ startScreenWithoutCamera: true,
813
+ pictureInPicture: { position: 'bottom-right', shape: 'circle', size: 0.22 },
814
+ });
815
+
816
+ if (isPictureInPictureSupported()) {
817
+ await recorder.startRecording({}, 'camera-screen');
818
+ await recorder.switchSource('screen');
819
+ await recorder.switchSource('camera-screen');
820
+ const cameraOnly = recorder.getCameraStream();
821
+ }
822
+ ```
823
+
824
+ `pictureInPicture` options:
825
+
826
+ | Option | Type | Default | Description |
827
+ |--------|------|---------|-------------|
828
+ | `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' \| { x: number; y: number }` | `'bottom-right'` | Bubble corner, or the normalized top-left of the bubble in the output frame (0 to 1 each) |
829
+ | `shape` | `'circle' \| 'rounded'` | `'circle'` | Bubble shape |
830
+ | `size` | `number` (0.05 to 0.5) | `0.22` | Bubble diameter as a fraction of the frame height |
831
+
832
+ Invalid values throw during initialization. The bubble has a 3% margin, a white border of about 2% of its diameter and a soft shadow. The camera image is center-cropped into the bubble and is not mirrored.
833
+
834
+ - Switching between `'camera'`, `'screen'` and `'camera-screen'` works in preview and while recording, without stopping the recording.
835
+ - If the user stops sharing from the browser or OS, the recorder switches to the camera and keeps recording.
836
+ - Stopping, `stopPreview` and `cleanup()` stop the screen, camera and microphone tracks and the compositor.
837
+ - `getCameraStream()` returns a video-only stream with the live camera track (in `'camera'` and `'camera-screen'`), or `null`.
838
+ - Composition runs in a dedicated Web Worker (`MediaStreamTrackProcessor` → `OffscreenCanvas` → `MediaStreamTrackGenerator`). New frames drive it, not timers or `requestAnimationFrame`, so it keeps running while the page is hidden or minimized. Output resolution is the screen resolution capped to the recording resolution. Frame rate follows the screen capture, capped at 30 fps.
839
+ - `'camera-screen'` requires `MediaStreamTrackProcessor`, `MediaStreamTrackGenerator` and `OffscreenCanvas` (Chromium-based browsers and Electron). Check with `isPictureInPictureSupported()`. On other browsers the switch reports an error with code `ERROR_PICTURE_IN_PICTURE_UNSUPPORTED` (`'screen.picture-in-picture-unsupported'`) and the current source stays active.
840
+
841
+ #### Free position and live layout updates
842
+
843
+ A free `position` places the bubble's top-left corner at `{ x, y }` as fractions of the output frame width and height. The point is clamped so the whole bubble stays inside the frame for its size and the frame aspect ratio; corner presets keep their 3% margin, free positions have none.
844
+
845
+ `setPictureInPicture(update)` merges a partial update into the current configuration, validates it and applies it to the running compositor without restarting it, in preview and while recording. The next composed frame eases toward the new rect over about 120 ms; pass `smooth: false` to jump. Without a running compositor the update is stored and used the next time `'camera-screen'` starts. Invalid values throw and keep the previous layout.
846
+
847
+ ```typescript
848
+ recorder.setPictureInPicture({ position: { x: 0.72, y: 0.64 }, size: 0.25 });
849
+ recorder.setPictureInPicture({ position: 'top-left', smooth: false });
850
+ const layout = recorder.getPictureInPictureConfig();
851
+ ```
852
+
853
+ `RecorderController` exposes the same `setPictureInPicture(update)` and `getPictureInPictureConfig()`.
854
+
855
+ The React hook `useVidtreoRecorder` accepts the same `startScreenWithoutCamera` and `pictureInPicture` options (`pictureInPicture` is the initial value) and exposes `actions.setPictureInPicture(update)`. It exposes `state.sourceType` and `state.cameraStream`. The prebuilt UI components do not offer these sources.
856
+
857
+ ### System audio (advanced)
858
+
859
+ Set `captureSystemAudio: true` (also accepted by `useVidtreoRecorder` and `RecorderController.configureSources`) to record the computer's sound (calls, videos, other apps) together with the microphone when recording `'screen'` or `'camera-screen'`. Default `false`: the display stream's audio is requested with `audio: false` and any audio track is stopped, exactly as before.
860
+
861
+ - Screen pickers are requested with `audio: true`. Whether audio comes back depends on the browser and the user's choice (Chrome only offers it for tabs, or for the whole screen on Windows/ChromeOS; Electron apps return it from `setDisplayMediaRequestHandler` with `audio: 'loopback'`). Without a display audio track the recording falls back to the microphone, without an error.
862
+ - While recording, the microphone and the system audio are mixed into one audio track with Web Audio (`AudioContext` → one `MediaStreamAudioSourceNode` and `GainNode` per input → `MediaStreamAudioDestinationNode`). Web Audio resamples both inputs to the context rate. The context is created when the recording starts and closed when it stops.
863
+ - Mute only silences the microphone; the system audio keeps recording. Audio levels (`getAudioStreamForAnalysis`) keep showing the microphone only.
864
+ - Reselecting the screen, or switching between camera and screen sources, swaps the system audio input without stopping the recording. A recording started from the camera picks up system audio once it switches to a screen source.
865
+ - The preview never plays the system audio. If the microphone picks up the speakers, the system audio is recorded twice (once directly, once through the room). Browser echo cancellation (`echoCancellation`, on by default) only cancels audio the page itself plays, so recommend headphones during calls.
866
+
867
+ ### Recording title
868
+
869
+ Call `setRecordingTitle(title)` before or during a recording to name the uploaded file `<title>.<ext>` instead of `recording-<timestamp>.<ext>`. The title is trimmed, whitespace is collapsed, control characters and `/ \ : * ? " < > |` are removed, leading dots are stripped and the result is capped at 120 characters. An empty result keeps the default name. The title is cleared once the recording's upload is queued, so set it again for the next recording. Pass `null` to clear it.
870
+
871
+ ```typescript
872
+ recorder.setRecordingTitle('Demo – Q4 ventas');
873
+ await recorder.startRecording({}, 'camera');
874
+ const result = await recorder.stopRecording(); // uploads "Demo – Q4 ventas.mp4"
875
+ ```
876
+
877
+ The same method exists on `RecorderController`, the React hook exposes it as `actions.setRecordingTitle(title)`, and `sanitizeRecordingTitle(title)` is exported to preview the sanitized name.
878
+
704
879
  ### Telemetry
705
880
 
706
881
  The recorder automatically sends telemetry events to monitor SDK usage, detect issues, and improve the product. **Telemetry is always active** and cannot be disabled through configuration.
@@ -893,6 +1068,55 @@ await controller.initialize({
893
1068
  });
894
1069
  ```
895
1070
 
1071
+ #### Native Camera Uploads
1072
+
1073
+ `NativeCameraHandler` validates a video picked with the device camera or gallery (`<input type="file" capture>`), transcodes it, and uploads it through the same persistent queue as recorded videos. UI packages use this single API for their native-camera mode.
1074
+
1075
+ ```typescript
1076
+ import {
1077
+ ConfigService,
1078
+ NativeCameraHandler,
1079
+ VideoUploadService,
1080
+ getUploadErrorCode,
1081
+ } from '@vidtreo/recorder';
1082
+
1083
+ const handler = new NativeCameraHandler(
1084
+ {
1085
+ apiKey: 'your-api-key', // or getAccessToken: () => fetchToken()
1086
+ backendUrl: 'https://core.vidtreo.com',
1087
+ maxRecordingTime: 300000,
1088
+ maxFileSize: 500 * 1024 * 1024,
1089
+ userMetadata: { interviewId: '42' },
1090
+ },
1091
+ ConfigService.getInstance({ apiKey: 'your-api-key', backendUrl: 'https://core.vidtreo.com' }),
1092
+ new VideoUploadService()
1093
+ );
1094
+
1095
+ await handler.startUploadQueue({
1096
+ onUploadProgress: (progress) => updateProgress(progress),
1097
+ onUploadProgressDetails: (details) => updatePhase(details.phase),
1098
+ onUploadRetry: (details) => showRetry(details.attempt),
1099
+ onUploadComplete: (result) => saveVideo(result),
1100
+ onUploadError: (error) => showError(getUploadErrorCode(error)),
1101
+ });
1102
+
1103
+ const file = await handler.handleFileSelection(input.files[0]);
1104
+ const result = await handler.processAndUpload(
1105
+ (transcodeProgress) => updateTranscode(transcodeProgress),
1106
+ (uploadProgress) => updateProgress(uploadProgress),
1107
+ (details) => updatePhase(details.phase),
1108
+ (retry) => showRetry(retry.attempt)
1109
+ );
1110
+ console.log(result.recordingId, result.transferStatus, result.blob);
1111
+
1112
+ handler.destroy();
1113
+ ```
1114
+
1115
+ - `startUploadQueue(callbacks?)` opens the IndexedDB queue and immediately resumes uploads left over from a previous page load (same idempotency key, only missing parts are sent). Its callbacks receive events for those resumed uploads and for queue-level errors. It resolves to `false` when persistent storage is unavailable; uploads then go directly to the backend.
1116
+ - `processAndUpload(onTranscodeProgress, onUploadProgress, onUploadProgressDetails?, onUploadRetry?)` queues the transcoded file and resolves with a `NativeCameraUploadResult` (`RecordingUploadResult & { blob: Blob }`) once the upload is confirmed. It rejects only with a terminal error, after the queue has exhausted its retries or immediately for `upload.insufficient-credits`. Events for this upload go to the per-call callbacks, not to the `startUploadQueue` callbacks.
1117
+ - Calling `processAndUpload` without `startUploadQueue` opens the queue on demand.
1118
+ - `destroy()` stops the queue timers. An upload that is still pending stays in IndexedDB and resumes the next time a handler or recorder starts the queue.
1119
+
896
1120
  #### Stream Management
897
1121
 
898
1122
  ```typescript