@vidtreo/recorder 1.3.4 → 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 +2025 -1713
- package/dist/index.js +1143 -127
- 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 = {
|
|
@@ -704,6 +716,488 @@ class DeviceManager {
|
|
|
704
716
|
return this.availableDevices;
|
|
705
717
|
}
|
|
706
718
|
}
|
|
719
|
+
// src/core/device/permission-checker.ts
|
|
720
|
+
var noOperation = () => {
|
|
721
|
+
return;
|
|
722
|
+
};
|
|
723
|
+
function isPermissionsApiAvailable() {
|
|
724
|
+
if (typeof navigator === "undefined") {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
if (!navigator.permissions) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
return typeof navigator.permissions.query === "function";
|
|
731
|
+
}
|
|
732
|
+
function queryPermissionState(permissionName) {
|
|
733
|
+
if (!isPermissionsApiAvailable()) {
|
|
734
|
+
return Promise.resolve("unknown");
|
|
735
|
+
}
|
|
736
|
+
return navigator.permissions.query({ name: permissionName }).then((status) => status.state).catch(() => "unknown");
|
|
737
|
+
}
|
|
738
|
+
async function queryAllPermissions() {
|
|
739
|
+
const [camera, microphone] = await Promise.all([
|
|
740
|
+
queryPermissionState("camera"),
|
|
741
|
+
queryPermissionState("microphone")
|
|
742
|
+
]);
|
|
743
|
+
return { camera, microphone };
|
|
744
|
+
}
|
|
745
|
+
function createPermissionChangeListener(permissionName, onStateChange) {
|
|
746
|
+
if (!isPermissionsApiAvailable()) {
|
|
747
|
+
return noOperation;
|
|
748
|
+
}
|
|
749
|
+
let permissionStatus = null;
|
|
750
|
+
let isDestroyed = false;
|
|
751
|
+
const handleChange = () => {
|
|
752
|
+
if (!permissionStatus) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
onStateChange(permissionStatus.state);
|
|
756
|
+
};
|
|
757
|
+
navigator.permissions.query({ name: permissionName }).then((status) => {
|
|
758
|
+
if (isDestroyed) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
permissionStatus = status;
|
|
762
|
+
permissionStatus.addEventListener("change", handleChange);
|
|
763
|
+
}).catch(noOperation);
|
|
764
|
+
return () => {
|
|
765
|
+
isDestroyed = true;
|
|
766
|
+
if (permissionStatus) {
|
|
767
|
+
permissionStatus.removeEventListener("change", handleChange);
|
|
768
|
+
permissionStatus = null;
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/core/device/permission-recovery.ts
|
|
774
|
+
var CHROME_INSTRUCTIONS = {
|
|
775
|
+
en: "Click the lock/tune icon in the address bar → Site settings → Camera/Microphone → Allow",
|
|
776
|
+
es: "Haz clic en el icono de candado/ajustes en la barra de direcciones → Configuración del sitio → Cámara/Micrófono → Permitir",
|
|
777
|
+
resetUrl: "chrome://settings/content/camera"
|
|
778
|
+
};
|
|
779
|
+
var SAFARI_INSTRUCTIONS = {
|
|
780
|
+
en: "Go to Safari → Settings → Websites → Camera/Microphone → Allow for this site",
|
|
781
|
+
es: "Ve a Safari → Ajustes → Sitios web → Cámara/Micrófono → Permitir para este sitio"
|
|
782
|
+
};
|
|
783
|
+
var EDGE_INSTRUCTIONS = {
|
|
784
|
+
en: "Click the lock icon in the address bar → Permissions → Camera/Microphone → Allow",
|
|
785
|
+
es: "Haz clic en el icono de candado en la barra de direcciones → Permisos → Cámara/Micrófono → Permitir",
|
|
786
|
+
resetUrl: "edge://settings/content/camera"
|
|
787
|
+
};
|
|
788
|
+
var OPERA_INSTRUCTIONS = {
|
|
789
|
+
en: "Click the lock icon → Site settings → Camera/Microphone → Allow",
|
|
790
|
+
es: "Haz clic en el icono de candado → Configuración del sitio → Cámara/Micrófono → Permitir",
|
|
791
|
+
resetUrl: "opera://settings/content/camera"
|
|
792
|
+
};
|
|
793
|
+
var UNKNOWN_BROWSER_INSTRUCTIONS = {
|
|
794
|
+
en: "Please check your browser settings to allow camera and microphone access for this site",
|
|
795
|
+
es: "Por favor, revisa la configuración de tu navegador para permitir el acceso a la cámara y el micrófono en este sitio"
|
|
796
|
+
};
|
|
797
|
+
var INSECURE_CONTEXT_INSTRUCTIONS = {
|
|
798
|
+
en: "This site must be accessed over HTTPS (secure connection) to use camera and microphone",
|
|
799
|
+
es: "Este sitio debe ser accedido a través de HTTPS (conexión segura) para usar cámara y micrófono"
|
|
800
|
+
};
|
|
801
|
+
var BROWSER_INSTRUCTIONS_MAP = {
|
|
802
|
+
chrome: CHROME_INSTRUCTIONS,
|
|
803
|
+
safari: SAFARI_INSTRUCTIONS,
|
|
804
|
+
edge: EDGE_INSTRUCTIONS,
|
|
805
|
+
opera: OPERA_INSTRUCTIONS,
|
|
806
|
+
unknown: UNKNOWN_BROWSER_INSTRUCTIONS
|
|
807
|
+
};
|
|
808
|
+
var RETRYABLE_ERROR_CODES = new Set([
|
|
809
|
+
"permission-denied-camera",
|
|
810
|
+
"permission-denied-microphone",
|
|
811
|
+
"permission-denied-both",
|
|
812
|
+
"device-not-found",
|
|
813
|
+
"device-in-use"
|
|
814
|
+
]);
|
|
815
|
+
function resolveBrowserKey(browserName) {
|
|
816
|
+
const normalizedName = browserName.toLowerCase();
|
|
817
|
+
if (normalizedName.includes("edge")) {
|
|
818
|
+
return "edge";
|
|
819
|
+
}
|
|
820
|
+
if (normalizedName.includes("opera") || normalizedName.includes("opr")) {
|
|
821
|
+
return "opera";
|
|
822
|
+
}
|
|
823
|
+
if (normalizedName.includes("chrome")) {
|
|
824
|
+
return "chrome";
|
|
825
|
+
}
|
|
826
|
+
if (normalizedName.includes("safari")) {
|
|
827
|
+
return "safari";
|
|
828
|
+
}
|
|
829
|
+
return "unknown";
|
|
830
|
+
}
|
|
831
|
+
function createPermissionRecoveryData(errorCode, browserInfo, language = "en") {
|
|
832
|
+
if (errorCode === "insecure-context") {
|
|
833
|
+
return {
|
|
834
|
+
errorCode,
|
|
835
|
+
browserName: browserInfo.name,
|
|
836
|
+
browserVersion: browserInfo.version,
|
|
837
|
+
resetInstructions: INSECURE_CONTEXT_INSTRUCTIONS[language],
|
|
838
|
+
resetUrl: undefined,
|
|
839
|
+
canRetry: false
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
const browserKey = resolveBrowserKey(browserInfo.name);
|
|
843
|
+
const instructions = BROWSER_INSTRUCTIONS_MAP[browserKey];
|
|
844
|
+
const canRetry = RETRYABLE_ERROR_CODES.has(errorCode);
|
|
845
|
+
return {
|
|
846
|
+
errorCode,
|
|
847
|
+
browserName: browserInfo.name,
|
|
848
|
+
browserVersion: browserInfo.version,
|
|
849
|
+
resetInstructions: instructions[language],
|
|
850
|
+
resetUrl: instructions.resetUrl,
|
|
851
|
+
canRetry
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/core/device/permission-flow-orchestrator.ts
|
|
856
|
+
var GRANTED_PERMISSION_COUNT = 2;
|
|
857
|
+
var DEFAULT_PERMISSION_STATUS = {
|
|
858
|
+
camera: "unknown",
|
|
859
|
+
microphone: "unknown"
|
|
860
|
+
};
|
|
861
|
+
function noOperation2() {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
function getIsSecureContext() {
|
|
865
|
+
const globalWindow = globalThis.window;
|
|
866
|
+
if (!globalWindow) {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
return globalWindow.isSecureContext;
|
|
870
|
+
}
|
|
871
|
+
function stopStreamTracks(stream) {
|
|
872
|
+
const tracks = stream.getTracks();
|
|
873
|
+
for (const track of tracks) {
|
|
874
|
+
track.stop();
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async function requestPermissionProbe() {
|
|
878
|
+
const mediaDevices = globalThis.navigator?.mediaDevices;
|
|
879
|
+
if (!mediaDevices) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const canGetUserMedia = typeof mediaDevices.getUserMedia === "function";
|
|
883
|
+
if (!canGetUserMedia) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const stream = await mediaDevices.getUserMedia({ audio: true, video: true });
|
|
887
|
+
stopStreamTracks(stream);
|
|
888
|
+
await mediaDevices.getUserMedia({
|
|
889
|
+
audio: true,
|
|
890
|
+
video: { width: { ideal: 1920 }, height: { ideal: 1080 } }
|
|
891
|
+
}).then((stream2) => {
|
|
892
|
+
stopStreamTracks(stream2);
|
|
893
|
+
return;
|
|
894
|
+
}).catch(noOperation2);
|
|
895
|
+
}
|
|
896
|
+
function countGrantedPermissions(permissions) {
|
|
897
|
+
let grantedCount = 0;
|
|
898
|
+
if (permissions.camera === "granted") {
|
|
899
|
+
grantedCount += 1;
|
|
900
|
+
}
|
|
901
|
+
if (permissions.microphone === "granted") {
|
|
902
|
+
grantedCount += 1;
|
|
903
|
+
}
|
|
904
|
+
return grantedCount;
|
|
905
|
+
}
|
|
906
|
+
function hasUnknownPermissionState(permissions) {
|
|
907
|
+
if (permissions.camera === "unknown") {
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
if (permissions.microphone === "unknown") {
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
function hasPromptPermissionState(permissions) {
|
|
916
|
+
if (permissions.camera === "prompt") {
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
if (permissions.microphone === "prompt") {
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
function hasHardPermissionDenial(permissions) {
|
|
925
|
+
if (permissions.camera === "denied") {
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
if (permissions.microphone === "denied") {
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
function getPermissionDeniedIssueCode(permissions) {
|
|
934
|
+
const hasCameraDenied = permissions.camera === "denied";
|
|
935
|
+
const hasMicrophoneDenied = permissions.microphone === "denied";
|
|
936
|
+
if (hasCameraDenied && hasMicrophoneDenied) {
|
|
937
|
+
return "permission-denied-both";
|
|
938
|
+
}
|
|
939
|
+
if (hasCameraDenied) {
|
|
940
|
+
return "permission-denied-camera";
|
|
941
|
+
}
|
|
942
|
+
return "permission-denied-microphone";
|
|
943
|
+
}
|
|
944
|
+
function createPermissionFlowOrchestratorDependencies() {
|
|
945
|
+
return {
|
|
946
|
+
queryAllPermissions,
|
|
947
|
+
createPermissionChangeListener,
|
|
948
|
+
createPermissionRecoveryData,
|
|
949
|
+
getBrowserInfo: () => {
|
|
950
|
+
const browserInfo = getBrowserInfo();
|
|
951
|
+
return {
|
|
952
|
+
name: browserInfo.name,
|
|
953
|
+
version: browserInfo.version
|
|
954
|
+
};
|
|
955
|
+
},
|
|
956
|
+
getIsSecureContext,
|
|
957
|
+
requestPermissionProbe
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
class PermissionFlowOrchestrator {
|
|
962
|
+
dependencies;
|
|
963
|
+
callbacks;
|
|
964
|
+
language;
|
|
965
|
+
currentState;
|
|
966
|
+
cleanupCameraListener = noOperation2;
|
|
967
|
+
cleanupMicrophoneListener = noOperation2;
|
|
968
|
+
isInitialized = false;
|
|
969
|
+
hasCompleted = false;
|
|
970
|
+
constructor(dependencies, options = {}) {
|
|
971
|
+
this.dependencies = dependencies;
|
|
972
|
+
let callbacks = {};
|
|
973
|
+
if (options.callbacks) {
|
|
974
|
+
callbacks = options.callbacks;
|
|
975
|
+
}
|
|
976
|
+
this.callbacks = callbacks;
|
|
977
|
+
let language = "en";
|
|
978
|
+
if (options.language === "es") {
|
|
979
|
+
language = "es";
|
|
980
|
+
}
|
|
981
|
+
this.language = language;
|
|
982
|
+
this.currentState = {
|
|
983
|
+
step: "idle",
|
|
984
|
+
permissions: DEFAULT_PERMISSION_STATUS,
|
|
985
|
+
denialType: "none",
|
|
986
|
+
isSecureContext: true,
|
|
987
|
+
isComplete: false,
|
|
988
|
+
canRetry: true,
|
|
989
|
+
shouldProbeUnknown: true
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
initialize() {
|
|
993
|
+
if (this.isInitialized) {
|
|
994
|
+
return Promise.resolve(this.currentState);
|
|
995
|
+
}
|
|
996
|
+
this.isInitialized = true;
|
|
997
|
+
this.hasCompleted = false;
|
|
998
|
+
this.setupPermissionListeners();
|
|
999
|
+
this.updateStateForChecking();
|
|
1000
|
+
this.emitChange("initialized");
|
|
1001
|
+
const isSecureContext = this.dependencies.getIsSecureContext();
|
|
1002
|
+
if (!isSecureContext) {
|
|
1003
|
+
this.currentState = this.createInsecureContextState();
|
|
1004
|
+
this.emitChange("insecure-context");
|
|
1005
|
+
this.tryComplete();
|
|
1006
|
+
return Promise.resolve(this.currentState);
|
|
1007
|
+
}
|
|
1008
|
+
return this.refreshPermissions("permission-refresh");
|
|
1009
|
+
}
|
|
1010
|
+
requestCurrentStep() {
|
|
1011
|
+
if (!this.isInitialized) {
|
|
1012
|
+
return this.initialize();
|
|
1013
|
+
}
|
|
1014
|
+
if (!this.currentState.canRetry) {
|
|
1015
|
+
return Promise.resolve(this.currentState);
|
|
1016
|
+
}
|
|
1017
|
+
this.updateStateForChecking();
|
|
1018
|
+
this.emitChange("current-step-requested");
|
|
1019
|
+
return this.requestPermissionAndRefresh();
|
|
1020
|
+
}
|
|
1021
|
+
retryCurrentStep() {
|
|
1022
|
+
if (!this.isInitialized) {
|
|
1023
|
+
return this.initialize();
|
|
1024
|
+
}
|
|
1025
|
+
if (!this.currentState.canRetry) {
|
|
1026
|
+
return Promise.resolve(this.currentState);
|
|
1027
|
+
}
|
|
1028
|
+
this.updateStateForChecking();
|
|
1029
|
+
this.emitChange("retry-requested");
|
|
1030
|
+
return this.requestPermissionAndRefresh();
|
|
1031
|
+
}
|
|
1032
|
+
getState() {
|
|
1033
|
+
return this.currentState;
|
|
1034
|
+
}
|
|
1035
|
+
requestPermissionAndRefresh() {
|
|
1036
|
+
return this.dependencies.requestPermissionProbe().then(() => {
|
|
1037
|
+
const grantedPermissions = {
|
|
1038
|
+
camera: "granted",
|
|
1039
|
+
microphone: "granted"
|
|
1040
|
+
};
|
|
1041
|
+
this.currentState = this.createStateFromPermissions(grantedPermissions);
|
|
1042
|
+
this.emitChange("probe-complete");
|
|
1043
|
+
this.tryComplete();
|
|
1044
|
+
return this.currentState;
|
|
1045
|
+
}).catch(() => this.refreshPermissions("permission-refresh").then((state) => {
|
|
1046
|
+
if (state.step !== "awaiting-user") {
|
|
1047
|
+
return state;
|
|
1048
|
+
}
|
|
1049
|
+
return this.transitionToBlockedFromFailedProbe();
|
|
1050
|
+
}));
|
|
1051
|
+
}
|
|
1052
|
+
destroy() {
|
|
1053
|
+
this.cleanupCameraListener();
|
|
1054
|
+
this.cleanupCameraListener = noOperation2;
|
|
1055
|
+
this.cleanupMicrophoneListener();
|
|
1056
|
+
this.cleanupMicrophoneListener = noOperation2;
|
|
1057
|
+
this.isInitialized = false;
|
|
1058
|
+
this.hasCompleted = false;
|
|
1059
|
+
this.emitChange("destroyed");
|
|
1060
|
+
}
|
|
1061
|
+
setupPermissionListeners() {
|
|
1062
|
+
this.cleanupCameraListener = this.dependencies.createPermissionChangeListener("camera", (state) => {
|
|
1063
|
+
this.handlePermissionStateChange("camera", state);
|
|
1064
|
+
});
|
|
1065
|
+
this.cleanupMicrophoneListener = this.dependencies.createPermissionChangeListener("microphone", (state) => {
|
|
1066
|
+
this.handlePermissionStateChange("microphone", state);
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
handlePermissionStateChange(permissionName, state) {
|
|
1070
|
+
if (!this.isInitialized) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const nextPermissions = {
|
|
1074
|
+
...this.currentState.permissions,
|
|
1075
|
+
[permissionName]: state
|
|
1076
|
+
};
|
|
1077
|
+
const previousStep = this.currentState.step;
|
|
1078
|
+
this.currentState = this.createStateFromPermissions(nextPermissions);
|
|
1079
|
+
const isFlowReactivating = previousStep === "ready" && this.currentState.step !== "ready";
|
|
1080
|
+
if (isFlowReactivating) {
|
|
1081
|
+
this.hasCompleted = false;
|
|
1082
|
+
}
|
|
1083
|
+
this.emitChange("permission-change");
|
|
1084
|
+
this.tryComplete();
|
|
1085
|
+
}
|
|
1086
|
+
async refreshPermissions(reason) {
|
|
1087
|
+
const permissions = await this.dependencies.queryAllPermissions();
|
|
1088
|
+
this.currentState = this.createStateFromPermissions(permissions);
|
|
1089
|
+
this.emitChange(reason);
|
|
1090
|
+
this.tryComplete();
|
|
1091
|
+
return this.currentState;
|
|
1092
|
+
}
|
|
1093
|
+
createStateFromPermissions(permissions) {
|
|
1094
|
+
const isSecureContext = this.dependencies.getIsSecureContext();
|
|
1095
|
+
if (!isSecureContext) {
|
|
1096
|
+
return this.createInsecureContextState();
|
|
1097
|
+
}
|
|
1098
|
+
const grantedCount = countGrantedPermissions(permissions);
|
|
1099
|
+
if (grantedCount === GRANTED_PERMISSION_COUNT) {
|
|
1100
|
+
return {
|
|
1101
|
+
step: "ready",
|
|
1102
|
+
permissions,
|
|
1103
|
+
denialType: "none",
|
|
1104
|
+
isSecureContext: true,
|
|
1105
|
+
isComplete: true,
|
|
1106
|
+
canRetry: false,
|
|
1107
|
+
shouldProbeUnknown: false
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
if (hasHardPermissionDenial(permissions)) {
|
|
1111
|
+
const issueCode = getPermissionDeniedIssueCode(permissions);
|
|
1112
|
+
const recoveryData = this.dependencies.createPermissionRecoveryData(issueCode, this.dependencies.getBrowserInfo(), this.language);
|
|
1113
|
+
return {
|
|
1114
|
+
step: "blocked",
|
|
1115
|
+
permissions,
|
|
1116
|
+
denialType: "hard",
|
|
1117
|
+
issueCode,
|
|
1118
|
+
recoveryData,
|
|
1119
|
+
isSecureContext: true,
|
|
1120
|
+
isComplete: true,
|
|
1121
|
+
canRetry: recoveryData.canRetry,
|
|
1122
|
+
shouldProbeUnknown: false
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const shouldProbeUnknown = hasUnknownPermissionState(permissions);
|
|
1126
|
+
const hasPromptState = hasPromptPermissionState(permissions);
|
|
1127
|
+
let canRetry = false;
|
|
1128
|
+
if (shouldProbeUnknown) {
|
|
1129
|
+
canRetry = true;
|
|
1130
|
+
}
|
|
1131
|
+
if (hasPromptState) {
|
|
1132
|
+
canRetry = true;
|
|
1133
|
+
}
|
|
1134
|
+
return {
|
|
1135
|
+
step: "awaiting-user",
|
|
1136
|
+
permissions,
|
|
1137
|
+
denialType: "soft",
|
|
1138
|
+
isSecureContext: true,
|
|
1139
|
+
isComplete: false,
|
|
1140
|
+
canRetry,
|
|
1141
|
+
shouldProbeUnknown
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
transitionToBlockedFromFailedProbe() {
|
|
1145
|
+
const issueCode = "permission-denied-both";
|
|
1146
|
+
const recoveryData = this.dependencies.createPermissionRecoveryData(issueCode, this.dependencies.getBrowserInfo(), this.language);
|
|
1147
|
+
this.currentState = {
|
|
1148
|
+
step: "blocked",
|
|
1149
|
+
permissions: this.currentState.permissions,
|
|
1150
|
+
denialType: "hard",
|
|
1151
|
+
issueCode,
|
|
1152
|
+
recoveryData,
|
|
1153
|
+
isSecureContext: true,
|
|
1154
|
+
isComplete: true,
|
|
1155
|
+
canRetry: recoveryData.canRetry,
|
|
1156
|
+
shouldProbeUnknown: false
|
|
1157
|
+
};
|
|
1158
|
+
this.emitChange("probe-complete");
|
|
1159
|
+
this.tryComplete();
|
|
1160
|
+
return this.currentState;
|
|
1161
|
+
}
|
|
1162
|
+
createInsecureContextState() {
|
|
1163
|
+
const issueCode = "insecure-context";
|
|
1164
|
+
const recoveryData = this.dependencies.createPermissionRecoveryData(issueCode, this.dependencies.getBrowserInfo(), this.language);
|
|
1165
|
+
return {
|
|
1166
|
+
step: "blocked",
|
|
1167
|
+
permissions: this.currentState.permissions,
|
|
1168
|
+
denialType: "hard",
|
|
1169
|
+
issueCode,
|
|
1170
|
+
recoveryData,
|
|
1171
|
+
isSecureContext: false,
|
|
1172
|
+
isComplete: true,
|
|
1173
|
+
canRetry: false,
|
|
1174
|
+
shouldProbeUnknown: false
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
updateStateForChecking() {
|
|
1178
|
+
this.currentState = {
|
|
1179
|
+
...this.currentState,
|
|
1180
|
+
step: "checking"
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
emitChange(reason) {
|
|
1184
|
+
if (this.callbacks.onChange) {
|
|
1185
|
+
this.callbacks.onChange(this.currentState, reason);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
tryComplete() {
|
|
1189
|
+
if (this.hasCompleted) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (!this.currentState.isComplete) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
this.hasCompleted = true;
|
|
1196
|
+
if (this.callbacks.onComplete) {
|
|
1197
|
+
this.callbacks.onComplete(this.currentState);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
707
1201
|
// src/core/utils/video-utils.ts
|
|
708
1202
|
import { BlobSource, Input, MP4 } from "mediabunny";
|
|
709
1203
|
async function extractVideoDuration(file) {
|
|
@@ -1337,6 +1831,7 @@ function revokeProbeWorkerUrl(workerUrl) {
|
|
|
1337
1831
|
|
|
1338
1832
|
// src/core/processor/worker/types.ts
|
|
1339
1833
|
var WORKER_MESSAGE_TYPE_PROBE = "probe";
|
|
1834
|
+
var WORKER_MESSAGE_TYPE_WARMUP = "warmup";
|
|
1340
1835
|
var WORKER_MESSAGE_TYPE_AUDIO_CHUNK = "audioChunk";
|
|
1341
1836
|
var WORKER_RESPONSE_TYPE_PROBE_RESULT = "probeResult";
|
|
1342
1837
|
var WORKER_RESPONSE_TYPE_DEBUG_LOG = "debugLog";
|
|
@@ -1531,11 +2026,11 @@ function getIsProbeFeaturesComplete(probeResult, requiresWatermark) {
|
|
|
1531
2026
|
}
|
|
1532
2027
|
return true;
|
|
1533
2028
|
}
|
|
1534
|
-
async function checkRecorderSupport(options = {}) {
|
|
2029
|
+
async function checkRecorderSupport(options = {}, dependencies = {}) {
|
|
1535
2030
|
const requiresAudio = resolveBooleanOption(options.requiresAudio, true);
|
|
1536
2031
|
const requiresWatermark = resolveBooleanOption(options.requiresWatermark, false);
|
|
1537
2032
|
if (!shouldUseSupportCache()) {
|
|
1538
|
-
return await buildSupportReport(requiresAudio, requiresWatermark);
|
|
2033
|
+
return await buildSupportReport(requiresAudio, requiresWatermark, dependencies);
|
|
1539
2034
|
}
|
|
1540
2035
|
const supportCacheKey = createSupportCacheKey(requiresAudio, requiresWatermark);
|
|
1541
2036
|
const cachedReport = supportReportCache.get(supportCacheKey);
|
|
@@ -1546,7 +2041,7 @@ async function checkRecorderSupport(options = {}) {
|
|
|
1546
2041
|
if (inflightReport) {
|
|
1547
2042
|
return await inflightReport;
|
|
1548
2043
|
}
|
|
1549
|
-
const reportPromise = buildSupportReport(requiresAudio, requiresWatermark).then((report) => {
|
|
2044
|
+
const reportPromise = buildSupportReport(requiresAudio, requiresWatermark, dependencies).then((report) => {
|
|
1550
2045
|
supportReportCache.set(supportCacheKey, report);
|
|
1551
2046
|
supportReportPromiseCache.delete(supportCacheKey);
|
|
1552
2047
|
return report;
|
|
@@ -1557,13 +2052,13 @@ async function checkRecorderSupport(options = {}) {
|
|
|
1557
2052
|
supportReportPromiseCache.set(supportCacheKey, reportPromise);
|
|
1558
2053
|
return await reportPromise;
|
|
1559
2054
|
}
|
|
1560
|
-
async function buildSupportReport(requiresAudio, requiresWatermark) {
|
|
2055
|
+
async function buildSupportReport(requiresAudio, requiresWatermark, dependencies) {
|
|
1561
2056
|
const hasWorker = typeof Worker !== "undefined";
|
|
1562
2057
|
const audioContextClass = getAudioContextClass();
|
|
1563
2058
|
const hasAudioContext = audioContextClass !== null;
|
|
1564
2059
|
const hasAudioWorklet = typeof AudioWorkletNode !== "undefined";
|
|
1565
2060
|
const hasMainThreadMediaStreamTrackProcessor = typeof MediaStreamTrackProcessor !== "undefined";
|
|
1566
|
-
const probeResult = await probeWorkerCapabilities(hasWorker);
|
|
2061
|
+
const probeResult = await probeWorkerCapabilities(hasWorker, dependencies);
|
|
1567
2062
|
const videoPath = resolveVideoPath({
|
|
1568
2063
|
probeResult,
|
|
1569
2064
|
hasMainThreadMediaStreamTrackProcessor
|
|
@@ -1608,7 +2103,7 @@ async function buildSupportReport(requiresAudio, requiresWatermark) {
|
|
|
1608
2103
|
audioPath
|
|
1609
2104
|
};
|
|
1610
2105
|
}
|
|
1611
|
-
async function probeWorkerCapabilities(hasWorker) {
|
|
2106
|
+
async function probeWorkerCapabilities(hasWorker, dependencies) {
|
|
1612
2107
|
if (!hasWorker) {
|
|
1613
2108
|
return getEmptyProbeResult();
|
|
1614
2109
|
}
|
|
@@ -1618,6 +2113,7 @@ async function probeWorkerCapabilities(hasWorker) {
|
|
|
1618
2113
|
}
|
|
1619
2114
|
return await new Promise((resolve) => {
|
|
1620
2115
|
let resolved = false;
|
|
2116
|
+
const probeTimeoutMilliseconds = dependencies.probeTimeoutMilliseconds ?? PROBE_TIMEOUT_MILLISECONDS;
|
|
1621
2117
|
const finalize = (result) => {
|
|
1622
2118
|
if (resolved) {
|
|
1623
2119
|
return;
|
|
@@ -1628,7 +2124,7 @@ async function probeWorkerCapabilities(hasWorker) {
|
|
|
1628
2124
|
};
|
|
1629
2125
|
const timeoutId = setTimeout(() => {
|
|
1630
2126
|
finalize(getEmptyProbeResult());
|
|
1631
|
-
},
|
|
2127
|
+
}, probeTimeoutMilliseconds);
|
|
1632
2128
|
worker.onmessage = (event) => {
|
|
1633
2129
|
const payload = event.data;
|
|
1634
2130
|
if (payload.type !== WORKER_RESPONSE_TYPE_PROBE_RESULT) {
|
|
@@ -2136,7 +2632,7 @@ function stopLiveTracks(tracks) {
|
|
|
2136
2632
|
}
|
|
2137
2633
|
}
|
|
2138
2634
|
}
|
|
2139
|
-
function
|
|
2635
|
+
function stopStreamTracks2(stream) {
|
|
2140
2636
|
stopLiveTracks(stream.getTracks());
|
|
2141
2637
|
}
|
|
2142
2638
|
function stopStreamVideoTracks(stream) {
|
|
@@ -2153,7 +2649,7 @@ function areTracksLive(videoTrack, audioTrack) {
|
|
|
2153
2649
|
}
|
|
2154
2650
|
function validateTrack(track, trackType, stream) {
|
|
2155
2651
|
if (!isTrackLive(track)) {
|
|
2156
|
-
|
|
2652
|
+
stopStreamTracks2(stream);
|
|
2157
2653
|
let readyState = "undefined";
|
|
2158
2654
|
if (track) {
|
|
2159
2655
|
readyState = track.readyState;
|
|
@@ -2311,7 +2807,7 @@ class CameraStreamBuilder {
|
|
|
2311
2807
|
}
|
|
2312
2808
|
const managerStream = this.dependencies.streamManager.getStream();
|
|
2313
2809
|
if (!isRecording && managerStream && managerStream !== this.dependencies.getOriginalCameraStream()) {
|
|
2314
|
-
|
|
2810
|
+
stopStreamTracks2(managerStream);
|
|
2315
2811
|
this.dependencies.streamManager.setMediaStream(null);
|
|
2316
2812
|
}
|
|
2317
2813
|
if (isRecording) {
|
|
@@ -2639,7 +3135,7 @@ class SourceSwitchManager {
|
|
|
2639
3135
|
callbacks: this.callbacks,
|
|
2640
3136
|
streamManager: this.streamManager,
|
|
2641
3137
|
combineScreenShareWithOriginalAudio: (screenVideoTrack) => this.combineScreenShareWithOriginalAudio(screenVideoTrack),
|
|
2642
|
-
stopStreamTracks: (stream) =>
|
|
3138
|
+
stopStreamTracks: (stream) => stopStreamTracks2(stream),
|
|
2643
3139
|
stopStreamVideoTracks: (stream) => stopStreamVideoTracks(stream),
|
|
2644
3140
|
getCurrentSourceType: () => this.currentSourceType,
|
|
2645
3141
|
setCurrentSourceType: (sourceType) => {
|
|
@@ -2949,26 +3445,48 @@ var DEFAULT_RECORDING_OPTIONS = Object.freeze({
|
|
|
2949
3445
|
|
|
2950
3446
|
// src/core/stream/stream-manager.ts
|
|
2951
3447
|
var TRACK_READY_STATE_LIVE2 = "live";
|
|
3448
|
+
var AUDIO_RETRY_DELAY_MILLISECONDS = 300;
|
|
3449
|
+
var AUDIO_MAX_RETRIES = 2;
|
|
2952
3450
|
var CAMERA_ERROR_CODE_MAP = {
|
|
2953
3451
|
NotReadableError: "camera.in-use",
|
|
2954
3452
|
NotFoundError: "camera.not-found",
|
|
2955
3453
|
NotAllowedError: "camera.permission-denied",
|
|
2956
3454
|
OverconstrainedError: "camera.overconstrained"
|
|
2957
3455
|
};
|
|
2958
|
-
|
|
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) {
|
|
2959
3463
|
if (error instanceof DOMException) {
|
|
2960
|
-
|
|
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];
|
|
2961
3475
|
if (mappedCode !== undefined) {
|
|
2962
3476
|
return mappedCode;
|
|
2963
3477
|
}
|
|
2964
3478
|
}
|
|
2965
|
-
|
|
2966
|
-
|
|
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];
|
|
2967
3485
|
if (mappedCode !== undefined) {
|
|
2968
3486
|
return mappedCode;
|
|
2969
3487
|
}
|
|
2970
3488
|
}
|
|
2971
|
-
return "
|
|
3489
|
+
return "audio.unknown";
|
|
2972
3490
|
}
|
|
2973
3491
|
function createCameraStreamError(error) {
|
|
2974
3492
|
const message = extractErrorMessage(error);
|
|
@@ -2977,6 +3495,20 @@ function createCameraStreamError(error) {
|
|
|
2977
3495
|
cameraError.code = classifyCameraError(error);
|
|
2978
3496
|
return cameraError;
|
|
2979
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
|
+
}
|
|
2980
3512
|
|
|
2981
3513
|
class StreamManager {
|
|
2982
3514
|
mediaStream = null;
|
|
@@ -2985,8 +3517,46 @@ class StreamManager {
|
|
|
2985
3517
|
streamConfig;
|
|
2986
3518
|
selectedAudioDeviceId = null;
|
|
2987
3519
|
selectedVideoDeviceId = null;
|
|
2988
|
-
|
|
3520
|
+
audioStatus = "pending";
|
|
3521
|
+
audioAcquisitionPromise = null;
|
|
3522
|
+
pendingAudioError = null;
|
|
3523
|
+
acquisitionGeneration = 0;
|
|
3524
|
+
waitMilliseconds;
|
|
3525
|
+
audioRetryDelayMilliseconds;
|
|
3526
|
+
constructor(streamConfig = {}, dependencies = {}) {
|
|
2989
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 });
|
|
2990
3560
|
}
|
|
2991
3561
|
getState() {
|
|
2992
3562
|
return this.state;
|
|
@@ -3120,20 +3690,7 @@ class StreamManager {
|
|
|
3120
3690
|
this.setState("starting");
|
|
3121
3691
|
logger.debug("[StreamManager] State set to 'starting'");
|
|
3122
3692
|
try {
|
|
3123
|
-
|
|
3124
|
-
selectedVideoDeviceId: this.selectedVideoDeviceId,
|
|
3125
|
-
selectedAudioDeviceId: this.selectedAudioDeviceId
|
|
3126
|
-
});
|
|
3127
|
-
const constraints = {
|
|
3128
|
-
video: this.buildVideoConstraints(this.selectedVideoDeviceId),
|
|
3129
|
-
audio: this.buildAudioConstraints(this.selectedAudioDeviceId)
|
|
3130
|
-
};
|
|
3131
|
-
logger.debug("[StreamManager] Requesting media stream with constraints", {
|
|
3132
|
-
hasVideo: !!constraints.video,
|
|
3133
|
-
hasAudio: !!constraints.audio
|
|
3134
|
-
});
|
|
3135
|
-
const mediaDevices = requireMediaDevices();
|
|
3136
|
-
this.mediaStream = await mediaDevices.getUserMedia(constraints);
|
|
3693
|
+
this.mediaStream = await this.acquireVideoAndAudioStream();
|
|
3137
3694
|
logger.info("[StreamManager] Media stream obtained", {
|
|
3138
3695
|
streamId: this.mediaStream.id,
|
|
3139
3696
|
videoTracks: this.mediaStream.getVideoTracks().length,
|
|
@@ -3145,6 +3702,12 @@ class StreamManager {
|
|
|
3145
3702
|
this.emit("streamstart", { stream: this.mediaStream });
|
|
3146
3703
|
return this.mediaStream;
|
|
3147
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
|
+
}
|
|
3148
3711
|
const err = createCameraStreamError(error);
|
|
3149
3712
|
logger.error("[StreamManager] Failed to start stream", err);
|
|
3150
3713
|
this.setState("error");
|
|
@@ -3152,6 +3715,187 @@ class StreamManager {
|
|
|
3152
3715
|
throw err;
|
|
3153
3716
|
}
|
|
3154
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
|
+
}
|
|
3155
3899
|
stopStream() {
|
|
3156
3900
|
if (this.mediaStream) {
|
|
3157
3901
|
for (const track of this.mediaStream.getTracks()) {
|
|
@@ -3159,6 +3903,10 @@ class StreamManager {
|
|
|
3159
3903
|
}
|
|
3160
3904
|
this.mediaStream = null;
|
|
3161
3905
|
}
|
|
3906
|
+
this.audioStatus = "pending";
|
|
3907
|
+
this.pendingAudioError = null;
|
|
3908
|
+
this.audioAcquisitionPromise = null;
|
|
3909
|
+
this.acquisitionGeneration++;
|
|
3162
3910
|
if (this.state !== "idle") {
|
|
3163
3911
|
this.setState("idle");
|
|
3164
3912
|
this.emit("streamstop", undefined);
|
|
@@ -3511,9 +4259,13 @@ class StreamRecordingState {
|
|
|
3511
4259
|
return Promise.resolve();
|
|
3512
4260
|
}
|
|
3513
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
|
+
}
|
|
3514
4266
|
const requiresWatermark = config.watermark !== undefined;
|
|
3515
4267
|
const supportReport = await this.dependencies.checkRecorderSupport({
|
|
3516
|
-
requiresAudio:
|
|
4268
|
+
requiresAudio: true,
|
|
3517
4269
|
requiresWatermark
|
|
3518
4270
|
});
|
|
3519
4271
|
if (!supportReport.isSupported) {
|
|
@@ -3901,6 +4653,15 @@ class CameraStreamManager {
|
|
|
3901
4653
|
getCurrentVideoSource() {
|
|
3902
4654
|
return this.recordingState.getCurrentVideoSource();
|
|
3903
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
|
+
}
|
|
3904
4665
|
destroy() {
|
|
3905
4666
|
this.recordingState.destroy();
|
|
3906
4667
|
this.streamManager.destroy();
|
|
@@ -3909,7 +4670,7 @@ class CameraStreamManager {
|
|
|
3909
4670
|
// package.json
|
|
3910
4671
|
var package_default = {
|
|
3911
4672
|
name: "@vidtreo/recorder",
|
|
3912
|
-
version: "1.
|
|
4673
|
+
version: "1.4.1",
|
|
3913
4674
|
type: "module",
|
|
3914
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.",
|
|
3915
4676
|
main: "./dist/index.js",
|
|
@@ -3936,6 +4697,7 @@ var package_default = {
|
|
|
3936
4697
|
test: "bun test --concurrent",
|
|
3937
4698
|
"test:watch": "bun test --watch --concurrent",
|
|
3938
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"',
|
|
3939
4701
|
"test:isolation": "bun test --bail",
|
|
3940
4702
|
"test:random": "bun test --bail --rerun-each 2"
|
|
3941
4703
|
},
|
|
@@ -3991,7 +4753,6 @@ var BATCH_FLUSH_INTERVAL_MS = 1000;
|
|
|
3991
4753
|
var THROTTLE_WINDOW_MS = 5000;
|
|
3992
4754
|
var MAX_RETRY_ATTEMPTS = 3;
|
|
3993
4755
|
var MAX_PENDING_EVENTS = 100;
|
|
3994
|
-
var ONE_TIME_EVENT_CACHE = new Map;
|
|
3995
4756
|
function resolveInstallationId(dependencies) {
|
|
3996
4757
|
const storageProvider = dependencies.storageProvider;
|
|
3997
4758
|
const stored = storageProvider?.getItem(TELEMETRY_STORAGE_KEY);
|
|
@@ -4044,7 +4805,11 @@ var TELEMETRY_EVENT_CATEGORY_MAP = {
|
|
|
4044
4805
|
"source.switch.requested": "interaction",
|
|
4045
4806
|
"source.switch.succeeded": "interaction",
|
|
4046
4807
|
"source.switch.failed": "error",
|
|
4047
|
-
"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"
|
|
4048
4813
|
};
|
|
4049
4814
|
|
|
4050
4815
|
class TelemetryClient {
|
|
@@ -4055,6 +4820,7 @@ class TelemetryClient {
|
|
|
4055
4820
|
flushTimeoutId = null;
|
|
4056
4821
|
throttledEventTimestamps = new Map;
|
|
4057
4822
|
retryCountMap = new Map;
|
|
4823
|
+
oneTimeEventCache = new Map;
|
|
4058
4824
|
constructor(config, dependencies) {
|
|
4059
4825
|
this.config = config;
|
|
4060
4826
|
this.dependencies = dependencies;
|
|
@@ -4157,7 +4923,7 @@ class TelemetryClient {
|
|
|
4157
4923
|
shouldSkipEvent(name, timestamp) {
|
|
4158
4924
|
if (this.isOneTimeEvent(name)) {
|
|
4159
4925
|
const cacheKey = this.getOneTimeCacheKey(name);
|
|
4160
|
-
const wasSent =
|
|
4926
|
+
const wasSent = this.oneTimeEventCache.get(cacheKey);
|
|
4161
4927
|
if (wasSent) {
|
|
4162
4928
|
logger.debug("Telemetry event skipped (dedupe)", {
|
|
4163
4929
|
event: name
|
|
@@ -4180,7 +4946,7 @@ class TelemetryClient {
|
|
|
4180
4946
|
markEventTracking(name, timestamp) {
|
|
4181
4947
|
if (this.isOneTimeEvent(name)) {
|
|
4182
4948
|
const cacheKey = this.getOneTimeCacheKey(name);
|
|
4183
|
-
|
|
4949
|
+
this.oneTimeEventCache.set(cacheKey, true);
|
|
4184
4950
|
}
|
|
4185
4951
|
if (this.isThrottledEvent(name)) {
|
|
4186
4952
|
this.throttledEventTimestamps = this.updateNumberMap(this.throttledEventTimestamps, name, timestamp);
|
|
@@ -4521,16 +5287,16 @@ class UploadQueueManager {
|
|
|
4521
5287
|
const retryableUploads = failedUploads.filter((upload) => upload.retryCount < MAX_RETRIES2);
|
|
4522
5288
|
if (retryableUploads.length > 0) {
|
|
4523
5289
|
const upload = this.getOldestFailedUpload(retryableUploads);
|
|
4524
|
-
const
|
|
5290
|
+
const delay2 = this.calculateRetryDelay(upload.retryCount);
|
|
4525
5291
|
const timeSinceLastAttempt = Date.now() - upload.updatedAt;
|
|
4526
|
-
if (timeSinceLastAttempt >=
|
|
5292
|
+
if (timeSinceLastAttempt >= delay2) {
|
|
4527
5293
|
await this.storageService.updateUploadStatus(upload.id, {
|
|
4528
5294
|
status: "pending",
|
|
4529
5295
|
retryCount: upload.retryCount
|
|
4530
5296
|
});
|
|
4531
5297
|
await this.processUpload(upload);
|
|
4532
5298
|
} else {
|
|
4533
|
-
const remainingDelay =
|
|
5299
|
+
const remainingDelay = delay2 - timeSinceLastAttempt;
|
|
4534
5300
|
this.scheduleRetry(remainingDelay);
|
|
4535
5301
|
}
|
|
4536
5302
|
}
|
|
@@ -4602,16 +5368,16 @@ class UploadQueueManager {
|
|
|
4602
5368
|
if (retryCount >= MAX_RETRIES2) {
|
|
4603
5369
|
this.callbacks.onUploadError?.(upload.id, new Error(`Upload failed after ${MAX_RETRIES2} attempts: ${errorMessage}`));
|
|
4604
5370
|
} else {
|
|
4605
|
-
const
|
|
4606
|
-
this.scheduleRetry(
|
|
5371
|
+
const delay2 = this.calculateRetryDelay(retryCount);
|
|
5372
|
+
this.scheduleRetry(delay2);
|
|
4607
5373
|
}
|
|
4608
5374
|
}
|
|
4609
5375
|
}
|
|
4610
5376
|
calculateRetryDelay(retryCount) {
|
|
4611
|
-
const
|
|
4612
|
-
return Math.min(
|
|
5377
|
+
const delay2 = INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** (retryCount - 1);
|
|
5378
|
+
return Math.min(delay2, MAX_RETRY_DELAY);
|
|
4613
5379
|
}
|
|
4614
|
-
scheduleRetry(
|
|
5380
|
+
scheduleRetry(delay2) {
|
|
4615
5381
|
this.clearTimer(this.retryTimeoutId, clearTimeout);
|
|
4616
5382
|
this.retryTimeoutId = window.setTimeout(() => {
|
|
4617
5383
|
this.retryTimeoutId = null;
|
|
@@ -4619,7 +5385,7 @@ class UploadQueueManager {
|
|
|
4619
5385
|
const errorMessage = extractErrorMessage(error);
|
|
4620
5386
|
this.callbacks.onUploadError?.("scheduled-retry", new Error(errorMessage));
|
|
4621
5387
|
});
|
|
4622
|
-
},
|
|
5388
|
+
}, delay2);
|
|
4623
5389
|
}
|
|
4624
5390
|
clearTimer(timerId, clearFn) {
|
|
4625
5391
|
if (timerId !== null) {
|
|
@@ -13993,20 +14759,31 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
13993
14759
|
if (!compositionPlan.needsComposition) {
|
|
13994
14760
|
return { frameToProcess: parameters.videoFrame, imageBitmap: null };
|
|
13995
14761
|
}
|
|
13996
|
-
const
|
|
13997
|
-
if (!
|
|
14762
|
+
const outputDimensions = this.getOutputDimensions(parameters.videoFrame, compositionPlan.rotationDegrees, parameters.config);
|
|
14763
|
+
if (!outputDimensions) {
|
|
13998
14764
|
return { frameToProcess: parameters.videoFrame, imageBitmap: null };
|
|
13999
14765
|
}
|
|
14000
|
-
const width =
|
|
14001
|
-
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
|
+
}
|
|
14002
14774
|
const context = this.ensureCompositionCanvas(width, height);
|
|
14003
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
|
+
}
|
|
14004
14780
|
this.drawVideoFrame({
|
|
14005
14781
|
context,
|
|
14006
14782
|
videoFrame: parameters.videoFrame,
|
|
14007
14783
|
rotationDegrees: compositionPlan.rotationDegrees,
|
|
14008
14784
|
width,
|
|
14009
|
-
height
|
|
14785
|
+
height,
|
|
14786
|
+
fit
|
|
14010
14787
|
});
|
|
14011
14788
|
this.applyOverlayIfNeeded(context, width, compositionPlan.shouldApplyOverlay, parameters.overlayConfig);
|
|
14012
14789
|
this.applyWatermarkIfNeeded({
|
|
@@ -14025,6 +14802,7 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14025
14802
|
if (parameters.config.watermark && this.watermarkCanvas) {
|
|
14026
14803
|
needsWatermark = true;
|
|
14027
14804
|
}
|
|
14805
|
+
const needsResizing = this.detectResizingNeed(parameters.videoFrame, rotationDegrees, parameters.config);
|
|
14028
14806
|
let needsComposition = false;
|
|
14029
14807
|
if (parameters.shouldApplyOverlay) {
|
|
14030
14808
|
needsComposition = true;
|
|
@@ -14035,30 +14813,17 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14035
14813
|
if (shouldRotateFrame) {
|
|
14036
14814
|
needsComposition = true;
|
|
14037
14815
|
}
|
|
14816
|
+
if (needsResizing) {
|
|
14817
|
+
needsComposition = true;
|
|
14818
|
+
}
|
|
14038
14819
|
return {
|
|
14039
14820
|
rotationDegrees,
|
|
14040
14821
|
shouldApplyOverlay: parameters.shouldApplyOverlay,
|
|
14041
14822
|
needsWatermark,
|
|
14823
|
+
needsResizing,
|
|
14042
14824
|
needsComposition
|
|
14043
14825
|
};
|
|
14044
14826
|
}
|
|
14045
|
-
getValidFrameDimensions(videoFrame, rotationDegrees) {
|
|
14046
|
-
const dimensions = this.getFrameDimensions(videoFrame, rotationDegrees);
|
|
14047
|
-
const width = dimensions.width;
|
|
14048
|
-
const height = dimensions.height;
|
|
14049
|
-
let hasInvalidDimensions = false;
|
|
14050
|
-
if (width <= 0) {
|
|
14051
|
-
hasInvalidDimensions = true;
|
|
14052
|
-
}
|
|
14053
|
-
if (height <= 0) {
|
|
14054
|
-
hasInvalidDimensions = true;
|
|
14055
|
-
}
|
|
14056
|
-
if (hasInvalidDimensions) {
|
|
14057
|
-
this.logger.warn(\`\${RECORDER_WORKER_LOG_PREFIX} Invalid video frame dimensions, skipping composition\`, { width, height });
|
|
14058
|
-
return null;
|
|
14059
|
-
}
|
|
14060
|
-
return { width, height };
|
|
14061
|
-
}
|
|
14062
14827
|
applyOverlayIfNeeded(context, videoWidth, shouldApplyOverlay, overlayConfig) {
|
|
14063
14828
|
if (!(shouldApplyOverlay && overlayConfig)) {
|
|
14064
14829
|
return;
|
|
@@ -14242,25 +15007,60 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14242
15007
|
return { width, height };
|
|
14243
15008
|
}
|
|
14244
15009
|
drawVideoFrame(parameters) {
|
|
14245
|
-
const { context, videoFrame, rotationDegrees, width, height } = parameters;
|
|
15010
|
+
const { context, videoFrame, rotationDegrees, width, height, fit } = parameters;
|
|
14246
15011
|
const sourceWidth = videoFrame.displayWidth;
|
|
14247
15012
|
const sourceHeight = videoFrame.displayHeight;
|
|
15013
|
+
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
|
15014
|
+
return;
|
|
15015
|
+
}
|
|
14248
15016
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
14249
|
-
if (
|
|
14250
|
-
|
|
14251
|
-
|
|
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
|
+
}
|
|
14252
15032
|
context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
|
|
14253
|
-
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
14254
15033
|
return;
|
|
14255
15034
|
}
|
|
14256
|
-
if (rotationDegrees ===
|
|
14257
|
-
context.
|
|
14258
|
-
context.rotate(ROTATION_RADIANS_270);
|
|
14259
|
-
context.drawImage(videoFrame, 0, 0, sourceWidth, sourceHeight);
|
|
14260
|
-
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);
|
|
14261
15037
|
return;
|
|
14262
15038
|
}
|
|
14263
|
-
|
|
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);
|
|
14264
15064
|
}
|
|
14265
15065
|
logWatermarkError(url2, error) {
|
|
14266
15066
|
const errorMessage = extractErrorMessage(error);
|
|
@@ -14270,6 +15070,78 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14270
15070
|
});
|
|
14271
15071
|
}
|
|
14272
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
|
+
}
|
|
14273
15145
|
|
|
14274
15146
|
// src/core/processor/worker/recording-integrity.ts
|
|
14275
15147
|
var EXCESSIVE_FRAME_ERROR_RATIO_THRESHOLD = 0.5;
|
|
@@ -14348,7 +15220,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14348
15220
|
|
|
14349
15221
|
// src/core/processor/worker/timestamp-manager.ts
|
|
14350
15222
|
var DEFAULT_FRAME_RATE = 30;
|
|
14351
|
-
var DEFAULT_KEY_FRAME_INTERVAL_SECONDS = 5;
|
|
14352
15223
|
var MILLISECONDS_PER_SECOND2 = 1000;
|
|
14353
15224
|
var MICROSECONDS_PER_SECOND = 1e6;
|
|
14354
15225
|
var MAX_LEAD_SECONDS = 0.05;
|
|
@@ -14363,7 +15234,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14363
15234
|
lastVideoTimestamp = 0;
|
|
14364
15235
|
baseVideoTimestamp = null;
|
|
14365
15236
|
frameCount = 0;
|
|
14366
|
-
lastKeyFrameTimestamp = 0;
|
|
14367
15237
|
forceNextKeyFrame = false;
|
|
14368
15238
|
driftOffset = 0;
|
|
14369
15239
|
logger;
|
|
@@ -14381,7 +15251,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14381
15251
|
this.lastVideoTimestamp = 0;
|
|
14382
15252
|
this.baseVideoTimestamp = null;
|
|
14383
15253
|
this.frameCount = 0;
|
|
14384
|
-
this.lastKeyFrameTimestamp = 0;
|
|
14385
15254
|
this.forceNextKeyFrame = false;
|
|
14386
15255
|
this.driftOffset = 0;
|
|
14387
15256
|
}
|
|
@@ -14470,36 +15339,25 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14470
15339
|
prepareFrameTiming(parameters) {
|
|
14471
15340
|
const frameDuration = 1 / this.frameRate;
|
|
14472
15341
|
let adjustedTimestamp = parameters.frameTimestamp + this.driftOffset;
|
|
14473
|
-
if (adjustedTimestamp - parameters.lastAudioTimestamp > MAX_LEAD_SECONDS) {
|
|
14474
|
-
adjustedTimestamp = parameters.lastAudioTimestamp + MAX_LEAD_SECONDS;
|
|
14475
|
-
}
|
|
14476
|
-
if (parameters.lastAudioTimestamp - adjustedTimestamp > MAX_LAG_SECONDS) {
|
|
14477
|
-
adjustedTimestamp = parameters.lastAudioTimestamp - MAX_LAG_SECONDS;
|
|
14478
|
-
}
|
|
14479
15342
|
const monotonicTimestamp = this.lastVideoTimestamp + frameDuration;
|
|
15343
|
+
if (adjustedTimestamp < monotonicTimestamp) {
|
|
15344
|
+
adjustedTimestamp = monotonicTimestamp;
|
|
15345
|
+
}
|
|
14480
15346
|
let finalTimestamp = adjustedTimestamp;
|
|
14481
|
-
if (finalTimestamp
|
|
14482
|
-
finalTimestamp =
|
|
15347
|
+
if (finalTimestamp - parameters.lastAudioTimestamp > MAX_LEAD_SECONDS) {
|
|
15348
|
+
finalTimestamp = parameters.lastAudioTimestamp + MAX_LEAD_SECONDS;
|
|
14483
15349
|
}
|
|
14484
|
-
|
|
14485
|
-
|
|
14486
|
-
keyFrameIntervalSeconds = DEFAULT_KEY_FRAME_INTERVAL_SECONDS;
|
|
15350
|
+
if (parameters.lastAudioTimestamp - finalTimestamp > MAX_LAG_SECONDS) {
|
|
15351
|
+
finalTimestamp = parameters.lastAudioTimestamp - MAX_LAG_SECONDS;
|
|
14487
15352
|
}
|
|
14488
|
-
|
|
14489
|
-
if (
|
|
14490
|
-
|
|
15353
|
+
const minimumTimestamp = this.lastVideoTimestamp + frameDuration;
|
|
15354
|
+
if (finalTimestamp < minimumTimestamp) {
|
|
15355
|
+
finalTimestamp = minimumTimestamp;
|
|
14491
15356
|
}
|
|
14492
|
-
const timeSinceLastKeyFrame = finalTimestamp - this.lastKeyFrameTimestamp;
|
|
14493
15357
|
let isKeyFrame = false;
|
|
14494
15358
|
if (this.forceNextKeyFrame) {
|
|
14495
15359
|
isKeyFrame = true;
|
|
14496
15360
|
}
|
|
14497
|
-
if (timeSinceLastKeyFrame >= keyFrameIntervalSeconds) {
|
|
14498
|
-
isKeyFrame = true;
|
|
14499
|
-
}
|
|
14500
|
-
if (this.frameCount % keyFrameIntervalFrames === 0) {
|
|
14501
|
-
isKeyFrame = true;
|
|
14502
|
-
}
|
|
14503
15361
|
this.driftOffset *= DRIFT_OFFSET_DECAY_FACTOR;
|
|
14504
15362
|
return {
|
|
14505
15363
|
finalTimestamp,
|
|
@@ -14511,7 +15369,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14511
15369
|
this.frameCount += 1;
|
|
14512
15370
|
this.lastVideoTimestamp = parameters.finalTimestamp;
|
|
14513
15371
|
if (parameters.isKeyFrame) {
|
|
14514
|
-
this.lastKeyFrameTimestamp = parameters.finalTimestamp;
|
|
14515
15372
|
this.forceNextKeyFrame = false;
|
|
14516
15373
|
}
|
|
14517
15374
|
let shouldLogDrift = false;
|
|
@@ -14554,6 +15411,7 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14554
15411
|
|
|
14555
15412
|
// src/core/processor/worker/types.ts
|
|
14556
15413
|
var WORKER_MESSAGE_TYPE_PROBE = "probe";
|
|
15414
|
+
var WORKER_MESSAGE_TYPE_WARMUP = "warmup";
|
|
14557
15415
|
var WORKER_MESSAGE_TYPE_AUDIO_CHUNK = "audioChunk";
|
|
14558
15416
|
var WORKER_RESPONSE_TYPE_PROBE_RESULT = "probeResult";
|
|
14559
15417
|
var WORKER_AUDIO_SAMPLE_FORMAT_F32_PLANAR = "f32-planar";
|
|
@@ -14738,6 +15596,7 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14738
15596
|
expectedAudioSampleRate = null;
|
|
14739
15597
|
pendingWriteCount = 0;
|
|
14740
15598
|
resolvedHardwareAcceleration = VIDEO_HARDWARE_ACCELERATION_PREFERENCE;
|
|
15599
|
+
hwAccelCacheKey = null;
|
|
14741
15600
|
consecutiveFrameErrors = 0;
|
|
14742
15601
|
videoProcessingRunId = 0;
|
|
14743
15602
|
totalFrameErrors = 0;
|
|
@@ -14820,6 +15679,13 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14820
15679
|
case WORKER_MESSAGE_TYPE_PROBE:
|
|
14821
15680
|
this.handleProbe();
|
|
14822
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;
|
|
14823
15689
|
case "start":
|
|
14824
15690
|
this.handleStartMessage(message);
|
|
14825
15691
|
return;
|
|
@@ -14979,7 +15845,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14979
15845
|
}
|
|
14980
15846
|
createVideoSource(config, hardwareAcceleration) {
|
|
14981
15847
|
const fps = this.timestampManager.getFrameRate();
|
|
14982
|
-
const keyFrameIntervalSeconds = config.keyFrameInterval;
|
|
14983
15848
|
const videoSourceOptions = {
|
|
14984
15849
|
codec: config.codec,
|
|
14985
15850
|
width: config.width,
|
|
@@ -14987,10 +15852,10 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
14987
15852
|
sizeChangeBehavior: "contain",
|
|
14988
15853
|
alpha: "discard",
|
|
14989
15854
|
bitrateMode: "variable",
|
|
14990
|
-
latencyMode: VIDEO_LATENCY_MODE_REALTIME,
|
|
15855
|
+
latencyMode: config.latencyMode || VIDEO_LATENCY_MODE_REALTIME,
|
|
14991
15856
|
contentHint: VIDEO_CONTENT_HINT_MOTION,
|
|
14992
15857
|
hardwareAcceleration,
|
|
14993
|
-
keyFrameInterval:
|
|
15858
|
+
keyFrameInterval: config.keyFrameInterval,
|
|
14994
15859
|
bitrate: this.deserializeBitrate(config.bitrate)
|
|
14995
15860
|
};
|
|
14996
15861
|
this.videoSource = new VideoSampleSource(videoSourceOptions);
|
|
@@ -15017,7 +15882,13 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
15017
15882
|
if (typeof config.bitrate === "number" && config.bitrate > 0) {
|
|
15018
15883
|
bitrate = config.bitrate;
|
|
15019
15884
|
}
|
|
15020
|
-
|
|
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;
|
|
15021
15892
|
}
|
|
15022
15893
|
setupAudioSource(audioConfig, config) {
|
|
15023
15894
|
if (!audioConfig) {
|
|
@@ -15119,9 +15990,13 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
15119
15990
|
format = DEFAULT_OUTPUT_FORMAT;
|
|
15120
15991
|
}
|
|
15121
15992
|
this.validateFormat(format);
|
|
15122
|
-
|
|
15123
|
-
this.sendEncoderAcceleration(this.resolvedHardwareAcceleration);
|
|
15993
|
+
const hwAccelPromise = this.resolveHardwareAcceleration(config);
|
|
15124
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);
|
|
15125
16000
|
this.createVideoSource(config, this.resolvedHardwareAcceleration);
|
|
15126
16001
|
if (videoStream) {
|
|
15127
16002
|
this.setupVideoProcessingFromStream(videoStream);
|
|
@@ -15135,9 +16010,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
15135
16010
|
this.setupAudioSource(audioConfig, config);
|
|
15136
16011
|
}
|
|
15137
16012
|
const output = requireNonNull(this.output, "Output must be initialized before starting");
|
|
15138
|
-
if (this.config?.watermark) {
|
|
15139
|
-
this.frameCompositor.prepareWatermark(this.config);
|
|
15140
|
-
}
|
|
15141
16013
|
await output.start();
|
|
15142
16014
|
this.bufferTracker.start();
|
|
15143
16015
|
this.sendReady();
|
|
@@ -15213,7 +16085,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
15213
16085
|
const lastAudioTimestamp = this.audioState.getLastAudioTimestamp();
|
|
15214
16086
|
const frameTiming = this.timestampManager.prepareFrameTiming({
|
|
15215
16087
|
frameTimestamp,
|
|
15216
|
-
keyFrameIntervalSeconds: config.keyFrameInterval,
|
|
15217
16088
|
lastAudioTimestamp
|
|
15218
16089
|
});
|
|
15219
16090
|
const sample = new VideoSample(compositionResult.frameToProcess, {
|
|
@@ -15717,7 +16588,6 @@ Mediabunny was loaded twice.\` + " This will likely cause Mediabunny not to work
|
|
|
15717
16588
|
this.expectedAudioChannels = null;
|
|
15718
16589
|
this.expectedAudioSampleRate = null;
|
|
15719
16590
|
this.pendingWriteCount = 0;
|
|
15720
|
-
this.resolvedHardwareAcceleration = VIDEO_HARDWARE_ACCELERATION_PREFERENCE;
|
|
15721
16591
|
this.consecutiveFrameErrors = 0;
|
|
15722
16592
|
this.totalFrameErrors = 0;
|
|
15723
16593
|
this.totalFramesProcessed = 0;
|
|
@@ -15941,6 +16811,7 @@ class WorkerProcessor {
|
|
|
15941
16811
|
isPaused = false;
|
|
15942
16812
|
overlayConfig = null;
|
|
15943
16813
|
readyPromiseResolve = null;
|
|
16814
|
+
lastConfigFps = DEFAULT_SWITCH_SOURCE_FPS;
|
|
15944
16815
|
workerProbeManager;
|
|
15945
16816
|
canUseMainThreadVideoProcessorFn;
|
|
15946
16817
|
createVideoStreamFromTrackFn;
|
|
@@ -16126,6 +16997,9 @@ class WorkerProcessor {
|
|
|
16126
16997
|
bitrate: config.bitrate
|
|
16127
16998
|
});
|
|
16128
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
|
+
}
|
|
16129
17003
|
const videoTracks = stream.getVideoTracks();
|
|
16130
17004
|
const audioTracks = stream.getAudioTracks();
|
|
16131
17005
|
logger.debug("[WorkerProcessor] Preparing to start processing", {
|
|
@@ -16285,6 +17159,7 @@ class WorkerProcessor {
|
|
|
16285
17159
|
audioBitrate,
|
|
16286
17160
|
codec,
|
|
16287
17161
|
keyFrameInterval: KEY_FRAME_INTERVAL_SECONDS,
|
|
17162
|
+
latencyMode: config.latencyMode,
|
|
16288
17163
|
format,
|
|
16289
17164
|
watermark: config.watermark
|
|
16290
17165
|
};
|
|
@@ -16406,7 +17281,7 @@ class WorkerProcessor {
|
|
|
16406
17281
|
return Promise.resolve();
|
|
16407
17282
|
}
|
|
16408
17283
|
const isScreenCapture = isScreenCaptureStream(newStream);
|
|
16409
|
-
const targetFps =
|
|
17284
|
+
const targetFps = this.lastConfigFps;
|
|
16410
17285
|
logger.debug("[WorkerProcessor] Source type detected", {
|
|
16411
17286
|
isScreenCapture,
|
|
16412
17287
|
targetFps
|
|
@@ -16620,6 +17495,21 @@ class WorkerProcessor {
|
|
|
16620
17495
|
this.totalSize = 0;
|
|
16621
17496
|
return Promise.resolve();
|
|
16622
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
|
+
}
|
|
16623
17513
|
getBufferSize() {
|
|
16624
17514
|
return this.totalSize;
|
|
16625
17515
|
}
|
|
@@ -16932,6 +17822,9 @@ class StreamProcessor {
|
|
|
16932
17822
|
setOnError(callback) {
|
|
16933
17823
|
this.onError = callback;
|
|
16934
17824
|
}
|
|
17825
|
+
warmupEncoder(config) {
|
|
17826
|
+
this.workerProcessor.warmupEncoder(config);
|
|
17827
|
+
}
|
|
16935
17828
|
async cancel() {
|
|
16936
17829
|
await this.workerProcessor.cancel();
|
|
16937
17830
|
this.workerProcessor.cleanup();
|
|
@@ -17008,8 +17901,11 @@ class RecordingManager {
|
|
|
17008
17901
|
getOriginalCameraStream() {
|
|
17009
17902
|
return this.originalCameraStream;
|
|
17010
17903
|
}
|
|
17011
|
-
prewarmStreamProcessor() {
|
|
17012
|
-
this.getOrCreateStreamProcessor();
|
|
17904
|
+
prewarmStreamProcessor(config) {
|
|
17905
|
+
const processor = this.getOrCreateStreamProcessor();
|
|
17906
|
+
if (config) {
|
|
17907
|
+
processor.warmupEncoder(config);
|
|
17908
|
+
}
|
|
17013
17909
|
}
|
|
17014
17910
|
async startRecording() {
|
|
17015
17911
|
try {
|
|
@@ -17392,6 +18288,7 @@ class RecorderController {
|
|
|
17392
18288
|
enableTabVisibilityOverlay = false;
|
|
17393
18289
|
tabVisibilityOverlayText;
|
|
17394
18290
|
recordingWarmupTimeoutId = null;
|
|
18291
|
+
audioTelemetryUnsub = null;
|
|
17395
18292
|
constructor(callbacks = {}) {
|
|
17396
18293
|
this.callbacks = callbacks;
|
|
17397
18294
|
this.streamManager = new CameraStreamManager;
|
|
@@ -17443,6 +18340,14 @@ class RecorderController {
|
|
|
17443
18340
|
}
|
|
17444
18341
|
});
|
|
17445
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
|
+
});
|
|
17446
18351
|
}
|
|
17447
18352
|
async initialize(config) {
|
|
17448
18353
|
if (this.isInitialized) {
|
|
@@ -17485,7 +18390,9 @@ class RecorderController {
|
|
|
17485
18390
|
logger.debug(`${LOGGER_PREFIX} startStream called`);
|
|
17486
18391
|
await this.streamManager.startStream();
|
|
17487
18392
|
this.ignorePromiseRejection(this.ensureConfigReady());
|
|
17488
|
-
this.
|
|
18393
|
+
this.ignorePromiseRejection(this.configManager.getConfig().then((config) => {
|
|
18394
|
+
this.recordingManager.prewarmStreamProcessor(config);
|
|
18395
|
+
}));
|
|
17489
18396
|
logger.debug(`${LOGGER_PREFIX} startStream completed`);
|
|
17490
18397
|
},
|
|
17491
18398
|
properties: {
|
|
@@ -17510,6 +18417,7 @@ class RecorderController {
|
|
|
17510
18417
|
failedEvent: "recording.start.failed",
|
|
17511
18418
|
action: async () => {
|
|
17512
18419
|
await this.ensureConfigReady();
|
|
18420
|
+
await this.streamManager.waitForAudio();
|
|
17513
18421
|
await this.recordingManager.startRecording();
|
|
17514
18422
|
},
|
|
17515
18423
|
properties: {
|
|
@@ -17656,6 +18564,10 @@ class RecorderController {
|
|
|
17656
18564
|
clearTimeout(this.recordingWarmupTimeoutId);
|
|
17657
18565
|
this.recordingWarmupTimeoutId = null;
|
|
17658
18566
|
}
|
|
18567
|
+
if (this.audioTelemetryUnsub) {
|
|
18568
|
+
this.audioTelemetryUnsub();
|
|
18569
|
+
this.audioTelemetryUnsub = null;
|
|
18570
|
+
}
|
|
17659
18571
|
this.uploadQueueManager?.destroy();
|
|
17660
18572
|
this.storageManager.destroy();
|
|
17661
18573
|
this.recordingManager.cleanup();
|
|
@@ -17695,6 +18607,19 @@ class RecorderController {
|
|
|
17695
18607
|
isActive() {
|
|
17696
18608
|
return this.streamManager.isActive();
|
|
17697
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
|
+
}
|
|
17698
18623
|
async initializeConfig(apiKey, backendUrl) {
|
|
17699
18624
|
let shouldInitializeConfig = true;
|
|
17700
18625
|
if (apiKey === null) {
|
|
@@ -17796,8 +18721,8 @@ class RecorderController {
|
|
|
17796
18721
|
return;
|
|
17797
18722
|
}
|
|
17798
18723
|
this.ignorePromiseRejection(this.ensureConfigReady());
|
|
17799
|
-
this.ignorePromiseRejection(
|
|
17800
|
-
this.recordingManager.prewarmStreamProcessor();
|
|
18724
|
+
this.ignorePromiseRejection(this.configManager.getConfig().then((config) => {
|
|
18725
|
+
this.recordingManager.prewarmStreamProcessor(config);
|
|
17801
18726
|
}));
|
|
17802
18727
|
}, RECORDING_WARMUP_DELAY_MILLISECONDS);
|
|
17803
18728
|
}
|
|
@@ -17959,6 +18884,18 @@ function getCameraErrorText(errorCode, translations) {
|
|
|
17959
18884
|
}
|
|
17960
18885
|
return translations.failedToStartCamera;
|
|
17961
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
|
+
}
|
|
17962
18899
|
function formatDynamicBrowserUnsupportedText(template, browserName, browserVersion) {
|
|
17963
18900
|
let resolvedBrowserName = FALLBACK_BROWSER_NAME;
|
|
17964
18901
|
if (browserName && browserName.trim().length > 0) {
|
|
@@ -18012,12 +18949,13 @@ function parseBrowserErrorLinkContent(text) {
|
|
|
18012
18949
|
// src/core/utils/device-detection.ts
|
|
18013
18950
|
import { UAParser as UAParser3 } from "ua-parser-js";
|
|
18014
18951
|
function isMobileDevice2() {
|
|
18015
|
-
const
|
|
18952
|
+
const userAgent = globalThis.navigator && typeof globalThis.navigator.userAgent === "string" ? globalThis.navigator.userAgent : "";
|
|
18953
|
+
const parser = new UAParser3(userAgent);
|
|
18016
18954
|
const result = parser.getResult();
|
|
18017
18955
|
const deviceType = result.device.type;
|
|
18018
18956
|
const isMobile = deviceType === "mobile" || deviceType === "tablet";
|
|
18019
18957
|
logger.debug("Mobile detection result", {
|
|
18020
|
-
userAgent
|
|
18958
|
+
userAgent,
|
|
18021
18959
|
deviceType,
|
|
18022
18960
|
isMobile,
|
|
18023
18961
|
device: result.device,
|
|
@@ -18026,6 +18964,80 @@ function isMobileDevice2() {
|
|
|
18026
18964
|
});
|
|
18027
18965
|
return isMobile;
|
|
18028
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
|
+
}
|
|
18029
19041
|
// src/vidtreo-recorder.ts
|
|
18030
19042
|
class VidtreoRecorder {
|
|
18031
19043
|
controller;
|
|
@@ -18231,6 +19243,7 @@ export {
|
|
|
18231
19243
|
validateBrowserSupport,
|
|
18232
19244
|
transcodeVideoForNativeCamera,
|
|
18233
19245
|
transcodeVideo,
|
|
19246
|
+
resolveDeviceError,
|
|
18234
19247
|
requireStream,
|
|
18235
19248
|
requireProcessor,
|
|
18236
19249
|
requireNonNull,
|
|
@@ -18252,12 +19265,14 @@ export {
|
|
|
18252
19265
|
getBrowserName,
|
|
18253
19266
|
getBrowserInfo,
|
|
18254
19267
|
getBrowserErrorText,
|
|
19268
|
+
getAudioErrorText,
|
|
18255
19269
|
getAudioCodecForFormat,
|
|
18256
19270
|
formatTime,
|
|
18257
19271
|
formatFileSize,
|
|
18258
19272
|
extractVideoDuration,
|
|
18259
19273
|
extractLastFrame,
|
|
18260
19274
|
extractErrorMessage,
|
|
19275
|
+
createPermissionFlowOrchestratorDependencies,
|
|
18261
19276
|
checkRecorderSupport,
|
|
18262
19277
|
calculateVideoBitrate,
|
|
18263
19278
|
calculateTotalBitrateFromMbPerMinute,
|
|
@@ -18271,6 +19286,7 @@ export {
|
|
|
18271
19286
|
RecordingManager,
|
|
18272
19287
|
RecorderController,
|
|
18273
19288
|
QuotaManager,
|
|
19289
|
+
PermissionFlowOrchestrator,
|
|
18274
19290
|
PRESET_SIZE_LIMIT_MB_PER_MINUTE,
|
|
18275
19291
|
NativeCameraHandler,
|
|
18276
19292
|
FORMAT_DEFAULT_CODECS,
|