Helios provides precise control over video playback and timeline navigation through a clean, imperative API.
Core timeline concepts
Time representations
Helios uses two primary time representations:
- Frames: Integer frame numbers (0, 1, 2, …)
- Seconds: Floating-point time values (0.0, 0.033, 0.067, …)
The relationship is always: time = frame / fps
const helios = new Helios({ duration: 10, fps: 30 });
// Frame 90 = 3 seconds (90 / 30)
helios.seek(90);
console.log(helios.currentTime.value); // 3.0
// 5 seconds = frame 150 (5 * 30)
helios.seekToTime(5);
console.log(helios.currentFrame.value); // 150
Reactive state
All timeline state is exposed as signals for reactive programming:
import { Helios } from '@helios-project/core';
const helios = new Helios({ duration: 10, fps: 30 });
// Signals update automatically
helios.currentFrame.subscribe(frame => {
console.log('Current frame:', frame);
});
helios.currentTime.subscribe(time => {
console.log('Current time:', time);
});
helios.seek(100); // Triggers both subscriptions
See packages/core/src/Helios.ts:123 for signal definitions.
Playback controls
Play and pause
const helios = new Helios({ duration: 10, fps: 30 });
// Start playback
helios.play();
// Pause playback
helios.pause();
// Check playback state
if (helios.isPlaying.value) {
console.log('Video is playing');
}
Playback implementation at Helios.ts:885:
public play() {
if (this._isPlaying.peek()) return;
this._isPlaying.value = true;
// Sync driver immediately
this.driver.update((this._currentFrame.peek() / this._fps.value) * 1000, {
isPlaying: true,
playbackRate: this._playbackRate.peek(),
volume: this._volume.peek(),
muted: this._muted.peek(),
audioTracks: this._audioTracks.peek()
});
this.ticker.start(this.onTick);
}
Seeking
Seek by frame
Seek by time
Seek to marker
// Seek to frame 150
helios.seek(150);
// Seek to end
const totalFrames = helios.duration.value * helios.fps.value;
helios.seek(totalFrames);
// Relative seeking
const current = helios.currentFrame.value;
helios.seek(current + 30); // Forward 1 second at 30fps
helios.seek(current - 30); // Back 1 second
Frames are automatically clamped to [0, duration * fps].// Seek to 5 seconds
helios.seekToTime(5);
// Seek to midpoint
helios.seekToTime(helios.duration.value / 2);
// Relative seeking
const current = helios.currentTime.value;
helios.seekToTime(current + 1.5); // Forward 1.5 seconds
Time is converted to frames internally: frame = seconds * fpsconst helios = new Helios({
duration: 10,
fps: 30,
markers: [
{ id: 'intro', time: 0, label: 'Introduction' },
{ id: 'main', time: 3, label: 'Main Content' },
{ id: 'outro', time: 8, label: 'Conclusion' }
]
});
// Seek to marker by ID
helios.seekToMarker('main'); // Jumps to 3 seconds
See Helios.ts:809 for marker seeking implementation.
Looping
const helios = new Helios({
duration: 10,
fps: 30,
loop: true // Enable looping
});
helios.play();
// Playback will restart from frame 0 when reaching the end
// Toggle looping dynamically
helios.setLoop(false);
Loop logic at Helios.ts:1229:
if (shouldLoop && rangeDuration > 0) {
if (playbackRate > 0 && nextFrame >= endFrame) {
// Wrap around
const overflow = nextFrame - startFrame;
this._currentFrame.value = startFrame + (overflow % rangeDuration);
}
// ...
}
Playback rate
Control playback speed:
const helios = new Helios({ duration: 10, fps: 30 });
// Normal speed
helios.setPlaybackRate(1.0);
// Half speed (slow motion)
helios.setPlaybackRate(0.5);
// Double speed
helios.setPlaybackRate(2.0);
// Reverse playback
helios.setPlaybackRate(-1.0);
// Access current rate
console.log(helios.playbackRate.value);
Playback rate affects:
- Frame advancement during
play()
- Audio/video element playback (via drivers)
- Animation speed (when using time-based animations)
Playback rate does not affect seeking. seek(100) always jumps to frame 100 regardless of playback rate.
Playback range
Limit playback to a specific frame range:
const helios = new Helios({
duration: 10,
fps: 30,
playbackRange: [60, 180] // Frames 60-180 (2-6 seconds)
});
// Or set dynamically
helios.setPlaybackRange(90, 150); // Frames 90-150 (3-5 seconds)
// Clear range to use full duration
helios.clearPlaybackRange();
Playback range affects:
- Where
play() starts (at range start)
- Where playback stops (at range end)
- Loop boundaries (wraps within range)
const helios = new Helios({
duration: 10,
fps: 30,
loop: true,
playbackRange: [60, 120]
});
helios.play();
// Starts at frame 60, loops back to 60 when reaching 120
See Helios.ts:832 for playback range API.
Timeline binding
Helios can be driven by different time sources:
Standalone playback (default)
const helios = new Helios({ duration: 10, fps: 30 });
helios.play(); // Drives its own playback loop
Uses a ticker (RAF or setTimeout) to advance frames.
Bound to document timeline
const helios = new Helios({ duration: 10, fps: 30 });
helios.bindToDocumentTimeline();
// Now reads from document.timeline.currentTime or __HELIOS_VIRTUAL_TIME__
Critical for server-side rendering: The renderer sets __HELIOS_VIRTUAL_TIME__ via CDP, and bound instances automatically sync to it.
See Helios.ts:1085 for binding implementation.
When bound to document timeline, calling play() has no effect—time is controlled externally.
Bound to another Helios instance
const master = new Helios({ duration: 10, fps: 30 });
const slave = new Helios({ duration: 10, fps: 30 });
slave.bindTo(master);
// slave now mirrors master's playback state
master.play();
// Both instances play in sync
See Helios.ts:994 for instance binding.
Unbinding
helios.unbind(); // Stop syncing to external time source
helios.unbindFromDocumentTimeline(); // Specific unbind
Frame-accurate rendering
For deterministic rendering, Helios provides a RenderSession API:
import { RenderSession } from '@helios-project/core';
const helios = new Helios({ duration: 10, fps: 30 });
const session = new RenderSession(helios, {
startFrame: 0,
endFrame: 299 // Render 300 frames (10 seconds at 30fps)
});
for await (const frame of session) {
console.log('Rendering frame', frame);
await helios.waitUntilStable();
// Capture frame here
}
See packages/core/src/render-session.ts:9 for implementation.
Stability waiting
Before capturing each frame, wait for all async operations to complete:
helios.seek(150);
await helios.waitUntilStable();
// Now safe to capture—fonts loaded, images decoded, media seeked
Stability checks at Helios.ts:943:
public async waitUntilStable(): Promise<void> {
const driverPromise = this.driver.waitUntilStable();
const checks = Array.from(this._stabilityChecks);
const checkPromises = checks.map(check => check());
await Promise.all([driverPromise, ...checkPromises, virtualTimePromise]);
}
This waits for:
- Driver stability (fonts, images, media)
- Custom registered checks
- Virtual time sync (if bound to document timeline)
State subscription
Subscribe to all state changes at once:
const unsubscribe = helios.subscribe(state => {
console.log('Current frame:', state.currentFrame);
console.log('Current time:', state.currentTime);
console.log('Is playing:', state.isPlaying);
console.log('Duration:', state.duration);
console.log('FPS:', state.fps);
console.log('Active clips:', state.activeClips);
console.log('Active captions:', state.activeCaptions);
});
// Unsubscribe when done
unsubscribe();
Full state type at Helios.ts:9:
export type HeliosState<TInputProps = Record<string, any>> = {
width: number;
height: number;
duration: number;
fps: number;
currentFrame: number;
loop: boolean;
isPlaying: boolean;
inputProps: TInputProps;
playbackRate: number;
volume: number;
muted: boolean;
audioTracks: Record<string, AudioTrackState>;
availableAudioTracks: AudioTrackMetadata[];
captions: CaptionCue[];
activeCaptions: CaptionCue[];
activeClips: HeliosClip[];
markers: Marker[];
playbackRange: [number, number] | null;
currentTime: number;
};
Dynamic configuration
Most configuration can be changed at runtime:
Duration and FPS
const helios = new Helios({ duration: 10, fps: 30 });
// Change duration (preserves current frame if possible)
helios.setDuration(15);
// Change FPS (preserves current time in seconds)
helios.setFps(60);
Changing FPS recalculates the current frame to maintain the same time position. For example, if you’re at 3 seconds (frame 90 at 30fps), changing to 60fps moves you to frame 180.
Dimensions
helios.setSize(1280, 720); // Change to 720p
Useful for responsive compositions or multi-resolution rendering.
Advanced timeline patterns
Scrubbing with momentum
let isDragging = false;
let velocity = 0;
timeline.addEventListener('pointerdown', () => {
isDragging = true;
velocity = 0;
});
timeline.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const delta = e.movementX;
velocity = delta * 2; // Pixels to frames
const current = helios.currentFrame.value;
helios.seek(current + velocity);
});
timeline.addEventListener('pointerup', () => {
isDragging = false;
// Momentum scrolling
const decelerate = () => {
if (Math.abs(velocity) < 0.5) return;
const current = helios.currentFrame.value;
helios.seek(current + velocity);
velocity *= 0.9; // Friction
requestAnimationFrame(decelerate);
};
decelerate();
});
Time remapping
import { interpolate } from '@helios-project/core';
const helios = new Helios({ duration: 10, fps: 30 });
helios.subscribe(({ currentFrame }) => {
// Slow-motion in middle third
const remapped = interpolate(
currentFrame,
[0, 100, 200, 300],
[0, 100, 150, 300] // Slow section: frames 100-200 map to 100-150
);
// Use remapped time for animations
element.style.transform = `translateX(${remapped}px)`;
});
Frame-by-frame control
const helios = new Helios({ duration: 10, fps: 30 });
document.addEventListener('keydown', (e) => {
const current = helios.currentFrame.value;
if (e.key === 'ArrowRight') {
helios.seek(current + 1); // Next frame
} else if (e.key === 'ArrowLeft') {
helios.seek(current - 1); // Previous frame
} else if (e.key === 'Space') {
if (helios.isPlaying.value) {
helios.pause();
} else {
helios.play();
}
}
});
Seeking triggers:
- Signal updates (fast)
- Driver sync (may trigger layout/paint)
- Subscriber callbacks (depends on your code)
For high-frequency scrubbing, debounce expensive operations:
import { debounce } from 'lodash';
const expensiveUpdate = debounce(() => {
// Heavy DOM manipulation
}, 16); // ~60fps
helios.currentFrame.subscribe(() => {
expensiveUpdate();
});
Batch updates
Multiple state changes can be batched:
// Instead of multiple updates:
helios.seek(100);
helios.setPlaybackRate(2);
helios.setLoop(true);
// Consider batching in a single transaction if you implement it,
// or just accept that signals batch automatically
Helios signals already batch updates efficiently—subscribers only run once per transaction.
Next steps