Video Studio SDKv0.0.3
Plugin authoringTiers

Medium plugin

Panel + inspector section + hotkey + plugin options, all living inside your app. The shape ~80% of internal plugins end up at.

The medium tier is where most in-project plugins stabilize. You have one panel, one inspector section that edits the active item, a hotkey for the power users, and config options passed in by the host app. Still no separate package, no build pipeline, no publishing.

This page assumes you've worked through Simple. The biggest jump from that page is the config-store pattern, which is how a lazy-loaded panel reads options the host app passed at registration time.

What you'll build

A "Captions" plugin that:

  1. Adds a sidebar panel listing caption presets.
  2. Adds an inspector section for text items — pick a preset to apply to the selected caption.
  3. Registers a hotkey (Shift+C) that inserts the default preset at the playhead.
  4. Accepts presets and defaultPresetId as plugin options from the host app.

Project layout

plugin.ts — createPlugin + onRegister
config-store.ts — host options, read at runtime
panel.tsx — sidebar UI
inspector.tsx — appears on text items
insert.ts — shared insert logic
presets.ts — built-in defaults
index.ts — barrel re-export

Seven small files — none required outside the plugin folder. You can collapse a couple if you want (presets.ts into plugin.ts, insert.ts into panel.tsx); we split them for clarity.

A tiny config store

The lazy-loaded panel can't receive plugin options as props. The pattern is a module-level config holder: the plugin sets it once during onRegister, every component reads from it at runtime.

src/vsdk-plugins/captions/config-store.ts
import type { TextStyle } from '@studio-dev/vsdk'

export interface CaptionPreset {
  id:    string
  label: string
  style: Partial<TextStyle>                     // shape of the SDK's TextStyle
}

export interface CaptionsConfig {
  presets:         CaptionPreset[]
  defaultPresetId: string
}

let config: CaptionsConfig = {
  presets:         [],
  defaultPresetId: '',
}

export function setConfig(next: CaptionsConfig): void { config = next }
export function getConfig(): CaptionsConfig            { return config }

That's the whole pattern. A getter, a setter, a module-scoped variable. Components import getConfig() lazily; the plugin writes once in onRegister.

This is safe because plugin options don't change at runtime — they're set at provider mount and immutable for the session. If you genuinely need reactive plugin config (e.g. the user toggles a setting at runtime), store it under metadata in the bundle or in your own Zustand store alongside the SDK's.

Built-in defaults

A small constant file. Real apps would tune these or pull them from your design system.

src/vsdk-plugins/captions/presets.ts
import type { CaptionPreset } from './config-store'

export const DEFAULT_PRESETS: CaptionPreset[] = [
  {
    id: 'minimal',
    label: 'Minimal',
    style: { fontSize: 48, color: '#ffffff', fontWeight: 500 },
  },
  {
    id: 'tiktok',
    label: 'TikTok',
    style: { fontSize: 72, color: '#ffffff', fontWeight: 800, textTransform: 'uppercase' },
  },
  {
    id: 'classic',
    label: 'Classic',
    style: { fontSize: 56, color: '#facc15', fontFamily: 'Inter' },
  },
]

Plugin definition

The plugin definition is where everything is wired up. Three registrations + one async config write:

src/vsdk-plugins/captions/plugin.ts
import { createPlugin, type PluginDefinition } from '@studio-dev/vsdk/plugins'
import { lazy } from 'react'

import { DEFAULT_PRESETS } from './presets'
import { insertDefaultCaption } from './insert'

import type { CaptionPreset } from './config-store'

export interface CaptionsPluginOptions {
  /** Caption presets the user can pick. Defaults to a built-in set. */
  presets?:         CaptionPreset[]
  /** ID of the preset used by the Shift+C hotkey. Defaults to the first preset. */
  defaultPresetId?: string
  /** Sidebar slot order. Default 60. */
  order?:           number
}

