Video Studio SDKv0.0.3
Core

Store

The Zustand + Immer store at the heart of the SDK — the full state shape, every action it exposes, and the two hooks you'll use most.

There is one Zustand store per provider. It holds the entire runtime state of the editor, exposes every mutation as a method, and emits typed events on the bus when state changes. Everything in the editor — built-in surfaces, plugin panels, your own components — reads from and writes to this one store.

Two hooks

You access the store through two hooks. The first hook is reactive (subscribes to changes); the second is imperative (no subscription).

useStudioStore(selector) — read state reactively

import { useStudioStore } from '@studio-dev/vsdk'

function ItemCount() {
  const count = useStudioStore((s) => Object.keys(s.timeline.items).length)
  return <span>{count} items</span>
}

The selector function runs on every state change. The component re-renders only when the selected value changes (Zustand uses === by default). Pick narrow selectors so you don't re-render on unrelated state.

Avoid useStudioStore((s) => s) — it re-renders on every state change, including every frame during playback. Always project to the smallest piece of state you need.

useStudioStoreApi() — get the store handle without subscribing

import { useStudioStoreApi } from '@studio-dev/vsdk'

function AddButton() {
  const storeApi = useStudioStoreApi()
  return (
    <button onClick={() => storeApi.getState().addItem({ /* ... */ })}>
      Add
    </button>
  )
}

useStudioStoreApi() returns a stable reference to the Zustand store instance. Use it in:

  • Event handlers — you don't need the component to re-render when state changes.
  • Effects — when you want to read or subscribe imperatively.
  • Callbacks passed to other components — pass storeApi (stable) instead of selected values (changing) to keep memoization working.

The store exposes the three Zustand primitives:

  • storeApi.getState() — current StudioState snapshot
  • storeApi.setState(updater) — rarely needed; prefer named actions
  • storeApi.subscribe(listener) — imperative subscription (returns unsubscribe)

State shape

StudioState is one object with six logical slices and a giant set of action methods. Actions live on the state object (not separate from it) — that's how Zustand stores work.

interface StudioState {
  timeline:   TimelineState
  selection:  SelectionState
  history:    HistoryState
  player:     PlayerState
  project:    ProjectState
  eventBus:   StudioEventBus

  // …plus ~70 action methods
}

timeline

interface TimelineState {
  tracks:              Track[]                              // ordered
  items:               Record<string, TrackItem>            // flat, keyed by id
  itemIdsByTrack:      Record<string, string[]>             // secondary index: trackId → sorted ids
  markers:             Marker[]
  regions:             Region[]
  transitions:         Record<string, TimelineTransition>
  zoom:                { pixelsPerFrame: number; min: number; max: number }
  view:                { scrollLeftPx: number; scrollTopPx: number }
  totalDurationFrames: number                               // derived, max of (startFrame + duration)
}

itemIdsByTrack is the secondary index — for O(1) "give me items on track X in order." The store keeps it consistent with items automatically; don't write to it directly.

selection

interface SelectionState {
  selectedItemIds: string[]                                  // currently selected items
  activeItemId:    string | null                             // the "primary" selection (for inspector)
}

activeItemId is the one item whose properties the inspector shows. With multiple selections it defaults to the most recently selected.

history

interface HistoryState {
  undoStack:      Command[]                                  // max 100
  redoStack:      Command[]
  canUndo:        boolean                                    // mirrors undoStack.length > 0
  canRedo:        boolean
  isBatching:     boolean                                    // inside startBatch / endBatch
  batchCommands:  Command[]                                  // batch buffer
}

The 100-entry cap is the constant MAX_HISTORY. See Commands for details.

player

interface PlayerState {
  currentFrame:  number
  isPlaying:     boolean
  playbackRate:  number                                     // 0.25 – 4
  volume:        number                                     // 0 – 1
  isMuted:       boolean
}

project

interface ProjectState {
  projectId:      string | null
  projectName:    string
  settings:       ProjectSettings
  isDirty:        boolean                                  // true after any executed command
  isSyncing:      boolean                                  // your code sets this during save
  isLoading:      boolean                                  // your code sets this during async load
  lastSavedAt:    number | null
  schemaVersion:  number
  export:         ExportState                              // phase, progress, downloadUrl, profile
}

eventBus

