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