Video Studio SDKv0.0.3
Plugin authoringTiers

Simple plugin

A single-file panel that drops items onto the timeline. Lives inside your app — no separate package, no build pipeline, no publishing.

This page is the fastest path from "I have the SDK mounted" to "I have my own panel in the sidebar." The plugin lives inside your app's source tree — usually a single folder under src/vsdk-plugins/. You don't publish it. You don't bundle it separately. You just import it and hand it to <VideoStudioProvider plugins={[…]}>.

Looking to ship a plugin as an npm package so other teams can install it? Skip this page and head to Publishing a plugin. Otherwise, keep reading — this is the in-project pattern.

What you'll build

A "Stickers" panel that lists a few static images. Clicking one inserts it at the playhead, on a free track (a new one is created if needed). Roughly 100 lines of code, two files, zero new dependencies.

When you're done, your editor will have a new sidebar tab — your own — and the user can press Cmd+Z to undo the insert just like any built-in action.

Project layout

Plugins live wherever you want inside your app. The convention is a top-level vsdk-plugins/ folder with one subfolder per plugin:

plugin.ts
panel.tsx
index.ts

Three files, all local. No package.json, no tsup, no peer-dependency dance.

Step 1 — the plugin definition

src/vsdk-plugins/stickers/plugin.ts
import { createPlugin } from '@studio-dev/vsdk/plugins'
import { lazy } from 'react'

export function stickersPlugin() {
  return createPlugin({
    id: 'app:stickers',          // namespace it with your app name (use 'mycompany:' if you prefer)
    name: 'Stickers',
    version: '0.1.0',            // any string; just for debugging — not enforced by the SDK

    onRegister(ctx) {
      ctx.registerPanel({
        id: 'stickers',
        label: 'Stickers',
        icon: ctx.icons.pluginElements,                   // reuse an SDK icon
        component: lazy(() => import('./panel')),         // panel chunk loads on demand
        order: 50,                                        // built-ins use 1–10
      })
    },
  })
}

Three things to lock in here:

  1. createPlugin is just a typed identity function. It returns its argument with the PluginDefinition shape. You could write export const stickersPlugin = (): PluginDefinition => ({ … }) and it would behave identically.
  2. React.lazy the panel. The panel only loads when the user opens the tab — saving startup cost. The SDK wraps lazy components in <Suspense> automatically.
  3. ctx.icons is the resolved icon map, so if the host app overrode pluginElements your panel automatically picks up that override.

Step 2 — the panel

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

import {
  Button,
  createDefaultTransform,
  findAvailableTrack,
  useIconMap,
  useStudioStore,
} from '@studio-dev/vsdk'

const STICKERS = [
  { id: 'cat', label: '🐱 Cat', url: 'https://cdn.example.com/cat.png' },
  { id: 'dog', label: '🐶 Dog', url: 'https://cdn.example.com/dog.png' },
  { id: 'star', label: '⭐ Star', url: 'https://cdn.example.com/star.png' },
]

export default function StickersPanel() {
  const icons = useIconMap()
  const Plus = icons.add

  // Pull narrow slices — one selector per piece of state. The component re-renders only when
  // *these* values change.
  const settings        = useStudioStore((s) => s.project.settings)
  const tracks          = useStudioStore((s) => s.timeline.tracks)
  const items           = useStudioStore((s) => s.timeline.items)
  const itemIdsByTrack  = useStudioStore((s) => s.timeline.itemIdsByTrack)
  const currentFrame    = useStudioStore((s) => s.player.currentFrame)
  const addItem         = useStudioStore((s) => s.addItem)
  const addTrack        = useStudioStore((s) => s.addTrack)

  const insert = (sticker: (typeof STICKERS)[number]) => {
    const durationFrames = settings.fps * 3                  // 3 seconds at project FPS

    // Try to land on an existing free track first; create a new one if every track is busy.
    const available = findAvailableTrack(
      tracks, itemIdsByTrack, items, 'track', currentFrame, durationFrames,
    )

    const trackId = available?.id
      ?? addTrack({
        name:   `Sticker ${tracks.filter((t) => t.kind === 'track').length + 1}`,
        kind:   'track',
        order:  tracks.length,
        locked: false, muted: false, hidden: false,
      })

    addItem({
      type:           'image',
      trackId,
      startFrame:     currentFrame,
      durationFrames,
      name:           sticker.label,
      zIndex:         10,
      locked: false, disabled: false, muted: false,
      source:         { assetUrl: sticker.url, thumbUrl: sticker.url },
      transform: {
        ...createDefaultTransform(settings),
        width:  settings.width  * 0.4,
        height: settings.height * 0.4,
      },
      style:    {},
      filters:  [],
      metadata: { source: 'stickers-plugin', stickerId: sticker.id },
    })
  }

  return (
    <div className="flex flex-col gap-2 p-3">
      {STICKERS.map((s) => (
        <Button
          key={s.id}
          variant="secondary"
          className="justify-start gap-2"
          onClick={() => insert(s)}
        >
          <Plus className="size-4" />
          {s.label}
        </Button>
      ))}
    </div>
  )
}

