Video Studio SDKv0.0.3
Plugin authoringExtension points

Inspector section

Inline sections vs. tab-mode sections, scoping to item types, and the {itemId} prop contract.

An inspector section renders inside the right-hand properties panel when an item is selected. It sees the selected item's id, reads the item from the store, and mutates it through actions. Two modes: inline (folds into the default properties tab) or tab (becomes its own tab on desktop / item action button on mobile).

The registration shape

interface InspectorSectionConfig {
  itemType:        string                                         // 'video' | 'audio' | 'image' | 'text' | 'overlay' | 'captions' | 'adjustment' | '*'
  component:       ComponentType<{ itemId: string }>
  order:           number
  label:           string
  mode?:           'inline' | 'tab'                               // default 'inline'
  icon?:           ComponentType<{ className?: string }>          // required when mode='tab'
  mobileComponent?: LazyExoticComponent<...> | ComponentType<{ itemId: string }>
}

Registering an inline section

Inline sections appear stacked in the default item-properties tab, sorted by order. Use them for property controls that belong next to the item's other properties.

ctx.registerInspectorSection({
  itemType:  'image',
  label:     'Filters',
  mode:      'inline',
  order:     50,
  component: lazy(() => import('./filter-controls')),
})

Registering a tab section

Tab sections create a new top-level tab in the inspector (desktop) and an action button on the mobile bottom bar. Use them for substantial sub-views — like an entire animation editor — that deserve their own surface.

ctx.registerInspectorSection({
  itemType:  'image',
  label:     'Effects',
  mode:      'tab',
  order:     100,
  icon:      ctx.icons.sliders,    // required for tabs
  component: lazy(() => import('./effects-tab')),
  mobileComponent: lazy(() => import('./effects-tab-mobile')),
})

icon is mandatory for tab mode — it's the tab indicator on desktop and the button glyph on mobile.

Scoping with itemType

ValueRenders when
'image', 'video', etc.An item of exactly that type is selected
'*'Any item is selected

Use '*' for cross-type controls (e.g. a "Notes" section that applies to anything). Use a specific type for property controls that only make sense for that item type.

De-duplication

The registry de-duplicates by (itemType, label) — re-registering with the same pair is a no-op. This means two plugins both registering an image section labeled "Effects" will collide; pick distinct labels.

The component contract

The component receives one prop: { itemId: string }. Use it to look the item up and mutate.

'use client'

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

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

  if (!item) return null

  return (
    <div className="flex flex-col gap-2 p-2">
      <Label>Opacity</Label>
      <Slider
        min={0}
        max={1}
        step={0.01}
        value={[item.transform.opacity]}
        onValueChange={([v]) => updateItemProperties(itemId, { transform: { opacity: v } })}
      />
    </div>
  )
}

Defensive lookups

The item can disappear between the selection event and the next render (someone deleted it, undo happened, etc.). Always guard:

if (!item) return null

Use updateItemProperties for almost everything

The canonical mutation for inspector controls is updateItemProperties(itemId, patch). It accepts a shallow patch on transform, style, filters, zIndex, locked, disabled, muted, startFrame, durationFrames — the SDK shallow-merges the patch in and registers an undoable command.

// Transform — nested partial
updateItemProperties(itemId, { transform: { opacity: 0.5 } })

// Style — accepts an arbitrary object (different per item type)
updateItemProperties(itemId, { style: { ...item.style, brightness: 1.2 } })

// Flags
updateItemProperties(itemId, { locked: !item.locked })

If you need to mutate something updateItemProperties doesn't cover (e.g. masks, animation keyframes) use the dedicated actions (addMask, setKeyframe, …) — they all push commands too.

Debounce continuous controls

Sliders fire onValueChange on every pointer move. Each call pushes a command. The undo stack quickly fills with 100 "Change opacity" entries that the user can't usefully undo through.

Two strategies:

Throttle pushes during drag, commit on release

import { useRef } from 'react'
import { Slider, useStudioStore, useStudioStoreApi } from '@studio-dev/vsdk'

function OpacityControl({ itemId }: { itemId: string }) {
  const storeApi = useStudioStoreApi()
  const startOpacityRef = useRef<number | null>(null)
  const opacity = useStudioStore((s) => s.timeline.items[itemId]?.transform.opacity ?? 1)

  return (
    <Slider
      min={0} max={1} step={0.01}
      value={[opacity]}
      onValueChange={([v]) => {
        const state = storeApi.getState()
        if (startOpacityRef.current === null) {
          startOpacityRef.current = state.timeline.items[itemId]?.transform.opacity ?? 1
          state.startBatch()
        }
        state.updateItemProperties(itemId, { transform: { opacity: v } })
      }}
      onValueCommit={() => {
        startOpacityRef.current = null
        storeApi.getState().endBatch('Change opacity')
      }}
    />
  )
}

startBatch / endBatch collapses every intermediate mutation into one undoable entry. The user slides smoothly; ⌘Z reverses the whole drag.

Bypass history for "preview" mutations

If you genuinely don't want any of the intermediate values undoable, write to the store directly and only call updateItemProperties on commit:

storeApi.setState((draft) => {
  const item = draft.timeline.items[itemId]
  if (item) item.transform.opacity = v
})

This is the escape hatch — see Commands.

Inline vs tab — picking the mode

Pick inline whenPick tab when
The controls fit in 3–5 rowsThe section needs its own scroll area
They're properties of the item, not a workflowThey drive a workflow (keyframe editor, mask editor)
You want them visible alongside the built-in propertiesThey'd compete for visual attention

A typical heavy plugin uses one of each — an inline section for quick adjustments, a tab for the full editor.

Querying registered sections

Custom inspector implementations can list sections via useInspectorSections(itemType) (inline) and useInspectorTabs(itemType) (tab mode). The built-in inspector uses both internally; your custom UI can follow the same shape.

import { useInspectorSections, useInspectorTabs } from '@studio-dev/vsdk'

function MyInspector({ item }: { item: TrackItem }) {
  const inline = useInspectorSections(item.type)
  const tabs   = useInspectorTabs(item.type)
  // render inline.map((s) => <s.component itemId={item.id} />) and tabs as a tabbed UI
}

On this page