A StudioEventBus instance (mitt wrapped in a class so Immer doesn't freeze it). See Events.

All actions, grouped

The store exposes ~70 actions. Below is the full list, grouped by domain. Every action that mutates timeline / selection / property state is wrapped in a Command and pushed to the undo stack — see Commands.

History

ActionSignatureBehavior
executeCommand(cmd)(command: Command) => voidExecute, push to undo stack, clear redo, mark dirty
undo()() => voidPop top of undo, run undo(), push to redo
redo()() => voidPop top of redo, run execute(), push to undo
startBatch()() => voidBegin a batch — all commands inside undo as one
endBatch(label)(label: string) => voidWrap collected commands as one BATCH command
cancelBatch()() => voidRoll back collected batch commands, discard

Timeline items

ActionSignature
addItem(item)(Omit<TrackItem,'id'> & { id?: string }) => string
removeItem(id)(itemId: string) => void
moveItem(id, toTrackId, toStartFrame)(itemId, toTrackId, toStartFrame) => void
trimItemStart(id, newStart)(itemId, newStartFrame) => void
trimItemEnd(id, newDuration)(itemId, newDurationFrames) => void
splitItem(id, atFrame)(itemId, atFrame) => { leftId, rightId } | null
duplicateItem(id)(itemId) => string | null
rippleDelete(id)(itemId) => void
rippleInsert(atFrame, dur, trackId)(atFrame, durationFrames, trackId) => void
nudge(ids, delta)(itemIds: string[], deltaFrames: number) => void
updateItemProperties(id, props)see below

updateItemProperties is the canonical way to change any property on an item. It accepts a partial of:

{
  transform?: Partial<TrackItemTransform>
  style?: Record<string, unknown>          // VideoStyle, TextStyle, etc.
  filters?: FilterDefinition[]
  zIndex?: number
  locked?: boolean
  disabled?: boolean
  muted?: boolean
  startFrame?: number
  durationFrames?: number
}

Nested keys (e.g. transform) are shallow-merged.

Tracks

ActionSignature
addTrack(track)(Omit<Track,'id'> & { id?: string }) => string
removeTrack(trackId)(trackId: string) => void
setTrackOrder(newOrder)(Array<{ id, order }>) => void
setTrackFlags(id, flags)(trackId, { locked?, muted?, hidden? }) => void
renameTrack(id, name)(trackId, name: string) => void

Markers

ActionSignature
addMarker(frame, label?, color?)returns the new marker id
removeMarker(id)
moveMarker(id, frame)

Selection

ActionSignature
selectItem(id, additive?)(itemId, additive?: boolean) => voidadditive = ctrl-click
deselectItem(id)(itemId) => void
selectAll()
deselectAll()

Player

ActionSignature
setCurrentFrame(frame)direct set (no event)
seekTo(frame)set + emit playback:seek
play()start playback
pause()stop playback
togglePlay()flip
setPlaybackRate(rate)0.25 – 4
setVolume(v)0 – 1
toggleMute()flip

Zoom / view

ActionSignature
setZoom(pixelsPerFrame)
setView(partial)merge Partial<ViewState>

Project

ActionSignature
updateSettings(partial)
changeFps(fps)rescales all item start/duration to the new FPS
changeResolution(w, h, aspectRatio?)rescales transforms
setProjectName(name)
markDirty()
markClean()
setSyncing(b)shows/hides the syncing indicator
setLoading(b)shows/hides the full-screen loading overlay
requestSave()emits project:save-request (when there's no onSave)

Keyframes

ActionSignature
setKeyframe(itemId, path, frame, value, easing?)upsert
removeKeyframe(itemId, path, keyframeId)
updateKeyframe(itemId, path, keyframeId, updates)
clearAnimation(itemId, path?)clear one path or all

Transitions

ActionSignature
addTransition(fromId, toId, type, durFrames, params?)returns id or null
removeTransition(transitionId)
updateTransition(transitionId, updates)

Masks

ActionSignature
addMask(itemId, type)returns mask id or null
removeMask(itemId, maskId)
updateMask(itemId, maskId, updates)
duplicateMask(itemId, maskId)
reorderMasks(itemId, fromIndex, toIndex)
setActiveMask(itemId, maskId | null)mask currently being edited

Export

ActionSignatureNotes
startExport(profile)(profile: RenderProfile)Sets phase to 'preparing', stores active profile
setExportPhase(phase)'idle' | 'preparing' | 'uploading' | 'rendering' | 'finalizing' | 'completed' | 'failed' | 'aborting'
setExportProgress(progress, stepLabel?)0 – 1
setExportCompleted(downloadUrl)Sets phase 'completed'
setExportFailed(error)Sets phase 'failed'
abortExport(reason?)Sets phase 'aborting', emits export:abort
resetExport()Back to 'idle'

Bundle / reset

ActionSignature
loadBundle(bundle)hard load — see Project bundle
toBundle()serialize current state
resetStore()back to initial empty state

How actions work internally

Take addItem as a concrete example — it's representative of most timeline actions:

addItem: (itemData) => {
  const { command, itemId } = createAddItemCommand(itemData)
  get().executeCommand(command)
  eventBus.emit('item:added', { itemId, trackId: itemData.trackId })
  return itemId
}

Three steps: build a Command (execute + undo), push it through history, emit an event. Plugin and consumer code can hook in at the event layer without touching the action itself.

Recipes

Read multiple slices in one render

Use a tuple-returning selector with an equalityFn if values change independently:

import { useStudioStore } from '@studio-dev/vsdk'
import { shallow } from 'zustand/shallow'

const [items, currentFrame] = useStudioStore(
  (s) => [s.timeline.items, s.player.currentFrame] as const,
  shallow,
)

Without shallow the tuple identity changes every render and React re-renders unnecessarily.

Modify an item in response to an event

function MyAutoNamer() {
  const storeApi = useStudioStoreApi()

  useEffect(() => {
    const eventBus = storeApi.getState().eventBus

    const handler = ({ itemId }: { itemId: string }) => {
      const state = storeApi.getState()
      const item = state.timeline.items[itemId]
      if (item && !item.name) {
        state.updateItemProperties(itemId, { /* nothing — example */ })
      }
    }

    eventBus.on('item:added', handler)
    return () => eventBus.off('item:added', handler)
  }, [storeApi])

  return null
}

Subscribe imperatively from outside React

const storeApi = /* useStudioStoreApi() inside the tree, then pass via context */
const unsub = storeApi.subscribe((state, prev) => {
  if (state.player.currentFrame !== prev.player.currentFrame) {
    // playback frame changed
  }
})

Batch a multi-step operation as one undo step

const state = storeApi.getState()
state.startBatch()
const id1 = state.addItem({ ... })
const id2 = state.addItem({ ... })
state.endBatch('Add two items')
// Ctrl+Z removes both in one step.

If something throws mid-batch, call state.cancelBatch() in a catch block — it rolls back what was already executed and exits batch mode.

On this page