UNPKG

@onamfc/video-transcoder

Version:

Backend-agnostic video recording and transcoding module with AWS integration

1,389 lines (1,380 loc) 54.5 kB
import { useState, useRef, useEffect, useCallback } from 'react'; 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 useVideoRecorder = ({ config, onComplete, onError, onProgress }) => { const [recorder, setRecorder] = useState(null); const [isInitialized, setIsInitialized] = useState(false); const [isRecording, setIsRecording] = useState(false); const [isPaused, setIsPaused] = useState(false); const [duration, setDuration] = useState(0); const [progress, setProgress] = useState(0); const [status, setStatus] = useState('Ready to initialize'); const [error, setError] = useState(null); const [lastRecording, setLastRecording] = useState(null); const durationIntervalRef = useRef(); // Initialize recorder when config changes useEffect(() => { const newRecorder = new VideoRecorder(config); // Set up event listeners newRecorder.onProgress((progressEvent) => { setProgress(progressEvent.progress); onProgress?.(progressEvent); if (progressEvent.type === 'upload') { setStatus(`Uploading: ${Math.round(progressEvent.progress)}%`); } else { setStatus(`Processing: ${progressEvent.stage || 'In progress'}`); } }); newRecorder.onComplete((result) => { setProgress(0); setStatus('Processing completed!'); onComplete?.(result); }); newRecorder.onError((errorEvent) => { setError(errorEvent.message); setStatus('Error occurred'); onError?.(errorEvent); }); setRecorder(newRecorder); return () => { newRecorder.cleanup(); }; }, [config, onComplete, onError, onProgress]); // Update duration during recording useEffect(() => { if (isRecording && !isPaused && recorder) { durationIntervalRef.current = setInterval(() => { const state = recorder.getRecordingState(); setDuration(Math.round(state.duration)); }, 1000); } else { if (durationIntervalRef.current) { clearInterval(durationIntervalRef.current); } } return () => { if (durationIntervalRef.current) { clearInterval(durationIntervalRef.current); } }; }, [isRecording, isPaused, recorder]); const initialize = useCallback(async () => { if (!recorder) return; try { setError(null); setStatus('Initializing camera...'); await recorder.initialize(); setIsInitialized(true); setStatus('Camera ready'); } catch (err) { const errorMessage = `Failed to access camera: ${err.message}`; setError(errorMessage); setStatus('Initialization failed'); throw err; } }, [recorder]); const startRecording = useCallback(async () => { if (!recorder) return; try { setError(null); await recorder.startRecording(); setIsRecording(true); setIsPaused(false); setDuration(0); setStatus('Recording...'); } catch (err) { const errorMessage = `Failed to start recording: ${err.message}`; setError(errorMessage); throw err; } }, [recorder]); const stopRecording = useCallback(async () => { if (!recorder) throw new Error('Recorder not initialized'); try { setStatus('Stopping recording...'); const recording = await recorder.stopRecording(); setLastRecording(recording); setIsRecording(false); setIsPaused(false); setStatus('Recording stopped'); return recording; } catch (err) { const errorMessage = `Failed to stop recording: ${err.message}`; setError(errorMessage); throw err; } }, [recorder]); const pauseRecording = useCallback(() => { if (!recorder) return; recorder.pauseRecording(); setIsPaused(true); setStatus('Recording paused'); }, [recorder]); const resumeRecording = useCallback(() => { if (!recorder) return; recorder.resumeRecording(); setIsPaused(false); setStatus('Recording resumed'); }, [recorder]); const uploadRecording = useCallback(async (blob, metadata) => { if (!recorder) throw new Error('Recorder not initialized'); try { setStatus('Uploading...'); setProgress(0); const result = await recorder.uploadRecording(blob, metadata); setStatus('Upload completed'); return result; } catch (err) { const errorMessage = `Upload failed: ${err.message}`; setError(errorMessage); throw err; } }, [recorder]); const clearError = useCallback(() => { setError(null); }, []); const getRecordingState = useCallback(() => { return recorder?.getRecordingState() || { isRecording: false, isPaused: false, duration: 0 }; }, [recorder]); const cleanup = useCallback(() => { if (durationIntervalRef.current) { clearInterval(durationIntervalRef.current); } recorder?.cleanup(); }, [recorder]); return { // State isInitialized, isRecording, isPaused, duration, progress, status, error, lastRecording, // Actions initialize, startRecording, stopRecording, pauseRecording, resumeRecording, uploadRecording, clearError, // Utilities getRecordingState, cleanup }; }; 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) {