Project bundle
The serializable JSON that fully describes a project — schema, versioning, helpers, and the load/save roundtrip.
A ProjectBundle is one JSON object that captures everything about a project: settings, tracks,
items, transitions, markers, regions, and metadata. It's what you persist to your database and what
you ship to the renderer. The bundle is the only source of truth between sessions; the runtime
StudioState is derived from it on load and serialized back to it on save.
Shape
interface ProjectBundle {
schemaVersion: number
metadata: {
id: string | null
name: string
createdAt: string // ISO 8601
updatedAt: string
}
settings: ProjectSettings
tracks: Track[]
items: TrackItem[] // flat list (denormalized from the store's keyed map)
markers: Marker[]
regions: Region[]
transitions?: Record<string, unknown>[]
}Metadata
id— your project ID. Generated bycreateEmptyBundleif you don't provide one. Pass it through your backend; the SDK doesn't care what it is as long as it's stable.name— display name shown in the toolbar title.createdAt/updatedAt— ISO timestamps. The SDK doesn't auto-update these — you control them in your save handler.
Settings (ProjectSettings)
interface ProjectSettings {
width: number // default 1920
height: number // default 1080
fps: number // default 30
aspectRatio: string // default '16:9'
durationFrames: number // recalculated by the store on every mutation
backgroundColor: string // default '#000000'
safeZones: { titleSafePercent: number; actionSafePercent: number }
renderProfiles: RenderProfile[] // see DEFAULT_RENDER_PROFILES
}durationFrames is derived — the store updates it after every item add/move/trim/remove to be the
max (startFrame + durationFrames) across all items. Don't write it manually.
renderProfiles is the list shown in the export dialog. The defaults come from
DEFAULT_RENDER_PROFILES: Draft 480p, Preview 720p, Final 1080p, Final 4K.
Tracks (Track[])
interface Track {
id: string
name: string
kind: 'main' | 'track' | 'audio'
order: number
locked: boolean
muted: boolean
hidden: boolean
}Three track kinds:
main— primary video/image track (default created on new bundle). Enforces non-overlap.track— generic overlay/text/captions/adjustment track. Allows multiple per project.audio— audio track.
Items (TrackItem[])
Stored flat in the bundle (so JSON serialization stays simple), keyed by ID in the runtime store. See
Timeline for the full TrackItem shape.
Transitions / markers / regions
- Transitions — track-level transitions between adjacent items. Optional.
- Markers —
{ id, frame, label?, color? }— labeled points on the timeline ruler. - Regions —
{ id, startFrame, endFrame, ... }— labeled ranges on the timeline.
Helpers
createEmptyBundle(name?, aspectRatio?)
Creates a fresh bundle with one main track, one audio track, and default settings.
import { createEmptyBundle } from '@studio-dev/vsdk'
const bundle = createEmptyBundle('My Project', '9:16')
// {
// schemaVersion: 1,
// metadata: { id: 'prj_...', name: 'My Project', createdAt: '...', updatedAt: '...' },
// settings: { width: 1080, height: 1920, fps: 30, aspectRatio: '9:16', ... },
// tracks: [
// { id: 'trk_main', name: 'Main', kind: 'main', ... },
// { id: 'trk_audio_1', name: 'Audio 1', kind: 'audio', ... },
// ],
// items: [], markers: [], regions: [], transitions: [],
// }The main track's id is always the constant trk_main. Don't delete it; the SDK treats it as
load-bearing.
normalizeBundle(partial)
Fills in missing fields with defaults. Use this when accepting bundles from older versions of your backend, hand-written test fixtures, or migrated data.
import { normalizeBundle } from '@studio-dev/vsdk'
const safe = normalizeBundle(maybeBrokenBundle)CURRENT_SCHEMA_VERSION
import { CURRENT_SCHEMA_VERSION } from '@studio-dev/vsdk'
// → 1 (at the time of writing)Check this before loading old bundles. If the saved bundle's schemaVersion is lower, run
migrateBundle from @studio-dev/vsdk/contracts before loading.
Validation (@studio-dev/vsdk/contracts)
The contracts subpath exports Zod schemas you can use to validate a bundle at the trust boundary:
import { projectBundleSchema } from '@studio-dev/vsdk/contracts'
const parsed = projectBundleSchema.safeParse(jsonFromBackend)
if (!parsed.success) {
// Bad bundle — refuse to load, log to your error tracker.
}The same module exports migrateBundle(bundle) to upgrade older bundles to CURRENT_SCHEMA_VERSION.
Load and save
Load on mount
Pass initialBundle to the provider. The store calls loadBundle(bundle) once on first render.
<VideoStudioProvider initialBundle={bundle}>Load on demand
From inside the provider tree, call loadBundle on the store API:
const storeApi = useStudioStoreApi()
storeApi.getState().loadBundle(newBundle)loadBundle:
- Replaces all timeline state with the bundle's data.
- Resets selection, history (undo + redo stacks), and playback.
- Marks the project clean.
It is not undoable — load-bundle is treated as a hard reset, not a user-level action.
Save
toBundle() serializes the current state:
const bundle = storeApi.getState().toBundle()
await saveToBackend(bundle)The provider's onSave callback does this for you when the user clicks Save — see
Configuration.
Round-trip guarantees
For any state S produced through public store actions:
loadBundle(toBundle(S)) ≡ S…modulo selection (cleared) and history (cleared). The bundle is the canonical representation; anything the SDK can rebuild from the bundle isn't serialized.
Recipes
Create a vertical (9:16) project
const bundle = createEmptyBundle('Vertical', '9:16')
// width: 1080, height: 1920Add an item to a bundle before loading
import { createEmptyBundle, generateItemId, createDefaultTransform } from '@studio-dev/vsdk'
const bundle = createEmptyBundle()
bundle.items.push({
id: generateItemId(),
trackId: 'trk_main',
type: 'image',
name: 'Logo',
startFrame: 0,
durationFrames: 90,
source: { assetUrl: 'https://...' },
transform: createDefaultTransform(bundle.settings),
style: {},
filters: [],
zIndex: 0,
locked: false,
disabled: false,
muted: false,
})
<VideoStudioProvider initialBundle={bundle}>Validate then load
import { projectBundleSchema, migrateBundle } from '@studio-dev/vsdk/contracts'
const parsed = projectBundleSchema.safeParse(raw)
if (!parsed.success) throw new Error('Invalid project bundle')
const upgraded = migrateBundle(parsed.data)
storeApi.getState().loadBundle(upgraded)Core overview
How the editor is put together — the bundle that is the source of truth, the store that holds state, the commands that mutate it, the timeline, and the event bus.
Store
The Zustand + Immer store at the heart of the SDK — the full state shape, every action it exposes, and the two hooks you'll use most.