Die gemeinsame Plattform ist jetzt softwareseitig deutlich vollständiger. Der Host-Core hat mit [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) eine echte Runtime-Bibliothek und Persistenz für aktive Szene, Runtime-Presets, Runtime-Gruppen und kreative Varianten bekommen; die Simulation in [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>) liefert jetzt typisierte Command-Ergebnisse, saubere Fehlercodes und persistiert nach data/runtime_state.json. Dazu kommt das generische External-Show-Control-Interface in [external_control.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/external_control.rs>), damit spätere Adapter nur auf definierte Commands und Snapshot-/Preset-/Parameter-Flächen zugreifen.

Die API v1 ist als Produktgrenze geschärft in [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>): getrennte Modelle für `state`, `preview`, `snapshot`, `command response`, `event stream` und stabile Fehlerobjekte mit echten Codes statt generischem Fallback. Dazu kamen `GET /api/v1/state` und `GET /api/v1/preview`, neue persistenzbezogene Commands wie `save_preset`, `save_creative_snapshot`, `recall_creative_snapshot`, `set_transition_style` und `upsert_group`, plus serverseitige Durchreichung der echten Fehlercodes. Die kreative Web-UI in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>) nutzt jetzt genau diese API für Preset-Speichern/Überschreiben, Varianten, Transition-Style, filterbaren Eventfeed und klarere Preview-Darstellung, ohne Parallelarchitektur.

Die Doku ist auf den neuen Stand gezogen in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>), [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>). Verifiziert habe ich `cargo check -q` und `cargo test -q`; dabei laufen die erweiterten Contract- und Persistenztests in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>) sowie neue Core-Tests in [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) und [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>). Nicht separat verifiziert habe ich einen echten Browserlauf der Web-UI; die JS-Datei wurde hier nicht mit `node` geprüft, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
2026-04-17 12:34:03 +02:00
parent a37a3c5cbe
commit 8e19f535ae
20 changed files with 2399 additions and 459 deletions

1
Cargo.lock generated
View File

@@ -1706,6 +1706,7 @@ dependencies = [
"infinity_protocol",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]

View File

@@ -34,7 +34,7 @@ The current baseline is intentionally strict about unresolved hardware facts. `U
2. Review the open validation checklist in [docs/validation_open_points.md](docs/validation_open_points.md).
3. Start from [config/project.example.toml](config/project.example.toml).
4. Inspect the software-first host snapshot with `cargo run -p infinity_host -- snapshot --config config/project.example.toml`.
5. Start the versioned host API plus creative web UI with `cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001`.
5. Start the versioned host API plus creative web UI with `cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json`.
6. Open `http://127.0.0.1:9001/` for the creative surface.
7. Start the engineering GUI with `cargo run -p infinity_host_ui`.
8. Use the host CLI to validate the project config before attempting activation.

View File

@@ -9,5 +9,6 @@ authors.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
infinity_config = { path = "../infinity_config" }
infinity_protocol = { path = "../infinity_protocol" }

View File

