@vidtreo/recorder 0.9.2 → 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 +53 -2
- package/dist/index.js +425 -169
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -138,6 +138,52 @@ export declare class VidtreoRecorder {
|
|
|
138
138
|
private ensureInitialized;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
export type NativeCameraFile = {
|
|
142
|
+
file: File;
|
|
143
|
+
previewUrl: string;
|
|
144
|
+
duration: number;
|
|
145
|
+
validated: boolean;
|
|
146
|
+
};
|
|
147
|
+
export type FileValidationResult = {
|
|
148
|
+
valid: boolean;
|
|
149
|
+
error?: string;
|
|
150
|
+
};
|
|
151
|
+
export type NativeCameraConfig = {
|
|
152
|
+
maxFileSize?: number;
|
|
153
|
+
maxDuration?: number;
|
|
154
|
+
allowedFormats?: string[];
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export declare function validateFile(file: File, config?: {
|
|
158
|
+
maxFileSize?: number;
|
|
159
|
+
maxRecordingTime?: number | null;
|
|
160
|
+
allowedFormats?: string[];
|
|
161
|
+
}): Promise<FileValidationResult>;
|
|
162
|
+
|
|
163
|
+
import type { RecordingStopResult } from "../../vidtreo-recorder";
|
|
164
|
+
import type { ConfigService } from "../config/config-service";
|
|
165
|
+
import type { VideoUploadService } from "../upload/upload-service";
|
|
166
|
+
export type NativeCameraHandlerConfig = {
|
|
167
|
+
apiKey?: string | null;
|
|
168
|
+
backendUrl?: string | null;
|
|
169
|
+
maxRecordingTime?: number | null;
|
|
170
|
+
maxFileSize?: number;
|
|
171
|
+
userMetadata?: Record<string, unknown>;
|
|
172
|
+
};
|
|
173
|
+
export declare class NativeCameraHandler {
|
|
174
|
+
private pendingFile;
|
|
175
|
+
private readonly configService;
|
|
176
|
+
private readonly uploadService;
|
|
177
|
+
private readonly config;
|
|
178
|
+
constructor(config: NativeCameraHandlerConfig, configService: ConfigService | null, uploadService: VideoUploadService);
|
|
179
|
+
handleFileSelection(file: File): Promise<NativeCameraFile>;
|
|
180
|
+
processAndUpload(onTranscodeProgress: (progress: number) => void, onUploadProgress: (progress: number) => void): Promise<RecordingStopResult>;
|
|
181
|
+
cancel(): void;
|
|
182
|
+
preloadConfig(): Promise<void>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export declare function extractLastFrame(file: File, timeoutMs?: number): Promise<Blob>;
|
|
186
|
+
|
|
141
187
|
export type VideoUploadOptions = {
|
|
142
188
|
apiKey: string;
|
|
143
189
|
backendUrl: string;
|
|
@@ -278,6 +324,8 @@ export declare function requireInitialized<T>(value: T | null | undefined, compo
|
|
|
278
324
|
export declare function requireStream(stream: MediaStream | null, message?: string): MediaStream;
|
|
279
325
|
export declare function requireProcessor<T>(processor: T | null, componentName?: string): T;
|
|
280
326
|
|
|
327
|
+
export declare function isMobileDevice(): boolean;
|
|
328
|
+
|
|
281
329
|
export declare function extractErrorMessage(error: unknown): string;
|
|
282
330
|
|
|
283
331
|
export declare const FILE_SIZE_UNITS: readonly ["Bytes", "KB", "MB", "GB"];
|
|
@@ -285,7 +333,7 @@ export declare const FILE_SIZE_BASE = 1024;
|
|
|
285
333
|
export declare function formatFileSize(bytes: number): string;
|
|
286
334
|
export declare function formatTime(totalSeconds: number): string;
|
|
287
335
|
|
|
288
|
-
export declare function extractVideoDuration(
|
|
336
|
+
export declare function extractVideoDuration(file: File | Blob): Promise<number>;
|
|
289
337
|
|
|
290
338
|
export type StorageQuota = {
|
|
291
339
|
usage: number;
|
|
@@ -888,8 +936,9 @@ export type BackendConfigResponse = {
|
|
|
888
936
|
presetEncoding: BackendPreset;
|
|
889
937
|
max_width: number;
|
|
890
938
|
max_height: number;
|
|
939
|
+
outputFormat?: "mp4" | "webm" | "mkv" | "mov";
|
|
891
940
|
};
|
|
892
|
-
export declare function mapPresetToConfig(preset: BackendPreset, maxWidth: number, maxHeight: number,
|
|
941
|
+
export declare function mapPresetToConfig(preset: BackendPreset, maxWidth: number, maxHeight: number, outputFormat?: TranscodeConfig["format"]): TranscodeConfig;
|
|
893
942
|
|
|
894
943
|
import type { TranscodeConfig } from "../transcode/transcode-types";
|
|
895
944
|
export declare const DEFAULT_BACKEND_URL = "https://api.vidtreo.com";
|
|
@@ -920,6 +969,7 @@ export type TranscodeResult = {
|
|
|
920
969
|
};
|
|
921
970
|
|
|
922
971
|
export declare function transcodeVideo(input: TranscodeInput, config?: Partial<TranscodeConfig>, onProgress?: (progress: number) => void): Promise<TranscodeResult>;
|
|
972
|
+
export declare function transcodeVideoForNativeCamera(file: File, config?: Partial<TranscodeConfig>, onProgress?: (progress: number) => void): Promise<TranscodeResult>;
|
|
923
973
|
|
|
924
974
|
import type { AudioLevelCallbacks } from "../audio/types";
|
|
925
975
|
import type { DeviceCallbacks } from "../device/types";
|
|
@@ -950,6 +1000,7 @@ export type RecorderConfig = {
|
|
|
950
1000
|
enableDeviceChange?: boolean;
|
|
951
1001
|
enableTabVisibilityOverlay?: boolean;
|
|
952
1002
|
tabVisibilityOverlayText?: string;
|
|
1003
|
+
nativeCamera?: boolean;
|
|
953
1004
|
};
|
|
954
1005
|
export type RecorderCallbacks = {
|
|
955
1006
|
recording?: Partial<RecordingCallbacks>;
|
package/dist/index.js
CHANGED
|
@@ -203,7 +203,7 @@ var QUALITY_MAP = {
|
|
|
203
203
|
"4k": QUALITY_VERY_HIGH
|
|
204
204
|
};
|
|
205
205
|
var AUDIO_BITRATE = 128000;
|
|
206
|
-
function mapPresetToConfig(preset, maxWidth, maxHeight,
|
|
206
|
+
function mapPresetToConfig(preset, maxWidth, maxHeight, outputFormat) {
|
|
207
207
|
if (!(preset in QUALITY_MAP)) {
|
|
208
208
|
throw new Error(`Invalid preset: ${preset}`);
|
|
209
209
|
}
|
|
@@ -213,6 +213,7 @@ function mapPresetToConfig(preset, maxWidth, maxHeight, format = "mp4") {
|
|
|
213
213
|
if (typeof maxHeight !== "number" || maxHeight <= 0) {
|
|
214
214
|
throw new Error("maxHeight must be a positive number");
|
|
215
215
|
}
|
|
216
|
+
const format = outputFormat || "mp4";
|
|
216
217
|
const audioCodec = getDefaultAudioCodecForFormat(format);
|
|
217
218
|
return {
|
|
218
219
|
format,
|
|
@@ -310,7 +311,7 @@ class ConfigService {
|
|
|
310
311
|
if (!data.presetEncoding || typeof data.max_width !== "number" || typeof data.max_height !== "number") {
|
|
311
312
|
throw new Error("Invalid config response from backend");
|
|
312
313
|
}
|
|
313
|
-
return mapPresetToConfig(data.presetEncoding, data.max_width, data.max_height);
|
|
314
|
+
return mapPresetToConfig(data.presetEncoding, data.max_width, data.max_height, data.outputFormat);
|
|
314
315
|
}
|
|
315
316
|
}
|
|
316
317
|
|
|
@@ -403,6 +404,406 @@ class DeviceManager {
|
|
|
403
404
|
return this.availableDevices;
|
|
404
405
|
}
|
|
405
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
|
+
}
|
|
406
807
|
// src/core/storage/video-storage.ts
|
|
407
808
|
var DB_NAME = "vidtreo-recorder";
|
|
408
809
|
var DB_VERSION = 1;
|
|
@@ -2611,55 +3012,6 @@ class VideoUploadService {
|
|
|
2611
3012
|
}
|
|
2612
3013
|
}
|
|
2613
3014
|
|
|
2614
|
-
// src/core/utils/video-utils.ts
|
|
2615
|
-
import { BlobSource, Input, MP4 } from "mediabunny";
|
|
2616
|
-
async function extractVideoDuration(blob) {
|
|
2617
|
-
try {
|
|
2618
|
-
const source = new BlobSource(blob);
|
|
2619
|
-
const input = new Input({
|
|
2620
|
-
formats: [MP4],
|
|
2621
|
-
source
|
|
2622
|
-
});
|
|
2623
|
-
if (typeof input.computeDuration !== "function") {
|
|
2624
|
-
throw new Error("computeDuration method is not available");
|
|
2625
|
-
}
|
|
2626
|
-
const duration = await input.computeDuration();
|
|
2627
|
-
if (!duration) {
|
|
2628
|
-
throw new Error("Duration is missing from computeDuration");
|
|
2629
|
-
}
|
|
2630
|
-
if (duration <= 0) {
|
|
2631
|
-
throw new Error("Invalid duration: must be greater than 0");
|
|
2632
|
-
}
|
|
2633
|
-
return duration;
|
|
2634
|
-
} catch {
|
|
2635
|
-
return extractDurationWithVideoElement(blob);
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
function extractDurationWithVideoElement(blob) {
|
|
2639
|
-
return new Promise((resolve, reject) => {
|
|
2640
|
-
const video = document.createElement("video");
|
|
2641
|
-
const url = URL.createObjectURL(blob);
|
|
2642
|
-
const cleanup = () => {
|
|
2643
|
-
URL.revokeObjectURL(url);
|
|
2644
|
-
};
|
|
2645
|
-
video.addEventListener("loadedmetadata", () => {
|
|
2646
|
-
cleanup();
|
|
2647
|
-
const duration = video.duration;
|
|
2648
|
-
if (!Number.isFinite(duration) || duration <= 0) {
|
|
2649
|
-
reject(new Error("Invalid video duration"));
|
|
2650
|
-
return;
|
|
2651
|
-
}
|
|
2652
|
-
resolve(duration);
|
|
2653
|
-
});
|
|
2654
|
-
video.addEventListener("error", () => {
|
|
2655
|
-
cleanup();
|
|
2656
|
-
reject(new Error("Failed to load video metadata"));
|
|
2657
|
-
});
|
|
2658
|
-
video.src = url;
|
|
2659
|
-
video.load();
|
|
2660
|
-
});
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
3015
|
// src/core/utils/stream-utils.ts
|
|
2664
3016
|
function isScreenCaptureStream(stream) {
|
|
2665
3017
|
const videoTracks = stream.getVideoTracks();
|
|
@@ -11850,124 +12202,6 @@ class QuotaManager {
|
|
|
11850
12202
|
return quota.percentage >= threshold;
|
|
11851
12203
|
}
|
|
11852
12204
|
}
|
|
11853
|
-
// src/core/transcode/video-transcoder.ts
|
|
11854
|
-
import {
|
|
11855
|
-
BlobSource as BlobSource2,
|
|
11856
|
-
BufferTarget,
|
|
11857
|
-
Conversion,
|
|
11858
|
-
FilePathSource,
|
|
11859
|
-
Input as Input2,
|
|
11860
|
-
MP4 as MP42,
|
|
11861
|
-
Mp4OutputFormat,
|
|
11862
|
-
Output
|
|
11863
|
-
} from "mediabunny";
|
|
11864
|
-
function createSource(input) {
|
|
11865
|
-
if (typeof input === "string") {
|
|
11866
|
-
return new FilePathSource(input);
|
|
11867
|
-
}
|
|
11868
|
-
if (input instanceof Blob) {
|
|
11869
|
-
return new BlobSource2(input);
|
|
11870
|
-
}
|
|
11871
|
-
throw new Error("Invalid input type. Expected Blob, File, or file path string.");
|
|
11872
|
-
}
|
|
11873
|
-
function createOutputFormat(format) {
|
|
11874
|
-
switch (format) {
|
|
11875
|
-
case "mp4":
|
|
11876
|
-
return new Mp4OutputFormat;
|
|
11877
|
-
case "webm":
|
|
11878
|
-
case "mkv":
|
|
11879
|
-
case "mov":
|
|
11880
|
-
throw new Error(`Format ${format} is not yet supported. Only MP4 is currently supported.`);
|
|
11881
|
-
default:
|
|
11882
|
-
throw new Error(`Unsupported output format: ${format}`);
|
|
11883
|
-
}
|
|
11884
|
-
}
|
|
11885
|
-
function getMimeTypeForFormat(format) {
|
|
11886
|
-
switch (format) {
|
|
11887
|
-
case "mp4":
|
|
11888
|
-
return "video/mp4";
|
|
11889
|
-
case "webm":
|
|
11890
|
-
return "video/webm";
|
|
11891
|
-
case "mkv":
|
|
11892
|
-
return "video/x-matroska";
|
|
11893
|
-
case "mov":
|
|
11894
|
-
return "video/quicktime";
|
|
11895
|
-
default:
|
|
11896
|
-
throw new Error(`Unsupported output format: ${format}`);
|
|
11897
|
-
}
|
|
11898
|
-
}
|
|
11899
|
-
function createConversionOptions(config) {
|
|
11900
|
-
const audioCodec = getAudioCodecForFormat(config.format, config.audioCodec);
|
|
11901
|
-
const video = {
|
|
11902
|
-
fit: "contain",
|
|
11903
|
-
forceTranscode: true
|
|
11904
|
-
};
|
|
11905
|
-
if (config.width !== undefined) {
|
|
11906
|
-
video.width = config.width;
|
|
11907
|
-
}
|
|
11908
|
-
if (config.height !== undefined) {
|
|
11909
|
-
video.height = config.height;
|
|
11910
|
-
}
|
|
11911
|
-
if (config.fps !== undefined) {
|
|
11912
|
-
video.frameRate = config.fps;
|
|
11913
|
-
}
|
|
11914
|
-
if (config.bitrate !== undefined) {
|
|
11915
|
-
video.bitrate = config.bitrate;
|
|
11916
|
-
}
|
|
11917
|
-
if (config.codec !== undefined) {
|
|
11918
|
-
video.codec = config.codec;
|
|
11919
|
-
}
|
|
11920
|
-
const audio = {
|
|
11921
|
-
codec: audioCodec,
|
|
11922
|
-
forceTranscode: true
|
|
11923
|
-
};
|
|
11924
|
-
return { video, audio };
|
|
11925
|
-
}
|
|
11926
|
-
function validateConversion(conversion) {
|
|
11927
|
-
if (!conversion.isValid) {
|
|
11928
|
-
const reasons = conversion.discardedTracks.map((track) => track.reason).join(", ");
|
|
11929
|
-
throw new Error(`Conversion is invalid. Discarded tracks: ${reasons}`);
|
|
11930
|
-
}
|
|
11931
|
-
}
|
|
11932
|
-
async function transcodeVideo(input, config = {}, onProgress) {
|
|
11933
|
-
const finalConfig = {
|
|
11934
|
-
...DEFAULT_TRANSCODE_CONFIG,
|
|
11935
|
-
...config,
|
|
11936
|
-
format: config.format || DEFAULT_TRANSCODE_CONFIG.format
|
|
11937
|
-
};
|
|
11938
|
-
if (!finalConfig.audioCodec) {
|
|
11939
|
-
finalConfig.audioCodec = getDefaultAudioCodecForFormat(finalConfig.format);
|
|
11940
|
-
}
|
|
11941
|
-
const source = createSource(input);
|
|
11942
|
-
const mediabunnyInput = new Input2({
|
|
11943
|
-
formats: [MP42],
|
|
11944
|
-
source
|
|
11945
|
-
});
|
|
11946
|
-
const outputFormat = createOutputFormat(finalConfig.format);
|
|
11947
|
-
const output = new Output({
|
|
11948
|
-
format: outputFormat,
|
|
11949
|
-
target: new BufferTarget
|
|
11950
|
-
});
|
|
11951
|
-
const conversion = await Conversion.init({
|
|
11952
|
-
input: mediabunnyInput,
|
|
11953
|
-
output,
|
|
11954
|
-
...createConversionOptions(finalConfig)
|
|
11955
|
-
});
|
|
11956
|
-
validateConversion(conversion);
|
|
11957
|
-
if (onProgress) {
|
|
11958
|
-
conversion.onProgress = onProgress;
|
|
11959
|
-
}
|
|
11960
|
-
await conversion.execute();
|
|
11961
|
-
const buffer = output.target.buffer;
|
|
11962
|
-
if (!buffer) {
|
|
11963
|
-
throw new Error("Transcoding completed but no output buffer was generated");
|
|
11964
|
-
}
|
|
11965
|
-
const mimeType = getMimeTypeForFormat(finalConfig.format);
|
|
11966
|
-
return {
|
|
11967
|
-
buffer,
|
|
11968
|
-
blob: new Blob([buffer], { type: mimeType })
|
|
11969
|
-
};
|
|
11970
|
-
}
|
|
11971
12205
|
// src/core/utils/audio-utils.ts
|
|
11972
12206
|
function calculateBarColor(position) {
|
|
11973
12207
|
if (position < 0.25) {
|
|
@@ -11985,6 +12219,23 @@ function calculateBarColor(position) {
|
|
|
11985
12219
|
const t = (position - 0.75) / 0.25;
|
|
11986
12220
|
return `rgb(0, ${Math.round(128 - (100 - 128) * t)}, ${Math.round(128 + (200 - 128) * t)})`;
|
|
11987
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
|
+
}
|
|
11988
12239
|
// src/vidtreo-recorder.ts
|
|
11989
12240
|
class VidtreoRecorder {
|
|
11990
12241
|
controller;
|
|
@@ -12188,6 +12439,8 @@ class VidtreoRecorder {
|
|
|
12188
12439
|
}
|
|
12189
12440
|
}
|
|
12190
12441
|
export {
|
|
12442
|
+
validateFile,
|
|
12443
|
+
transcodeVideoForNativeCamera,
|
|
12191
12444
|
transcodeVideo,
|
|
12192
12445
|
requireStream,
|
|
12193
12446
|
requireProcessor,
|
|
@@ -12197,12 +12450,14 @@ export {
|
|
|
12197
12450
|
requireActive,
|
|
12198
12451
|
mapPresetToConfig,
|
|
12199
12452
|
logger,
|
|
12453
|
+
isMobileDevice,
|
|
12200
12454
|
getDefaultConfigForFormat,
|
|
12201
12455
|
getDefaultAudioCodecForFormat,
|
|
12202
12456
|
getAudioCodecForFormat,
|
|
12203
12457
|
formatTime,
|
|
12204
12458
|
formatFileSize,
|
|
12205
12459
|
extractVideoDuration,
|
|
12460
|
+
extractLastFrame,
|
|
12206
12461
|
extractErrorMessage,
|
|
12207
12462
|
calculateBarColor,
|
|
12208
12463
|
VidtreoRecorder,
|
|
@@ -12214,6 +12469,7 @@ export {
|
|
|
12214
12469
|
RecordingManager,
|
|
12215
12470
|
RecorderController,
|
|
12216
12471
|
QuotaManager,
|
|
12472
|
+
NativeCameraHandler,
|
|
12217
12473
|
FORMAT_DEFAULT_CODECS,
|
|
12218
12474
|
DeviceManager,
|
|
12219
12475
|
DEFAULT_TRANSCODE_CONFIG,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vidtreo/recorder",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"author": "cfonseca@vidtreo.com",
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mediabunny": "^1.25.8"
|
|
47
|
+
"mediabunny": "^1.25.8",
|
|
48
|
+
"ua-parser-js": "^2.0.7"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@happy-dom/global-registrator": "^20.0.11",
|