export function captionsPlugin(options: CaptionsPluginOptions = {}): PluginDefinition {
  return createPlugin({
    id:      'app:captions',
    name:    'Captions',
    version: '0.1.0',

    async onRegister(ctx) {
      // Resolve options against defaults and stash for runtime.
      const { setConfig } = await import('./config-store')
      const presets = options.presets ?? DEFAULT_PRESETS
      const defaultPresetId = options.defaultPresetId ?? presets[0]?.id ?? ''
      setConfig({ presets, defaultPresetId })

      // 1. Sidebar panel
      ctx.registerPanel({
        id:        'captions',
        label:     'Captions',
        icon:      ctx.icons.itemCaptions,
        component: lazy(() => import('./panel')),
        order:     options.order ?? 60,
      })

      // 2. Inspector section — appears only on text items
      ctx.registerInspectorSection({
        itemType:  'text',
        label:     'Caption preset',
        mode:      'inline',
        order:     50,
        component: lazy(() => import('./inspector')),
      })

      // 3. Hotkey — insert default preset at playhead
      ctx.registerHotkey({
        id:        'captions:insert-default',
        key:       'c',
        modifiers: ['shift'],
        label:     'Insert default caption',
        when:      'always',
        onTrigger: () => insertDefaultCaption(ctx.store),
      })
    },
  })
}

Three new tools you didn't need in the simple tier:

New hereWhat it does
async onRegisterThe SDK awaits this before rendering plugin UI, so the config store is populated by the time the panel mounts.
ctx.registerInspectorSectionAdds a section to the inspector panel for a specific item type. mode: 'inline' renders inside the default tab; mode: 'tab' adds a dedicated tab.
ctx.registerHotkeyRegisters a keyboard shortcut. when scopes it: 'always', 'timeline-focused', 'player-focused'.

The panel

src/vsdk-plugins/captions/panel.tsx
'use client'

import { Button, useStudioStore } from '@studio-dev/vsdk'
import { useMemo } from 'react'

import { getConfig } from './config-store'
import { insertCaption } from './insert'

export default function CaptionsPanel() {
  // Static read — config doesn't change at runtime.
  const { presets } = useMemo(() => getConfig(), [])

  // Narrow selectors keep the component cheap.
  const tracks         = useStudioStore((s) => s.timeline.tracks)
  const items          = useStudioStore((s) => s.timeline.items)
  const itemIdsByTrack = useStudioStore((s) => s.timeline.itemIdsByTrack)
  const settings       = useStudioStore((s) => s.project.settings)
  const currentFrame   = useStudioStore((s) => s.player.currentFrame)
  const addItem        = useStudioStore((s) => s.addItem)
  const addTrack       = useStudioStore((s) => s.addTrack)

  return (
    <div className="flex h-full flex-col gap-2 p-3">
      <p className="text-xs text-studio-text-tertiary">
        Click to insert. Each preset becomes a new text item at the playhead.
      </p>
      {presets.map((p) => (
        <Button
          key={p.id}
          variant="secondary"
          className="justify-start"
          onClick={() =>
            insertCaption({
              preset: p,
              tracks, items, itemIdsByTrack, settings, currentFrame, addItem, addTrack,
            })
          }
        >
          {p.label}
        </Button>
      ))}
    </div>
  )
}

The inspector section

Inspector components receive the active item id as a prop. Read the item, mutate it via updateItemProperties.

src/vsdk-plugins/captions/inspector.tsx
'use client'

import { Label, useStudioStore } from '@studio-dev/vsdk'
import { useMemo } from 'react'

import { getConfig } from './config-store'

