@vidtreo/recorder 0.9.1 → 0.9.3
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 +75 -21
- package/dist/index.js +604 -235
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
7
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
8
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
9
|
+
for (let key of __getOwnPropNames(mod))
|
|
10
|
+
if (!__hasOwnProp.call(to, key))
|
|
11
|
+
__defProp(to, key, {
|
|
12
|
+
get: () => mod[key],
|
|
13
|
+
enumerable: true
|
|
14
|
+
});
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
18
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
19
|
+
}) : x)(function(x) {
|
|
20
|
+
if (typeof require !== "undefined")
|
|
21
|
+
return require.apply(this, arguments);
|
|
22
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
23
|
+
});
|
|
24
|
+
|
|
1
25
|
// src/core/utils/error-handler.ts
|
|
2
26
|
function extractErrorMessage(error) {
|
|
3
27
|
if (error instanceof Error) {
|
|
@@ -127,6 +151,9 @@ class AudioLevelAnalyzer {
|
|
|
127
151
|
return isMutedFromCallback || hasDisabledTracks;
|
|
128
152
|
}
|
|
129
153
|
}
|
|
154
|
+
// src/core/config/config-constants.ts
|
|
155
|
+
import { QUALITY_HIGH } from "mediabunny";
|
|
156
|
+
|
|
130
157
|
// src/core/processor/format-codec-mapper.ts
|
|
131
158
|
var FORMAT_DEFAULT_CODECS = {
|
|
132
159
|
mp4: "aac",
|
|
@@ -149,13 +176,11 @@ var DEFAULT_BACKEND_URL = "https://api.vidtreo.com";
|
|
|
149
176
|
var DEFAULT_TRANSCODE_CONFIG = Object.freeze({
|
|
150
177
|
format: "mp4",
|
|
151
178
|
fps: 30,
|
|
152
|
-
width:
|
|
153
|
-
height:
|
|
154
|
-
bitrate:
|
|
155
|
-
audioCodec:
|
|
156
|
-
audioBitrate:
|
|
157
|
-
preset: "medium",
|
|
158
|
-
packetCount: 1200
|
|
179
|
+
width: 1920,
|
|
180
|
+
height: 1080,
|
|
181
|
+
bitrate: QUALITY_HIGH,
|
|
182
|
+
audioCodec: "aac",
|
|
183
|
+
audioBitrate: 96000
|
|
159
184
|
});
|
|
160
185
|
function getDefaultConfigForFormat(format) {
|
|
161
186
|
return {
|
|
@@ -165,23 +190,21 @@ function getDefaultConfigForFormat(format) {
|
|
|
165
190
|
};
|
|
166
191
|
}
|
|
167
192
|
// src/core/config/preset-mapper.ts
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
193
|
+
import {
|
|
194
|
+
QUALITY_HIGH as QUALITY_HIGH2,
|
|
195
|
+
QUALITY_LOW,
|
|
196
|
+
QUALITY_MEDIUM,
|
|
197
|
+
QUALITY_VERY_HIGH
|
|
198
|
+
} from "mediabunny";
|
|
199
|
+
var QUALITY_MAP = {
|
|
200
|
+
sd: QUALITY_LOW,
|
|
201
|
+
hd: QUALITY_MEDIUM,
|
|
202
|
+
fhd: QUALITY_HIGH2,
|
|
203
|
+
"4k": QUALITY_VERY_HIGH
|
|
173
204
|
};
|
|
174
205
|
var AUDIO_BITRATE = 128000;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
hd: 1200,
|
|
178
|
-
fhd: 2000,
|
|
179
|
-
"4k": 4000
|
|
180
|
-
};
|
|
181
|
-
var DEFAULT_FPS = 30;
|
|
182
|
-
var DEFAULT_PRESET = "medium";
|
|
183
|
-
function mapPresetToConfig(preset, maxWidth, maxHeight, format = "mp4") {
|
|
184
|
-
if (!(preset in BITRATE_MAP)) {
|
|
206
|
+
function mapPresetToConfig(preset, maxWidth, maxHeight, outputFormat) {
|
|
207
|
+
if (!(preset in QUALITY_MAP)) {
|
|
185
208
|
throw new Error(`Invalid preset: ${preset}`);
|
|
186
209
|
}
|
|
187
210
|
if (typeof maxWidth !== "number" || maxWidth <= 0) {
|
|
@@ -190,16 +213,14 @@ function mapPresetToConfig(preset, maxWidth, maxHeight, format = "mp4") {
|
|
|
190
213
|
if (typeof maxHeight !== "number" || maxHeight <= 0) {
|
|
191
214
|
throw new Error("maxHeight must be a positive number");
|
|
192
215
|
}
|
|
216
|
+
const format = outputFormat || "mp4";
|
|
193
217
|
const audioCodec = getDefaultAudioCodecForFormat(format);
|
|
194
218
|
return {
|
|
195
219
|
format,
|
|
196
|
-
fps: DEFAULT_FPS,
|
|
197
220
|
width: maxWidth,
|
|
198
221
|
height: maxHeight,
|
|
199
|
-
bitrate:
|
|
222
|
+
bitrate: QUALITY_MAP[preset],
|
|
200
223
|
audioCodec,
|
|
201
|
-
preset: DEFAULT_PRESET,
|
|
202
|
-
packetCount: PACKET_COUNT_MAP[preset],
|
|
203
224
|
audioBitrate: AUDIO_BITRATE
|
|
204
225
|
};
|
|
205
226
|
}
|
|
@@ -290,7 +311,7 @@ class ConfigService {
|
|
|
290
311
|
if (!data.presetEncoding || typeof data.max_width !== "number" || typeof data.max_height !== "number") {
|
|
291
312
|
throw new Error("Invalid config response from backend");
|
|
292
313
|
}
|
|
293
|
-
return mapPresetToConfig(data.presetEncoding, data.max_width, data.max_height);
|
|
314
|
+
return mapPresetToConfig(data.presetEncoding, data.max_width, data.max_height, data.outputFormat);
|
|
294
315
|
}
|
|
295
316
|
}
|
|
296
317
|
|
|
@@ -311,8 +332,12 @@ class ConfigManager {
|
|
|
311
332
|
apiKey,
|
|
312
333
|
backendUrl: normalizedBackendUrl
|
|
313
334
|
});
|
|
314
|
-
this.
|
|
315
|
-
|
|
335
|
+
this.configService.fetchConfig().then((config) => {
|
|
336
|
+
this.currentConfig = config;
|
|
337
|
+
this.configFetched = true;
|
|
338
|
+
}).catch(() => {
|
|
339
|
+
this.configFetched = false;
|
|
340
|
+
});
|
|
316
341
|
}
|
|
317
342
|
async fetchConfig() {
|
|
318
343
|
if (!this.configService) {
|
|
@@ -379,6 +404,406 @@ class DeviceManager {
|
|
|
379
404
|
return this.availableDevices;
|
|
380
405
|
}
|
|
381
406
|
}
|
|
407
|
+
// src/core/utils/video-utils.ts
|
|
408
|
+
import { BlobSource, Input, MP4 } from "mediabunny";
|
|
409
|
+
async function extractVideoDuration(file) {
|
|
410
|
+
try {
|
|
411
|
+
const source = new BlobSource(file);
|
|
412
|
+
const input = new Input({
|
|
413
|
+
formats: [MP4],
|
|
414
|
+
source
|
|
415
|
+
});
|
|
416
|
+
if (typeof input.computeDuration !== "function") {
|
|
417
|
+
throw new Error("computeDuration method is not available");
|
|
418
|
+
}
|
|
419
|
+
const duration = await input.computeDuration();
|
|
420
|
+
if (!duration) {
|
|
421
|
+
throw new Error("Duration is missing from computeDuration");
|
|
422
|
+
}
|
|
423
|
+
if (duration <= 0) {
|
|
424
|
+
throw new Error("Invalid duration: must be greater than 0");
|
|
425
|
+
}
|
|
426
|
+
return duration;
|
|
427
|
+
} catch {
|
|
428
|
+
return extractDurationWithVideoElement(file);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function extractDurationWithVideoElement(blob) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const video = document.createElement("video");
|
|
434
|
+
const url = URL.createObjectURL(blob);
|
|
435
|
+
const cleanup = () => {
|
|
436
|
+
URL.revokeObjectURL(url);
|
|
437
|
+
};
|
|
438
|
+
video.addEventListener("loadedmetadata", () => {
|
|
439
|
+
cleanup();
|
|
440
|
+
const duration = video.duration;
|
|
441
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
442
|
+
reject(new Error("Invalid video duration"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
resolve(duration);
|
|
446
|
+
});
|
|
447
|
+
video.addEventListener("error", () => {
|
|
448
|
+
cleanup();
|
|
449
|
+
reject(new Error("Failed to load video metadata"));
|
|
450
|
+
});
|
|
451
|
+
video.src = url;
|
|
452
|
+
video.load();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/core/native-camera/file-validator.ts
|
|
457
|
+
var DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024;
|
|
458
|
+
var ALLOWED_FORMATS = ["video/mp4", "video/quicktime", "video/webm"];
|
|
459
|
+
async function validateFile(file, config = {}) {
|
|
460
|
+
const maxSize = config.maxFileSize !== undefined ? config.maxFileSize : DEFAULT_MAX_FILE_SIZE;
|
|
461
|
+
if (file.size > maxSize) {
|
|
462
|
+
const error = `File too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)}MB`;
|
|
463
|
+
return { valid: false, error };
|
|
464
|
+
}
|
|
465
|
+
const allowedFormats = config.allowedFormats !== undefined ? config.allowedFormats : ALLOWED_FORMATS;
|
|
466
|
+
if (!allowedFormats.includes(file.type)) {
|
|
467
|
+
const error = `Unsupported format. Please use: ${allowedFormats.join(", ")}`;
|
|
468
|
+
return { valid: false, error };
|
|
469
|
+
}
|
|
470
|
+
const durationResult = await extractVideoDuration(file).then((videoDuration) => ({
|
|
471
|
+
success: true,
|
|
472
|
+
duration: videoDuration
|
|
473
|
+
})).catch((error) => ({
|
|
474
|
+
success: false,
|
|
475
|
+
error: extractErrorMessage(error)
|
|
476
|
+
}));
|
|
477
|
+
if (durationResult.success === false) {
|
|
478
|
+
return {
|
|
479
|
+
valid: false,
|
|
480
|
+
error: `Failed to read video file: ${durationResult.error}`
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const duration = durationResult.duration;
|
|
484
|
+
if (config.maxRecordingTime !== null && config.maxRecordingTime !== undefined && duration > config.maxRecordingTime) {
|
|
485
|
+
const error = `Video too long. Maximum duration is ${config.maxRecordingTime} seconds`;
|
|
486
|
+
return { valid: false, error };
|
|
487
|
+
}
|
|
488
|
+
return { valid: true };
|
|
489
|
+
}
|
|
490
|
+
// src/core/transcode/video-transcoder.ts
|
|
491
|
+
import {
|
|
492
|
+
ALL_FORMATS,
|
|
493
|
+
BlobSource as BlobSource2,
|
|
494
|
+
BufferTarget,
|
|
495
|
+
Conversion,
|
|
496
|
+
FilePathSource,
|
|
497
|
+
Input as Input2,
|
|
498
|
+
MkvOutputFormat,
|
|
499
|
+
MovOutputFormat,
|
|
500
|
+
Mp4OutputFormat,
|
|
501
|
+
Output,
|
|
502
|
+
WebMOutputFormat
|
|
503
|
+
} from "mediabunny";
|
|
504
|
+
function createSource(input) {
|
|
505
|
+
if (typeof input === "string") {
|
|
506
|
+
return new FilePathSource(input);
|
|
507
|
+
}
|
|
508
|
+
if (input instanceof Blob) {
|
|
509
|
+
return new BlobSource2(input);
|
|
510
|
+
}
|
|
511
|
+
throw new Error("Invalid input type. Expected Blob, File, or file path string.");
|
|
512
|
+
}
|
|
513
|
+
function createOutputFormat(format) {
|
|
514
|
+
switch (format) {
|
|
515
|
+
case "mp4":
|
|
516
|
+
return new Mp4OutputFormat;
|
|
517
|
+
case "webm":
|
|
518
|
+
return new WebMOutputFormat;
|
|
519
|
+
case "mkv":
|
|
520
|
+
return new MkvOutputFormat;
|
|
521
|
+
case "mov":
|
|
522
|
+
return new MovOutputFormat;
|
|
523
|
+
default:
|
|
524
|
+
throw new Error(`Unsupported output format: ${format}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function getMimeTypeForFormat(format) {
|
|
528
|
+
switch (format) {
|
|
529
|
+
case "mp4":
|
|
530
|
+
return "video/mp4";
|
|
531
|
+
case "webm":
|
|
532
|
+
return "video/webm";
|
|
533
|
+
case "mkv":
|
|
534
|
+
return "video/x-matroska";
|
|
535
|
+
case "mov":
|
|
536
|
+
return "video/quicktime";
|
|
537
|
+
default:
|
|
538
|
+
throw new Error(`Unsupported output format: ${format}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function createConversionOptions(config, optimizeForSpeed = false) {
|
|
542
|
+
const audioCodec = getAudioCodecForFormat(config.format, config.audioCodec);
|
|
543
|
+
const video = {
|
|
544
|
+
fit: "contain",
|
|
545
|
+
forceTranscode: true
|
|
546
|
+
};
|
|
547
|
+
if (config.width !== undefined) {
|
|
548
|
+
video.width = config.width;
|
|
549
|
+
}
|
|
550
|
+
if (config.height !== undefined) {
|
|
551
|
+
video.height = config.height;
|
|
552
|
+
}
|
|
553
|
+
if (config.fps !== undefined) {
|
|
554
|
+
video.frameRate = config.fps;
|
|
555
|
+
}
|
|
556
|
+
if (config.bitrate !== undefined) {
|
|
557
|
+
video.bitrate = config.bitrate;
|
|
558
|
+
}
|
|
559
|
+
if (config.codec !== undefined) {
|
|
560
|
+
video.codec = config.codec;
|
|
561
|
+
}
|
|
562
|
+
if (optimizeForSpeed) {
|
|
563
|
+
video.bitrateMode = "variable";
|
|
564
|
+
video.latencyMode = "realtime";
|
|
565
|
+
video.hardwareAcceleration = "prefer-hardware";
|
|
566
|
+
video.keyFrameInterval = 2;
|
|
567
|
+
}
|
|
568
|
+
const audio = {
|
|
569
|
+
codec: audioCodec,
|
|
570
|
+
forceTranscode: true,
|
|
571
|
+
...optimizeForSpeed && { bitrateMode: "variable" }
|
|
572
|
+
};
|
|
573
|
+
return { video, audio };
|
|
574
|
+
}
|
|
575
|
+
function validateConversion(conversion) {
|
|
576
|
+
if (!conversion.isValid) {
|
|
577
|
+
const reasons = conversion.discardedTracks.map((track) => track.reason).join(", ");
|
|
578
|
+
throw new Error(`Conversion is invalid. Discarded tracks: ${reasons}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function transcodeVideo(input, config = {}, onProgress) {
|
|
582
|
+
const finalConfig = {
|
|
583
|
+
...DEFAULT_TRANSCODE_CONFIG,
|
|
584
|
+
...config,
|
|
585
|
+
format: config.format || DEFAULT_TRANSCODE_CONFIG.format
|
|
586
|
+
};
|
|
587
|
+
if (!finalConfig.audioCodec) {
|
|
588
|
+
finalConfig.audioCodec = getDefaultAudioCodecForFormat(finalConfig.format);
|
|
589
|
+
}
|
|
590
|
+
const source = createSource(input);
|
|
591
|
+
const mediabunnyInput = new Input2({
|
|
592
|
+
formats: ALL_FORMATS,
|
|
593
|
+
source
|
|
594
|
+
});
|
|
595
|
+
const outputFormat = createOutputFormat(finalConfig.format);
|
|
596
|
+
const output = new Output({
|
|
597
|
+
format: outputFormat,
|
|
598
|
+
target: new BufferTarget
|
|
599
|
+
});
|
|
600
|
+
const conversion = await Conversion.init({
|
|
601
|
+
input: mediabunnyInput,
|
|
602
|
+
output,
|
|
603
|
+
...createConversionOptions(finalConfig)
|
|
604
|
+
});
|
|
605
|
+
validateConversion(conversion);
|
|
606
|
+
if (onProgress) {
|
|
607
|
+
conversion.onProgress = onProgress;
|
|
608
|
+
}
|
|
609
|
+
await conversion.execute();
|
|
610
|
+
const buffer = output.target.buffer;
|
|
611
|
+
if (!buffer) {
|
|
612
|
+
throw new Error("Transcoding completed but no output buffer was generated");
|
|
613
|
+
}
|
|
614
|
+
const mimeType = getMimeTypeForFormat(finalConfig.format);
|
|
615
|
+
return {
|
|
616
|
+
buffer,
|
|
617
|
+
blob: new Blob([buffer], { type: mimeType })
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
async function transcodeVideoForNativeCamera(file, config = {}, onProgress) {
|
|
621
|
+
const finalConfig = {
|
|
622
|
+
...DEFAULT_TRANSCODE_CONFIG,
|
|
623
|
+
...config,
|
|
624
|
+
format: config.format || DEFAULT_TRANSCODE_CONFIG.format
|
|
625
|
+
};
|
|
626
|
+
if (!finalConfig.audioCodec) {
|
|
627
|
+
finalConfig.audioCodec = getDefaultAudioCodecForFormat(finalConfig.format);
|
|
628
|
+
}
|
|
629
|
+
const source = new BlobSource2(file);
|
|
630
|
+
const mediabunnyInput = new Input2({
|
|
631
|
+
formats: ALL_FORMATS,
|
|
632
|
+
source
|
|
633
|
+
});
|
|
634
|
+
const outputFormat = createOutputFormat(finalConfig.format);
|
|
635
|
+
const output = new Output({
|
|
636
|
+
format: outputFormat,
|
|
637
|
+
target: new BufferTarget
|
|
638
|
+
});
|
|
639
|
+
const conversion = await Conversion.init({
|
|
640
|
+
input: mediabunnyInput,
|
|
641
|
+
output,
|
|
642
|
+
video: {
|
|
643
|
+
codec: finalConfig.codec,
|
|
644
|
+
width: finalConfig.width,
|
|
645
|
+
height: finalConfig.height,
|
|
646
|
+
frameRate: finalConfig.fps,
|
|
647
|
+
bitrate: finalConfig.bitrate,
|
|
648
|
+
fit: "contain"
|
|
649
|
+
},
|
|
650
|
+
audio: {
|
|
651
|
+
codec: finalConfig.audioCodec
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
validateConversion(conversion);
|
|
655
|
+
if (onProgress) {
|
|
656
|
+
conversion.onProgress = onProgress;
|
|
657
|
+
}
|
|
658
|
+
await conversion.execute();
|
|
659
|
+
const buffer = output.target.buffer;
|
|
660
|
+
if (!buffer) {
|
|
661
|
+
throw new Error("Transcoding completed but no output buffer was generated");
|
|
662
|
+
}
|
|
663
|
+
const mimeType = getMimeTypeForFormat(finalConfig.format);
|
|
664
|
+
return {
|
|
665
|
+
buffer,
|
|
666
|
+
blob: new Blob([buffer], { type: mimeType })
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/core/native-camera/preview-extractor.ts
|
|
671
|
+
function extractLastFrame(file, timeoutMs = 1e4) {
|
|
672
|
+
return new Promise((resolve, reject) => {
|
|
673
|
+
const video = document.createElement("video");
|
|
674
|
+
video.preload = "metadata";
|
|
675
|
+
video.muted = true;
|
|
676
|
+
const timeout = setTimeout(() => {
|
|
677
|
+
cleanup();
|
|
678
|
+
reject(new Error("Preview extraction timeout"));
|
|
679
|
+
}, timeoutMs);
|
|
680
|
+
const cleanup = () => {
|
|
681
|
+
clearTimeout(timeout);
|
|
682
|
+
URL.revokeObjectURL(video.src);
|
|
683
|
+
};
|
|
684
|
+
video.addEventListener("loadedmetadata", () => {
|
|
685
|
+
video.currentTime = Math.max(0, video.duration - 0.1);
|
|
686
|
+
});
|
|
687
|
+
video.addEventListener("seeked", () => {
|
|
688
|
+
const canvas = document.createElement("canvas");
|
|
689
|
+
canvas.width = video.videoWidth;
|
|
690
|
+
canvas.height = video.videoHeight;
|
|
691
|
+
const ctx = canvas.getContext("2d");
|
|
692
|
+
if (!ctx) {
|
|
693
|
+
cleanup();
|
|
694
|
+
reject(new Error("Canvas context unavailable"));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (video.videoWidth === 0 || video.videoHeight === 0) {
|
|
698
|
+
cleanup();
|
|
699
|
+
reject(new Error("Invalid video dimensions"));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (!(Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight))) {
|
|
703
|
+
cleanup();
|
|
704
|
+
reject(new Error("Invalid video dimensions"));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
708
|
+
canvas.toBlob((blob) => {
|
|
709
|
+
cleanup();
|
|
710
|
+
if (blob) {
|
|
711
|
+
resolve(blob);
|
|
712
|
+
} else {
|
|
713
|
+
reject(new Error("Failed to create preview blob"));
|
|
714
|
+
}
|
|
715
|
+
}, "image/jpeg", 0.8);
|
|
716
|
+
});
|
|
717
|
+
video.addEventListener("error", () => {
|
|
718
|
+
cleanup();
|
|
719
|
+
reject(new Error("Failed to load video for preview extraction"));
|
|
720
|
+
});
|
|
721
|
+
const videoUrl = URL.createObjectURL(file);
|
|
722
|
+
video.src = videoUrl;
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/core/native-camera/native-camera-handler.ts
|
|
727
|
+
class NativeCameraHandler {
|
|
728
|
+
pendingFile = null;
|
|
729
|
+
configService = null;
|
|
730
|
+
uploadService;
|
|
731
|
+
config;
|
|
732
|
+
constructor(config, configService, uploadService) {
|
|
733
|
+
this.config = config;
|
|
734
|
+
this.configService = configService;
|
|
735
|
+
this.uploadService = uploadService;
|
|
736
|
+
}
|
|
737
|
+
async handleFileSelection(file) {
|
|
738
|
+
const maxFileSize = this.config.maxFileSize !== undefined ? this.config.maxFileSize : 2 * 1024 * 1024 * 1024;
|
|
739
|
+
const validation = await validateFile(file, {
|
|
740
|
+
maxFileSize,
|
|
741
|
+
maxRecordingTime: this.config.maxRecordingTime,
|
|
742
|
+
allowedFormats: ["video/mp4", "video/quicktime", "video/webm"]
|
|
743
|
+
});
|
|
744
|
+
if (!validation.valid) {
|
|
745
|
+
throw new Error(validation.error);
|
|
746
|
+
}
|
|
747
|
+
const previewBlob = await extractLastFrame(file);
|
|
748
|
+
const previewUrl = URL.createObjectURL(previewBlob);
|
|
749
|
+
const durationResult = await extractVideoDuration(file).then((videoDuration) => ({
|
|
750
|
+
success: true,
|
|
751
|
+
duration: videoDuration
|
|
752
|
+
})).catch((error) => ({
|
|
753
|
+
success: false,
|
|
754
|
+
error: extractErrorMessage(error)
|
|
755
|
+
}));
|
|
756
|
+
if (durationResult.success === false) {
|
|
757
|
+
throw new Error(`Failed to extract video duration: ${durationResult.error}`);
|
|
758
|
+
}
|
|
759
|
+
const duration = durationResult.duration;
|
|
760
|
+
this.pendingFile = file;
|
|
761
|
+
return { file, previewUrl, duration, validated: true };
|
|
762
|
+
}
|
|
763
|
+
async processAndUpload(onTranscodeProgress, onUploadProgress) {
|
|
764
|
+
if (!this.pendingFile) {
|
|
765
|
+
throw new Error("No file selected");
|
|
766
|
+
}
|
|
767
|
+
const transcodeConfig = this.configService ? await this.configService.fetchConfig() : DEFAULT_TRANSCODE_CONFIG;
|
|
768
|
+
const result = await transcodeVideoForNativeCamera(this.pendingFile, transcodeConfig, onTranscodeProgress);
|
|
769
|
+
if (!(this.config.apiKey && this.config.backendUrl)) {
|
|
770
|
+
throw new Error("Upload requires API key and backend URL");
|
|
771
|
+
}
|
|
772
|
+
const durationResult = await extractVideoDuration(result.blob).then((videoDuration) => ({
|
|
773
|
+
success: true,
|
|
774
|
+
duration: videoDuration
|
|
775
|
+
})).catch((error) => ({
|
|
776
|
+
success: false,
|
|
777
|
+
error: extractErrorMessage(error)
|
|
778
|
+
}));
|
|
779
|
+
if (durationResult.success === false) {
|
|
780
|
+
throw new Error(`Failed to extract video duration: ${durationResult.error}`);
|
|
781
|
+
}
|
|
782
|
+
const duration = durationResult.duration;
|
|
783
|
+
const uploadResult = await this.uploadService.uploadVideo(result.blob, {
|
|
784
|
+
apiKey: this.config.apiKey,
|
|
785
|
+
backendUrl: this.config.backendUrl,
|
|
786
|
+
filename: `${Date.now()}.mp4`,
|
|
787
|
+
duration,
|
|
788
|
+
userMetadata: this.config.userMetadata,
|
|
789
|
+
onProgress: onUploadProgress
|
|
790
|
+
});
|
|
791
|
+
this.pendingFile = null;
|
|
792
|
+
return {
|
|
793
|
+
blob: result.blob,
|
|
794
|
+
recordingId: uploadResult.videoId,
|
|
795
|
+
uploadUrl: uploadResult.uploadUrl
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
cancel() {
|
|
799
|
+
this.pendingFile = null;
|
|
800
|
+
}
|
|
801
|
+
async preloadConfig() {
|
|
802
|
+
if (this.configService) {
|
|
803
|
+
await this.configService.fetchConfig();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
382
807
|
// src/core/storage/video-storage.ts
|
|
383
808
|
var DB_NAME = "vidtreo-recorder";
|
|
384
809
|
var DB_VERSION = 1;
|
|
@@ -1379,9 +1804,9 @@ class SourceSwitchManager {
|
|
|
1379
1804
|
|
|
1380
1805
|
// src/core/stream/stream-constants.ts
|
|
1381
1806
|
var DEFAULT_CAMERA_CONSTRAINTS = Object.freeze({
|
|
1382
|
-
width: { ideal: DEFAULT_TRANSCODE_CONFIG.width },
|
|
1383
|
-
height: { ideal: DEFAULT_TRANSCODE_CONFIG.height },
|
|
1384
|
-
frameRate: { ideal: DEFAULT_TRANSCODE_CONFIG.fps }
|
|
1807
|
+
width: { ideal: DEFAULT_TRANSCODE_CONFIG.width || 1920 },
|
|
1808
|
+
height: { ideal: DEFAULT_TRANSCODE_CONFIG.height || 1080 },
|
|
1809
|
+
frameRate: { ideal: DEFAULT_TRANSCODE_CONFIG.fps || 30 }
|
|
1385
1810
|
});
|
|
1386
1811
|
var DEFAULT_STREAM_CONFIG = Object.freeze({
|
|
1387
1812
|
video: DEFAULT_CAMERA_CONSTRAINTS,
|
|
@@ -2587,55 +3012,6 @@ class VideoUploadService {
|
|
|
2587
3012
|
}
|
|
2588
3013
|
}
|
|
2589
3014
|
|
|
2590
|
-
// src/core/utils/video-utils.ts
|
|
2591
|
-
import { BlobSource, Input, MP4 } from "mediabunny";
|
|
2592
|
-
async function extractVideoDuration(blob) {
|
|
2593
|
-
try {
|
|
2594
|
-
const source = new BlobSource(blob);
|
|
2595
|
-
const input = new Input({
|
|
2596
|
-
formats: [MP4],
|
|
2597
|
-
source
|
|
2598
|
-
});
|
|
2599
|
-
if (typeof input.computeDuration !== "function") {
|
|
2600
|
-
throw new Error("computeDuration method is not available");
|
|
2601
|
-
}
|
|
2602
|
-
const duration = await input.computeDuration();
|
|
2603
|
-
if (!duration) {
|
|
2604
|
-
throw new Error("Duration is missing from computeDuration");
|
|
2605
|
-
}
|
|
2606
|
-
if (duration <= 0) {
|
|
2607
|
-
throw new Error("Invalid duration: must be greater than 0");
|
|
2608
|
-
}
|
|
2609
|
-
return duration;
|
|
2610
|
-
} catch {
|
|
2611
|
-
return extractDurationWithVideoElement(blob);
|
|
2612
|
-
}
|
|
2613
|
-
}
|
|
2614
|
-
function extractDurationWithVideoElement(blob) {
|
|
2615
|
-
return new Promise((resolve, reject) => {
|
|
2616
|
-
const video = document.createElement("video");
|
|
2617
|
-
const url = URL.createObjectURL(blob);
|
|
2618
|
-
const cleanup = () => {
|
|
2619
|
-
URL.revokeObjectURL(url);
|
|
2620
|
-
};
|
|
2621
|
-
video.addEventListener("loadedmetadata", () => {
|
|
2622
|
-
cleanup();
|
|
2623
|
-
const duration = video.duration;
|
|
2624
|
-
if (!Number.isFinite(duration) || duration <= 0) {
|
|
2625
|
-
reject(new Error("Invalid video duration"));
|
|
2626
|
-
return;
|
|
2627
|
-
}
|
|
2628
|
-
resolve(duration);
|
|
2629
|
-
});
|
|
2630
|
-
video.addEventListener("error", () => {
|
|
2631
|
-
cleanup();
|
|
2632
|
-
reject(new Error("Failed to load video metadata"));
|
|
2633
|
-
});
|
|
2634
|
-
video.src = url;
|
|
2635
|
-
video.load();
|
|
2636
|
-
});
|
|
2637
|
-
}
|
|
2638
|
-
|
|
2639
3015
|
// src/core/utils/stream-utils.ts
|
|
2640
3016
|
function isScreenCaptureStream(stream) {
|
|
2641
3017
|
const videoTracks = stream.getVideoTracks();
|
|
@@ -2647,6 +3023,30 @@ function isScreenCaptureStream(stream) {
|
|
|
2647
3023
|
return "displaySurface" in settings || videoTrack.label.toLowerCase().includes("screen") || videoTrack.label.toLowerCase().includes("display");
|
|
2648
3024
|
}
|
|
2649
3025
|
|
|
3026
|
+
// src/core/processor/codec-detector.ts
|
|
3027
|
+
async function detectBestCodec(width, height, bitrate) {
|
|
3028
|
+
try {
|
|
3029
|
+
const { canEncodeVideo } = await import("mediabunny");
|
|
3030
|
+
if (typeof canEncodeVideo === "function") {
|
|
3031
|
+
const checkOptions = {};
|
|
3032
|
+
if (width !== undefined) {
|
|
3033
|
+
checkOptions.width = width;
|
|
3034
|
+
}
|
|
3035
|
+
if (height !== undefined) {
|
|
3036
|
+
checkOptions.height = height;
|
|
3037
|
+
}
|
|
3038
|
+
if (bitrate !== undefined) {
|
|
3039
|
+
checkOptions.bitrate = bitrate;
|
|
3040
|
+
}
|
|
3041
|
+
const hevcSupported = await canEncodeVideo("hevc", checkOptions);
|
|
3042
|
+
if (hevcSupported) {
|
|
3043
|
+
return "hevc";
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
} catch {}
|
|
3047
|
+
return "avc";
|
|
3048
|
+
}
|
|
3049
|
+
|
|
2650
3050
|
// src/core/processor/worker/recorder-worker.code.ts
|
|
2651
3051
|
var workerCode = `// ../../node_modules/mediabunny/dist/modules/src/misc.js
|
|
2652
3052
|
/*!
|
|
@@ -8366,6 +8766,7 @@ class Quality {
|
|
|
8366
8766
|
return Math.round(finalBitrate / 1000) * 1000;
|
|
8367
8767
|
}
|
|
8368
8768
|
}
|
|
8769
|
+
var QUALITY_HIGH = /* @__PURE__ */ new Quality(2);
|
|
8369
8770
|
|
|
8370
8771
|
// ../../node_modules/mediabunny/dist/modules/src/media-source.js
|
|
8371
8772
|
/*!
|
|
@@ -9731,6 +10132,8 @@ class RecorderWorker {
|
|
|
9731
10132
|
baseVideoTimestamp = null;
|
|
9732
10133
|
frameCount = 0;
|
|
9733
10134
|
config = null;
|
|
10135
|
+
lastKeyFrameTimestamp = 0;
|
|
10136
|
+
forceNextKeyFrame = false;
|
|
9734
10137
|
videoProcessingActive = false;
|
|
9735
10138
|
audioProcessingActive = false;
|
|
9736
10139
|
isStopping = false;
|
|
@@ -9819,54 +10222,50 @@ class RecorderWorker {
|
|
|
9819
10222
|
}
|
|
9820
10223
|
this.sendError(new Error(\`Unknown message type: \${message.type}\`));
|
|
9821
10224
|
};
|
|
9822
|
-
|
|
10225
|
+
validateConfig(config) {
|
|
9823
10226
|
requireDefined(config, "Transcode config is required");
|
|
9824
|
-
if (config.width
|
|
9825
|
-
throw new Error("Video
|
|
10227
|
+
if (config.width !== undefined && config.width <= 0) {
|
|
10228
|
+
throw new Error("Video width must be greater than zero");
|
|
9826
10229
|
}
|
|
9827
|
-
if (config.
|
|
10230
|
+
if (config.height !== undefined && config.height <= 0) {
|
|
10231
|
+
throw new Error("Video height must be greater than zero");
|
|
10232
|
+
}
|
|
10233
|
+
if (config.fps !== undefined && config.fps <= 0) {
|
|
9828
10234
|
throw new Error("Frame rate must be greater than zero");
|
|
9829
10235
|
}
|
|
9830
|
-
if (config.bitrate <= 0) {
|
|
10236
|
+
if (config.bitrate !== undefined && typeof config.bitrate === "number" && config.bitrate <= 0) {
|
|
9831
10237
|
throw new Error("Bitrate must be greater than zero");
|
|
9832
10238
|
}
|
|
9833
10239
|
if (config.keyFrameInterval <= 0) {
|
|
9834
10240
|
throw new Error("Key frame interval must be greater than zero");
|
|
9835
10241
|
}
|
|
9836
|
-
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
|
|
9840
|
-
width: config.width,
|
|
9841
|
-
height: config.height,
|
|
9842
|
-
fps: config.fps,
|
|
9843
|
-
bitrate: config.bitrate
|
|
9844
|
-
},
|
|
9845
|
-
hasOverlayConfig: !!overlayConfig,
|
|
9846
|
-
overlayConfig
|
|
9847
|
-
});
|
|
9848
|
-
this.isStopping = false;
|
|
9849
|
-
this.isFinalized = false;
|
|
9850
|
-
if (this.output) {
|
|
9851
|
-
logger.debug("[RecorderWorker] Cleaning up existing output");
|
|
9852
|
-
await this.cleanup();
|
|
10242
|
+
}
|
|
10243
|
+
validateFormat(format) {
|
|
10244
|
+
if (format !== "mp4") {
|
|
10245
|
+
throw new Error(\`Format \${format} is not yet supported in worker. Only MP4 is currently supported.\`);
|
|
9853
10246
|
}
|
|
10247
|
+
}
|
|
10248
|
+
initializeRecordingState(config) {
|
|
9854
10249
|
this.config = config;
|
|
9855
|
-
this.frameRate = config.fps;
|
|
10250
|
+
this.frameRate = config.fps || 30;
|
|
9856
10251
|
this.isPaused = false;
|
|
9857
10252
|
this.isMuted = false;
|
|
9858
10253
|
this.lastVideoTimestamp = 0;
|
|
9859
10254
|
this.lastAudioTimestamp = 0;
|
|
9860
10255
|
this.baseVideoTimestamp = null;
|
|
9861
10256
|
this.frameCount = 0;
|
|
10257
|
+
this.lastKeyFrameTimestamp = 0;
|
|
10258
|
+
this.forceNextKeyFrame = false;
|
|
9862
10259
|
this.pausedDuration = 0;
|
|
9863
10260
|
this.pauseStartedAt = null;
|
|
9864
|
-
this.overlayConfig = overlayConfig ? { enabled: overlayConfig.enabled, text: overlayConfig.text } : null;
|
|
9865
10261
|
this.overlayCanvas = null;
|
|
9866
10262
|
this.hiddenIntervals = [];
|
|
9867
10263
|
this.currentHiddenIntervalStart = null;
|
|
9868
|
-
this.recordingStartTime = overlayConfig?.recordingStartTime !== undefined ? overlayConfig.recordingStartTime / 1000 : performance.now() / 1000;
|
|
9869
10264
|
this.pendingVisibilityUpdates = [];
|
|
10265
|
+
}
|
|
10266
|
+
setupOverlayConfig(overlayConfig) {
|
|
10267
|
+
this.overlayConfig = overlayConfig ? { enabled: overlayConfig.enabled, text: overlayConfig.text } : null;
|
|
10268
|
+
this.recordingStartTime = overlayConfig?.recordingStartTime !== undefined ? overlayConfig.recordingStartTime / 1000 : performance.now() / 1000;
|
|
9870
10269
|
const logData = {
|
|
9871
10270
|
hasOverlayConfig: !!this.overlayConfig,
|
|
9872
10271
|
overlayEnabled: this.overlayConfig?.enabled,
|
|
@@ -9874,15 +10273,13 @@ class RecorderWorker {
|
|
|
9874
10273
|
recordingStartTime: this.recordingStartTime
|
|
9875
10274
|
};
|
|
9876
10275
|
logger.debug("[RecorderWorker] Overlay config initialized", logData);
|
|
10276
|
+
}
|
|
10277
|
+
createOutput() {
|
|
9877
10278
|
const writable = new WritableStream({
|
|
9878
10279
|
write: (chunk) => {
|
|
9879
10280
|
this.sendChunk(chunk.data, chunk.position);
|
|
9880
10281
|
}
|
|
9881
10282
|
});
|
|
9882
|
-
const format = config.format || "mp4";
|
|
9883
|
-
if (format !== "mp4") {
|
|
9884
|
-
throw new Error(\`Format \${format} is not yet supported in worker. Only MP4 is currently supported.\`);
|
|
9885
|
-
}
|
|
9886
10283
|
this.output = new Output({
|
|
9887
10284
|
format: new Mp4OutputFormat({
|
|
9888
10285
|
fastStart: "fragmented"
|
|
@@ -9892,27 +10289,75 @@ class RecorderWorker {
|
|
|
9892
10289
|
chunkSize: CHUNK_SIZE
|
|
9893
10290
|
})
|
|
9894
10291
|
});
|
|
9895
|
-
|
|
10292
|
+
}
|
|
10293
|
+
createVideoSource(config) {
|
|
10294
|
+
const fps = config.fps || 30;
|
|
10295
|
+
const keyFrameIntervalSeconds = config.keyFrameInterval / fps;
|
|
10296
|
+
const videoSourceOptions = {
|
|
9896
10297
|
codec: config.codec,
|
|
9897
|
-
|
|
9898
|
-
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
10298
|
+
sizeChangeBehavior: "passThrough",
|
|
10299
|
+
bitrateMode: "variable",
|
|
10300
|
+
latencyMode: "quality",
|
|
10301
|
+
contentHint: "detail",
|
|
10302
|
+
hardwareAcceleration: "prefer-hardware",
|
|
10303
|
+
keyFrameInterval: keyFrameIntervalSeconds,
|
|
10304
|
+
bitrate: config.bitrate ?? QUALITY_HIGH
|
|
10305
|
+
};
|
|
10306
|
+
this.videoSource = new VideoSampleSource(videoSourceOptions);
|
|
10307
|
+
const output = requireNonNull(this.output, "Output must be initialized before adding video track");
|
|
10308
|
+
const trackOptions = {};
|
|
10309
|
+
if (fps !== undefined) {
|
|
10310
|
+
trackOptions.frameRate = fps;
|
|
9903
10311
|
}
|
|
10312
|
+
output.addVideoTrack(this.videoSource, trackOptions);
|
|
10313
|
+
}
|
|
10314
|
+
setupAudioSource(audioStream, config) {
|
|
9904
10315
|
if (audioStream && config.audioBitrate && config.audioCodec) {
|
|
9905
10316
|
if (config.audioBitrate <= 0) {
|
|
9906
10317
|
throw new Error("Audio bitrate must be greater than zero");
|
|
9907
10318
|
}
|
|
9908
10319
|
this.audioSource = new AudioSampleSource({
|
|
9909
10320
|
codec: config.audioCodec,
|
|
9910
|
-
bitrate: config.audioBitrate
|
|
10321
|
+
bitrate: config.audioBitrate,
|
|
10322
|
+
bitrateMode: "variable"
|
|
9911
10323
|
});
|
|
9912
|
-
|
|
10324
|
+
const output = requireNonNull(this.output, "Output must be initialized before adding audio track");
|
|
10325
|
+
output.addAudioTrack(this.audioSource);
|
|
9913
10326
|
this.setupAudioProcessing(audioStream);
|
|
9914
10327
|
}
|
|
9915
|
-
|
|
10328
|
+
}
|
|
10329
|
+
async handleStart(videoStream, audioStream, config, overlayConfig) {
|
|
10330
|
+
this.validateConfig(config);
|
|
10331
|
+
logger.debug("[RecorderWorker] handleStart called", {
|
|
10332
|
+
hasVideoStream: !!videoStream,
|
|
10333
|
+
hasAudioStream: !!audioStream,
|
|
10334
|
+
config: {
|
|
10335
|
+
width: config.width,
|
|
10336
|
+
height: config.height,
|
|
10337
|
+
fps: config.fps,
|
|
10338
|
+
bitrate: config.bitrate
|
|
10339
|
+
},
|
|
10340
|
+
hasOverlayConfig: !!overlayConfig,
|
|
10341
|
+
overlayConfig
|
|
10342
|
+
});
|
|
10343
|
+
this.isStopping = false;
|
|
10344
|
+
this.isFinalized = false;
|
|
10345
|
+
if (this.output) {
|
|
10346
|
+
logger.debug("[RecorderWorker] Cleaning up existing output");
|
|
10347
|
+
await this.cleanup();
|
|
10348
|
+
}
|
|
10349
|
+
this.initializeRecordingState(config);
|
|
10350
|
+
this.setupOverlayConfig(overlayConfig);
|
|
10351
|
+
const format = config.format || "mp4";
|
|
10352
|
+
this.validateFormat(format);
|
|
10353
|
+
this.createOutput();
|
|
10354
|
+
this.createVideoSource(config);
|
|
10355
|
+
if (videoStream) {
|
|
10356
|
+
this.setupVideoProcessing(videoStream);
|
|
10357
|
+
}
|
|
10358
|
+
this.setupAudioSource(audioStream, config);
|
|
10359
|
+
const output = requireNonNull(this.output, "Output must be initialized before starting");
|
|
10360
|
+
await output.start();
|
|
9916
10361
|
this.startBufferUpdates();
|
|
9917
10362
|
this.sendReady();
|
|
9918
10363
|
this.sendStateChange("recording");
|
|
@@ -10157,8 +10602,6 @@ class RecorderWorker {
|
|
|
10157
10602
|
}
|
|
10158
10603
|
}
|
|
10159
10604
|
}
|
|
10160
|
-
const keyFrameInterval = config.keyFrameInterval > 0 ? config.keyFrameInterval : 5;
|
|
10161
|
-
const isKeyFrame = this.frameCount % keyFrameInterval === 0;
|
|
10162
10605
|
const maxLead = 0.05;
|
|
10163
10606
|
const maxLag = 0.1;
|
|
10164
10607
|
const targetAudio = this.lastAudioTimestamp;
|
|
@@ -10170,6 +10613,10 @@ class RecorderWorker {
|
|
|
10170
10613
|
}
|
|
10171
10614
|
const monotonicTimestamp = this.lastVideoTimestamp + frameDuration;
|
|
10172
10615
|
const finalTimestamp = adjustedTimestamp >= monotonicTimestamp ? adjustedTimestamp : monotonicTimestamp;
|
|
10616
|
+
const keyFrameIntervalFrames = config.keyFrameInterval > 0 ? config.keyFrameInterval : 5;
|
|
10617
|
+
const keyFrameIntervalSeconds = keyFrameIntervalFrames / this.frameRate;
|
|
10618
|
+
const timeSinceLastKeyFrame = finalTimestamp - this.lastKeyFrameTimestamp;
|
|
10619
|
+
const isKeyFrame = this.forceNextKeyFrame || timeSinceLastKeyFrame >= keyFrameIntervalSeconds || this.frameCount % keyFrameIntervalFrames === 0;
|
|
10173
10620
|
this.driftOffset *= 0.5;
|
|
10174
10621
|
const sample = new VideoSample(frameToProcess, {
|
|
10175
10622
|
timestamp: finalTimestamp,
|
|
@@ -10184,6 +10631,10 @@ class RecorderWorker {
|
|
|
10184
10631
|
if (!addError) {
|
|
10185
10632
|
this.frameCount += 1;
|
|
10186
10633
|
this.lastVideoTimestamp = finalTimestamp;
|
|
10634
|
+
if (isKeyFrame) {
|
|
10635
|
+
this.lastKeyFrameTimestamp = finalTimestamp;
|
|
10636
|
+
this.forceNextKeyFrame = false;
|
|
10637
|
+
}
|
|
10187
10638
|
if (this.frameCount % 90 === 0 && this.audioProcessingActive) {
|
|
10188
10639
|
const avDrift = this.lastAudioTimestamp - this.lastVideoTimestamp;
|
|
10189
10640
|
logger.debug("[RecorderWorker] AV drift metrics", {
|
|
@@ -10460,6 +10911,7 @@ class RecorderWorker {
|
|
|
10460
10911
|
const previousVideoTimestamp = this.lastVideoTimestamp;
|
|
10461
10912
|
this.lastVideoTimestamp = continuationTimestamp;
|
|
10462
10913
|
this.frameCount = 0;
|
|
10914
|
+
this.forceNextKeyFrame = true;
|
|
10463
10915
|
logger.debug("[RecorderWorker] handleSwitchSource - preserving baseVideoTimestamp", {
|
|
10464
10916
|
continuationTimestamp,
|
|
10465
10917
|
lastVideoTimestamp: this.lastVideoTimestamp,
|
|
@@ -10512,6 +10964,8 @@ class RecorderWorker {
|
|
|
10512
10964
|
this.lastAudioTimestamp = 0;
|
|
10513
10965
|
this.baseVideoTimestamp = null;
|
|
10514
10966
|
this.frameCount = 0;
|
|
10967
|
+
this.lastKeyFrameTimestamp = 0;
|
|
10968
|
+
this.forceNextKeyFrame = false;
|
|
10515
10969
|
this.totalSize = 0;
|
|
10516
10970
|
this.pausedDuration = 0;
|
|
10517
10971
|
this.pauseStartedAt = null;
|
|
@@ -10566,7 +11020,6 @@ new RecorderWorker;
|
|
|
10566
11020
|
|
|
10567
11021
|
// src/core/processor/worker-processor.ts
|
|
10568
11022
|
var KEY_FRAME_INTERVAL = 5;
|
|
10569
|
-
var H264_CODEC = "avc";
|
|
10570
11023
|
var workerBlobUrl = null;
|
|
10571
11024
|
function getWorkerUrl() {
|
|
10572
11025
|
if (workerBlobUrl) {
|
|
@@ -10687,20 +11140,21 @@ class WorkerProcessor {
|
|
|
10687
11140
|
const format = config.format || "mp4";
|
|
10688
11141
|
const audioCodec = config.audioCodec || getDefaultAudioCodecForFormat(format);
|
|
10689
11142
|
const isScreenCapture = isScreenCaptureStream(stream);
|
|
10690
|
-
const
|
|
11143
|
+
const codec = config.codec || await detectBestCodec(config.width, config.height, config.bitrate);
|
|
10691
11144
|
logger.debug("[WorkerProcessor] Starting processing", {
|
|
10692
11145
|
isScreenCapture,
|
|
10693
|
-
|
|
10694
|
-
|
|
11146
|
+
fps: config.fps,
|
|
11147
|
+
codec,
|
|
11148
|
+
bitrate: config.bitrate
|
|
10695
11149
|
});
|
|
10696
11150
|
const workerConfig = {
|
|
10697
11151
|
width: config.width,
|
|
10698
11152
|
height: config.height,
|
|
10699
|
-
fps:
|
|
11153
|
+
fps: config.fps,
|
|
10700
11154
|
bitrate: config.bitrate,
|
|
10701
11155
|
audioCodec,
|
|
10702
11156
|
audioBitrate: config.audioBitrate,
|
|
10703
|
-
codec
|
|
11157
|
+
codec,
|
|
10704
11158
|
keyFrameInterval: KEY_FRAME_INTERVAL,
|
|
10705
11159
|
format
|
|
10706
11160
|
};
|
|
@@ -11748,113 +12202,6 @@ class QuotaManager {
|
|
|
11748
12202
|
return quota.percentage >= threshold;
|
|
11749
12203
|
}
|
|
11750
12204
|
}
|
|
11751
|
-
// src/core/transcode/video-transcoder.ts
|
|
11752
|
-
import {
|
|
11753
|
-
BlobSource as BlobSource2,
|
|
11754
|
-
BufferTarget,
|
|
11755
|
-
Conversion,
|
|
11756
|
-
FilePathSource,
|
|
11757
|
-
Input as Input2,
|
|
11758
|
-
MP4 as MP42,
|
|
11759
|
-
Mp4OutputFormat,
|
|
11760
|
-
Output
|
|
11761
|
-
} from "mediabunny";
|
|
11762
|
-
function createSource(input) {
|
|
11763
|
-
if (typeof input === "string") {
|
|
11764
|
-
return new FilePathSource(input);
|
|
11765
|
-
}
|
|
11766
|
-
if (input instanceof Blob) {
|
|
11767
|
-
return new BlobSource2(input);
|
|
11768
|
-
}
|
|
11769
|
-
throw new Error("Invalid input type. Expected Blob, File, or file path string.");
|
|
11770
|
-
}
|
|
11771
|
-
function createOutputFormat(format) {
|
|
11772
|
-
switch (format) {
|
|
11773
|
-
case "mp4":
|
|
11774
|
-
return new Mp4OutputFormat;
|
|
11775
|
-
case "webm":
|
|
11776
|
-
case "mkv":
|
|
11777
|
-
case "mov":
|
|
11778
|
-
throw new Error(`Format ${format} is not yet supported. Only MP4 is currently supported.`);
|
|
11779
|
-
default:
|
|
11780
|
-
throw new Error(`Unsupported output format: ${format}`);
|
|
11781
|
-
}
|
|
11782
|
-
}
|
|
11783
|
-
function getMimeTypeForFormat(format) {
|
|
11784
|
-
switch (format) {
|
|
11785
|
-
case "mp4":
|
|
11786
|
-
return "video/mp4";
|
|
11787
|
-
case "webm":
|
|
11788
|
-
return "video/webm";
|
|
11789
|
-
case "mkv":
|
|
11790
|
-
return "video/x-matroska";
|
|
11791
|
-
case "mov":
|
|
11792
|
-
return "video/quicktime";
|
|
11793
|
-
default:
|
|
11794
|
-
throw new Error(`Unsupported output format: ${format}`);
|
|
11795
|
-
}
|
|
11796
|
-
}
|
|
11797
|
-
function createConversionOptions(config) {
|
|
11798
|
-
const audioCodec = getAudioCodecForFormat(config.format, config.audioCodec);
|
|
11799
|
-
const video = {
|
|
11800
|
-
width: config.width,
|
|
11801
|
-
height: config.height,
|
|
11802
|
-
fit: "contain",
|
|
11803
|
-
frameRate: config.fps,
|
|
11804
|
-
bitrate: config.bitrate,
|
|
11805
|
-
forceTranscode: true
|
|
11806
|
-
};
|
|
11807
|
-
const audio = {
|
|
11808
|
-
codec: audioCodec,
|
|
11809
|
-
forceTranscode: true
|
|
11810
|
-
};
|
|
11811
|
-
return { video, audio };
|
|
11812
|
-
}
|
|
11813
|
-
function validateConversion(conversion) {
|
|
11814
|
-
if (!conversion.isValid) {
|
|
11815
|
-
const reasons = conversion.discardedTracks.map((track) => track.reason).join(", ");
|
|
11816
|
-
throw new Error(`Conversion is invalid. Discarded tracks: ${reasons}`);
|
|
11817
|
-
}
|
|
11818
|
-
}
|
|
11819
|
-
async function transcodeVideo(input, config = {}, onProgress) {
|
|
11820
|
-
const finalConfig = {
|
|
11821
|
-
...DEFAULT_TRANSCODE_CONFIG,
|
|
11822
|
-
...config,
|
|
11823
|
-
format: config.format || DEFAULT_TRANSCODE_CONFIG.format
|
|
11824
|
-
};
|
|
11825
|
-
if (!finalConfig.audioCodec) {
|
|
11826
|
-
finalConfig.audioCodec = getDefaultAudioCodecForFormat(finalConfig.format);
|
|
11827
|
-
}
|
|
11828
|
-
const source = createSource(input);
|
|
11829
|
-
const mediabunnyInput = new Input2({
|
|
11830
|
-
formats: [MP42],
|
|
11831
|
-
source
|
|
11832
|
-
});
|
|
11833
|
-
const outputFormat = createOutputFormat(finalConfig.format);
|
|
11834
|
-
const output = new Output({
|
|
11835
|
-
format: outputFormat,
|
|
11836
|
-
target: new BufferTarget
|
|
11837
|
-
});
|
|
11838
|
-
const conversion = await Conversion.init({
|
|
11839
|
-
input: mediabunnyInput,
|
|
11840
|
-
output,
|
|
11841
|
-
...createConversionOptions(finalConfig)
|
|
11842
|
-
});
|
|
11843
|
-
validateConversion(conversion);
|
|
11844
|
-
if (onProgress) {
|
|
11845
|
-
conversion.onProgress = onProgress;
|
|
11846
|
-
}
|
|
11847
|
-
await conversion.execute();
|
|
11848
|
-
const buffer = output.target.buffer;
|
|
11849
|
-
if (!buffer) {
|
|
11850
|
-
throw new Error("Transcoding completed but no output buffer was generated");
|
|
11851
|
-
}
|
|
11852
|
-
const mimeType = getMimeTypeForFormat(finalConfig.format);
|
|
11853
|
-
return {
|
|
11854
|
-
buffer,
|
|
11855
|
-
blob: new Blob([buffer], { type: mimeType })
|
|
11856
|
-
};
|
|
11857
|
-
}
|
|
11858
12205
|
// src/core/utils/audio-utils.ts
|
|
11859
12206
|
function calculateBarColor(position) {
|
|
11860
12207
|
if (position < 0.25) {
|
|
@@ -11872,6 +12219,23 @@ function calculateBarColor(position) {
|
|
|
11872
12219
|
const t = (position - 0.75) / 0.25;
|
|
11873
12220
|
return `rgb(0, ${Math.round(128 - (100 - 128) * t)}, ${Math.round(128 + (200 - 128) * t)})`;
|
|
11874
12221
|
}
|
|
12222
|
+
// src/core/utils/device-detection.ts
|
|
12223
|
+
import { UAParser } from "ua-parser-js";
|
|
12224
|
+
function isMobileDevice() {
|
|
12225
|
+
const parser = new UAParser;
|
|
12226
|
+
const result = parser.getResult();
|
|
12227
|
+
const deviceType = result.device.type;
|
|
12228
|
+
const isMobile = deviceType === "mobile" || deviceType === "tablet";
|
|
12229
|
+
logger.debug("Mobile detection result", {
|
|
12230
|
+
userAgent: navigator.userAgent,
|
|
12231
|
+
deviceType,
|
|
12232
|
+
isMobile,
|
|
12233
|
+
device: result.device,
|
|
12234
|
+
os: result.os,
|
|
12235
|
+
browser: result.browser
|
|
12236
|
+
});
|
|
12237
|
+
return isMobile;
|
|
12238
|
+
}
|
|
11875
12239
|
// src/vidtreo-recorder.ts
|
|
11876
12240
|
class VidtreoRecorder {
|
|
11877
12241
|
controller;
|
|
@@ -12075,6 +12439,8 @@ class VidtreoRecorder {
|
|
|
12075
12439
|
}
|
|
12076
12440
|
}
|
|
12077
12441
|
export {
|
|
12442
|
+
validateFile,
|
|
12443
|
+
transcodeVideoForNativeCamera,
|
|
12078
12444
|
transcodeVideo,
|
|
12079
12445
|
requireStream,
|
|
12080
12446
|
requireProcessor,
|
|
@@ -12084,12 +12450,14 @@ export {
|
|
|
12084
12450
|
requireActive,
|
|
12085
12451
|
mapPresetToConfig,
|
|
12086
12452
|
logger,
|
|
12453
|
+
isMobileDevice,
|
|
12087
12454
|
getDefaultConfigForFormat,
|
|
12088
12455
|
getDefaultAudioCodecForFormat,
|
|
12089
12456
|
getAudioCodecForFormat,
|
|
12090
12457
|
formatTime,
|
|
12091
12458
|
formatFileSize,
|
|
12092
12459
|
extractVideoDuration,
|
|
12460
|
+
extractLastFrame,
|
|
12093
12461
|
extractErrorMessage,
|
|
12094
12462
|
calculateBarColor,
|
|
12095
12463
|
VidtreoRecorder,
|
|
@@ -12101,6 +12469,7 @@ export {
|
|
|
12101
12469
|
RecordingManager,
|
|
12102
12470
|
RecorderController,
|
|
12103
12471
|
QuotaManager,
|
|
12472
|
+
NativeCameraHandler,
|
|
12104
12473
|
FORMAT_DEFAULT_CODECS,
|
|
12105
12474
|
DeviceManager,
|
|
12106
12475
|
DEFAULT_TRANSCODE_CONFIG,
|