Timelinx

Data Model

The shape of TimelineState and every entity inside it - Timeline, Track, Clip, and Asset

@timelinx/core stores everything in a single plain object called TimelineState. No class instances, no circular references, no hidden mutable state. Every edit returns a new TimelineState; the old one is untouched.

TimelineState

type TimelineState = {
  readonly schemaVersion: number;
  readonly timeline: Timeline;
  readonly assetRegistry: ReadonlyMap<AssetId, Asset>;
};
FieldTypePurpose
schemaVersionnumberGuards persisted projects from silent incompatibility. Always compare to CURRENT_SCHEMA_VERSION after loading from storage.
timelineTimelineThe one editorial document: tracks, clips, markers, groups, sequence settings.
assetRegistryReadonlyMap<AssetId, Asset>All media sources. Clips reference assets by assetId, not by embedding media metadata directly.

Create with:

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

const state = createTimelineState({
  timeline: createTimeline({
    id: 'tl-1',
    name: 'My Edit',
    fps: frameRate(30),
    duration: toFrame(9000), // 300 seconds at 30fps
  }),
});

assetRegistry defaults to an empty ReadonlyMap. The runtime wrapper rejects set, delete, and clear calls - you mutate it only through REGISTER_ASSET, UNREGISTER_ASSET, and SET_ASSET_STATUS operations.


Timeline

type Timeline = {
  readonly id: string;
  readonly name: string;
  readonly fps: FrameRate;
  readonly duration: TimelineFrame;
  readonly version: number;
  readonly tracks: readonly Track[];
  readonly markers: readonly Marker[];
  readonly beatGrid?: BeatGrid;
  readonly inPoint?: TimelineFrame;
  readonly outPoint?: TimelineFrame;
  readonly trackGroups: readonly TrackGroup[];
  readonly linkGroups: readonly LinkGroup[];
  readonly sequenceSettings: SequenceSettings;
};
FieldTypeNotes
idstringStable across edits.
namestringDisplay name shown in TopNav.
fpsFrameRate{ num: number, den: number } - rational. Use frameRate(30) or frameRate(24000, 1001) for 23.976.
durationTimelineFrameTotal length. Clips cannot extend past this.
versionnumberIncrements by 1 on every accepted transaction. Hooks use it to detect staleness.
tracksreadonly Track[]Top-to-bottom visual order. V1 is index 0.
markersreadonly Marker[]Point and range markers on the ruler.
beatGrid?BeatGridBPM, time signature, and offset for music-aligned editing. Absent unless ADD_BEAT_GRID has been dispatched.
inPoint?TimelineFrameExport region start. SET_IN_POINT sets this.
outPoint?TimelineFrameExport region end.
trackGroupsreadonly TrackGroup[]Collapsible track nesting.
linkGroupsreadonly LinkGroup[]Sets of clips that move together.
sequenceSettingsSequenceSettingsResolution, pixel aspect ratio, audio sample rate, and field order.

Factory:

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

const timeline = createTimeline({
  id: 'tl-1',
  name: 'Untitled Project',
  fps: frameRate(30),
  duration: toFrame(18000), // 10 minutes at 30fps
});
// tracks, markers, trackGroups, linkGroups default to []
// sequenceSettings defaults to 1920×1080, 48kHz, progressive

Track

type Track = {
  readonly id: TrackId;
  readonly name: string;
  readonly type: TrackType;
  readonly locked: boolean;
  readonly muted: boolean;
  readonly solo: boolean;
  readonly height: number;
  readonly clips: readonly Clip[];
  readonly captions: readonly Caption[];
  readonly blendMode?: string;
  readonly opacity?: number;
  readonly groupId?: TrackGroupId;
};

