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