Skip to main content
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 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].

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:
  1. Driver stability (fonts, images, media)
  2. Custom registered checks
  3. 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();
    }
  }
});

Performance considerations

Seeking performance

Seeking triggers:
  1. Signal updates (fast)
  2. Driver sync (may trigger layout/paint)
  3. 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

Build docs developers (and LLMs) love