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>;
};| Field | Type | Purpose |
|---|---|---|
schemaVersion | number | Guards persisted projects from silent incompatibility. Always compare to CURRENT_SCHEMA_VERSION after loading from storage. |
timeline | Timeline | The one editorial document: tracks, clips, markers, groups, sequence settings. |
assetRegistry | ReadonlyMap<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;
};| Field | Type | Notes |
|---|---|---|
id | string | Stable across edits. |
name | string | Display name shown in TopNav. |
fps | FrameRate | { num: number, den: number } - rational. Use frameRate(30) or frameRate(24000, 1001) for 23.976. |
duration | TimelineFrame | Total length. Clips cannot extend past this. |
version | number | Increments by 1 on every accepted transaction. Hooks use it to detect staleness. |
tracks | readonly Track[] | Top-to-bottom visual order. V1 is index 0. |
markers | readonly Marker[] | Point and range markers on the ruler. |
beatGrid? | BeatGrid | BPM, time signature, and offset for music-aligned editing. Absent unless ADD_BEAT_GRID has been dispatched. |
inPoint? | TimelineFrame | Export region start. SET_IN_POINT sets this. |
outPoint? | TimelineFrame | Export region end. |
trackGroups | readonly TrackGroup[] | Collapsible track nesting. |
linkGroups | readonly LinkGroup[] | Sets of clips that move together. |
sequenceSettings | SequenceSettings | Resolution, 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, progressiveTrack
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';| Field | Type | Default | Notes |
|---|---|---|---|
id | TrackId | required | Branded string. Use toTrackId('v1'). |
name | string | required | Display label in the track header. |
type | TrackType | required | Clips and assets must share the same type. |
locked | boolean | false | Dispatch rejects all edits to a locked track. |
muted | boolean | false | Playback-only - muted tracks are excluded from the compositor. |
solo | boolean | false | Only soloed tracks play when any track is soloed. |
height | number | 56 | Row height in pixels. UI enforces 36–140 range. |
clips | readonly Clip[] | [] | Always sorted ascending by timelineStart. This is an invariant - checkInvariants() rejects unsorted clips. |
captions | readonly Caption[] | [] | Subtitle/title items. Available on subtitle and title tracks. |
blendMode? | string | 'normal' | CSS blend mode for compositing. |
opacity? | number | 1 | 0–1. Values outside this range cause INVALID_OPACITY invariant violations. |
groupId? | TrackGroupId | none | Reference 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;
};| Field | Type | Notes |
|---|---|---|
id | ClipId | Branded string. Must be unique across all clips in all tracks. |
assetId | AssetId | Must exist in assetRegistry. INSERT_CLIP rejects if missing. |
trackId | TrackId | Must match the containing track's id. Invariant checks this. |
timelineStart | TimelineFrame | Inclusive start frame on the timeline. |
timelineEnd | TimelineFrame | Exclusive end frame. timelineEnd > timelineStart is invariant. |
mediaIn | TimelineFrame | Start of the media window inside the asset. |
mediaOut | TimelineFrame | End of the media window. mediaOut > mediaIn is invariant. |
speed | number | Must be > 0. At speed=1.0, mediaOut - mediaIn === timelineEnd - timelineStart. |
enabled | boolean | false = clip is skipped during compositing. |
reversed | boolean | true = asset plays from mediaOut backward to mediaIn. |
name | string | null | Display name override. null falls back to the asset name. |
color | string | null | Hex string for track-row color coding. |
effects? | readonly Effect[] | Applied in order during compositing. |
transform? | ClipTransform | Position, scale, rotation, anchor point, and crop. |
audio? | AudioProperties | Volume, pan, and channel routing. |
transition? | Transition | A single transition attached to this clip. |
The duration invariant:
At speed = 1.0:
mediaOut - mediaIn === timelineEnd - timelineStartWhen 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:
| Field | Type | Notes |
|---|---|---|
kind | 'file' | Discriminant. |
id | AssetId | Branded string. Must be unique in assetRegistry. |
name | string | Display name in AssetBin. |
mediaType | TrackType | Must match the track type of any clip that references this asset. |
filePath | string | Path or URL to the source file. Core treats this as opaque - the media pipeline resolves it. |
intrinsicDuration | TimelineFrame | Full length of the source. mediaOut on clips cannot exceed this. |
nativeFps | FrameRate | Source frame rate. Used for media frame conversion. |
sourceTimecodeOffset | TimelineFrame | Where the source's own timecode starts (for media with embedded TC). |
status | AssetStatus | '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
| Object | Created with | Contains |
|---|---|---|
TimelineState | createTimelineState() | timeline + assetRegistry |
Timeline | createTimeline() | FPS, duration, tracks, markers, groups |
Track | createTrack() | Type, clips, captions, lock/mute/solo |
Clip | createClip() | Timeline bounds, media bounds, effects |
Asset | createAsset() / createGeneratorAsset() | File path or generator def, duration, status |