Advanced plugin
Adapter-backed I/O, custom commands, event subscriptions, drag & drop, custom icons. Still a folder inside your app — not an npm package.
The advanced tier is where in-project plugins start to look like the official ones — except they still live in your own repo, behind your own auth, calling your own backend. This page covers every power tool the plugin system exposes, applied to one concrete plugin you'd realistically build in a production app.
This page is the in-project, in-app advanced template. If you want to ship a plugin to npm so other teams can install it, the conventions overlap heavily but you also need a build pipeline, peer-dep declarations, semantic versioning, testing coverage, and a submission gate. That's the Publishing a plugin guide.
What you'll build
A "Stock photos" plugin that:
- Accepts an adapter interface for I/O (host wires Pexels, Unsplash, or its own CMS).
- Renders a panel with a search input and a virtualized grid.
- Supports drag-and-drop from the grid into the timeline / player.
- Registers a custom command ("Send all stock items to back") used by a context-menu entry.
- Tracks stock inserts via the event bus (analytics hook).
- Registers a custom icon so the sidebar shows the plugin's brand mark.
- Exposes a license-gated "HD download" toggle (read via
useLicense()).
Everything stays in src/vsdk-plugins/stock/. No package.json of its own.
Project layout
The adapter contract
The medium-tier plugin took plain config (presets: [...]). The advanced tier takes an adapter
interface the host implements. Your plugin doesn't know about Pexels, Unsplash, or your CMS — it
just calls adapter.search and adapter.download.
export interface StockPhoto {
id: string
thumbUrl: string
fullUrl: string
hdUrl?: string // only present for HD-licensed accounts
width: number
height: number
author: { name: string; url?: string }
}
export interface StockSearchResult {
photos: StockPhoto[]
page: number
hasMore: boolean
}
export interface StockAdapter {
search: (query: string, page: number) => Promise<StockSearchResult>
/** Resolves to a stable, host-owned URL (your CDN). */
download: (photoId: string, quality: 'standard' | 'hd') => Promise<{ assetUrl: string; assetId: string }>
}
export interface StockPluginOptions {
/** Required — the plugin is useless without I/O. */
adapter: StockAdapter
/** Sidebar slot order. Default 70. */
order?: number
/** Grid page size. Default 20. */
pageSize?: number
}The benefit of the adapter pattern, even when it's just internal to your app:
- Swap providers without touching panel code. Move from Pexels to your own asset library — change the adapter, ship.
- Test with a mock. A
createMockStockAdapter()returning hard-coded photos lets you Storybook the panel without network. - Keep API keys / auth tokens at the boundary. The plugin file never sees them; only the adapter does.
A Pexels adapter
import type { StockAdapter } from '../types'
export interface PexelsAdapterConfig {
apiKey: string
/** Defaults to https://api.pexels.com/v1 */
baseUrl?: string
/** Endpoint that mirrors a photo into your CDN and returns the final URL. */
resolveDownloadUrl: (photoId: string, quality: 'standard' | 'hd') => Promise<{ assetUrl: string; assetId: string }>
}
export function createPexelsStockAdapter(config: PexelsAdapterConfig): StockAdapter {
const baseUrl = config.baseUrl ?? 'https://api.pexels.com/v1'
return {
async search(query, page) {
const res = await fetch(
`${baseUrl}/search?query=${encodeURIComponent(query)}&page=${page}&per_page=20`,
{ headers: { Authorization: config.apiKey } },
)
if (!res.ok) throw new Error(`Pexels search failed: ${res.status}`)
const json = await res.json()
return {
photos: json.photos.map((p: any) => ({
id: String(p.id),
thumbUrl: p.src.tiny,
fullUrl: p.src.large2x,
hdUrl: p.src.original,
width: p.width,
height: p.height,
author: { name: p.photographer, url: p.photographer_url },
})),
page,
hasMore: !!json.next_page,
}
},
download(photoId, quality) {
// Hand off to the host's "mirror to my CDN" endpoint.
return config.resolveDownloadUrl(photoId, quality)
},
}
}The host wires the adapter at the call site:
stockPlugin({
adapter: createPexelsStockAdapter({
apiKey: process.env.NEXT_PUBLIC_PEXELS_KEY!,
resolveDownloadUrl: async (id, quality) =>
fetch('/api/stock/mirror', {
method: 'POST',
body: JSON.stringify({ provider: 'pexels', id, quality }),
}).then((r) => r.json()),
}),
})A mock adapter for dev / tests
import type { StockAdapter, StockPhoto } from '../types'
const FIXTURES: StockPhoto[] = [
{ id: '1', thumbUrl: '/mock/1-thumb.jpg', fullUrl: '/mock/1.jpg', width: 1920, height: 1080,
author: { name: 'Mock Photographer' } },
/* … */
]
export function createMockStockAdapter(): StockAdapter {
return {
async search(query) {
const filtered = FIXTURES.filter((p) =>
query ? p.author.name.toLowerCase().includes(query.toLowerCase()) : true,
)
return { photos: filtered, page: 1, hasMore: false }
},
async download(photoId) {
const photo = FIXTURES.find((p) => p.id === photoId)
return { assetUrl: photo?.fullUrl ?? '', assetId: `mock-${photoId}` }
},
}
}Same shape, no network. Useful in Storybook and in unit tests.
Config store
import type { StockAdapter } from './types'
interface InternalConfig {
adapter: StockAdapter | null
pageSize: number
}
let config: InternalConfig = { adapter: null, pageSize: 20 }
export function setConfig(next: InternalConfig): void { config = next }
export function getConfig(): InternalConfig { return config }A custom icon
The SDK's DEFAULT_ICON_MAP is exhaustive but plugin-owned icons let you brand your panel. Any
component that accepts { className?: string } works:
import type { SVGProps } from 'react'
export function StockIcon(props: SVGProps<SVGSVGElement> & { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="8.5" cy="10" r="1.5" />
<path d="M21 16l-5-5-9 9" />
</svg>
)
}The plugin registers it during onRegister. After that it's available to every plugin via
ctx.icons.stockPhoto.
Custom commands
Use a custom command whenever a mutation isn't already covered by a store action. Here we want
"send every selected stock item to z-index -100" as one undoable step.
import { createCommand, type Command } from '@studio-dev/vsdk/core'
export function createSendStockItemsToBackCommand(
snapshots: { id: string; oldZ: number }[],
): Command {
return createCommand(
'STOCK_SEND_TO_BACK',
'Send stock items to back',
// execute — runs on initial call and on redo
(draft) => {
for (const { id } of snapshots) {
const item = draft.timeline.items[id]
if (item) item.zIndex = -100
}
},
// undo — restore captured z-indexes
(draft) => {
for (const { id, oldZ } of snapshots) {
const item = draft.timeline.items[id]
if (item) item.zIndex = oldZ
}
},
)
}The canonical shape: capture the "before" values outside the command (closure over
snapshots), build the command with execute / undo, ship it through executeCommand. Never
look up old values inside undo — the state at undo time isn't necessarily the state you captured
from.
Analytics — event-bus subscription with cleanup
import type { StudioStore } from '@studio-dev/vsdk'
export function subscribeStockAnalytics(store: StudioStore): () => void {
const eventBus = store.getState().eventBus
const onAdded = ({ itemId }: { itemId: string }) => {
const item = store.getState().timeline.items[itemId]
if (item?.metadata?.source !== 'stock-plugin') return
// Replace with your real analytics — Segment, PostHog, custom.
fetch('/api/analytics/event', {
method: 'POST',
body: JSON.stringify({
name: 'stock_inserted',
photoId: item.metadata?.photoId,
quality: item.metadata?.quality,
}),
}).catch(() => { /* swallow */ })
}
eventBus.on('item:added', onAdded)
return () => eventBus.off('item:added', onAdded)
}subscribeStockAnalytics(ctx.store) returns its unsubscribe function — the plugin's onActivate
just bubbles that up.
Plugin definition — everything wired together
import { createPlugin, type PluginDefinition } from '@studio-dev/vsdk/plugins'
import { lazy } from 'react'
import { subscribeStockAnalytics } from './analytics'
import { StockIcon } from './icon'
import type { StockPluginOptions } from './types'
export function stockPlugin(options: StockPluginOptions): PluginDefinition {
return createPlugin({
id: 'app:stock',
name: 'Stock photos',
version: '1.0.0',
async onRegister(ctx) {
const { setConfig } = await import('./config-store')
setConfig({ adapter: options.adapter, pageSize: options.pageSize ?? 20 })
// Custom icon, used by this plugin and reachable from any other plugin
ctx.registerIcons({ stockPhoto: StockIcon })
// Sidebar panel
ctx.registerPanel({
id: 'stock-photos',
label: 'Stock',
icon: ctx.icons.stockPhoto, // resolved from the map (user overrides win)
component: lazy(() => import('./panel')),
order: options.order ?? 70,
})
// Inspector — show attribution for stock items
ctx.registerInspectorSection({
itemType: 'image',
label: 'Attribution',
mode: 'inline',
order: 80,
component: lazy(() => import('./inspector')),
})
// Context-menu — only enabled when every selected item is a stock item
ctx.registerContextMenuAction({
id: 'stock:send-to-back',
label: 'Send stock to back',
order: 90,
isVisible: (itemIds) => {
const state = ctx.store.getState()
return itemIds.length > 0 && itemIds.every(
(id) => state.timeline.items[id]?.metadata?.source === 'stock-plugin',
)
},
onAction: async (itemIds) => {
const { createSendStockItemsToBackCommand } = await import('./commands')
const state = ctx.store.getState()
const snapshots = itemIds
.map((id) => state.timeline.items[id])
.filter(Boolean)
.map((it) => ({ id: it.id, oldZ: it.zIndex }))
state.executeCommand(createSendStockItemsToBackCommand(snapshots))
},
})
// Hotkey — "/" to focus the search input (handled inside the panel)
ctx.registerHotkey({
id: 'stock:focus-search',
key: '/',
when: 'always',
label: 'Focus stock search',
onTrigger: () => {
document.querySelector<HTMLInputElement>('[data-stock-search]')?.focus()
},
})
},
onActivate(ctx) {
// Returned cleanup is invoked when the plugin / provider unmounts.
return subscribeStockAnalytics(ctx.store)
},
})
}Eight surfaces touched in one plugin: icons, panel, inspector, context menu, hotkey, event bus subscription, custom command, async dynamic-import for the commands module. That's everything the plugin system exposes — there is nothing more powerful, only more code.
The panel — search + virtualised grid + drag & drop
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Input, useItemDrag, useStudioStore } from '@studio-dev/vsdk'
import { getConfig } from './config-store'
import type { StockPhoto } from './types'
export default function StockPanel() {
const { adapter, pageSize: _ } = useMemo(() => getConfig(), [])
const [query, setQuery] = useState('')
const [photos, setPhotos] = useState<StockPhoto[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!adapter || !query) {
setPhotos([])
return
}
let cancelled = false
setError(null)
adapter.search(query, 1).then(
(r) => { if (!cancelled) setPhotos(r.photos) },
(e) => { if (!cancelled) setError(e.message ?? 'Search failed') },
)
return () => { cancelled = true }
}, [adapter, query])
if (!adapter) {
return <p className="p-3 text-sm text-studio-text-tertiary">No stock adapter configured.</p>
}
return (
<div className="flex h-full flex-col gap-2 p-3">
<Input
data-stock-search
placeholder="Search stock photos…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="grid grid-cols-2 gap-1.5 overflow-auto">
{photos.map((p) => <PhotoCard key={p.id} photo={p} />)}
</div>
</div>
)
}
function PhotoCard({ photo }: { photo: StockPhoto }) {
// useItemDrag wires DataTransfer + an in-memory cache that the timeline / player drop targets read.
const { isDragging, dragProps } = useItemDrag({
getData: () => ({
itemType: 'image',
source: {
assetUrl: photo.fullUrl,
thumbUrl: photo.thumbUrl,
metadata: { photoId: photo.id, quality: 'standard' },
},
durationSeconds: 5, // converted to frames using project FPS at drop time
name: `Stock photo · ${photo.author.name}`,
}),
})
return (
<div {...dragProps} className={isDragging ? 'opacity-50' : ''}>
<img
src={photo.thumbUrl}
alt=""
loading="lazy"
className="aspect-square w-full rounded object-cover ring-1 ring-studio-border-subtle"
/>
</div>
)
}useItemDrag is the standard SDK hook for "draggable thing → drop target." The drop target
(timeline track or player canvas) reads the PanelDragData payload back out — you only describe
the payload, the SDK constructs the TrackItem and dispatches addItem for you. The new item
arrives undoable, taggable via metadata, and placed at the drop point.
Drop-target placement, briefly
| Drop target | Effect |
|---|---|
| Timeline track | Inserts an item starting at the drop frame, on that track |
| Empty timeline area | Creates a new track + inserts the item |
| Player canvas | Inserts an item starting at the current playhead, on a free track |
Inspector — attribution
'use client'
import { useStudioStore } from '@studio-dev/vsdk'
export default function StockInspector({ itemId }: { itemId: string }) {
const item = useStudioStore((s) => s.timeline.items[itemId])
if (item?.metadata?.source !== 'stock-plugin') return null
const photoId = item.metadata?.photoId as string | undefined
return (
<div className="flex flex-col gap-1.5 p-2 text-xs">
<div className="text-studio-text-tertiary uppercase tracking-wide">Attribution</div>
<div>Photo #{photoId}</div>
{item.name && <div className="truncate text-studio-text-secondary">{item.name}</div>}
</div>
)
}License gating
Read license state via useLicense() to gate features (HD download, certain providers, …):
import { useLicense } from '@studio-dev/vsdk'
function HdToggle() {
const { status, plan } = useLicense()
if (status !== 'valid' || plan !== 'pro') {
return <p className="text-xs text-studio-text-tertiary">HD requires a Pro license.</p>
}
// …pro features
}License keys are passed to the provider via <VideoStudioProvider licenseKey={…}>. Your plugin
just reads the resolved state.
Entrypoint
export { stockPlugin } from './plugin'
export { createPexelsStockAdapter } from './adapters/pexels'
export { createMockStockAdapter } from './adapters/mock'
export type {
StockAdapter,
StockPhoto,
StockSearchResult,
StockPluginOptions,
} from './types'Wire it up
'use client'
import { useMemo } from 'react'
import { StudioEditor, VideoStudioProvider } from '@studio-dev/vsdk'
import '@studio-dev/vsdk/styles.css'
import { stockPlugin, createPexelsStockAdapter } from '@/vsdk-plugins/stock'
export default function Editor() {
const plugins = useMemo(
() => [
stockPlugin({
adapter: createPexelsStockAdapter({
apiKey: process.env.NEXT_PUBLIC_PEXELS_KEY!,
resolveDownloadUrl: async (id, quality) =>
fetch('/api/stock/mirror', {
method: 'POST',
body: JSON.stringify({ provider: 'pexels', id, quality }),
}).then((r) => r.json()),
}),
order: 70,
}),
],
[],
)
return (
<VideoStudioProvider plugins={plugins} theme="dark">
<StudioEditor className="h-screen" />
</VideoStudioProvider>
)
}Advanced patterns checklist
A plugin that hits these is doing the right things:
- ✅ Adapter interface for all I/O — host wires the implementation.
- ✅ Lazy-loaded panel and inspector (
React.lazy). - ✅ Mock adapter alongside the real one — used in Storybook / tests.
- ✅ Cleanup returned from
onActivatefor every subscription. - ✅ Custom commands wrap any mutation that isn't a one-liner on the store.
- ✅
metadata.sourcetag on every item the plugin creates. - ✅ Context-menu actions gated by
isVisibleso they only show on owned items. - ✅ Drag-and-drop via
useItemDraginstead of click-only insert. - ✅ Plugin owns its icons via
registerIcons. - ✅ License-gated features fall back to a clear "upgrade" message, not a silent failure.
Recipes specific to advanced plugins
Coordinating with another plugin
Plugins can read each other's items via metadata.source. Show a "Replace with stock" inspector
button only when the selected text item came from your captions plugin, by checking
item.metadata?.source === 'captions-plugin' and offering an action.
Cleaning up plugin-created items
Most plugins should leave items alone on uninstall — the user might still want them. But if your
plugin is truly transient (a "temporary draft" tool), tag and remove on onDeactivate:
onDeactivate(ctx) {
const state = ctx.store.getState()
const mine = Object.values(state.timeline.items).filter(
(i) => i.metadata?.source === 'stock-plugin',
)
state.startBatch()
for (const it of mine) state.removeItem(it.id)
state.endBatch('Uninstall stock plugin')
}Reading the playhead
subscribe() on the store fires after every state update — including every frame during playback.
Don't put heavy work in that handler. For "user jumped" only, subscribe to the event bus:
eventBus.on('playback:seek', ({ frame }) => {
// user-initiated jump only
})For per-frame work in plugin code, use the SDK's useFrameSync hook inside a React component — it
batches into requestAnimationFrame.
Tests for the panel
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { StudioEditor, VideoStudioProvider } from '@studio-dev/vsdk'
import { stockPlugin, createMockStockAdapter } from './'
test('search renders results from the adapter', async () => {
render(
<VideoStudioProvider plugins={[stockPlugin({ adapter: createMockStockAdapter() })]}>
<StudioEditor />
</VideoStudioProvider>,
)
// Open the panel
fireEvent.click(screen.getByRole('button', { name: /stock/i }))
fireEvent.change(await screen.findByPlaceholderText(/search/i), { target: { value: 'mock' } })
await waitFor(() => expect(screen.getAllByRole('img').length).toBeGreaterThan(0))
})The mock adapter is what makes this reliable in CI — no network, no flakiness.
When you've outgrown advanced
You're past this template when:
- Another team wants to install the plugin. That's a package-publishing problem — head to Publishing a plugin.
- The plugin is bigger than the editor that hosts it. That usually means too much logic landed inside the plugin; pull domain logic out into a normal package the plugin imports.
- You need editor-level features the plugin API can't reach (e.g. swapping the entire timeline
renderer). The plugin system intentionally doesn't expose that — fork the editor shell instead
(use
createDefaultStudio+ custom layouts).
For everything else, the patterns on this page are the ceiling.