Skip to main content
This example demonstrates how to structure animations for distributed rendering, where multiple workers render different frame ranges independently and outputs are stitched together.

Overview

The distributed rendering example shows:
  • Stateless frame rendering
  • Deterministic composition structure
  • Frame-independent state calculation
  • Optimizations for parallel execution

Complete implementation

composition.html
<!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>
main.ts
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;

Key patterns for distributed rendering

Stateless frame calculation

Each frame must be renderable independently without relying on previous frames:
// 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
}

Deterministic calculations

All calculations must be deterministic given only the frame number:
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;
}

Avoid temporal dependencies

Never store state between frames that affects rendering:
// 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);
});

Frame-derived randomness

Use deterministic random functions seeded by frame number:
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)
    }
}

Distributed rendering architecture

Worker assignment

Divide frame ranges among workers:
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

Frame seeking

Jump directly to any frame without replaying previous frames:
// 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);
    }
}

Output stitching

Combine worker outputs without re-encoding:
# 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'

Performance tips

Optimize initialization

Minimize per-frame initialization by separating setup from rendering:
// 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
}

Cache expensive calculations

Store results that don’t change between frames:
// 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
}

Use typed arrays

Typed arrays are faster for numeric data:
// 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
}

Minimize garbage collection

Reuse objects instead of creating new ones each frame:
// 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);
}

Advanced techniques

Chunk-based rendering

Render in chunks to balance load and enable checkpointing:
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}`);
}

Dynamic work distribution

Assign work based on worker speed:
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);
}

Fault tolerance

Implement retry logic and checkpointing:
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));
        }
    }
}

Parallel composition

Render multiple compositions simultaneously:
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;
}

Build docs developers (and LLMs) love