@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.
- package/dist/index.d.ts +1864 -1696
- package/dist/index.js +655 -123
- 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
|
|
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:
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3448
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
5290
|
+
const delay2 = this.calculateRetryDelay(upload.retryCount);
|
|
5007
5291
|
const timeSinceLastAttempt = Date.now() - upload.updatedAt;
|
|
5008
|
-
if (timeSinceLastAttempt >=
|
|
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 =
|
|
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
|
|
5088
|
-
this.scheduleRetry(
|
|
5371
|
+
const delay2 = this.calculateRetryDelay(retryCount);
|
|
5372
|
+
this.scheduleRetry(delay2);
|
|
5089
5373
|
}
|
|
5090
5374
|
}
|
|
5091
5375
|
}
|
|
5092
5376
|
calculateRetryDelay(retryCount) {
|
|
5093
|
-
const
|
|
5094
|
-
return Math.min(
|
|
5377
|
+
const delay2 = INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** (retryCount - 1);
|
|
5378
|
+
return Math.min(delay2, MAX_RETRY_DELAY);
|
|
5095
5379
|
}
|
|
5096
|
-
scheduleRetry(
|
|
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
|
-
},
|
|
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
|
|
14479
|
-
if (!
|
|
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 =
|
|
14483
|
-
const 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 (
|
|
14732
|
-
|
|
14733
|
-
|
|
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 ===
|
|
14739
|
-
context.
|
|
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
|
-
|
|
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
|
|
14964
|
-
finalTimestamp =
|
|
15347
|
+
if (finalTimestamp - parameters.lastAudioTimestamp > MAX_LEAD_SECONDS) {
|
|
15348
|
+
finalTimestamp = parameters.lastAudioTimestamp + MAX_LEAD_SECONDS;
|
|
14965
15349
|
}
|
|
14966
|
-
|
|
14967
|
-
|
|
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
|
-
|
|
14971
|
-
if (
|
|
14972
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
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,
|