Skip to content

Show File Schema

A Lightbridge show file (.lbshow) is a JSON serialisation of a ShowFile object plus all the data it transitively references. This page documents the type tree as it exists in the engine source today.

Source file header

ShowFile — the top-level persistence format.

A Show contains multiple Sets (performance cues). Each Set has its own chain, bindings, and modulation — all independent. The active set index determines what’s currently rendering.

File format: .lbshow (JSON, human-readable)

Type declarations

Below are the exported types and interfaces from ShowFile.ts, with their leading doc comments. They form a tree: ShowFile is the root, PerformanceSet is the per-set unit, ShowSettings is the show-level settings block. Cross-references to types in other files (ChainNode, ModSlotConfig, PaletteConfig, CustomButtonConfig, ControllerProfile) are documented in their own modules.

SetDefinition

  • SetDefinition — the contents of a performance set (chain, modulation, etc.).
  • Kept separate from PerformanceSet so the disk format can wrap it in a
  • preset/liveState envelope (Sets v2 drift tracking) without disrupting the
  • in-memory flat shape used by the runtime store.
export interface SetDefinition {
chain: ChainNode[]
modulation: ModSlotConfig[]
/** Bindings are serialized separately — just the raw JSON from BindingEngine.toJSON() */
bindings: BindingsJSON
selectedNodeId: string | null
/** Per-set palette config. Always MultiPaletteConfig after v17 migration. */
palette?: MultiPaletteConfig
/** Per-set action buttons. */
customButtons?: CustomButtonConfig[]
/** Per-set pad-to-button bindings (MIDI pad grid). */
padButtonBindings?: Record<string, { buttonId: string; label: string; color: string }>
/** Per-set controller knob/fader bindings (MIDI CCs). */
controllerBindings?: Record<
string,
{ target: string; label: string; rangeMin?: number; rangeMax?: number }
>
/** Accumulated beat division scale factor from global ×2/÷2 (default 1). */
beatDivScale?: number
/** Chain group navigation path — stack of group IDs the user has drilled into. */
groupPath?: string[]
/** Per-set collapsed state for color H/S/B detail rows, keyed as "<nodeId>/<colorGroup>". */
colorGroupCollapsed?: Record<string, boolean>
/** Extension data preserved across load/save. Plugins store custom data here. */
extensions?: Record<string, unknown>
}

PerformanceSet

  • PerformanceSet — both the in-memory AND current on-disk shape.
  • A flat spread of SetDefinition + identity fields (id, name, notes). The
  • runtime store and serialized .lbshow files both use this shape.
export interface PerformanceSet extends SetDefinition {
id: string
name: string
/** Free-text performance notes for this set (e.g. "top left knob is hue"). */
notes?: string
}

CanvasAspectMode

  • Canvas aspect ratio mode for the canvas panel preview.
  • 'auto' fills the panel. The fixed presets letterbox/pillarbox the canvas
  • to that ratio. Output rendering itself is unaffected — this is a preview-side
  • framing aid, not a composition-aspect setting.
