Rendering to video
Turn an effect into an MP4 or WebM: record it in the design editor, or render it frame by frame from Remotion, Hyperframes or your own headless Chrome script.
Recording in the design editor
Requires Shaders Pro.
Open the recording dialog from the editor toolbar, pick a duration, frame rate and format, and the editor renders the effect and downloads the file. MP4 and WebM are available; WebM keeps a transparent background when the effect has one. This is the fastest route when the effect stands on its own and you don't need it inside a larger edit.
Rendering frame by frame
The other routes share one idea. A video renderer asks for frame 120, then frame 7, often from several browser tabs at once, and every request must give the same image. Wall-clock time can't do that, so the headless renderer's renderFrame() takes a deltaSeconds option that advances the effect by exactly that many seconds:
import { createRendererFromJSON } from 'shaders/core'
const renderer = createRendererFromJSON({
components: [{ type: 'Aurora', props: { speed: 1 } }],
})
await renderer.initialize(canvas)
await renderer.renderFrame({ deltaSeconds: 2 }) // the frame two seconds in
The first call with deltaSeconds frame-locks the renderer: its clocks restart at zero and it stops drawing on its own. From then on, time only moves when you pass a delta. A negative delta rewinds, so scrubbing backwards works. Each section below applies this to one tool.
Rendering with Remotion
Remotion gives each render a frame number through useCurrentFrame. Keep the renderer in a ref, remember how many seconds it has advanced, and render the difference. delayRender holds the frame until the canvas is drawn:
import { useEffect, useRef } from 'react'
import { useCurrentFrame, useVideoConfig, delayRender, continueRender } from 'remotion'
import { createRendererFromJSON } from 'shaders/core'
export function ShaderScene({ preset }) {
const canvasRef = useRef(null)
const rendererRef = useRef(null)
const advancedRef = useRef(0) // seconds rendered so far in this tab
const frame = useCurrentFrame()
const { fps, width, height } = useVideoConfig()
useEffect(() => {
const handle = delayRender('shader frame')
;(async () => {
if (!rendererRef.current) {
rendererRef.current = createRendererFromJSON(preset)
await rendererRef.current.initialize(canvasRef.current)
}
const target = frame / fps
await rendererRef.current.renderFrame({ deltaSeconds: target - advancedRef.current })
advancedRef.current = target
continueRender(handle)
})()
}, [frame, fps, preset])
return <canvas ref={canvasRef} width={width} height={height} style={{ width, height }} />
}
Remotion renders in parallel tabs, and each tab tracks its own advanced time, so a tab that starts at frame 150 lands on the same pixels as one that stepped there. On Remotion 4, render with --gl=angle (or chromiumOptions: { gl: 'angle' } in the Node API) so headless Chrome exposes WebGPU. Remotion 5 turns this on by default.
Rendering with Hyperframes
Hyperframes seeks the page to each frame and fires an hf-seek event with the time in seconds. Render the difference from the last seek and hand the promise to waitUntil, so the frame isn't captured until the canvas is drawn:
<div data-composition-id="aurora" data-start="0" data-duration="4"
data-width="1920" data-height="1080" data-requires-webgpu>
<canvas id="fx" width="1920" height="1080"></canvas>
</div>
<script type="module">
import { createRendererFromJSON } from 'shaders/core'
const renderer = createRendererFromJSON({
components: [{ type: 'Aurora', props: { speed: 1 } }],
})
const ready = renderer.initialize(document.getElementById('fx'))
let advanced = 0
window.addEventListener('hf-seek', (e) => {
const done = ready.then(() => {
const delta = e.detail.time - advanced
advanced = e.detail.time
return renderer.renderFrame({ deltaSeconds: delta })
})
e.detail.waitUntil(done) // waitUntil must be called synchronously in the listener
})
</script>
data-requires-webgpu on the composition tells Hyperframes the block needs a GPU. Then render as usual:
npx hyperframes render --fps 60 --output aurora.mp4
Rendering with Puppeteer or Playwright
For your own pipeline, launch Chrome with WebGPU enabled, step the renderer one frame at a time, and read each frame back with toBlob():
import puppeteer from 'puppeteer'
import { mkdir, writeFile } from 'node:fs/promises'
const browser = await puppeteer.launch({
args: ['--enable-unsafe-webgpu', '--use-angle=metal'], // --use-angle=vulkan on Linux
})
try {
const page = await browser.newPage()
await page.goto('http://localhost:5173/scene.html') // creates the renderer as `window.renderer`
await mkdir('frames', { recursive: true })
const fps = 60
for (let frame = 0; frame < 300; frame++) {
const dataUrl = await page.evaluate(async (dt) => {
await window.renderer.renderFrame({ deltaSeconds: dt, waitForGpu: false })
const blob = await new Promise(resolve => window.canvas.toBlob(resolve, 'image/png'))
return new Promise(resolve => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.readAsDataURL(blob)
})
}, frame === 0 ? 0 : 1 / fps) // frame 0 is time zero; every later frame advances one step
const png = Buffer.from(dataUrl.split(',')[1], 'base64')
await writeFile(`frames/${String(frame).padStart(4, '0')}.png`, png)
}
} finally {
await browser.close()
}
Then encode the folder with ffmpeg. toBlob() waits for the GPU itself, which is why waitForGpu: false is safe here and saves a fence per frame. Keep the default if you read the canvas any other way, such as page.screenshot() or drawImage into another canvas, because those read whatever is on screen and can catch the previous frame. Without the launch flags, headless Chrome reports a WebGPU adapter but draws nothing.
Simulations depend on the step order
Simulation components keep state from one frame to the next: CursorRipples, DataMosh, FloatingParticles, Boids, and any other effect whose particles or ripples carry over between frames. For them a jump of 2s isn't the same as 120 steps of 1/60s, by design. Render those in order, in one tab, at the output frame rate. Every other component gives the same pixels however you reach a frame.