Things to internalize for every plugin you'll ever write:

PatternWhy
One selector per state sliceRe-renders are scoped to the value you actually use. Avoid useStudioStore((s) => s) like the plague — it re-renders on every frame during playback.
findAvailableTrack before addTrackMulti-clicks land on the same track instead of stacking on top of each other.
createDefaultTransform(settings)Returns a centered, full-canvas transform sized to the project. Override individual fields (width/height/rotation/opacity).
metadata.source: 'stickers-plugin'Lets you find your items later — analytics, cleanup, "show this inspector only on my items". The SDK preserves metadata across save/load but never reads it.
No imperative undo wiringaddItem already pushes a reversible command. Ctrl+Z just works.

Step 3 — the entrypoint

src/vsdk-plugins/stickers/index.ts
export { stickersPlugin } from './plugin'

A one-liner barrel file is the convention even for in-project plugins — it gives you a single import path you can later rename without touching the call site.

Step 4 — wire it into the editor

This is the only piece that touches your editor mount:

src/app/editor.tsx
'use client'

import { useMemo } from 'react'
import { StudioEditor, VideoStudioProvider } from '@studio-dev/vsdk'
import '@studio-dev/vsdk/styles.css'

import { stickersPlugin } from '@/vsdk-plugins/stickers'

export default function Editor() {
  // `useMemo` here is important: a new array literal would re-register every render.
  const plugins = useMemo(() => [stickersPlugin()], [])

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

Open the editor, look in the sidebar — there's "Stickers" with the icon you picked. Click it, click a sticker, watch it drop on the timeline at the current frame.

What you got for free

You didn't write any of these — and they all work:

  • Undo / redo for the insert (one Ctrl+Z removes it cleanly).
  • Persistence — your sticker's assetUrl and metadata survive onSave / loadBundle.
  • Move / trim / split the new item on the timeline.
  • Mobile — the sidebar tab automatically becomes a bottom-sheet entry.
  • Lazy loading — your panel module ships only when the user opens it.
  • Theming — every Tailwind class you used reads the editor's design tokens, so light/dark mode swap with the rest of the UI.

Anatomy of an in-project plugin

The whole file lives next to your other React code. There's no build step beyond your app's normal bundler. The shape stays the same regardless of complexity:

plugin.ts       — the createPlugin({ … }) call. Registers extension points in onRegister.
panel.tsx       — the lazy-loaded UI surface. Reads state via hooks, writes via actions.
index.ts        — barrel re-export.

Add files as your plugin grows (inspector.tsx, hotkeys.ts, commands.ts, config.ts) — the shape doesn't change.

A few small variations

Adding a hotkey too

Two lines extra in onRegister:

ctx.registerHotkey({
  id: 'stickers:insert-cat',
  key: 's',
  modifiers: ['shift'],
  label: 'Insert sticker (Cat)',
  when: 'always',
  onTrigger: () => {
    const state = ctx.store.getState()
    // …reuse `insert(STICKERS[0])` logic here
  },
})

when controls focus scope: 'always', 'timeline-focused', or 'player-focused'.

Adding a toolbar button

ctx.registerToolbarAction({
  id: 'stickers:quick-insert',
  label: 'Quick sticker',
  icon: ctx.icons.add,
  order: 80,
  onClick: () => { /* same insert logic */ },
})

Both registrations live happily next to registerPanel in the same onRegister.

Passing config from the host app

The simple-tier doesn't typically need this — your STICKERS list is right there in the panel file. But the moment a sibling component needs to read plugin config, jump to the medium-tier config-store pattern.

When to graduate to medium

Stay on this tier as long as:

  • The plugin contributes one panel with static content (presets baked into the file).
  • You don't need to mutate selected items.
  • You don't need to take options from the host app.

Graduate to Medium when:

  • You need an inspector section to edit the selected item's properties.
  • You want config options passed in by the host app (preset list, defaults, slot order…).
  • You want shared insert logic reused by both a panel button and a hotkey.

Common mistakes (and the fixes)

MistakeFix
Panel re-renders on every playback frameYou selected too much state. Replace useStudioStore((s) => s) with narrow selectors.
Two clicks land on the same trackUse findAvailableTrack before falling back to addTrack.
New track appears with wrong orderAlways set order: tracks.length — the SDK doesn't autoincrement.
Sticker has wrong sizeOverride width / height in transform. createDefaultTransform fills the whole canvas by default.
Plugin re-registers on every renderMove plugins={[...]} to a useMemo (or a module constant). The provider compares by identity.

What's next

On this page