Video Studio SDKv0.0.3
Core

Commands

Every undoable mutation is a Command. This page covers the pattern, the built-in factories, batching, and authoring your own.

The SDK uses the command pattern for every state mutation that should be undoable. A Command is a plain object that knows how to apply a change and how to reverse it. The store's executeCommand runs it, pushes it onto the undo stack (capped at 100), and clears redo.

The shape

import type { StudioState } from '@studio-dev/vsdk/core'
import type { Draft } from 'immer'

interface Command {
  id:        string                                    // auto-generated cmd_…
  type:      string                                    // e.g. 'ADD_ITEM', 'MOVE_ITEM'
  label:     string                                    // human-readable, used in events
  timestamp: number
  execute: (draft: Draft<StudioState>) => void         // apply the change
  undo:    (draft: Draft<StudioState>) => void         // reverse it
}

Both execute and undo receive an Immer draft of the entire state. Mutate it directly — Immer captures the patches and produces the new immutable state internally.

How it flows

store.addItem(item)

createAddItemCommand(item) → { command, itemId }

store.executeCommand(command)

  └── (inside Immer) command.execute(draft)
  └── undoStack.push(command)
  └── redoStack = []
  └── project.isDirty = true
  └── (if isBatching) batchCommands.push(command) instead of undoStack

eventBus.emit('history:push', { commandType: command.type })

Built-in store actions (addItem, moveItem, updateItemProperties, …) build the command, call executeCommand, and emit a domain event (item:added, item:moved, …) on top.

Undo / redo

const state = storeApi.getState()
state.undo()        // pop undo, run cmd.undo(), push to redo, emit 'history:undo'
state.redo()        // pop redo, run cmd.execute(), push to undo, emit 'history:redo'

The store also exposes canUndo and canRedo booleans for UI:

const canUndo = useStudioStore((s) => s.history.canUndo)
const undo   = useStudioStore((s) => s.undo)

<Button disabled={!canUndo} onClick={() => undo()}>Undo</Button>

Built-in hotkeys handle ⌘Z / ⌘⇧Z for you (useHotkeys()).

Batching

When a single user action mutates state multiple times, wrap the calls in a batch so one ⌘Z reverses the whole sequence:

const state = storeApi.getState()
state.startBatch()
const idA = state.addItem({ ... })
const idB = state.addItem({ ... })
state.moveItem(idA, 'trk_main', 30)
state.endBatch('Add two clips')

endBatch(label) wraps the buffered commands as one BatchCommand (type: 'BATCH') and pushes it to the undo stack as a single entry.

If something throws mid-batch, call state.cancelBatch() in your catch. It plays all buffered undos in reverse to roll back partial state, then exits batch mode:

try {
  state.startBatch()
  const id = state.addItem({ ... })
  doSomethingThatMayThrow(id)
  state.endBatch('Risky op')
} catch (err) {
  state.cancelBatch()
  throw err
}

Built-in command factories

Most of the time you call the store action (e.g. state.addItem), and the action constructs the command for you. But the factories are exported separately too — useful when authoring a custom action that needs to push a command directly.

import {
  createAddItemCommand,
  createRemoveItemCommand,
  createMoveItemCommand,
  createTrimItemStartCommand,
  createTrimItemEndCommand,
  createSplitItemCommand,
  createDuplicateItemCommand,
  createRippleDeleteCommand,
  createRippleInsertCommand,
  createNudgeCommand,
  createUpdateItemPropertiesCommand,
  createAddTrackCommand,
  createRemoveTrackCommand,
  createSetTrackOrderCommand,
  createSetTrackFlagsCommand,
} from '@studio-dev/vsdk/core'

Each factory returns either a Command or { command, …extras } (e.g. createAddItemCommand also returns the generated itemId so the caller can return it from the action).

Authoring custom commands

For plugins that need to mutate state in a way no built-in action covers, build your own with createCommand:

import { createCommand } from '@studio-dev/vsdk/core'
import { useStudioStoreApi } from '@studio-dev/vsdk'

function applyMyEffect(itemId: string) {
  const storeApi = useStudioStoreApi()
  const state = storeApi.getState()
  const item = state.timeline.items[itemId]
  if (!item) return

  // Capture the value we'll restore on undo BEFORE we mutate.
  const oldStyle = item.style ?? {}

  const command = createCommand(
    'MY_EFFECT_APPLY',           // command type — show up in history events
    'Apply my effect',           // human-readable label
    (draft) => {
      const target = draft.timeline.items[itemId]
      if (!target) return
      target.style = { ...target.style, myEffect: { intensity: 0.8 } }
    },
    (draft) => {
      const target = draft.timeline.items[itemId]
      if (!target) return
      target.style = oldStyle
    },
  )

  state.executeCommand(command)
}

Guidelines

  • Capture old values before mutation, by reference closure — don't read them inside undo from the draft, because by then the change is already applied. Save them outside the command.
  • Always handle the "item is gone" case — between execute and undo something else may have removed the item. Guard with if (!target) return.
  • Type your command type strings — pick a stable identifier; consumers and plugins listen to history:push events and may filter by commandType.
  • Don't emit events from inside the command — emit them from the calling action (the way built-in actions do). Otherwise events fire on redo too, which is rarely what you want.
  • One command = one logical user action. If it requires multiple mutations, fold them into one execute. Or use startBatch/endBatch if you're composing existing actions.

BatchCommand

import { createBatchCommand } from '@studio-dev/vsdk/core'

interface BatchCommand extends Command {
  type: 'BATCH'
  commands: Command[]
}

You rarely build these by hand — endBatch constructs one automatically. But the type is exported for advanced use (e.g. recording a macro and replaying it).

Hooks and patterns

A "save state" button for debugging

function HistoryDebug() {
  const undoStack = useStudioStore((s) => s.history.undoStack)
  return (
    <ol>
      {undoStack.map((cmd) => <li key={cmd.id}>{cmd.label}</li>)}
    </ol>
  )
}

React to commands of a specific type

useEffect(() => {
  const eventBus = storeApi.getState().eventBus

  const handler = ({ commandType }: { commandType: string }) => {
    if (commandType === 'MY_EFFECT_APPLY') trackAnalytics('effect-applied')
  }

  eventBus.on('history:push', handler)
  return () => eventBus.off('history:push', handler)
}, [storeApi])

Disable undo briefly (rare)

The store has no "skip history" mode. If you have a mutation that shouldn't be undoable (e.g. an auto-cleanup), use setState directly:

storeApi.setState((draft) => {
  delete draft.timeline.items[idToCleanup]
})

This bypasses executeCommand entirely — nothing pushed to undo, no events emitted. Use sparingly; the user has no way to recover from it.

On this page