removed obsolete files and robots.txt, robots.txt should be handled by reverse proxy instead
This commit is contained in:
BIN
Binary file not shown.
@@ -1,14 +0,0 @@
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Twitterbot
|
||||
Allow: /
|
||||
|
||||
User-agent: facebookexternalhit
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -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<TestExportResult> {
|
||||
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<string> {
|
||||
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<string>((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<ValidationResult> {
|
||||
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;
|
||||
Reference in New Issue
Block a user