@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +75 -21
  2. package/dist/index.js +604 -235
  3. 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: 1280,
153
- height: 720,
154
- bitrate: 500000,
155
- audioCodec: undefined,
156
- audioBitrate: 128000,
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
- var BITRATE_MAP = {
169
- sd: 500000,
170
- hd: 1e6,
171
- fhd: 2000000,
172
- "4k": 8000000
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
- var PACKET_COUNT_MAP = {
176
- sd: 800,
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: BITRATE_MAP[preset],
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.currentConfig = await this.configService.fetchConfig();
315
- this.configFetched = true;
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
- async handleStart(videoStream, audioStream, config, overlayConfig) {
10225
+ validateConfig(config) {
9823
10226
  requireDefined(config, "Transcode config is required");
9824
- if (config.width <= 0 || config.height <= 0) {
9825
- throw new Error("Video dimensions must be greater than zero");
10227
+ if (config.width !== undefined && config.width <= 0) {
10228
+ throw new Error("Video width must be greater than zero");
9826
10229
  }
9827
- if (config.fps <= 0) {
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
- logger.debug("[RecorderWorker] handleStart called", {
9837
- hasVideoStream: !!videoStream,
9838
- hasAudioStream: !!audioStream,
9839
- config: {
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
- this.videoSource = new VideoSampleSource({
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
- bitrate: config.bitrate,
9898
- sizeChangeBehavior: "passThrough"
9899
- });
9900
- this.output.addVideoTrack(this.videoSource);
9901
- if (videoStream) {
9902
- this.setupVideoProcessing(videoStream);
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
- this.output.addAudioTrack(this.audioSource);
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
- await this.output.start();
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 targetFps = config.fps;
11143
+ const codec = config.codec || await detectBestCodec(config.width, config.height, config.bitrate);
10691
11144
  logger.debug("[WorkerProcessor] Starting processing", {
10692
11145
  isScreenCapture,
10693
- targetFps,
10694
- originalFps: config.fps
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: targetFps,
11153
+ fps: config.fps,
10700
11154
  bitrate: config.bitrate,
10701
11155
  audioCodec,
10702
11156
  audioBitrate: config.audioBitrate,
10703
- codec: H264_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,