-
-
Save peterc/8a8ac6e25904c9aee28a325c0f24d392 to your computer and use it in GitHub Desktop.
// Open a supplied HTML file and record whatever's going on to an MP4. | |
// | |
// Usage: node recorder.cjs <path_to_html_file> | |
// Dependencies: npm install puppeteer fluent-ffmpeg | |
// (and yes, you need ffmpeg installed) | |
// | |
// It expects a <canvas> element to be on the page as it waits for | |
// that to load in first, but you can edit the code below if you | |
// don't want that. | |
// | |
// This code is 'do whatever you want'. Let's just say public domain. | |
// It's scrappy and just an example of an idea that I needed for a | |
// quick script. | |
const puppeteer = require('puppeteer'); | |
const ffmpeg = require('fluent-ffmpeg'); | |
const fs = require('fs'); | |
const path = require('path'); | |
const TEMP_DIR = path.join(__dirname, 'temp_frames'); | |
const DURATION = 5; // <<<<< Put your desired recording time here! | |
// Needs a temporary folder to store the frames we catch | |
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR); | |
async function captureVideo() { | |
const browser = await puppeteer.launch({ | |
headless: "new", | |
args: ['--window-size=1280,820'] | |
}); | |
// ^^^ I had to increase the window size to 820 vertical as I think some | |
// browser chrome is getting in the way? | |
const page = await browser.newPage(); | |
// Change the viewport if you really want to | |
await page.setViewport({ width: 1280, height: 720 }); | |
await page.goto(`file:${path.join(__dirname, process.argv[2] || 'animation.html')}`); | |
// Change this if you have no canvas | |
await page.waitForSelector('canvas'); | |
const client = await page.target().createCDPSession(); | |
await client.send('Page.startScreencast', { format: 'png', quality: 100, everyNthFrame: 1 }); | |
let frameCount = 0; | |
client.on('Page.screencastFrame', async (frame) => { | |
const filename = path.join(TEMP_DIR, `frame_${String(frameCount).padStart(5, '0')}.png`); | |
fs.writeFileSync(filename, frame.data, 'base64'); | |
frameCount++; | |
await client.send('Page.screencastFrameAck', { sessionId: frame.sessionId }); | |
}); | |
// This feels like a silly way to do it but hey it works | |
await new Promise(resolve => setTimeout(resolve, DURATION * 1000)); | |
await client.send('Page.stopScreencast'); | |
await browser.close(); | |
const effectiveFPS = frameCount / DURATION; | |
return new Promise((resolve, reject) => { | |
ffmpeg() | |
.input(path.join(TEMP_DIR, 'frame_%05d.png')) | |
.inputFPS(effectiveFPS) | |
.output('output.mp4') | |
.videoCodec('libx264') | |
.outputOptions([ | |
'-pix_fmt yuv420p', | |
'-profile:v high', | |
'-level 4.0', | |
'-preset veryslow', | |
'-crf 17', | |
'-movflags +faststart' | |
]) | |
.on('end', resolve) | |
.on('error', reject) | |
.run(); | |
}); | |
} | |
// Get rid of that temporary folder the frames are in | |
function cleanup() { | |
fs.rmSync(TEMP_DIR, { recursive: true, force: true }); | |
} | |
(async function main() { | |
try { | |
await captureVideo(); | |
cleanup(); | |
console.log('Video generated: output.mp4'); | |
} catch (error) { | |
console.error(error); | |
cleanup(); | |
process.exit(1); | |
} | |
})(); |
I've done a little research and all avenues are reasonably negative, though also quite old. There are ways to get the FPS up but the last statement from the Chrome team I saw said there were no plans to be able to force an FPS. However, if you ack the frame as quickly as possible, you will get the most speed out of it. So I could move the "ack" to the very start of the callback (before saving the image to disk) and that could theoretically get the potential FPS up.
My next steps if I were super interested in this (and FPS isn't really my focus) would be to create an HTML page that renders frame numbers to a canvas at 60fps, tweak this script, then see what the output is like and if any frames are being "dropped". Then go from there.
So actually just out of curiosity I did what I said above and I'm getting the full 60fps without even editing the script :)
Here's the HTML document that generates the frame numbers:
<!DOCTYPE html>
<html>
<head>
<title>Frame Counter</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
}
canvas {
border: 1px solid #333;
}
</style>
</head>
<body>
<canvas id="counter" width="1280" height="720"></canvas>
<script>
const canvas = document.getElementById('counter');
const ctx = canvas.getContext('2d');
let frameCount = 0;
let lastTime = performance.now();
let fps = 0;
function updateFrame(currentTime) {
// Calculate actual FPS
const deltaTime = currentTime - lastTime;
fps = Math.round(1000 / deltaTime);
lastTime = currentTime;
// Clear canvas
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw frame number
ctx.fillStyle = '#fff';
ctx.font = 'bold 200px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(frameCount, canvas.width / 2, canvas.height / 2);
// Draw FPS counter
ctx.font = '20px Arial';
ctx.textAlign = 'left';
ctx.fillText(`FPS: ${fps}`, 10, 30);
frameCount++;
requestAnimationFrame(updateFrame);
}
requestAnimationFrame(updateFrame);
</script>
</body>
</html>
output.mp4
And here's the video.
Thank you very much for the investigation. So, probably, it's just possible (optimizing the script to ack the frame instantly in case 60FPS are not achived) to achive the maximum perfomance needed.
Thank again for the work!
Yes. I didn't do any further testing but using getAnimationFrame may also be a key part. In my research I did read that if a frame doesn't change, it's not sent! So you can end up with skipped frames and you're meant to simply duplicate the frame at the other end by yourself if you want to create an accurate video. I have no idea if that's still the case but something to be aware of.
This is amazing! Great work! You wouldn't believe the lengths I went to to get this kind of thing working in the past.
Just curious, but have you seen Page.screencast in the Puppeteer docs? It claims that it can write out a WEBM at 30FPS. I've never tried it tho.
I have, however this more manual approach does seem to get the 60fps I wanted. It might make it far simpler though!
Wow, this seems very nice! I've used a tool in the past but was very tricky because it override setTimeout on the browser, so CSS animations didn't work, only JS works. This behavior was to achive 60FPS, blocking the JS animation to take the screenshot and continue till the end.
I've a question about this method, that seems much more natural using screncast feature, how can I force 60FPS? Thank you very much!