Optimize Whammy implementation: Use fixed 30fps and precise frame interpolation

This commit is contained in:
2025-12-28 11:46:13 +11:00
parent 04dd7de2f2
commit c90dc96d12

113
main.js
View File

@@ -696,71 +696,86 @@ async function downloadVideo() {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
// Calculate total duration from the last frame's timing
downloadBtn.disabled = false;
downloadBtn.textContent = '⬇ Download';
};
recorder.onerror = (e) => {
console.error('MediaRecorder error:', e);
throw new Error('Recording failed');
};
// Start with timeslice to ensure proper chunking and duration
recorder.start(100); // Request data every 100ms
// Calculate frame duration based on filmstrip timing
const totalDuration = videoFrames[videoFrames.length - 1].timing; const totalDuration = videoFrames[videoFrames.length - 1].timing;
const frameDuration = totalDuration / videoFrames.length; console.log(`Compiling video: ${videoFrames.length} source frames, total ${totalDuration}ms`);
console.log(`Rendering ${videoFrames.length} frames over ${totalDuration}ms (${frameDuration}ms per frame)`); // We will generate a frame for every 1/30th of a second
const frameInterval = 1000 / fps; // ~33.33ms
const totalOutputFrames = Math.ceil(totalDuration / frameInterval);
// Render frames with proper timing // Pre-load all images
for (let i = 0; i < videoFrames.length; i++) { const loadedImages = await Promise.all(videoFrames.map(async frame => {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
return new Promise((resolve, reject) => {
await new Promise((resolve, reject) => { img.onload = () => resolve({ img, timing: frame.timing });
img.onload = resolve; img.onerror = () => resolve(null); // Skip failed frames
img.onerror = () => reject(new Error('Failed to load frame')); img.src = frame.data;
img.src = videoFrames[i].data;
}); });
}));
// Render this frame multiple times to fill the duration const validImages = loadedImages.filter(i => i !== null);
const renderCount = Math.max(1, Math.ceil(frameDuration / 33)); // 33ms per render at 30fps
for (let r = 0; r < renderCount; r++) { // Generate video frames
// Black background for (let i = 0; i < totalOutputFrames; i++) {
ctx.fillStyle = '#000'; const currentTime = i * frameInterval;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Center and scale image // Find the image that should be displayed at this time
const scale = Math.min(canvas.width / img.width, canvas.height / img.height); // It's the latest image whose timing is <= currentTime
const x = (canvas.width - img.width * scale) / 2; let currentImage = validImages[0];
const y = (canvas.height - img.height * scale) / 2; for (let j = 0; j < validImages.length; j++) {
ctx.drawImage(img, x, y, img.width * scale, img.height * scale); if (validImages[j].timing <= currentTime) {
currentImage = validImages[j];
// Add timestamp overlay } else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; break;
ctx.fillRect(10, canvas.height - 50, 200, 40); }
ctx.fillStyle = '#fff';
ctx.font = 'bold 24px Arial';
ctx.fillText(`${(videoFrames[i].timing / 1000).toFixed(1)}s`, 20, canvas.height - 20);
// Wait for next frame at 30fps
await new Promise(r => setTimeout(r, 33));
} }
// Draw frame
// Clear and draw black background
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Center and scale image (Contain)
const scale = Math.min(canvas.width / currentImage.img.width, canvas.height / currentImage.img.height);
const x = (canvas.width - currentImage.img.width * scale) / 2;
const y = (canvas.height - currentImage.img.height * scale) / 2;
// Use high quality image smoothing
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(currentImage.img, x, y, currentImage.img.width * scale, currentImage.img.height * scale);
// Add timestamp overlay (crisp text)
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(20, canvas.height - 70, 220, 50);
ctx.fillStyle = '#fff';
ctx.font = 'bold 32px Arial';
ctx.fillText(`${(currentTime / 1000).toFixed(1)}s`, 40, canvas.height - 35);
// Add frame to encoder
encoder.add(canvas);
// Yield to UI thread occasionally to prevent freezing
if (i % 15 === 0) await new Promise(r => setTimeout(r, 0));
} }
// Add a small delay before stopping to ensure all data is captured // Compile and download
await new Promise(r => setTimeout(r, 200)); const outputBlob = encoder.compile();
const url = URL.createObjectURL(outputBlob);
// Stop recording after all frames are rendered const a = document.createElement('a');
recorder.stop(); a.href = url;
a.download = `page-load-${Date.now()}.webm`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error('Video download error:', error); console.error('Video download error:', error);
alert(`Failed to create video: ${error.message}\n\nYour browser may not support this feature. Try using Chrome or Edge.`); alert(`Failed to create video: ${error.message}\n\nYour browser may not support this feature. Try using Chrome or Edge.`);
} finally {
downloadBtn.disabled = false; downloadBtn.disabled = false;
downloadBtn.textContent = '⬇ Download'; downloadBtn.textContent = '⬇ Download';
} }