export default function CaptionsInspector({ itemId }: { itemId: string }) {
  const { presets } = useMemo(() => getConfig(), [])

  const item                 = useStudioStore((s) => s.timeline.items[itemId])
  const updateItemProperties = useStudioStore((s) => s.updateItemProperties)

  if (!item) return null

  const apply = (presetId: string) => {
    const preset = presets.find((p) => p.id === presetId)
    if (!preset) return
    // Spread the existing style so user customisations survive.
    updateItemProperties(itemId, { style: { ...item.style, ...preset.style } })
  }

  return (
    <div className="flex flex-col gap-2 p-2">
      <Label className="text-xs uppercase tracking-wide text-studio-text-tertiary">
        Caption preset
      </Label>
      <div className="flex flex-wrap gap-1.5">
        {presets.map((p) => (
          <button
            key={p.id}
            onClick={() => apply(p.id)}
            className="rounded border border-studio-border-subtle bg-studio-surface-raised px-2.5 py-1 text-xs hover:bg-studio-accent/15 hover:border-studio-accent/40"
          >
            {p.label}
          </button>
        ))}
      </div>
    </div>
  )
}

A few things worth highlighting:

  • mode: 'inline' (set in plugin.ts) means the section renders inside the default tab on desktop. Use mode: 'tab' if the section is heavy enough to deserve its own tab (then add an icon).
  • updateItemProperties is reversible — applying a preset is one ⌘Z step. No commands to write yourself.
  • Spread the existing style when applying a preset so user-edited fields aren't clobbered.
  • Tailwind tokens like bg-studio-surface-raised, border-studio-border-subtle come from the SDK's design tokens — they swap with the theme automatically.

Shared insert logic

The panel and the hotkey both insert captions, so the logic lives in one place:

src/vsdk-plugins/captions/insert.ts
import {
  createDefaultTransform,
  findAvailableTrack,
  type StudioStore,
  type ProjectSettings,
  type Track,
  type TrackItem,
} from '@studio-dev/vsdk'

import { getConfig, type CaptionPreset } from './config-store'

interface InsertArgs {
  preset:         CaptionPreset
  tracks:         Track[]
  items:          Record<string, TrackItem>
  itemIdsByTrack: Record<string, string[]>
  settings:       ProjectSettings
  currentFrame:   number
  addItem:        (item: Omit<TrackItem, 'id'> & { id?: string }) => string
  addTrack:       (track: Omit<Track, 'id'> & { id?: string }) => string
}

const CAPTION_DURATION_SECONDS = 4

export function insertCaption(args: InsertArgs): string {
  const { preset, tracks, items, itemIdsByTrack, settings, currentFrame, addItem, addTrack } = args
  const durationFrames = settings.fps * CAPTION_DURATION_SECONDS

  const trackId =
    findAvailableTrack(tracks, itemIdsByTrack, items, 'track', currentFrame, durationFrames)?.id
    ?? addTrack({
      name:   'Captions',
      kind:   'track',
      order:  tracks.length,
      locked: false, muted: false, hidden: false,
    })

  return addItem({
    type:           'text',
    trackId,
    startFrame:     currentFrame,
    durationFrames,
    name:           preset.label,
    zIndex:         20,
    locked: false, disabled: false, muted: false,
    source:    {},
    transform: createDefaultTransform(settings),
    style:     { text: 'Sample text', ...preset.style },
    filters:   [],
    metadata:  { source: 'captions-plugin', presetId: preset.id },
  })
}

/** Wrapper the hotkey calls with just the store handle. */
export function insertDefaultCaption(store: StudioStore): void {
  const { presets, defaultPresetId } = getConfig()
  const preset = presets.find((p) => p.id === defaultPresetId) ?? presets[0]
  if (!preset) return

  const s = store.getState()
  insertCaption({
    preset,
    tracks:         s.timeline.tracks,
    items:          s.timeline.items,
    itemIdsByTrack: s.timeline.itemIdsByTrack,
    settings:       s.project.settings,
    currentFrame:   s.player.currentFrame,
    addItem:        s.addItem,
    addTrack:       s.addTrack,
  })
}

The hotkey handler calls insertDefaultCaption(ctx.store) directly — ctx.store is the same Zustand store hooks read from inside React. Outside React you always use store.getState() to read.

The entrypoint

src/vsdk-plugins/captions/index.ts
export { captionsPlugin, type CaptionsPluginOptions } from './plugin'
export type { CaptionPreset, CaptionsConfig } from './config-store'

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 { captionsPlugin } from '@/vsdk-plugins/captions'

