@vidtreo/recorder 1.4.0 → 1.4.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 (3) hide show
  1. package/dist/index.d.ts +1864 -1696
  2. package/dist/index.js +655 -123
  3. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -224,7 +224,9 @@ var HD_SIZE_LIMIT_MB_PER_MINUTE = 8;
224
224
  var FHD_SIZE_LIMIT_MB_PER_MINUTE = 18;
225
225
  var K4_SIZE_LIMIT_MB_PER_MINUTE = 46;
226
226
  var MP4_AUDIO_BITRATE = 128000;
227
+ var MP4_AUDIO_BITRATE_SD = 64000;
227
228
  var WEBM_AUDIO_BITRATE = 96000;
229
+ var WEBM_AUDIO_BITRATE_SD = 48000;
228
230
  var VIDEO_CODEC_AVC = "avc";
229
231
  var VIDEO_CODEC_VP9 = "vp9";
230
232
  var VIDEO_CODEC_AV1 = "av1";
@@ -323,13 +325,19 @@ function getPresetTotalBitrate(preset) {
323
325
  const sizeLimit = PRESET_SIZE_LIMIT_MB_PER_MINUTE[preset];
324
326
  return calculateTotalBitrateFromMbPerMinute(sizeLimit);
325
327
  }
326
- function getPresetAudioBitrateForFormat(format) {
328
+ function getPresetAudioBitrateForFormat(format, preset) {
329
+ if (preset === "sd") {
330
+ if (format === "webm") {
331
+ return WEBM_AUDIO_BITRATE_SD;
332
+ }
333
+ return MP4_AUDIO_BITRATE_SD;
334
+ }
327
335
  const policy = getFormatCompatibilityPolicy(format);
328
336
  return policy.audioBitrate;
329
337
  }
330
338
  function getPresetVideoBitrateForFormat(preset, format) {
331
339
  const totalBitrate = getPresetTotalBitrate(preset);
332
- const audioBitrate = getPresetAudioBitrateForFormat(format);
340
+ const audioBitrate = getPresetAudioBitrateForFormat(format, preset);
333
341
  return calculateVideoBitrate(totalBitrate, audioBitrate);
334
342
  }
335
343
  var PRESET_VIDEO_BITRATE_MAP = {
@@ -350,6 +358,7 @@ var MOBILE_RESOLUTION_MAP = {
350
358
  fhd: { width: 1080, height: 1920 },
351
359
  "4k": { width: 2160, height: 3840 }
352
360
  };
361
+ var PRESET_DEFAULT_FPS = 30;
353
362
  var DEFAULT_BACKEND_URL = "https://core.vidtreo.com";
354
363
  var DEFAULT_TRANSCODE_CONFIG = Object.freeze({
355
364
  format: "mp4",
@@ -378,7 +387,8 @@ function getDefaultConfigForFormat(format) {
378
387
  // src/core/utils/device-detector.ts
379
388
  import { UAParser as UAParser2 } from "ua-parser-js";
380
389
  function isMobileDevice() {
381
- const parser = new UAParser2;
390
+ const userAgent = globalThis.navigator && typeof globalThis.navigator.userAgent === "string" ? globalThis.navigator.userAgent : "";
391
+ const parser = new UAParser2(userAgent);
382
392
  const device = parser.getDevice();
383
393
  if (device.type === "mobile") {
384
394
  return true;
@@ -485,11 +495,13 @@ function mapPresetToConfig(options) {
485
495
  });
486
496
  const config = {
487
497
  format,
498
+ fps: PRESET_DEFAULT_FPS,
488
499
  width,
489
500
  height,
490
501
  bitrate: getPresetVideoBitrateForFormat(preset, format),
491
502
  audioCodec: policy.preferredAudioCodec,
492
- audioBitrate: policy.audioBitrate
503
+ audioBitrate: getPresetAudioBitrateForFormat(format, preset),
504
+ latencyMode: preset === "sd" ? "quality" : undefined
493
505
  };
494
506
  if (watermark) {
495
507
  config.watermark = {
@@ -1819,6 +1831,7 @@ function revokeProbeWorkerUrl(workerUrl) {
1819
1831
 
1820
1832
  // src/core/processor/worker/types.ts
1821
1833
  var WORKER_MESSAGE_TYPE_PROBE = "probe";
1834
+ var WORKER_MESSAGE_TYPE_WARMUP = "warmup";
1822
1835
  var WORKER_MESSAGE_TYPE_AUDIO_CHUNK = "audioChunk";
1823
1836
  var WORKER_RESPONSE_TYPE_PROBE_RESULT = "probeResult";
1824
1837
  var WORKER_RESPONSE_TYPE_DEBUG_LOG = "debugLog";
@@ -2013,11 +2026,11 @@ function getIsProbeFeaturesComplete(probeResult, requiresWatermark) {
2013
2026
  }
2014
2027
  return true;
2015
2028
  }
2016
- async function checkRecorderSupport(options = {}) {
2029
+ async function checkRecorderSupport(options = {}, dependencies = {}) {
2017
2030
  const requiresAudio = resolveBooleanOption(options.requiresAudio, true);
2018
2031
  const requiresWatermark = resolveBooleanOption(options.requiresWatermark, false);
2019
2032
  if (!shouldUseSupportCache()) {
2020
- return await buildSupportReport(requiresAudio, requiresWatermark);
2033
+ return await buildSupportReport(requiresAudio, requiresWatermark, dependencies);
2021
2034
  }
2022
2035
  const supportCacheKey = createSupportCacheKey(requiresAudio, requiresWatermark);
2023
2036
  const cachedReport = supportReportCache.get(supportCacheKey);
@@ -2028,7 +2041,7 @@ async function checkRecorderSupport(options = {}) {
2028
2041
  if (inflightReport) {
2029
2042
  return await inflightReport;
2030
2043
  }
2031
- const reportPromise = buildSupportReport(requiresAudio, requiresWatermark).then((report) => {
2044
+ const reportPromise = buildSupportReport(requiresAudio, requiresWatermark, dependencies).then((report) => {
2032
2045
  supportReportCache.set(supportCacheKey, report);
2033
2046
  supportReportPromiseCache.delete(supportCacheKey);
2034
2047
  return report;
@@ -2039,13 +2052,13 @@ async function checkRecorderSupport(options = {}) {
2039
2052
  supportReportPromiseCache.set(supportCacheKey, reportPromise);
2040
2053
  return await reportPromise;
2041
2054
  }
2042
- async function buildSupportReport(requiresAudio, requiresWatermark) {
2055
+ async function buildSupportReport(requiresAudio, requiresWatermark, dependencies) {
2043
2056
  const hasWorker = typeof Worker !== "undefined";
2044
2057
  const audioContextClass = getAudioContextClass();
2045
2058
  const hasAudioContext = audioContextClass !== null;
2046
2059
  const hasAudioWorklet = typeof AudioWorkletNode !== "undefined";
2047
2060
  const hasMainThreadMediaStreamTrackProcessor = typeof MediaStreamTrackProcessor !== "undefined";
2048
- const probeResult = await probeWorkerCapabilities(hasWorker);
2061
+ const probeResult = await probeWorkerCapabilities(hasWorker, dependencies);
2049
2062
  const videoPath = resolveVideoPath({
2050
2063
  probeResult,
2051
2064
  hasMainThreadMediaStreamTrackProcessor
@@ -2090,7 +2103,7 @@ async function buildSupportReport(requiresAudio, requiresWatermark) {
2090
2103
  audioPath
2091
2104
  };
2092
2105
  }
2093
- async function probeWorkerCapabilities(hasWorker) {
2106
+ async function probeWorkerCapabilities(hasWorker, dependencies) {
2094
2107
  if (!hasWorker) {
2095
2108
  return getEmptyProbeResult();
2096
2109
  }
@@ -2100,6 +2113,7 @@ async function probeWorkerCapabilities(hasWorker) {
2100
2113
  }
2101
2114
  return await new Promise((resolve) => {
2102
2115
  let resolved = false;
2116
+ const probeTimeoutMilliseconds = dependencies.probeTimeoutMilliseconds ?? PROBE_TIMEOUT_MILLISECONDS;
2103
2117
  const finalize = (result) => {
2104
2118
  if (resolved) {
2105
2119
  return;
@@ -2110,7 +2124,7 @@ async function probeWorkerCapabilities(hasWorker) {
2110
2124
  };
2111
2125
  const timeoutId = setTimeout(() => {
2112
2126
  finalize(getEmptyProbeResult());
2113
- }, PROBE_TIMEOUT_MILLISECONDS);
2127
+ }, probeTimeoutMilliseconds);
2114
2128
  worker.onmessage = (event) => {
2115
2129
  const payload = event.data;
2116
2130
  if (payload.type !== WORKER_RESPONSE_TYPE_PROBE_RESULT) {
@@ -3431,26 +3445,48 @@ var DEFAULT_RECORDING_OPTIONS = Object.freeze({
3431
3445
 
3432
3446
  // src/core/stream/stream-manager.ts
3433
3447
  var TRACK_READY_STATE_LIVE2 = "live";
3448
+ var AUDIO_RETRY_DELAY_MILLISECONDS = 300;
3449
+ var AUDIO_MAX_RETRIES = 2;
3434
3450
  var CAMERA_ERROR_CODE_MAP = {
3435
3451
  NotReadableError: "camera.in-use",
3436
3452
  NotFoundError: "camera.not-found",
3437
3453
  NotAllowedError: "camera.permission-denied",
3438
3454
  OverconstrainedError: "camera.overconstrained"
3439
3455
  };
3440
- function classifyCameraError(error) {
3456
+ var AUDIO_ERROR_CODE_MAP = {
3457
+ NotReadableError: "audio.in-use",
3458
+ NotFoundError: "audio.not-found",
3459
+ NotAllowedError: "audio.permission-denied",
3460
+ OverconstrainedError: "audio.overconstrained"
3461
+ };
3462
+ function getErrorName(error) {
3441
3463
  if (error instanceof DOMException) {
3442
- const mappedCode = CAMERA_ERROR_CODE_MAP[error.name];
3464
+ return error.name;
3465
+ }
3466
+ if (error !== null && typeof error === "object" && "name" in error && typeof error.name === "string") {
3467
+ return error.name;
3468
+ }
3469
+ return null;
3470
+ }
3471
+ function classifyCameraError(error) {
3472
+ const errorName = getErrorName(error);
3473
+ if (errorName !== null) {
3474
+ const mappedCode = CAMERA_ERROR_CODE_MAP[errorName];
3443
3475
  if (mappedCode !== undefined) {
3444
3476
  return mappedCode;
3445
3477
  }
3446
3478
  }
3447
- if (error !== null && typeof error === "object" && "name" in error && typeof error.name === "string") {
3448
- const mappedCode = CAMERA_ERROR_CODE_MAP[error.name];
3479
+ return "camera.unknown";
3480
+ }
3481
+ function classifyAudioError(error) {
3482
+ const errorName = getErrorName(error);
3483
+ if (errorName !== null) {
3484
+ const mappedCode = AUDIO_ERROR_CODE_MAP[errorName];
3449
3485
  if (mappedCode !== undefined) {
3450
3486
  return mappedCode;
3451
3487
  }
3452
3488
  }
3453
- return "camera.unknown";
3489
+ return "audio.unknown";
3454
3490
  }
3455
3491
  function createCameraStreamError(error) {
3456
3492
  const message = extractErrorMessage(error);
@@ -3459,6 +3495,20 @@ function createCameraStreamError(error) {
3459
3495
  cameraError.code = classifyCameraError(error);
3460
3496
  return cameraError;
3461
3497
  }
3498
+ function createAudioStreamError(error) {
3499
+ const message = extractErrorMessage(error);
3500
+ const audioError = new Error(message);
3501
+ audioError.name = "AudioError";
3502
+ audioError.code = classifyAudioError(error);
3503
+ return audioError;
3504
+ }
3505
+ function isRetriableAudioError(error) {
3506
+ const errorName = getErrorName(error);
3507
+ return errorName === "NotReadableError";
3508
+ }
3509
+ function delay(milliseconds) {
3510
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
3511
+ }
3462
3512
 
3463
3513
  class StreamManager {
3464
3514
  mediaStream = null;
@@ -3467,8 +3517,46 @@ class StreamManager {
3467
3517
  streamConfig;
3468
3518
  selectedAudioDeviceId = null;
3469
3519
  selectedVideoDeviceId = null;
3470
- constructor(streamConfig = {}) {
3520
+ audioStatus = "pending";
3521
+ audioAcquisitionPromise = null;
3522
+ pendingAudioError = null;
3523
+ acquisitionGeneration = 0;
3524
+ waitMilliseconds;
3525
+ audioRetryDelayMilliseconds;
3526
+ constructor(streamConfig = {}, dependencies = {}) {
3471
3527
  this.streamConfig = { ...DEFAULT_STREAM_CONFIG, ...streamConfig };
3528
+ this.waitMilliseconds = dependencies.waitMilliseconds ?? delay;
3529
+ this.audioRetryDelayMilliseconds = dependencies.audioRetryDelayMilliseconds ?? AUDIO_RETRY_DELAY_MILLISECONDS;
3530
+ }
3531
+ getAudioStatus() {
3532
+ return this.audioStatus;
3533
+ }
3534
+ isAudioReady() {
3535
+ return this.audioStatus === "acquired";
3536
+ }
3537
+ async waitForAudio() {
3538
+ if (this.audioStatus === "acquired") {
3539
+ return;
3540
+ }
3541
+ if (this.audioStatus === "failed") {
3542
+ throw this.pendingAudioError ?? createAudioStreamError(null);
3543
+ }
3544
+ if (this.audioAcquisitionPromise) {
3545
+ await this.audioAcquisitionPromise;
3546
+ }
3547
+ if (this.getAudioStatus() === "failed") {
3548
+ throw this.pendingAudioError ?? createAudioStreamError(null);
3549
+ }
3550
+ }
3551
+ setAudioStatus(status) {
3552
+ if (this.audioStatus === status) {
3553
+ return;
3554
+ }
3555
+ this.audioStatus = status;
3556
+ this.emit("audiostatuschange", { status });
3557
+ }
3558
+ emitAudioTelemetry(event) {
3559
+ this.emit("audiotelemetry", { event });
3472
3560
  }
3473
3561
  getState() {
3474
3562
  return this.state;
@@ -3602,20 +3690,7 @@ class StreamManager {
3602
3690
  this.setState("starting");
3603
3691
  logger.debug("[StreamManager] State set to 'starting'");
3604
3692
  try {
3605
- logger.debug("[StreamManager] Building constraints", {
3606
- selectedVideoDeviceId: this.selectedVideoDeviceId,
3607
- selectedAudioDeviceId: this.selectedAudioDeviceId
3608
- });
3609
- const constraints = {
3610
- video: this.buildVideoConstraints(this.selectedVideoDeviceId),
3611
- audio: this.buildAudioConstraints(this.selectedAudioDeviceId)
3612
- };
3613
- logger.debug("[StreamManager] Requesting media stream with constraints", {
3614
- hasVideo: !!constraints.video,
3615
- hasAudio: !!constraints.audio
3616
- });
3617
- const mediaDevices = requireMediaDevices();
3618
- this.mediaStream = await mediaDevices.getUserMedia(constraints);
3693
+ this.mediaStream = await this.acquireVideoAndAudioStream();
3619
3694
  logger.info("[StreamManager] Media stream obtained", {
3620
3695
  streamId: this.mediaStream.id,
3621
3696
  videoTracks: this.mediaStream.getVideoTracks().length,
@@ -3627,6 +3702,12 @@ class StreamManager {
3627
3702
  this.emit("streamstart", { stream: this.mediaStream });
3628
3703
  return this.mediaStream;
3629
3704
  } catch (error) {
3705
+ if (error instanceof Error && "code" in error && (error.name === "CameraError" || error.name === "AudioError")) {
3706
+ logger.error("[StreamManager] Failed to start stream", error);
3707
+ this.setState("error");
3708
+ this.emit("error", { error });
3709
+ throw error;
3710
+ }
3630
3711
  const err = createCameraStreamError(error);
3631
3712
  logger.error("[StreamManager] Failed to start stream", err);
3632
3713
  this.setState("error");
@@ -3634,6 +3715,187 @@ class StreamManager {
3634
3715
  throw err;
3635
3716
  }
3636
3717
  }
3718
+ async acquireVideoAndAudioStream() {
3719
+ const mediaDevices = requireMediaDevices();
3720
+ const videoConstraints = this.buildVideoConstraints(this.selectedVideoDeviceId);
3721
+ const audioConstraints = this.buildAudioConstraints(this.selectedAudioDeviceId);
3722
+ this.setAudioStatus("pending");
3723
+ this.pendingAudioError = null;
3724
+ logger.debug("[StreamManager] Attempting combined getUserMedia", {
3725
+ selectedVideoDeviceId: this.selectedVideoDeviceId,
3726
+ selectedAudioDeviceId: this.selectedAudioDeviceId
3727
+ });
3728
+ try {
3729
+ const stream = await mediaDevices.getUserMedia({
3730
+ video: videoConstraints,
3731
+ audio: audioConstraints
3732
+ });
3733
+ const hasVideo = stream.getVideoTracks().length > 0;
3734
+ const hasAudio = stream.getAudioTracks().length > 0;
3735
+ if (hasVideo && hasAudio) {
3736
+ logger.debug("[StreamManager] Combined getUserMedia succeeded with video + audio");
3737
+ this.setAudioStatus("acquired");
3738
+ return stream;
3739
+ }
3740
+ logger.warn("[StreamManager] Combined getUserMedia returned incomplete stream", {
3741
+ videoTracks: stream.getVideoTracks().length,
3742
+ audioTracks: stream.getAudioTracks().length
3743
+ });
3744
+ this.stopStreamTracks(stream);
3745
+ } catch (combinedError) {
3746
+ const combinedErrorName = getErrorName(combinedError);
3747
+ const combinedErrorMessage = extractErrorMessage(combinedError);
3748
+ logger.warn("[StreamManager] Combined getUserMedia failed, falling back to separate acquisition", {
3749
+ error: combinedErrorMessage,
3750
+ errorName: combinedErrorName
3751
+ });
3752
+ this.emitAudioTelemetry({
3753
+ name: "audio.acquisition.fallback",
3754
+ properties: {
3755
+ reason: "combined_getUserMedia_failed",
3756
+ originalError: combinedErrorMessage,
3757
+ originalErrorName: combinedErrorName,
3758
+ selectedAudioDeviceId: this.selectedAudioDeviceId,
3759
+ selectedVideoDeviceId: this.selectedVideoDeviceId
3760
+ }
3761
+ });
3762
+ }
3763
+ let videoStream;
3764
+ try {
3765
+ videoStream = await mediaDevices.getUserMedia({
3766
+ video: videoConstraints,
3767
+ audio: false
3768
+ });
3769
+ logger.debug("[StreamManager] Video-only stream acquired for preview");
3770
+ } catch (videoError) {
3771
+ this.setAudioStatus("failed");
3772
+ throw createCameraStreamError(videoError);
3773
+ }
3774
+ this.mediaStream = videoStream;
3775
+ this.acquisitionGeneration++;
3776
+ const generation = this.acquisitionGeneration;
3777
+ this.audioAcquisitionPromise = this.acquireAudioInBackground(mediaDevices, audioConstraints, generation);
3778
+ return videoStream;
3779
+ }
3780
+ async acquireAudioInBackground(mediaDevices, audioConstraints, generation) {
3781
+ try {
3782
+ const audioTrack = await this.acquireAudioTrackWithRetry(mediaDevices, audioConstraints);
3783
+ if (!this.mediaStream || this.acquisitionGeneration !== generation) {
3784
+ logger.debug("[StreamManager] Audio acquired but stream was replaced/stopped, discarding track", { generation, currentGeneration: this.acquisitionGeneration });
3785
+ audioTrack.stop();
3786
+ return;
3787
+ }
3788
+ this.mediaStream.addTrack(audioTrack);
3789
+ logger.info("[StreamManager] Audio track added to stream", {
3790
+ audioTrackId: audioTrack.id,
3791
+ audioTrackLabel: audioTrack.label,
3792
+ totalAudioTracks: this.mediaStream.getAudioTracks().length
3793
+ });
3794
+ this.setAudioStatus("acquired");
3795
+ } catch (error) {
3796
+ if (this.acquisitionGeneration !== generation) {
3797
+ return;
3798
+ }
3799
+ const audioError = error instanceof Error && "code" in error ? error : createAudioStreamError(error);
3800
+ this.pendingAudioError = audioError;
3801
+ this.setAudioStatus("failed");
3802
+ logger.error("[StreamManager] Background audio acquisition failed", audioError);
3803
+ this.emitAudioTelemetry({
3804
+ name: "audio.acquisition.failed",
3805
+ properties: {
3806
+ errorCode: audioError.code,
3807
+ errorName: audioError.name,
3808
+ maxRetries: AUDIO_MAX_RETRIES,
3809
+ retryDelayMs: this.audioRetryDelayMilliseconds,
3810
+ selectedAudioDeviceId: this.selectedAudioDeviceId
3811
+ },
3812
+ error: audioError
3813
+ });
3814
+ this.emit("error", { error: audioError });
3815
+ } finally {
3816
+ this.audioAcquisitionPromise = null;
3817
+ }
3818
+ }
3819
+ resolveAudioConstraintsForAttempt(audioConstraints, attempt) {
3820
+ if (attempt !== AUDIO_MAX_RETRIES) {
3821
+ return audioConstraints;
3822
+ }
3823
+ if (typeof audioConstraints !== "object") {
3824
+ return audioConstraints;
3825
+ }
3826
+ const { deviceId: _deviceId, ...relaxed } = audioConstraints;
3827
+ const hasOtherConstraints = Object.keys(relaxed).length > 0;
3828
+ const result = hasOtherConstraints ? relaxed : true;
3829
+ logger.debug("[StreamManager] Audio retry with relaxed constraints (no deviceId)", { attempt });
3830
+ return result;
3831
+ }
3832
+ async acquireAudioTrackWithRetry(mediaDevices, audioConstraints) {
3833
+ let lastError = null;
3834
+ for (let attempt = 0;attempt <= AUDIO_MAX_RETRIES; attempt++) {
3835
+ try {
3836
+ const constraintsForAttempt = this.resolveAudioConstraintsForAttempt(audioConstraints, attempt);
3837
+ const audioStream = await mediaDevices.getUserMedia({
3838
+ video: false,
3839
+ audio: constraintsForAttempt
3840
+ });
3841
+ const audioTrack = audioStream.getAudioTracks()[0];
3842
+ if (!audioTrack) {
3843
+ this.stopStreamTracks(audioStream);
3844
+ throw new Error("getUserMedia returned no audio tracks");
3845
+ }
3846
+ for (const track of audioStream.getVideoTracks()) {
3847
+ track.stop();
3848
+ }
3849
+ logger.debug("[StreamManager] Audio track acquired", {
3850
+ attempt,
3851
+ trackId: audioTrack.id,
3852
+ trackLabel: audioTrack.label
3853
+ });
3854
+ if (attempt > 0) {
3855
+ this.emitAudioTelemetry({
3856
+ name: "audio.acquisition.recovered",
3857
+ properties: {
3858
+ successAttempt: attempt,
3859
+ totalAttempts: attempt + 1,
3860
+ audioTrackLabel: audioTrack.label,
3861
+ usedRelaxedConstraints: attempt === AUDIO_MAX_RETRIES
3862
+ }
3863
+ });
3864
+ }
3865
+ return audioTrack;
3866
+ } catch (error) {
3867
+ lastError = error;
3868
+ const errorMessage = extractErrorMessage(error);
3869
+ const errorName = getErrorName(error);
3870
+ const retriable = isRetriableAudioError(error);
3871
+ logger.warn("[StreamManager] Audio acquisition failed", {
3872
+ attempt,
3873
+ maxRetries: AUDIO_MAX_RETRIES,
3874
+ error: errorMessage,
3875
+ errorName,
3876
+ isRetriable: retriable
3877
+ });
3878
+ this.emitAudioTelemetry({
3879
+ name: "audio.acquisition.retry",
3880
+ properties: {
3881
+ attempt,
3882
+ maxRetries: AUDIO_MAX_RETRIES,
3883
+ errorMessage,
3884
+ errorName: errorName ?? "unknown",
3885
+ isRetriable: retriable,
3886
+ usedRelaxedConstraints: attempt === AUDIO_MAX_RETRIES,
3887
+ willRetry: attempt < AUDIO_MAX_RETRIES && retriable
3888
+ }
3889
+ });
3890
+ const canRetry = attempt < AUDIO_MAX_RETRIES && retriable;
3891
+ if (!canRetry) {
3892
+ break;
3893
+ }
3894
+ await this.waitMilliseconds(this.audioRetryDelayMilliseconds);
3895
+ }
3896
+ }
3897
+ throw createAudioStreamError(lastError);
3898
+ }
3637
3899
  stopStream() {
3638
3900
  if (this.mediaStream) {
3639
3901
  for (const track of this.mediaStream.getTracks()) {
@@ -3641,6 +3903,10 @@ class StreamManager {
3641
3903
  }
3642
3904
  this.mediaStream = null;
3643
3905
  }
3906
+ this.audioStatus = "pending";
3907
+ this.pendingAudioError = null;
3908
+ this.audioAcquisitionPromise = null;
3909
+ this.acquisitionGeneration++;
3644
3910
  if (this.state !== "idle") {
3645
3911
  this.setState("idle");
3646
3912
  this.emit("streamstop", undefined);
@@ -3993,9 +4259,13 @@ class StreamRecordingState {
3993
4259
  return Promise.resolve();
3994
4260
  }
3995
4261
  const hasAudioTracks = mediaStream.getAudioTracks().length > 0;
4262
+ if (!hasAudioTracks) {
4263
+ logger.error("[StreamRecordingState] Cannot start recording without audio tracks");
4264
+ throw new Error("Cannot start recording: no audio track available. Please check your microphone.");
4265
+ }
3996
4266
  const requiresWatermark = config.watermark !== undefined;
3997
4267
  const supportReport = await this.dependencies.checkRecorderSupport({
3998
- requiresAudio: hasAudioTracks,
4268
+ requiresAudio: true,
3999
4269
  requiresWatermark
4000
4270
  });
4001
4271
  if (!supportReport.isSupported) {
@@ -4383,6 +4653,15 @@ class CameraStreamManager {
4383
4653
  getCurrentVideoSource() {
4384
4654
  return this.recordingState.getCurrentVideoSource();
4385
4655
  }
4656
+ getAudioStatus() {
4657
+ return this.streamManager.getAudioStatus();
4658
+ }
4659
+ isAudioReady() {
4660
+ return this.streamManager.isAudioReady();
4661
+ }
4662
+ async waitForAudio() {
4663
+ return await this.streamManager.waitForAudio();
4664
+ }
4386
4665
  destroy() {
4387
4666
  this.recordingState.destroy();
4388
4667
  this.streamManager.destroy();
@@ -4391,7 +4670,7 @@ class CameraStreamManager {
4391
4670
  // package.json
4392
4671
  var package_default = {
4393
4672
  name: "@vidtreo/recorder",
4394
- version: "1.4.0",
4673
+ version: "1.4.1",
4395
4674
  type: "module",
4396
4675
  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.",
4397
4676
  main: "./dist/index.js",
@@ -4418,6 +4697,7 @@ var package_default = {
4418
4697
  test: "bun test --concurrent",
4419
4698
  "test:watch": "bun test --watch --concurrent",
4420
4699
  "test:coverage": "bun test --coverage --concurrent",
4700
+ "test:bench:recording-start": 'RUN_RECORDING_BENCHMARKS=1 bun test --concurrent "tests/core/recording/recording-start.micro-benchmark.test.ts" "tests/core/recording/recording-start.realistic-benchmark.test.ts"',
4421
4701
  "test:isolation": "bun test --bail",
4422
4702
  "test:random": "bun test --bail --rerun-each 2"
4423
4703
  },
@@ -4473,7 +4753,6 @@ var BATCH_FLUSH_INTERVAL_MS = 1000;
4473
4753
  var THROTTLE_WINDOW_MS = 5000;
4474
4754
  var MAX_RETRY_ATTEMPTS = 3;
4475
4755
  var MAX_PENDING_EVENTS = 100;
4476
- var ONE_TIME_EVENT_CACHE = new Map;
4477
4756
  function resolveInstallationId(dependencies) {
4478
4757
  const storageProvider = dependencies.storageProvider;
4479
4758
  const stored = storageProvider?.getItem(TELEMETRY_STORAGE_KEY);
@@ -4526,7 +4805,11 @@ var TELEMETRY_EVENT_CATEGORY_MAP = {
4526
4805
  "source.switch.requested": "interaction",
4527
4806
  "source.switch.succeeded": "interaction",
4528
4807
  "source.switch.failed": "error",
4529
- "stream.error": "error"
4808
+ "stream.error": "error",
4809
+ "audio.acquisition.fallback": "lifecycle",
4810
+ "audio.acquisition.retry": "lifecycle",
4811
+ "audio.acquisition.recovered": "lifecycle",
4812
+ "audio.acquisition.failed": "error"
4530
4813
  };
4531
4814
 
4532
4815
  class TelemetryClient {
@@ -4537,6 +4820,7 @@ class TelemetryClient {
4537
4820
  flushTimeoutId = null;
4538
4821
  throttledEventTimestamps = new Map;
4539
4822
  retryCountMap = new Map;
4823
+ oneTimeEventCache = new Map;
4540
4824
  constructor(config, dependencies) {
4541
4825
  this.config = config;
4542
4826
  this.dependencies = dependencies;
@@ -4639,7 +4923,7 @@ class TelemetryClient {
4639
4923
  shouldSkipEvent(name, timestamp) {
4640
4924
  if (this.isOneTimeEvent(name)) {
4641
4925
  const cacheKey = this.getOneTimeCacheKey(name);
4642
- const wasSent = ONE_TIME_EVENT_CACHE.get(cacheKey);
4926
+ const wasSent = this.oneTimeEventCache.get(cacheKey);
4643
4927
  if (wasSent) {
4644
4928
  logger.debug("Telemetry event skipped (dedupe)", {
4645
4929
  event: name
@@ -4662,7 +4946,7 @@ class TelemetryClient {
4662
4946
  markEventTracking(name, timestamp) {
4663
4947
  if (this.isOneTimeEvent(name)) {
4664
4948
  const cacheKey = this.getOneTimeCacheKey(name);
4665
- ONE_TIME_EVENT_CACHE.set(cacheKey, true);
4949
+ this.oneTimeEventCache.set(cacheKey, true);
4666
4950
  }
4667
4951
  if (this.isThrottledEvent(name)) {
4668
4952
  this.throttledEventTimestamps = this.updateNumberMap(this.throttledEventTimestamps, name, timestamp);
@@ -5003,16 +5287,16 @@ class UploadQueueManager {
5003
5287
  const retryableUploads = failedUploads.filter((upload) => upload.retryCount < MAX_RETRIES2);
5004
5288
  if (retryableUploads.length > 0) {
5005
5289
  const upload = this.getOldestFailedUpload(retryableUploads);
5006
- const delay = this.calculateRetryDelay(upload.retryCount);
5290
+ const delay2 = this.calculateRetryDelay(upload.retryCount);
5007
5291
  const timeSinceLastAttempt = Date.now() - upload.updatedAt;
5008
- if (timeSinceLastAttempt >= delay) {
5292
+ if (timeSinceLastAttempt >= delay2) {
5009
5293
  await this.storageService.updateUploadStatus(upload.id, {
5010
5294
  status: "pending",
5011
5295
  retryCount: upload.retryCount
5012
5296
  });
5013
5297
  await this.processUpload(upload);
5014
5298
  } else {
5015
- const remainingDelay = delay - timeSinceLastAttempt;
5299
+ const remainingDelay = delay2 - timeSinceLastAttempt;
5016
5300
  this.scheduleRetry(remainingDelay);
5017
5301
  }
5018
5302
  }
@@ -5084,16 +5368,16 @@ class UploadQueueManager {
5084
5368
  if (retryCount >= MAX_RETRIES2) {
5085
5369
  this.callbacks.onUploadError?.(upload.id, new Error(`Upload failed after ${MAX_RETRIES2} attempts: ${errorMessage}`));
5086
5370
  } else {
5087
- const delay = this.calculateRetryDelay(retryCount);
5088
- this.scheduleRetry(delay);
5371
+ const delay2 = this.calculateRetryDelay(retryCount);
5372
+ this.scheduleRetry(delay2);
5089
5373
  }
5090
5374
  }
5091
5375
  }
5092
5376
  calculateRetryDelay(retryCount) {
5093
- const delay = INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** (retryCount - 1);
5094
- return Math.min(delay, MAX_RETRY_DELAY);
5377
+ const delay2 = INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** (retryCount - 1);
5378
+ return Math.min(delay2, MAX_RETRY_DELAY);
5095
5379
  }
5096
- scheduleRetry(delay) {
5380
+ scheduleRetry(delay2) {
5097
5381
  this.clearTimer(this.retryTimeoutId, clearTimeout);
5098
5382
  this.retryTimeoutId = window.setTimeout(() => {
5099
5383
  this.retryTimeoutId = null;
@@ -5101,7 +5385,7 @@ class UploadQueueManager {
5101
5385
  const errorMessage = extractErrorMessage(error);
5102
5386
  this.callbacks.onUploadError?.("scheduled-retry", new Error(errorMessage));
5103
5387
  });
5104
- }, delay);
5388
+ }, delay2);
5105
5389
  }
5106
5390
  clearTimer(timerId, clearFn) {
5107
5391
  if (timerId !== null) {
@@ -14475,20 +14759,31 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14475
14759
  if (!compositionPlan.needsComposition) {
14476
14760
  return { frameToProcess: parameters.videoFrame, imageBitmap: null };
14477
14761
  }
14478
- const dimensions = this.getValidFrameDimensions(parameters.videoFrame, compositionPlan.rotationDegrees);
14479
- if (!dimensions) {
14762
+ const outputDimensions = this.getOutputDimensions(parameters.videoFrame, compositionPlan.rotationDegrees, parameters.config);
14763
+ if (!outputDimensions) {
14480
14764
  return { frameToProcess: parameters.videoFrame, imageBitmap: null };
14481
14765
  }
14482
- const width = dimensions.width;
14483
- const height = dimensions.height;
14766
+ const width = outputDimensions.width;
14767
+ const height = outputDimensions.height;
14768
+ let fit = null;
14769
+ const sourceDimensions = this.getFrameDimensions(parameters.videoFrame, compositionPlan.rotationDegrees);
14770
+ const dimensionsMismatch = sourceDimensions.width !== width || sourceDimensions.height !== height;
14771
+ if (compositionPlan.needsResizing || dimensionsMismatch) {
14772
+ fit = this.calculateContainFit(sourceDimensions.width, sourceDimensions.height, width, height);
14773
+ }
14484
14774
  const context = this.ensureCompositionCanvas(width, height);
14485
14775
  context.clearRect(0, 0, width, height);
14776
+ if (fit && (fit.drawX > 0 || fit.drawY > 0)) {
14777
+ context.fillStyle = "#000000";
14778
+ context.fillRect(0, 0, width, height);
14779
+ }
14486
14780
  this.drawVideoFrame({
14487
14781
  context,
14488
14782
  videoFrame: parameters.videoFrame,
14489
14783
  rotationDegrees: compositionPlan.rotationDegrees,
14490
14784
  width,
14491
- height
14785
+ height,
14786
+ fit
14492
14787
  });
14493
14788
  this.applyOverlayIfNeeded(context, width, compositionPlan.shouldApplyOverlay, parameters.overlayConfig);
14494
14789
  this.applyWatermarkIfNeeded({
@@ -14507,6 +14802,7 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14507
14802
  if (parameters.config.watermark && this.watermarkCanvas) {
14508
14803
  needsWatermark = true;
14509
14804
  }
14805
+ const needsResizing = this.detectResizingNeed(parameters.videoFrame, rotationDegrees, parameters.config);
14510
14806
  let needsComposition = false;
14511
14807
  if (parameters.shouldApplyOverlay) {
14512
14808
  needsComposition = true;
@@ -14517,30 +14813,17 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14517
14813
  if (shouldRotateFrame) {
14518
14814
  needsComposition = true;
14519
14815
  }
14816
+ if (needsResizing) {
14817
+ needsComposition = true;
14818
+ }
14520
14819
  return {
14521
14820
  rotationDegrees,
14522
14821
  shouldApplyOverlay: parameters.shouldApplyOverlay,
14523
14822
  needsWatermark,
14823
+ needsResizing,
14524
14824
  needsComposition
14525
14825
  };
14526
14826
  }
14527
- getValidFrameDimensions(videoFrame, rotationDegrees) {
14528
- const dimensions = this.getFrameDimensions(videoFrame, rotationDegrees);
14529
- const width = dimensions.width;
14530
- const height = dimensions.height;
14531
- let hasInvalidDimensions = false;
14532
- if (width <= 0) {
14533
- hasInvalidDimensions = true;
14534
- }
14535
- if (height <= 0) {
14536
- hasInvalidDimensions = true;
14537
- }
14538
- if (hasInvalidDimensions) {
14539
- this.logger.warn(\`\${RECORDER_WORKER_LOG_PREFIX} Invalid video frame dimensions, skipping composition\`, { width, height });
14540
- return null;
14541
- }
14542
- return { width, height };
14543
- }
14544
14827
  applyOverlayIfNeeded(context, videoWidth, shouldApplyOverlay, overlayConfig) {
14545
14828
  if (!(shouldApplyOverlay && overlayConfig)) {
14546
14829
  return;
@@ -14724,25 +15007,60 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14724
15007
  return { width, height };
14725
15008
  }
14726
15009
  drawVideoFrame(parameters) {
14727
- const { context, videoFrame, rotationDegrees, width, height } = parameters;
15010
+ const { context, videoFrame, rotationDegrees, width, height, fit } = parameters;
14728
15011
  const sourceWidth = videoFrame.displayWidth;
14729
15012
  const sourceHeight = videoFrame.displayHeight;
15013
+ if (sourceWidth <= 0 || sourceHeight <= 0) {
15014
+ return;
15015
+ }
14730
15016
  context.setTransform(1, 0, 0, 1, 0, 0);
14731
- if (rotationDegrees === ROTATION_DEGREES_90) {
14732
- context.translate(width, 0);
14733
- context.rotate(ROTATION_RADIANS_90);
15017
+ if (!fit) {
15018
+ if (rotationDegrees === ROTATION_DEGREES_90) {
15019
+ context.translate(width, 0);
15020
+ context.rotate(ROTATION_RADIANS_90);
15021
+ context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
15022
+ context.setTransform(1, 0, 0, 1, 0, 0);
15023
+ return;
15024
+ }
15025
+ if (rotationDegrees === ROTATION_DEGREES_270) {
15026
+ context.translate(0, height);
15027
+ context.rotate(ROTATION_RADIANS_270);
15028
+ context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
15029
+ context.setTransform(1, 0, 0, 1, 0, 0);
15030
+ return;
15031
+ }
14734
15032
  context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
14735
- context.setTransform(1, 0, 0, 1, 0, 0);
14736
15033
  return;
14737
15034
  }
14738
- if (rotationDegrees === ROTATION_DEGREES_270) {
14739
- context.translate(0, height);
14740
- context.rotate(ROTATION_RADIANS_270);
14741
- context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
14742
- context.setTransform(1, 0, 0, 1, 0, 0);
15035
+ if (rotationDegrees === ROTATION_DEGREES_0) {
15036
+ context.drawImage(videoFrame, fit.drawX, fit.drawY, fit.drawWidth, fit.drawHeight);
14743
15037
  return;
14744
15038
  }
14745
- context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
15039
+ const centerX = fit.drawX + fit.drawWidth / DOUBLE_VALUE;
15040
+ const centerY = fit.drawY + fit.drawHeight / DOUBLE_VALUE;
15041
+ context.translate(centerX, centerY);
15042
+ if (rotationDegrees === ROTATION_DEGREES_90) {
15043
+ context.rotate(ROTATION_RADIANS_90);
15044
+ } else if (rotationDegrees === ROTATION_DEGREES_270) {
15045
+ context.rotate(ROTATION_RADIANS_270);
15046
+ } else {
15047
+ context.rotate(Math.PI * rotationDegrees / ROTATION_DEGREES_180);
15048
+ }
15049
+ const isSwappedRotation = rotationDegrees === ROTATION_DEGREES_90 || rotationDegrees === ROTATION_DEGREES_270;
15050
+ const scaleX = isSwappedRotation ? fit.drawWidth / sourceHeight : fit.drawWidth / sourceWidth;
15051
+ const scaleY = isSwappedRotation ? fit.drawHeight / sourceWidth : fit.drawHeight / sourceHeight;
15052
+ context.scale(scaleX, scaleY);
15053
+ context.drawImage(videoFrame, -sourceWidth / DOUBLE_VALUE, -sourceHeight / DOUBLE_VALUE, sourceWidth, sourceHeight);
15054
+ context.setTransform(1, 0, 0, 1, 0, 0);
15055
+ }
15056
+ detectResizingNeed(videoFrame, rotationDegrees, config) {
15057
+ return detectResizingNeed(videoFrame, rotationDegrees, config);
15058
+ }
15059
+ getOutputDimensions(videoFrame, rotationDegrees, config) {
15060
+ return getOutputDimensions(videoFrame, rotationDegrees, config);
15061
+ }
15062
+ calculateContainFit(sourceWidth, sourceHeight, targetWidth, targetHeight) {
15063
+ return calculateContainFit(sourceWidth, sourceHeight, targetWidth, targetHeight);
14746
15064
  }
14747
15065
  logWatermarkError(url2, error) {
14748
15066
  const errorMessage = extractErrorMessage(error);
@@ -14752,6 +15070,78 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14752
15070
  });
14753
15071
  }
14754
15072
  }
15073
+ function calculateContainFit(sourceWidth, sourceHeight, targetWidth, targetHeight) {
15074
+ if (sourceWidth <= 0 || sourceHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
15075
+ return {
15076
+ drawX: 0,
15077
+ drawY: 0,
15078
+ drawWidth: targetWidth,
15079
+ drawHeight: targetHeight
15080
+ };
15081
+ }
15082
+ const sourceAspect = sourceWidth / sourceHeight;
15083
+ const targetAspect = targetWidth / targetHeight;
15084
+ let drawWidth;
15085
+ let drawHeight;
15086
+ if (sourceAspect > targetAspect) {
15087
+ drawWidth = targetWidth;
15088
+ drawHeight = targetWidth / sourceAspect;
15089
+ } else {
15090
+ drawHeight = targetHeight;
15091
+ drawWidth = targetHeight * sourceAspect;
15092
+ }
15093
+ const roundedDrawWidth = Math.round(drawWidth);
15094
+ const roundedDrawHeight = Math.round(drawHeight);
15095
+ const drawX = (targetWidth - roundedDrawWidth) / 2;
15096
+ const drawY = (targetHeight - roundedDrawHeight) / 2;
15097
+ return {
15098
+ drawX: Math.round(drawX),
15099
+ drawY: Math.round(drawY),
15100
+ drawWidth: roundedDrawWidth,
15101
+ drawHeight: roundedDrawHeight
15102
+ };
15103
+ }
15104
+ function detectResizingNeed(videoFrame, rotationDegrees, config) {
15105
+ if (typeof config.width !== "number" || typeof config.height !== "number") {
15106
+ return false;
15107
+ }
15108
+ if (config.width <= 0 || config.height <= 0) {
15109
+ return false;
15110
+ }
15111
+ let frameWidth = videoFrame.displayWidth;
15112
+ let frameHeight = videoFrame.displayHeight;
15113
+ if (rotationDegrees === 90 || rotationDegrees === 270) {
15114
+ frameWidth = videoFrame.displayHeight;
15115
+ frameHeight = videoFrame.displayWidth;
15116
+ }
15117
+ if (frameWidth === config.width && frameHeight === config.height) {
15118
+ return false;
15119
+ }
15120
+ const sourceAspect = frameWidth / frameHeight;
15121
+ const targetAspect = config.width / config.height;
15122
+ const aspectRatioTolerance = 0.02;
15123
+ if (Math.abs(sourceAspect - targetAspect) > aspectRatioTolerance) {
15124
+ return true;
15125
+ }
15126
+ const sourcePixels = frameWidth * frameHeight;
15127
+ const targetPixels = config.width * config.height;
15128
+ return sourcePixels > targetPixels;
15129
+ }
15130
+ function getOutputDimensions(videoFrame, rotationDegrees, config) {
15131
+ if (typeof config.width === "number" && config.width > 0 && typeof config.height === "number" && config.height > 0) {
15132
+ return { width: config.width, height: config.height };
15133
+ }
15134
+ let width = videoFrame.displayWidth;
15135
+ let height = videoFrame.displayHeight;
15136
+ if (rotationDegrees === 90 || rotationDegrees === 270) {
15137
+ width = videoFrame.displayHeight;
15138
+ height = videoFrame.displayWidth;
15139
+ }
15140
+ if (width <= 0 || height <= 0) {
15141
+ return null;
15142
+ }
15143
+ return { width, height };
15144
+ }
14755
15145
 
14756
15146
  // src/core/processor/worker/recording-integrity.ts
14757
15147
  var EXCESSIVE_FRAME_ERROR_RATIO_THRESHOLD = 0.5;
@@ -14830,7 +15220,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14830
15220
 
14831
15221
  // src/core/processor/worker/timestamp-manager.ts
14832
15222
  var DEFAULT_FRAME_RATE = 30;
14833
- var DEFAULT_KEY_FRAME_INTERVAL_SECONDS = 5;
14834
15223
  var MILLISECONDS_PER_SECOND2 = 1000;
14835
15224
  var MICROSECONDS_PER_SECOND = 1e6;
14836
15225
  var MAX_LEAD_SECONDS = 0.05;
@@ -14845,7 +15234,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14845
15234
  lastVideoTimestamp = 0;
14846
15235
  baseVideoTimestamp = null;
14847
15236
  frameCount = 0;
14848
- lastKeyFrameTimestamp = 0;
14849
15237
  forceNextKeyFrame = false;
14850
15238
  driftOffset = 0;
14851
15239
  logger;
@@ -14863,7 +15251,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14863
15251
  this.lastVideoTimestamp = 0;
14864
15252
  this.baseVideoTimestamp = null;
14865
15253
  this.frameCount = 0;
14866
- this.lastKeyFrameTimestamp = 0;
14867
15254
  this.forceNextKeyFrame = false;
14868
15255
  this.driftOffset = 0;
14869
15256
  }
@@ -14952,36 +15339,25 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14952
15339
  prepareFrameTiming(parameters) {
14953
15340
  const frameDuration = 1 / this.frameRate;
14954
15341
  let adjustedTimestamp = parameters.frameTimestamp + this.driftOffset;
14955
- if (adjustedTimestamp - parameters.lastAudioTimestamp > MAX_LEAD_SECONDS) {
14956
- adjustedTimestamp = parameters.lastAudioTimestamp + MAX_LEAD_SECONDS;
14957
- }
14958
- if (parameters.lastAudioTimestamp - adjustedTimestamp > MAX_LAG_SECONDS) {
14959
- adjustedTimestamp = parameters.lastAudioTimestamp - MAX_LAG_SECONDS;
14960
- }
14961
15342
  const monotonicTimestamp = this.lastVideoTimestamp + frameDuration;
15343
+ if (adjustedTimestamp < monotonicTimestamp) {
15344
+ adjustedTimestamp = monotonicTimestamp;
15345
+ }
14962
15346
  let finalTimestamp = adjustedTimestamp;
14963
- if (finalTimestamp < monotonicTimestamp) {
14964
- finalTimestamp = monotonicTimestamp;
15347
+ if (finalTimestamp - parameters.lastAudioTimestamp > MAX_LEAD_SECONDS) {
15348
+ finalTimestamp = parameters.lastAudioTimestamp + MAX_LEAD_SECONDS;
14965
15349
  }
14966
- let keyFrameIntervalSeconds = parameters.keyFrameIntervalSeconds;
14967
- if (!(keyFrameIntervalSeconds > 0)) {
14968
- keyFrameIntervalSeconds = DEFAULT_KEY_FRAME_INTERVAL_SECONDS;
15350
+ if (parameters.lastAudioTimestamp - finalTimestamp > MAX_LAG_SECONDS) {
15351
+ finalTimestamp = parameters.lastAudioTimestamp - MAX_LAG_SECONDS;
14969
15352
  }
14970
- let keyFrameIntervalFrames = Math.round(keyFrameIntervalSeconds * this.frameRate);
14971
- if (keyFrameIntervalFrames < 1) {
14972
- keyFrameIntervalFrames = 1;
15353
+ const minimumTimestamp = this.lastVideoTimestamp + frameDuration;
15354
+ if (finalTimestamp < minimumTimestamp) {
15355
+ finalTimestamp = minimumTimestamp;
14973
15356
  }
14974
- const timeSinceLastKeyFrame = finalTimestamp - this.lastKeyFrameTimestamp;
14975
15357
  let isKeyFrame = false;
14976
15358
  if (this.forceNextKeyFrame) {
14977
15359
  isKeyFrame = true;
14978
15360
  }
14979
- if (timeSinceLastKeyFrame >= keyFrameIntervalSeconds) {
14980
- isKeyFrame = true;
14981
- }
14982
- if (this.frameCount % keyFrameIntervalFrames === 0) {
14983
- isKeyFrame = true;
14984
- }
14985
15361
  this.driftOffset *= DRIFT_OFFSET_DECAY_FACTOR;
14986
15362
  return {
14987
15363
  finalTimestamp,
@@ -14993,7 +15369,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
14993
15369
  this.frameCount += 1;
14994
15370
  this.lastVideoTimestamp = parameters.finalTimestamp;
14995
15371
  if (parameters.isKeyFrame) {
14996
- this.lastKeyFrameTimestamp = parameters.finalTimestamp;
14997
15372
  this.forceNextKeyFrame = false;
14998
15373
  }
14999
15374
  let shouldLogDrift = false;
@@ -15036,6 +15411,7 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15036
15411
 
15037
15412
  // src/core/processor/worker/types.ts
15038
15413
  var WORKER_MESSAGE_TYPE_PROBE = "probe";
15414
+ var WORKER_MESSAGE_TYPE_WARMUP = "warmup";
15039
15415
  var WORKER_MESSAGE_TYPE_AUDIO_CHUNK = "audioChunk";
15040
15416
  var WORKER_RESPONSE_TYPE_PROBE_RESULT = "probeResult";
15041
15417
  var WORKER_AUDIO_SAMPLE_FORMAT_F32_PLANAR = "f32-planar";
@@ -15220,6 +15596,7 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15220
15596
  expectedAudioSampleRate = null;
15221
15597
  pendingWriteCount = 0;
15222
15598
  resolvedHardwareAcceleration = VIDEO_HARDWARE_ACCELERATION_PREFERENCE;
15599
+ hwAccelCacheKey = null;
15223
15600
  consecutiveFrameErrors = 0;
15224
15601
  videoProcessingRunId = 0;
15225
15602
  totalFrameErrors = 0;
@@ -15302,6 +15679,13 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15302
15679
  case WORKER_MESSAGE_TYPE_PROBE:
15303
15680
  this.handleProbe();
15304
15681
  return;
15682
+ case WORKER_MESSAGE_TYPE_WARMUP:
15683
+ this.resolveHardwareAcceleration(message.config).then((result) => {
15684
+ this.resolvedHardwareAcceleration = result;
15685
+ }).catch((error) => {
15686
+ logger.warn("[RecorderWorker] Warmup hardware acceleration probe failed", { error: extractErrorMessage(error) });
15687
+ });
15688
+ return;
15305
15689
  case "start":
15306
15690
  this.handleStartMessage(message);
15307
15691
  return;
@@ -15461,7 +15845,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15461
15845
  }
15462
15846
  createVideoSource(config, hardwareAcceleration) {
15463
15847
  const fps = this.timestampManager.getFrameRate();
15464
- const keyFrameIntervalSeconds = config.keyFrameInterval;
15465
15848
  const videoSourceOptions = {
15466
15849
  codec: config.codec,
15467
15850
  width: config.width,
@@ -15469,10 +15852,10 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15469
15852
  sizeChangeBehavior: "contain",
15470
15853
  alpha: "discard",
15471
15854
  bitrateMode: "variable",
15472
- latencyMode: VIDEO_LATENCY_MODE_REALTIME,
15855
+ latencyMode: config.latencyMode || VIDEO_LATENCY_MODE_REALTIME,
15473
15856
  contentHint: VIDEO_CONTENT_HINT_MOTION,
15474
15857
  hardwareAcceleration,
15475
- keyFrameInterval: keyFrameIntervalSeconds,
15858
+ keyFrameInterval: config.keyFrameInterval,
15476
15859
  bitrate: this.deserializeBitrate(config.bitrate)
15477
15860
  };
15478
15861
  this.videoSource = new VideoSampleSource(videoSourceOptions);
@@ -15499,7 +15882,13 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15499
15882
  if (typeof config.bitrate === "number" && config.bitrate > 0) {
15500
15883
  bitrate = config.bitrate;
15501
15884
  }
15502
- return await resolveVideoHardwareAcceleration(config.codec, width, height, bitrate);
15885
+ const cacheKey = \`\${config.codec}:\${width}:\${height}:\${config.bitrate}\`;
15886
+ if (this.hwAccelCacheKey === cacheKey) {
15887
+ return this.resolvedHardwareAcceleration;
15888
+ }
15889
+ const result = await resolveVideoHardwareAcceleration(config.codec, width, height, bitrate);
15890
+ this.hwAccelCacheKey = cacheKey;
15891
+ return result;
15503
15892
  }
15504
15893
  setupAudioSource(audioConfig, config) {
15505
15894
  if (!audioConfig) {
@@ -15601,9 +15990,13 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15601
15990
  format = DEFAULT_OUTPUT_FORMAT;
15602
15991
  }
15603
15992
  this.validateFormat(format);
15604
- this.resolvedHardwareAcceleration = await this.resolveHardwareAcceleration(config);
15605
- this.sendEncoderAcceleration(this.resolvedHardwareAcceleration);
15993
+ const hwAccelPromise = this.resolveHardwareAcceleration(config);
15606
15994
  this.createOutput();
15995
+ if (this.config?.watermark) {
15996
+ this.frameCompositor.prepareWatermark(this.config);
15997
+ }
15998
+ this.resolvedHardwareAcceleration = await hwAccelPromise;
15999
+ this.sendEncoderAcceleration(this.resolvedHardwareAcceleration);
15607
16000
  this.createVideoSource(config, this.resolvedHardwareAcceleration);
15608
16001
  if (videoStream) {
15609
16002
  this.setupVideoProcessingFromStream(videoStream);
@@ -15617,9 +16010,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15617
16010
  this.setupAudioSource(audioConfig, config);
15618
16011
  }
15619
16012
  const output = requireNonNull(this.output, "Output must be initialized before starting");
15620
- if (this.config?.watermark) {
15621
- this.frameCompositor.prepareWatermark(this.config);
15622
- }
15623
16013
  await output.start();
15624
16014
  this.bufferTracker.start();
15625
16015
  this.sendReady();
@@ -15695,7 +16085,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
15695
16085
  const lastAudioTimestamp = this.audioState.getLastAudioTimestamp();
15696
16086
  const frameTiming = this.timestampManager.prepareFrameTiming({
15697
16087
  frameTimestamp,
15698
- keyFrameIntervalSeconds: config.keyFrameInterval,
15699
16088
  lastAudioTimestamp
15700
16089
  });
15701
16090
  const sample = new VideoSample(compositionResult.frameToProcess, {
@@ -16199,7 +16588,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
16199
16588
  this.expectedAudioChannels = null;
16200
16589
  this.expectedAudioSampleRate = null;
16201
16590
  this.pendingWriteCount = 0;
16202
- this.resolvedHardwareAcceleration = VIDEO_HARDWARE_ACCELERATION_PREFERENCE;
16203
16591
  this.consecutiveFrameErrors = 0;
16204
16592
  this.totalFrameErrors = 0;
16205
16593
  this.totalFramesProcessed = 0;
@@ -16423,6 +16811,7 @@ class WorkerProcessor {
16423
16811
  isPaused = false;
16424
16812
  overlayConfig = null;
16425
16813
  readyPromiseResolve = null;
16814
+ lastConfigFps = DEFAULT_SWITCH_SOURCE_FPS;
16426
16815
  workerProbeManager;
16427
16816
  canUseMainThreadVideoProcessorFn;
16428
16817
  createVideoStreamFromTrackFn;
@@ -16608,6 +16997,9 @@ class WorkerProcessor {
16608
16997
  bitrate: config.bitrate
16609
16998
  });
16610
16999
  const workerConfig = this.buildWorkerTranscodeConfig(config, audioCodec, audioBitrate, codec, format);
17000
+ if (typeof config.fps === "number" && config.fps > 0) {
17001
+ this.lastConfigFps = config.fps;
17002
+ }
16611
17003
  const videoTracks = stream.getVideoTracks();
16612
17004
  const audioTracks = stream.getAudioTracks();
16613
17005
  logger.debug("[WorkerProcessor] Preparing to start processing", {
@@ -16767,6 +17159,7 @@ class WorkerProcessor {
16767
17159
  audioBitrate,
16768
17160
  codec,
16769
17161
  keyFrameInterval: KEY_FRAME_INTERVAL_SECONDS,
17162
+ latencyMode: config.latencyMode,
16770
17163
  format,
16771
17164
  watermark: config.watermark
16772
17165
  };
@@ -16888,7 +17281,7 @@ class WorkerProcessor {
16888
17281
  return Promise.resolve();
16889
17282
  }
16890
17283
  const isScreenCapture = isScreenCaptureStream(newStream);
16891
- const targetFps = DEFAULT_SWITCH_SOURCE_FPS;
17284
+ const targetFps = this.lastConfigFps;
16892
17285
  logger.debug("[WorkerProcessor] Source type detected", {
16893
17286
  isScreenCapture,
16894
17287
  targetFps
@@ -17102,6 +17495,21 @@ class WorkerProcessor {
17102
17495
  this.totalSize = 0;
17103
17496
  return Promise.resolve();
17104
17497
  }
17498
+ warmupEncoder(config) {
17499
+ if (!this.worker) {
17500
+ return;
17501
+ }
17502
+ const format = config.format || "mp4";
17503
+ const policy = getFormatCompatibilityPolicy(format);
17504
+ const codec = config.codec || policy.preferredVideoCodec;
17505
+ const audioBitrate = config.audioBitrate !== undefined ? config.audioBitrate : policy.audioBitrate;
17506
+ const workerConfig = this.buildWorkerTranscodeConfig(config, policy.preferredAudioCodec, audioBitrate, codec, format);
17507
+ const message = {
17508
+ type: WORKER_MESSAGE_TYPE_WARMUP,
17509
+ config: workerConfig
17510
+ };
17511
+ this.worker.postMessage(message);
17512
+ }
17105
17513
  getBufferSize() {
17106
17514
  return this.totalSize;
17107
17515
  }
@@ -17414,6 +17822,9 @@ class StreamProcessor {
17414
17822
  setOnError(callback) {
17415
17823
  this.onError = callback;
17416
17824
  }
17825
+ warmupEncoder(config) {
17826
+ this.workerProcessor.warmupEncoder(config);
17827
+ }
17417
17828
  async cancel() {
17418
17829
  await this.workerProcessor.cancel();
17419
17830
  this.workerProcessor.cleanup();
@@ -17490,8 +17901,11 @@ class RecordingManager {
17490
17901
  getOriginalCameraStream() {
17491
17902
  return this.originalCameraStream;
17492
17903
  }
17493
- prewarmStreamProcessor() {
17494
- this.getOrCreateStreamProcessor();
17904
+ prewarmStreamProcessor(config) {
17905
+ const processor = this.getOrCreateStreamProcessor();
17906
+ if (config) {
17907
+ processor.warmupEncoder(config);
17908
+ }
17495
17909
  }
17496
17910
  async startRecording() {
17497
17911
  try {
@@ -17874,6 +18288,7 @@ class RecorderController {
17874
18288
  enableTabVisibilityOverlay = false;
17875
18289
  tabVisibilityOverlayText;
17876
18290
  recordingWarmupTimeoutId = null;
18291
+ audioTelemetryUnsub = null;
17877
18292
  constructor(callbacks = {}) {
17878
18293
  this.callbacks = callbacks;
17879
18294
  this.streamManager = new CameraStreamManager;
@@ -17925,6 +18340,14 @@ class RecorderController {
17925
18340
  }
17926
18341
  });
17927
18342
  }
18343
+ this.audioTelemetryUnsub = this.streamManager.on("audiotelemetry", ({ event }) => {
18344
+ const browserName = this.getBrowserNameForTelemetry();
18345
+ this.telemetryManager.sendEvent(event.name, {
18346
+ ...event.properties,
18347
+ browserName,
18348
+ sourceType: this.getCurrentSourceType()
18349
+ }, event.error);
18350
+ });
17928
18351
  }
17929
18352
  async initialize(config) {
17930
18353
  if (this.isInitialized) {
@@ -17967,7 +18390,9 @@ class RecorderController {
17967
18390
  logger.debug(`${LOGGER_PREFIX} startStream called`);
17968
18391
  await this.streamManager.startStream();
17969
18392
  this.ignorePromiseRejection(this.ensureConfigReady());
17970
- this.recordingManager.prewarmStreamProcessor();
18393
+ this.ignorePromiseRejection(this.configManager.getConfig().then((config) => {
18394
+ this.recordingManager.prewarmStreamProcessor(config);
18395
+ }));
17971
18396
  logger.debug(`${LOGGER_PREFIX} startStream completed`);
17972
18397
  },
17973
18398
  properties: {
@@ -17992,6 +18417,7 @@ class RecorderController {
17992
18417
  failedEvent: "recording.start.failed",
17993
18418
  action: async () => {
17994
18419
  await this.ensureConfigReady();
18420
+ await this.streamManager.waitForAudio();
17995
18421
  await this.recordingManager.startRecording();
17996
18422
  },
17997
18423
  properties: {
@@ -18138,6 +18564,10 @@ class RecorderController {
18138
18564
  clearTimeout(this.recordingWarmupTimeoutId);
18139
18565
  this.recordingWarmupTimeoutId = null;
18140
18566
  }
18567
+ if (this.audioTelemetryUnsub) {
18568
+ this.audioTelemetryUnsub();
18569
+ this.audioTelemetryUnsub = null;
18570
+ }
18141
18571
  this.uploadQueueManager?.destroy();
18142
18572
  this.storageManager.destroy();
18143
18573
  this.recordingManager.cleanup();
@@ -18177,6 +18607,19 @@ class RecorderController {
18177
18607
  isActive() {
18178
18608
  return this.streamManager.isActive();
18179
18609
  }
18610
+ isAudioReady() {
18611
+ return this.streamManager.isAudioReady();
18612
+ }
18613
+ getAudioStatus() {
18614
+ return this.streamManager.getAudioStatus();
18615
+ }
18616
+ getBrowserNameForTelemetry() {
18617
+ try {
18618
+ return getBrowserName();
18619
+ } catch {
18620
+ return "unknown";
18621
+ }
18622
+ }
18180
18623
  async initializeConfig(apiKey, backendUrl) {
18181
18624
  let shouldInitializeConfig = true;
18182
18625
  if (apiKey === null) {
@@ -18278,8 +18721,8 @@ class RecorderController {
18278
18721
  return;
18279
18722
  }
18280
18723
  this.ignorePromiseRejection(this.ensureConfigReady());
18281
- this.ignorePromiseRejection(Promise.resolve().then(() => {
18282
- this.recordingManager.prewarmStreamProcessor();
18724
+ this.ignorePromiseRejection(this.configManager.getConfig().then((config) => {
18725
+ this.recordingManager.prewarmStreamProcessor(config);
18283
18726
  }));
18284
18727
  }, RECORDING_WARMUP_DELAY_MILLISECONDS);
18285
18728
  }
@@ -18441,6 +18884,18 @@ function getCameraErrorText(errorCode, translations) {
18441
18884
  }
18442
18885
  return translations.failedToStartCamera;
18443
18886
  }
18887
+ function getAudioErrorText(errorCode, translations) {
18888
+ if (errorCode === "audio.in-use") {
18889
+ return translations.audioInUse;
18890
+ }
18891
+ if (errorCode === "audio.not-found") {
18892
+ return translations.audioNotFound;
18893
+ }
18894
+ if (errorCode === "audio.permission-denied") {
18895
+ return translations.audioPermissionDenied;
18896
+ }
18897
+ return translations.failedToStartAudio;
18898
+ }
18444
18899
  function formatDynamicBrowserUnsupportedText(template, browserName, browserVersion) {
18445
18900
  let resolvedBrowserName = FALLBACK_BROWSER_NAME;
18446
18901
  if (browserName && browserName.trim().length > 0) {
@@ -18494,12 +18949,13 @@ function parseBrowserErrorLinkContent(text) {
18494
18949
  // src/core/utils/device-detection.ts
18495
18950
  import { UAParser as UAParser3 } from "ua-parser-js";
18496
18951
  function isMobileDevice2() {
18497
- const parser = new UAParser3;
18952
+ const userAgent = globalThis.navigator && typeof globalThis.navigator.userAgent === "string" ? globalThis.navigator.userAgent : "";
18953
+ const parser = new UAParser3(userAgent);
18498
18954
  const result = parser.getResult();
18499
18955
  const deviceType = result.device.type;
18500
18956
  const isMobile = deviceType === "mobile" || deviceType === "tablet";
18501
18957
  logger.debug("Mobile detection result", {
18502
- userAgent: navigator.userAgent,
18958
+ userAgent,
18503
18959
  deviceType,
18504
18960
  isMobile,
18505
18961
  device: result.device,
@@ -18508,6 +18964,80 @@ function isMobileDevice2() {
18508
18964
  });
18509
18965
  return isMobile;
18510
18966
  }
18967
+ // src/core/utils/device-error-resolver.ts
18968
+ var ERROR_CODE_BROWSER_UNSUPPORTED2 = "browser.unsupported";
18969
+ var ERROR_CODE_CAMERA_PERMISSION_DENIED = "camera.permission-denied";
18970
+ var ERROR_CODE_AUDIO_PERMISSION_DENIED = "audio.permission-denied";
18971
+ var HIDDEN_RESULT = Object.freeze({
18972
+ visible: false,
18973
+ variant: "generic",
18974
+ canRetry: false,
18975
+ isCameraError: false,
18976
+ isAudioError: false,
18977
+ isBrowserUnsupported: false,
18978
+ isPermissionDenied: false
18979
+ });
18980
+ function resolveDeviceError(input) {
18981
+ const { errorCode, hasAudioFailed, error } = input;
18982
+ if (errorCode === ERROR_CODE_BROWSER_UNSUPPORTED2) {
18983
+ return {
18984
+ visible: true,
18985
+ variant: "browser",
18986
+ canRetry: false,
18987
+ isCameraError: false,
18988
+ isAudioError: false,
18989
+ isBrowserUnsupported: true,
18990
+ isPermissionDenied: false
18991
+ };
18992
+ }
18993
+ if (errorCode?.startsWith("camera.")) {
18994
+ const isPermissionDenied = errorCode === ERROR_CODE_CAMERA_PERMISSION_DENIED;
18995
+ return {
18996
+ visible: true,
18997
+ variant: "camera",
18998
+ canRetry: !isPermissionDenied,
18999
+ isCameraError: true,
19000
+ isAudioError: false,
19001
+ isBrowserUnsupported: false,
19002
+ isPermissionDenied
19003
+ };
19004
+ }
19005
+ if (hasAudioFailed) {
19006
+ return {
19007
+ visible: true,
19008
+ variant: "audio",
19009
+ canRetry: true,
19010
+ isCameraError: false,
19011
+ isAudioError: true,
19012
+ isBrowserUnsupported: false,
19013
+ isPermissionDenied: false
19014
+ };
19015
+ }
19016
+ if (errorCode?.startsWith("audio.")) {
19017
+ const isPermissionDenied = errorCode === ERROR_CODE_AUDIO_PERMISSION_DENIED;
19018
+ return {
19019
+ visible: true,
19020
+ variant: "audio",
19021
+ canRetry: !isPermissionDenied,
19022
+ isCameraError: false,
19023
+ isAudioError: true,
19024
+ isBrowserUnsupported: false,
19025
+ isPermissionDenied
19026
+ };
19027
+ }
19028
+ if (error) {
19029
+ return {
19030
+ visible: true,
19031
+ variant: "generic",
19032
+ canRetry: true,
19033
+ isCameraError: false,
19034
+ isAudioError: false,
19035
+ isBrowserUnsupported: false,
19036
+ isPermissionDenied: false
19037
+ };
19038
+ }
19039
+ return HIDDEN_RESULT;
19040
+ }
18511
19041
  // src/vidtreo-recorder.ts
18512
19042
  class VidtreoRecorder {
18513
19043
  controller;
@@ -18713,6 +19243,7 @@ export {
18713
19243
  validateBrowserSupport,
18714
19244
  transcodeVideoForNativeCamera,
18715
19245
  transcodeVideo,
19246
+ resolveDeviceError,
18716
19247
  requireStream,
18717
19248
  requireProcessor,
18718
19249
  requireNonNull,
@@ -18734,6 +19265,7 @@ export {
18734
19265
  getBrowserName,
18735
19266
  getBrowserInfo,
18736
19267
  getBrowserErrorText,
19268
+ getAudioErrorText,
18737
19269
  getAudioCodecForFormat,
18738
19270
  formatTime,
18739
19271
  formatFileSize,