Drivers are the abstraction layer that connects Helios’s timeline to the rendering environment. They control how time advances and how animations synchronize.
Driver architecture
The TimeDriver interface
All drivers 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>;
}
See packages/core/src/drivers/TimeDriver.ts:15 for the full interface definition.
Driver selection
Helios automatically selects a driver based on configuration:
const helios = new Helios({
duration: 10,
fps: 30,
autoSyncAnimations: true // Uses DomDriver
});
Driver selection logic at Helios.ts:542:
if (options.driver) {
this.driver = options.driver; // Explicit driver
} else if (this.autoSyncAnimations) {
this.driver = new DomDriver(); // Auto-sync enabled
} else {
this.driver = new NoopDriver(); // No-op driver
}
Built-in drivers
NoopDriver
The simplest driver—does nothing. Used when you don’t need animation synchronization.
import { NoopDriver } from '@helios-project/core';
const helios = new Helios({
duration: 10,
fps: 30,
driver: new NoopDriver()
});
Use cases:
- Compositions with no CSS/WAAPI animations
- Manual animation control only
- Headless calculations without rendering
Implementation at packages/core/src/drivers/NoopDriver.ts:3:
export class NoopDriver implements TimeDriver {
init(scope: unknown) {}
update(timeInMs: number, options?: {...}) {}
waitUntilStable(): Promise<void> {
return Promise.resolve();
}
}
DomDriver
Synchronizes CSS animations, WAAPI timelines, and media elements in the browser.
import { DomDriver } from '@helios-project/core';
const helios = new Helios({
duration: 10,
fps: 30,
driver: new DomDriver(),
animationScope: document // Optional: scope to search for animations
});
Features:
- WAAPI animation seeking
- Media element (
<video>, <audio>) synchronization
- Shadow DOM support
- Audio track discovery and mixing
- Stability checks (fonts, images, media)
Implementation details:
See packages/core/src/drivers/DomDriver.ts:11 for the full implementation.
WAAPI sync
Media sync
Stability checks
// Simplified from DomDriver.ts:372
private syncWaapiAnimations(timeInMs: number) {
let anims: Animation[] = [];
// Discover animations in all scopes (document + shadow roots)
for (const scope of this.scopes) {
if (typeof (scope as any).getAnimations === 'function') {
const scopeAnims = (scope as any).getAnimations({ subtree: true });
scopeAnims.forEach((a: Animation) => anims.push(a));
}
}
// Seek all animations to target time
anims.forEach((anim: Animation) => {
anim.currentTime = timeInMs;
if (anim.playState !== 'paused') {
anim.pause();
}
});
}
// Simplified from DomDriver.ts:392
private syncMediaElements(timeInMs: number, options: {...}) {
const timeInSeconds = timeInMs / 1000;
this.mediaElements.forEach((el) => {
const offset = parseFloat(el.getAttribute('data-helios-offset') || '0');
const seek = parseFloat(el.getAttribute('data-helios-seek') || '0');
const timeRelToStart = timeInSeconds - offset;
const isBeforeStart = timeRelToStart < 0;
const targetTime = Math.max(0, timeRelToStart + seek);
if (options.isPlaying && !isBeforeStart) {
// Playback mode: play with drift correction
if (el.paused) el.play().catch(() => {});
if (Math.abs(el.currentTime - targetTime) > 0.25) {
el.currentTime = targetTime;
}
} else {
// Scrubbing mode: exact seeking
if (!el.paused) el.pause();
if (Math.abs(el.currentTime - targetTime) > 0.001) {
el.currentTime = targetTime;
}
}
});
}
// Simplified from DomDriver.ts:313
async waitUntilStable(): Promise<void> {
// 1. Wait for fonts
const fontPromise = document.fonts ? document.fonts.ready : Promise.resolve();
// 2. Wait for images
const images = Array.from(this.scope.querySelectorAll('img'));
const imagePromises = images.map(img => {
if (img.complete) return Promise.resolve();
return img.decode().catch(() => {});
});
// 3. Wait for media seeking
const mediaPromises = Array.from(this.mediaElements).map(el => {
return new Promise<void>(resolve => {
if (!el.seeking && el.readyState >= 2) return resolve();
el.addEventListener('seeked', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
});
});
await Promise.all([fontPromise, ...imagePromises, ...mediaPromises]);
}
CdpTimeDriver (Renderer)
Uses Chrome DevTools Protocol to virtualize time for deterministic server-side rendering.
import { CdpTimeDriver } from '@helios-project/renderer';
const driver = new CdpTimeDriver(30000); // 30s timeout
await driver.init(page);
await driver.prepare(page);
await driver.setTime(page, 1.5); // Advance to 1.5 seconds
How it works:
- Pause virtual time: Freeze the browser’s internal clock
- Sync media elements: Manually seek
<video>/<audio> to target time
- Advance virtual time: Resume clock for exact frame duration
- Wait for stability: Check for async operations to complete
See packages/renderer/src/drivers/CdpTimeDriver.ts:6 for implementation.
Initialization
Time advancement
Media synchronization
// From CdpTimeDriver.ts:19
async prepare(page: Page): Promise<void> {
this.client = await page.context().newCDPSession(page);
// Initialize virtual time to deterministic epoch
const INITIAL_VIRTUAL_TIME = 1704067200; // 2024-01-01T00:00:00Z
await this.client.send('Emulation.setVirtualTimePolicy', {
policy: 'pause',
initialVirtualTime: INITIAL_VIRTUAL_TIME
});
// Override performance.now() to match virtual time
await page.evaluate((epoch) => {
window.performance.now = () => Date.now() - epoch;
}, INITIAL_VIRTUAL_TIME * 1000);
this.currentTime = 0;
}
// From CdpTimeDriver.ts:39
async setTime(page: Page, timeInSeconds: number): Promise<void> {
const delta = timeInSeconds - this.currentTime;
if (delta <= 0) return;
const budget = delta * 1000; // Convert to milliseconds
// 1. Manually sync media elements BEFORE advancing time
await this.syncMediaElements(page, timeInSeconds);
// 2. Advance virtual time by exact budget
await new Promise<void>((resolve, reject) => {
this.client.once('Emulation.virtualTimeBudgetExpired', () => resolve());
this.client.send('Emulation.setVirtualTimePolicy', {
policy: 'advance',
budget: budget
}).catch(reject);
});
this.currentTime = timeInSeconds;
// 3. Wait for custom stability checks
await this.waitForStability(page);
}
// From CdpTimeDriver.ts:58
const mediaSyncScript = `
(async (t) => {
const findAllMedia = (root) => { /* ... */ };
const syncMedia = (el, time) => {
const offset = parseFloat(el.getAttribute('data-helios-offset') || '0');
const seek = parseFloat(el.getAttribute('data-helios-seek') || '0');
const targetTime = Math.max(0, time - offset + seek);
if (Math.abs(el.currentTime - targetTime) > 0.001) {
el.currentTime = targetTime;
}
};
const mediaElements = findAllMedia(document);
mediaElements.forEach(el => syncMedia(el, t));
})(${timeInSeconds})
`;
// Execute in all frames (supports iframes)
await Promise.all(page.frames().map(frame =>
frame.evaluate(mediaSyncScript).catch(e => {
console.warn('Failed to sync media in frame:', e);
})
));
CDP virtual time is deterministic because the browser’s event loop is paused until the time budget expires. This means requestAnimationFrame callbacks and CSS animations all update to the exact target time before rendering continues.
Data attributes
Control media playback with custom attributes:
<audio
data-helios-track-id="bgm"
data-helios-offset="2"
data-helios-seek="5"
data-helios-fade-in="1"
data-helios-fade-out="2"
data-helios-fade-easing="easeOut.cubic"
src="background.mp3"
></audio>
Attributes:
data-helios-track-id: Unique identifier for track control
data-helios-offset: When the track starts in composition time (seconds)
data-helios-seek: In-point in the media file (seconds)
data-helios-fade-in: Fade-in duration (seconds)
data-helios-fade-out: Fade-out duration (seconds)
data-helios-fade-easing: Easing function for fades
Audio track control
const helios = new Helios({
duration: 10,
fps: 30,
autoSyncAnimations: true,
volume: 0.8, // Master volume
audioTracks: {
'bgm': { volume: 0.5, muted: false },
'sfx': { volume: 1.0, muted: false }
}
});
// Change track volume
helios.setAudioTrackVolume('bgm', 0.3);
// Mute track
helios.setAudioTrackMuted('sfx', true);
// Master controls
helios.setAudioVolume(0.5);
helios.setAudioMuted(true);
Final volume calculation:
finalVolume = baseVolume * masterVolume * trackVolume * fadeInMultiplier * fadeOutMultiplier;
See DomDriver.ts:461 for fade and volume logic.
Audio track discovery
DomDriver automatically discovers audio tracks:
helios.availableAudioTracks.subscribe(tracks => {
console.log('Discovered tracks:', tracks);
// [
// {
// id: 'bgm',
// src: 'background.mp3',
// startTime: 2,
// duration: 120,
// fadeInDuration: 1,
// fadeOutDuration: 2,
// fadeEasing: 'easeOut.cubic'
// }
// ]
});
Custom drivers
Create a custom driver to integrate with non-standard time sources.
Example: External clock driver
import { TimeDriver } from '@helios-project/core';
export class ExternalClockDriver implements TimeDriver {
private externalClock: ExternalTimeSource;
init(scope: unknown) {
this.externalClock = new ExternalTimeSource();
}
update(timeInMs: number, options: {...}) {
// Sync external clock to Helios time
this.externalClock.setTime(timeInMs);
// Update dependent systems
if (options.isPlaying) {
this.externalClock.play(options.playbackRate);
} else {
this.externalClock.pause();
}
}
async waitUntilStable(): Promise<void> {
// Wait for external system to stabilize
await this.externalClock.waitForSync();
}
dispose() {
this.externalClock.disconnect();
}
}
// Use custom driver
const helios = new Helios({
duration: 10,
fps: 30,
driver: new ExternalClockDriver()
});
Example: MIDI timecode driver
import { TimeDriver } from '@helios-project/core';
export class MidiTimecodeDriver implements TimeDriver {
private midiAccess: any;
async init(scope: unknown) {
this.midiAccess = await navigator.requestMIDIAccess();
// Listen for MIDI timecode messages
}
update(timeInMs: number, options: {...}) {
// Send MIDI timecode to hardware
const timecode = this.convertToMTC(timeInMs);
this.sendMTC(timecode);
}
async waitUntilStable(): Promise<void> {
// Wait for MIDI buffer to flush
return new Promise(resolve => setTimeout(resolve, 10));
}
private convertToMTC(ms: number) {
// Convert milliseconds to MIDI Timecode format
// ...
}
private sendMTC(timecode: any) {
// Send via MIDI output
// ...
}
}
Stability patterns
Custom stability checks
Register async operations that should block rendering:
helios.registerStabilityCheck(async () => {
// Wait for Three.js scene to load
await scene.loadAssets();
});
helios.registerStabilityCheck(async () => {
// Wait for custom data fetch
await fetch('/api/data').then(r => r.json());
});
// Both checks run in parallel during waitUntilStable()
await helios.waitUntilStable();
See Helios.ts:876 for the stability check API.
Virtual time synchronization
When using bindToDocumentTimeline(), Helios polls __HELIOS_VIRTUAL_TIME__:
// Set by renderer (CdpTimeDriver)
window.__HELIOS_VIRTUAL_TIME__ = 1500; // 1.5 seconds
// Helios automatically syncs
helios.currentTime.value; // 1.5
Stability waiting ensures Helios catches up to virtual time:
// From Helios.ts:952
const virtualTimePromise = new Promise<void>((resolve) => {
const checkSync = () => {
const virtualTime = window.__HELIOS_VIRTUAL_TIME__;
const targetFrame = (virtualTime / 1000) * this._fps.value;
if (Math.abs(targetFrame - this._currentFrame.peek()) <= 0.01) {
resolve(); // Synced within 0.01 frame tolerance
} else {
requestAnimationFrame(checkSync); // Keep polling
}
};
checkSync();
});
Tickers
Drivers use tickers to manage the playback loop. Tickers are separate from drivers to allow different timing strategies.
RafTicker (default)
Uses requestAnimationFrame for smooth browser playback:
import { RafTicker } from '@helios-project/core';
const helios = new Helios({
duration: 10,
fps: 30,
ticker: new RafTicker()
});
TimeoutTicker
Uses setTimeout for Node.js or non-browser environments:
import { TimeoutTicker } from '@helios-project/core';
const helios = new Helios({
duration: 10,
fps: 30,
ticker: new TimeoutTicker()
});
ManualTicker
Manually advance time for testing:
import { ManualTicker } from '@helios-project/core';
const ticker = new ManualTicker();
const helios = new Helios({
duration: 10,
fps: 30,
ticker
});
helios.play();
// Manually tick
ticker.tick(33.33); // Advance by one frame (at 30fps)
ticker.tick(33.33);
See packages/core/src/drivers/ for all ticker implementations.
Best practices
Choose the right driver
- NoopDriver: Manual control, no animations
- DomDriver: CSS/WAAPI animations, media elements
- CdpTimeDriver: Server-side rendering only
Scope animations carefully
// Narrow scope for better performance
const helios = new Helios({
duration: 10,
fps: 30,
autoSyncAnimations: true,
animationScope: document.querySelector('#composition')
});
<audio preload="auto" src="audio.mp3"></audio>
This ensures media is ready before rendering starts.
Handle stability timeouts
For long-running async operations, increase the timeout:
const driver = new CdpTimeDriver(60000); // 60 second timeout
Or split into smaller stability checks:
helios.registerStabilityCheck(async () => {
await loadCriticalAssets();
});
helios.registerStabilityCheck(async () => {
await loadSecondaryAssets();
});
Next steps