Overview
The distributed rendering example shows:- Stateless frame rendering
- Deterministic composition structure
- Frame-independent state calculation
- Optimizations for parallel execution
Complete implementation
composition.html
main.ts
Build compositions optimized for distributed rendering with stateless frame seeking
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas Composition</title>
<style>
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background-color: #111;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<canvas id="composition-canvas"></canvas>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
import { Helios, HeliosState } from '@helios-project/core';
const canvas = document.getElementById('composition-canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
console.log(`Canvas resized to ${canvas.width}x${canvas.height}`);
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
const duration = 5; // seconds
const fps = 30;
const helios = new Helios({
duration,
fps
});
helios.bindToDocumentTimeline();
function draw(currentFrame: number) {
const time = currentFrame / fps * 1000; // in ms
const progress = (time % (duration * 1000)) / (duration * 1000);
const { width, height } = canvas;
// Clear
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, width, height);
const x = progress * width;
const y = height / 2;
const radius = 50;
// Draw moving circle
ctx.fillStyle = 'royalblue';
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
// Draw rotating square
const squareSize = 100;
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate(progress * Math.PI * 2);
ctx.fillStyle = 'tomato';
ctx.fillRect(-squareSize / 2, -squareSize / 2, squareSize, squareSize);
ctx.restore();
}
helios.subscribe((state: HeliosState) => {
draw(state.currentFrame);
});
declare global {
interface Window {
helios: Helios;
}
}
window.helios = helios;
// Good - stateless, frame-independent
function draw(currentFrame: number) {
const progress = currentFrame / totalFrames;
const x = progress * width;
// All state derived from currentFrame
}
// Bad - stateful, depends on previous frames
let x = 0;
function draw() {
x += velocity; // Requires previous frame's x value
}
function draw(currentFrame: number) {
// Frame-based time (deterministic)
const time = currentFrame / fps;
// Deterministic progress calculation
const progress = (time % duration) / duration;
// All positions derived from frame number
const x = progress * width;
const rotation = progress * Math.PI * 2;
}
// Good - no temporal dependencies
helios.subscribe((state) => {
const angle = (state.currentFrame / 30) * Math.PI * 2;
draw(angle);
});
// Bad - state accumulates across frames
let angle = 0;
helios.subscribe(() => {
angle += 0.1; // Non-deterministic across frame ranges
draw(angle);
});
import { random } from '@helios-project/core';
function draw(currentFrame: number) {
// Same frame always produces same random values
for (let i = 0; i < 100; i++) {
const seed = currentFrame * 1000 + i;
const x = random(seed) * width;
const y = random(seed + 10000) * height;
// Draw element at (x, y)
}
}
const totalFrames = duration * fps;
const workerCount = 4;
const framesPerWorker = Math.ceil(totalFrames / workerCount);
const assignments = Array.from({ length: workerCount }, (_, i) => ({
workerId: i,
startFrame: i * framesPerWorker,
endFrame: Math.min((i + 1) * framesPerWorker, totalFrames)
}));
// Worker 0: frames 0-37
// Worker 1: frames 38-75
// Worker 2: frames 76-112
// Worker 3: frames 113-150
// In distributed worker
function renderFrameRange(startFrame: number, endFrame: number) {
for (let frame = startFrame; frame < endFrame; frame++) {
// Seek directly to frame (no replay needed)
helios.currentFrame.value = frame;
// Render frame
draw(frame);
// Capture output
captureFrame(frame);
}
}
# Concatenate video segments using ffmpeg
ffmpeg -f concat -safe 0 -i segments.txt -c copy output.mp4
# segments.txt:
# file 'segment_0.mp4'
# file 'segment_1.mp4'
# file 'segment_2.mp4'
# file 'segment_3.mp4'
// One-time setup
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;
const helios = new Helios({ duration: 10, fps: 30 });
// Pre-calculate constant values
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Fast per-frame rendering
function draw(frame: number) {
const angle = (frame / 30) * Math.PI * 2;
// Use pre-calculated constants
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ... render frame
}
// Pre-calculate path data
const pathPoints = generateComplexPath();
// Reuse in each frame
function draw(frame: number) {
const progress = frame / totalFrames;
const index = Math.floor(progress * pathPoints.length);
const point = pathPoints[index];
// Render at point
}
// Pre-allocate typed arrays
const positions = new Float32Array(PARTICLE_COUNT * 2);
const colors = new Uint8ClampedArray(PARTICLE_COUNT * 4);
function draw(frame: number) {
for (let i = 0; i < PARTICLE_COUNT; i++) {
// Update positions
positions[i * 2] = calculateX(frame, i);
positions[i * 2 + 1] = calculateY(frame, i);
}
// Render from typed arrays
}
// Good - reuse object
const point = { x: 0, y: 0 };
function draw(frame: number) {
point.x = calculateX(frame);
point.y = calculateY(frame);
render(point);
}
// Bad - creates garbage
function draw(frame: number) {
const point = { x: calculateX(frame), y: calculateY(frame) };
render(point);
}
const CHUNK_SIZE = 30; // 1 second at 30fps
async function renderChunk(startFrame: number) {
const endFrame = Math.min(startFrame + CHUNK_SIZE, totalFrames);
const frames: Blob[] = [];
for (let frame = startFrame; frame < endFrame; frame++) {
helios.currentFrame.value = frame;
const blob = await captureFrame();
frames.push(blob);
}
// Save chunk
await saveChunk(startFrame, frames);
// Report progress
console.log(`Rendered frames ${startFrame}-${endFrame}`);
}
class WorkQueue {
private queue: number[] = [];
private completed = 0;
constructor(totalFrames: number, chunkSize: number) {
for (let i = 0; i < totalFrames; i += chunkSize) {
this.queue.push(i);
}
}
getNextChunk(): number | null {
return this.queue.shift() ?? null;
}
markComplete(startFrame: number) {
this.completed++;
console.log(`Progress: ${this.completed}/${this.queue.length}`);
}
}
// Workers request chunks dynamically
const queue = new WorkQueue(150, 30);
while (true) {
const chunk = queue.getNextChunk();
if (!chunk) break;
await renderChunk(chunk);
queue.markComplete(chunk);
}
async function renderWithRetry(frame: number, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
helios.currentFrame.value = frame;
const result = await captureFrame();
await saveFrame(frame, result);
return;
} catch (error) {
console.warn(`Frame ${frame} failed (attempt ${attempt + 1}/${maxRetries})`);
if (attempt === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
async function renderParallel(compositions: string[], frameRange: [number, number]) {
const results = await Promise.all(
compositions.map(async (comp) => {
const iframe = createIsolatedFrame(comp);
return await renderRange(iframe, ...frameRange);
})
);
return results;
}