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
.lbshowfiles 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-anyfunction 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.
Related
- Saving Your Work guide — how to save, load, and recover from auto-saved sessions.
- Performance Sets guide — what’s stored per set vs at the show level.