diff --git a/intro.mp4 b/intro.mp4 deleted file mode 100644 index 4d479a9..0000000 Binary files a/intro.mp4 and /dev/null differ diff --git a/intro.webm b/intro.webm deleted file mode 100644 index 8c86073..0000000 Binary files a/intro.webm and /dev/null differ diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 6018e70..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,14 +0,0 @@ -User-agent: Googlebot -Allow: / - -User-agent: Bingbot -Allow: / - -User-agent: Twitterbot -Allow: / - -User-agent: facebookexternalhit -Allow: / - -User-agent: * -Allow: / diff --git a/videoExportTestApi.ts b/videoExportTestApi.ts deleted file mode 100755 index b59de92..0000000 --- a/videoExportTestApi.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * 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;