use crate::{ control::{ CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError, OutputBackendMode, PanelTarget, PresetSummary, SceneTransitionStyle, }, scene::{PatternRegistry, SceneRuntime}, }; use infinity_config::{ColorOrder, DriverKind, LedDirection, PanelPosition, ProjectConfig}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; pub const RUNTIME_STATE_SCHEMA_VERSION: u16 = 1; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StoredPreset { pub preset_id: String, pub scene: SceneRuntime, pub transition_duration_ms: u32, pub transition_style: SceneTransitionStyle, pub source: CatalogSource, pub updated_at_unix_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct StoredGroup { pub group_id: String, pub tags: Vec, pub members: Vec, pub source: CatalogSource, pub updated_at_unix_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StoredCreativeSnapshot { pub snapshot_id: String, pub label: Option, pub scene: SceneRuntime, pub transition_duration_ms: u32, pub transition_style: SceneTransitionStyle, pub saved_at_unix_ms: u64, pub source_preset_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PersistedGlobalState { pub blackout: bool, pub master_brightness: f32, pub transition_duration_ms: u32, pub transition_style: SceneTransitionStyle, } impl Default for PersistedGlobalState { fn default() -> Self { Self { blackout: false, master_brightness: 0.20, transition_duration_ms: 2_000, transition_style: SceneTransitionStyle::Crossfade, } } } fn default_output_fps() -> u16 { 40 } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PersistedNodeState { pub node_id: String, pub reserved_ip: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PersistedPanelState { pub target: PanelTarget, pub physical_output_name: String, pub driver_kind: DriverKind, pub driver_reference: String, pub led_count: u16, pub direction: LedDirection, pub color_order: ColorOrder, pub enabled: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PersistedTechnicalState { pub backend_mode: OutputBackendMode, pub output_enabled: bool, #[serde(default = "default_output_fps")] pub output_fps: u16, #[serde(default)] pub nodes: Vec, #[serde(default)] pub panels: Vec, } impl Default for PersistedTechnicalState { fn default() -> Self { Self { backend_mode: OutputBackendMode::PreviewOnly, output_enabled: false, output_fps: default_output_fps(), nodes: Vec::new(), panels: Vec::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct PersistedRuntimeState { pub active_scene: Option, pub global: PersistedGlobalState, #[serde(default)] pub technical: PersistedTechnicalState, pub user_presets: Vec, pub user_groups: Vec, pub creative_snapshots: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] struct RuntimeStateEnvelope { schema_version: u16, saved_at_unix_ms: u64, runtime: PersistedRuntimeState, } #[derive(Debug, Clone)] pub struct RuntimeStateStorage { path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeStateLoadWarning { pub code: String, pub message: String, } #[derive(Debug, Clone, PartialEq)] pub struct RuntimeStateLoadResult { pub runtime: PersistedRuntimeState, pub loaded_from_disk: bool, pub warnings: Vec, } #[derive(Debug, thiserror::Error)] pub enum ShowStoreError { #[error("runtime state I/O failed: {0}")] Io(#[from] std::io::Error), #[error("runtime state parse failed: {0}")] Parse(#[from] serde_json::Error), #[error("{0}")] Validation(String), } #[derive(Debug, Clone, Default)] pub struct ShowStore { presets: Vec, groups: Vec, creative_snapshots: Vec, } impl RuntimeStateStorage { pub fn new(path: impl Into) -> Self { Self { path: path.into() } } pub fn path(&self) -> &Path { &self.path } pub fn load(&self) -> Result { if !self.path.exists() { return Ok(PersistedRuntimeState::default()); } let raw = fs::read_to_string(&self.path)?; parse_runtime_state(&raw, &self.path) } pub fn load_with_recovery(&self) -> RuntimeStateLoadResult { if !self.path.exists() { return RuntimeStateLoadResult { runtime: PersistedRuntimeState::default(), loaded_from_disk: false, warnings: Vec::new(), }; } let raw = match fs::read_to_string(&self.path) { Ok(raw) => raw, Err(error) => { return RuntimeStateLoadResult { runtime: PersistedRuntimeState::default(), loaded_from_disk: false, warnings: vec![RuntimeStateLoadWarning::new( "runtime_state_read_failed", format!( "runtime state at {} could not be read and was reset to defaults: {error}", self.path.display() ), )], }; } }; if raw.trim().is_empty() { return RuntimeStateLoadResult { runtime: PersistedRuntimeState::default(), loaded_from_disk: false, warnings: vec![RuntimeStateLoadWarning::new( "runtime_state_empty", format!( "runtime state at {} was empty and was reset to defaults", self.path.display() ), )], }; } match parse_runtime_state(&raw, &self.path) { Ok(runtime) => RuntimeStateLoadResult { runtime, loaded_from_disk: true, warnings: Vec::new(), }, Err(ShowStoreError::Parse(error)) => RuntimeStateLoadResult { runtime: PersistedRuntimeState::default(), loaded_from_disk: false, warnings: vec![RuntimeStateLoadWarning::new( "runtime_state_parse_failed", format!( "runtime state at {} could not be parsed and was reset to defaults: {error}", self.path.display() ), )], }, Err(ShowStoreError::Validation(message)) => RuntimeStateLoadResult { runtime: PersistedRuntimeState::default(), loaded_from_disk: false, warnings: vec![RuntimeStateLoadWarning::new( "runtime_state_schema_unsupported", format!("{message}; runtime state was reset to defaults"), )], }, Err(ShowStoreError::Io(error)) => RuntimeStateLoadResult { runtime: PersistedRuntimeState::default(), loaded_from_disk: false, warnings: vec![RuntimeStateLoadWarning::new( "runtime_state_read_failed", format!( "runtime state at {} could not be read and was reset to defaults: {error}", self.path.display() ), )], }, } } pub fn save(&self, runtime: &PersistedRuntimeState) -> Result<(), ShowStoreError> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; } let envelope = RuntimeStateEnvelope { schema_version: RUNTIME_STATE_SCHEMA_VERSION, saved_at_unix_ms: now_unix_ms(), runtime: runtime.clone(), }; let payload = serde_json::to_string_pretty(&envelope)?; fs::write(&self.path, payload)?; Ok(()) } } impl RuntimeStateLoadWarning { fn new(code: impl Into, message: impl Into) -> Self { Self { code: code.into(), message: message.into(), } } } fn parse_runtime_state(raw: &str, path: &Path) -> Result { let envelope = serde_json::from_str::(raw)?; if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION { return Err(ShowStoreError::Validation(format!( "unsupported runtime state schema version {} at {}", envelope.schema_version, path.display() ))); } Ok(envelope.runtime) } impl ShowStore { pub fn from_project(project: &ProjectConfig, registry: &PatternRegistry) -> Self { let presets = project .presets .iter() .map(|preset| StoredPreset { preset_id: preset.preset_id.clone(), scene: registry.scene_from_preset_config(preset), transition_duration_ms: preset.transition_ms, transition_style: crate::scene::transition_style_from_duration( preset.transition_ms, ), source: CatalogSource::BuiltIn, updated_at_unix_ms: None, }) .collect::>(); let groups = project .topology .groups .iter() .map(|group| StoredGroup { group_id: group.group_id.clone(), tags: group.tags.clone(), members: group .members .iter() .map(|member| PanelTarget { node_id: member.node_id.clone(), panel_position: member.panel_position.clone(), }) .collect(), source: CatalogSource::BuiltIn, updated_at_unix_ms: None, }) .collect::>(); Self { presets, groups, creative_snapshots: Vec::new(), } } pub fn apply_persisted(&mut self, runtime: PersistedRuntimeState) { for preset in runtime.user_presets { replace_or_append_by(&mut self.presets, preset, |left, right| { left.preset_id == right.preset_id }); } for group in runtime.user_groups { replace_or_append_by(&mut self.groups, group, |left, right| { left.group_id == right.group_id }); } self.creative_snapshots = runtime.creative_snapshots; } pub fn catalog(&self, registry: &PatternRegistry) -> CatalogSnapshot { CatalogSnapshot { patterns: registry.pattern_definitions(), presets: self .presets .iter() .map(|preset| PresetSummary { preset_id: preset.preset_id.clone(), pattern_id: preset.scene.pattern_id.clone(), target_group: preset.scene.target_group.clone(), transition_duration_ms: preset.transition_duration_ms, transition_style: preset.transition_style, source: preset.source, }) .collect(), groups: self .groups .iter() .map(|group| GroupSummary { group_id: group.group_id.clone(), member_count: group.members.len(), tags: group.tags.clone(), source: group.source, }) .collect(), creative_snapshots: self .creative_snapshots .iter() .map(|snapshot| CreativeSnapshotSummary { snapshot_id: snapshot.snapshot_id.clone(), label: snapshot.label.clone(), pattern_id: snapshot.scene.pattern_id.clone(), target_group: snapshot.scene.target_group.clone(), transition_duration_ms: snapshot.transition_duration_ms, transition_style: snapshot.transition_style, saved_at_unix_ms: snapshot.saved_at_unix_ms, }) .collect(), } } pub fn initial_scene(&self, registry: &PatternRegistry) -> SceneRuntime { self.presets .first() .map(|preset| preset.scene.clone()) .unwrap_or_else(|| { registry.scene_for_pattern( "solid_color", Some("bootstrap-solid-color".to_string()), None, 1, vec!["#ffffff".to_string()], false, ) }) } pub fn available_patterns(&self, registry: &PatternRegistry) -> Vec { registry .pattern_definitions() .into_iter() .map(|pattern| pattern.pattern_id) .collect() } pub fn scene_from_preset_id(&self, preset_id: &str) -> Option { self.presets .iter() .find(|preset| preset.preset_id == preset_id) .map(|preset| preset.scene.clone()) } pub fn transition_for_preset(&self, preset_id: &str) -> Option<(u32, SceneTransitionStyle)> { self.presets .iter() .find(|preset| preset.preset_id == preset_id) .map(|preset| (preset.transition_duration_ms, preset.transition_style)) } pub fn recall_creative_snapshot(&self, snapshot_id: &str) -> Option { self.creative_snapshots .iter() .find(|snapshot| snapshot.snapshot_id == snapshot_id) .cloned() } pub fn has_group(&self, group_id: &str) -> bool { self.groups.iter().any(|group| group.group_id == group_id) } pub fn group_members_map(&self) -> BTreeMap> { self.groups .iter() .map(|group| { let members = group .members .iter() .map(|member| { format!( "{}:{}", member.node_id, panel_position_key(&member.panel_position) ) }) .collect(); (group.group_id.clone(), members) }) .collect() } pub fn save_preset_from_scene( &mut self, preset_id: &str, scene: &SceneRuntime, transition_duration_ms: u32, transition_style: SceneTransitionStyle, overwrite: bool, ) -> Result<(), HostCommandError> { if preset_id.trim().is_empty() { return Err(HostCommandError::new( "invalid_preset_id", "preset_id must not be empty", )); } if let Some(existing) = self .presets .iter() .find(|preset| preset.preset_id == preset_id) { if !overwrite { return Err(HostCommandError::new( "preset_exists", format!("preset '{preset_id}' already exists"), )); } if existing.source == CatalogSource::BuiltIn { // Overwriting a built-in preset becomes a runtime overlay with the same id. } } let preset = StoredPreset { preset_id: preset_id.to_string(), scene: scene.clone(), transition_duration_ms, transition_style, source: CatalogSource::RuntimeUser, updated_at_unix_ms: Some(now_unix_ms()), }; replace_or_append_by(&mut self.presets, preset, |left, right| { left.preset_id == right.preset_id }); Ok(()) } pub fn delete_preset(&mut self, preset_id: &str) -> Result<(), HostCommandError> { if preset_id.trim().is_empty() { return Err(HostCommandError::new( "invalid_preset_id", "preset_id must not be empty", )); } let preset_index = self .presets .iter() .position(|preset| preset.preset_id == preset_id) .ok_or_else(|| { HostCommandError::new( "unknown_preset", format!("preset '{preset_id}' does not exist"), ) })?; if self.presets[preset_index].source != CatalogSource::RuntimeUser { return Err(HostCommandError::new( "preset_delete_forbidden", format!("preset '{preset_id}' is built-in and cannot be deleted"), )); } self.presets.remove(preset_index); Ok(()) } pub fn save_creative_snapshot( &mut self, snapshot_id: &str, label: Option, scene: &SceneRuntime, transition_duration_ms: u32, transition_style: SceneTransitionStyle, overwrite: bool, ) -> Result<(), HostCommandError> { if snapshot_id.trim().is_empty() { return Err(HostCommandError::new( "invalid_snapshot_id", "snapshot_id must not be empty", )); } if self .creative_snapshots .iter() .any(|snapshot| snapshot.snapshot_id == snapshot_id) && !overwrite { return Err(HostCommandError::new( "snapshot_exists", format!("creative snapshot '{snapshot_id}' already exists"), )); } let snapshot = StoredCreativeSnapshot { snapshot_id: snapshot_id.to_string(), label, scene: scene.clone(), transition_duration_ms, transition_style, saved_at_unix_ms: now_unix_ms(), source_preset_id: scene.preset_id.clone(), }; replace_or_append_by(&mut self.creative_snapshots, snapshot, |left, right| { left.snapshot_id == right.snapshot_id }); Ok(()) } pub fn upsert_group( &mut self, group_id: &str, tags: Vec, members: Vec, overwrite: bool, ) -> Result<(), HostCommandError> { if group_id.trim().is_empty() { return Err(HostCommandError::new( "invalid_group_id", "group_id must not be empty", )); } if members.is_empty() { return Err(HostCommandError::new( "invalid_group_members", "group must contain at least one panel target", )); } if self.groups.iter().any(|group| group.group_id == group_id) && !overwrite { return Err(HostCommandError::new( "group_exists", format!("group '{group_id}' already exists"), )); } let group = StoredGroup { group_id: group_id.to_string(), tags, members, source: CatalogSource::RuntimeUser, updated_at_unix_ms: Some(now_unix_ms()), }; replace_or_append_by(&mut self.groups, group, |left, right| { left.group_id == right.group_id }); Ok(()) } pub fn persisted_runtime( &self, active_scene: &SceneRuntime, global: PersistedGlobalState, technical: PersistedTechnicalState, ) -> PersistedRuntimeState { PersistedRuntimeState { active_scene: Some(active_scene.clone()), global, technical, user_presets: self .presets .iter() .filter(|preset| preset.source == CatalogSource::RuntimeUser) .cloned() .collect(), user_groups: self .groups .iter() .filter(|group| group.source == CatalogSource::RuntimeUser) .cloned() .collect(), creative_snapshots: self.creative_snapshots.clone(), } } } fn panel_position_key(position: &PanelPosition) -> &'static str { match position { PanelPosition::Top => "top", PanelPosition::Middle => "middle", PanelPosition::Bottom => "bottom", } } fn replace_or_append_by(items: &mut Vec, item: T, predicate: F) where F: Fn(&T, &T) -> bool, { if let Some(index) = items.iter().position(|existing| predicate(existing, &item)) { items[index] = item; } else { items.push(item); } } fn now_unix_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis() as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; fn sample_project() -> ProjectConfig { ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml")) .expect("project config must parse") } #[test] fn show_store_builds_runtime_catalog() { let registry = PatternRegistry::new(); let store = ShowStore::from_project(&sample_project(), ®istry); let catalog = store.catalog(®istry); assert!(catalog .presets .iter() .any(|preset| preset.preset_id == "ocean_gradient")); assert!(catalog .groups .iter() .any(|group| group.group_id == "top_panels")); } #[test] fn runtime_presets_and_snapshots_can_be_saved() { let registry = PatternRegistry::new(); let mut store = ShowStore::from_project(&sample_project(), ®istry); let scene = registry.scene_for_pattern( "gradient", None, Some("top_panels".to_string()), 77, vec!["#112233".to_string(), "#445566".to_string()], false, ); store .save_preset_from_scene( "user_gradient", &scene, 420, SceneTransitionStyle::Crossfade, false, ) .expect("preset save should succeed"); store .save_creative_snapshot( "variant_a", Some("Variant A".to_string()), &scene, 240, SceneTransitionStyle::Chase, false, ) .expect("snapshot save should succeed"); assert!(store.scene_from_preset_id("user_gradient").is_some()); assert!(store.recall_creative_snapshot("variant_a").is_some()); } #[test] fn runtime_presets_can_be_deleted_but_builtins_cannot() { let registry = PatternRegistry::new(); let mut store = ShowStore::from_project(&sample_project(), ®istry); let scene = registry.scene_for_pattern( "noise", None, Some("top_panels".to_string()), 31, vec!["#AA8844".to_string()], false, ); store .save_preset_from_scene( "runtime_delete_me", &scene, 210, SceneTransitionStyle::Crossfade, false, ) .expect("runtime preset save should succeed"); store .delete_preset("runtime_delete_me") .expect("runtime preset delete should succeed"); assert!(store.scene_from_preset_id("runtime_delete_me").is_none()); let built_in_preset_id = store .catalog(®istry) .presets .iter() .find(|preset| preset.source == CatalogSource::BuiltIn) .map(|preset| preset.preset_id.clone()) .expect("sample project should contain built-in presets"); let delete_error = store .delete_preset(&built_in_preset_id) .expect_err("built-in presets should not be deletable"); assert_eq!(delete_error.code, "preset_delete_forbidden"); } #[test] fn runtime_state_storage_roundtrip_preserves_scene_and_library() { let registry = PatternRegistry::new(); let mut store = ShowStore::from_project(&sample_project(), ®istry); let scene = registry.scene_for_pattern( "noise", None, Some("bottom_panels".to_string()), 99, vec!["#AA8844".to_string()], false, ); store .save_preset_from_scene( "roundtrip_noise", &scene, 220, SceneTransitionStyle::Chase, false, ) .expect("preset save should succeed"); let path = std::env::temp_dir().join(format!( "infinity_vis_show_store_{}.json", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time") .as_millis() )); let storage = RuntimeStateStorage::new(&path); let runtime = store.persisted_runtime( &scene, PersistedGlobalState { blackout: false, master_brightness: 0.42, transition_duration_ms: 220, transition_style: SceneTransitionStyle::Chase, }, PersistedTechnicalState::default(), ); storage.save(&runtime).expect("save should work"); let loaded = storage.load().expect("load should work"); assert_eq!(loaded.active_scene, Some(scene)); assert!(loaded .user_presets .iter() .any(|preset| preset.preset_id == "roundtrip_noise")); let _ = std::fs::remove_file(path); } #[test] fn runtime_state_storage_recovers_from_empty_file() { let path = std::env::temp_dir().join(format!( "infinity_vis_show_store_empty_{}.json", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time") .as_millis() )); let storage = RuntimeStateStorage::new(&path); std::fs::write(&path, "").expect("empty file should write"); let loaded = storage.load_with_recovery(); assert_eq!(loaded.runtime, PersistedRuntimeState::default()); assert!(!loaded.loaded_from_disk); assert_eq!(loaded.warnings.len(), 1); assert_eq!(loaded.warnings[0].code, "runtime_state_empty"); let _ = std::fs::remove_file(path); } #[test] fn runtime_state_storage_recovers_from_invalid_json() { let path = std::env::temp_dir().join(format!( "infinity_vis_show_store_invalid_{}.json", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time") .as_millis() )); let storage = RuntimeStateStorage::new(&path); std::fs::write(&path, "{ definitely not json").expect("invalid file should write"); let loaded = storage.load_with_recovery(); assert_eq!(loaded.runtime, PersistedRuntimeState::default()); assert!(!loaded.loaded_from_disk); assert_eq!(loaded.warnings.len(), 1); assert_eq!(loaded.warnings[0].code, "runtime_state_parse_failed"); let _ = std::fs::remove_file(path); } #[test] fn runtime_state_storage_recovers_from_unsupported_schema() { let path = std::env::temp_dir().join(format!( "infinity_vis_show_store_schema_{}.json", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time") .as_millis() )); let storage = RuntimeStateStorage::new(&path); std::fs::write( &path, r#"{ "schema_version": 99, "saved_at_unix_ms": 1, "runtime": { "active_scene": null, "global": { "blackout": false, "master_brightness": 0.2, "transition_duration_ms": 150, "transition_style": "crossfade" }, "user_presets": [], "user_groups": [], "creative_snapshots": [] } }"#, ) .expect("schema file should write"); let loaded = storage.load_with_recovery(); assert_eq!(loaded.runtime, PersistedRuntimeState::default()); assert!(!loaded.loaded_from_disk); assert_eq!(loaded.warnings.len(), 1); assert_eq!(loaded.warnings[0].code, "runtime_state_schema_unsupported"); let _ = std::fs::remove_file(path); } }