Video Studio SDKv0.0.3
Plugin authoring

Recipes

Cross-cutting plugin patterns — drag & drop, working with the store, batching, store subscriptions, async config, autosave, attribution.

A grab-bag of recipes that don't fit cleanly under a single extension point. Each is self-contained and copy-pasteable.

Drag and drop from a panel

The SDK's timeline and player accept dropped items from panels via a single hook: useItemDrag. Wrap your draggable element, return a payload, and the drop targets handle the rest.

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

function AssetCard({ asset }: { asset: { id: string; url: string; thumb: string; name: string } }) {
  const { isDragging, dragProps } = useItemDrag({
    getData: () => ({
      itemType: 'image',
      source: { assetId: asset.id, assetUrl: asset.url, thumbUrl: asset.thumb },
      durationSeconds: 5,
      name: asset.name,
    }),
  })

  return (
    <div {...dragProps} className={isDragging ? 'opacity-50' : ''}>
      <img src={asset.thumb} alt="" />
    </div>
  )
}

The payload shape:

interface PanelDragData {
  itemType: ItemType
  source:   { assetId?, assetUrl?, proxyUrl?, thumbUrl?, metadata? }
  style?:   Record<string, unknown>
  durationSeconds?: number    // converted to frames using project FPS at drop time
  name?:    string
}

The drop target (timeline track / player canvas) constructs a TrackItem from the payload, places it where the user dropped, and pushes the resulting ADD_ITEM command. Your plugin only describes the payload.

Working with the store imperatively

For mutations not tied to React re-renders (timers, websocket handlers, plugin lifecycle hooks), use ctx.store directly:

onActivate(ctx) {
  const interval = setInterval(() => {
    const state = ctx.store.getState()
    if (state.project.isDirty) {
      // …trigger autosave
    }
  }, 5_000)
  return () => clearInterval(interval)
}

Inside React components, prefer useStudioStore(selector) for reactive reads and useStudioStoreApi() for stable imperative access. See Core → Store.

Subscribing to store changes

For "fire when X changes" without React, use ctx.store.subscribe:

onActivate(ctx) {
  const unsub = ctx.store.subscribe((state, prev) => {
    if (state.player.currentFrame !== prev.player.currentFrame) {
      // playhead moved
    }
  })
  return unsub
}

subscribe fires after every state update with both the new and previous state. Diff what you care about and bail early.

For domain-shaped events, prefer the event bus — it's narrower and typed:

onActivate(ctx) {
  const eventBus = ctx.store.getState().eventBus
  const handler = ({ itemId }: { itemId: string }) => { /* … */ }
  eventBus.on('item:added', handler)
  return () => eventBus.off('item:added', handler)
}

Batching multi-step mutations

Any time one user action triggers multiple store mutations, wrap them so ⌘Z reverses the whole thing:

const state = ctx.store.getState()
state.startBatch()
try {
  state.addItem({ /* … */ })
  state.addItem({ /* … */ })
  state.endBatch('Insert pair')
} catch (err) {
  state.cancelBatch()
  throw err
}

startBatch / endBatch collapses every nested command into one BATCH entry on the undo stack.

Authoring a custom command

When no built-in action covers your mutation, build a command directly:

import { createCommand } from '@studio-dev/vsdk/core'

const command = createCommand(
  'MYCO_HIGHLIGHT_ITEM',
  'Highlight item',
  (draft) => {
    const item = draft.timeline.items[itemId]
    if (item) item.metadata = { ...item.metadata, highlighted: true }
  },
  (draft) => {
    const item = draft.timeline.items[itemId]
    if (item) {
      const next = { ...item.metadata }
      delete (next as any).highlighted
      item.metadata = next
    }
  },
)

ctx.store.getState().executeCommand(command)

Capture the values you'll restore (here: the absence of highlighted) outside the command, never inside undo. See Commands for the full guide.

Async plugin configuration

When the plugin needs to fetch config before extensions are registered, make onRegister async:

async onRegister(ctx) {
  const config = await fetch('/api/my-plugin/config').then((r) => r.json())
  const { setConfig } = await import('./config-store')
  setConfig(config)

  ctx.registerPanel({ /* … */ })
}

The SDK awaits onRegister before rendering plugin UI, so the panel never mounts with empty config.

Tagging items with metadata

Plugin-created items should carry a metadata.source (or similar) so you can find them later:

state.addItem({
  type: 'image',
  /* … */
  metadata: { source: 'my-plugin', presetId: 'sparkle' },
})

Use it for:

  • Analytics (if (item.metadata?.source === 'stock-plugin') track(...)).
  • Cleanup commands (isVisible: (ids) => ids.some(id => state.items[id]?.metadata?.source === 'my-plugin')).
  • Plugin-to-plugin coordination ("show this inspector only on my items").

metadata is preserved across save/load but never read by the SDK.

Autosave

Subscribe to history:push (any mutation pushed a command), debounce, save:

import { debounce } from 'lodash-es'

onActivate(ctx) {
  const eventBus = ctx.store.getState().eventBus

  const save = debounce(async () => {
    const state = ctx.store.getState()
    state.setSyncing(true)
    try {
      const bundle = state.toBundle()
      await fetch(`/api/projects/${bundle.metadata.id}`, {
        method: 'PUT',
        body: JSON.stringify(bundle),
      })
      state.markClean()
    } finally {
      state.setSyncing(false)
    }
  }, 2_000)

  eventBus.on('history:push', save)
  eventBus.on('history:undo', save)
  eventBus.on('history:redo', save)

  return () => {
    eventBus.off('history:push', save)
    eventBus.off('history:undo', save)
    eventBus.off('history:redo', save)
    save.cancel()
  }
}

setSyncing(true) shows the syncing indicator in the toolbar; markClean() resets the dirty flag once persisted.

Reacting to the playhead

Don't subscribe to playback:frame-update — it fires on every frame and will tank performance if your handler does anything non-trivial. Either subscribe to playback:seek (once per user-initiated jump) or read state.player.currentFrame on-demand from a less-hot trigger.

eventBus.on('playback:seek', ({ frame }) => {
  // user jumped — safe place to do work
})

"Apply to all selected" pattern

Most context-menu and toolbar actions want to apply themselves across the current selection:

function applyToSelection(store: StudioStore, mutate: (id: string) => void, label: string) {
  const state = store.getState()
  const ids = [...state.selection.selectedItemIds]
  if (ids.length === 0) return
  state.startBatch()
  for (const id of ids) mutate(id)
  state.endBatch(label)
}

// Usage from a toolbar action / hotkey / context menu:
applyToSelection(ctx.store, (id) => {
  ctx.store.getState().updateItemProperties(id, { /* … */ })
}, 'Apply effect')

Cleaning up your items on plugin deactivate

If your plugin should remove every item it created when uninstalled (rare — usually you want them to survive), tag items with metadata.source and clean up in onDeactivate:

onDeactivate(ctx) {
  const state = ctx.store.getState()
  const myItems = Object.values(state.timeline.items).filter(
    (i) => i.metadata?.source === 'my-plugin',
  )
  state.startBatch()
  for (const item of myItems) state.removeItem(item.id)
  state.endBatch('Uninstall My Plugin')
}

License-gating a plugin

Read license state via useLicense() to disable features when no valid license is present:

'use client'

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

export default function ProPanel() {
  const { status, plan } = useLicense()
  if (status !== 'valid' || plan !== 'pro') {
    return <p>This plugin requires a Pro license.</p>
  }
  // …pro features
}

This is host-app-controlled — license keys are passed to VideoStudioProvider via licenseKey.

On this page