Timeline
Tracks, items, the normalized index, the snap engine, invariants, and time utilities.
The timeline is a normalized data model — items are stored flat by ID, with secondary indexes for ordered "what's on this track?" lookups. The same set of helpers powers built-in editing actions and is exported for your own use.
Data model
Track
interface Track {
id: string
name: string
kind: 'main' | 'track' | 'audio'
order: number
locked: boolean
muted: boolean
hidden: boolean
}Three kinds:
| Kind | Role | Overlap |
|---|---|---|
main | Primary video/image track. There is always one with id trk_main. | No overlaps allowed |
track | Generic overlay/text/captions/adjustment track. Multiple allowed. | Allowed |
audio | Audio track. Multiple allowed. | Allowed |
The main track is load-bearing — createEmptyBundle creates it with id trk_main. Don't remove it
or reassign that id.
TrackItem
The full shape:
interface TrackItem {
id: string
trackId: string
type: ItemType
name: string
startFrame: number
durationFrames: number
source: TrackItemSource
transform: TrackItemTransform
style?: Record<string, unknown> // VideoStyle, TextStyle, …
filters?: FilterDefinition[]
animation?: AnimationData // keyframe tracks
masks?: MaskData
zIndex: number
locked: boolean
disabled: boolean
muted: boolean
metadata?: Record<string, unknown> // free-form plugin data
}
type ItemType =
| 'video'
| 'audio'
| 'image'
| 'text'
| 'overlay'
| 'captions'
| 'adjustment'metadata is preserved across save/load but never read by the SDK — use it for plugin-specific
identifiers ("which preset created this clip?").
TrackItemTransform
interface TrackItemTransform {
x: number
y: number
width: number
height: number
rotation: number // degrees
scaleX: number // 1 = 100%
scaleY: number
anchorX: number // 0–1, transform origin
anchorY: number
opacity: number // 0–1
cropTop: number // 0–1 from the top
cropRight: number
cropBottom: number
cropLeft: number
}Crop values are 0..1 — cropLeft: 0.1 means crop off the leftmost 10%. The renderer composes the
visible region from cropTop/Right/Bottom/Left after applying width/height/scaleX/Y.
TrackItemSource
interface TrackItemSource {
assetId?: string
assetUrl?: string
thumbUrl?: string
proxyUrl?: string // lower-quality preview source
metadata?: Record<string, unknown>
}Plugins that fetch media from third-party APIs (Pexels, Giphy, …) typically set assetUrl and
thumbUrl from the upstream response.
TimelineState
interface TimelineState {
tracks: Track[] // sorted by .order
items: Record<string, TrackItem> // flat, keyed by id
itemIdsByTrack: Record<string, string[]> // secondary index, sorted by startFrame
markers: Marker[]
regions: Region[]
transitions: Record<string, TimelineTransition>
zoom: { pixelsPerFrame, min, max }
view: { scrollLeftPx, scrollTopPx }
totalDurationFrames: number // derived
}itemIdsByTrack[trackId] is the ordered list of item ids on that track. The store rebuilds it
after every mutation; you can rely on it being sorted by startFrame.
Mutating the timeline
Every timeline operation goes through a store action. The full list is in Store — the common ones:
const state = storeApi.getState()
state.addItem({ trackId: 'trk_main', type: 'image', /* ... */ })
state.moveItem(itemId, 'trk_main', 60)
state.trimItemStart(itemId, 30)
state.trimItemEnd(itemId, 90)
state.splitItem(itemId, atFrame)
state.duplicateItem(itemId)
state.rippleDelete(itemId) // delete + close gap (downstream items shift left)
state.rippleInsert(atFrame, 60, trackId) // insert empty gap (downstream items shift right)
state.nudge([id1, id2], -5) // shift by delta
state.updateItemProperties(itemId, { transform: { x: 100 } })All of these are reversible — see Commands.
Helpers
Picking a track for a new item
findAvailableTrack returns the first track of the given kind that has free space at the requested
range, or null if every track of that kind is occupied:
import { findAvailableTrack } from '@studio-dev/vsdk'
const track = findAvailableTrack(
state.timeline.tracks,
state.timeline.itemIdsByTrack,
state.timeline.items,
'track', // kind we need
currentFrame,
90, // duration in frames
)itemTypeToTrackKind(itemType) maps an item type to the kind of track it belongs on:
import { itemTypeToTrackKind } from '@studio-dev/vsdk'
itemTypeToTrackKind('video') // 'main'
itemTypeToTrackKind('image') // 'main'
itemTypeToTrackKind('audio') // 'audio'
itemTypeToTrackKind('text') // 'track'
itemTypeToTrackKind('overlay') // 'track'
itemTypeToTrackKind('captions') // 'track'
itemTypeToTrackKind('adjustment') // 'track'Overlap detection
import { hasOverlapOnTrack } from '@studio-dev/vsdk'
const overlaps = hasOverlapOnTrack(
state.timeline.itemIdsByTrack,
state.timeline.items,
trackId,
startFrame,
durationFrames,
excludeItemId, // optional — skip this item when checking
)For collecting the items that overlap (not just a boolean):
import { findOverlappingItems } from '@studio-dev/vsdk/core'
const items = findOverlappingItems(
state.timeline.items,
trackId,
startFrame,
durationFrames,
excludeItemId,
)Invariants
import { validateItemPlacement, enforceInvariants } from '@studio-dev/vsdk/core'
const result = validateItemPlacement(
state.timeline.items,
state.timeline.tracks,
candidateItem,
/* allowOverlaps */ false, // pass true to skip overlap check
)
// { valid: true } | { valid: false, reason: string }
const clean = enforceInvariants(item)
// Rounds + clamps startFrame and durationFrames; never returns invalid values.validateItemPlacement rejects:
- Negative
startFrame durationFrames < 1- Missing track
- Locked track
- Overlap (only on
kind: 'main'tracks)
Finding gaps
import { findNextGap } from '@studio-dev/vsdk/core'
const insertAt = findNextGap(state.timeline.items, trackId, afterFrame, requiredDuration)Returns the first frame on that track at or after afterFrame where requiredDuration frames are
free. Use it for "insert at next available gap" UX.
Snap engine
Snap candidates are: the playhead, all item start/end edges, all marker frames, all region start/end frames.
import { collectSnapPoints, computeSnap, computeSnapForEdges } from '@studio-dev/vsdk/core'
const snapCtx = {
items: state.timeline.items,
markers: state.timeline.markers,
regions: state.timeline.regions,
playheadFrame: state.player.currentFrame,
pixelsPerFrame: state.timeline.zoom.pixelsPerFrame,
excludeItemIds: [draggingItemId], // skip the item being moved
}
// Single edge / playhead — used when dragging an item or the playhead.
const { snappedFrame, guides } = computeSnap(candidateFrame, snapCtx, /* thresholdPx */ 8)
// Both edges (start + end) — used when dragging an item by its body, where the whole range moves.
const { snappedStart, snappedEnd, guides, deltaFrames } = computeSnapForEdges(
startFrame, endFrame, snapCtx, 8,
)guides is what you render — each guide has { frame, type }. Snap threshold is in pixels, not
frames; the engine converts based on pixelsPerFrame so snapping feels consistent at any zoom.
Time utilities
Frame-based timing utilities:
import {
framesToSeconds, secondsToFrames,
framesToTimecode, framesToDisplayTime, timecodeToFrames,
msToFrames, framesToMs,
clampFrame, durationFramesFromMeta,
} from '@studio-dev/vsdk'
framesToSeconds(150, 30) // 5
secondsToFrames(5, 30) // 150
framesToTimecode(4530, 30) // '00:02:31:00' (hh:mm:ss:ff)
framesToDisplayTime(4530, 30) // '2:31'
timecodeToFrames('00:02:31:00', 30) // 4530
clampFrame(-5, 0, 100) // 0Always use these — never hand-roll frame ↔ time math. The renderer uses the same helpers, and tiny rounding differences will misalign clips when exporting.
Markers and regions
const state = storeApi.getState()
// Markers — labeled single frames
const markerId = state.addMarker(120, 'Cut here', '#f59e0b')
state.moveMarker(markerId, 180)
state.removeMarker(markerId)
// Regions — labeled ranges (set via updateSettings or direct store access; no high-level action)Markers are reflected in the snap engine — drop a marker at frame 120 and items snap to it when dragged nearby.
Keyframe animation
Any numeric, color, or boolean property under transform.* or style.* (and registered custom
paths) can be animated. The full keyframe API is exported from @studio-dev/vsdk/core:
const state = storeApi.getState()
state.setKeyframe(itemId, 'transform.opacity', 30, 0.5)
state.setKeyframe(itemId, 'transform.opacity', 60, 1, { type: 'ease-out' })
state.updateKeyframe(itemId, 'transform.opacity', kfId, { value: 0.8 })
state.removeKeyframe(itemId, 'transform.opacity', kfId)
state.clearAnimation(itemId) // all properties
state.clearAnimation(itemId, 'transform.opacity') // just oneEasing options: 'linear' | 'hold' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier' (with
controlPoints for the bezier).
To register a custom animatable property from a plugin (e.g. a new style field your plugin adds):
import { registerAnimatableProperty } from '@studio-dev/vsdk/core'
registerAnimatableProperty('video', {
path: 'style.myCustomValue',
label: 'Custom Value',
group: 'visual',
valueType: 'number',
defaultValue: 0,
min: 0,
max: 100,
step: 1,
})To resolve the interpolated value at a frame (for rendering):
import { resolveAnimatedValue, resolveAnimatedTransform } from '@studio-dev/vsdk/core'
const opacity = resolveAnimatedValue(item, 'transform.opacity', currentFrame)
const transform = resolveAnimatedTransform(item, currentFrame)Transitions
const id = state.addTransition(fromItemId, toItemId, 'fade', 15, { direction: 'from-left' })
state.updateTransition(id, { durationFrames: 30 })
state.removeTransition(id)The bundled transition types are: fade, wipe, clock-wipe, none, slide, flip, push,
whip-pan, directional-blur, cross-zoom, dip-to-black, dip-to-white, light-leak,
zoom-blur, glitch, split-reveal.
Register a custom transition from a plugin:
import { registerTransition } from '@studio-dev/vsdk/core'
registerTransition({
type: 'my-transition',
label: 'My Transition',
category: 'custom',
defaultDurationFrames: 15,
defaultParams: { intensity: 1 },
presentation: (params) => myRemotionPresentationFactory(params),
})Masks
Masks live on individual image/video items. The five built-in types are rectangle, ellipse,
polygon, split, filmstrip. The full mask actions are listed in Store; see
Configuration → masking for enabling/disabling.