Video Studio SDKv0.0.3
Core

Events

The typed event bus — full catalog, subscription patterns, and how it cooperates with the store.

The store carries a typed StudioEventBus instance under state.eventBus. Every domain mutation (item added, item moved, transition added, export progressed, …) emits an event on it. Plugins and consumer code subscribe to react without coupling to internal store mechanics.

Why a bus at all?

The store is the source of truth for state. The event bus is the source of truth for what just happened. State changes are observable, but they're declarative — you see "items now contains X" not "X was just added." For analytics, autosave triggers, plugin coordination, etc., the bus is the right tool.

Reading and writing

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

const storeApi = useStudioStoreApi()
const eventBus = storeApi.getState().eventBus

// Subscribe
eventBus.on('item:added', ({ itemId, trackId }) => {
  console.log('Added', itemId, 'to', trackId)
})

// Unsubscribe (pass the same handler reference)
eventBus.off('item:added', handler)

// Emit (rare from consumer code — useful for custom events / testing)
eventBus.emit('item:added', { itemId: 'foo', trackId: 'trk_main' })

The bus is a class instance (StudioEventBus), not a plain object — this is deliberate so Immer doesn't deep-freeze its internal handler list when the state is produced.

In React

Subscribe in useEffect, return the unsubscribe in the cleanup:

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

function ItemAddedToast() {
  const storeApi = useStudioStoreApi()

  useEffect(() => {
    const eventBus = storeApi.getState().eventBus
    const handler = ({ itemId }: { itemId: string }) => toast(`Added ${itemId}`)
    eventBus.on('item:added', handler)
    return () => eventBus.off('item:added', handler)
  }, [storeApi])

  return null
}

The storeApi handle is stable, so this effect runs once per mount.

Full event catalog

All event payloads are fully typed. The keys below are the union of StudioEvents.

Items

EventPayload
item:added{ itemId, trackId }
item:removed{ itemId }
item:moved{ itemId, fromTrackId, toTrackId }
item:trimmed{ itemId }
item:split{ originalId, leftId, rightId }
item:selected{ itemIds }
item:deselected{ itemIds }
item:properties-changed{ itemId, changes: Record<string, unknown> }

Tracks

EventPayload
track:added{ trackId }
track:removed{ trackId }
track:reordered{ trackIds: string[] }

Playback

EventPayload
playback:playvoid
playback:pausevoid
playback:seek{ frame }
playback:frame-update{ frame }

frame-update fires on every frame while playing — be careful what you do in its handler.

History

EventPayload
history:push{ commandType } — new command executed
history:undo{ commandType }
history:redo{ commandType }

Project

EventPayload
project:loaded{ projectId }
project:saved{ projectId, version }
project:dirtyvoid
project:cleanvoid
project:sync-startvoid
project:sync-endvoid
project:save-requestvoid — emitted by requestSave() when no onSave is configured

Keyframes / animation

EventPayload
keyframe:added{ itemId, propertyPath, frame }
keyframe:removed{ itemId, propertyPath, keyframeId }
keyframe:updated{ itemId, propertyPath, keyframeId }
animation:cleared{ itemId, propertyPath? }

Transitions

EventPayload
transition:added{ transitionId, fromItemId, toItemId }
transition:removed{ transitionId }
transition:updated{ transitionId }

Masks

EventPayload
mask:added{ itemId, maskId }
mask:removed{ itemId, maskId }
mask:updated{ itemId, maskId }

Export

EventPayload
export:started{ profileId, projectId }
export:progress{ progress, stepLabel }
export:completed{ downloadUrl }
export:failed{ error }
export:dismissedvoid
export:abort{ projectId, profileId, reason } — emitted by abortExport()

Assets / jobs

EventPayload
asset:uploaded{ assetId }
job:created{ jobId, type }
job:progress{ jobId, progress }
job:completed{ jobId }
job:failed{ jobId, error }

TypeScript

Event names are a union, and payloads are strongly typed. You can import the type for use in generics:

import type { StudioEvents } from '@studio-dev/vsdk'

type EventName = keyof StudioEvents
type ItemAddedPayload = StudioEvents['item:added']  // { itemId: string; trackId: string }

Patterns

One-time listener

const handler = (data: StudioEvents['export:completed']) => {
  eventBus.off('export:completed', handler)
  notify(data.downloadUrl)
}
eventBus.on('export:completed', handler)

Multiple events, one handler

mitt's '*' (any event) isn't typed here — register each separately:

const onChange = () => markUnsynced()
eventBus.on('item:added',   onChange)
eventBus.on('item:removed', onChange)
eventBus.on('item:moved',   onChange)
return () => {
  eventBus.off('item:added',   onChange)
  eventBus.off('item:removed', onChange)
  eventBus.off('item:moved',   onChange)
}

Autosave trigger

Debounce on any dirty-marking event:

import { debounce } from 'lodash-es'

const debouncedSave = debounce(async () => {
  const bundle = storeApi.getState().toBundle()
  await saveToBackend(bundle)
  storeApi.getState().markClean()
}, 2000)

eventBus.on('history:push', debouncedSave)

Plugin coordination

Plugin A can emit a custom payload that plugin B listens to. Stick to the existing event names if you can — they're typed. For genuinely new events, use requestSave style: define a tiny module-level event in your plugin package and have consumers import the constant.

Anti-patterns

  • Don't trigger store mutations from inside an event handler synchronously — you'll re-enter the store mid-action. If you must, defer to a microtask: queueMicrotask(() => storeApi.getState().…).
  • Don't rely on event ordering across domains — within a single action the events fire in a fixed order, but across batched / nested actions the order can shift. Read state from storeApi.getState() inside the handler if you need the truth.
  • Don't forget to unsubscribe. The bus retains handlers for the lifetime of the store (≈ the lifetime of the provider). Returning the unsubscribe from useEffect is the safe pattern.

On this page