Skip to main content

Overview

Helios uses a driver-based architecture to synchronize animations across different rendering contexts. The TimeDriver interface allows you to create custom implementations for specialized workflows beyond DOM and canvas.

TimeDriver interface

All drivers must implement the TimeDriver interface:
export interface TimeDriver {
  init(scope: unknown): void;
  update(timeInMs: number, options?: {
    isPlaying: boolean;
    playbackRate: number;
    volume?: number;
    muted?: boolean;
    audioTracks?: Record<string, { volume: number; muted: boolean }>;
  }): void;
  waitUntilStable(): Promise<void>;
  dispose?(): void;
  subscribeToMetadata?(callback: (meta: DriverMetadata) => void): () => void;
  getAudioContext?(): Promise<unknown>;
  getAudioSourceNode?(trackId: string): Promise<unknown>;
}

Required methods

init(scope: unknown) Called once when the Helios instance is created. Use this to set up your rendering context.
init(scope: unknown) {
  this.scope = scope as MyCustomContext;
  // Initialize resources, scan for elements, etc.
}
update(timeInMs: number, options) Called every frame to synchronize your rendering context to the given time.
update(timeInMs: number, options) {
  const { isPlaying, playbackRate, volume, muted } = options;
  // Update animations, audio, etc. to match timeInMs
}
waitUntilStable() Return a promise that resolves when all assets are loaded and ready to render.
async waitUntilStable() {
  // Wait for fonts, images, models, etc.
  await Promise.all(this.pendingAssets);
}

Optional methods

dispose() - Clean up resources when the instance is destroyed subscribeToMetadata(callback) - Notify Helios about discovered audio tracks getAudioContext() - Return Web Audio API context for mixing getAudioSourceNode(trackId) - Return audio source node for a specific track

Built-in drivers

DomDriver

The default driver for HTML/CSS animations. It:
  • Scans for animations using document.getAnimations()
  • Seeks animations via animation.currentTime
  • Discovers shadow DOM boundaries
  • Syncs <audio> and <video> elements
  • Tracks audio metadata with data-helios-track-id

NoopDriver

A no-op driver that does nothing. Useful for:
  • Pure canvas compositions with manual timing
  • Testing
  • Headless environments without DOM
import { Helios, NoopDriver } from '@helios-project/core';

const helios = new Helios({
  duration: 10,
  fps: 30,
  driver: new NoopDriver()
});

Creating a custom driver

Example: Three.js driver

Here’s a custom driver for synchronizing Three.js animations:
import { TimeDriver } from '@helios-project/core';
import * as THREE from 'three';

interface ThreeScope {
  scene: THREE.Scene;
  mixer?: THREE.AnimationMixer;
}

export class ThreeDriver implements TimeDriver {
  private scope: ThreeScope | null = null;
  private mixer: THREE.AnimationMixer | null = null;

  init(scope: unknown) {
    this.scope = scope as ThreeScope;
    
    // Create animation mixer if scene is provided
    if (this.scope.scene) {
      this.mixer = new THREE.AnimationMixer(this.scope.scene);
    } else if (this.scope.mixer) {
      this.mixer = this.scope.mixer;
    }
  }

  update(timeInMs: number) {
    if (!this.mixer) return;
    
    // Convert to seconds and set mixer time
    const timeInSeconds = timeInMs / 1000;
    this.mixer.setTime(timeInSeconds);
  }

  async waitUntilStable() {
    // Wait for GLTF models, textures, etc.
    if (!this.scope?.scene) return;
    
    const promises: Promise<void>[] = [];
    
    this.scope.scene.traverse((obj) => {
      if (obj instanceof THREE.Mesh) {
        const material = obj.material as THREE.MeshStandardMaterial;
        if (material.map && !material.map.image) {
          promises.push(
            new Promise((resolve) => {
              material.map!.onLoad = () => resolve();
            })
          );
        }
      }
    });
    
    await Promise.all(promises);
  }

  dispose() {
    this.mixer = null;
    this.scope = null;
  }
}

