Timelinx

TimelineEngine

The React engine class - constructor options, methods, snapshot model, and lifetime management

TimelineEngine is the central object in every @timelinx/react application. It owns the timeline state, history stack, tool registry, snap index, playback engine, and keyboard handler. React hooks subscribe to it via useSyncExternalStore.

import { TimelineEngine } from '@timelinx/react';
import { createTimelineState, createTimeline, frameRate, toFrame } from '@timelinx/core';

const engine = new TimelineEngine({
  initialState: createTimelineState({
    timeline: createTimeline({
      id: 'tl-1',
      name: 'My Edit',
      fps: frameRate(30),
      duration: toFrame(9000),
    }),
  }),
});

Constructor Options

new TimelineEngine(options: TimelineEngineOptions)
OptionTypeDefaultPurpose
initialStateTimelineStaterequiredThe starting document.
historyLimitnumber100Maximum number of undo entries. Older entries are evicted when the limit is reached.
compressionCompressionPolicyDEFAULT_COMPRESSION_POLICYControls which operations merge in history (e.g., repeated drag frames merge into one).
toolsITool[][]Additional tools merged with the 12 defaults. Same ID overwrites the built-in.
defaultToolIdstring'selection'Which tool is active on mount.
getPixelsPerFrame() => number() => 10Returns current zoom level for snap radius calculation. Wire this to the UI provider's ppf.
onZoomChange(ppf: number) => voidnoopCalled by ZoomTool when the user zooms. Update UI ppf state here.
pipelinePipelineConfignoneMedia backend - video decoder, compositor, thumbnail provider. Without this, the engine runs edit-only.
dimensions{ width: number; height: number }{ width: 1920, height: 1080 }Compositor canvas dimensions passed to PlaybackEngine.
clockClockbrowserClockTime source for playback. Swap for createTestClock() in tests.
onMarkIn() => voidnoneCalled when the user presses the I key (mark in-point).
onMarkOut() => voidnoneCalled when the user presses the O key.
onError(err: unknown, phase: string) => voidnoneCalled when a tool or dispatch throws an unexpected error. phase is 'dispatch', 'onPointerDown', etc.

Methods

dispatch(transaction)

dispatch(transaction: Transaction): DispatchResult

Validates and applies a transaction. On acceptance:

  1. Core dispatch() validates each operation and applies them in order.
  2. diffStates() computes what changed.
  3. The accepted entry is pushed to history with compression.
  4. Track index and snap index are rebuilt.
  5. Playback receives the new state.
  6. A new EngineSnapshot is built and all React subscribers are notified.

On rejection, the current state is unchanged and React does not re-render.

const result = engine.dispatch({
  id: 'tx-add-clip',
  label: 'Add interview clip',
  timestamp: Date.now(),
  operations: [
    { type: 'REGISTER_ASSET', asset: videoAsset },
    { type: 'INSERT_CLIP', trackId: track.id, clip },
  ],
});

if (!result.accepted) {
  console.error(result.reason, result.message);
}

undo() / redo()

undo(): boolean
redo(): boolean

Returns true if the operation succeeded, false if there was nothing to undo/redo. After a successful undo or redo, the engine rebuilds indexes, notifies subscribers, and emits a new snapshot.

import { useEngine, useCanUndoRedo } from '@timelinx/react';

function UndoRedoButtons() {
  const engine = useEngine();
  const { canUndo, canRedo } = useCanUndoRedo();

  return (
    <>
      <button disabled={!canUndo} onClick={() => engine.undo()}>Undo</button>
      <button disabled={!canRedo} onClick={() => engine.redo()}>Redo</button>
    </>
  );
}

activateTool(toolId) / getActiveToolId()

activateTool(toolId: string): void
getActiveToolId(): string

Switches the active tool and notifies subscribers. The cursor updates to reflect the new tool's default cursor.


Pointer & Keyboard Event Handlers

handlePointerDown(event: TimelinePointerEvent, modifiers: Modifiers): void
handlePointerMove(event: TimelinePointerEvent, modifiers: Modifiers): void
handlePointerUp(event: TimelinePointerEvent, modifiers: Modifiers): void
handleKeyDown(event: TimelineKeyEvent, modifiers: Modifiers): void
handleKeyUp(event: TimelineKeyEvent, modifiers: Modifiers): void

These forward events to the active tool. Call them from the tool router (useToolRouter) or from manual event handlers on the timeline container.

type Modifiers = {
  shift: boolean;
  alt: boolean;
  ctrl: boolean;
  meta: boolean;
};

Playback Methods

setPlayheadFrame(frame: TimelineFrame): void
play(): void
pause(): void
stop(): void

setPlayheadFrame moves the playhead without playing. play() starts playback from the current frame - only available when pipeline was provided in the constructor. Without a pipeline, play() has no effect.


