@vidtreo/recorder 1.9.0 → 1.10.0

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
@@ -29,7 +29,7 @@ Recordings and native-camera uploads are written to an IndexedDB upload queue be
29
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
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
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.
32
+ - An `upload.insufficient-credits` rejection does not use up the 10 attempts and never gives up: the upload stays in the queue and is retried every 5 minutes and whenever the queue starts. Each rejection is reported through `onUploadError` with the backend message, and the upload is removed once it succeeds.
33
33
  - When IndexedDB is unavailable (private browsing, blocked storage) the SDK uploads directly without persistence.
34
34
 
35
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`.
@@ -747,7 +747,7 @@ const recorder = new VidtreoRecorder({
747
747
 
748
748
  | Code | Meaning |
749
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. |
750
+ | `upload.insufficient-credits` | The backend rejected the upload with HTTP 402 because the account has no credits left. The upload stays queued and is retried every 5 minutes (and when the queue starts), and `onUploadError` receives this error on every rejected attempt. Repeated rejections of the same upload carry `isRepeatedRejection: true` (check it with `isRepeatedUploadRejection(error)`); the built-in UIs use it to show their dialog only once. |
751
751
 
752
752
  `getUploadErrorCode` returns `null` for every other error (network failures, timeouts, and so on). Those errors are retried automatically before they are reported.
753
753
 
@@ -1113,7 +1113,7 @@ handler.destroy();
1113
1113
  ```
1114
1114
 
1115
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.
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 with `upload.insufficient-credits` on the first credit rejection; that upload stays queued and keeps retrying, and later rejections and the final success reach the `startUploadQueue` callbacks, so call `startUploadQueue` (with those callbacks) if you want to receive them. Events for this upload go to the per-call callbacks, not to the `startUploadQueue` callbacks.
1117
1117
  - Calling `processAndUpload` without `startUploadQueue` opens the queue on demand.
1118
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
1119
 
package/dist/index.d.ts CHANGED
@@ -275,6 +275,8 @@ export {};
275
275
 
276
276
  export {};
277
277
 