Using the custom driver

import { Helios } from '@helios-project/core';
import { ThreeDriver } from './ThreeDriver';
import * as THREE from 'three';

const scene = new THREE.Scene();
const mixer = new THREE.AnimationMixer(scene);

// Load animations and add to mixer
const clip = THREE.AnimationClip.CreateFromMorphTargetSequence(/* ... */);
const action = mixer.clipAction(clip);
action.play();

const helios = new Helios({
  duration: 10,
  fps: 30,
  driver: new ThreeDriver(),
  animationScope: { scene, mixer }
});

helios.subscribe((state) => {
  renderer.render(scene, camera);
});

Custom tickers

Drivers control where animations happen. Tickers control when frames are rendered during preview.

Ticker interface

export interface Ticker {
  start(callback: TickCallback): void;
  stop(): void;
}

type TickCallback = (deltaTime: number) => void;

Built-in tickers

RafTicker - Uses requestAnimationFrame (default for browser) TimeoutTicker - Uses setTimeout for Node.js environments ManualTicker - Allows manual frame stepping for tests

Example: Fixed timestep ticker

import { Ticker, TickCallback } from '@helios-project/core';

export class FixedTicker implements Ticker {
  private interval: NodeJS.Timeout | null = null;
  private callback: TickCallback | null = null;
  private fps: number;

  constructor(fps: number = 60) {
    this.fps = fps;
  }

  start(cb: TickCallback) {
    this.callback = cb;
    const frameTime = 1000 / this.fps;
    
    this.interval = setInterval(() => {
      if (this.callback) {
        this.callback(frameTime);
      }
    }, frameTime);
  }

  stop() {
    if (this.interval) {
      clearInterval(this.interval);
      this.interval = null;
    }
    this.callback = null;
  }
}

Audio track discovery

Drivers can notify Helios about audio tracks for mixing and export.
import { TimeDriver, DriverMetadata, AudioTrackMetadata } from '@helios-project/core';

export class CustomAudioDriver implements TimeDriver {
  private tracks = new Map<string, AudioTrackMetadata>();
  private subscribers = new Set<(meta: DriverMetadata) => void>();

  init(scope: unknown) {
    // Scan for audio sources
    this.discoverAudioTracks();
  }

  private discoverAudioTracks() {
    // Example: Scan custom audio elements
    const audioElements = document.querySelectorAll('[data-audio-track]');
    
    audioElements.forEach((el) => {
      const id = el.getAttribute('data-audio-track')!;
      const src = el.getAttribute('src') || '';
      const offset = parseFloat(el.getAttribute('data-offset') || '0');
      
      this.tracks.set(id, {
        id,
        src,
        startTime: offset,
        duration: 0, // Will be updated when metadata loads
        fadeInDuration: 0,
        fadeOutDuration: 0
      });
    });
    
    this.notifySubscribers();
  }

  subscribeToMetadata(callback: (meta: DriverMetadata) => void) {
    this.subscribers.add(callback);
    
    // Immediately notify with current state
    callback({ audioTracks: Array.from(this.tracks.values()) });
    
    return () => this.subscribers.delete(callback);
  }

  private notifySubscribers() {
    const metadata = { audioTracks: Array.from(this.tracks.values()) };
    this.subscribers.forEach(cb => cb(metadata));
  }

  update(timeInMs: number, options) {
    // Sync audio playback
  }

  async waitUntilStable() {
    // Wait for audio to load
  }
}
Custom drivers must be deterministic. The same timeInMs should always produce identical visual output, regardless of how many times update() is called or in what order.

Best practices

Use existing drivers when possible - DomDriver handles most HTML/CSS/SVG use cases Make drivers stateless - Don’t accumulate state across update() calls Handle disposal properly - Clean up event listeners, contexts, and resources Test with manual ticker - Use ManualTicker to verify frame-perfect accuracy Document scope requirements - Make it clear what animationScope should contain

Next steps

Build docs developers (and LLMs) love