No After Effects. No Premiere Pro. No Figma. Just code.
This repo contains the full pipeline for creating high-quality product promo videos using nothing but HTML/CSS/JS animations rendered to MP4 via Playwright (headless Chrome) and FFmpeg.
Built for CooBear β a group dinner expense tracker.
βββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββ
β HTML/CSS/JS ββββββΆβ Playwright ββββββΆβ FFmpeg β
β Animation β β Frame Capture β β MP4 Encode β
β (single file) β β (30 FPS PNGs) β β (H.264) β
βββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββ
- Design your animation as a single HTML file with CSS animations and JS-driven sequencing
- Capture every frame using Playwright's headless Chrome with a virtual time system
- Encode to MP4 using FFmpeg with high-quality H.264 settings
The key innovation is the virtual time system β the render script overrides setTimeout so animations run in deterministic, frame-perfect time rather than real-time. This means every frame is captured exactly right, regardless of how fast or slow your machine is.
.
βββ README.md
βββ package.json
βββ render-video.js # π₯ The rendering engine
βββ coobear_dinner_animation_v2.html # Animation: iMessage chat sequence
βββ coobear_dinner_animation_v3_followup.html # Animation: app walkthrough
βββ coobear_uber_edit.html # Animation: Uber-style edit
βββ COOBEAR_WHITE_FF.PNG # Logo asset
βββ render_frames/ # (generated) PNG frames during render
βββ rendered/ # (generated) Final MP4 output
- Node.js v18+
- Google Chrome installed (used as the rendering engine)
- FFmpeg installed
brew install ffmpegsudo apt install ffmpegDownload from ffmpeg.org and add to PATH.
# Clone the repo
git clone https://github.com/AVinashthorat2211/code-to-video.git
cd code-to-video
# Install dependencies
npm install# Render the default animation
node render-video.js
# Render a specific animation
node render-video.js coobear_dinner_animation_v2.html
# Render with background audio
node render-video.js coobear_dinner_animation_v3_followup.html audio.mp3The rendered MP4 will appear in the rendered/ directory.
Create a single HTML file with your animation. The file must:
- Expose
window.__animationDurationMsβ total duration in milliseconds - Expose
window.__animationDoneβ set totruewhen animation completes - Define a
runSequence()function β the main animation entry point - Support
?render=1query parameter β to disable auto-restart loops
<!DOCTYPE html>
<html>
<head>
<style>
/* Your styles here */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
font-family: -apple-system, sans-serif;
}
.element {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.element.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div id="scene">
<h1 class="element" id="title">Your Product</h1>
<p class="element" id="subtitle">A tagline here</p>
</div>
<script>
// Required: sleep helper using setTimeout (gets overridden by renderer)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Required: detect render mode
const renderMode = new URLSearchParams(window.location.search).get("render") === "1";
// Required: expose these globals
window.__animationDone = false;
window.__animationDurationMs = 5000; // total duration in ms
// Required: main animation function
async function runSequence() {
window.__animationDone = false;
// Reset all elements
document.querySelectorAll('.element').forEach(el => {
el.classList.remove('visible');
});
// Animate!
await sleep(500);
document.getElementById('title').classList.add('visible');
await sleep(1000);
document.getElementById('subtitle').classList.add('visible');
await sleep(2000);
// ... more animation steps
window.__animationDone = true;
// Auto-restart for browser preview (not during render)
if (!renderMode) {
setTimeout(runSequence, 1000);
}
}
// Start the animation
runSequence();
</script>
</body>
</html>Open your HTML file directly in Chrome to preview:
open your_animation.htmlThe animation will loop continuously so you can tweak it.
node render-video.js your_animation.htmlEdit the constants at the top of render-video.js:
| Variable | Default | Description |
|---|---|---|
FPS |
30 |
Frames per second |
WIDTH |
1280 |
Video width in pixels |
HEIGHT |
720 |
Video height in pixels |
CHROME_PATH |
/Applications/Google Chrome.app/... |
Path to Chrome executable |
| OS | Path |
|---|---|
| macOS | /Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
| Linux | /usr/bin/google-chrome or /usr/bin/chromium-browser |
| Windows | C:\Program Files\Google\Chrome\Application\chrome.exe |
Update CHROME_PATH in render-video.js for your platform.
The renderer replaces setTimeout with a virtual time-controlled version. This means:
- β Frame-perfect captures β no dropped frames, no timing issues
- β Machine-independent β same output on fast or slow hardware
- β Deterministic β render the same animation twice, get identical output
The virtual time advances exactly 1000/FPS milliseconds per frame (33.33ms at 30 FPS).
| Technique | How |
|---|---|
| iMessage chat bubbles | CSS border-radius, flexbox layout, opacity transitions |
| Typing indicators | Three dots with staggered @keyframes bounce |
| Slide-up reveals | transform: translateY(10px) β translateY(0) with opacity |
| Screen transitions | Layer-based system with .on class toggling display: grid |
| Camera crosshair | Absolute-positioned element with CSS pseudo-elements |
| Flash effect | Full-screen white overlay with quick opacity pulse |
| Progress bars | CSS width transition on inner div |
| Button press | transform: scale(0.985) on .pressed class |
Pass an audio file as the second argument:
node render-video.js animation.html background_music.mp3The audio will be encoded as AAC at 256kbps. The -shortest flag ensures the video length matches the shorter of video/audio.
- Use system fonts β
-apple-system, "SF Pro Display", sans-seriffor native feel - Keep transitions snappy β 300-400ms is the sweet spot
- Add breathing room β
await sleep(500-800)between major beats - Layer your scenes β use absolute-positioned sections toggled with classes
- Design at 16:9 β use
aspect-ratio: 16/9on your frame container - Retina quality β the renderer captures at 2x
deviceScaleFactor - Use
sleep()for all timing β never usesetIntervalorrequestAnimationFrame
A conversation between friends deciding where to eat, ending with a CooBear logo reveal. Features typing indicators, read receipts, and a camera flash transition.
Multi-scene product demo: search β expense details β PDF download β iMessage share β logo. Shows the full CooBear user flow with crosshair click interactions.
MIT β use this pipeline for your own product videos!
Built by @thoratarchit@gmail.com
If you use this to make a product video, tag me β I'd love to see it! π»