@onamfc/video-transcoder
Version:
Backend-agnostic video recording and transcoding module with AWS integration
1,222 lines (1,214 loc) • 54.5 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.VideoRecorder = {}));
})(this, (function (exports) { 'use strict';
class WebcamRecorder {
constructor(config) {
Object.defineProperty(this, "mediaRecorder", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "stream", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "chunks", {
enumerable: true,
configurable: true,
writable: true,
value: []
});
Object.defineProperty(this, "startTime", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "config", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "isRecording", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "isPaused", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "previewElement", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "isInitialized", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
this.config = config;
this.previewElement = config.previewElement || null;
}
/**
* Update configuration while preserving existing stream and preview
*/
updateConfig(newConfig) {
const wasInitialized = this.isInitialized;
// Store current preview element reference
const currentPreview = this.previewElement;
// Update config
this.config = { ...this.config, ...newConfig };
// If we were initialized and quality changed, we need to reinitialize
if (wasInitialized && this.hasVideoQualityChanged(newConfig)) {
this.reinitializeWithNewQuality(currentPreview);
}
else {
// Just update config without reinitializing
this.config = { ...this.config, ...newConfig };
}
}
hasVideoQualityChanged(newConfig) {
return newConfig.videoQuality !== undefined &&
newConfig.videoQuality !== this.config.videoQuality;
}
async reinitializeWithNewQuality(preservePreview) {
try {
// Stop current stream
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
// Get new stream with updated constraints
const constraints = this.getMediaConstraints();
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
// Restore preview if it existed
if (preservePreview && this.config.showPreview) {
this.previewElement = preservePreview;
this.previewElement.srcObject = this.stream;
}
}
catch (error) {
console.error('Failed to reinitialize with new quality:', error);
throw error;
}
}
async initialize() {
try {
const constraints = this.getMediaConstraints();
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
this.isInitialized = true;
if (this.config.showPreview) {
this.setupPreview();
}
}
catch (error) {
throw new Error(`Failed to access camera: ${error.message}`);
}
}
/**
* Set a custom video element for preview instead of creating one
*/
setPreviewElement(element) {
this.previewElement = element;
if (this.stream && this.config.showPreview) {
this.setupPreview();
}
}
getMediaConstraints() {
const quality = this.config.videoQuality || 'medium';
const qualitySettings = {
low: { width: 640, height: 480, frameRate: 15 },
medium: { width: 1280, height: 720, frameRate: 30 },
high: { width: 1920, height: 1080, frameRate: 30 },
auto: { width: 1280, height: 720, frameRate: 30 }
};
const settings = qualitySettings[quality];
return {
video: {
width: { ideal: settings.width, max: settings.width },
height: { ideal: settings.height, max: settings.height },
frameRate: { ideal: settings.frameRate, max: settings.frameRate }
},
audio: this.config.audioEnabled !== false
};
}
setupPreview() {
let preview = this.previewElement;
// Use provided element or create a new one
if (!preview) {
preview = document.getElementById('video-transcoder-preview');
}
if (!preview) {
preview = document.createElement('video');
preview.id = 'video-transcoder-preview';
// Apply custom styles if provided, otherwise use defaults
this.applyPreviewStyles(preview);
// Only append to body if we created the element
document.body.appendChild(preview);
}
// Configure video element properties
preview.autoplay = true;
preview.muted = true;
preview.playsInline = true;
// Set the stream
if (this.stream) {
preview.srcObject = this.stream;
}
// Store reference for cleanup
this.previewElement = preview;
}
applyPreviewStyles(element) {
if (this.config.customStyles) {
Object.assign(element.style, this.config.customStyles);
}
else {
// Default styles
element.style.width = '100%';
element.style.maxWidth = '400px';
element.style.borderRadius = '8px';
element.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';
}
}
async startRecording() {
if (!this.stream) {
throw new Error('Camera not initialized. Call initialize() first.');
}
if (this.isRecording) {
throw new Error('Recording already in progress');
}
this.chunks = [];
this.startTime = Date.now();
const options = {
mimeType: this.getSupportedMimeType()
};
this.mediaRecorder = new MediaRecorder(this.stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.mediaRecorder.start(1000); // Collect data every second
this.isRecording = true;
this.isPaused = false;
// Auto-stop after max duration
if (this.config.maxDuration) {
setTimeout(() => {
if (this.isRecording && !this.isPaused) {
this.stopRecording();
}
}, this.config.maxDuration * 1000);
}
}
pauseRecording() {
if (this.mediaRecorder && this.isRecording && !this.isPaused) {
this.mediaRecorder.pause();
this.isPaused = true;
}
}
resumeRecording() {
if (this.mediaRecorder && this.isRecording && this.isPaused) {
this.mediaRecorder.resume();
this.isPaused = false;
}
}
async stopRecording() {
return new Promise((resolve, reject) => {
if (!this.mediaRecorder || !this.isRecording) {
reject(new Error('No recording in progress'));
return;
}
this.mediaRecorder.onstop = () => {
const duration = (Date.now() - this.startTime) / 1000;
const blob = new Blob(this.chunks, {
type: this.getSupportedMimeType()
});
this.isRecording = false;
this.isPaused = false;
resolve({
blob,
duration,
size: blob.size,
mimeType: blob.type
});
};
this.mediaRecorder.stop();
});
}
getSupportedMimeType() {
const types = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4'
];
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return 'video/webm';
}
cleanup() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.mediaRecorder) {
this.mediaRecorder = null;
}
// Only remove preview if we created it (not provided by user)
if (this.previewElement && this.previewElement.id === 'video-transcoder-preview') {
this.previewElement.remove();
}
this.previewElement = null;
this.chunks = [];
this.isRecording = false;
this.isPaused = false;
this.isInitialized = false;
}
getRecordingState() {
return {
isRecording: this.isRecording,
isPaused: this.isPaused,
duration: this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
};
}
}
class UploadManager {
constructor(config) {
Object.defineProperty(this, "config", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "activeUploads", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "progressCallbacks", {
enumerable: true,
configurable: true,
writable: true,
value: new Set()
});
this.config = config;
}
async uploadRecording(file, metadata = {}) {
const trackingId = this.generateTrackingId();
const abortController = new AbortController();
this.activeUploads.set(trackingId, abortController);
try {
// Get upload token from backend
const uploadInfo = await this.getUploadToken(file, metadata);
// Determine upload strategy based on file size
const chunkSize = this.config.chunkSize ?? 5 * 1024 * 1024; // 5MB default
if (file.size > chunkSize) {
return await this.multipartUpload(file, uploadInfo, trackingId, abortController.signal);
}
else {
return await this.singleUpload(file, uploadInfo, trackingId, abortController.signal);
}
}
catch (error) {
this.activeUploads.delete(trackingId);
throw error;
}
}
async getUploadToken(file, metadata) {
const response = await fetch(`${this.config.apiEndpoint}/upload-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.authHeaders ?? {})
},
body: JSON.stringify({
filename: `recording-${Date.now()}.webm`,
size: file.size,
mimeType: file.type,
metadata
})
});
if (!response.ok) {
throw new Error(`Failed to get upload token: ${response.status} ${response.statusText}`);
}
return (await response.json());
}
async singleUpload(file, uploadInfo, trackingId, signal) {
const xhr = new XMLHttpRequest();
// initial progress
this.emitProgress({
trackingId,
type: 'upload',
progress: 0,
bytesUploaded: 0,
totalBytes: file.size
});
return new Promise((resolve, reject) => {
const onAbort = () => {
xhr.abort();
reject(new Error('Upload cancelled'));
};
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = (event.loaded / event.total) * 100;
this.emitProgress({
trackingId,
type: 'upload',
progress,
bytesUploaded: event.loaded,
totalBytes: event.total
});
}
};
xhr.onload = () => {
// S3/Cloud providers often return 200 or 204 for PUT
if (xhr.status >= 200 && xhr.status < 300) {
this.activeUploads.delete(trackingId);
// finalize to 100% (in case the last progress didn't fire)
this.emitProgress({
trackingId,
type: 'upload',
progress: 100,
bytesUploaded: file.size,
totalBytes: file.size
});
resolve({ ...uploadInfo, trackingId });
}
else {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => {
reject(new Error('Upload failed due to network error'));
};
signal.addEventListener('abort', onAbort, { once: true });
try {
xhr.open('PUT', uploadInfo.uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
}
catch (e) {
reject(e);
}
});
}
async multipartUpload(file, uploadInfo, trackingId, signal) {
if (!uploadInfo.multipart) {
throw new Error('Multipart upload info not provided');
}
const { chunks } = uploadInfo.multipart;
const chunkSize = this.config.chunkSize ?? 5 * 1024 * 1024;
const parallelUploads = this.config.parallelUploads ?? 3;
let uploadedBytes = 0;
const completedChunks = [];
// initial progress
this.emitProgress({
trackingId,
type: 'upload',
progress: 0,
bytesUploaded: 0,
totalBytes: file.size
});
// Upload chunks in parallel batches
for (let i = 0; i < chunks.length; i += parallelUploads) {
const batch = chunks.slice(i, i + parallelUploads);
const batchPromises = batch.map(async (chunkInfo) => {
const start = chunkInfo.chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const response = await fetch(chunkInfo.uploadUrl, {
method: 'PUT',
body: chunk,
signal
});
if (!response.ok) {
throw new Error(`Chunk upload failed: ${response.status} ${response.statusText}`);
}
const etag = response.headers.get('ETag');
if (!etag) {
throw new Error('Missing ETag in chunk upload response');
}
uploadedBytes += chunk.size;
this.emitProgress({
trackingId,
type: 'upload',
progress: (uploadedBytes / file.size) * 100,
bytesUploaded: uploadedBytes,
totalBytes: file.size
});
return {
partNumber: chunkInfo.chunkIndex + 1,
etag: etag.replace(/"/g, '')
};
});
const batchResults = await Promise.all(batchPromises);
completedChunks.push(...batchResults);
}
// Complete multipart upload
await this.completeMultipartUpload(uploadInfo.multipart, completedChunks);
// finalize to 100%
this.emitProgress({
trackingId,
type: 'upload',
progress: 100,
bytesUploaded: file.size,
totalBytes: file.size
});
this.activeUploads.delete(trackingId);
return { ...uploadInfo, trackingId };
}
async completeMultipartUpload(multipartInfo, parts) {
const response = await fetch(`${this.config.apiEndpoint}/complete-upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.authHeaders ?? {})
},
body: JSON.stringify({
uploadId: multipartInfo.uploadId,
key: multipartInfo.key,
parts: parts.sort((a, b) => a.partNumber - b.partNumber)
})
});
if (!response.ok) {
throw new Error(`Failed to complete upload: ${response.status} ${response.statusText}`);
}
}
async retryUpload(trackingId) {
// Implementation would retrieve failed upload info and retry
throw new Error(`${trackingId} - Retry upload not yet implemented`);
}
async cancelUpload(trackingId) {
const controller = this.activeUploads.get(trackingId);
if (controller) {
controller.abort();
this.activeUploads.delete(trackingId);
}
}
onProgress(callback) {
this.progressCallbacks.add(callback);
}
offProgress(callback) {
this.progressCallbacks.delete(callback);
}
emitProgress(progress) {
this.progressCallbacks.forEach((callback) => {
try {
callback(progress);
}
catch (error) {
console.error('Error in progress callback:', error);
}
});
}
generateTrackingId() {
// e.g. rec_1700000000000_k3jx9q7c1
return `rec_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
cleanup() {
// Cancel all active uploads
this.activeUploads.forEach((controller) => controller.abort());
this.activeUploads.clear();
this.progressCallbacks.clear();
}
}
class StatusTracker {
constructor(config) {
Object.defineProperty(this, "config", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "pollingIntervals", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "completionCallbacks", {
enumerable: true,
configurable: true,
writable: true,
value: new Set()
});
Object.defineProperty(this, "errorCallbacks", {
enumerable: true,
configurable: true,
writable: true,
value: new Set()
});
this.config = config;
}
async getUploadStatus(trackingId) {
const response = await fetch(`${this.config.apiEndpoint}/status/${trackingId}`, {
headers: {
...(this.config.authHeaders ?? {})
}
});
if (!response.ok) {
throw new Error(`Failed to get status: ${response.status} ${response.statusText}`);
}
return (await response.json());
}
async listRecordings(userId, page = 1) {
const params = new URLSearchParams({
page: String(page),
...(userId ? { userId } : {})
});
const response = await fetch(`${this.config.apiEndpoint}/recordings?${params}`, {
headers: {
...(this.config.authHeaders ?? {})
}
});
if (!response.ok) {
throw new Error(`Failed to list recordings: ${response.status} ${response.statusText}`);
}
return (await response.json());
}
startPolling(trackingId, intervalMs) {
// Use provided interval, config interval, or default to 2000ms
const pollInterval = intervalMs ?? this.config.pollingIntervalMs ?? 2000;
if (this.pollingIntervals.has(trackingId)) {
return; // Already polling
}
const poll = async () => {
try {
const status = await this.getUploadStatus(trackingId);
if (status.status === 'completed') {
this.stopPolling(trackingId);
this.emitCompletion({
trackingId,
status: 'completed',
hlsUrl: status.urls?.hls,
mp4Url: status.urls?.mp4,
webmUrl: status.urls?.webm,
thumbnails: status.thumbnails,
// If your API returns duration or other fields, add them here.
});
}
else if (status.status === 'failed') {
this.stopPolling(trackingId);
this.emitError({
trackingId,
type: 'processing',
message: status.error || 'Processing failed',
retryable: false
});
}
}
catch (err) {
// Keep polling but surface a retryable network error
console.error('Polling error:', err);
this.emitError({
trackingId,
type: 'network',
message: 'Failed to check status',
retryable: true
});
}
};
const intervalId = window.setInterval(poll, pollInterval);
this.pollingIntervals.set(trackingId, intervalId);
// Initial poll immediately
void poll();
}
stopPolling(trackingId) {
const id = this.pollingIntervals.get(trackingId);
if (id !== undefined) {
window.clearInterval(id);
this.pollingIntervals.delete(trackingId);
}
}
onComplete(callback) {
this.completionCallbacks.add(callback);
}
onError(callback) {
this.errorCallbacks.add(callback);
}
emitCompletion(result) {
this.completionCallbacks.forEach((callback) => {
try {
callback(result);
}
catch (err) {
console.error('Error in completion callback:', err);
}
});
}
emitError(error) {
this.errorCallbacks.forEach((callback) => {
try {
callback(error);
}
catch (err) {
console.error('Error in error callback:', err);
}
});
}
cleanup() {
// Stop all polling
this.pollingIntervals.forEach((id) => window.clearInterval(id));
this.pollingIntervals.clear();
// Clear callbacks
this.completionCallbacks.clear();
this.errorCallbacks.clear();
}
}
class VideoRecorder {
constructor(config) {
Object.defineProperty(this, "recorder", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "uploader", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "tracker", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "config", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.config = {
maxDuration: 300, // 5 minutes default
videoQuality: 'medium',
audioEnabled: true,
chunkSize: 5 * 1024 * 1024, // 5MB
maxRetries: 3,
parallelUploads: 3,
pollingIntervalMs: 2000, // 2 seconds default
outputFormats: ['hls', 'mp4'],
thumbnailCount: 3,
showPreview: true,
...config
};
this.recorder = new WebcamRecorder(this.config);
this.uploader = new UploadManager(this.config);
this.tracker = new StatusTracker(this.config);
}
// Configuration
configure(options) {
// Update config
this.config = { ...this.config, ...options };
// Update recorder with new config (preserves stream and preview)
this.recorder.updateConfig(this.config);
// Recreate uploader and tracker with new config
this.uploader = new UploadManager(this.config);
this.tracker = new StatusTracker(this.config);
}
// Recording lifecycle
async initialize() {
await this.recorder.initialize();
}
// Preview management
setPreviewElement(element) {
this.recorder.setPreviewElement(element);
}
async startRecording() {
await this.recorder.startRecording();
}
async stopRecording() {
return this.recorder.stopRecording();
}
pauseRecording() {
this.recorder.pauseRecording();
}
resumeRecording() {
this.recorder.resumeRecording();
}
// Upload management
async uploadRecording(file, metadata) {
const result = await this.uploader.uploadRecording(file, metadata);
// Start polling for processing status
this.tracker.startPolling(result.trackingId);
return result;
}
async retryUpload(trackingId) {
return this.uploader.retryUpload(trackingId);
}
async cancelUpload(trackingId) {
await this.uploader.cancelUpload(trackingId);
this.tracker.stopPolling(trackingId);
}
// Progress tracking
onProgress(callback) {
this.uploader.onProgress(callback);
}
onComplete(callback) {
this.tracker.onComplete(callback);
}
onError(callback) {
this.tracker.onError(callback);
}
// Status queries
async getUploadStatus(trackingId) {
return this.tracker.getUploadStatus(trackingId);
}
async listRecordings(userId) {
return this.tracker.listRecordings(userId);
}
// Utility methods
getRecordingState() {
return this.recorder.getRecordingState();
}
/**
* Records video and automatically uploads when complete.
* Uses the existing event system for progress tracking and completion.
*/
async recordAndUpload(metadata) {
// Start recording
await this.startRecording();
// Wait for recording to complete (either manually stopped or max duration reached)
const recording = await this.waitForRecordingComplete();
// Upload the recording
const upload = await this.uploadRecording(recording.blob, metadata);
return { recording, upload };
}
/**
* Waits for recording to complete either by manual stop or max duration
*/
async waitForRecordingComplete() {
return new Promise((resolve, reject) => {
const checkRecordingState = () => {
const state = this.getRecordingState();
// Check if recording stopped manually
if (!state.isRecording) {
// Recording was stopped externally, resolve immediately
this.stopRecording().then(resolve).catch(reject);
return;
}
// Check if max duration reached
if (this.config.maxDuration && state.duration >= this.config.maxDuration) {
this.stopRecording().then(resolve).catch(reject);
return;
}
// Continue checking
setTimeout(checkRecordingState, 1000);
};
// Start checking after a short delay
setTimeout(checkRecordingState, 1000);
});
}
// Cleanup
cleanup() {
this.recorder.cleanup();
this.uploader.cleanup();
this.tracker.cleanup();
}
// Static utility methods
static async checkBrowserSupport() {
// Guard for non-browser environments
const hasWindow = typeof window !== 'undefined';
const hasNavigator = typeof navigator !== 'undefined';
const features = {
mediaRecorder: hasWindow && 'MediaRecorder' in window,
getUserMedia: hasNavigator &&
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
// Vendor-prefixed peer connection detection with safe casting
webrtc: hasWindow &&
(!!window.RTCPeerConnection ||
!!window
.webkitRTCPeerConnection)
};
const supported = features.mediaRecorder && features.getUserMedia;
const recommendations = [];
if (!features.mediaRecorder) {
recommendations.push('MediaRecorder API not supported. Consider using a WebRTC-based fallback.');
}
if (!features.getUserMedia) {
recommendations.push('getUserMedia not supported. Camera access unavailable.');
}
return recommendations.length
? { supported, features, recommendations }
: { supported, features };
}
static getRecommendedSettings(deviceType = 'desktop') {
if (deviceType === 'mobile') {
return {
videoQuality: 'medium',
maxDuration: 180, // 3 minutes
chunkSize: 2 * 1024 * 1024, // 2MB
parallelUploads: 2
};
}
return {
videoQuality: 'high',
maxDuration: 600, // 10 minutes
chunkSize: 10 * 1024 * 1024, // 10MB
parallelUploads: 4
};
}
}
const formatFileSize = (bytes) => {
// Guard invalid / tiny values
if (!Number.isFinite(bytes) || bytes <= 0)
return '0 B';
const k = 1024;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1);
const value = bytes / Math.pow(k, i);
// Keep two decimals, no parseFloat re-rounding needed
return `${value.toFixed(2)} ${units[i]}`;
};
const formatDuration = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0)
seconds = 0;
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
if (mins > 0) {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
return `${secs}s`;
};
const formatBitrate = (bitsPerSecond) => {
if (!Number.isFinite(bitsPerSecond) || bitsPerSecond <= 0)
return '0 kbps';
const kbps = bitsPerSecond / 1000;
if (kbps < 1000) {
return `${Math.round(kbps)} kbps`;
}
const mbps = kbps / 1000;
return `${mbps.toFixed(1)} Mbps`;
};
const formatTimestamp = (timestamp) => {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
// Fallback for invalid dates
if (!(date instanceof Date) || isNaN(date.getTime()))
return String(timestamp);
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
};
const formatProgress = (current, total) => {
if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0)
return '0%';
const percentage = Math.max(0, Math.min(100, (current / total) * 100));
return `${Math.round(percentage)}%`;
};
const formatUploadSpeed = (bytesPerSecond) => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0)
return '0 B/s';
return `${formatFileSize(bytesPerSecond)}/s`;
};
const formatETA = (remainingBytes, bytesPerSecond) => {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0)
return 'Calculating...';
if (!Number.isFinite(remainingBytes) || remainingBytes <= 0)
return '0s remaining';
const remainingSeconds = remainingBytes / bytesPerSecond;
if (remainingSeconds < 60) {
return `${Math.round(remainingSeconds)}s remaining`;
}
if (remainingSeconds < 3600) {
const minutes = Math.round(remainingSeconds / 60);
return `${minutes}m remaining`;
}
const hours = Math.floor(remainingSeconds / 3600);
const minutes = Math.round((remainingSeconds % 3600) / 60);
return `${hours}h ${minutes}m remaining`;
};
const truncateText = (text, maxLength) => {
if (maxLength <= 3 || text.length <= maxLength)
return text;
return `${text.substring(0, maxLength - 3)}...`;
};
function formatQualityLabel(quality) {
const map = {
low: 'Low (640×480)',
medium: 'Medium (1280×720)',
high: 'High (1920×1080)',
auto: 'Auto (Adaptive)'
};
return map[quality] ?? quality;
}
class BrowserSupportChecker {
static async checkSupport() {
const hasWindow = typeof window !== 'undefined';
const hasNavigator = typeof navigator !== 'undefined';
const features = {
mediaRecorder: hasWindow && 'MediaRecorder' in window,
getUserMedia: hasNavigator &&
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
webrtc: hasWindow &&
!!(window.RTCPeerConnection ||
window.webkitRTCPeerConnection),
indexedDB: hasWindow && 'indexedDB' in window,
serviceWorker: hasNavigator && 'serviceWorker' in navigator
};
const supported = features.mediaRecorder && features.getUserMedia;
const recommendations = [];
if (!features.mediaRecorder) {
recommendations.push('MediaRecorder API not supported. Consider using a WebRTC-based fallback.');
}
if (!features.getUserMedia) {
recommendations.push('getUserMedia not supported. Camera access unavailable.');
}
if (!features.webrtc) {
recommendations.push('WebRTC not supported. Real-time features may be limited.');
}
if (!features.indexedDB) {
recommendations.push('IndexedDB not supported. Offline capabilities unavailable.');
}
const deviceType = this.detectDeviceType();
const browserInfo = this.getBrowserInfo();
return recommendations.length
? { supported, features, recommendations, deviceType, browserInfo }
: { supported, features, deviceType, browserInfo };
}
static detectDeviceType() {
if (typeof navigator === 'undefined')
return 'desktop';
const ua = navigator.userAgent.toLowerCase();
// iPadOS can report as Mac; detect touch-capable Macs as tablets
const isIPadOS = /\biPad\b/.test(navigator.userAgent) ||
(/\bMacintosh\b/.test(navigator.userAgent) &&
'ontouchend' in (typeof window !== 'undefined' ? window : {}));
const isTablet = /ipad|android(?!.*mobile)/i.test(ua) || isIPadOS;
const isMobile = /android|webos|iphone|ipod|blackberry|iemobile|opera mini/i.test(ua) &&
!isTablet;
if (isTablet)
return 'tablet';
if (isMobile)
return 'mobile';
return 'desktop';
}
static getBrowserInfo() {
if (typeof navigator === 'undefined') {
return { name: 'Unknown', version: 'Unknown', engine: 'Unknown' };
}
const ua = navigator.userAgent;
let name = 'Unknown';
let version = 'Unknown';
let engine = 'Unknown';
if (ua.includes('Edg/')) {
name = 'Edge';
version = ua.match(/Edg\/(\d+)/)?.[1] ?? 'Unknown';
engine = 'Blink';
}
else if (ua.includes('Chrome') && !ua.includes('Edg') && !ua.includes('OPR')) {
name = 'Chrome';
version = ua.match(/Chrome\/(\d+)/)?.[1] ?? 'Unknown';
engine = 'Blink';
}
else if (ua.includes('Firefox')) {
name = 'Firefox';
version = ua.match(/Firefox\/(\d+)/)?.[1] ?? 'Unknown';
engine = 'Gecko';
}
else if (ua.includes('Safari') && !ua.includes('Chrome')) {
name = 'Safari';
version = ua.match(/Version\/(\d+)/)?.[1] ?? 'Unknown';
engine = 'WebKit';
}
else if (ua.includes('OPR/') || ua.includes('Opera')) {
name = 'Opera';
version = ua.match(/OPR\/(\d+)/)?.[1] ?? ua.match(/Opera\/(\d+)/)?.[1] ?? 'Unknown';
engine = 'Blink';
}
return { name, version, engine };
}
static getRecommendedSettings(deviceType = 'desktop') {
const baseSettings = {
audioEnabled: true,
showPreview: true,
maxRetries: 3
};
switch (deviceType) {
case 'mobile':
return {
...baseSettings,
videoQuality: 'medium',
maxDuration: 180,
chunkSize: 2 * 1024 * 1024,
parallelUploads: 2
};
case 'tablet':
return {
...baseSettings,
videoQuality: 'medium',
maxDuration: 300,
chunkSize: 5 * 1024 * 1024,
parallelUploads: 3
};
case 'desktop':
default:
return {
...baseSettings,
videoQuality: 'high',
maxDuration: 600,
chunkSize: 10 * 1024 * 1024,
parallelUploads: 4
};
}
}
static async testCameraAccess() {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) {
return { hasCamera: false, hasMicrophone: false, devices: [] };
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter((d) => d.kind === 'videoinput');
const audioDevices = devices.filter((d) => d.kind === 'audioinput');
return {
hasCamera: videoDevices.length > 0,
hasMicrophone: audioDevices.length > 0,
devices
};
}
catch {
return { hasCamera: false, hasMicrophone: false, devices: [] };
}
}
static async testRecordingCapability() {
const hasWindow = typeof window !== 'undefined';
const hasNavigator = typeof navigator !== 'undefined';
const hasMediaRecorder = hasWindow && 'MediaRecorder' in window && typeof window.MediaRecorder !== 'undefined';
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=h264,opus',
'video/webm',
'video/mp4;codecs=h264,aac',
'video/mp4'
];
const supportedMimeTypes = hasMediaRecorder
? candidates.filter((t) => window.MediaRecorder.isTypeSupported?.(t))
: [];
let maxResolution;
if (hasNavigator && navigator.mediaDevices?.getUserMedia) {
try {
// Try 1080p
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1920, height: 1080 }
});
const videoTrack = stream.getVideoTracks()[0];
const settings = videoTrack?.getSettings?.() ?? {};
maxResolution = {
width: settings.width ?? 1920,
height: settings.height ?? 1080
};
stream.getTracks().forEach((t) => t.stop());
}
catch {
// Fallback to 720p
maxResolution = { width: 1280, height: 720 };
}
}
return {
canRecord: supportedMimeTypes.length > 0,
supportedMimeTypes,
maxResolution
};
}
}
class VideoRecorderError extends Error {
constructor(message, type, options = {}) {
// If your TS/lib supports ErrorOptions, you can do: super(message, { cause: options.cause })
super(message);
Object.defineProperty(this, "type", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "code", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "retryable", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "trackingId", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.name = 'VideoRecorderError';
this.type = type;
this.code = options.code;
this.retryable = options.retryable ?? false;
this.trackingId = options.trackingId;
// Assign cause safely even if lib.dom doesn't include it
if (options.cause) {
this.cause = options.cause;
}
}
toErrorEvent() {
return {
trackingId: this.trackingId,
type: this.type,
message: this.message,
code: this.code,
retryable: this.retryable
};
}
}
class ErrorHandler {
static handleRecordingError(error, trackingId) {
if (error.name === 'NotAllowedError') {
return new VideoRecorderError('Camera access denied. Please allow camera permissions and try again.', 'recording', { code: 'CAMERA_ACCESS_DENIED', retryable: true, trackingId });
}
if (error.name === 'NotFoundError') {
return new VideoRecorderError('No camera device found. Please connect a camera and try again.', 'recording', { code: 'CAMERA_NOT_FOUND', retryable: false, trackingId });
}
if (error.name === 'NotReadableError') {
return new VideoRecorderError('Camera is already in use by another application.', 'recording', { code: 'CAMERA_IN_USE', retryable: true, trackingId });
}
if (error.name === 'OverconstrainedError') {
return new VideoRecorderError('Camera does not support the requested video quality. Try a lower quality setting.', 'recording', { code: 'UNSUPPORTED_CONSTRAINTS', retryable: true, trackingId });
}
return new VideoRecorderError(`Recording failed: ${error.message}`, 'recording', { retryable: true, trackingId, cause: error });
}
static handleUploadError(error, trackingId) {
if (error.name === 'AbortError') {
return new VideoRecorderError('Upload was cancelled.', 'upload', { code: 'UPLOAD_CANCELLED', retryable: false, trackingId });
}
if (error.message.includes('NetworkError') || error.message.includes('fetch')) {
return new VideoRecorderError('Network error during upload. Please check your connection and try again.', 'network', { code: 'NETWORK_ERROR', retryable: true, trackingId });
}
if (error.message.includes('413') || /too large/i.test(error.message)) {
return new VideoRecorderError('File is too large for upload. Try recording a shorter video or reducing quality.', 'upload', { code: 'FILE_TOO_LARGE', retryable: false, trackingId });
}
if (error.message.includes('403') || /forbidden/i.test(error.message)) {