@@ -34,6 +34,7 @@ pub struct GlobalControlSnapshot {
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -52,6 +53,7 @@ pub struct CatalogSnapshot {
pub patterns: Vec<PatternDefinition>,
pub presets: Vec<PresetSummary>,
pub groups: Vec<GroupSummary>,
pub creative_snapshots: Vec<CreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -62,12 +64,21 @@ pub struct PatternDefinition {
pub parameters: Vec<SceneParameterSpec>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CatalogSource {
BuiltIn,
RuntimeUser,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PresetSummary {
pub preset_id: String,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -75,6 +86,18 @@ pub struct GroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -195,9 +218,19 @@ pub struct PanelSnapshot {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusEvent {
pub at_millis: u64,
pub kind: StatusEventKind,
pub code: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StatusEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeConnectionState {
@@ -229,10 +262,29 @@ pub enum HostCommand {
value: SceneParameterValue,
},
SetTransitionDurationMs(u32),
SetTransitionStyle(SceneTransitionStyle),
TriggerPanelTest {
target: PanelTarget,
pattern: TestPatternKind,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<PanelTarget>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -241,9 +293,21 @@ pub enum TestPatternKind {
WalkingPixel106,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandOutcome {
pub generated_at_millis: u64,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostCommandError {
pub code: String,
pub message: String,
}
pub trait HostApiPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn send_command(&self, command: HostCommand);
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError>;
}
pub trait HostUiPort: HostApiPort {}
@@ -260,6 +324,35 @@ impl NodeConnectionState {
}
}
impl SceneTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl CatalogSource {
pub fn label(self) -> &'static str {
match self {
Self::BuiltIn => "built_in",
Self::RuntimeUser => "runtime_user",
}
}
}
impl StatusEventKind {
pub fn label(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
}
}
}
impl TestPatternKind {
pub fn label(self) -> &'static str {
match self {
@@ -287,3 +380,12 @@ impl SceneParameterValue {
Self::Text(value.into())
}
}
impl HostCommandError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}

View File

@@ -0,0 +1,124 @@
use crate::{
CommandOutcome, HostApiPort, HostCommand, HostCommandError, HostSnapshot, PanelTarget,
SceneParameterValue, SceneTransitionStyle,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case", tag = "action", content = "payload")]
pub enum ExternalControlAction {
SetBlackout {
enabled: bool,
},
SetMasterBrightness {
value: f32,
},
SelectPattern {
pattern_id: String,
},
RecallPreset {
preset_id: String,
},
SelectGroup {
group_id: Option<String>,
},
SetSceneParameter {
key: String,
value: SceneParameterValue,
},
SetTransitionConfig {
duration_ms: u32,
style: SceneTransitionStyle,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
TriggerPanelTest {
target: PanelTarget,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalAdapterCapabilities {
pub supports_group_targeting: bool,
pub supports_preset_recall: bool,
pub supports_parameter_updates: bool,
pub supports_transition_config: bool,
pub supports_panel_tests: bool,
}
impl Default for ExternalAdapterCapabilities {
fn default() -> Self {
Self {
supports_group_targeting: true,
supports_preset_recall: true,
supports_parameter_updates: true,
supports_transition_config: true,
supports_panel_tests: false,
}
}
}
pub trait ExternalShowControlPort: Send + Sync {
fn snapshot(&self) -> HostSnapshot;
fn execute_action(
&self,
action: ExternalControlAction,
) -> Result<CommandOutcome, HostCommandError>;
}
pub trait ExternalShowControlAdapter: Send {
fn adapter_id(&self) -> &str;
fn capabilities(&self) -> ExternalAdapterCapabilities {
ExternalAdapterCapabilities::default()
}
fn translate(
&mut self,
action: ExternalControlAction,
) -> Result<Vec<HostCommand>, HostCommandError>;
}
impl<T: HostApiPort + ?Sized> ExternalShowControlPort for T {
fn snapshot(&self) -> HostSnapshot {
HostApiPort::snapshot(self)
}
fn execute_action(
&self,
action: ExternalControlAction,
) -> Result<CommandOutcome, HostCommandError> {
match action {
ExternalControlAction::SetBlackout { enabled } => {
self.send_command(HostCommand::SetBlackout(enabled))
}
ExternalControlAction::SetMasterBrightness { value } => {
self.send_command(HostCommand::SetMasterBrightness(value))
}
ExternalControlAction::SelectPattern { pattern_id } => {
self.send_command(HostCommand::SelectPattern(pattern_id))
}
ExternalControlAction::RecallPreset { preset_id } => {
self.send_command(HostCommand::RecallPreset { preset_id })
}
ExternalControlAction::SelectGroup { group_id } => {
self.send_command(HostCommand::SelectGroup { group_id })
}
ExternalControlAction::SetSceneParameter { key, value } => {
self.send_command(HostCommand::SetSceneParameter { key, value })
}
ExternalControlAction::SetTransitionConfig { duration_ms, style } => {
self.send_command(HostCommand::SetTransitionDurationMs(duration_ms))?;
self.send_command(HostCommand::SetTransitionStyle(style))
}
ExternalControlAction::RecallCreativeSnapshot { snapshot_id } => {
self.send_command(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ExternalControlAction::TriggerPanelTest { target } => {
self.send_command(HostCommand::TriggerPanelTest {
target,
pattern: crate::TestPatternKind::WalkingPixel106,
})
}
}
}
}

View File

@@ -1,9 +1,13 @@
pub mod control;
pub mod runtime;
pub mod scene;
pub mod show_store;
pub mod simulation;
pub mod external_control;
pub use control::*;
pub use external_control::*;
pub use runtime::*;
pub use scene::*;
pub use show_store::*;
pub use simulation::*;

View File

@@ -1,9 +1,10 @@
use crate::control::{
ActiveSceneSnapshot, CatalogSnapshot, GroupSummary, PatternDefinition, PresetSummary,
SceneParameterKind, SceneParameterSpec, SceneParameterState, SceneParameterValue,
SceneTransitionStyle, TransitionSnapshot,
ActiveSceneSnapshot, CatalogSnapshot, CatalogSource, GroupSummary, PatternDefinition,
PresetSummary, SceneParameterKind, SceneParameterSpec, SceneParameterState,
SceneParameterValue, SceneTransitionStyle, TransitionSnapshot,
};
use infinity_config::{PanelPosition, PresetConfig, ProjectConfig};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
hash::{Hash, Hasher},
@@ -12,7 +13,7 @@ use std::{
const DEFAULT_SAMPLE_LED_COUNT: usize = 6;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SceneRuntime {
pub preset_id: Option<String>,
pub pattern_id: String,
@@ -70,6 +71,8 @@ impl PatternRegistry {
pattern_id: preset.scene.effect.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_ms,
transition_style: transition_style_from_duration(preset.transition_ms),
source: CatalogSource::BuiltIn,
})
.collect(),
groups: project
@@ -80,8 +83,10 @@ impl PatternRegistry {
group_id: group.group_id.clone(),
member_count: group.members.len(),
tags: group.tags.clone(),
source: CatalogSource::BuiltIn,
})
.collect(),
creative_snapshots: Vec::new(),
}
}
@@ -102,6 +107,10 @@ impl PatternRegistry {
})
}
pub fn pattern_definitions(&self) -> Vec<PatternDefinition> {
self.definitions.values().cloned().collect()
}
pub fn scene_from_preset_id(&self, project: &ProjectConfig, preset_id: &str) -> Option<SceneRuntime> {
project
.presets
@@ -246,7 +255,7 @@ impl PatternRegistry {
}
}
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
pub fn scene_from_preset_config(&self, preset: &PresetConfig) -> SceneRuntime {
let mut scene = self.scene_for_pattern(
&preset.scene.effect,
Some(preset.preset_id.clone()),
@@ -264,6 +273,10 @@ impl PatternRegistry {
scene
}
fn scene_from_preset(&self, preset: &PresetConfig) -> SceneRuntime {
self.scene_from_preset_config(preset)
}
fn definition_or_default(&self, pattern_id: &str) -> &PatternDefinition {
self.definitions
.get(pattern_id)

View File

@@ -0,0 +1,605 @@
use crate::{
control::{
CatalogSnapshot, CatalogSource, CreativeSnapshotSummary, GroupSummary, HostCommandError,
PanelTarget, PresetSummary, SceneTransitionStyle,
},
scene::{SceneRuntime, PatternRegistry},
};
use infinity_config::{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<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StoredGroup {
pub group_id: String,
pub tags: Vec<String>,
pub members: Vec<PanelTarget>,
pub source: CatalogSource,
pub updated_at_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredCreativeSnapshot {
pub snapshot_id: String,
pub label: Option<String>,
pub scene: SceneRuntime,
pub transition_duration_ms: u32,
pub transition_style: SceneTransitionStyle,
pub saved_at_unix_ms: u64,
pub source_preset_id: Option<String>,
}
#[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: 150,
transition_style: SceneTransitionStyle::Crossfade,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct PersistedRuntimeState {
pub active_scene: Option<SceneRuntime>,
pub global: PersistedGlobalState,
pub user_presets: Vec<StoredPreset>,
pub user_groups: Vec<StoredGroup>,
pub creative_snapshots: Vec<StoredCreativeSnapshot>,
}
#[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, 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<StoredPreset>,
groups: Vec<StoredGroup>,
creative_snapshots: Vec<StoredCreativeSnapshot>,
}
impl RuntimeStateStorage {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<PersistedRuntimeState, ShowStoreError> {
if !self.path.exists() {
return Ok(PersistedRuntimeState::default());
}
let raw = fs::read_to_string(&self.path)?;
let envelope = serde_json::from_str::<RuntimeStateEnvelope>(&raw)?;
if envelope.schema_version != RUNTIME_STATE_SCHEMA_VERSION {
return Err(ShowStoreError::Validation(format!(
"unsupported runtime state schema version {} at {}",
envelope.schema_version,
self.path.display()
)));
}
Ok(envelope.runtime)
}
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 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::<Vec<_>>();
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::<Vec<_>>();
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<String> {
registry
.pattern_definitions()
.into_iter()
.map(|pattern| pattern.pattern_id)
.collect()
}
pub fn scene_from_preset_id(&self, preset_id: &str) -> Option<SceneRuntime> {
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<StoredCreativeSnapshot> {
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<String, BTreeSet<String>> {
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 save_creative_snapshot(
&mut self,
snapshot_id: &str,
label: Option<String>,
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<String>,
members: Vec<PanelTarget>,
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,
) -> PersistedRuntimeState {
PersistedRuntimeState {
active_scene: Some(active_scene.clone()),
global,
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<T, F>(items: &mut Vec<T>, 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(), &registry);
let catalog = store.catalog(&registry);
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(), &registry);
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_state_storage_roundtrip_preserves_scene_and_library() {
let registry = PatternRegistry::new();
let mut store = ShowStore::from_project(&sample_project(), &registry);
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,
},
);
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);
}
}

View File

@@ -1,19 +1,23 @@
use crate::{
control::{
EngineSnapshot, GlobalControlSnapshot, HostApiPort, HostCommand, HostSnapshot,
HOST_API_VERSION, NodeConnectionState, NodeSnapshot, PanelSnapshot, PanelTarget,
PreviewPanelSnapshot, PreviewSource, StatusEvent, SystemSnapshot,
CatalogSnapshot, CommandOutcome, EngineSnapshot, GlobalControlSnapshot, HostApiPort,
HostCommand, HostCommandError, HostSnapshot, HOST_API_VERSION, NodeConnectionState,
NodeSnapshot, PanelSnapshot, PanelTarget, PreviewPanelSnapshot, PreviewSource,
SceneTransitionStyle, StatusEvent, StatusEventKind, SystemSnapshot,
},
runtime::TickSchedule,
scene::{
apply_group_gate, blackout_preview, blend_previews, build_group_members,
panel_membership_key, panel_test_preview, transition_style_from_duration, PatternRegistry,
RenderedPreview, SceneRuntime, TransitionRuntime,
apply_group_gate, blackout_preview, blend_previews, panel_membership_key,
panel_test_preview, PatternRegistry, RenderedPreview, SceneRuntime, TransitionRuntime,
},
show_store::{
PersistedGlobalState, RuntimeStateStorage, ShowStore, ShowStoreError,
},
};
use infinity_config::{PanelPosition, ProjectConfig};
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
@@ -29,9 +33,10 @@ pub struct SimulationHostService {
#[derive(Debug)]
struct SimulationState {
project: ProjectConfig,
registry: PatternRegistry,
group_members: BTreeMap<String, std::collections::BTreeSet<String>>,
show_store: ShowStore,
runtime_storage: Option<RuntimeStateStorage>,
started_at: Instant,
next_seed: u64,
tick_count: u64,
@@ -45,9 +50,7 @@ struct SimulationState {
impl SimulationHostService {
pub fn new(project: ProjectConfig) -> Self {
Self {
inner: Arc::new(Mutex::new(SimulationState::new(project))),
}
Self::try_new(project).expect("memory-only simulation host service must initialize")
}
pub fn spawn_shared(project: ProjectConfig) -> Arc<Self> {
@@ -56,6 +59,33 @@ impl SimulationHostService {
service
}
pub fn try_new(project: ProjectConfig) -> Result<Self, ShowStoreError> {
Ok(Self {
inner: Arc::new(Mutex::new(SimulationState::try_new(project, None)?)),
})
}
pub fn try_new_with_persistence(
project: ProjectConfig,
runtime_state_path: impl Into<PathBuf>,
) -> Result<Self, ShowStoreError> {
Ok(Self {
inner: Arc::new(Mutex::new(SimulationState::try_new(
project,
Some(RuntimeStateStorage::new(runtime_state_path)),
)?)),
})
}
pub fn try_spawn_shared_with_persistence(
project: ProjectConfig,
runtime_state_path: impl Into<PathBuf>,
) -> Result<Arc<Self>, ShowStoreError> {
let service = Arc::new(Self::try_new_with_persistence(project, runtime_state_path)?);
Self::spawn_simulation_loop(Arc::clone(&service));
Ok(service)
}
fn spawn_simulation_loop(service: Arc<Self>) {
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(80));
@@ -74,25 +104,38 @@ impl HostApiPort for SimulationHostService {
.unwrap_or_else(|_| unavailable_snapshot())
}
fn send_command(&self, command: HostCommand) {
fn send_command(&self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
if let Ok(mut state) = self.inner.lock() {
state.apply_command(command);
state.apply_command(command)
} else {
Err(HostCommandError::new(
"service_unavailable",
"simulation state lock was unavailable",
))
}
}
}
impl SimulationState {
fn new(project: ProjectConfig) -> Self {
fn try_new(
project: ProjectConfig,
runtime_storage: Option<RuntimeStateStorage>,
) -> Result<Self, ShowStoreError> {
let registry = PatternRegistry::new();
let group_members = build_group_members(&project);
let mut show_store = ShowStore::from_project(&project, &registry);
let persisted_runtime = if let Some(storage) = &runtime_storage {
storage.load()?
} else {
Default::default()
};
let restored_scene = persisted_runtime.active_scene.clone();
let restored_global = persisted_runtime.global.clone();
show_store.apply_persisted(persisted_runtime);
let group_members = show_store.group_members_map();
let schedule = TickSchedule::default();
let current_scene = registry.initial_scene(&project);
let catalog = registry.catalog(&project);
let available_patterns = catalog
.patterns
.iter()
.map(|pattern| pattern.pattern_id.clone())
.collect::<Vec<_>>();
let current_scene = restored_scene.unwrap_or_else(|| show_store.initial_scene(&registry));
let catalog = show_store.catalog(&registry);
let available_patterns = show_store.available_patterns(&registry);
let nodes = project
.topology
.nodes
@@ -131,10 +174,12 @@ impl SimulationState {
})
.collect::<Vec<_>>();
let selected_pattern = current_scene.pattern_id.clone();
let mut state = Self {
project: project.clone(),
registry,
group_members,
show_store,
runtime_storage,
started_at: Instant::now(),
next_seed: 100,
tick_count: 0,
@@ -153,11 +198,12 @@ impl SimulationState {
topology_label: "6 nodes / 18 outputs / 106 LEDs".to_string(),
},
global: GlobalControlSnapshot {
blackout: false,
master_brightness: 0.20,
selected_pattern: "solid_color".to_string(),
blackout: restored_global.blackout,
master_brightness: restored_global.master_brightness,
selected_pattern,
selected_group: None,
transition_duration_ms: 150,
transition_duration_ms: restored_global.transition_duration_ms,
transition_style: restored_global.transition_style,
},
engine: EngineSnapshot {
logic_hz: schedule.logic_hz,
@@ -188,9 +234,16 @@ impl SimulationState {
state.snapshot.global.selected_pattern = state.current_scene.pattern_id.clone();
state.snapshot.global.selected_group = state.current_scene.target_group.clone();
state.snapshot.active_scene = state.registry.active_scene_snapshot(&state.current_scene);
state.push_event("simulation host service started".to_string());
state.push_event(StatusEventKind::Info, None, "simulation host service started".to_string());
if state.runtime_storage.is_some() {
state.push_event(
StatusEventKind::Info,
Some("runtime_state_restored".to_string()),
"runtime state persistence enabled".to_string(),
);
}
state.simulate_tick();
state
Ok(state)
}
fn simulate_tick(&mut self) {
@@ -218,22 +271,29 @@ impl SimulationState {
self.snapshot.preview.panels = self.render_preview_panels(elapsed_ms);
}
fn apply_command(&mut self, command: HostCommand) {
match command {
fn apply_command(&mut self, command: HostCommand) -> Result<CommandOutcome, HostCommandError> {
let mut should_persist = false;
let summary = match command {
HostCommand::SetBlackout(enabled) => {
self.snapshot.global.blackout = enabled;
self.push_event(if enabled {
should_persist = true;
let summary = if enabled {
"global blackout enabled".to_string()
} else {
"global blackout released".to_string()
});
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetMasterBrightness(value) => {
self.snapshot.global.master_brightness = value.clamp(0.0, 1.0);
self.push_event(format!(
should_persist = true;
let summary = format!(
"master brightness set to {:.0}%",
self.snapshot.global.master_brightness * 100.0
));
);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SelectPattern(pattern_id) => {
let mut new_scene = self.registry.scene_for_pattern(
@@ -255,55 +315,95 @@ impl SimulationState {
}
let duration_ms = self.snapshot.global.transition_duration_ms;
let style = self.snapshot.global.transition_style;
should_persist = true;
self.start_scene_transition(
new_scene,
duration_ms,
transition_style_from_duration(duration_ms),
style,
format!("pattern selected: {pattern_id}"),
);
format!("pattern selected: {pattern_id}")
}
HostCommand::RecallPreset { preset_id } => {
if let Some(scene) = self.registry.scene_from_preset_id(&self.project, &preset_id) {
let duration_ms = self
.project
.presets
.iter()
.find(|preset| preset.preset_id == preset_id)
.map(|preset| preset.transition_ms)
.unwrap_or(self.snapshot.global.transition_duration_ms);
let Some(scene) = self.show_store.scene_from_preset_id(&preset_id) else {
let error = HostCommandError::new(
"unknown_preset",
format!("preset '{preset_id}' does not exist"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
};
let (duration_ms, style) = self
.show_store
.transition_for_preset(&preset_id)
.unwrap_or((
self.snapshot.global.transition_duration_ms,
self.snapshot.global.transition_style,
));
self.snapshot.global.transition_duration_ms = duration_ms;
self.snapshot.global.transition_style = style;
should_persist = true;
self.start_scene_transition(
scene,
duration_ms,
self.registry
.transition_style_for_preset(&self.project, &preset_id),
style,
format!("preset recalled: {preset_id}"),
);
} else {
self.push_event(format!("ignored unknown preset request: {preset_id}"));
}
format!("preset recalled: {preset_id}")
}
HostCommand::SelectGroup { group_id } => {
if let Some(group_id_ref) = &group_id {
if !self.show_store.has_group(group_id_ref) {
let error = HostCommandError::new(
"unknown_group",
format!("group '{group_id_ref}' does not exist"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
}
}
self.current_scene.target_group = group_id.clone();
self.snapshot.global.selected_group = group_id.clone();
self.current_scene.preset_id = None;
self.push_event(format!(
should_persist = true;
let summary = format!(
"target group set to {}",
group_id.as_deref().unwrap_or("all_panels")
));
);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetSceneParameter { key, value } => {
self.registry
.set_scene_parameter(&mut self.current_scene, &key, value.clone());
self.current_scene.preset_id = None;
self.push_event(format!("scene parameter updated: {key} = {value:?}"));
should_persist = true;
let summary = format!("scene parameter updated: {key} = {value:?}");
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetTransitionDurationMs(duration_ms) => {
self.snapshot.global.transition_duration_ms = duration_ms;
self.push_event(format!(
"default transition duration set to {} ms",
duration_ms
));
should_persist = true;
let summary = format!("default transition duration set to {} ms", duration_ms);
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SetTransitionStyle(style) => {
self.snapshot.global.transition_style = style;
should_persist = true;
let summary = format!("default transition style set to {}", style.label());
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::TriggerPanelTest { target, pattern } => {
let now = self.elapsed_millis();
@@ -329,18 +429,114 @@ impl SimulationState {
);
}
self.push_event(message);
self.push_event(StatusEventKind::Info, None, message.clone());
message
}
HostCommand::SavePreset {
preset_id,
overwrite,
} => {
self.show_store.save_preset_from_scene(
&preset_id,
&self.current_scene,
self.snapshot.global.transition_duration_ms,
self.snapshot.global.transition_style,
overwrite,
)?;
self.rebuild_catalog();
self.group_members = self.show_store.group_members_map();
should_persist = true;
let summary = if overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
} => {
self.show_store.save_creative_snapshot(
&snapshot_id,
label.clone(),
&self.current_scene,
self.snapshot.global.transition_duration_ms,
self.snapshot.global.transition_style,
overwrite,
)?;
self.rebuild_catalog();
should_persist = true;
let summary = if overwrite {
format!("creative snapshot overwritten: {snapshot_id}")
} else {
format!("creative snapshot saved: {snapshot_id}")
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
HostCommand::RecallCreativeSnapshot { snapshot_id } => {
let Some(snapshot) = self.show_store.recall_creative_snapshot(&snapshot_id) else {
let error = HostCommandError::new(
"unknown_creative_snapshot",
format!("creative snapshot '{snapshot_id}' does not exist"),
);
self.push_event(
StatusEventKind::Warning,
Some(error.code.clone()),
error.message.clone(),
);
return Err(error);
};
self.snapshot.global.transition_duration_ms = snapshot.transition_duration_ms;
self.snapshot.global.transition_style = snapshot.transition_style;
should_persist = true;
self.start_scene_transition(
snapshot.scene,
snapshot.transition_duration_ms,
snapshot.transition_style,
format!("creative snapshot recalled: {snapshot_id}"),
);
format!("creative snapshot recalled: {snapshot_id}")
}
HostCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => {
self.show_store
.upsert_group(&group_id, tags, members, overwrite)?;
self.group_members = self.show_store.group_members_map();
self.rebuild_catalog();
should_persist = true;
let summary = if overwrite {
format!("group updated: {group_id}")
} else {
format!("group saved: {group_id}")
};
self.push_event(StatusEventKind::Info, None, summary.clone());
summary
}
};
self.simulate_tick();
if should_persist {
self.persist_runtime_state()?;
}
Ok(CommandOutcome {
generated_at_millis: self.snapshot.generated_at_millis,
summary,
})
}
fn start_scene_transition(
&mut self,
new_scene: SceneRuntime,
duration_ms: u32,
style: crate::control::SceneTransitionStyle,
style: SceneTransitionStyle,
event_message: String,
) {
let previous_scene = self.current_scene.clone();
@@ -355,7 +551,7 @@ impl SimulationState {
from_scene: previous_scene,
})
};
self.push_event(event_message);
self.push_event(StatusEventKind::Info, None, event_message);
}
fn resolve_transition_if_complete(&mut self) {
@@ -367,7 +563,7 @@ impl SimulationState {
if finished {
self.active_transition = None;
self.push_event(format!(
self.push_event(StatusEventKind::Info, None, format!(
"transition completed to {}",
self.current_scene.pattern_id
));
@@ -403,7 +599,11 @@ impl SimulationState {
}
for message in transition_messages {
self.push_event(message);
self.push_event(
StatusEventKind::Warning,
Some("node_connection_state".to_string()),
message,
);
}
}
@@ -501,11 +701,58 @@ impl SimulationState {
(scale_preview(preview, self.snapshot.global.master_brightness), source)
}
fn push_event(&mut self, message: String) {
fn rebuild_catalog(&mut self) {
self.snapshot.catalog = self.show_store.catalog(&self.registry);
}
fn persisted_global_state(&self) -> PersistedGlobalState {
PersistedGlobalState {
blackout: self.snapshot.global.blackout,
master_brightness: self.snapshot.global.master_brightness,
transition_duration_ms: self.snapshot.global.transition_duration_ms,
transition_style: self.snapshot.global.transition_style,
}
}
fn persist_runtime_state(&mut self) -> Result<(), HostCommandError> {
let Some(storage) = &self.runtime_storage else {
return Ok(());
};
let storage_path = storage.path().to_path_buf();
let runtime_state = self
.show_store
.persisted_runtime(&self.current_scene, self.persisted_global_state());
if let Err(error) = storage.save(&runtime_state) {
let command_error = HostCommandError::new(
"persist_failed",
format!(
"runtime state could not be saved to {}: {error}",
storage_path.display()
),
);
self.push_event(
StatusEventKind::Error,
Some(command_error.code.clone()),
command_error.message.clone(),
);
return Err(command_error);
}
Ok(())
}
fn push_event(
&mut self,
kind: StatusEventKind,
code: Option<String>,
message: String,
) {
self.snapshot.recent_events.insert(
0,
StatusEvent {
at_millis: self.elapsed_millis(),
kind,
code,
message,
},
);
@@ -533,6 +780,7 @@ fn unavailable_snapshot() -> HostSnapshot {
selected_pattern: "unavailable".to_string(),
selected_group: None,
transition_duration_ms: 0,
transition_style: SceneTransitionStyle::Snap,
},
engine: EngineSnapshot {
logic_hz: 0,
@@ -543,10 +791,11 @@ fn unavailable_snapshot() -> HostSnapshot {
dropped_frames: 0,
active_transition: None,
},
catalog: crate::control::CatalogSnapshot {
catalog: CatalogSnapshot {
patterns: Vec::new(),
presets: Vec::new(),
groups: Vec::new(),
creative_snapshots: Vec::new(),
},
active_scene: crate::control::ActiveSceneSnapshot {
preset_id: None,
@@ -563,6 +812,8 @@ fn unavailable_snapshot() -> HostSnapshot {
panels: Vec::new(),
recent_events: vec![StatusEvent {
at_millis: 0,
kind: StatusEventKind::Error,
code: Some("service_unavailable".to_string()),
message: "simulation service lock was unavailable".to_string(),
}],
}
@@ -668,13 +919,13 @@ mod tests {
#[test]
fn commands_update_scene_and_group() {
let service = SimulationHostService::new(sample_project());
service.send_command(HostCommand::SelectGroup {
let _ = service.send_command(HostCommand::SelectGroup {
group_id: Some("top_panels".to_string()),
});
service.send_command(HostCommand::RecallPreset {
let _ = service.send_command(HostCommand::RecallPreset {
preset_id: "mapping_walk_test".to_string(),
});
service.send_command(HostCommand::SetSceneParameter {
let _ = service.send_command(HostCommand::SetSceneParameter {
key: "speed".to_string(),
value: SceneParameterValue::Scalar(2.0),
});
@@ -688,4 +939,22 @@ mod tests {
.iter()
.any(|parameter| parameter.key == "speed"));
}
#[test]
fn unknown_group_returns_typed_error_and_warning_event() {
let service = SimulationHostService::new(sample_project());
let error = service
.send_command(HostCommand::SelectGroup {
group_id: Some("does_not_exist".to_string()),
})
.expect_err("unknown group should fail");
let snapshot = service.snapshot();
assert_eq!(error.code, "unknown_group");
assert!(snapshot
.recent_events
.iter()
.any(|event| event.kind == StatusEventKind::Warning
&& event.code.as_deref() == Some("unknown_group")));
}
}

View File

@@ -1,7 +1,8 @@
use infinity_config::{ColorOrder, LedDirection, PanelPosition, ValidationState};
use infinity_host::{
HostCommand, HostSnapshot, NodeConnectionState, PreviewSource, SceneParameterKind,
SceneParameterValue, SceneTransitionStyle, TestPatternKind,
CatalogSource, HostCommand, HostSnapshot, NodeConnectionState, PreviewSource,
SceneParameterKind, SceneParameterValue, SceneTransitionStyle, StatusEventKind,
TestPatternKind,
};
use serde::{Deserialize, Serialize};
@@ -15,12 +16,27 @@ pub struct ApiSnapshotResponse {
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiStateResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub state: ApiStateSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiPreviewResponse {
pub api_version: &'static str,
pub generated_at_millis: u64,
pub preview: ApiPreviewSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCatalogResponse {
pub api_version: &'static str,
pub patterns: Vec<ApiPatternCatalogEntry>,
pub presets: Vec<ApiPresetSummary>,
pub groups: Vec<ApiGroupSummary>,
pub creative_snapshots: Vec<ApiCreativeSnapshotSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -48,6 +64,7 @@ pub struct ApiCommandResponse {
pub accepted: bool,
pub request_id: Option<String>,
pub generated_at_millis: u64,
pub command_type: String,
pub summary: String,
}
@@ -98,6 +115,7 @@ pub enum ApiStreamMessage {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiEventNotice {
pub kind: ApiEventKind,
pub code: Option<String>,
pub message: String,
}
@@ -105,6 +123,8 @@ pub struct ApiEventNotice {
#[serde(rename_all = "snake_case")]
pub enum ApiEventKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -121,6 +141,7 @@ pub struct ApiGlobalState {
pub selected_pattern: String,
pub selected_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -198,6 +219,8 @@ pub struct ApiPresetSummary {
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -205,6 +228,18 @@ pub struct ApiGroupSummary {
pub group_id: String,
pub member_count: usize,
pub tags: Vec<String>,
pub source: ApiCatalogSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiCreativeSnapshotSummary {
pub snapshot_id: String,
pub label: Option<String>,
pub pattern_id: String,
pub target_group: Option<String>,
pub transition_duration_ms: u32,
pub transition_style: ApiTransitionStyle,
pub saved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -277,6 +312,13 @@ pub enum ApiParameterKind {
Text,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiCatalogSource {
BuiltIn,
RuntimeUser,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApiLedDirection {
@@ -336,11 +378,38 @@ pub enum ApiCommand {
SetTransitionDurationMs {
duration_ms: u32,
},
SetTransitionStyle {
style: ApiTransitionStyle,
},
TriggerPanelTest {
node_id: String,
panel_position: ApiPanelPosition,
pattern: ApiTestPattern,
},
SavePreset {
preset_id: String,
overwrite: bool,
},
SaveCreativeSnapshot {
snapshot_id: String,
label: Option<String>,
overwrite: bool,
},
RecallCreativeSnapshot {
snapshot_id: String,
},
UpsertGroup {
group_id: String,
tags: Vec<String>,
members: Vec<ApiPanelRef>,
overwrite: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApiPanelRef {
pub node_id: String,
pub panel_position: ApiPanelPosition,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -362,6 +431,26 @@ impl ApiSnapshotResponse {
}
}
impl ApiStateResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
state: ApiStateSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiPreviewResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
api_version: API_VERSION,
generated_at_millis: snapshot.generated_at_millis,
preview: ApiPreviewSnapshot::from_snapshot(snapshot),
}
}
}
impl ApiCatalogResponse {
pub fn from_snapshot(snapshot: &HostSnapshot) -> Self {
Self {
@@ -398,6 +487,8 @@ impl ApiCatalogResponse {
pattern_id: preset.pattern_id.clone(),
target_group: preset.target_group.clone(),
transition_duration_ms: preset.transition_duration_ms,
transition_style: map_transition_style(preset.transition_style),
source: map_catalog_source(preset.source),
})
.collect(),
groups: snapshot
@@ -408,6 +499,21 @@ impl ApiCatalogResponse {
group_id: group.group_id.clone(),
member_count: group.member_count,
tags: group.tags.clone(),
source: map_catalog_source(group.source),
})
.collect(),
creative_snapshots: snapshot
.catalog
.creative_snapshots
.iter()
.map(|snapshot| ApiCreativeSnapshotSummary {
snapshot_id: snapshot.snapshot_id.clone(),
label: snapshot.label.clone(),
pattern_id: snapshot.pattern_id.clone(),
target_group: snapshot.target_group.clone(),
transition_duration_ms: snapshot.transition_duration_ms,
transition_style: map_transition_style(snapshot.transition_style),
saved_at_unix_ms: snapshot.saved_at_unix_ms,
})
.collect(),
}
@@ -446,6 +552,7 @@ impl ApiStateSnapshot {
selected_pattern: snapshot.global.selected_pattern.clone(),
selected_group: snapshot.global.selected_group.clone(),
transition_duration_ms: snapshot.global.transition_duration_ms,
transition_style: map_transition_style(snapshot.global.transition_style),
},
engine: ApiEngineState {
logic_hz: snapshot.engine.logic_hz,
@@ -565,6 +672,9 @@ impl ApiCommandRequest {
ApiCommand::SetTransitionDurationMs { duration_ms } => {
Ok(HostCommand::SetTransitionDurationMs(duration_ms))
}
ApiCommand::SetTransitionStyle { style } => {
Ok(HostCommand::SetTransitionStyle(map_command_transition_style(style)))
}
ApiCommand::TriggerPanelTest {
node_id,
panel_position,
@@ -578,6 +688,42 @@ impl ApiCommandRequest {
ApiTestPattern::WalkingPixel106 => TestPatternKind::WalkingPixel106,
},
}),
ApiCommand::SavePreset {
preset_id,
overwrite,
} => Ok(HostCommand::SavePreset {
preset_id,
overwrite,
}),
ApiCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
} => Ok(HostCommand::SaveCreativeSnapshot {
snapshot_id,
label,
overwrite,
}),
ApiCommand::RecallCreativeSnapshot { snapshot_id } => {
Ok(HostCommand::RecallCreativeSnapshot { snapshot_id })
}
ApiCommand::UpsertGroup {
group_id,
tags,
members,
overwrite,
} => Ok(HostCommand::UpsertGroup {
group_id,
tags,
members: members
.into_iter()
.map(|member| infinity_host::PanelTarget {
node_id: member.node_id,
panel_position: map_command_panel_position(member.panel_position),
})
.collect(),
overwrite,
}),
}
}
@@ -653,6 +799,29 @@ fn map_transition_style(style: SceneTransitionStyle) -> ApiTransitionStyle {
}
}
fn map_command_transition_style(style: ApiTransitionStyle) -> SceneTransitionStyle {
match style {
ApiTransitionStyle::Snap => SceneTransitionStyle::Snap,
ApiTransitionStyle::Crossfade => SceneTransitionStyle::Crossfade,
ApiTransitionStyle::Chase => SceneTransitionStyle::Chase,
}
}
fn map_catalog_source(source: CatalogSource) -> ApiCatalogSource {
match source {
CatalogSource::BuiltIn => ApiCatalogSource::BuiltIn,
CatalogSource::RuntimeUser => ApiCatalogSource::RuntimeUser,
}
}
fn map_event_kind(kind: StatusEventKind) -> ApiEventKind {
match kind {
StatusEventKind::Info => ApiEventKind::Info,
StatusEventKind::Warning => ApiEventKind::Warning,
StatusEventKind::Error => ApiEventKind::Error,
}
}
fn map_parameter_kind(kind: SceneParameterKind) -> ApiParameterKind {
match kind {
SceneParameterKind::Scalar => ApiParameterKind::Scalar,
@@ -678,6 +847,24 @@ fn map_command_parameter_value(value: ApiParameterValue) -> SceneParameterValue
}
impl ApiCommand {
pub fn kind_label(&self) -> &'static str {
match self {
Self::SetBlackout { .. } => "set_blackout",
Self::SetMasterBrightness { .. } => "set_master_brightness",
Self::SelectPattern { .. } => "select_pattern",
Self::RecallPreset { .. } => "recall_preset",
Self::SelectGroup { .. } => "select_group",
Self::SetSceneParameter { .. } => "set_scene_parameter",
Self::SetTransitionDurationMs { .. } => "set_transition_duration_ms",
Self::SetTransitionStyle { .. } => "set_transition_style",
Self::TriggerPanelTest { .. } => "trigger_panel_test",
Self::SavePreset { .. } => "save_preset",
Self::SaveCreativeSnapshot { .. } => "save_creative_snapshot",
Self::RecallCreativeSnapshot { .. } => "recall_creative_snapshot",
Self::UpsertGroup { .. } => "upsert_group",
}
}
pub fn summary(&self) -> String {
match self {
Self::SetBlackout { enabled } => {
@@ -700,6 +887,9 @@ impl ApiCommand {
Self::SetTransitionDurationMs { duration_ms } => {
format!("transition duration set to {duration_ms} ms")
}
Self::SetTransitionStyle { style } => {
format!("transition style set to {}", style.label())
}
Self::TriggerPanelTest {
node_id,
panel_position,
@@ -710,6 +900,38 @@ impl ApiCommand {
node_id,
panel_position.label()
),
Self::SavePreset { preset_id, overwrite } => {
if *overwrite {
format!("preset overwritten: {preset_id}")
} else {
format!("preset saved: {preset_id}")
}
}
Self::SaveCreativeSnapshot {
snapshot_id,
overwrite,
..
} => {
if *overwrite {
format!("creative snapshot overwritten: {snapshot_id}")
} else {
format!("creative snapshot saved: {snapshot_id}")
}
}
Self::RecallCreativeSnapshot { snapshot_id } => {
format!("creative snapshot recalled: {snapshot_id}")
}
Self::UpsertGroup {
group_id,
overwrite,
..
} => {
if *overwrite {
format!("group updated: {group_id}")
} else {
format!("group saved: {group_id}")
}
}
}
}
}
@@ -724,6 +946,16 @@ impl ApiPanelPosition {
}
}
impl ApiTransitionStyle {
pub fn label(self) -> &'static str {
match self {
Self::Snap => "snap",
Self::Crossfade => "crossfade",
Self::Chase => "chase",
}
}
}
impl ApiTestPattern {
pub fn label(self) -> &'static str {
match self {
@@ -743,3 +975,13 @@ impl ApiErrorResponse {
}
}
}
impl From<infinity_host::StatusEvent> for ApiEventNotice {
fn from(event: infinity_host::StatusEvent) -> Self {
Self {
kind: map_event_kind(event.kind),
code: event.code,
message: event.message,
}
}
}

View File

@@ -11,16 +11,23 @@ struct Cli {
config: PathBuf,
#[arg(long, default_value = "127.0.0.1:9001")]
bind: String,
#[arg(long, default_value = "data/runtime_state.json")]
runtime_state: PathBuf,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let project = load_project(&cli.config)?;
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(project);
let service: Arc<dyn HostApiPort> =
SimulationHostService::try_spawn_shared_with_persistence(project, &cli.runtime_state)?;
let server = HostApiServer::bind(&cli.bind, service)?;
println!("Infinity Vis host API listening on http://{}", server.local_addr());
println!("Web UI available at http://{}/", server.local_addr());
println!(
"Runtime state persistence: {}",
cli.runtime_state.display()
);
loop {
thread::sleep(Duration::from_secs(60));

View File

@@ -1,7 +1,7 @@
use crate::dto::{
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse, ApiEventKind,
ApiEventNotice, ApiGroupListResponse, ApiPresetListResponse, ApiSnapshotResponse,
ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
ApiCatalogResponse, ApiCommandRequest, ApiCommandResponse, ApiErrorResponse,
ApiGroupListResponse, ApiPresetListResponse, ApiPreviewResponse, ApiSnapshotResponse,
ApiStateResponse, ApiStateSnapshot, ApiStreamEnvelope, ApiStreamMessage, API_VERSION,
};
use crate::websocket::{websocket_accept_value, write_text_frame};
use infinity_host::HostApiPort;
@@ -21,6 +21,13 @@ pub struct HostApiServer {
accept_thread: Option<JoinHandle<()>>,
}
#[derive(Debug)]
struct ApiRequestError {
status: u16,
code: String,
message: String,
}
impl HostApiServer {
pub fn bind(bind: &str, service: Arc<dyn HostApiPort>) -> io::Result<Self> {
let listener = TcpListener::bind(bind)?;
@@ -86,6 +93,14 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiSnapshotResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/state") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiStateResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/preview") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiPreviewResponse::from_snapshot(&snapshot))
}
("GET", "/api/v1/catalog") => {
let snapshot = service.snapshot();
respond_json(&mut stream, 200, &ApiCatalogResponse::from_snapshot(&snapshot))
@@ -102,9 +117,9 @@ fn handle_connection(mut stream: TcpStream, service: Arc<dyn HostApiPort>) -> io
Ok(()) => Ok(()),
Err(error) => respond_error(
&mut stream,
400,
"invalid_command",
format!("command request was rejected: {error}"),
error.status,
error.code,
error.message,
),
},
("GET", "/") => respond_text(
@@ -148,16 +163,29 @@ fn handle_command_post(
stream: &mut TcpStream,
request: HttpRequest,
service: Arc<dyn HostApiPort>,
) -> io::Result<()> {
) -> Result<(), ApiRequestError> {
let parsed = serde_json::from_slice::<ApiCommandRequest>(&request.body)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_request_json".to_string(),
message: format!("command request body could not be parsed: {error}"),
})?;
let request_id = parsed.request_id.clone();
let summary = parsed.summary();
let command_type = parsed.command.kind_label().to_string();
let command = parsed
.into_host_command()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
service.send_command(command);
let snapshot = service.snapshot();
.map_err(|error| ApiRequestError {
status: 400,
code: "invalid_command".to_string(),
message: error,
})?;
let outcome = service
.send_command(command)
.map_err(|error| ApiRequestError {
status: 400,
code: error.code,
message: error.message,
})?;
respond_json(
stream,
200,
@@ -165,10 +193,16 @@ fn handle_command_post(
api_version: API_VERSION,
accepted: true,
request_id,
generated_at_millis: snapshot.generated_at_millis,
summary,
generated_at_millis: outcome.generated_at_millis,
command_type,
summary: outcome.summary,
},
)
.map_err(|error| ApiRequestError {
status: 500,
code: "response_write_failed".to_string(),
message: error.to_string(),
})
}
fn handle_websocket(
@@ -223,10 +257,7 @@ fn handle_websocket(
&mut stream,
sequence,
event.at_millis,
ApiStreamMessage::Event(ApiEventNotice {
kind: ApiEventKind::Info,
message: event.message,
}),
ApiStreamMessage::Event(event.into()),
)?;
sequence += 1;
}
@@ -280,6 +311,7 @@ fn respond_text(
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK",
};
let response = format!(

View File

@@ -5,8 +5,9 @@ use serde_json::Value;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn sample_project() -> ProjectConfig {
ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml"))
@@ -14,7 +15,15 @@ fn sample_project() -> ProjectConfig {
}
fn start_server() -> HostApiServer {
let service: Arc<dyn HostApiPort> = SimulationHostService::spawn_shared(sample_project());
let service: Arc<dyn HostApiPort> = Arc::new(SimulationHostService::new(sample_project()));
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
fn start_server_with_runtime_state(path: &PathBuf) -> HostApiServer {
let service: Arc<dyn HostApiPort> = Arc::new(
SimulationHostService::try_new_with_persistence(sample_project(), path)
.expect("persistent service must initialize"),
);
HostApiServer::bind("127.0.0.1:0", service).expect("server must bind")
}
@@ -25,118 +34,183 @@ struct HttpResponse {
}
#[test]
fn root_serves_creative_console_shell() {
fn root_and_web_assets_target_the_versioned_api_contract() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/", None);
let html = send_http_request(server.local_addr(), "GET", "/", None);
let app_js = send_http_request(server.local_addr(), "GET", "/app.js", None);
assert_eq!(response.status_code, 200);
assert!(response
assert_eq!(html.status_code, 200);
assert!(html
.headers
.get("content-type")
.expect("content-type header")
.starts_with("text/html"));
assert!(response.body.contains("Infinity Vis / Creative Surface"));
assert!(response.body.contains("/app.js"));
assert!(html.body.contains("Preset Capture"));
assert!(html.body.contains("Creative Snapshots"));
assert!(html.body.contains("Event Stream"));
assert_eq!(app_js.status_code, 200);
assert!(app_js.body.contains("/api/v1/state"));
assert!(app_js.body.contains("/api/v1/preview"));
assert!(app_js.body.contains("save_preset"));
assert!(app_js.body.contains("save_creative_snapshot"));
server.shutdown();
}
#[test]
fn snapshot_endpoint_is_versioned_and_separates_state_and_preview() {
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
let server = start_server();
let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse");
assert_eq!(response.status_code, 200);
assert_eq!(body["api_version"], "v1");
assert_eq!(body["state"]["system"]["project_name"], "Infinity Vis");
assert_eq!(body["state"]["nodes"].as_array().map(Vec::len), Some(6));
assert_eq!(body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(body["state"]["active_scene"]["pattern_id"].is_string());
server.shutdown();
}
#[test]
fn catalog_presets_and_groups_endpoints_return_expected_lists() {
let server = start_server();
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let presets = send_http_request(server.local_addr(), "GET", "/api/v1/presets", None);
let groups = send_http_request(server.local_addr(), "GET", "/api/v1/groups", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let preset_body: Value = serde_json::from_str(&presets.body).expect("preset json");
let group_body: Value = serde_json::from_str(&groups.body).expect("group json");
assert_eq!(catalog.status_code, 200);
assert!(catalog_body["patterns"]
.as_array()
.expect("patterns array")
.iter()
.any(|pattern| pattern["pattern_id"] == "walking_pixel"));
assert!(preset_body["presets"]
.as_array()
.expect("presets array")
.iter()
.any(|preset| preset["preset_id"] == "ocean_gradient"));
assert!(group_body["groups"]
.as_array()
.expect("groups array")
.iter()
.any(|group| group["group_id"] == "top_panels"));
server.shutdown();
}
#[test]
fn command_endpoint_applies_state_changes_and_rejects_invalid_payload() {
let server = start_server();
let response = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-blackout",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
);
let response_body: Value =
serde_json::from_str(&response.body).expect("command response must parse");
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json");
assert_eq!(response.status_code, 200);
assert_eq!(response_body["accepted"], true);
assert_eq!(response_body["request_id"], "contract-blackout");
assert_eq!(snapshot_body["state"]["global"]["blackout"], true);
assert_eq!(state.status_code, 200);
assert_eq!(state_body["api_version"], "v1");
assert!(state_body.get("state").is_some());
assert!(state_body.get("preview").is_none());
assert_eq!(state_body["state"]["nodes"].as_array().map(Vec::len), Some(6));
let invalid = send_http_request(
server.local_addr(),
"POST",
"/api/v1/command",
Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#),
);
let invalid_body: Value =
serde_json::from_str(&invalid.body).expect("invalid response must parse");
assert_eq!(preview.status_code, 200);
assert_eq!(preview_body["api_version"], "v1");
assert!(preview_body.get("preview").is_some());
assert!(preview_body.get("state").is_none());
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["api_version"], "v1");
assert_eq!(invalid_body["error"]["code"], "invalid_command");
assert_eq!(snapshot.status_code, 200);
assert_eq!(snapshot_body["api_version"], "v1");
assert!(snapshot_body.get("state").is_some());
assert!(snapshot_body.get("preview").is_some());
server.shutdown();
}
#[test]
fn websocket_stream_emits_snapshot_preview_and_event_messages() {
fn command_flow_updates_group_parameters_transition_and_blackout() {
let server = start_server();
let responses = [
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"top_panels"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"speed","value":{"kind":"scalar","value":2.25}}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_style","payload":{"style":"chase"}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_transition_duration_ms","payload":{"duration_ms":320}}}"#,
),
send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"gradient"}}}"#,
),
];
for response in responses {
assert_eq!(response.status_code, 200);
}
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(state_body["state"]["global"]["selected_group"], "top_panels");
assert_eq!(state_body["state"]["global"]["transition_style"], "chase");
assert_eq!(state_body["state"]["global"]["transition_duration_ms"], 320);
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "gradient");
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "speed" && parameter["value"]["value"] == 2.25));
let blackout = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_blackout","payload":{"enabled":true}}}"#,
);
let blackout_body: Value = serde_json::from_str(&blackout.body).expect("blackout json");
assert_eq!(blackout.status_code, 200);
assert_eq!(blackout_body["command_type"], "set_blackout");
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
assert!(preview_body["preview"]["panels"]
.as_array()
.expect("preview panels")
.iter()
.all(|panel| panel["energy_percent"] == 0 && panel["source"] == "blackout"));
server.shutdown();
}
#[test]
fn presets_and_creative_snapshots_persist_across_restart() {
let runtime_state_path = unique_runtime_state_path("persistence");
let server = start_server_with_runtime_state(&runtime_state_path);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_group","payload":{"group_id":"bottom_panels"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"select_pattern","payload":{"pattern_id":"noise"}}}"#,
);
let _ = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_scene_parameter","payload":{"key":"grain","value":{"kind":"scalar","value":0.93}}}}"#,
);
let save_preset = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_preset","payload":{"preset_id":"user_noise_floor","overwrite":false}}}"#,
);
let save_snapshot = send_command_json(
server.local_addr(),
r#"{"command":{"type":"save_creative_snapshot","payload":{"snapshot_id":"variant_floor","label":"Variant Floor","overwrite":false}}}"#,
);
assert_eq!(save_preset.status_code, 200);
assert_eq!(save_snapshot.status_code, 200);
server.shutdown();
let restarted = start_server_with_runtime_state(&runtime_state_path);
let catalog = send_http_request(restarted.local_addr(), "GET", "/api/v1/catalog", None);
let state = send_http_request(restarted.local_addr(), "GET", "/api/v1/state", None);
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert!(catalog_body["presets"]
.as_array()
.expect("preset array")
.iter()
.any(|preset| preset["preset_id"] == "user_noise_floor" && preset["source"] == "runtime_user"));
assert!(catalog_body["creative_snapshots"]
.as_array()
.expect("snapshot array")
.iter()
.any(|snapshot| snapshot["snapshot_id"] == "variant_floor"));
assert_eq!(state_body["state"]["active_scene"]["pattern_id"], "noise");
assert_eq!(state_body["state"]["active_scene"]["target_group"], "bottom_panels");
assert!(state_body["state"]["active_scene"]["parameters"]
.as_array()
.expect("parameter array")
.iter()
.any(|parameter| parameter["key"] == "grain" && parameter["value"]["value"] == 0.93));
restarted.shutdown();
let _ = std::fs::remove_file(runtime_state_path);
}
#[test]
fn websocket_stream_reports_event_codes_and_command_failures_stay_typed() {
let server = start_server();
let mut stream = open_websocket(server.local_addr());
@@ -148,43 +222,97 @@ fn websocket_stream_emits_snapshot_preview_and_event_messages() {
let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame");
assert_eq!(second_payload["message"]["type"], "preview");
let _ = send_http_request(
let invalid = send_command_json(
server.local_addr(),
"POST",
"/api/v1/command",
Some(
r#"{
"request_id": "contract-event",
"command": {
"type": "set_blackout",
"payload": {
"enabled": true
}
}
}"#,
),
r#"{"command":{"type":"recall_creative_snapshot","payload":{"snapshot_id":"does_not_exist"}}}"#,
);
let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid json");
assert_eq!(invalid.status_code, 400);
assert_eq!(invalid_body["error"]["code"], "unknown_creative_snapshot");
let mut saw_event = false;
let mut saw_warning = false;
for _ in 0..8 {
let frame = read_websocket_text_frame(&mut stream);
let payload: Value = serde_json::from_str(&frame).expect("ws event frame");
let payload: Value = serde_json::from_str(&frame).expect("ws frame");
if payload["message"]["type"] == "event" {
saw_event = true;
saw_warning = true;
assert_eq!(payload["message"]["payload"]["kind"], "warning");
assert_eq!(payload["message"]["payload"]["code"], "unknown_creative_snapshot");
assert!(payload["message"]["payload"]["message"]
.as_str()
.expect("event message")
.contains("blackout"));
.contains("does_not_exist"));
break;
}
}
assert!(saw_event, "expected websocket event after command");
assert!(saw_warning, "expected warning event after failed command");
let _ = stream.shutdown(Shutdown::Both);
server.shutdown();
}
#[test]
#[ignore = "longer load-oriented sequence for platform hardening"]
fn load_sequence_keeps_state_preview_and_catalog_consistent() {
let server = start_server();
let patterns = ["solid_color", "gradient", "chase", "pulse", "noise"];
let groups = [None, Some("top_panels"), Some("middle_panels"), Some("bottom_panels")];
for index in 0..80 {
let pattern = patterns[index % patterns.len()];
let group = groups[index % groups.len()];
let brightness = ((index % 10) as f32) / 10.0;
let speed = 0.5 + (index % 6) as f32 * 0.25;
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_pattern","payload":{{"pattern_id":"{pattern}"}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_master_brightness","payload":{{"value":{brightness}}}}}}}"#
),
);
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"set_scene_parameter","payload":{{"key":"speed","value":{{"kind":"scalar","value":{speed}}}}}}}}}"#
),
);
let group_json = match group {
Some(group_id) => format!(r#""{group_id}""#),
None => "null".to_string(),
};
let _ = send_command_json(
server.local_addr(),
&format!(
r#"{{"command":{{"type":"select_group","payload":{{"group_id":{group_json}}}}}}}"#
),
);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let preview = send_http_request(server.local_addr(), "GET", "/api/v1/preview", None);
let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
let preview_body: Value = serde_json::from_str(&preview.body).expect("preview json");
let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json");
assert_eq!(state_body["state"]["panels"].as_array().map(Vec::len), Some(18));
assert_eq!(preview_body["preview"]["panels"].as_array().map(Vec::len), Some(18));
assert!(catalog_body["patterns"].as_array().map(Vec::len).unwrap_or_default() >= 5);
}
server.shutdown();
}
fn send_command_json(addr: SocketAddr, body: &str) -> HttpResponse {
send_http_request(addr, "POST", "/api/v1/command", Some(body))
}
fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse {
let body = body.unwrap_or("");
let request = format!(
@@ -293,3 +421,11 @@ fn read_websocket_text_frame(stream: &mut TcpStream) -> String {
stream.read_exact(&mut payload).expect("frame payload");
String::from_utf8(payload).expect("frame utf8")
}
fn unique_runtime_state_path(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_millis();
std::env::temp_dir().join(format!("infinity_vis_{label}_{millis}.json"))
}

View File

@@ -69,7 +69,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
});
if ui.add(blackout_button).clicked() {
service.send_command(HostCommand::SetBlackout(!blackout));
let _ = service.send_command(HostCommand::SetBlackout(!blackout));
}
let mut brightness = snapshot.global.master_brightness;
@@ -80,7 +80,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
)
.changed()
{
service.send_command(HostCommand::SetMasterBrightness(brightness));
let _ = service.send_command(HostCommand::SetMasterBrightness(brightness));
}
let selected_pattern = snapshot.global.selected_pattern.clone();
@@ -93,7 +93,7 @@ fn draw_global_controls(ui: &mut egui::Ui, snapshot: &HostSnapshot, service: &Ar
.selectable_label(selected_pattern == *pattern, pattern)
.clicked()
{
service.send_command(HostCommand::SelectPattern(pattern.clone()));
let _ = service.send_command(HostCommand::SelectPattern(pattern.clone()));
}
}
});
@@ -183,7 +183,7 @@ fn draw_panel_mapping(
}
});
if ui.button("Walk 106").clicked() {
service.send_command(HostCommand::TriggerPanelTest {
let _ = service.send_command(HostCommand::TriggerPanelTest {
target: PanelTarget {
node_id: panel.target.node_id.clone(),
panel_position: panel.target.panel_position.clone(),

View File

@@ -55,6 +55,13 @@ Every surface must talk to the same host API:
- CLI inspection
- future grandMA adapter
The host core now also carries a runtime show store and persistence layer for:
- saved presets
- runtime user groups
- active scene state
- creative snapshots and variants
The current software-first implementation uses a simulation-backed host API so looks, presets, parameters, and grouping can be developed before real node activation.
## Modes
@@ -104,5 +111,5 @@ The codebase deliberately blocks activation when these remain unresolved:
1. Expand creative authoring on top of the now-versioned host API and web UI
2. Keep the engineering GUI focused on mapping, diagnostics, topology, and admin
3. Implement transport adapters without coupling them to any single frontend
4. Add future external show-control bridges such as grandMA on the same API boundary
4. Add future external show-control bridges such as grandMA on the same API boundary and generic adapter interface
5. Keep hardware activation behind explicit later validation gates

View File

@@ -12,13 +12,13 @@ Suggested commands:
```powershell
cargo test
cargo run -p infinity_host -- snapshot --config config/project.example.toml
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
cargo run -p infinity_host_ui
cargo run -p infinity_host -- validate --config config/project.example.toml --mode structural
cargo run -p infinity_host -- plan-boot-scene --config config/project.example.toml --preset-id safe_static_blue
```
The host API server now exposes the common software-first control boundary over HTTP and WebSocket. The creative web UI is served directly from the same process at `http://127.0.0.1:9001/`.
The host API server now exposes the common software-first control boundary over HTTP and WebSocket. The creative web UI is served directly from the same process at `http://127.0.0.1:9001/`. Runtime creative data such as saved presets, groups, active scene state, and creative snapshots are persisted to `data/runtime_state.json` by default.
The native engineering UI and the CLI snapshot continue to run against the same simulation-backed host core so looks, presets, grouping, and parameter flow can be exercised before transport and firmware integration are complete.

View File

@@ -2,58 +2,107 @@
## Purpose
The host API is the stable external boundary for:
The host API is the stable external product boundary for:
- the creative web UI
- the existing engineering GUI
- future external show-control adapters such as grandMA
- the native engineering GUI
- future remote operator clients
- later external show-control adapters such as a grandMA bridge
The core rule stays unchanged:
The realtime rule remains strict:
- the API is a control and observation layer
- the realtime engine remains the timing authority
- no surface is allowed to become the LED clock
- the host core remains the timing authority
- no frontend or external adapter is allowed to become the LED clock
## Current Implementation
## Runtime Components
Runtime pieces:
Core and API implementation:
- `crates/infinity_host/src/control.rs`
- `crates/infinity_host/src/scene.rs`
- `crates/infinity_host/src/show_store.rs`
- `crates/infinity_host/src/simulation.rs`
- `crates/infinity_host/src/external_control.rs`
- `crates/infinity_host_api/src/dto.rs`
- `crates/infinity_host_api/src/server.rs`
The network-facing server is started with:
Server startup:
```powershell
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
Creative web UI V1 is served by the same process at:
Creative web UI V1 is served by the same process:
```text
http://127.0.0.1:9001/
```
## Versioning
## Versioning Policy
- HTTP and WebSocket routes are versioned under `/api/v1`
- responses include `api_version: "v1"`
- the external DTOs are intentionally not a direct 1:1 dump of internal core structs
The current public contract is `v1`.
## Endpoint Contract
Rules:
### GET `/api/v1/snapshot`
- all public HTTP and WebSocket routes are namespaced under `/api/v1`
- every response body carries `api_version: "v1"`
- additive fields are allowed inside `v1`
- semantic breaking changes require a new version namespace
- external consumers must treat undocumented internal-only fields as unstable and ignore them
Returns the current state and preview in one response.
## Stable External Models
Stable external response families:
- command response
- state snapshot
- preview snapshot
- combined snapshot
- catalog
- event stream
- typed error object
Stable external command families:
- global control
- pattern and preset selection
- group targeting
- scene parameter updates
- transition configuration
- preset persistence
- creative snapshot persistence and recall
- panel test trigger
## Internal Versus External Fields
External and stable in `v1`:
- every field defined in `crates/infinity_host_api/src/dto.rs`
- route names and payload shapes documented below
- error object shape `{ api_version, error: { code, message } }`
- event stream envelope shape `{ api_version, sequence, generated_at_millis, message }`
Internal and not part of the API contract:
- the exact shape of `HostSnapshot` in `crates/infinity_host/src/control.rs`
- simulation-only storage layout in `data/runtime_state.json`
- internal event history buffering size
- internal scene library structures in `show_store.rs`
- engineering-GUI-specific rendering or polling behavior
## HTTP Endpoints
### GET `/api/v1/state`
Returns only the stable state snapshot.
Example:
```json
{
"api_version": "v1",
"generated_at_millis": 241,
"generated_at_millis": 512,
"state": {
"system": {
"project_name": "Infinity Vis",
@@ -63,186 +112,42 @@ Example:
"global": {
"blackout": false,
"master_brightness": 0.2,
"selected_pattern": "solid_color",
"selected_group": null,
"transition_duration_ms": 150
"selected_pattern": "gradient",
"selected_group": "top_panels",
"transition_duration_ms": 320,
"transition_style": "chase"
},
"engine": {
"logic_hz": 120,
"frame_hz": 60,
"preview_hz": 15,
"uptime_ms": 241,
"frame_index": 14,
"uptime_ms": 512,
"frame_index": 30,
"dropped_frames": 0,
"active_transition": null
},
"active_scene": {
"preset_id": null,
"pattern_id": "solid_color",
"seed": 100,
"palette": [
"#ffffff"
],
"parameters": [],
"target_group": null,
"blackout": false
"active_transition": {
"style": "chase",
"from_pattern_id": "solid_color",
"to_pattern_id": "gradient",
"duration_ms": 320,
"progress": 0.28
}
},
}
}
}
```
### GET `/api/v1/preview`
Returns only the stable preview snapshot.
Example:
```json
{
"api_version": "v1",
"generated_at_millis": 512,
"preview": {
"generated_at_millis": 241,
"panels": [
{
"node_id": "node-01",
"panel_position": "top",
"representative_color_hex": "#33CCFF",
"sample_led_hex": [
"#33CCFF",
"#28A3CC",
"#1E7A99"
],
"energy_percent": 28,
"source": "scene"
}
]
}
}
```
### GET `/api/v1/catalog`
Returns the creative catalog:
- patterns
- presets
- groups
### GET `/api/v1/presets`
Returns only preset summaries.
Example:
```json
{
"api_version": "v1",
"presets": [
{
"preset_id": "ocean_gradient",
"pattern_id": "gradient",
"target_group": null,
"transition_duration_ms": 320
}
]
}
```
### GET `/api/v1/groups`
Returns only group summaries.
Example:
```json
{
"api_version": "v1",
"groups": [
{
"group_id": "top_panels",
"member_count": 6,
"tags": [
"row",
"top"
]
}
]
}
```
### POST `/api/v1/command`
Accepts a versioned command envelope.
Example request:
```json
{
"request_id": "web-1713352662000",
"command": {
"type": "set_master_brightness",
"payload": {
"value": 0.42
}
}
}
```
Example response:
```json
{
"api_version": "v1",
"accepted": true,
"request_id": "web-1713352662000",
"generated_at_millis": 522,
"summary": "master brightness set to 42%"
}
```
Errors use a stable error object:
```json
{
"api_version": "v1",
"error": {
"code": "invalid_command",
"message": "command request was rejected: missing field `enabled`"
}
}
```
### WS `/api/v1/stream`
The WebSocket stream emits envelopes with a monotonic sequence and a typed payload.
Stream message types:
- `snapshot`
- `preview`
- `event`
Example snapshot envelope:
```json
{
"api_version": "v1",
"sequence": 17,
"generated_at_millis": 875,
"message": {
"type": "snapshot",
"payload": {
"global": {
"blackout": false,
"master_brightness": 0.35,
"selected_pattern": "gradient",
"selected_group": "top_panels",
"transition_duration_ms": 320
}
}
}
}
```
Example preview envelope:
```json
{
"api_version": "v1",
"sequence": 18,
"generated_at_millis": 875,
"message": {
"type": "preview",
"payload": {
"generated_at_millis": 875,
"generated_at_millis": 512,
"panels": [
{
"node_id": "node-01",
@@ -258,11 +163,125 @@ Example preview envelope:
}
]
}
}
```
### GET `/api/v1/snapshot`
Returns the convenience composition of `state` plus `preview`.
This route exists for lightweight clients and debugging. Consumers that want strict separation should prefer `GET /api/v1/state` and `GET /api/v1/preview`.
### GET `/api/v1/catalog`
Returns the stable creative library:
- patterns
- presets
- groups
- creative snapshots
Example preset summary:
```json
{
"preset_id": "ocean_gradient",
"pattern_id": "gradient",
"target_group": null,
"transition_duration_ms": 320,
"transition_style": "crossfade",
"source": "built_in"
}
```
Example creative snapshot summary:
```json
{
"snapshot_id": "variant_floor",
"label": "Variant Floor",
"pattern_id": "noise",
"target_group": "bottom_panels",
"transition_duration_ms": 220,
"transition_style": "chase",
"saved_at_unix_ms": 1760000000000
}
```
### GET `/api/v1/presets`
Returns only preset summaries.
### GET `/api/v1/groups`
Returns only group summaries.
### POST `/api/v1/command`
Accepts one versioned command envelope.
Example request:
```json
{
"request_id": "web-1713352662000",
"command": {
"type": "save_creative_snapshot",
"payload": {
"snapshot_id": "variant_floor",
"label": "Variant Floor",
"overwrite": false
}
}
}
```
Example event envelope:
Example response:
```json
{
"api_version": "v1",
"accepted": true,
"request_id": "web-1713352662000",
"generated_at_millis": 522,
"command_type": "save_creative_snapshot",
"summary": "creative snapshot saved: variant_floor"
}
```
## Stable Error Object
All API failures return:
```json
{
"api_version": "v1",
"error": {
"code": "unknown_creative_snapshot",
"message": "creative snapshot 'does_not_exist' does not exist"
}
}
```
Stable `v1` error families currently include:
- `invalid_request_json`
- `invalid_command`
- `unknown_group`
- `unknown_preset`
- `unknown_creative_snapshot`
- `preset_exists`
- `snapshot_exists`
- `group_exists`
- `persist_failed`
- `missing_websocket_key`
- `not_found`
## WebSocket Event Stream
### WS `/api/v1/stream`
The stream emits a typed envelope with a monotonic sequence counter:
```json
{
@@ -272,16 +291,29 @@ Example event envelope:
"message": {
"type": "event",
"payload": {
"kind": "info",
"message": "preset recalled: ocean_gradient"
"kind": "warning",
"code": "unknown_creative_snapshot",
"message": "creative snapshot 'does_not_exist' does not exist"
}
}
}
```
## Supported Commands
Stable message types:
The current API command set covers:
- `snapshot`
- `preview`
- `event`
Stable event kinds:
- `info`
- `warning`
- `error`
## Guaranteed Commands In `v1`
Guaranteed control commands:
- `set_blackout`
- `set_master_brightness`
@@ -290,53 +322,76 @@ The current API command set covers:
- `select_group`
- `set_scene_parameter`
- `set_transition_duration_ms`
- `set_transition_style`
- `trigger_panel_test`
This is intentionally enough for:
Guaranteed persistence and creative-library commands:
- creative look development in the web UI
- engineering test triggers in the native GUI
- future external show-control translation layers
- `save_preset`
- `save_creative_snapshot`
- `recall_creative_snapshot`
- `upsert_group`
## Web UI V1
## Persistence Behavior
The first creative web UI is intentionally limited to:
The simulation-backed host service now persists runtime-facing creative data to `data/runtime_state.json` by default.
- pattern selection
- preset recall
- group selection
- global brightness
- blackout
- transition duration
- scene parameter controls driven from the API schema
- panel preview
- snapshot display
- event feed
Persisted data includes:
It does not absorb mapping, topology, or hardware-diagnostic workflows. Those stay in the native engineering UI.
- active scene
- global blackout and brightness state
- transition duration and style
- runtime user presets
- runtime user groups
- creative snapshots and variants
## Contract Tests
This persistence file is an internal runtime artifact, not the public API contract.
The API contract is currently verified in:
## External Show Control Adapter Boundary
The generic internal adapter surface lives in:
- `crates/infinity_host/src/external_control.rs`
Key rule:
- future adapters may only translate external intent into the defined host command surface
- they must not reach into simulation internals, UI state, or hardware driver details directly
## Contract And Integration Coverage
Current software-side hardening lives in:
- `crates/infinity_host_api/tests/contract.rs`
- `crates/infinity_host/src/show_store.rs` tests
- `crates/infinity_host/src/simulation.rs` tests
Covered paths:
Covered flows include:
- root web shell
- `GET /api/v1/snapshot`
- `GET /api/v1/catalog`
- `GET /api/v1/presets`
- `GET /api/v1/groups`
- `POST /api/v1/command`
- `WS /api/v1/stream`
- state, preview, snapshot, catalog, presets, and groups endpoints
- command success and typed command failure
- WebSocket snapshot, preview, and event messages
- group targeting
- parameter updates
- transition configuration
- blackout
- preset save
- creative snapshot save and recall
- persistence across restart
- a longer ignored load-oriented sequence for platform hardening
## Future Direction
## Web UI Scope
Next adapters should be built on this boundary instead of reaching into the host core directly.
The current web UI intentionally focuses on creative use:
That includes:
- pattern and preset selection
- group targeting
- transition configuration
- scene parameters
- preset save and overwrite
- creative snapshot save and recall
- preview
- raw snapshot display
- filterable event feed
- a richer web authoring surface
- remote operator clients
- a grandMA bridge that translates external show control into host API commands
Mapping, topology diagnostics, panel-test administration, and low-level node status remain primarily in the native engineering GUI.

View File

@@ -1,9 +1,8 @@
(function () {
const apiState = {
snapshot: null,
stateResponse: null,
previewResponse: null,
catalog: null,
presets: [],
groups: [],
events: [],
ws: null,
commandTimers: new Map(),
@@ -18,15 +17,27 @@
patternSelect: document.getElementById("pattern-select"),
transitionSlider: document.getElementById("transition-slider"),
transitionValue: document.getElementById("transition-value"),
transitionStyleSelect: document.getElementById("transition-style-select"),
brightnessSlider: document.getElementById("brightness-slider"),
brightnessValue: document.getElementById("brightness-value"),
blackoutButton: document.getElementById("blackout-button"),
presetList: document.getElementById("preset-list"),
presetIdInput: document.getElementById("preset-id-input"),
presetOverwriteInput: document.getElementById("preset-overwrite-input"),
savePresetButton: document.getElementById("save-preset-button"),
groupFilterInput: document.getElementById("group-filter-input"),
groupList: document.getElementById("group-list"),
snapshotIdInput: document.getElementById("snapshot-id-input"),
snapshotLabelInput: document.getElementById("snapshot-label-input"),
snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"),
saveSnapshotButton: document.getElementById("save-snapshot-button"),
snapshotList: document.getElementById("snapshot-list"),
sceneParams: document.getElementById("scene-params"),
previewGrid: document.getElementById("preview-grid"),
summaryCards: document.getElementById("summary-cards"),
snapshotJson: document.getElementById("snapshot-json"),
eventKindFilter: document.getElementById("event-kind-filter"),
eventSearchFilter: document.getElementById("event-search-filter"),
eventList: document.getElementById("event-list"),
};
@@ -49,12 +60,19 @@
dom.transitionSlider.addEventListener("input", (event) => {
const value = Number(event.target.value);
dom.transitionValue.textContent = `${value} ms`;
debounceCommand("transition", {
debounceCommand("transition-duration", {
type: "set_transition_duration_ms",
payload: { duration_ms: value },
});
});
dom.transitionStyleSelect.addEventListener("change", (event) => {
sendCommand({
type: "set_transition_style",
payload: { style: event.target.value },
});
});
dom.brightnessSlider.addEventListener("input", (event) => {
const value = Number(event.target.value);
dom.brightnessValue.textContent = `${Math.round(value * 100)}%`;
@@ -65,36 +83,83 @@
});
dom.blackoutButton.addEventListener("click", () => {
const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false);
const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false);
sendCommand({
type: "set_blackout",
payload: { enabled },
});
});
dom.savePresetButton.addEventListener("click", async () => {
const presetId = dom.presetIdInput.value.trim();
if (!presetId) {
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "warning",
code: "preset_id_required",
message: "Preset ID is required before saving.",
});
return;
}
await sendCommand({
type: "save_preset",
payload: {
preset_id: presetId,
overwrite: dom.presetOverwriteInput.checked,
},
});
});
dom.saveSnapshotButton.addEventListener("click", async () => {
const snapshotId = dom.snapshotIdInput.value.trim();
if (!snapshotId) {
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "warning",
code: "snapshot_id_required",
message: "Snapshot ID is required before saving a creative variant.",
});
return;
}
await sendCommand({
type: "save_creative_snapshot",
payload: {
snapshot_id: snapshotId,
label: dom.snapshotLabelInput.value.trim() || null,
overwrite: dom.snapshotOverwriteInput.checked,
},
});
});
dom.groupFilterInput.addEventListener("input", () => renderGroups());
dom.eventKindFilter.addEventListener("change", () => renderEvents());
dom.eventSearchFilter.addEventListener("input", () => renderEvents());
}
async function refreshAll() {
setConnectionState("connecting", "loading");
try {
const [snapshot, catalog, presets, groups] = await Promise.all([
fetchJson("/api/v1/snapshot"),
const [stateResponse, previewResponse, catalog] = await Promise.all([
fetchJson("/api/v1/state"),
fetchJson("/api/v1/preview"),
fetchJson("/api/v1/catalog"),
fetchJson("/api/v1/presets"),
fetchJson("/api/v1/groups"),
]);
apiState.snapshot = snapshot;
apiState.stateResponse = stateResponse;
apiState.previewResponse = previewResponse;
apiState.catalog = catalog;
apiState.presets = presets.presets || catalog.presets || [];
apiState.groups = groups.groups || catalog.groups || [];
renderAll();
setConnectionState("online", "HTTP snapshot synced");
setConnectionState("online", "HTTP sync");
} catch (error) {
console.error(error);
setConnectionState("offline", "snapshot fetch failed");
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "error",
code: "http_refresh_failed",
message: `HTTP refresh failed: ${error.message}`,
});
}
@@ -110,6 +175,8 @@
setConnectionState("online", "stream connected");
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "info",
code: "stream_connected",
message: "WebSocket stream connected",
});
});
@@ -123,6 +190,8 @@
setConnectionState("offline", "stream disconnected");
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "warning",
code: "stream_reconnect",
message: "WebSocket stream closed, retrying",
});
window.setTimeout(connectStream, 1500);
@@ -139,26 +208,22 @@
return;
}
if (!apiState.snapshot) {
apiState.snapshot = {
if (message.type === "snapshot") {
apiState.stateResponse = {
api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis,
state: null,
preview: null,
state: message.payload,
};
}
if (message.type === "snapshot") {
apiState.snapshot.api_version = envelope.api_version;
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
apiState.snapshot.state = message.payload;
renderState();
return;
}
if (message.type === "preview") {
apiState.snapshot.preview = message.payload;
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
apiState.previewResponse = {
api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis,
preview: message.payload,
};
renderPreview();
renderSnapshotJson();
return;
@@ -167,6 +232,8 @@
if (message.type === "event") {
pushEvent({
at: `${envelope.generated_at_millis} ms`,
kind: message.payload.kind || "info",
code: message.payload.code || null,
message: message.payload.message,
});
}
@@ -184,15 +251,21 @@
});
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "info",
code: response.command_type,
message: response.summary,
});
await refreshAll();
return response;
} catch (error) {
console.error(error);
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "error",
code: "command_failed",
message: `Command failed: ${error.message}`,
});
return null;
}
}
@@ -232,17 +305,16 @@
}
function renderState() {
if (!apiState.snapshot?.state) {
const state = apiState.stateResponse?.state;
if (!state) {
return;
}
const snapshot = apiState.snapshot;
const state = snapshot.state;
const global = state.global;
const scene = state.active_scene;
dom.projectName.textContent = state.system.project_name;
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${snapshot.api_version}`;
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`;
dom.patternSelect.innerHTML = "";
(apiState.catalog?.patterns || []).forEach((pattern) => {
@@ -255,22 +327,23 @@
dom.transitionSlider.value = String(global.transition_duration_ms);
dom.transitionValue.textContent = `${global.transition_duration_ms} ms`;
dom.transitionStyleSelect.value = global.transition_style;
dom.brightnessSlider.value = String(global.master_brightness);
dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`;
dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout";
dom.blackoutButton.classList.toggle("is-active", global.blackout);
renderPresetButtons(scene, global);
renderGroupButtons(global);
renderPresets(scene);
renderGroups(global);
renderCreativeSnapshots();
renderSceneParameters(scene);
renderSummaryCards(state, snapshot.generated_at_millis);
renderSummaryCards(state);
renderSnapshotJson();
dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`;
}
function renderPresetButtons(scene) {
function renderPresets(scene) {
dom.presetList.innerHTML = "";
const presets = apiState.presets || [];
const presets = apiState.catalog?.presets || [];
if (!presets.length) {
dom.presetList.innerHTML = '<div class="empty-state">No presets available.</div>';
return;
@@ -283,7 +356,7 @@
button.classList.toggle("active", scene.preset_id === preset.preset_id);
button.innerHTML = `
<strong>${preset.preset_id}</strong>
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_duration_ms} ms</div>
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_style} / ${preset.source}</div>
`;
button.addEventListener("click", () =>
sendCommand({
@@ -295,8 +368,18 @@
});
}
function renderGroupButtons(global) {
function renderGroups(global) {
dom.groupList.innerHTML = "";
const filterValue = dom.groupFilterInput.value.trim().toLowerCase();
const groups = (apiState.catalog?.groups || []).filter((group) => {
if (!filterValue) {
return true;
}
return (
group.group_id.toLowerCase().includes(filterValue) ||
(group.tags || []).some((tag) => tag.toLowerCase().includes(filterValue))
);
});
const allButton = document.createElement("button");
allButton.type = "button";
@@ -311,14 +394,22 @@
);
dom.groupList.appendChild(allButton);
(apiState.groups || []).forEach((group) => {
if (!groups.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "No groups match the current filter.";
dom.groupList.appendChild(empty);
return;
}
groups.forEach((group) => {
const button = document.createElement("button");
button.type = "button";
button.className = "group-button";
button.classList.toggle("active", group.group_id === global.selected_group);
button.innerHTML = `
<strong>${group.group_id}</strong>
<div class="pill-subtext">${group.member_count} members</div>
<div class="pill-subtext">${group.member_count} members / ${group.source}</div>
`;
button.addEventListener("click", () =>
sendCommand({
@@ -330,6 +421,43 @@
});
}
function renderCreativeSnapshots() {
dom.snapshotList.innerHTML = "";
const snapshots = apiState.catalog?.creative_snapshots || [];
if (!snapshots.length) {
dom.snapshotList.innerHTML =
'<div class="empty-state">No creative snapshots saved yet.</div>';
return;
}
snapshots.forEach((snapshot) => {
const card = document.createElement("article");
card.className = "snapshot-card";
card.innerHTML = `
<div class="snapshot-card-header">
<div>
<strong>${snapshot.label || snapshot.snapshot_id}</strong>
<div class="preview-meta">${snapshot.snapshot_id}</div>
</div>
<button type="button" class="ghost-button">Recall</button>
</div>
<div class="snapshot-meta-row">
<span class="meta-chip">${snapshot.pattern_id}</span>
<span class="meta-chip">${snapshot.transition_style}</span>
<span class="meta-chip">${snapshot.transition_duration_ms} ms</span>
<span class="meta-chip">${snapshot.target_group || "all_panels"}</span>
</div>
`;
card.querySelector("button").addEventListener("click", () =>
sendCommand({
type: "recall_creative_snapshot",
payload: { snapshot_id: snapshot.snapshot_id },
})
);
dom.snapshotList.appendChild(card);
});
}
function renderSceneParameters(scene) {
dom.sceneParams.innerHTML = "";
const parameters = scene.parameters || [];
@@ -417,7 +545,7 @@
}
function renderPreview() {
const preview = apiState.snapshot?.preview;
const preview = apiState.previewResponse?.preview;
dom.previewGrid.innerHTML = "";
if (!preview?.panels?.length) {
dom.previewGrid.innerHTML =
@@ -425,6 +553,7 @@
return;
}
dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`;
const panels = [...preview.panels].sort(comparePreviewPanels);
panels.forEach((panel) => {
const card = document.createElement("article");
@@ -439,6 +568,7 @@
<strong>${panel.energy_percent}%</strong>
</div>
<div class="preview-swatch"></div>
<div class="energy-bar"><span style="--energy-width: ${panel.energy_percent}%"></span></div>
<div class="sample-row">
${panel.sample_led_hex
.map(
@@ -452,11 +582,12 @@
});
}
function renderSummaryCards(state, generatedAtMillis) {
function renderSummaryCards(state) {
const scene = state.active_scene;
const global = state.global;
const engine = state.engine;
const nodeStats = summarizeNodes(state.nodes || []);
const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length;
const cards = [
{
@@ -467,11 +598,11 @@
{
label: "Group Target",
value: scene.target_group || "all_panels",
detail: `${(apiState.groups || []).length} groups available`,
detail: `${(apiState.catalog?.groups || []).length} groups available`,
},
{
label: "Transition",
value: `${global.transition_duration_ms} ms`,
value: `${global.transition_style} / ${global.transition_duration_ms} ms`,
detail: engine.active_transition
? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%`
: "idle",
@@ -492,9 +623,9 @@
detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`,
},
{
label: "Preview Timestamp",
value: `${generatedAtMillis} ms`,
detail: `${state.system.schema_version} schema`,
label: "Creative Snapshots",
value: `${creativeSnapshotCount}`,
detail: `${(apiState.catalog?.presets || []).length} presets in library`,
},
];
@@ -512,28 +643,55 @@
}
function renderSnapshotJson() {
dom.snapshotJson.textContent = apiState.snapshot
? JSON.stringify(apiState.snapshot, null, 2)
: "No snapshot loaded.";
dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2);
}
function buildComposedSnapshot() {
return {
api_version: apiState.stateResponse?.api_version || apiState.previewResponse?.api_version || "v1",
generated_at_millis:
apiState.previewResponse?.generated_at_millis ||
apiState.stateResponse?.generated_at_millis ||
0,
state: apiState.stateResponse?.state || null,
preview: apiState.previewResponse?.preview || null,
catalog: apiState.catalog || null,
};
}
function pushEvent(entry) {
apiState.events.unshift(entry);
apiState.events = apiState.events.slice(0, 12);
apiState.events.unshift({
kind: entry.kind || "info",
code: entry.code || null,
...entry,
});
apiState.events = apiState.events.slice(0, 50);
renderEvents();
}
function renderEvents() {
if (!apiState.events.length) {
dom.eventList.innerHTML = '<div class="empty-state">No websocket notices yet.</div>';
const kindFilter = dom.eventKindFilter.value;
const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase();
const filtered = apiState.events.filter((entry) => {
const kindMatches = kindFilter === "all" || entry.kind === kindFilter;
const searchMatches =
!searchFilter ||
(entry.message || "").toLowerCase().includes(searchFilter) ||
(entry.code || "").toLowerCase().includes(searchFilter);
return kindMatches && searchMatches;
});
if (!filtered.length) {
dom.eventList.innerHTML = '<div class="empty-state">No events match the current filter.</div>';
return;
}
dom.eventList.innerHTML = apiState.events
dom.eventList.innerHTML = filtered
.map(
(entry) => `
<article class="event-item">
<article class="event-item event-${entry.kind}">
<div class="event-meta">${entry.at}</div>
${entry.code ? `<span class="event-code">${entry.code}</span>` : ""}
<strong>${entry.message}</strong>
</article>
`

View File

@@ -45,11 +45,20 @@
</label>
<label class="field">
<span>Transition</span>
<span>Transition Duration</span>
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
<strong id="transition-value">0 ms</strong>
</label>
<label class="field">
<span>Transition Style</span>
<select id="transition-style-select">
<option value="snap">Snap</option>
<option value="crossfade">Crossfade</option>
<option value="chase">Chase</option>
</select>
</label>
<label class="field">
<span>Master Brightness</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
@@ -72,14 +81,65 @@
<div id="preset-list" class="pill-row"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Preset Capture</h3>
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
</div>
<div class="capture-grid">
<label class="field">
<span>Preset ID</span>
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
</label>
<label class="field inline-checkbox">
<span>Overwrite Existing</span>
<input id="preset-overwrite-input" type="checkbox" />
</label>
<button id="save-preset-button" class="ghost-button" type="button">
Save Current Scene As Preset
</button>
</div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Groups</h3>
<p>Focus looks on a subset while keeping the core scene model shared.</p>
</div>
<input
id="group-filter-input"
class="filter-input"
type="text"
placeholder="Filter groups by id or tag"
/>
<div id="group-list" class="pill-row"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Creative Snapshots</h3>
<p>Capture exploratory variants without replacing curated presets.</p>
</div>
<div class="capture-grid">
<label class="field">
<span>Snapshot ID</span>
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
</label>
<label class="field">
<span>Label</span>
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
</label>
<label class="field inline-checkbox">
<span>Overwrite Existing</span>
<input id="snapshot-overwrite-input" type="checkbox" />
</label>
<button id="save-snapshot-button" class="ghost-button" type="button">
Save Creative Snapshot
</button>
</div>
<div id="snapshot-list" class="snapshot-list"></div>
</div>
<div class="subsection">
<div class="subsection-heading">
<h3>Scene Parameters</h3>
@@ -111,6 +171,20 @@
<h2>Event Stream</h2>
<p>Recent notices from the websocket feed.</p>
</div>
<div class="event-filter-bar">
<select id="event-kind-filter">
<option value="all">All kinds</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input
id="event-search-filter"
class="filter-input"
type="text"
placeholder="Filter by code or message"
/>
</div>
<div id="event-list" class="event-list"></div>
</section>
</main>

View File

@@ -202,6 +202,7 @@ body::after {
}
.control-grid,
.capture-grid,
.parameter-grid,
.summary-cards {
display: grid;
@@ -212,6 +213,11 @@ body::after {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.capture-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.parameter-grid,
.summary-cards {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -295,11 +301,26 @@ input[type="text"] {
color: var(--text);
}
.filter-input {
margin-bottom: 12px;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.inline-checkbox {
align-content: start;
}
.inline-checkbox input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 4px 0 0;
accent-color: var(--accent-cool);
}
.ghost-button,
.preset-button,
.group-button {
@@ -374,6 +395,21 @@ input[type="range"] {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
}
.energy-bar {
height: 8px;
margin-top: 12px;
border-radius: 999px;
background: rgba(31, 36, 36, 0.08);
overflow: hidden;
}
.energy-bar > span {
display: block;
height: 100%;
width: var(--energy-width, 0%);
background: linear-gradient(90deg, var(--preview-color, #999999), rgba(255, 255, 255, 0.84));
}
.sample-row {
display: flex;
flex-wrap: wrap;
@@ -429,11 +465,84 @@ input[type="range"] {
gap: 12px;
}
.event-filter-bar {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.event-item {
display: grid;
gap: 8px;
}
.event-item.event-info strong {
color: var(--accent-strong);
}
.event-item.event-warning strong {
color: #a7631c;
}
.event-item.event-error strong {
color: var(--danger);
}
.event-code {
display: inline-flex;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: rgba(31, 36, 36, 0.08);
color: var(--muted);
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.snapshot-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.snapshot-card {
display: grid;
gap: 8px;
padding: 14px;
border-radius: var(--radius-md);
background: var(--surface-strong);
border: 1px solid rgba(56, 63, 61, 0.08);
}
.snapshot-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.snapshot-card-header strong {
display: block;
margin-bottom: 2px;
}
.snapshot-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.meta-chip {
display: inline-flex;
padding: 5px 8px;
border-radius: 999px;
background: rgba(31, 36, 36, 0.08);
color: var(--muted);
font-size: 0.82rem;
}
.event-item strong {
color: var(--accent-strong);
}
@@ -460,7 +569,8 @@ input[type="range"] {
@media (max-width: 1080px) {
.layout,
.hero,
.control-grid {
.control-grid,
.event-filter-bar {
grid-template-columns: 1fr;
}