export type CanvasAspectMode = 'auto' | '16:9' | '21:9' | '4:3' | '1:1' | '9:16'
/** Per-show settings persisted alongside the sets. */
export interface ShowSettings {
outputLevel: number
audioAttack: number
audioRelease: number
/** Adaptive per-band loudness normalization (AGC). Optional for old shows — defaults to true. */
audioNormalize?: boolean
bandGains: [number, number, number, number, number, number]
globalAudioGain: number
bpm: number
beatSource: BeatSource
/** Beats per bar (time signature numerator). Optional for backward compat — defaults to 4. */
beatsPerBar?: number
/** Canvas panel aspect mode (default 'auto'). Optional for old shows. */
canvasAspect?: CanvasAspectMode
}
export const DEFAULT_SHOW_SETTINGS: ShowSettings = {
outputLevel: 1,
audioAttack: 0.3,
audioRelease: 0.05,
audioNormalize: true,
bandGains: [1, 1, 1, 1, 1, 1],
globalAudioGain: 0.5,
bpm: 120,
beatSource: 'fixed',
beatsPerBar: 4,
canvasAspect: 'auto',
}
/** Latest ShowFile format version. Bump when adding a migration. */
export const CURRENT_SHOW_VERSION = 20 as const
export interface ShowFile {
version: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20
name: string
/** Sets are stored flat (spread of SetDefinition + identity fields) in both memory and on disk. */
sets: PerformanceSet[]
activeSetIndex: number
/**
* Legacy top-level customButtons field. On load, migrated into the active set.
* New shows always store buttons per-set in PerformanceSet.customButtons.
*/
customButtons?: CustomButtonConfig[]
settings: ShowSettings
/** Saved palette presets (optional, migrated from older files). */
palettePresets?: PalettePreset[]
/** Controller profiles — show-level, shared across all sets. */
controllerProfiles?: ControllerProfile[]
/**
* Setlists — ordered set sequences with per-entry transition config (ADR-0018
* Step 4). Show-level; reference setIds, so reordering a setlist never moves
* sets. Added in v18 (additive — old shows load with `[]`).
*/
setlists?: Setlist[]
/** Id of the currently-armed setlist, or null when no auto-advance is armed. */
activeSetlistId?: string | null
/**
* Global crossfade defaults for Prev/Next/Go-To-Set transitions (Set Transition
* panel, ADR-0018). Added in v19 (additive — old shows default-fill via migration).
*/
transitionDefaults?: TransitionDefaults
/** Multi-audio input configurations. Missing = single default input (backwards compat). */
audioInputConfigs?: AudioInputConfig[]
/** CRC32 checksum of the file body (computed by main process on save). */
checksum?: string
/** App version that last saved this file. Informational, no migration logic. */
appVersion?: string
/** Extension data preserved across load/save. Plugins store custom data here. */
extensions?: Record<string, unknown>
}
export function createPerformanceSet(name: string): PerformanceSet {
return {
id: uid('set'),
name,
chain: [],
modulation: [],
bindings: {},
selectedNodeId: null,
palette: createDefaultMultiPalette(),
customButtons: [],
colorGroupCollapsed: {},
}
}
/** Create a new empty show with one default set. */
export function createShowFile(name = 'Untitled'): ShowFile {
return {
version: CURRENT_SHOW_VERSION,
name,
sets: [createPerformanceSet('Main')],
activeSetIndex: 0,
settings: { ...DEFAULT_SHOW_SETTINGS },
setlists: [],
activeSetlistId: null,
// A freshly-created v19 show carries the defaults explicitly; the v18→v19
// migration only back-fills them for older files (ShowFile.ts ~967).
transitionDefaults: { ...DEFAULT_TRANSITION_DEFAULTS },
}
}
/**
* v2 → v3 migration: old native-range param values → normalized 0-1.
* Keys are "sourceName/paramKey" or "effectName/paramKey".
* oldMin/oldMax are the native shader ranges stored in v2 show files.
*/
const V2_NATIVE_RANGES: Record<string, { oldMin: number; oldMax: number }> = {
'gradient/angle': { oldMin: 0, oldMax: 6.28 },
'tunnel/speed': { oldMin: 0, oldMax: 5 },
'tunnel/twist': { oldMin: 0, oldMax: 3 },
'tunnel/colorCycle': { oldMin: 0, oldMax: 5 },
'voronoi/speed': { oldMin: 0, oldMax: 5 },
'voronoi/scale': { oldMin: 0.2, oldMax: 3 },
'voronoi/rotation': { oldMin: 0, oldMax: 6.28 },
'voronoi/borderWidth': { oldMin: 0, oldMax: 0.2 },
'julia/cx': { oldMin: -2, oldMax: 2 },
'julia/cy': { oldMin: -2, oldMax: 2 },
'julia/power': { oldMin: 1.5, oldMax: 5 },
'julia/zoom': { oldMin: 0.2, oldMax: 5 },
'julia/panX': { oldMin: -2, oldMax: 2 },
'julia/panY': { oldMin: -2, oldMax: 2 },
'julia/rotation': { oldMin: 0, oldMax: 6.28 },
'julia/colorCycle': { oldMin: 0, oldMax: 5 },
'video/speed': { oldMin: 0, oldMax: 4 },
'plasma/frequency1': { oldMin: 0.5, oldMax: 20 },
'plasma/frequency2': { oldMin: 0.5, oldMax: 20 },
'plasma/speed': { oldMin: 0, oldMax: 5 },
'plasma/scale': { oldMin: 0.2, oldMax: 5 },
'plasma/distortion': { oldMin: 0, oldMax: 2 },
'plasma/color_b': { oldMin: 0, oldMax: 2 },
'plasma/contrast': { oldMin: 0.2, oldMax: 3 },
'plasma/colorCycle': { oldMin: 0, oldMax: 5 },
'noise/lacunarity': { oldMin: 1, oldMax: 4 },
'noise/gain': { oldMin: 0.1, oldMax: 0.9 },
'noise/scale': { oldMin: 0.5, oldMax: 20 },
'noise/speed': { oldMin: 0, oldMax: 3 },
'noise/warp': { oldMin: 0, oldMax: 3 },
'noise/warp_speed': { oldMin: 0, oldMax: 3 },
'noise/rotation': { oldMin: 0, oldMax: 6.28 },
'noise/cell_scale': { oldMin: 0.5, oldMax: 10 },
'noise/contrast': { oldMin: 0.2, oldMax: 3 },
'fluid/burstDecay': { oldMin: 0.05, oldMax: 2 },
'protostar/speed': { oldMin: 0, oldMax: 3 },
'protostar/foldScale': { oldMin: 0.5, oldMax: 4 },
'protostar/surfaceThickness': { oldMin: 0.01, oldMax: 0.5 },
'protostar/glowIntensity': { oldMin: 0.1, oldMax: 5 },
'protostar/cameraDistance': { oldMin: 1, oldMax: 20 },
'protostar/toneMapStrength': { oldMin: 0.1, oldMax: 5 },
'clouds/density': { oldMin: 0, oldMax: 5 },
'clouds/coverage': { oldMin: -1, oldMax: 1 },
'clouds/scale': { oldMin: 0.2, oldMax: 4 },
'clouds/speed': { oldMin: 0, oldMax: 2 },
'clouds/cam_height': { oldMin: 0, oldMax: 5 },
'clouds/fov': { oldMin: 0.3, oldMax: 3 },
'clouds/sun_height': { oldMin: -0.5, oldMax: 0.5 },
'clouds/horizon_glow': { oldMin: 0, oldMax: 2 },
'clouds/vignette': { oldMin: 0, oldMax: 0.5 },
'clouds/star_size': { oldMin: 0.01, oldMax: 0.4 },
'clouds/star_glow': { oldMin: 0, oldMax: 2 },
'clouds/star_speed': { oldMin: 0, oldMax: 2 },
'synthwave/sun_x': { oldMin: -1.5, oldMax: 1.5 },
'synthwave/sun_y': { oldMin: -0.5, oldMax: 1 },
'synthwave/sunRadius': { oldMin: 0.05, oldMax: 0.8 },
'synthwave/sunBloomRadius': { oldMin: 0, oldMax: 1.5 },
'synthwave/sunBloomIntensity': { oldMin: 0, oldMax: 1.5 },
'synthwave/sunCutSpeed': { oldMin: 0, oldMax: 2 },
'synthwave/gridSpeed': { oldMin: 0, oldMax: 15 },
'synthwave/gridScale': { oldMin: 0.001, oldMax: 0.05 },
'synthwave/gridGlow': { oldMin: 0, oldMax: 2 },
'synthwave/gridPerspective': { oldMin: 0.5, oldMax: 8 },
'synthwave/gridHorizonY': { oldMin: -0.5, oldMax: 0.1 },
'synthwave/mtn_x': { oldMin: -2, oldMax: 2 },
'synthwave/mtnBaseWidth': { oldMin: 0.5, oldMax: 4 },
'synthwave/mtnPeakWidth': { oldMin: 0.01, oldMax: 1 },
'synthwave/mtnHeight': { oldMin: 0.1, oldMax: 1.5 },
'synthwave/mtnOutlineWidth': { oldMin: 0.001, oldMax: 0.05 },
'synthwave/snowFreq': { oldMin: 1, oldMax: 60 },
'synthwave/snowSpeed': { oldMin: 0, oldMax: 8 },
'synthwave/snowAmplitude': { oldMin: 0, oldMax: 0.2 },
'synthwave/cloudMorphSpeed': { oldMin: 0, oldMax: 3 },
'synthwave/cloudMorphAmount': { oldMin: 0, oldMax: 0.3 },
'synthwave/cloudThickness': { oldMin: 0.01, oldMax: 0.2 },
'synthwave/cloud1Y': { oldMin: -1, oldMax: 0.2 },
'synthwave/cloud2Y': { oldMin: -1, oldMax: 0.2 },
'synthwave/cloudWrap': { oldMin: 1, oldMax: 8 },
'synthwave/cloudOutline': { oldMin: 0, oldMax: 2 },
'synthwave/horizonGradient': { oldMin: 0.5, oldMax: 8 },
'synthwave/intensity': { oldMin: 0, oldMax: 1.5 },
'synthwave/timeScale': { oldMin: 0, oldMax: 3 },
'synthwave/sunLoopPeriod': { oldMin: 0, oldMax: 30 },
'synthwave/gridLoopPeriod': { oldMin: 0, oldMax: 30 },
'synthwave/snowLoopPeriod': { oldMin: 0, oldMax: 30 },
'synthwave/cloudDriftPeriod': { oldMin: 0, oldMax: 60 },
'synthwave/cloudMorphPeriod': { oldMin: 0, oldMax: 30 },
'synthwave/masterLoopPeriod': { oldMin: 0, oldMax: 120 },
'kaleidoscope/rotation': { oldMin: 0, oldMax: 6.28 },
'colorgrade/brightness': { oldMin: 0, oldMax: 3 },
'colorgrade/contrast': { oldMin: 0, oldMax: 3 },
'colorgrade/saturation': { oldMin: 0, oldMax: 3 },
'colorgrade/hueShift': { oldMin: 0, oldMax: 6.28 },
'bloom/intensity': { oldMin: 0, oldMax: 5 },
'bloom/radius': { oldMin: 0, oldMax: 20 },
'glitch/speed': { oldMin: 0, oldMax: 30 },
'glitch/rgbSplit': { oldMin: 0, oldMax: 0.05 },
'chromatic_aberration/amount': { oldMin: 0, oldMax: 0.05 },
'chromatic_aberration/falloff': { oldMin: 0, oldMax: 10 },
'chromatic_aberration/angle': { oldMin: 0, oldMax: 6.28 },
'wave/frequency': { oldMin: 1, oldMax: 50 },
'wave/amplitude': { oldMin: 0, oldMax: 0.1 },
'wave/speed': { oldMin: 0, oldMax: 10 },
'wave/phase': { oldMin: 0, oldMax: 6.28 },
'mirror/rotation': { oldMin: 0, oldMax: 6.28 },
'mirror/gap': { oldMin: 0, oldMax: 0.5 },
'strobe/rate': { oldMin: 0, oldMax: 30 },
'filmgrain/intensity': { oldMin: 0, oldMax: 0.5 },
'filmgrain/size': { oldMin: 1, oldMax: 8 },
'filmgrain/speed': { oldMin: 1, oldMax: 30 },
'barrel/strength': { oldMin: -3, oldMax: 3 },
'barrel/zoom': { oldMin: 0.5, oldMax: 2 },
'halftone/angle': { oldMin: 0, oldMax: 3.14 },
'edges/thickness': { oldMin: 0.5, oldMax: 5 },
'vhs/scanlines': { oldMin: 1, oldMax: 8 },
'vhs/noise': { oldMin: 0, oldMax: 0.5 },
'vhs/colorBleed': { oldMin: 0, oldMax: 5 },
'vhs/distortion': { oldMin: 0, oldMax: 2 },
'vhs/jitterSpeed': { oldMin: 1, oldMax: 20 },
'pixelsort/angle': { oldMin: 0, oldMax: 6.28 },
'radialblur/falloff': { oldMin: 0.1, oldMax: 3 },
'radialblur/spin': { oldMin: 0, oldMax: 6.28 },
'posterize/gamma': { oldMin: 0.2, oldMax: 3 },
'posterize/softness': { oldMin: 0, oldMax: 0.5 },
'threshold/softness': { oldMin: 0, oldMax: 0.2 },
'sharpen/amount': { oldMin: 0, oldMax: 5 },
'sharpen/radius': { oldMin: 0.5, oldMax: 5 },
'sharpen/threshold': { oldMin: 0, oldMax: 0.5 },
'vignette/radius': { oldMin: 0, oldMax: 1.5 },
'vignette/softness': { oldMin: 0, oldMax: 1.5 },
'vignette/intensity': { oldMin: 0, oldMax: 2 },
'emboss/strength': { oldMin: 0, oldMax: 5 },
'emboss/angle': { oldMin: 0, oldMax: 6.28 },
'emboss/elevation': { oldMin: 0.5, oldMax: 5 },
'tiltshift/blurAmount': { oldMin: 0, oldMax: 10 },
'tiltshift/angle': { oldMin: 0, oldMax: 6.28 },
'kuwahara/sharpness': { oldMin: 0, oldMax: 5 },
'ascii/gamma': { oldMin: 0.2, oldMax: 3 },
'slitscan/warp': { oldMin: 0, oldMax: 2 },
'slitscan/warpFreq': { oldMin: 0.5, oldMax: 20 },
}
/** Clamp a value to [0, 1]. */
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v
}
/** Convert old native-range value to normalized 0-1. */
function normalizeValue(oldValue: number, oldMin: number, oldMax: number): number {
return clamp01((oldValue - oldMin) / (oldMax - oldMin))
}
/** Convert old native-range delta to normalized delta. */
function normalizeDelta(delta: number, oldMin: number, oldMax: number): number {
return delta / (oldMax - oldMin)
}
/**
* Migrate a single button config from older formats:
* - holdMode 'toggle' → 'latch' (v2 → v3)
* - Convert old duration/lag/overshoot fields → transition/transitionTime (v2 → v3)
* - Convert per-action nudgeWrap/accumulate → button-level clampMode
* - Migrate color/offColor/colorCycle → unified colorStates array (v6 → v7)
*/
function migrateButtonConfig(btn: CustomButtonConfig): CustomButtonConfig {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = btn as any
// holdMode: toggle → latch (reverse the old migration)
if (raw.holdMode === 'toggle') {
raw.holdMode = 'latch'
}
// Convert old duration/lag/overshoot to transition/transitionTime
if (raw.duration !== undefined && raw.transition === undefined) {
if (raw.duration === 0 && raw.lag === 0) {
raw.transition = 'instant'
raw.transitionTime = 0.3
} else {
// Best-effort mapping from lag+overshoot to named curve
const os = raw.overshoot ?? 0
if (os >= 0.5) {
raw.transition = raw.overshoot >= 0.8 ? 'bounce' : 'elastic'
} else if (raw.lag >= 0.6) {
raw.transition = 'lag'
} else {
raw.transition = 'smooth'
}
raw.transitionTime = raw.duration > 0 ? raw.duration : raw.lag || 0.3
}
delete raw.duration
delete raw.lag
delete raw.overshoot
}
// Convert per-action nudgeWrap/accumulate → button-level clampMode
if (raw.clampMode === undefined) {
let clamp = 'clamp'
for (const action of raw.actions ?? []) {
if (action.accumulate) clamp = 'accumulate'
else if (action.nudgeWrap) clamp = 'wrap'
// Clean up old per-action fields
delete action.nudgeWrap
delete action.accumulate
}
raw.clampMode = clamp
}
// NOTE: momentaryOffset is NOT back-filled here. This migrateButtonConfig runs
// unconditionally on every load, so back-filling re-injected a dead field on
// already-current (v14+) files forever (it's only stripped under version===13).
// The two places that still read it both tolerate its absence: the v13→v14 step
// uses `momentaryOffset ?? 0.2`, and the v2 param-remap guards on `!== undefined`.
// v6 → v7: unify color / offColor / colorCycle into colorStates
// Legacy fields are removed once migrated.
if (!Array.isArray(raw.colorStates)) {
raw.colorStates = migrateLegacyColorFields(raw)
delete raw.color
delete raw.offColor
delete raw.colorCycle
}
// ADR-0008: promote legacy single `keyBinding` into the multi-binding
// `keyBindings[]` array so the new editor can list them uniformly. Non-
// destructive: `keyBinding` is left in place too (marked deprecated in the
// type), so any code still reading the singular field keeps working. When
// both exist, readers should prefer `keyBindings`.
if (!Array.isArray(raw.keyBindings) && typeof raw.keyBinding === 'string' && raw.keyBinding) {
raw.keyBindings = [raw.keyBinding]
}
return raw as CustomButtonConfig
}
/**
* Build the new colorStates array from the legacy color / offColor / colorCycle fields.
*
* Mapping rules:
* - color only → [color] (length 1: simple)
* - color + offColor → [color, offColor] (length 2: active/inactive)
* - color + colorCycle → [color, ...colorCycle] (length 3+: cycle)
* - color + offColor + colorCycle → [color, ...colorCycle] (offColor lost; rare combo)
*
* Always returns an array with at least one entry. Falls back to a safe default
* if every legacy field is somehow missing.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function migrateLegacyColorFields(raw: any): string[] {
const states: string[] = []
const baseColor = typeof raw.color === 'string' && raw.color ? raw.color : '#4a9eff'
states.push(baseColor)
const cycle = Array.isArray(raw.colorCycle)
? raw.colorCycle.filter((c: unknown): c is string => typeof c === 'string' && c.length > 0)
: []
if (cycle.length > 0) {
// Cycle wins over offColor when both are present (the old runtime preferred cycle).
for (const c of cycle) states.push(c)
} else if (typeof raw.offColor === 'string' && raw.offColor) {
states.push(raw.offColor)
}
return states
}
function normalizeLegacyNodeTree(nodes: ChainNode[]): void {
for (const node of nodes) {
if (!Array.isArray(node.sourceEffects)) {
;(node as ChainNode & { sourceEffects: ChainNode[] }).sourceEffects = []
}
normalizeLegacyNodeTree(node.sourceEffects)
if (node.children) normalizeLegacyNodeTree(node.children)
}
}
function migrateButtonConfigsInSet(set: PerformanceSet): void {
if (!set.customButtons) set.customButtons = []
set.customButtons = set.customButtons.map(migrateButtonConfig)
for (const node of collectAllNodesDeep(set.chain)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacy = (node as any).customButtons as CustomButtonConfig[] | undefined
if (legacy && legacy.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(node as any).customButtons = legacy.map(migrateButtonConfig)
}
}
}
function collectButtonOwnersInSet(set: PerformanceSet): CustomButtonConfig[][] {
const owners: CustomButtonConfig[][] = []
if (set.customButtons) owners.push(set.customButtons)
for (const node of collectAllNodesDeep(set.chain)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacy = (node as any).customButtons as CustomButtonConfig[] | undefined
if (legacy) owners.push(legacy)
}
return owners
}
/**
* Migrate legacy show files: move top-level customButtons into the active set,
* convert old button formats to current TD-matching convention.
* Call this after loading a show file from disk.
*/
export function migrateShowFile(show: ShowFile): ShowFile {
for (const set of show.sets) {
normalizeLegacyNodeTree(set.chain)
}
// Migrate top-level customButtons into the active set.
// Guard the index: a corrupted/hand-edited file can carry an out-of-bounds
// activeSetIndex. Previously `delete show.customButtons` ran unconditionally,
// so an invalid index silently wiped the entire button bank instead of
// relocating it. Fall back to set 0, and only delete once the buttons have
// actually been appended.
if (show.customButtons && show.customButtons.length > 0) {
if (show.sets.length === 0) {
// A corrupted/hand-edited file can carry the legacy top-level button bank
// with an empty sets array. With no set to relocate into, the buttons
// would be orphaned on the dead top-level field (no load path reads it).
// Synthesize a default set to hold them so they survive load+save.
const fallback = createPerformanceSet('Main')
fallback.customButtons = [...show.customButtons]
show.sets.push(fallback)
show.activeSetIndex = 0
delete show.customButtons
} else {
const idx =
show.activeSetIndex >= 0 && show.activeSetIndex < show.sets.length ? show.activeSetIndex : 0
const activeSet = show.sets[idx]
if (activeSet) {
activeSet.customButtons = [...(activeSet.customButtons ?? []), ...show.customButtons]
delete show.customButtons
}
}
}
// Migrate legacy LFO play-mode values on every modulation slot. migratePlayMode
// (e.g. 'pingpong' -> 'bounce') otherwise only runs inside createModSlot, which
// the load path bypasses — so a slot deserialized with a legacy value kept the
// raw string, and the runtime's `=== 'bounce'` check silently degraded an
// authored bounce LFO to a one-way loop (and re-saved it that way).
for (const set of show.sets) {
for (const slot of set.modulation ?? []) {
slot.lfoPlayMode = migratePlayMode(slot.lfoPlayMode)
}
}
// Ensure all sets have customButtons array + migrate button naming
for (const set of show.sets) {
migrateButtonConfigsInSet(set)
}
// v2 → v3: normalize continuous params from old native ranges to 0-1
if (show.version === 2) {
for (const set of show.sets) {
// Build a node-by-id lookup for button target resolution
const allNodes = collectAllNodesDeep(set.chain)
const nodeById = new Map<string, ChainNode>()
for (const node of allNodes) {
nodeById.set(node.id, node)
}
// Migrate node params (sources, effects, and sourceEffects)
for (const node of allNodes) {
migrateNodeParams(node)
}
// Migrate custom button action values (set-level)
for (const btn of set.customButtons ?? []) {
migrateButtonActions(btn, nodeById)
}
// Migrate custom button action values (legacy node-level — pre-v12).
// Field is gone from the typed model; access via `as any`.
for (const buttons of collectButtonOwnersInSet(set)) {
if (buttons === set.customButtons) continue
for (const btn of buttons) {
migrateButtonActions(btn, nodeById)
}
}
}
show.version = 3
}
// v3 → v4: migrate fb_ source params to feedback effect nodes
if (show.version === 3) {
for (const set of show.sets) {
migrateFeedbackToEffect(set)
}
show.version = 4
}
// v4 → v5: rename bg_o → bg_opacity, bg_brightness → bg_b
if (show.version === 4) {
const PARAM_RENAMES: Record<string, string> = {
bg_o: 'bg_opacity',
bg_brightness: 'bg_b',
}
for (const set of show.sets) {
for (const node of set.chain) {
renameParams(node, PARAM_RENAMES)
for (const se of node.sourceEffects ?? []) {
renameParams(se, PARAM_RENAMES)
}
}
}
show.version = 5
}
// v5 → v6: rename AudioLabs source IDs to match display labels
if (show.version === 5) {
const SOURCE_RENAMES: Record<string, string> = {
alcore: 'monolith',
alkaleidoscope: 'prism',
altunnel: 'abyss',
alpixels: 'citadel',
altape: 'drift',
alfluid: 'ether',
alstrings: 'filament',
alnetwork: 'synapse',
alwaves: 'terraform',
alportal: 'warpgate',
}
for (const set of show.sets) {
for (const node of set.chain) {
const renamed = SOURCE_RENAMES[node.name] as string | undefined
if (renamed) {
node.name = renamed
}
}
}
show.version = 6
}
// v6 → v7: unify button color / offColor / colorCycle → colorStates[]
// The actual mapping happens inside migrateButtonConfig() above (called for
// every button), so we just need to bump the version once everything has
// already been re-run through the per-button migrator.
if (show.version === 6) {
show.version = 7
}
// v7 → v8: collapse setPalette / cyclePalette / cycleSwatch ops into the
// standard set/cycle ops with synthetic palette: targets. The legacy
// cycleSwatchNodeId/cycleSwatchGroup fields move into the target string.
if (show.version === 7) {
const allButtons: CustomButtonConfig[] = []
for (const set of show.sets) {
for (const buttons of collectButtonOwnersInSet(set)) allButtons.push(...buttons)
}
if (show.customButtons) allButtons.push(...show.customButtons)
for (const btn of allButtons) {
for (const action of btn.actions ?? []) {
// Use raw access since the legacy ops are no longer in ActionOp.
const op = (action as { operation: string }).operation
if (op === 'setPalette') {
;(action as { operation: string }).operation = 'set'
action.target = 'palette:group'
} else if (op === 'cyclePalette') {
;(action as { operation: string }).operation = 'cycle'
action.target = 'palette:group'
} else if (op === 'cycleSwatch') {
;(action as { operation: string }).operation = 'cycle'
const legacy = action as {
cycleSwatchNodeId?: string
cycleSwatchGroup?: string
}
if (legacy.cycleSwatchNodeId && legacy.cycleSwatchGroup) {
action.target = `palette:swatch:${legacy.cycleSwatchNodeId}/${legacy.cycleSwatchGroup}`
}
delete legacy.cycleSwatchNodeId
delete legacy.cycleSwatchGroup
}
}
}
show.version = 8
}
// v8 → v9: chain groups support. No data transformation needed —
// existing show files have no group nodes. Version bump ensures
// new features (group type, children array, groupPath) are available.
// Future v9→v10 migrations MUST use walkNodesDeep() to traverse children.
if (show.version === 8) {
show.version = 9
}
// v9 → v10: pure version bump — no on-disk shape change. Sets are persisted
// flat (spread of SetDefinition + identity fields).
if (show.version === 9) {
show.version = 10
}
// v10 → v11: phase out snapshot-toggle buttons in favour of invert-on-mod
// targets (ADR-0010). Param-scoped snapshots get rewritten as
// `{op:'invert', target:'mod:<slotId>/enabled'}` using the set's modulation
// list. Whole-node snapshots (no snapshotParamKey) are dropped — they're
// unreachable from any current UI factory but may exist in hand-edited
// shows. The toggle:true flag is stripped everywhere — toggle behaviour
// now comes from invert + boolean target.
if (show.version === 10) {
for (const set of show.sets) {
const slotByTarget = new Map<string, string>()
for (const slot of set.modulation ?? []) {
slotByTarget.set(slot.target, slot.id)
}
// Walk every button: set-level + every node (recursive through groups)
// + every node's source-effects.
const buttonOwners: CustomButtonConfig[][] = []
if (set.customButtons) buttonOwners.push(set.customButtons)
for (const node of collectAllNodesDeep(set.chain)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacy = (node as any).customButtons as CustomButtonConfig[] | undefined
if (legacy) buttonOwners.push(legacy)
}
for (const buttons of buttonOwners) {
for (const btn of buttons) {
migrateSnapshotToggleButton(btn, slotByTarget)
}
}
}
show.version = 11
}
// v11 → v12: flatten node-stored customButtons into the owning set, drop
// any residual snapshot ops (ADR-0052). Buttons stop belonging to nodes;

File format

A show file is plain JSON with a .lbshow extension. It is human-readable, portable, and can be version-controlled alongside the rest of your project. Show files are written atomically so a crash mid-save cannot produce a corrupted file.

For the auto-save / session file conventions, see the Saving Your Work guide.