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.
Register icons
Add icons to the global map so your plugin's UI can use them — and so other plugins can too.
Publishing a plugin
From a working in-project plugin to a validated, npm-published package listed in the official VSDK plugin catalog. Structure, peer-deps, testing coverage, validation, submission.