type TrackType = 'video' | 'audio' | 'subtitle' | 'title';
FieldTypeDefaultNotes
idTrackIdrequiredBranded string. Use toTrackId('v1').
namestringrequiredDisplay label in the track header.
typeTrackTyperequiredClips and assets must share the same type.
lockedbooleanfalseDispatch rejects all edits to a locked track.
mutedbooleanfalsePlayback-only - muted tracks are excluded from the compositor.
solobooleanfalseOnly soloed tracks play when any track is soloed.
heightnumber56Row height in pixels. UI enforces 36–140 range.
clipsreadonly Clip[][]Always sorted ascending by timelineStart. This is an invariant - checkInvariants() rejects unsorted clips.
captionsreadonly Caption[][]Subtitle/title items. Available on subtitle and title tracks.
blendMode?string'normal'CSS blend mode for compositing.
opacity?number10–1. Values outside this range cause INVALID_OPACITY invariant violations.
groupId?TrackGroupIdnoneReference to a TrackGroup for collapsible nesting.

Factory:

import { createTrack } from '@timelinx/core';

const videoTrack = createTrack({
  id: 'v1',
  name: 'V1',
  type: 'video',
});

const audioTrack = createTrack({
  id: 'a1',
  name: 'A1',
  type: 'audio',
  height: 68,
});

Track ordering

Tracks are stored in the tracks array. The first entry (index 0) renders at the top of the timeline. Use REORDER_TRACK to change positions.


Clip

A clip is a time-bound viewport into an asset. It has two independent sets of bounds:

  • Timeline bounds - where the clip sits on the track
  • Media bounds - which portion of the source asset plays
Timeline:  |----[  clip  ]-----|
Asset:  [.......[mediaIn...mediaOut].......]
type Clip = {
  readonly id: ClipId;
  readonly assetId: AssetId;
  readonly trackId: TrackId;
  // Timeline bounds
  readonly timelineStart: TimelineFrame;
  readonly timelineEnd: TimelineFrame;
  // Media bounds
  readonly mediaIn: TimelineFrame;
  readonly mediaOut: TimelineFrame;
  // Playback
  readonly speed: number;
  readonly enabled: boolean;
  readonly reversed: boolean;
  // Label
  readonly name: string | null;
  readonly color: string | null;
  readonly metadata: Record<string, string>;
  // Phase 4
  readonly effects?: readonly Effect[];
  readonly transform?: ClipTransform;
  readonly audio?: AudioProperties;
  readonly transition?: Transition;
};
FieldTypeNotes
idClipIdBranded string. Must be unique across all clips in all tracks.
assetIdAssetIdMust exist in assetRegistry. INSERT_CLIP rejects if missing.
trackIdTrackIdMust match the containing track's id. Invariant checks this.
timelineStartTimelineFrameInclusive start frame on the timeline.
timelineEndTimelineFrameExclusive end frame. timelineEnd > timelineStart is invariant.
mediaInTimelineFrameStart of the media window inside the asset.
mediaOutTimelineFrameEnd of the media window. mediaOut > mediaIn is invariant.
speednumberMust be > 0. At speed=1.0, mediaOut - mediaIn === timelineEnd - timelineStart.
enabledbooleanfalse = clip is skipped during compositing.
reversedbooleantrue = asset plays from mediaOut backward to mediaIn.
namestring | nullDisplay name override. null falls back to the asset name.
colorstring | nullHex string for track-row color coding.
effects?readonly Effect[]Applied in order during compositing.
transform?ClipTransformPosition, scale, rotation, anchor point, and crop.
audio?AudioPropertiesVolume, pan, and channel routing.
transition?TransitionA single transition attached to this clip.

The duration invariant:

At speed = 1.0:

mediaOut - mediaIn === timelineEnd - timelineStart

When speed > 1.0 (fast motion), the media window is proportionally smaller than the timeline window. The dispatcher enforces this automatically when resizing.

Factory:

import { createClip, toFrame } from '@timelinx/core';

const clip = createClip({
  id: 'clip-1',
  assetId: 'asset-interview',
  trackId: 'v1',
  timelineStart: toFrame(0),
  timelineEnd: toFrame(300),   // 10 seconds at 30fps
  mediaIn: toFrame(90),        // starts 3 seconds into the source
  mediaOut: toFrame(390),
});

Asset must be registered first

INSERT_CLIP will reject with ASSET_MISSING if the clip's assetId is not already in assetRegistry. Always dispatch REGISTER_ASSET in the same transaction or a prior one.


Asset

