Configuration
Every prop on VideoStudioProvider, explained — what it does, when to set it, the type shape, defaults, gotchas, and worked recipes for each.
VideoStudioProvider is the root of the SDK. Everything below it has access to the store, the
plugin registry, the icon map, and the config context. This page is the complete prop reference
— every option, its type, its default, when to set it, common gotchas, and at least one worked
example.
The full shape
interface VideoStudioProviderProps {
// ─── Required ────────────────────────────────────────────────────────────
children: ReactNode
// ─── Project ──────────────────────────────────────────────────────────────
initialBundle?: ProjectBundle
plugins?: PluginDefinition[]
// ─── Appearance ───────────────────────────────────────────────────────────
theme?: 'light' | 'dark' | 'system'
themeOverrides?: ThemeOverrides
icons?: Partial<StudioIconMap>
logo?: ReactNode
className?: string
rootClassName?: string
// ─── Layout extensions ────────────────────────────────────────────────────
defaultActivePanel?: string
sidebarButtons?: CustomSidebarButton[]
// ─── Callbacks ────────────────────────────────────────────────────────────
onSave?: (bundle: ProjectBundle) => void | Promise<void>
onExport?: (payload: ExportPayload) => void | Promise<void>
onExportAbort?: (payload: ExportAbortPayload) => void | Promise<void>
// ─── Subsystems ───────────────────────────────────────────────────────────
masking?: MaskingConfig
routes?: ApiRoutesConfig
// ─── License (required in production) ─────────────────────────────────────
licenseKey?: string
licenseServerUrl?: string
}Everything except children is optional. The rest of this page walks through each group, in the
order you'll typically reach for them.
Project
initialBundle
A serialized project to load on mount. If omitted, the provider creates an empty project (one "Main" track, default 1920×1080 @ 30fps, 30s duration).
<VideoStudioProvider initialBundle={savedBundle}>
<StudioEditor />
</VideoStudioProvider>ProjectBundle shape (abbreviated — see Project bundle for full):
interface ProjectBundle {
schemaVersion: number
metadata: {
id: string | null
name: string
createdAt: string
updatedAt: string
}
settings: ProjectSettings
tracks: Track[]
items: TrackItem[]
markers: Marker[]
regions: Region[]
transitions?: Record<string, unknown>[]
}For async loading (fetch by id, show a spinner, etc.) see the Quickstart → Loading a project recipe.
initialBundle is only read on mount. Changing it later does nothing — the SDK doesn't track
it after the initial load. To load a new project at runtime, call loadBundle(bundle) via
useStudioStoreApi.
plugins
An array of PluginDefinition objects, typically created by calling each plugin's factory
function. Plugins are registered in array order.
import { filtersPlugin } from '@studio-dev/vsdk-plugin-filters'
import { typographyPlugin } from '@studio-dev/vsdk-plugin-typography'
<VideoStudioProvider
plugins={[filtersPlugin(), typographyPlugin()]}
>
<StudioEditor />
</VideoStudioProvider>The provider compares plugins by reference identity. Pass a stable reference — either a
useMemo or a module-level constant — or you'll re-register every plugin on every render.
const plugins = useMemo(() => [filtersPlugin(), typographyPlugin()], [])See Plugin authoring to write your own.
Appearance
theme
| Value | Behavior |
|---|---|
'light' | Always light mode |
'dark' | Always dark mode |
'system' (default) | Follow OS preference, reactive to changes |
The provider applies .dark to its root <div> when dark mode is active. Tailwind classes like
dark:bg-… work as expected. You can also read the resolved theme via the data-theme attribute
on the root: data-theme="light" or data-theme="dark".
themeOverrides
Override individual theme tokens. Values are raw RGB channel strings (e.g. "124 58 237") so
the SDK can compose them with opacity utilities.
Single-mode (applies to both light and dark):
<VideoStudioProvider
themeOverrides={{
primary: '124 58 237',
studioAccent: '124 58 237',
studioAppBg: '10 10 12',
}}
>Per-mode (different values for light vs dark):
<VideoStudioProvider
themeOverrides={{
light: { primary: '30 80 50', studioAppBg: '245 245 245' },
dark: { primary: '155 120 255', studioAppBg: '20 18 28' },
}}
>The full list of tokens and their meanings is in UI → Color system. Recipes for common rebrands live in Theming & customization.
icons
Override any icon used by the SDK. Each key is a semantic name from StudioIconName; each value
is a React component that accepts { className?: string }.
import { Play, Pause, Save } from 'lucide-react'
// Define OUTSIDE the component (or wrap in useMemo) to keep the reference stable.
const ICONS = {
play: Play,
pause: Pause,
save: Save,
}
export default function Editor() {
return (
<VideoStudioProvider icons={ICONS}>
<StudioEditor />
</VideoStudioProvider>
)
}Override precedence: defaults < plugin-registered icons < icons prop. The user always wins.
The full icon-key list and override patterns are in UI → Icon system.
logo
Custom logo for the toolbar. Accepts any ReactNode:
<VideoStudioProvider logo={<img src="/logo.svg" className="h-5 w-auto" />}>
<VideoStudioProvider logo={<MyAnimatedLogo />}>
<VideoStudioProvider logo="Acme Studio"> {/* string → text */}
<VideoStudioProvider logo={null}> {/* default VideoIcon */}The toolbar reserves a logo slot of ~24px height — design assets to fit within that.
className / rootClassName
Both apply to the provider's root <div> (where the .dark class is also applied). Both exist for
historical reasons — use whichever fits your code-base style.
<VideoStudioProvider className="h-screen w-screen" />
<VideoStudioProvider rootClassName="bg-zinc-950" /> {/* equivalent for class purposes */}If you set both, they're concatenated. The root also has the immovable vsdk-root class and the
data-theme attribute.
Layout extensions
defaultActivePanel
The id of the plugin panel to open by default in the sidebar. If omitted, the user starts with no panel selected.
<VideoStudioProvider defaultActivePanel="stock">The id must match a registered panel's id. Built-in plugins use their package's short name
(filters, gif, stock, typography, etc.).
sidebarButtons
Custom buttons rendered at the bottom of the desktop icon sidebar and at the start of the mobile bottom bar. Use this for app-level navigation (e.g. "Back to dashboard", account menu) that needs to live alongside the editor.
import { LogOutIcon, SettingsIcon } from 'lucide-react'
const BUTTONS = [
{ id: 'back', label: 'Dashboard', icon: LogOutIcon, onClick: () => router.push('/') },
{ id: 'settings', label: 'Settings', icon: SettingsIcon, onClick: () => openSettings() },
]
<VideoStudioProvider sidebarButtons={BUTTONS}>CustomSidebarButton shape:
interface CustomSidebarButton {
id: string // stable React key
label: string // tooltip + mobile caption
icon: ComponentType<{ className?: string }>
onClick: () => void
}Memoize the array (or define outside the component) for stable references.
Callbacks
onSave
Called when the user clicks Save. Receives the current ProjectBundle. Your job: persist it.
<VideoStudioProvider
onSave={async (bundle) => {
await fetch(`/api/projects/${bundle.metadata.id}`, {
method: 'PUT',
body: JSON.stringify(bundle),
})
// The SDK marks the project clean automatically once the promise resolves.
}}
>Behavioral details:
- The SDK calls
setSyncing(true)before invokingonSaveandsetSyncing(false)after the promise resolves or rejects. - The SDK calls
markClean()after the promise resolves successfully. - If
onSavethrows or rejects, the project stays dirty and the toolbar shows the syncing indicator going off — but the SDK won't surface the error to the user. Catch and toast yourself. - If
onSaveis not provided, clicking Save emits theproject:save-requestevent on the event bus — useful when you want to handle saving from outside the provider tree.
onExport
Called when the user starts an export. Receives an ExportPayload:
interface ExportPayload {
projectId: string
profileId: string
profile: RenderProfile // width, height, fps, bitrate, codec
bundle: ProjectBundle
}Your job: kick off a render on your backend and drive the SDK's export UI by calling store actions as the render progresses:
<VideoStudioProvider
onExport={async (payload) => {
const storeApi = /* obtain via useStudioStoreApi() inside the tree */
const job = await startRender(payload)
job.on('progress', ({ progress, stepLabel }) => {
storeApi.getState().setExportProgress(progress, stepLabel)
})
job.on('done', ({ downloadUrl }) => {
storeApi.getState().setExportCompleted(downloadUrl)
})
job.on('error', ({ message }) => {
storeApi.getState().setExportFailed(message)
})
}}
>The export-related store actions are: startExport, setExportPhase, setExportProgress,
setExportCompleted, setExportFailed, abortExport, resetExport. See
Core → Store → Export actions for the full signatures and
Save & Export for the full flow.
onExportAbort
Called when the user clicks Cancel during an active export. The SDK has already set the phase
to 'aborting' — your job is to tell your backend to actually stop the render job, then call
resetExport() once the backend acknowledges.
<VideoStudioProvider
onExportAbort={async ({ projectId, profileId, reason }) => {
await fetch(`/api/renders/${projectId}/cancel`, { method: 'POST' })
storeApi.getState().resetExport()
}}
>ExportAbortPayload:
interface ExportAbortPayload {
projectId: string
profileId: string | null // may be null if aborted before profile was set
profile: RenderProfile | null
reason: string | null // optional reason passed to abortExport()
}Subsystems
masking
Configure the masking system (rectangle / ellipse / polygon / split / filmstrip masks on image and video items).
interface MaskingConfig {
enabled?: boolean // default: true
enabledMaskTypes?: MaskType[] // default: all 5
maxMasksPerItem?: number // default: 10
defaultFeather?: number // default: 0
defaultExpansion?: number // default: 0
}<VideoStudioProvider
masking={{
enabledMaskTypes: ['rectangle', 'ellipse'],
maxMasksPerItem: 3,
}}
>Set enabled: false to hide the masking UI entirely.
Allowed MaskType values: 'rectangle', 'ellipse', 'polygon', 'split', 'filmstrip'.
routes
Override API route paths used by built-in modules. Currently only storage is configurable. All
defaults match the reference backend.
interface ApiRoutesConfig {
storage?: {
list?: string // default: '/v1/user/storage'
get?: string // default: '/v1/user/storage/{id}'
delete?: string // default: '/v1/user/storage/{id}'
uploadRegular?: string // default: '/v1/user/storage/upload/regular'
uploadChunkedInit?: string // default: '/v1/user/storage/upload/chunked/init'
uploadChunkedChunk?: string // default: '/v1/user/storage/upload/chunked/chunk'
uploadChunkedComplete?: string // default: '/v1/user/storage/upload/chunked/complete'
uploadChunkedAbort?: string // default: '/v1/user/storage/upload/chunked/abort'
}
}{id} is a placeholder the SDK substitutes at request time.
<VideoStudioProvider
routes={{
storage: {
list: '/api/assets',
get: '/api/assets/{id}',
delete: '/api/assets/{id}',
},
}}
>License
licenseKey
Your production license key (stdk_live_…). The editor renders a license overlay when no valid key
is present. Set this in production builds.
<VideoStudioProvider licenseKey={process.env.NEXT_PUBLIC_VSDK_LICENSE_KEY}>Development keys (stdk_test_…) work without a server round-trip and are intended for local /
preview builds.
licenseServerUrl
Override the license validation endpoint. Defaults to the public Studio license server. Set this if you self-host the validation service:
<VideoStudioProvider
licenseKey="stdk_live_…"
licenseServerUrl="https://license.acme.com"
>Read license state inside the tree via useLicense():
const { status, plan } = useLicense()
// status: 'valid' | 'invalid' | 'unknown' | 'loading'
// plan: 'free' | 'pro' | 'enterprise' | nullPutting it together — production patterns
Pattern A — Single-project editor with backend
'use client'
import { useMemo } from 'react'
import {
StudioEditor,
VideoStudioProvider,
type ProjectBundle,
} from '@studio-dev/vsdk'
import { filtersPlugin } from '@studio-dev/vsdk-plugin-filters'
import { typographyPlugin } from '@studio-dev/vsdk-plugin-typography'
import '@studio-dev/vsdk/styles.css'
import { Logo } from './logo'
import { ICONS } from './icons'
export function Editor({ bundle }: { bundle: ProjectBundle }) {
const plugins = useMemo(() => [filtersPlugin(), typographyPlugin()], [])
return (
<VideoStudioProvider
initialBundle={bundle}
plugins={plugins}
theme="dark"
logo={<Logo />}
icons={ICONS}
defaultActivePanel="filters"
licenseKey={process.env.NEXT_PUBLIC_VSDK_LICENSE_KEY}
onSave={async (b) => {
await fetch(`/api/projects/${b.metadata.id}`, {
method: 'PUT',
body: JSON.stringify(b),
})
}}
onExport={async (payload) => {
await fetch('/api/renders', {
method: 'POST',
body: JSON.stringify(payload),
})
// The render job will call back via webhooks to drive setExportProgress/Completed/Failed.
}}
>
<StudioEditor className="h-screen" />
</VideoStudioProvider>
)
}Pattern B — Multi-tenant editor (per-tenant theme)
function tenantOverrides(tenantId: string): ThemeOverrides {
return TENANT_THEMES[tenantId] ?? {}
}
<VideoStudioProvider
theme="system"
themeOverrides={tenantOverrides(tenantId)}
icons={tenantIcons(tenantId)}
logo={<TenantLogo tenantId={tenantId} />}
defaultActivePanel="brand-kit"
plugins={tenantPlugins(tenantId)}
>Pattern C — Read-only embed (e.g. preview iframe)
<VideoStudioProvider
initialBundle={bundle}
theme="dark"
/* no onSave — Save button emits an event your host can choose to ignore */
/* no onExport — Export button does nothing visible */
plugins={[]} // strip down to the bare editor
>
<StudioEditor className="h-full pointer-events-none select-none" />
</VideoStudioProvider>For true read-only — disabling timeline interactions — combine the above with track.locked: true
on every track via setTrackFlags.
Defaults at a glance
| Prop | Default |
|---|---|
theme | 'system' |
themeOverrides | none |
icons | none (uses DEFAULT_ICON_MAP) |
logo | default VideoIcon |
plugins | [] |
defaultActivePanel | none (no panel open) |
sidebarButtons | [] |
masking | { enabled: true, enabledMaskTypes: all-five, maxMasksPerItem: 10, defaultFeather: 0, defaultExpansion: 0 } |
routes.storage | the eight /v1/user/storage/* defaults listed above |
licenseKey | none (license overlay shown until set) |
licenseServerUrl | public Studio license server |
onSave / onExport / onExportAbort | none (Save / Export buttons emit events instead) |
Common gotchas
| Problem | Fix |
|---|---|
| Plugins re-register on every render | useMemo(() => [...], []) for the plugins array, or hoist to module scope. |
icons prop is ignored | Same — wrap in useMemo. Identity-based comparison. |
| Theme tokens have no visible effect | Wrong format — values are raw RGB triplets like "124 58 237", not "#7c3aed". |
| Editor renders unstyled | Forgot import '@studio-dev/vsdk/styles.css' somewhere in the app. |
defaultActivePanel="stock" doesn't open anything | The matching plugin isn't registered. The id must match a registered panel's id. |
onSave finishes but the project stays "dirty" | onSave threw / rejected — the SDK won't catch it. Wrap in try/catch and toast yourself. |
| License overlay appears in production | licenseKey is undefined — check env var name and NEXT_PUBLIC_ prefix. |