Video Studio SDKv0.0.3
Core

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:

KindRoleOverlap
mainPrimary video/image track. There is always one with id trk_main.No overlaps allowed
trackGeneric overlay/text/captions/adjustment track. Multiple allowed.Allowed
audioAudio 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)                 // 0

Always 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 one

Easing 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.

On this page