Assets are media sources registered in assetRegistry. A single asset can be referenced by many clips - the registry stores the metadata once, clips store only the assetId.

type Asset = FileAsset | GeneratorAsset;

type FileAsset = {
  readonly kind: 'file';
  readonly id: AssetId;
  readonly name: string;
  readonly mediaType: TrackType;
  readonly filePath: string;
  readonly intrinsicDuration: TimelineFrame;
  readonly nativeFps: FrameRate;
  readonly sourceTimecodeOffset: TimelineFrame;
  readonly status: AssetStatus;
};

type GeneratorAsset = {
  readonly kind: 'generator';
  readonly id: AssetId;
  readonly name: string;
  readonly mediaType: TrackType;
  readonly intrinsicDuration: TimelineFrame;
  readonly nativeFps: FrameRate;
  readonly sourceTimecodeOffset: TimelineFrame;
  readonly status: AssetStatus;
  readonly generatorDef: Generator;
};

type AssetStatus = 'online' | 'offline' | 'proxy-only' | 'missing';

FileAsset fields:

FieldTypeNotes
kind'file'Discriminant.
idAssetIdBranded string. Must be unique in assetRegistry.
namestringDisplay name in AssetBin.
mediaTypeTrackTypeMust match the track type of any clip that references this asset.
filePathstringPath or URL to the source file. Core treats this as opaque - the media pipeline resolves it.
intrinsicDurationTimelineFrameFull length of the source. mediaOut on clips cannot exceed this.
nativeFpsFrameRateSource frame rate. Used for media frame conversion.
sourceTimecodeOffsetTimelineFrameWhere the source's own timecode starts (for media with embedded TC).
statusAssetStatus'online' = available; 'offline' = not currently accessible; 'proxy-only' = low-res proxy; 'missing' = never found.

Factories:

import { createAsset, createGeneratorAsset, frameRate, toFrame } from '@timelinx/core';

// File asset
const videoAsset = createAsset({
  id: 'asset-interview',
  name: 'interview.mp4',
  mediaType: 'video',
  filePath: '/media/interview.mp4',
  intrinsicDuration: toFrame(9000),  // 5 minutes at 30fps
  nativeFps: frameRate(30),
  sourceTimecodeOffset: toFrame(0),
});

// Generator asset (synthetic  - no file)
const colorBarAsset = createGeneratorAsset({
  id: 'asset-colorbars',
  name: 'Color Bars',
  mediaType: 'video',
  generatorDef: {
    type: 'color-bars',
    duration: toFrame(300),
  },
  nativeFps: frameRate(30),
});

Register before inserting clips:

import { dispatch } from '@timelinx/core';

const result = dispatch(state, {
  id: 'tx-setup',
  label: 'Register asset and add clip',
  timestamp: Date.now(),
  operations: [
    { type: 'REGISTER_ASSET', asset: videoAsset },  // ← must come before INSERT_CLIP
    { type: 'INSERT_CLIP', trackId: track.id, clip },
  ],
});

Branded IDs

All entity IDs are branded strings that prevent accidental cross-entity substitution at the TypeScript type level:

type AssetId  = string & { readonly __brand: 'AssetId' };
type ClipId   = string & { readonly __brand: 'ClipId' };
type TrackId  = string & { readonly __brand: 'TrackId' };
type MarkerId = string & { readonly __brand: 'MarkerId' };

Branding helpers:

import { toAssetId, toClipId, toTrackId, toMarkerId } from '@timelinx/core';

const assetId  = toAssetId('my-video');
const clipId   = toClipId('clip-1');
const trackId  = toTrackId('v1');
const markerId = toMarkerId('marker-chapter-1');

You rarely call these manually. Factories handle branding internally. Use them when constructing raw entity objects outside the factories, or when receiving IDs from external storage.


Summary

ObjectCreated withContains
TimelineStatecreateTimelineState()timeline + assetRegistry
TimelinecreateTimeline()FPS, duration, tracks, markers, groups
TrackcreateTrack()Type, clips, captions, lock/mute/solo
ClipcreateClip()Timeline bounds, media bounds, effects
AssetcreateAsset() / createGeneratorAsset()File path or generator def, duration, status

On this page