subscribe(callback) / getSnapshot()

subscribe(callback: () => void): () => void
getSnapshot(): EngineSnapshot

These are the useSyncExternalStore integration points. React hooks call these internally - you only need them when building a custom subscription outside React.

The returned subscribe function returns an unsubscribe function:

const unsubscribe = engine.subscribe(() => {
  console.log('state changed');
});
// later:
unsubscribe();

EngineSnapshot

Hooks read from an EngineSnapshot - an immutable frozen object rebuilt after every state change:

type EngineSnapshot = {
  readonly state: TimelineState;
  readonly stableTrackIds: readonly string[];
  readonly history: { canUndo: boolean; canRedo: boolean };
  readonly activeToolId: string;
  readonly cursor: string | null;
  readonly provisional: ProvisionalState | null;
  readonly playheadFrame: TimelineFrame;
  readonly isPlaying: boolean;
  readonly selectedClipIds: ReadonlySet<string>;
  readonly selectedCaptionIds: ReadonlySet<string>;
  readonly snapIndex: SnapIndex;
  readonly change: StateChange;
};
FieldNotes
stateCurrent TimelineState.
stableTrackIdsTrack ID array. Stable reference - only replaced when track count or order changes. React hooks use this to mount/unmount per-track components without re-running on unrelated state changes.
historycanUndo and canRedo flags. Updated once when availability changes, not on every snapshot.
activeToolIdString ID of the current tool.
cursorCSS cursor string for the current tool and hover state.
provisionalDrag-preview state from the active tool. null when idle.
playheadFrameCurrent playhead position.
isPlayingtrue when playback is running.
selectedClipIdsReadonlySet<string> from the SelectionTool.
selectedCaptionIdsReadonlySet<string> of selected captions.
snapIndexCurrent snap index - rebuilt after every dispatch.
changeStateChange diff from the previous snapshot. Use with useChange() for coarse re-renders.

Lifetime Management

Create the engine outside of the render function:

// ✅ Correct: engine is stable across renders
const engine = new TimelineEngine({ initialState });

function App() {
  return <TimelineProvider engine={engine}><Editor /></TimelineProvider>;
}

Wrong - recreating on every render resets all state:

// ❌ Wrong: engine resets on every render
function App() {
  const engine = new TimelineEngine({ initialState }); // don't do this
  return <TimelineProvider engine={engine}><Editor /></TimelineProvider>;
}

When initial state is loaded asynchronously:

import { useMemo, useState, useEffect } from 'react';
import { TimelineEngine } from '@timelinx/react';

function App() {
  const [initialState, setInitialState] = useState<TimelineState | null>(null);

  useEffect(() => {
    loadProjectFromServer().then(setInitialState);
  }, []);

  const engine = useMemo(
    () => (initialState ? new TimelineEngine({ initialState }) : null),
    [initialState],
  );

  if (!engine) return <LoadingScreen />;

  return <TimelineProvider engine={engine}><Editor /></TimelineProvider>;
}

Do not memoize with stale deps

If initialState changes identity on every render (e.g., from a selector that constructs a new object), the engine will be recreated on every render. Load your state once and keep the reference stable.


Multiple Engine Instances

You can mount multiple independent editor instances on the same page. Each engine maintains its own state, history, and subscriptions:

function SplitEditorApp() {
  const engineA = useMemo(() => new TimelineEngine({ initialState: stateA }), []);
  const engineB = useMemo(() => new TimelineEngine({ initialState: stateB }), []);

  return (
    <div style={{ display: 'flex', gap: 16 }}>
      <TimelineProvider engine={engineA}>
        <TimelineEditor engine={engineA} />
      </TimelineProvider>
      <TimelineProvider engine={engineB}>
        <TimelineEditor engine={engineB} />
      </TimelineProvider>
    </div>
  );
}

Engine-first hooks accept an engine argument directly for use outside the default provider context:

function ExternalInspector({ engine }: { engine: TimelineEngine }) {
  const timeline = useTimelineWithEngine(engine);
  return <h2>{timeline.name}</h2>;
}

Wiring Media Playback

Pass a PipelineConfig to enable frame decoding and compositing:

import { createWebCodecsDecoder } from '@timelinx/media-web';
import { createWebGLCompositor } from '@timelinx/media-web';
import { createThumbnailExtractor } from '@timelinx/media-web';

const engine = new TimelineEngine({
  initialState,
  pipeline: {
    videoDecoder: createWebCodecsDecoder(),
    compositor: createWebGLCompositor({ canvas: canvasRef.current! }),
    thumbnailProvider: createThumbnailExtractor(),
  },
  dimensions: { width: 1920, height: 1080 },
});

Without pipeline, the engine is edit-only: dispatch, undo/redo, selection, and tool routing work normally but play() has no effect.

On this page