/** * Video Export Test API * * Exposes a global API for automated testing of video exports. * * Usage in browser console or automated tests: * * // Run export with a test audio file * const result = await window.VideoExportTestAPI.runExport(audioFileBlob, { * width: 1920, * height: 1080, * fps: 60, * mode: 'combined' * }); * * // result = { success: boolean, url?: string, error?: string, stats: {...} } * * // Download the result * window.VideoExportTestAPI.downloadBlob(result.url, 'test-output.webm'); * * // Validate the blob (basic checks) * const validation = await window.VideoExportTestAPI.validateBlob(result.url); * // validation = { valid: boolean, size: number, type: string, issues: string[] } */ import type { OscilloscopeMode } from '../hooks/useOscilloscopeRenderer'; export interface TestExportOptions { width?: number; height?: number; fps?: number; mode?: OscilloscopeMode; } export interface TestExportResult { success: boolean; url?: string; error?: string; stats: { duration: number; blobSize: number; mimeType: string; exportTimeMs: number; }; } export interface ValidationResult { valid: boolean; size: number; type: string; issues: string[]; } // Simple audio analyzer for test purposes async function analyzeAudio(file: File): Promise<{ leftChannel: Float32Array; rightChannel: Float32Array; sampleRate: number; }> { const audioContext = new AudioContext(); const arrayBuffer = await file.arrayBuffer(); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); const leftChannel = audioBuffer.getChannelData(0); const rightChannel = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : leftChannel; await audioContext.close(); return { leftChannel, rightChannel, sampleRate: audioBuffer.sampleRate, }; } class VideoExportTestAPIClass { async runExport( audioFile: File | Blob, options: TestExportOptions = {} ): Promise { const startTime = performance.now(); const file = audioFile instanceof File ? audioFile : new File([audioFile], 'test-audio.mp3', { type: audioFile.type }); const opts = { width: options.width ?? 1920, height: options.height ?? 1080, fps: options.fps ?? 60, mode: options.mode ?? 'combined' as OscilloscopeMode, }; console.log('[VideoExportTestAPI] Starting export with options:', opts); try { // Analyze audio const audioData = await analyzeAudio(file); console.log('[VideoExportTestAPI] Audio analyzed:', { sampleRate: audioData.sampleRate, duration: audioData.leftChannel.length / audioData.sampleRate, samples: audioData.leftChannel.length, }); // Execute export const url = await this.executeExport(audioData, file, opts); const blob = await fetch(url).then(r => r.blob()); const exportTimeMs = performance.now() - startTime; const result: TestExportResult = { success: true, url, stats: { duration: audioData.leftChannel.length / audioData.sampleRate, blobSize: blob.size, mimeType: blob.type, exportTimeMs, }, }; console.log('[VideoExportTestAPI] Export completed:', result); return result; } catch (error) { const exportTimeMs = performance.now() - startTime; const result: TestExportResult = { success: false, error: error instanceof Error ? error.message : String(error), stats: { duration: 0, blobSize: 0, mimeType: '', exportTimeMs, }, }; console.error('[VideoExportTestAPI] Export failed:', result); return result; } } private async executeExport( audioData: { leftChannel: Float32Array; rightChannel: Float32Array; sampleRate: number }, audioFile: File, options: { width: number; height: number; fps: number; mode: OscilloscopeMode } ): Promise { const { width, height, fps, mode } = options; const totalSamples = audioData.leftChannel.length; const samplesPerFrame = Math.floor(audioData.sampleRate / fps); const log = (...args: unknown[]) => { console.log('[VideoExportTestAPI]', ...args); }; // Create canvas const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Could not get 2D context'); const leftColor = '#00ff00'; const rightColor = '#00ccff'; const xyColor = '#ff8800'; const dividerColor = '#333333'; const renderFrame = (startSample: number) => { ctx.fillStyle = 'black'; ctx.fillRect(0, 0, width, height); ctx.lineWidth = 2; const endSample = Math.min(startSample + samplesPerFrame, totalSamples); if (mode === 'combined') { ctx.strokeStyle = leftColor; ctx.beginPath(); const samplesPerPixel = samplesPerFrame / width; const centerY = height / 2; for (let x = 0; x < width; x++) { const sampleIndex = Math.floor(startSample + x * samplesPerPixel); if (sampleIndex >= totalSamples) break; const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2; const y = centerY - sample * (height * 0.4); if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } else if (mode === 'separate') { const halfHeight = height / 2; const samplesPerPixel = samplesPerFrame / width; // Left (top) ctx.strokeStyle = leftColor; ctx.beginPath(); const leftCenterY = halfHeight / 2; for (let x = 0; x < width; x++) { const sampleIndex = Math.floor(startSample + x * samplesPerPixel); if (sampleIndex >= totalSamples) break; const sample = audioData.leftChannel[sampleIndex]; const y = leftCenterY - sample * (halfHeight * 0.35); if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // Right (bottom) ctx.strokeStyle = rightColor; ctx.beginPath(); const rightCenterY = halfHeight + halfHeight / 2; for (let x = 0; x < width; x++) { const sampleIndex = Math.floor(startSample + x * samplesPerPixel); if (sampleIndex >= totalSamples) break; const sample = audioData.rightChannel[sampleIndex]; const y = rightCenterY - sample * (halfHeight * 0.35); if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // Divider ctx.strokeStyle = dividerColor; ctx.beginPath(); ctx.moveTo(0, halfHeight); ctx.lineTo(width, halfHeight); ctx.stroke(); } else if (mode === 'all') { const topHeight = height / 2; const bottomHeight = height / 2; const halfWidth = width / 2; const samplesPerPixel = samplesPerFrame / halfWidth; // Left (top-left) ctx.strokeStyle = leftColor; ctx.beginPath(); const leftCenterY = topHeight / 2; for (let x = 0; x < halfWidth; x++) { const sampleIndex = Math.floor(startSample + x * samplesPerPixel); if (sampleIndex >= totalSamples) break; const sample = audioData.leftChannel[sampleIndex]; const y = leftCenterY - sample * (topHeight * 0.35); if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // Right (top-right) ctx.strokeStyle = rightColor; ctx.beginPath(); const rightCenterY = topHeight / 2; for (let x = 0; x < halfWidth; x++) { const sampleIndex = Math.floor(startSample + x * samplesPerPixel); if (sampleIndex >= totalSamples) break; const sample = audioData.rightChannel[sampleIndex]; const y = rightCenterY - sample * (topHeight * 0.35); if (x === 0) ctx.moveTo(halfWidth + x, y); else ctx.lineTo(halfWidth + x, y); } ctx.stroke(); // XY (bottom half) ctx.strokeStyle = xyColor; ctx.beginPath(); const xyCenterX = width / 2; const xyCenterY = topHeight + bottomHeight / 2; const xyScale = Math.min(halfWidth, bottomHeight) * 0.35; for (let i = startSample; i < endSample; i++) { const x = xyCenterX + audioData.leftChannel[i] * xyScale; const y = xyCenterY - audioData.rightChannel[i] * xyScale; if (i === startSample) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // Dividers ctx.strokeStyle = dividerColor; ctx.beginPath(); ctx.moveTo(0, topHeight); ctx.lineTo(width, topHeight); ctx.stroke(); ctx.beginPath(); ctx.moveTo(halfWidth, 0); ctx.lineTo(halfWidth, topHeight); ctx.stroke(); } }; // Setup recording const videoStream = canvas.captureStream(fps); const audioContext = new AudioContext(); await audioContext.resume(); const audioArrayBuffer = await audioFile.arrayBuffer(); const audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer); const audioSource = audioContext.createBufferSource(); audioSource.buffer = audioBuffer; const audioDestination = audioContext.createMediaStreamDestination(); audioSource.connect(audioDestination); const combinedStream = new MediaStream([ ...videoStream.getVideoTracks(), ...audioDestination.stream.getAudioTracks(), ]); let mimeType = 'video/webm;codecs=vp8,opus'; if (!MediaRecorder.isTypeSupported(mimeType)) { mimeType = 'video/webm;codecs=vp9,opus'; } if (!MediaRecorder.isTypeSupported(mimeType)) { mimeType = 'video/webm'; } const mediaRecorder = new MediaRecorder(combinedStream, { mimeType, videoBitsPerSecond: 8000000, audioBitsPerSecond: 256000, }); const chunks: Blob[] = []; return new Promise((resolve, reject) => { let stopped = false; const stopRecorder = (reason: string) => { if (stopped) return; stopped = true; log('stopRecorder', reason); if (mediaRecorder.state === 'recording') { mediaRecorder.stop(); } }; mediaRecorder.ondataavailable = (e) => { log('ondataavailable', { size: e.data?.size, type: e.data?.type }); if (e.data && e.data.size > 0) { chunks.push(e.data); } }; mediaRecorder.onstop = async () => { log('onstop', { chunks: chunks.length }); await audioContext.close(); combinedStream.getTracks().forEach(t => t.stop()); const blob = new Blob(chunks, { type: mimeType }); log('final blob', { size: blob.size }); if (blob.size === 0) { reject(new Error('Empty blob')); return; } resolve(URL.createObjectURL(blob)); }; mediaRecorder.onerror = (e) => reject(e); audioSource.onended = () => { log('audioSource.onended'); renderFrame(Math.max(0, totalSamples - samplesPerFrame)); stopRecorder('audio_ended'); }; // Start recording mediaRecorder.start(); const exportStart = audioContext.currentTime; audioSource.start(0); log('started', { duration: audioBuffer.duration }); // Safety timeout setTimeout(() => stopRecorder('timeout'), (audioBuffer.duration + 30) * 1000); // Render loop let lastFrame = -1; const loop = () => { if (stopped) return; const t = Math.max(0, audioContext.currentTime - exportStart); const frameIndex = Math.floor(t * fps); if (frameIndex !== lastFrame) { renderFrame(Math.min(frameIndex * samplesPerFrame, totalSamples - 1)); lastFrame = frameIndex; } requestAnimationFrame(loop); }; requestAnimationFrame(loop); }); } async validateBlob(url: string): Promise { const issues: string[] = []; try { const response = await fetch(url); const blob = await response.blob(); if (blob.size === 0) { issues.push('Blob is empty'); } if (!blob.type.includes('webm')) { issues.push(`Unexpected MIME type: ${blob.type}`); } // Check WebM magic bytes const header = await blob.slice(0, 4).arrayBuffer(); const bytes = new Uint8Array(header); // WebM starts with 0x1A 0x45 0xDF 0xA3 (EBML header) if (bytes[0] !== 0x1A || bytes[1] !== 0x45 || bytes[2] !== 0xDF || bytes[3] !== 0xA3) { issues.push('Invalid WebM header (missing EBML magic bytes)'); } return { valid: issues.length === 0, size: blob.size, type: blob.type, issues, }; } catch (error) { return { valid: false, size: 0, type: '', issues: [error instanceof Error ? error.message : String(error)], }; } } downloadBlob(url: string, filename: string = 'test-export.webm') { const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); } } // Expose globally for testing const api = new VideoExportTestAPIClass(); declare global { interface Window { VideoExportTestAPI: VideoExportTestAPIClass; } } if (typeof window !== 'undefined') { window.VideoExportTestAPI = api; } export const VideoExportTestAPI = api;