export default function Editor() {
  const plugins = useMemo(
    () => [
      captionsPlugin({
        presets: [
          { id: 'brand-a', label: 'Brand A', style: { fontSize: 64, color: '#ff0080' } },
          { id: 'brand-b', label: 'Brand B', style: { fontSize: 64, color: '#00d4ff' } },
        ],
        defaultPresetId: 'brand-a',
      }),
    ],
    [],
  )

  return (
    <VideoStudioProvider plugins={plugins} theme="dark">
      <StudioEditor className="h-screen" />
    </VideoStudioProvider>
  )
}

Now your editor has:

  • A "Captions" panel in the sidebar listing two brand presets.
  • An inspector tab appearing whenever the user selects a text item.
  • A Shift+C hotkey that inserts the default brand preset.
  • All of it persisted across save/load via metadata.source.

What you can build with this template

This shape (panel + inspector + hotkey + options) is enough for almost any preset library plugin:

  • A typography plugin — preset list, click to insert, inspector to swap, hotkey to insert default.
  • An animation presets plugin — list animations, apply to selected item, hotkey for "apply default to all selected".
  • A filters plugin — list filters, apply on click, inspector with sliders for fine-tuning.
  • A brand kit plugin — colors / logos / fonts, apply to selection.

Recipe — "apply to all selected"

A common upgrade: the inspector currently mutates one item. Sometimes the user wants to apply a preset to every selected item. Use a batch so all mutations land as one undo step:

function applyToSelection(presetId: string) {
  const state = useStudioStore.getState()      // outside React — use the imperative store
  const ids = [...state.selection.selectedItemIds]
  if (ids.length === 0) return

  state.startBatch()
  for (const id of ids) {
    const item = state.timeline.items[id]
    if (!item || item.type !== 'text') continue
    const preset = presets.find((p) => p.id === presetId)
    if (!preset) continue
    state.updateItemProperties(id, { style: { ...item.style, ...preset.style } })
  }
  state.endBatch('Apply caption preset to selection')
}

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

Recipe — async plugin config

If your config needs to be fetched (a brand kit endpoint, feature flags, …), make onRegister async:

async onRegister(ctx) {
  const { setConfig } = await import('./config-store')
  const remote = await fetch('/api/brand/captions').then((r) => r.json())
  setConfig({ presets: remote.presets, defaultPresetId: remote.defaultId })

  // …registrations follow as usual.
}

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

Recipe — disabling the inspector section conditionally

registerInspectorSection doesn't take an isVisible. The standard workaround is to early-return null from the component when the item shouldn't see it:

export default function CaptionsInspector({ itemId }: { itemId: string }) {
  const item = useStudioStore((s) => s.timeline.items[itemId])

  // Only show on captions you created (tagged via metadata.source).
  if (item?.metadata?.source !== 'captions-plugin') return null

  // …rest
}

The section is registered for itemType: 'text', but the runtime check narrows it further.

Common mistakes (and the fixes)

MistakeFix
Panel re-renders on every frameYou read too much state. Replace useStudioStore((s) => s.player) with a narrow selector.
Config is empty when the panel mountsYou forgot await somewhere in onRegister, or you set the config after registering the panel. Set config first.
Hotkey fires while the user typesSet when: 'timeline-focused' instead of 'always', or the SDK's input-focus check will handle it for typing fields.
Inspector section never shows upitemType mismatched. Use '*' to match every item type while debugging.
Two clicks land on the same trackAlways call findAvailableTrack before addTrack.

When to graduate to advanced

You'll outgrow the medium template when:

  • The plugin needs data from a backend (asset browser, search, account info) — wrap it in an adapter the host app provides.
  • You want drag-and-drop from panel to timeline instead of click-to-insert.
  • You need a custom command for a mutation the store's actions don't cover.
  • You want to subscribe to events for cross-cutting behavior (autosave, analytics, plugin coordination).
  • You want to register your own icons for the panel and other UI surfaces.

Continue to Advanced →

On this page