278
+ export {};
279
+
278
280
  export type VidtreoRecorderConfig = {
279
281
  apiKey?: string;
280
282
  getAccessToken?: AccessTokenProvider;
@@ -3666,6 +3668,8 @@ export declare class RecorderController {
3666
3668
  prepareLocalRecording(blob: Blob): Promise<LocalRecording>;
3667
3669
  uploadVideo(blob: Blob, apiKey: string | null, backendUrl: string, metadata: Record<string, unknown>, getAccessToken?: AccessTokenProvider): Promise<void>;
3668
3670
  private uploadVideoAfterStatusClear;
3671
+ private handleQueuedUploadError;
3672
+ private sendUploadFailedTelemetry;
3669
3673
  private uploadDirectlyWithoutQueue;
3670
3674
  getStream(): MediaStream | null;
3671
3675
  isConfigReady(): boolean;
@@ -3777,6 +3781,7 @@ export declare class UploadQueueManager {
3777
3781
  private readonly networkOnlineHandler;
3778
3782
  private readonly progressFloors;
3779
3783
  private readonly interruptedUploadIds;
3784
+ private readonly creditRejectedUploadIds;
3780
3785
  private isProcessing;
3781
3786
  private hasRecoveredStaleUploads;
3782
3787
  private hasNetworkRecovered;
@@ -3802,11 +3807,14 @@ export declare class UploadQueueManager {
3802
3807
  private canProcessMatchingUpload;
3803
3808
  private matchesMetadata;
3804
3809
  private processUpload;
3810
+ private handleUploadFailure;
3805
3811
  private isResumedUpload;
3806
3812
  private resolveRetryCount;
3807
3813
  private reportUploadProgress;
3808
3814
  private forgetUploadProgress;
3809
3815
  private isOffline;
3816
+ private getRemainingRetryDelay;
3817
+ private resolveRetryDelay;
3810
3818
  private calculateRetryDelay;
3811
3819
  private scheduleRetry;
3812
3820
  private clearTimer;
@@ -3817,10 +3825,13 @@ export declare const UPLOAD_ERROR_INSUFFICIENT_CREDITS = "upload.insufficient-cr
3817
3825
  export type UploadErrorCode = typeof UPLOAD_ERROR_INSUFFICIENT_CREDITS;
3818
3826
  export type UploadError = Error & {
3819
3827
  code: UploadErrorCode;
3828
+ isRepeatedRejection?: true;
3820
3829
  };
3821
3830
  export declare function createUploadError(code: UploadErrorCode, message: string): UploadError;
3822
3831
  export declare function getUploadErrorCode(error: unknown): UploadErrorCode | null;
3823
- export declare function isRetryableUploadError(error: unknown): boolean;
3832
+ export declare function isInsufficientCreditsError(error: unknown): boolean;
3833
+ export declare function markRepeatedUploadRejection(error: Error): void;
3834
+ export declare function isRepeatedUploadRejection(error: unknown): boolean;
3824
3835
 
3825
3836
  export type PartHasher = {
3826
3837
  hash: (part: Blob) => Promise<PartChecksums>;
package/dist/index.js CHANGED
@@ -2937,8 +2937,14 @@ function getUploadErrorCode(error) {
2937
2937
  }
2938
2938
  return null;
2939
2939
  }
2940
- function isRetryableUploadError(error) {
2941
- return getUploadErrorCode(error) === null;
2940
+ function isInsufficientCreditsError(error) {
2941
+ return getUploadErrorCode(error) === UPLOAD_ERROR_INSUFFICIENT_CREDITS;
2942
+ }
2943
+ function markRepeatedUploadRejection(error) {
2944
+ Object.assign(error, { isRepeatedRejection: true });
2945
+ }
2946
+ function isRepeatedUploadRejection(error) {
2947
+ return error instanceof Error && "isRepeatedRejection" in error && error.isRepeatedRejection === true;
2942
2948
  }
2943
2949
 
2944
2950
  // src/core/upload/upload-queue.ts
@@ -2947,6 +2953,7 @@ var INITIAL_RETRY_DELAY = 2000;
2947
2953
  var MAX_RETRY_DELAY = 300000;
2948
2954
  var RETRY_MULTIPLIER = 1.5;
2949
2955
  var PROCESSING_INTERVAL = 5000;
2956
+ var creditRejectedUploadIdsInPageSession = new Set;
2950
2957
 
2951
2958
  class UploadQueueManager {
2952
2959
  getAccessToken;
@@ -2956,6 +2963,7 @@ class UploadQueueManager {
2956
2963
  networkOnlineHandler;
2957
2964
  progressFloors = new Map;
2958
2965
  interruptedUploadIds = new Set;
2966
+ creditRejectedUploadIds = new Set;
2959
2967
  isProcessing = false;
2960
2968
  hasRecoveredStaleUploads = false;
2961
2969
  hasNetworkRecovered = false;
@@ -3016,20 +3024,19 @@ class UploadQueueManager {
3016
3024
  const failedUploads = await this.storageService.getPendingUploads("failed");
3017
3025
  const retryableUploads = failedUploads.filter((upload) => upload.retryCount < MAX_RETRIES2);
3018
3026
  if (retryableUploads.length > 0) {
3019
- const upload = this.getOldestFailedUpload(retryableUploads);
3020
- const delay = this.calculateRetryDelay(upload.retryCount);
3021
- const timeSinceLastAttempt = Date.now() - upload.updatedAt;
3022
- const isRetryDue = this.hasNetworkRecovered || timeSinceLastAttempt >= delay;
3027
+ const hasNetworkRecovered = this.hasNetworkRecovered;
3023
3028
  this.hasNetworkRecovered = false;
3024
- if (isRetryDue) {
3029
+ const dueUploads = retryableUploads.filter((upload) => hasNetworkRecovered || this.getRemainingRetryDelay(upload) <= 0);
3030
+ if (dueUploads.length > 0) {
3031
+ const upload = this.getOldestFailedUpload(dueUploads);
3025
3032
  await this.storageService.updateUploadStatus(upload.id, {
3026
3033
  status: "pending",
3027
3034
  retryCount: upload.retryCount
3028
3035
  });
3029
3036
  await this.processUpload(upload);
3030
3037
  } else {
3031
- const remainingDelay = delay - timeSinceLastAttempt;
3032
- this.scheduleRetry(remainingDelay);
3038
+ const remainingDelays = retryableUploads.map((upload) => this.getRemainingRetryDelay(upload));
3039
+ this.scheduleRetry(Math.min(...remainingDelays));
3033
3040
  }
3034
3041
  }
3035
3042
  this.isProcessing = false;
@@ -3059,7 +3066,7 @@ class UploadQueueManager {
3059
3066
  return result;
3060
3067
  } catch (error) {
3061
3068
  this.isProcessing = false;
3062
- if (!isRetryableUploadError(error)) {
3069
+ if (isInsufficientCreditsError(error)) {
3063
3070
  throw error;
3064
3071
  }
3065
3072
  throw new Error(`Error processing matching upload: ${extractErrorMessage(error)}`);
@@ -3187,57 +3194,80 @@ class UploadQueueManager {
3187
3194
  this.callbacks.onUploadComplete?.(upload.id, result);
3188
3195
  return result;
3189
3196
  } catch (error) {
3190
- const errorMessage = extractErrorMessage(error);
3191
- const isRetryable = isRetryableUploadError(error);
3192
- const uploadError = error instanceof Error ? error : new Error(errorMessage);
3193
- const retryCount = this.resolveRetryCount(upload, isRetryable);
3194
- const willRetry = retryCount < MAX_RETRIES2;
3195
- const retryDelayMs = willRetry ? this.calculateRetryDelay(retryCount) : null;
3196
- await this.storageService.updateUploadStatus(upload.id, {
3197
- status: "failed",
3198
- retryCount,
3199
- lastError: errorMessage
3200
- });
3201
- this.callbacks.onUploadAttemptFailed?.(upload.id, {
3197
+ return this.handleUploadFailure({
3198
+ upload,
3199
+ error,
3202
3200
  attempt,
3201
+ progressDetails,
3202
+ shouldThrowOnError
3203
+ });
3204
+ }
3205
+ }
3206
+ async handleUploadFailure({
3207
+ upload,
3208
+ error,
3209
+ attempt,
3210
+ progressDetails,
3211
+ shouldThrowOnError
3212
+ }) {
3213
+ const errorMessage = extractErrorMessage(error);
3214
+ const isCreditRejected = isInsufficientCreditsError(error);
3215
+ const uploadError = error instanceof Error ? error : new Error(errorMessage);
3216
+ const retryCount = this.resolveRetryCount(upload, isCreditRejected);
3217
+ const willRetry = retryCount < MAX_RETRIES2;
3218
+ if (isCreditRejected) {
3219
+ if (creditRejectedUploadIdsInPageSession.has(upload.id)) {
3220
+ markRepeatedUploadRejection(uploadError);
3221
+ }
3222
+ creditRejectedUploadIdsInPageSession.add(upload.id);
3223
+ this.creditRejectedUploadIds.add(upload.id);
3224
+ } else {
3225
+ this.creditRejectedUploadIds.delete(upload.id);
3226
+ }
3227
+ const retryDelayMs = this.resolveRetryDelay(upload.id, retryCount);
3228
+ await this.storageService.updateUploadStatus(upload.id, {
3229
+ status: "failed",
3230
+ retryCount,
3231
+ lastError: errorMessage
3232
+ });
3233
+ this.callbacks.onUploadAttemptFailed?.(upload.id, {
3234
+ attempt,
3235
+ maxAttempts: MAX_RETRIES2,
3236
+ loadedBytes: progressDetails.loadedBytes,
3237
+ totalBytes: progressDetails.totalBytes,
3238
+ reason: errorMessage,
3239
+ retryDelayMs: willRetry ? retryDelayMs : null,
3240
+ willRetry
3241
+ });
3242
+ if (!willRetry) {
3243
+ this.forgetUploadProgress(upload.id);
3244
+ }
3245
+ if (willRetry && !isCreditRejected) {
3246
+ this.callbacks.onUploadRetry?.(upload.id, {
3247
+ attempt: attempt + 1,
3203
3248
  maxAttempts: MAX_RETRIES2,
3204
- loadedBytes: progressDetails.loadedBytes,
3205
- totalBytes: progressDetails.totalBytes,
3206
3249
  reason: errorMessage,
3207
- retryDelayMs,
3208
- willRetry
3250
+ retryDelayMs
3209
3251
  });
3210
- if (!willRetry) {
3211
- this.forgetUploadProgress(upload.id);
3212
- }
3213
- if (willRetry) {
3214
- const delay = this.calculateRetryDelay(retryCount);
3215
- this.callbacks.onUploadRetry?.(upload.id, {
3216
- attempt: attempt + 1,
3217
- maxAttempts: MAX_RETRIES2,
3218
- reason: errorMessage,
3219
- retryDelayMs: delay
3220
- });
3221
- this.scheduleRetry(delay);
3222
- } else if (isRetryable) {
3223
- this.callbacks.onUploadError?.(upload.id, new Error(`Upload failed after ${MAX_RETRIES2} attempts: ${errorMessage}`));
3224
- } else {
3225
- this.callbacks.onUploadError?.(upload.id, uploadError);
3226
- }
3227
- if (shouldThrowOnError) {
3228
- throw isRetryable ? new Error(errorMessage) : uploadError;
3229
- }
3230
- return null;
3231
3252
  }
3253
+ if (willRetry) {
3254
+ this.scheduleRetry(retryDelayMs);
3255
+ }
3256
+ if (isCreditRejected) {
3257
+ this.callbacks.onUploadError?.(upload.id, uploadError);
3258
+ } else if (!willRetry) {
3259
+ this.callbacks.onUploadError?.(upload.id, new Error(`Upload failed after ${MAX_RETRIES2} attempts: ${errorMessage}`));
3260
+ }
3261
+ if (shouldThrowOnError) {
3262
+ throw isCreditRejected ? uploadError : new Error(errorMessage);
3263
+ }
3264
+ return null;
3232
3265
  }
3233
3266
  isResumedUpload(upload) {
3234
- return upload.retryCount > 0 || this.interruptedUploadIds.has(upload.id) || this.progressFloors.has(upload.id);
3267
+ return upload.retryCount > 0 || upload.status === "failed" || this.interruptedUploadIds.has(upload.id) || this.progressFloors.has(upload.id);
3235
3268
  }
3236
- resolveRetryCount(upload, isRetryable) {
3237
- if (!isRetryable) {
3238
- return MAX_RETRIES2;
3239
- }
3240
- if (this.isOffline()) {
3269
+ resolveRetryCount(upload, isCreditRejected) {
3270
+ if (isCreditRejected || this.isOffline()) {
3241
3271
  return upload.retryCount;
3242
3272
  }
3243
3273
  return upload.retryCount + 1;
@@ -3253,10 +3283,22 @@ class UploadQueueManager {
3253
3283
  forgetUploadProgress(id) {
3254
3284
  this.progressFloors.delete(id);
3255
3285
  this.interruptedUploadIds.delete(id);
3286
+ this.creditRejectedUploadIds.delete(id);
3287
+ creditRejectedUploadIdsInPageSession.delete(id);
3256
3288
  }
3257
3289
  isOffline() {
3258
3290
  return typeof navigator !== "undefined" && navigator.onLine === false;
3259
3291
  }
3292
+ getRemainingRetryDelay(upload) {
3293
+ const delay = this.resolveRetryDelay(upload.id, upload.retryCount);
3294
+ return delay - (Date.now() - upload.updatedAt);
3295
+ }
3296
+ resolveRetryDelay(uploadId, retryCount) {
3297
+ if (this.creditRejectedUploadIds.has(uploadId)) {
3298
+ return MAX_RETRY_DELAY;
3299
+ }
3300
+ return this.calculateRetryDelay(retryCount);
3301
+ }
3260
3302
  calculateRetryDelay(retryCount) {
3261
3303
  const delay = INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** (retryCount - 1);
3262
3304
  return Math.min(delay, MAX_RETRY_DELAY);
@@ -9049,7 +9091,7 @@ function resolveDeviceError(input) {
9049
9091
  // package.json
9050
9092
  var package_default = {
9051
9093
  name: "@vidtreo/recorder",
9052
- version: "1.9.0",
9094
+ version: "1.10.0",
9053
9095
  type: "module",
9054
9096
  description: "Vidtreo SDK for browser-based video recording and transcoding. Features include camera/screen recording, real-time MP4 transcoding, audio level analysis, mute/pause controls, source switching, device selection, and automatic backend uploads. Similar to Ziggeo and Addpipe, Vidtreo provides enterprise-grade video processing capabilities for web applications.",
9055
9097
  main: "./dist/index.js",
@@ -32528,6 +32570,30 @@ class RecorderController {
32528
32570
  sourceType
32529
32571
  });
32530
32572
  }
32573
+ handleQueuedUploadError(id, error) {
32574
+ this.isUploadInProgress = false;
32575
+ this.uploadCallbacks.onError(error);
32576
+ const metadata = this.uploadMetadataManager.getMetadata(id);
32577
+ if (isInsufficientCreditsError(error)) {
32578
+ if (metadata && !isRepeatedUploadRejection(error)) {
32579
+ this.sendUploadFailedTelemetry(metadata, error);
32580
+ }
32581
+ return;
32582
+ }
32583
+ if (metadata) {
32584
+ this.sendUploadFailedTelemetry(metadata, error);
32585
+ this.uploadMetadataManager.clearMetadata(id);
32586
+ }
32587
+ this.uploadAttemptFailureById.delete(id);
32588
+ this.uploadAttemptSuccessById.delete(id);
32589
+ }
32590
+ sendUploadFailedTelemetry(metadata, error) {
32591
+ this.telemetryManager.sendEvent("upload.failed", {
32592
+ filename: metadata.filename,
32593
+ duration: metadata.duration,
32594
+ sourceType: metadata.sourceType
32595
+ }, error);
32596
+ }
32531
32597
  async uploadDirectlyWithoutQueue(options) {
32532
32598
  if (!this.uploadService) {
32533
32599
  const error = new Error("Upload service not available");
@@ -32952,19 +33018,7 @@ class RecorderController {
32952
33018
  this.uploadAttemptSuccessById.delete(id);
32953
33019
  },
32954
33020
  onUploadError: (id, error) => {
32955
- this.isUploadInProgress = false;
32956
- this.uploadCallbacks.onError(error);
32957
- const metadata = this.uploadMetadataManager.getMetadata(id);
32958
- if (metadata) {
32959
- this.telemetryManager.sendEvent("upload.failed", {
32960
- filename: metadata.filename,
32961
- duration: metadata.duration,
32962
- sourceType: metadata.sourceType
32963
- }, error);
32964
- this.uploadMetadataManager.clearMetadata(id);
32965
- }
32966
- this.uploadAttemptFailureById.delete(id);
32967
- this.uploadAttemptSuccessById.delete(id);
33021
+ this.handleQueuedUploadError(id, error);
32968
33022
  }
32969
33023
  });
32970
33024
  this.uploadQueueManager.processQueue().catch((error) => {
@@ -33727,6 +33781,7 @@ export {
33727
33781
  isCredentialConflictError,
33728
33782
  isMobileDevice2 as isMobileDevice,
33729
33783
  isPictureInPictureSupported,
33784
+ isRepeatedUploadRejection,
33730
33785
  logger,
33731
33786
  mapPresetToConfig,
33732
33787
  parseAudioRecordingConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vidtreo/recorder",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "description": "Vidtreo SDK for browser-based video recording and transcoding. Features include camera/screen recording, real-time MP4 transcoding, audio level analysis, mute/pause controls, source switching, device selection, and automatic backend uploads. Similar to Ziggeo and Addpipe, Vidtreo provides enterprise-grade video processing capabilities for web applications.",
6
6
  "main": "./dist/index.js",