@vidtreo/recorder 1.9.0 → 1.11.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;
@@ -453,6 +455,7 @@ export type TranscodedAudio = {
453
455
  };
454
456
 
455
457
  export declare const DISABLED_AUDIO_RECORDING_CONFIG: Readonly<AudioRecordingConfig>;
458
+ export declare const DEMO_AUDIO_RECORDING_CONFIG: Readonly<AudioRecordingConfig>;
456
459
  export declare function parseAudioRecordingConfig(value: unknown): AudioRecordingConfig;
457
460
  export declare function requireEnabledAudioRecordingConfig(config: AudioRecordingConfig | undefined): AudioRecordingConfig;
458
461
 
@@ -1121,6 +1124,7 @@ export declare class ConfigManager {
1121
1124
  private configFetched;
1122
1125
  private configReady;
1123
1126
  initialize(apiKey: string | null, backendUrl: string | null, getAccessToken?: AccessTokenProvider | null): Promise<void>;
1127
+ applyDemoConfig(): void;
1124
1128
  fetchConfig(): Promise<void>;
1125
1129
  getConfig(): Promise<TranscodeConfig>;
1126
1130
  getConfigForRecording(): TranscodeConfig;
@@ -3666,6 +3670,8 @@ export declare class RecorderController {
3666
3670
  prepareLocalRecording(blob: Blob): Promise<LocalRecording>;
3667
3671
  uploadVideo(blob: Blob, apiKey: string | null, backendUrl: string, metadata: Record<string, unknown>, getAccessToken?: AccessTokenProvider): Promise<void>;
3668
3672
  private uploadVideoAfterStatusClear;
3673
+ private handleQueuedUploadError;
3674
+ private sendUploadFailedTelemetry;
3669
3675
  private uploadDirectlyWithoutQueue;
3670
3676
  getStream(): MediaStream | null;
3671
3677
  isConfigReady(): boolean;
@@ -3777,6 +3783,7 @@ export declare class UploadQueueManager {
3777
3783
  private readonly networkOnlineHandler;
3778
3784
  private readonly progressFloors;
3779
3785
  private readonly interruptedUploadIds;
3786
+ private readonly creditRejectedUploadIds;
3780
3787
  private isProcessing;
3781
3788
  private hasRecoveredStaleUploads;
3782
3789
  private hasNetworkRecovered;
@@ -3802,11 +3809,14 @@ export declare class UploadQueueManager {
3802
3809
  private canProcessMatchingUpload;
3803
3810
  private matchesMetadata;
3804
3811
  private processUpload;
3812
+ private handleUploadFailure;
3805
3813
  private isResumedUpload;
3806
3814
  private resolveRetryCount;
3807
3815
  private reportUploadProgress;
3808
3816
  private forgetUploadProgress;
3809
3817
  private isOffline;
3818
+ private getRemainingRetryDelay;
3819
+ private resolveRetryDelay;
3810
3820
  private calculateRetryDelay;
3811
3821
  private scheduleRetry;
3812
3822
  private clearTimer;
@@ -3817,10 +3827,13 @@ export declare const UPLOAD_ERROR_INSUFFICIENT_CREDITS = "upload.insufficient-cr
3817
3827
  export type UploadErrorCode = typeof UPLOAD_ERROR_INSUFFICIENT_CREDITS;
3818
3828
  export type UploadError = Error & {
3819
3829
  code: UploadErrorCode;
3830
+ isRepeatedRejection?: true;
3820
3831
  };
3821
3832
  export declare function createUploadError(code: UploadErrorCode, message: string): UploadError;
3822
3833
  export declare function getUploadErrorCode(error: unknown): UploadErrorCode | null;
3823
- export declare function isRetryableUploadError(error: unknown): boolean;
3834
+ export declare function isInsufficientCreditsError(error: unknown): boolean;
3835
+ export declare function markRepeatedUploadRejection(error: Error): void;
3836
+ export declare function isRepeatedUploadRejection(error: unknown): boolean;
3824
3837
 
3825
3838
  export type PartHasher = {
3826
3839
  hash: (part: Blob) => Promise<PartChecksums>;
package/dist/index.js CHANGED
@@ -544,6 +544,10 @@ var DISABLED_AUDIO_RECORDING_CONFIG = Object.freeze({
544
544
  autoGain: true,
545
545
  normalize: false
546
546
  });
547
+ var DEMO_AUDIO_RECORDING_CONFIG = Object.freeze({
548
+ ...DISABLED_AUDIO_RECORDING_CONFIG,
549
+ enabled: true
550
+ });
547
551
  function isRecord(value) {
548
552
  return typeof value === "object" && value !== null && !Array.isArray(value);
549
553
  }
@@ -1098,6 +1102,12 @@ class ConfigManager {
1098
1102
  }
1099
1103
  this.fetchConfigInBackground();
1100
1104
  }
1105
+ applyDemoConfig() {
1106
+ this.currentConfig = {
1107
+ ...DEFAULT_TRANSCODE_CONFIG,
1108
+ audioRecording: { ...DEMO_AUDIO_RECORDING_CONFIG }
1109
+ };
1110
+ }
1101
1111
  async fetchConfig() {
1102
1112
  if (!this.configService) {
1103
1113
  return;
@@ -2937,8 +2947,14 @@ function getUploadErrorCode(error) {
2937
2947
  }
2938
2948
  return null;
2939
2949
  }
2940
- function isRetryableUploadError(error) {
2941
- return getUploadErrorCode(error) === null;
2950
+ function isInsufficientCreditsError(error) {
2951
+ return getUploadErrorCode(error) === UPLOAD_ERROR_INSUFFICIENT_CREDITS;
2952
+ }
2953
+ function markRepeatedUploadRejection(error) {
2954
+ Object.assign(error, { isRepeatedRejection: true });
2955
+ }
2956
+ function isRepeatedUploadRejection(error) {
2957
+ return error instanceof Error && "isRepeatedRejection" in error && error.isRepeatedRejection === true;
2942
2958
  }
2943
2959
 
2944
2960
  // src/core/upload/upload-queue.ts
@@ -2947,6 +2963,7 @@ var INITIAL_RETRY_DELAY = 2000;
2947
2963
  var MAX_RETRY_DELAY = 300000;
2948
2964
  var RETRY_MULTIPLIER = 1.5;
2949
2965
  var PROCESSING_INTERVAL = 5000;
2966
+ var creditRejectedUploadIdsInPageSession = new Set;
2950
2967
 
2951
2968
  class UploadQueueManager {
2952
2969
  getAccessToken;
@@ -2956,6 +2973,7 @@ class UploadQueueManager {
2956
2973
  networkOnlineHandler;
2957
2974
  progressFloors = new Map;
2958
2975
  interruptedUploadIds = new Set;
2976
+ creditRejectedUploadIds = new Set;
2959
2977
  isProcessing = false;
2960
2978
  hasRecoveredStaleUploads = false;
2961
2979
  hasNetworkRecovered = false;
@@ -3016,20 +3034,19 @@ class UploadQueueManager {
3016
3034
  const failedUploads = await this.storageService.getPendingUploads("failed");
3017
3035
  const retryableUploads = failedUploads.filter((upload) => upload.retryCount < MAX_RETRIES2);
3018
3036
  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;
3037
+ const hasNetworkRecovered = this.hasNetworkRecovered;
3023
3038
  this.hasNetworkRecovered = false;
3024
- if (isRetryDue) {
3039
+ const dueUploads = retryableUploads.filter((upload) => hasNetworkRecovered || this.getRemainingRetryDelay(upload) <= 0);
3040
+ if (dueUploads.length > 0) {
3041
+ const upload = this.getOldestFailedUpload(dueUploads);
3025
3042
  await this.storageService.updateUploadStatus(upload.id, {
3026
3043
  status: "pending",
3027
3044
  retryCount: upload.retryCount
3028
3045
  });
3029
3046
  await this.processUpload(upload);
3030
3047
  } else {
3031
- const remainingDelay = delay - timeSinceLastAttempt;
3032
- this.scheduleRetry(remainingDelay);
3048
+ const remainingDelays = retryableUploads.map((upload) => this.getRemainingRetryDelay(upload));
3049
+ this.scheduleRetry(Math.min(...remainingDelays));
3033
3050
  }
3034
3051
  }
3035
3052
  this.isProcessing = false;
@@ -3059,7 +3076,7 @@ class UploadQueueManager {
3059
3076
  return result;
3060
3077
  } catch (error) {
3061
3078
  this.isProcessing = false;
3062
- if (!isRetryableUploadError(error)) {
3079
+ if (isInsufficientCreditsError(error)) {
3063
3080
  throw error;
3064
3081
  }
3065
3082
  throw new Error(`Error processing matching upload: ${extractErrorMessage(error)}`);
@@ -3187,57 +3204,80 @@ class UploadQueueManager {
3187
3204
  this.callbacks.onUploadComplete?.(upload.id, result);
3188
3205
  return result;
3189
3206
  } 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, {
3207
+ return this.handleUploadFailure({
3208
+ upload,
3209
+ error,
3202
3210
  attempt,
3211
+ progressDetails,
3212
+ shouldThrowOnError
3213
+ });
3214
+ }
3215
+ }
3216
+ async handleUploadFailure({
3217
+ upload,
3218
+ error,
3219
+ attempt,
3220
+ progressDetails,
3221
+ shouldThrowOnError
3222
+ }) {
3223
+ const errorMessage = extractErrorMessage(error);
3224
+ const isCreditRejected = isInsufficientCreditsError(error);
3225
+ const uploadError = error instanceof Error ? error : new Error(errorMessage);
3226
+ const retryCount = this.resolveRetryCount(upload, isCreditRejected);
3227
+ const willRetry = retryCount < MAX_RETRIES2;
3228
+ if (isCreditRejected) {
3229
+ if (creditRejectedUploadIdsInPageSession.has(upload.id)) {
3230
+ markRepeatedUploadRejection(uploadError);
3231
+ }
3232
+ creditRejectedUploadIdsInPageSession.add(upload.id);
3233
+ this.creditRejectedUploadIds.add(upload.id);
3234
+ } else {
3235
+ this.creditRejectedUploadIds.delete(upload.id);
3236
+ }
3237
+ const retryDelayMs = this.resolveRetryDelay(upload.id, retryCount);
3238
+ await this.storageService.updateUploadStatus(upload.id, {
3239
+ status: "failed",
3240
+ retryCount,
3241
+ lastError: errorMessage
3242
+ });
3243
+ this.callbacks.onUploadAttemptFailed?.(upload.id, {
3244
+ attempt,
3245
+ maxAttempts: MAX_RETRIES2,
3246
+ loadedBytes: progressDetails.loadedBytes,
3247
+ totalBytes: progressDetails.totalBytes,
3248
+ reason: errorMessage,
3249
+ retryDelayMs: willRetry ? retryDelayMs : null,
3250
+ willRetry
3251
+ });
3252
+ if (!willRetry) {
3253
+ this.forgetUploadProgress(upload.id);
3254
+ }
3255
+ if (willRetry && !isCreditRejected) {
3256
+ this.callbacks.onUploadRetry?.(upload.id, {
3257
+ attempt: attempt + 1,
3203
3258
  maxAttempts: MAX_RETRIES2,
3204
- loadedBytes: progressDetails.loadedBytes,
3205
- totalBytes: progressDetails.totalBytes,
3206
3259
  reason: errorMessage,
3207
- retryDelayMs,
3208
- willRetry
3260
+ retryDelayMs
3209
3261
  });
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
3262
  }
3263
+ if (willRetry) {
3264
+ this.scheduleRetry(retryDelayMs);
3265
+ }
3266
+ if (isCreditRejected) {
3267
+ this.callbacks.onUploadError?.(upload.id, uploadError);
3268
+ } else if (!willRetry) {
3269
+ this.callbacks.onUploadError?.(upload.id, new Error(`Upload failed after ${MAX_RETRIES2} attempts: ${errorMessage}`));
3270
+ }
3271
+ if (shouldThrowOnError) {
3272
+ throw isCreditRejected ? uploadError : new Error(errorMessage);
3273
+ }
3274
+ return null;
3232
3275
  }
3233
3276
  isResumedUpload(upload) {
3234
- return upload.retryCount > 0 || this.interruptedUploadIds.has(upload.id) || this.progressFloors.has(upload.id);
3277
+ return upload.retryCount > 0 || upload.status === "failed" || this.interruptedUploadIds.has(upload.id) || this.progressFloors.has(upload.id);
3235
3278
  }
3236
- resolveRetryCount(upload, isRetryable) {
3237
- if (!isRetryable) {
3238
- return MAX_RETRIES2;
3239
- }
3240
- if (this.isOffline()) {
3279
+ resolveRetryCount(upload, isCreditRejected) {
3280
+ if (isCreditRejected || this.isOffline()) {
3241
3281
  return upload.retryCount;
3242
3282
  }
3243
3283
  return upload.retryCount + 1;
@@ -3253,10 +3293,22 @@ class UploadQueueManager {
3253
3293
  forgetUploadProgress(id) {
3254
3294
  this.progressFloors.delete(id);
3255
3295
  this.interruptedUploadIds.delete(id);
3296
+ this.creditRejectedUploadIds.delete(id);
3297
+ creditRejectedUploadIdsInPageSession.delete(id);
3256
3298
  }
3257
3299
  isOffline() {
3258
3300
  return typeof navigator !== "undefined" && navigator.onLine === false;
3259
3301
  }
3302
+ getRemainingRetryDelay(upload) {
3303
+ const delay = this.resolveRetryDelay(upload.id, upload.retryCount);
3304
+ return delay - (Date.now() - upload.updatedAt);
3305
+ }
3306
+ resolveRetryDelay(uploadId, retryCount) {
3307
+ if (this.creditRejectedUploadIds.has(uploadId)) {
3308
+ return MAX_RETRY_DELAY;
3309
+ }
3310
+ return this.calculateRetryDelay(retryCount);
3311
+ }
3260
3312
  calculateRetryDelay(retryCount) {
3261
3313
  const delay = INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** (retryCount - 1);
3262
3314
  return Math.min(delay, MAX_RETRY_DELAY);
@@ -9049,7 +9101,7 @@ function resolveDeviceError(input) {
9049
9101
  // package.json
9050
9102
  var package_default = {
9051
9103
  name: "@vidtreo/recorder",
9052
- version: "1.9.0",
9104
+ version: "1.11.0",
9053
9105
  type: "module",
9054
9106
  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
9107
  main: "./dist/index.js",
@@ -32143,6 +32195,9 @@ class RecorderController {
32143
32195
  isDemo = config.demo;
32144
32196
  }
32145
32197
  this.isDemo = isDemo;
32198
+ if (isDemo) {
32199
+ this.configManager.applyDemoConfig();
32200
+ }
32146
32201
  let apiKey = null;
32147
32202
  if (typeof config.apiKey === "string" && config.apiKey.length > 0) {
32148
32203
  apiKey = config.apiKey;
@@ -32528,6 +32583,30 @@ class RecorderController {
32528
32583
  sourceType
32529
32584
  });
32530
32585
  }
32586
+ handleQueuedUploadError(id, error) {
32587
+ this.isUploadInProgress = false;
32588
+ this.uploadCallbacks.onError(error);
32589
+ const metadata = this.uploadMetadataManager.getMetadata(id);
32590
+ if (isInsufficientCreditsError(error)) {
32591
+ if (metadata && !isRepeatedUploadRejection(error)) {
32592
+ this.sendUploadFailedTelemetry(metadata, error);
32593
+ }
32594
+ return;
32595
+ }
32596
+ if (metadata) {
32597
+ this.sendUploadFailedTelemetry(metadata, error);
32598
+ this.uploadMetadataManager.clearMetadata(id);
32599
+ }
32600
+ this.uploadAttemptFailureById.delete(id);
32601
+ this.uploadAttemptSuccessById.delete(id);
32602
+ }
32603
+ sendUploadFailedTelemetry(metadata, error) {
32604
+ this.telemetryManager.sendEvent("upload.failed", {
32605
+ filename: metadata.filename,
32606
+ duration: metadata.duration,
32607
+ sourceType: metadata.sourceType
32608
+ }, error);
32609
+ }
32531
32610
  async uploadDirectlyWithoutQueue(options) {
32532
32611
  if (!this.uploadService) {
32533
32612
  const error = new Error("Upload service not available");
@@ -32952,19 +33031,7 @@ class RecorderController {
32952
33031
  this.uploadAttemptSuccessById.delete(id);
32953
33032
  },
32954
33033
  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);
33034
+ this.handleQueuedUploadError(id, error);
32968
33035
  }
32969
33036
  });
32970
33037
  this.uploadQueueManager.processQueue().catch((error) => {
@@ -33727,6 +33794,7 @@ export {
33727
33794
  isCredentialConflictError,
33728
33795
  isMobileDevice2 as isMobileDevice,
33729
33796
  isPictureInPictureSupported,
33797
+ isRepeatedUploadRejection,
33730
33798
  logger,
33731
33799
  mapPresetToConfig,
33732
33800
  parseAudioRecordingConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vidtreo/recorder",
3
- "version": "1.9.0",
3
+ "version": "1.11.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",