Video Studio SDKv0.0.3
Plugin authoringTiers

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:

  1. Accepts an adapter interface for I/O (host wires Pexels, Unsplash, or its own CMS).
  2. Renders a panel with a search input and a virtualized grid.
  3. Supports drag-and-drop from the grid into the timeline / player.
  4. Registers a custom command ("Send all stock items to back") used by a context-menu entry.
  5. Tracks stock inserts via the event bus (analytics hook).
  6. Registers a custom icon so the sidebar shows the plugin's brand mark.
  7. 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

plugin.ts
config-store.ts
types.ts
pexels.ts
mock.ts — used in dev / tests
panel.tsx
inspector.tsx
commands.ts
icon.tsx
analytics.ts
index.ts

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.

src/vsdk-plugins/stock/types.ts
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

src/vsdk-plugins/stock/adapters/pexels.ts
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

src/vsdk-plugins/stock/adapters/mock.ts
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

src/vsdk-plugins/stock/config-store.ts
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:

src/vsdk-plugins/stock/icon.tsx
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.

src/vsdk-plugins/stock/commands.ts
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

src/vsdk-plugins/stock/analytics.ts
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

src/vsdk-plugins/stock/plugin.ts
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

src/vsdk-plugins/stock/panel.tsx
'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 targetEffect
Timeline trackInserts an item starting at the drop frame, on that track
Empty timeline areaCreates a new track + inserts the item
Player canvasInserts an item starting at the current playhead, on a free track

Inspector — attribution

src/vsdk-plugins/stock/inspector.tsx
'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

src/vsdk-plugins/stock/index.ts
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

src/app/editor.tsx
'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 onActivate for every subscription.
  • ✅ Custom commands wrap any mutation that isn't a one-liner on the store.
  • metadata.source tag on every item the plugin creates.
  • ✅ Context-menu actions gated by isVisible so they only show on owned items.
  • ✅ Drag-and-drop via useItemDrag instead 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

src/vsdk-plugins/stock/panel.test.tsx
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.

On this page