diff --git a/Cargo.lock b/Cargo.lock index 4da1a0d..621bbaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2629,7 +2629,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "store" version = "0.1.0" dependencies = [ + "chrono", "domain", + "serde", "serde_json", "sqlx", "thiserror", diff --git a/config/default.toml b/config/default.toml index 2b3b017..32ee59e 100644 --- a/config/default.toml +++ b/config/default.toml @@ -6,6 +6,10 @@ freq_hz = 1000000 latch_time_micros = 500 crossfade_ms = 300 +[transitions] +default_fade_ms = 300 +default_curve = "ease_in_out" + [server] ip_address = "127.0.0.1" port = 5000 diff --git a/crates/api/src/active_scene.rs b/crates/api/src/active_scene.rs index 02e44f3..0811b07 100644 --- a/crates/api/src/active_scene.rs +++ b/crates/api/src/active_scene.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use store::{NewLayer, Store}; use crate::error::ApiError; +use crate::transition::TransitionQuery; use crate::types::{LayerResponse, SceneResponse}; #[get("/scenes/active")] @@ -31,6 +32,10 @@ pub async fn clear_active_scene( Ok(HttpResponse::NoContent().finish()) } +fn default_opacity() -> f32 { + 1.0 +} + #[derive(Deserialize)] struct AddLayerRequest { effect_id: String, @@ -39,6 +44,8 @@ struct AddLayerRequest { blend_mode: BlendMode, #[serde(default)] params: HashMap, + #[serde(default = "default_opacity")] + opacity: f32, } impl From for NewLayer { @@ -48,6 +55,7 @@ impl From for NewLayer { zone_id: r.zone_id, blend_mode: r.blend_mode, params: r.params, + opacity: r.opacity, } } } @@ -56,11 +64,12 @@ impl From for NewLayer { pub async fn set_active_scene( store: web::Data>, runtime: web::Data, + query: web::Query, body: web::Json>, ) -> Result { let layers: Vec = body.into_inner().into_iter().map(Into::into).collect(); store.replace_active_layers(&layers).await?; - runtime.reload_active(); + runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref())); let layers = store.get_active_layers().await?; Ok(HttpResponse::Ok().json( layers @@ -74,6 +83,7 @@ pub async fn set_active_scene( pub async fn add_layer( store: web::Data>, runtime: web::Data, + query: web::Query, body: web::Json, ) -> Result { let layer = store @@ -84,7 +94,7 @@ pub async fn add_layer( &body.params, ) .await?; - runtime.reload_active(); + runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref())); Ok(HttpResponse::Created().json(LayerResponse::from(layer))) } @@ -94,12 +104,14 @@ struct PatchLayerRequest { enabled: Option, blend_mode: Option, params: Option>, + opacity: Option, } #[patch("/scenes/active/layers/{id}")] pub async fn patch_layer( store: web::Data>, runtime: web::Data, + query: web::Query, path: web::Path, body: web::Json, ) -> Result { @@ -109,10 +121,14 @@ pub async fn patch_layer( let enabled = body.enabled.unwrap_or(current.enabled); let blend_mode = body.blend_mode.unwrap_or(current.blend_mode); let params = body.params.clone().unwrap_or(current.params); - let layer = store + store .update_active_layer(&id, zone_id, enabled, blend_mode, ¶ms) .await?; - runtime.reload_active(); + if let Some(opacity) = body.opacity { + store.update_active_layer_opacity(&id, opacity).await?; + } + let layer = store.get_active_layer(&id).await?; + runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref())); Ok(HttpResponse::Ok().json(LayerResponse::from(layer))) } @@ -120,10 +136,34 @@ pub async fn patch_layer( pub async fn remove_layer( store: web::Data>, runtime: web::Data, + query: web::Query, path: web::Path, ) -> Result { - store.remove_active_layer(&path.into_inner()).await?; - runtime.reload_active(); + let id = path.into_inner(); + let spec = query.into_inner().resolve(runtime.as_ref()); + + if spec.is_instant() { + store.remove_active_layer(&id).await?; + runtime.reload_active_with(spec); + return Ok(HttpResponse::NoContent().finish()); + } + + // Fade-out: tween opacity to 0 in-place, then drop the row once the + // visible fade is done. Reload runs immediately so the LiveParam picks + // up the new target; the cleanup reload uses INSTANT to avoid stacking + // another scene crossfade on top of a layer that has already faded out. + store.update_active_layer_opacity(&id, 0.0).await?; + runtime.reload_active_with(spec); + + let store = store.into_inner(); + let runtime: Arc = web::Data::clone(&runtime).into_inner(); + let fade_ms = spec.duration_ms as u64; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(fade_ms)).await; + let _ = store.remove_active_layer(&id).await; + runtime.reload_active_with(domain::TransitionSpec::INSTANT); + }); + Ok(HttpResponse::NoContent().finish()) } @@ -136,10 +176,11 @@ struct ReorderRequest { pub async fn reorder_layers( store: web::Data>, runtime: web::Data, + query: web::Query, body: web::Json, ) -> Result { store.reorder_active_layers(&body.ordered_ids).await?; - runtime.reload_active(); + runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref())); Ok(HttpResponse::NoContent().finish()) } @@ -161,10 +202,11 @@ pub async fn save_active_scene( pub async fn load_scene_into_active( store: web::Data>, runtime: web::Data, + query: web::Query, path: web::Path, ) -> Result { store.load_scene_into_active(&path.into_inner()).await?; - runtime.reload_active(); + runtime.reload_active_with(query.into_inner().resolve(runtime.as_ref())); Ok(HttpResponse::NoContent().finish()) } diff --git a/crates/api/src/device.rs b/crates/api/src/device.rs index 27ac81b..980da82 100644 --- a/crates/api/src/device.rs +++ b/crates/api/src/device.rs @@ -3,6 +3,8 @@ use application::{SceneRuntime, SceneSnapshot}; use domain::Rgb; use serde::{Deserialize, Serialize}; +use crate::transition::TransitionQuery; + #[derive(Serialize)] struct DeviceStateResponse { on: bool, @@ -37,16 +39,18 @@ struct PatchStateRequest { #[patch("/device/state")] pub async fn patch_state( runtime: web::Data, + query: web::Query, body: web::Json, ) -> impl Responder { + let spec = query.into_inner().resolve(runtime.as_ref()); if let Some(on) = body.on { - runtime.set_on_off(on); + runtime.set_on_off_with(on, spec); } if let Some(brightness) = body.brightness { - runtime.set_brightness(brightness); + runtime.set_brightness_with(brightness, spec); } if let Some(color) = body.color { - runtime.set_color(color); + runtime.set_color_with(color, spec); } HttpResponse::Ok().json(DeviceStateResponse::from(runtime.snapshot())) } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 07c84f6..65452df 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -4,5 +4,7 @@ pub mod effects; pub mod error; pub mod events; pub mod scenes; +pub mod state_stack; +pub mod transition; pub mod types; pub mod zones; diff --git a/crates/api/src/state_stack.rs b/crates/api/src/state_stack.rs new file mode 100644 index 0000000..3f5c805 --- /dev/null +++ b/crates/api/src/state_stack.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use actix_web::{HttpResponse, Responder, delete, get, post, web}; +use application::SceneRuntime; +use serde::Serialize; +use store::{StackDevice, StackEntry, StackLayer, Store}; + +use crate::error::ApiError; +use crate::transition::TransitionQuery; + +#[derive(Serialize)] +struct StackSummary { + position: i64, + pushed_at: i64, + layer_count: usize, +} + +impl From<&StackEntry> for StackSummary { + fn from(e: &StackEntry) -> Self { + Self { + position: e.position, + pushed_at: e.pushed_at, + layer_count: e.layers.len(), + } + } +} + +#[post("/scenes/active/push")] +pub async fn push_state( + store: web::Data>, + runtime: web::Data, +) -> Result { + let layers: Vec = store + .get_active_layers() + .await? + .into_iter() + .map(|l| StackLayer { + effect_id: l.effect_id, + zone_id: l.zone_id, + blend_mode: l.blend_mode, + enabled: l.enabled, + params: l.params, + opacity: l.opacity, + }) + .collect(); + + let snapshot = runtime.snapshot(); + let device = StackDevice { + on: snapshot.on, + brightness: snapshot.brightness, + color: snapshot.color, + }; + store.push_state(&layers, &device).await?; + Ok(HttpResponse::NoContent().finish()) +} + +#[post("/scenes/active/pop")] +pub async fn pop_state( + store: web::Data>, + runtime: web::Data, + query: web::Query, +) -> Result { + let entry = store.pop_state().await?.ok_or(ApiError::NotFound)?; + store.restore_active_from_stack(&entry.layers).await?; + let spec = query.into_inner().resolve(runtime.as_ref()); + runtime.set_on_off_with(entry.device.on, spec); + runtime.set_brightness_with(entry.device.brightness, spec); + runtime.set_color_with(entry.device.color, spec); + runtime.reload_active_with(spec); + Ok(HttpResponse::NoContent().finish()) +} + +#[get("/scenes/active/stack")] +pub async fn get_stack(store: web::Data>) -> Result { + let entries = store.list_state_stack().await?; + let summaries: Vec = entries.iter().map(StackSummary::from).collect(); + Ok(HttpResponse::Ok().json(summaries)) +} + +#[delete("/scenes/active/stack")] +pub async fn clear_stack(store: web::Data>) -> Result { + store.clear_state_stack().await?; + Ok(HttpResponse::NoContent().finish()) +} + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(push_state) + .service(pop_state) + .service(get_stack) + .service(clear_stack); +} diff --git a/crates/api/src/transition.rs b/crates/api/src/transition.rs new file mode 100644 index 0000000..00f8b0c --- /dev/null +++ b/crates/api/src/transition.rs @@ -0,0 +1,19 @@ +use application::SceneRuntime; +use domain::{TransitionCurve, TransitionSpec}; +use serde::Deserialize; + +#[derive(Debug, Default, Clone, Copy, Deserialize)] +pub struct TransitionQuery { + pub fade_ms: Option, + pub curve: Option, +} + +impl TransitionQuery { + pub fn resolve(self, runtime: &dyn SceneRuntime) -> TransitionSpec { + let default = runtime.default_spec(); + TransitionSpec::new( + self.fade_ms.unwrap_or(default.duration_ms), + self.curve.unwrap_or(default.curve), + ) + } +} diff --git a/crates/api/src/types.rs b/crates/api/src/types.rs index 812741b..7bac1ca 100644 --- a/crates/api/src/types.rs +++ b/crates/api/src/types.rs @@ -13,6 +13,7 @@ pub struct LayerResponse { pub params: HashMap, pub enabled: bool, pub position: u32, + pub opacity: f32, } impl From for LayerResponse { @@ -25,6 +26,7 @@ impl From for LayerResponse { params: r.params, enabled: r.enabled, position: r.position, + opacity: r.opacity, } } } diff --git a/crates/api/tests/active_scene.rs b/crates/api/tests/active_scene.rs index 1d7f43d..52b62b8 100644 --- a/crates/api/tests/active_scene.rs +++ b/crates/api/tests/active_scene.rs @@ -6,7 +6,8 @@ use actix_web::http::StatusCode; use actix_web::test::{self, TestRequest}; use actix_web::{App, web}; use application::SceneRuntime; -use common::{make_store, mock_runtime}; +use common::{DefaultSpecRuntime, make_store, mock_runtime}; +use domain::{TransitionCurve, TransitionSpec}; use serde_json::{Value, json}; // active_scene routes must come BEFORE scenes routes: /scenes/active must match @@ -172,6 +173,151 @@ async fn save_and_load() { assert_eq!(body[0]["effect_id"], "builtin:rainbow"); } +#[actix_web::test] +async fn put_scene_round_trips_opacity() { + let svc = svc!(make_store().await); + + let resp = test::call_service( + &svc, + TestRequest::put() + .uri("/scenes/active") + .set_json(json!([ + {"effect_id": "builtin:rainbow", "zone_id": "all", "opacity": 0.25}, + {"effect_id": "builtin:aurora", "zone_id": "all"} + ])) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body[0]["opacity"].as_f64().unwrap(), 0.25); + assert_eq!(body[1]["opacity"].as_f64().unwrap(), 1.0); +} + +#[actix_web::test] +async fn added_layer_response_includes_default_opacity() { + let svc = svc!(make_store().await); + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["opacity"].as_f64().unwrap(), 1.0); +} + +#[actix_web::test] +async fn patch_layer_opacity_updates_db() { + let svc = svc!(make_store().await); + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + let id = test::read_body_json::(resp).await["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = test::call_service( + &svc, + TestRequest::patch() + .uri(&format!("/scenes/active/layers/{id}?fade_ms=200")) + .set_json(json!({"opacity": 0.4})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!((body["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-5); + + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").to_request()).await; + let body: Vec = test::read_body_json(resp).await; + assert!((body[0]["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-5); +} + +#[actix_web::test] +async fn delete_layer_with_fade_keeps_row_until_after_fade() { + let svc = svc!(make_store().await); + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + let id = test::read_body_json::(resp).await["id"] + .as_str() + .unwrap() + .to_string(); + + let resp = test::call_service( + &svc, + TestRequest::delete() + .uri(&format!("/scenes/active/layers/{id}?fade_ms=150")) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Immediately after DELETE: row still exists, opacity has been set to 0. + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").to_request()).await; + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body.len(), 1, "layer should remain during fade-out"); + assert_eq!(body[0]["opacity"].as_f64().unwrap(), 0.0); + + // After the fade window: row gone. + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").to_request()).await; + let body: Vec = test::read_body_json(resp).await; + assert!( + body.is_empty(), + "layer should be deleted after fade completes" + ); +} + +#[actix_web::test] +async fn delete_layer_without_fade_drops_row_immediately() { + let svc = svc!(make_store().await); + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + let id = test::read_body_json::(resp).await["id"] + .as_str() + .unwrap() + .to_string(); + + test::call_service( + &svc, + TestRequest::delete() + .uri(&format!("/scenes/active/layers/{id}")) + .to_request(), + ) + .await; + + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").to_request()).await; + let body: Vec = test::read_body_json(resp).await; + assert!(body.is_empty()); +} + #[actix_web::test] async fn load_nonexistent_returns_404() { let svc = svc!(make_store().await); @@ -185,3 +331,89 @@ async fn load_nonexistent_returns_404() { .await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +macro_rules! svc_with { + ($runtime:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new(make_store().await)) + .app_data(web::Data::from($runtime.clone() as Arc)) + .configure(api::active_scene::config) + .configure(api::scenes::config), + ) + .await + }}; +} + +#[actix_web::test] +async fn add_layer_without_query_forwards_runtime_default_spec_to_reload() { + let default = TransitionSpec::new(400, TransitionCurve::EaseInOut); + let rec = Arc::new(DefaultSpecRuntime::new(default)); + let svc = svc_with!(rec); + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + assert_eq!(rec.last_reload.lock().unwrap().unwrap(), default); +} + +#[actix_web::test] +async fn add_layer_with_fade_ms_forwards_overridden_spec_to_reload() { + let rec = Arc::new(DefaultSpecRuntime::new(TransitionSpec::new( + 300, + TransitionCurve::EaseInOut, + ))); + let svc = svc_with!(rec); + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers?fade_ms=2500&curve=ease_in") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + let spec = rec.last_reload.lock().unwrap().unwrap(); + assert_eq!(spec, TransitionSpec::new(2500, TransitionCurve::EaseIn)); +} + +#[actix_web::test] +async fn load_scene_forwards_fade_ms_to_reload() { + let rec = Arc::new(DefaultSpecRuntime::new(TransitionSpec::INSTANT)); + let svc = svc_with!(rec); + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + let save_resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/save") + .set_json(json!({"name": "tv"})) + .to_request(), + ) + .await; + let body: Value = test::read_body_json(save_resp).await; + let scene_id = body["id"].as_str().unwrap().to_string(); + + test::call_service( + &svc, + TestRequest::post() + .uri(&format!( + "/scenes/active/load/{scene_id}?fade_ms=10000&curve=ease_out" + )) + .to_request(), + ) + .await; + + let spec = rec.last_reload.lock().unwrap().unwrap(); + assert_eq!(spec, TransitionSpec::new(10000, TransitionCurve::EaseOut)); +} diff --git a/crates/api/tests/common/mod.rs b/crates/api/tests/common/mod.rs index 68595e9..820513e 100644 --- a/crates/api/tests/common/mod.rs +++ b/crates/api/tests/common/mod.rs @@ -1,19 +1,24 @@ -use std::sync::Arc; +#![allow(dead_code)] + +use std::sync::{Arc, Mutex}; use application::{SceneRuntime, SceneSnapshot}; -use domain::Rgb; +use domain::{Rgb, TransitionSpec}; use sqlx::sqlite::SqlitePoolOptions; use store::Store; pub struct MockRuntime; impl SceneRuntime for MockRuntime { - fn set_color(&self, _: Rgb) {} - fn set_brightness(&self, _: u8) {} - fn set_on_off(&self, _: bool) {} + fn default_spec(&self) -> TransitionSpec { + TransitionSpec::INSTANT + } + fn set_color_with(&self, _: Rgb, _: TransitionSpec) {} + fn set_brightness_with(&self, _: u8, _: TransitionSpec) {} + fn set_on_off_with(&self, _: bool, _: TransitionSpec) {} fn halt(&self) {} fn stop_effect(&self) {} - fn reload_active(&self) {} + fn reload_active_with(&self, _: TransitionSpec) {} fn snapshot(&self) -> SceneSnapshot { SceneSnapshot { on: true, @@ -37,3 +42,52 @@ pub async fn make_store() -> Arc { pub fn mock_runtime() -> Arc { Arc::new(MockRuntime) } + +pub struct DefaultSpecRuntime { + default: TransitionSpec, + pub last_color: Mutex>, + pub last_brightness: Mutex>, + pub last_on_off: Mutex>, + pub last_reload: Mutex>, +} + +impl DefaultSpecRuntime { + pub fn new(default: TransitionSpec) -> Self { + Self { + default, + last_color: Mutex::new(None), + last_brightness: Mutex::new(None), + last_on_off: Mutex::new(None), + last_reload: Mutex::new(None), + } + } +} + +impl SceneRuntime for DefaultSpecRuntime { + fn default_spec(&self) -> TransitionSpec { + self.default + } + fn set_color_with(&self, color: Rgb, spec: TransitionSpec) { + *self.last_color.lock().unwrap() = Some((color, spec)); + } + fn set_brightness_with(&self, b: u8, spec: TransitionSpec) { + *self.last_brightness.lock().unwrap() = Some((b, spec)); + } + fn set_on_off_with(&self, on: bool, spec: TransitionSpec) { + *self.last_on_off.lock().unwrap() = Some((on, spec)); + } + fn halt(&self) {} + fn stop_effect(&self) {} + fn reload_active_with(&self, spec: TransitionSpec) { + *self.last_reload.lock().unwrap() = Some(spec); + } + fn snapshot(&self) -> SceneSnapshot { + SceneSnapshot { + on: true, + brightness: 255, + color: Rgb::BLACK, + active_effect: None, + light_effect_end_unix_timestamp_sec: None, + } + } +} diff --git a/crates/api/tests/device.rs b/crates/api/tests/device.rs index fea4313..6c71e24 100644 --- a/crates/api/tests/device.rs +++ b/crates/api/tests/device.rs @@ -6,7 +6,8 @@ use actix_web::http::StatusCode; use actix_web::test::{self, TestRequest}; use actix_web::{App, web}; use application::SceneRuntime; -use common::{make_store, mock_runtime}; +use common::{DefaultSpecRuntime, make_store, mock_runtime}; +use domain::{TransitionCurve, TransitionSpec}; use serde_json::{Value, json}; macro_rules! svc { @@ -49,3 +50,72 @@ async fn patch_state() { let body: Value = test::read_body_json(resp).await; assert!(body["on"].is_boolean()); } + +fn recorder(default: TransitionSpec) -> Arc { + Arc::new(DefaultSpecRuntime::new(default)) +} + +async fn patch_with(uri: &str, body: Value, runtime: Arc) { + let svc = test::init_service( + App::new() + .app_data(web::Data::new(make_store().await)) + .app_data(web::Data::from(runtime as Arc)) + .configure(api::device::config), + ) + .await; + let resp = test::call_service( + &svc, + TestRequest::patch().uri(uri).set_json(body).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); +} + +#[actix_web::test] +async fn patch_state_without_query_uses_runtime_default_spec() { + let default = TransitionSpec::new(400, TransitionCurve::EaseInOut); + let rec = recorder(default); + patch_with("/device/state", json!({"on": true}), rec.clone()).await; + assert_eq!(rec.last_on_off.lock().unwrap().unwrap().1, default); +} + +#[actix_web::test] +async fn patch_state_with_fade_ms_overrides_duration() { + let rec = recorder(TransitionSpec::new(400, TransitionCurve::EaseInOut)); + patch_with( + "/device/state?fade_ms=1500", + json!({"on": false}), + rec.clone(), + ) + .await; + let (_, spec) = rec.last_on_off.lock().unwrap().unwrap(); + assert_eq!(spec.duration_ms, 1500); + assert_eq!(spec.curve, TransitionCurve::EaseInOut); +} + +#[actix_web::test] +async fn patch_state_with_curve_overrides_default_curve() { + let rec = recorder(TransitionSpec::new(400, TransitionCurve::EaseInOut)); + patch_with( + "/device/state?curve=exp", + json!({"brightness": 80}), + rec.clone(), + ) + .await; + let (value, spec) = rec.last_brightness.lock().unwrap().unwrap(); + assert_eq!(value, 80); + assert_eq!(spec.curve, TransitionCurve::Exp); +} + +#[actix_web::test] +async fn patch_state_fade_ms_zero_threads_instant_spec() { + let rec = recorder(TransitionSpec::new(800, TransitionCurve::EaseInOut)); + patch_with( + "/device/state?fade_ms=0", + json!({"color": {"r": 1, "g": 2, "b": 3}}), + rec.clone(), + ) + .await; + let (_, spec) = rec.last_color.lock().unwrap().unwrap(); + assert!(spec.is_instant()); +} diff --git a/crates/api/tests/state_stack.rs b/crates/api/tests/state_stack.rs new file mode 100644 index 0000000..4ca4cef --- /dev/null +++ b/crates/api/tests/state_stack.rs @@ -0,0 +1,252 @@ +mod common; + +use std::sync::Arc; + +use actix_web::http::StatusCode; +use actix_web::test::{self, TestRequest}; +use actix_web::{App, web}; +use application::SceneRuntime; +use common::{DefaultSpecRuntime, make_store}; +use domain::{TransitionCurve, TransitionSpec}; +use serde_json::{Value, json}; + +macro_rules! svc { + ($store:expr, $runtime:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new($store)) + .app_data(web::Data::from($runtime.clone() as Arc)) + .configure(api::state_stack::config) + .configure(api::active_scene::config), + ) + .await + }}; +} + +fn runtime() -> Arc { + Arc::new(DefaultSpecRuntime::new(TransitionSpec::INSTANT)) +} + +#[actix_web::test] +async fn empty_stack_returns_empty_list() { + let svc = svc!(make_store().await, runtime()); + + let resp = test::call_service( + &svc, + TestRequest::get().uri("/scenes/active/stack").to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = test::read_body_json(resp).await; + assert!(body.is_empty()); +} + +#[actix_web::test] +async fn pop_on_empty_stack_returns_404() { + let svc = svc!(make_store().await, runtime()); + + let resp = test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/pop").to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[actix_web::test] +async fn push_then_get_stack_lists_one_entry() { + let svc = svc!(make_store().await, runtime()); + + // add a layer so the snapshot has something to capture + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + + let resp = test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/push").to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = test::call_service( + &svc, + TestRequest::get().uri("/scenes/active/stack").to_request(), + ) + .await; + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body.len(), 1); + assert_eq!(body[0]["layer_count"], 1); + assert!(body[0]["pushed_at"].as_i64().unwrap() > 0); +} + +#[actix_web::test] +async fn push_pop_round_trips_active_layers() { + let svc = svc!(make_store().await, runtime()); + + // baseline state: one layer + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + + // push baseline + test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/push").to_request(), + ) + .await; + + // overwrite with TV scene + test::call_service( + &svc, + TestRequest::put() + .uri("/scenes/active") + .set_json(json!([{"effect_id": "builtin:aurora", "zone_id": "all"}])) + .to_request(), + ) + .await; + + // pop restores the baseline + let resp = test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/pop").to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").to_request()).await; + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body.len(), 1); + assert_eq!(body[0]["effect_id"], "builtin:rainbow"); +} + +#[actix_web::test] +async fn pop_forwards_fade_ms_to_runtime() { + let rec = runtime(); + let svc = svc!(make_store().await, rec); + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/push").to_request(), + ) + .await; + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/pop?fade_ms=10000&curve=ease_out") + .to_request(), + ) + .await; + + let spec = rec.last_reload.lock().unwrap().unwrap(); + assert_eq!(spec, TransitionSpec::new(10000, TransitionCurve::EaseOut)); +} + +#[actix_web::test] +async fn clear_stack_drops_entries() { + let svc = svc!(make_store().await, runtime()); + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/push").to_request(), + ) + .await; + + let resp = test::call_service( + &svc, + TestRequest::delete() + .uri("/scenes/active/stack") + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = test::call_service( + &svc, + TestRequest::get().uri("/scenes/active/stack").to_request(), + ) + .await; + let body: Vec = test::read_body_json(resp).await; + assert!(body.is_empty()); +} + +#[actix_web::test] +async fn stack_is_lifo() { + let svc = svc!(make_store().await, runtime()); + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({"effect_id": "builtin:rainbow", "zone_id": "all"})) + .to_request(), + ) + .await; + test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/push").to_request(), + ) + .await; + + test::call_service( + &svc, + TestRequest::put() + .uri("/scenes/active") + .set_json(json!([{"effect_id": "builtin:aurora", "zone_id": "all"}])) + .to_request(), + ) + .await; + test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/push").to_request(), + ) + .await; + + // mutate again so we can prove pop restores `aurora` not `rainbow` + test::call_service( + &svc, + TestRequest::put() + .uri("/scenes/active") + .set_json(json!([{"effect_id": "builtin:lava", "zone_id": "all"}])) + .to_request(), + ) + .await; + + test::call_service( + &svc, + TestRequest::post().uri("/scenes/active/pop").to_request(), + ) + .await; + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").to_request()).await; + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body[0]["effect_id"], "builtin:aurora"); +} diff --git a/crates/api/tests/transition.rs b/crates/api/tests/transition.rs new file mode 100644 index 0000000..3fcc855 --- /dev/null +++ b/crates/api/tests/transition.rs @@ -0,0 +1,66 @@ +mod common; + +use api::transition::TransitionQuery; +use application::SceneRuntime; +use domain::{TransitionCurve, TransitionSpec}; + +use common::DefaultSpecRuntime; + +fn runtime_with(default: TransitionSpec) -> DefaultSpecRuntime { + DefaultSpecRuntime::new(default) +} + +#[test] +fn empty_query_resolves_to_runtime_default_spec() { + let runtime = runtime_with(TransitionSpec::new(750, TransitionCurve::EaseInOut)); + let query = TransitionQuery::default(); + let spec = query.resolve(&runtime as &dyn SceneRuntime); + assert_eq!(spec, runtime.default_spec()); +} + +#[test] +fn fade_ms_overrides_duration_but_keeps_default_curve() { + let runtime = runtime_with(TransitionSpec::new(800, TransitionCurve::EaseOut)); + let query = TransitionQuery { + fade_ms: Some(120), + curve: None, + }; + let spec = query.resolve(&runtime as &dyn SceneRuntime); + assert_eq!(spec.duration_ms, 120); + assert_eq!(spec.curve, TransitionCurve::EaseOut); +} + +#[test] +fn curve_overrides_default_but_keeps_default_duration() { + let runtime = runtime_with(TransitionSpec::new(500, TransitionCurve::Linear)); + let query = TransitionQuery { + fade_ms: None, + curve: Some(TransitionCurve::Exp), + }; + let spec = query.resolve(&runtime as &dyn SceneRuntime); + assert_eq!(spec.duration_ms, 500); + assert_eq!(spec.curve, TransitionCurve::Exp); +} + +#[test] +fn fade_ms_and_curve_override_both_fields() { + let runtime = runtime_with(TransitionSpec::new(300, TransitionCurve::EaseInOut)); + let query = TransitionQuery { + fade_ms: Some(2000), + curve: Some(TransitionCurve::EaseIn), + }; + let spec = query.resolve(&runtime as &dyn SceneRuntime); + assert_eq!(spec, TransitionSpec::new(2000, TransitionCurve::EaseIn)); +} + +#[test] +fn fade_ms_zero_resolves_to_instant_duration() { + let runtime = runtime_with(TransitionSpec::new(500, TransitionCurve::EaseInOut)); + let query = TransitionQuery { + fade_ms: Some(0), + curve: None, + }; + let spec = query.resolve(&runtime as &dyn SceneRuntime); + assert_eq!(spec.duration_ms, 0); + assert!(spec.is_instant()); +} diff --git a/crates/application/src/runtime.rs b/crates/application/src/runtime.rs index e809391..77c5e68 100644 --- a/crates/application/src/runtime.rs +++ b/crates/application/src/runtime.rs @@ -1,16 +1,36 @@ -use domain::Rgb; +use domain::{Rgb, TransitionSpec}; use crate::SceneSnapshot; pub trait SceneRuntime: Send + Sync { - fn set_color(&self, color: Rgb); - fn set_brightness(&self, brightness: u8); - fn set_on_off(&self, on: bool); + fn default_spec(&self) -> TransitionSpec; + + fn set_color_with(&self, color: Rgb, spec: TransitionSpec); + fn set_color(&self, color: Rgb) { + self.set_color_with(color, self.default_spec()); + } + + fn set_brightness_with(&self, brightness: u8, spec: TransitionSpec); + fn set_brightness(&self, brightness: u8) { + self.set_brightness_with(brightness, self.default_spec()); + } + + fn set_on_off_with(&self, on: bool, spec: TransitionSpec); + fn set_on_off(&self, on: bool) { + self.set_on_off_with(on, self.default_spec()); + } + fn halt(&self); fn stop_effect(&self); + fn start_color_loop(&self, _duration: u64) {} fn start_sleep(&self, _duration: u64) {} fn start_wake(&self, _duration: u64) {} - fn reload_active(&self); + + fn reload_active_with(&self, spec: TransitionSpec); + fn reload_active(&self) { + self.reload_active_with(self.default_spec()); + } + fn snapshot(&self) -> SceneSnapshot; } diff --git a/crates/application/tests/scene_runtime.rs b/crates/application/tests/scene_runtime.rs new file mode 100644 index 0000000..b7c82d7 --- /dev/null +++ b/crates/application/tests/scene_runtime.rs @@ -0,0 +1,97 @@ +use std::sync::Mutex; + +use application::{SceneRuntime, SceneSnapshot}; +use domain::{Rgb, TransitionCurve, TransitionSpec}; + +#[derive(Default)] +struct Recorder { + default: TransitionSpec, + last_color: Mutex>, + last_brightness: Mutex>, + last_on_off: Mutex>, + last_reload: Mutex>, +} + +impl Recorder { + fn new(default: TransitionSpec) -> Self { + Self { + default, + ..Default::default() + } + } +} + +impl SceneRuntime for Recorder { + fn default_spec(&self) -> TransitionSpec { + self.default + } + fn set_color_with(&self, color: Rgb, spec: TransitionSpec) { + *self.last_color.lock().unwrap() = Some((color, spec)); + } + fn set_brightness_with(&self, b: u8, spec: TransitionSpec) { + *self.last_brightness.lock().unwrap() = Some((b, spec)); + } + fn set_on_off_with(&self, on: bool, spec: TransitionSpec) { + *self.last_on_off.lock().unwrap() = Some((on, spec)); + } + fn halt(&self) {} + fn stop_effect(&self) {} + fn reload_active_with(&self, spec: TransitionSpec) { + *self.last_reload.lock().unwrap() = Some(spec); + } + fn snapshot(&self) -> SceneSnapshot { + SceneSnapshot { + on: true, + brightness: 0, + color: Rgb::BLACK, + active_effect: None, + light_effect_end_unix_timestamp_sec: None, + } + } +} + +fn fade() -> TransitionSpec { + TransitionSpec::new(500, TransitionCurve::EaseInOut) +} + +#[test] +fn set_color_delegates_to_set_color_with_using_default_spec() { + let rec = Recorder::new(fade()); + rec.set_color(Rgb::new(1, 2, 3)); + let (color, spec) = rec.last_color.lock().unwrap().unwrap(); + assert_eq!(color, Rgb::new(1, 2, 3)); + assert_eq!(spec, fade()); +} + +#[test] +fn set_brightness_delegates_to_set_brightness_with_using_default_spec() { + let rec = Recorder::new(fade()); + rec.set_brightness(123); + let (value, spec) = rec.last_brightness.lock().unwrap().unwrap(); + assert_eq!(value, 123); + assert_eq!(spec, fade()); +} + +#[test] +fn set_on_off_delegates_to_set_on_off_with_using_default_spec() { + let rec = Recorder::new(fade()); + rec.set_on_off(false); + let (value, spec) = rec.last_on_off.lock().unwrap().unwrap(); + assert!(!value); + assert_eq!(spec, fade()); +} + +#[test] +fn reload_active_delegates_to_reload_active_with_using_default_spec() { + let rec = Recorder::new(fade()); + rec.reload_active(); + assert_eq!(rec.last_reload.lock().unwrap().unwrap(), fade()); +} + +#[test] +fn explicit_with_call_overrides_default_spec() { + let rec = Recorder::new(TransitionSpec::INSTANT); + let custom = TransitionSpec::new(900, TransitionCurve::EaseIn); + rec.set_color_with(Rgb::new(10, 20, 30), custom); + assert_eq!(rec.last_color.lock().unwrap().unwrap().1, custom); +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 41a1caf..a1ccaf9 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -2,9 +2,11 @@ pub mod blend_mode; pub mod color; pub mod device_state; pub mod param; +pub mod transition; pub mod zone; pub use blend_mode::BlendMode; pub use color::Rgb; pub use device_state::DeviceState; pub use param::{ParamControl, ParamDef, ParamValue}; +pub use transition::{TransitionCurve, TransitionSpec}; pub use zone::Zone; diff --git a/crates/domain/src/transition/curve.rs b/crates/domain/src/transition/curve.rs new file mode 100644 index 0000000..77fcac8 --- /dev/null +++ b/crates/domain/src/transition/curve.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransitionCurve { + Linear, + EaseIn, + EaseOut, + #[default] + EaseInOut, + Exp, +} + +impl TransitionCurve { + pub fn weight(self, t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + match self { + Self::Linear => t, + Self::EaseIn => t * t, + Self::EaseOut => 1.0 - (1.0 - t) * (1.0 - t), + Self::EaseInOut => { + if t < 0.5 { + 2.0 * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0 + } + } + Self::Exp => { + if t <= 0.0 { + 0.0 + } else if t >= 1.0 { + 1.0 + } else { + 2f32.powf(10.0 * (t - 1.0)) + } + } + } + } +} diff --git a/crates/domain/src/transition/mod.rs b/crates/domain/src/transition/mod.rs new file mode 100644 index 0000000..4a6d83c --- /dev/null +++ b/crates/domain/src/transition/mod.rs @@ -0,0 +1,5 @@ +pub mod curve; +pub mod spec; + +pub use curve::TransitionCurve; +pub use spec::TransitionSpec; diff --git a/crates/domain/src/transition/spec.rs b/crates/domain/src/transition/spec.rs new file mode 100644 index 0000000..e22fa68 --- /dev/null +++ b/crates/domain/src/transition/spec.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +use super::curve::TransitionCurve; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransitionSpec { + pub duration_ms: u32, + #[serde(default)] + pub curve: TransitionCurve, +} + +impl TransitionSpec { + pub const INSTANT: Self = Self { + duration_ms: 0, + curve: TransitionCurve::Linear, + }; + + pub fn new(duration_ms: u32, curve: TransitionCurve) -> Self { + Self { duration_ms, curve } + } + + pub fn frames(self, frame_ms: u32) -> u32 { + match frame_ms { + 0 => 0, + _ => self.duration_ms / frame_ms, + } + } + + pub fn is_instant(self) -> bool { + self.duration_ms == 0 + } +} + +impl Default for TransitionSpec { + fn default() -> Self { + Self::INSTANT + } +} diff --git a/crates/domain/tests/transition_curve.rs b/crates/domain/tests/transition_curve.rs new file mode 100644 index 0000000..5a3cf2f --- /dev/null +++ b/crates/domain/tests/transition_curve.rs @@ -0,0 +1,112 @@ +use domain::TransitionCurve; + +const EPS: f32 = 1e-5; + +fn approx(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() < EPS, + "expected {expected}, got {actual}" + ); +} + +const ALL: [TransitionCurve; 5] = [ + TransitionCurve::Linear, + TransitionCurve::EaseIn, + TransitionCurve::EaseOut, + TransitionCurve::EaseInOut, + TransitionCurve::Exp, +]; + +#[test] +fn endpoints_are_exact_for_every_curve() { + for curve in ALL { + approx(curve.weight(0.0), 0.0); + approx(curve.weight(1.0), 1.0); + } +} + +#[test] +fn input_below_zero_clamps_to_zero() { + for curve in ALL { + approx(curve.weight(-0.5), 0.0); + approx(curve.weight(-10.0), 0.0); + } +} + +#[test] +fn input_above_one_clamps_to_one() { + for curve in ALL { + approx(curve.weight(1.5), 1.0); + approx(curve.weight(10.0), 1.0); + } +} + +#[test] +fn linear_is_identity() { + approx(TransitionCurve::Linear.weight(0.25), 0.25); + approx(TransitionCurve::Linear.weight(0.5), 0.5); + approx(TransitionCurve::Linear.weight(0.75), 0.75); +} + +#[test] +fn ease_in_starts_below_linear() { + let c = TransitionCurve::EaseIn; + assert!(c.weight(0.25) < 0.25); + assert!(c.weight(0.5) < 0.5); + assert!(c.weight(0.75) < 0.75); +} + +#[test] +fn ease_out_starts_above_linear() { + let c = TransitionCurve::EaseOut; + assert!(c.weight(0.25) > 0.25); + assert!(c.weight(0.5) > 0.5); + assert!(c.weight(0.75) > 0.75); +} + +#[test] +fn ease_in_out_is_symmetric_at_midpoint() { + approx(TransitionCurve::EaseInOut.weight(0.5), 0.5); +} + +#[test] +fn every_curve_is_monotonic_non_decreasing() { + for curve in ALL { + let mut prev = curve.weight(0.0); + for i in 1..=100 { + let cur = curve.weight(i as f32 / 100.0); + assert!( + cur >= prev, + "{curve:?} non-monotonic at {i}: {prev} -> {cur}" + ); + prev = cur; + } + } +} + +#[test] +fn default_curve_is_ease_in_out() { + assert_eq!(TransitionCurve::default(), TransitionCurve::EaseInOut); +} + +#[test] +fn serde_uses_snake_case_variant_names() { + assert_eq!( + serde_json::to_string(&TransitionCurve::EaseInOut).unwrap(), + "\"ease_in_out\"" + ); + assert_eq!( + serde_json::to_string(&TransitionCurve::EaseIn).unwrap(), + "\"ease_in\"" + ); + assert_eq!( + serde_json::to_string(&TransitionCurve::Exp).unwrap(), + "\"exp\"" + ); +} + +#[test] +fn serde_deserializes_from_snake_case() { + let parsed: TransitionCurve = serde_json::from_str("\"ease_in\"").unwrap(); + assert_eq!(parsed, TransitionCurve::EaseIn); +} diff --git a/crates/domain/tests/transition_spec.rs b/crates/domain/tests/transition_spec.rs new file mode 100644 index 0000000..622ae8d --- /dev/null +++ b/crates/domain/tests/transition_spec.rs @@ -0,0 +1,50 @@ +use domain::{TransitionCurve, TransitionSpec}; + +#[test] +fn instant_constant_has_zero_duration() { + assert!(TransitionSpec::INSTANT.is_instant()); + assert_eq!(TransitionSpec::INSTANT.duration_ms, 0); +} + +#[test] +fn non_zero_duration_is_not_instant() { + let spec = TransitionSpec::new(100, TransitionCurve::Linear); + assert!(!spec.is_instant()); +} + +#[test] +fn default_is_instant() { + assert_eq!(TransitionSpec::default(), TransitionSpec::INSTANT); +} + +#[test] +fn frames_divides_duration_by_frame_step() { + let spec = TransitionSpec::new(320, TransitionCurve::Linear); + assert_eq!(spec.frames(16), 20); +} + +#[test] +fn frames_zero_step_yields_zero() { + let spec = TransitionSpec::new(320, TransitionCurve::Linear); + assert_eq!(spec.frames(0), 0); +} + +#[test] +fn instant_spec_yields_zero_frames() { + assert_eq!(TransitionSpec::INSTANT.frames(16), 0); +} + +#[test] +fn serde_round_trips() { + let spec = TransitionSpec::new(500, TransitionCurve::Exp); + let json = serde_json::to_string(&spec).unwrap(); + let back: TransitionSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(spec, back); +} + +#[test] +fn serde_defaults_curve_when_field_omitted() { + let parsed: TransitionSpec = serde_json::from_str(r#"{"duration_ms": 250}"#).unwrap(); + assert_eq!(parsed.duration_ms, 250); + assert_eq!(parsed.curve, TransitionCurve::EaseInOut); +} diff --git a/crates/effects/src/compositor/composite.rs b/crates/effects/src/compositor/composite.rs index 3f64637..025bd64 100644 --- a/crates/effects/src/compositor/composite.rs +++ b/crates/effects/src/compositor/composite.rs @@ -15,18 +15,22 @@ pub struct CompositeEffect { impl Effect for CompositeEffect { fn frames(&self, pixels: &[Rgb]) -> Box> + Send + 'static> { let strip_len = pixels.len(); - let layers = cull_layers(&self.layers, strip_len); + let layers = self.layers.clone(); let brightness = Arc::clone(&self.brightness); let primary_color = Arc::clone(&self.primary_color); Box::new(std::iter::from_fn(move || { brightness.tick(); primary_color.tick(); - let frame = layers + for layer in &layers { + layer.opacity.tick(); + } + let first = first_visible_layer(&layers, strip_len); + let frame = layers[first..] .iter() .fold(vec![Rgb::BLACK; strip_len], |base, layer| { let overlay = layer.effect.render(layer.zone.zone_len()); - blend_layer(base, &overlay, layer.mode, &layer.zone) + blend_layer(base, &overlay, layer.mode, &layer.zone, layer.opacity.get()) }); let b = brightness.get() as u8; Some(frame.into_iter().map(|c| c.with_brightness(b)).collect()) @@ -34,8 +38,8 @@ impl Effect for CompositeEffect { } } -fn cull_layers(layers: &[CompositeLayer], strip_len: usize) -> Vec { - let first_visible = layers +fn first_visible_layer(layers: &[CompositeLayer], strip_len: usize) -> usize { + layers .iter() .enumerate() .rev() @@ -44,14 +48,20 @@ fn cull_layers(layers: &[CompositeLayer], strip_len: usize) -> Vec= strip_len && l.zone.transition_length == 0 + && l.opacity.get() >= 1.0 }) .map(|(i, _)| i) - .unwrap_or(0); - layers[first_visible..].to_vec() + .unwrap_or(0) } -fn blend_layer(base: Vec, overlay: &[Rgb], mode: BlendMode, zone: &ZoneGradient) -> Vec { - if overlay.is_empty() { +fn blend_layer( + base: Vec, + overlay: &[Rgb], + mode: BlendMode, + zone: &ZoneGradient, + layer_opacity: f32, +) -> Vec { + if overlay.is_empty() || layer_opacity <= 0.0 { return base; } let strip_len = base.len(); @@ -72,7 +82,7 @@ fn blend_layer(base: Vec, overlay: &[Rgb], mode: BlendMode, zone: &ZoneGrad base_px, overlay[idx], mode, - pixel_opacity(zone, strip_px, strip_len), + pixel_opacity(zone, strip_px, strip_len) * layer_opacity, ) } }, @@ -81,21 +91,23 @@ fn blend_layer(base: Vec, overlay: &[Rgb], mode: BlendMode, zone: &ZoneGrad } fn blend_pixel(base: Rgb, overlay: Rgb, mode: BlendMode, opacity: f32) -> Rgb { - let o = overlay.dim(opacity); - match mode { - BlendMode::Override => base.lerp(o, opacity), - BlendMode::Add => base + o, + // Each blend mode produces a fully-blended pixel at opacity=1.0; opacity + // then lerps between base (no effect) and the blended result. + let blended = match mode { + BlendMode::Override => overlay, + BlendMode::Add => base + overlay, BlendMode::Screen => Rgb { - r: 255 - ((255 - base.r as u16) * (255 - o.r as u16) / 255) as u8, - g: 255 - ((255 - base.g as u16) * (255 - o.g as u16) / 255) as u8, - b: 255 - ((255 - base.b as u16) * (255 - o.b as u16) / 255) as u8, + r: 255 - ((255 - base.r as u16) * (255 - overlay.r as u16) / 255) as u8, + g: 255 - ((255 - base.g as u16) * (255 - overlay.g as u16) / 255) as u8, + b: 255 - ((255 - base.b as u16) * (255 - overlay.b as u16) / 255) as u8, }, BlendMode::Multiply => Rgb { - r: (base.r as u16 * o.r as u16 / 255) as u8, - g: (base.g as u16 * o.g as u16 / 255) as u8, - b: (base.b as u16 * o.b as u16 / 255) as u8, + r: (base.r as u16 * overlay.r as u16 / 255) as u8, + g: (base.g as u16 * overlay.g as u16 / 255) as u8, + b: (base.b as u16 * overlay.b as u16 / 255) as u8, }, - } + }; + base.lerp(blended, opacity) } fn pixel_opacity(zone: &ZoneGradient, strip_px: usize, strip_len: usize) -> f32 { diff --git a/crates/effects/src/compositor/layer.rs b/crates/effects/src/compositor/layer.rs index 7d5273c..7f4e497 100644 --- a/crates/effects/src/compositor/layer.rs +++ b/crates/effects/src/compositor/layer.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use domain::{BlendMode, Rgb}; +use super::live_param::LiveParam; + pub trait LayerEffect: Send + Sync + 'static { fn render(&self, len: usize) -> Vec; } @@ -40,4 +42,5 @@ pub struct CompositeLayer { pub effect: Arc, pub mode: BlendMode, pub zone: ZoneGradient, + pub opacity: Arc>, } diff --git a/crates/effects/src/compositor/live_param.rs b/crates/effects/src/compositor/live_param.rs index 0947497..014a901 100644 --- a/crates/effects/src/compositor/live_param.rs +++ b/crates/effects/src/compositor/live_param.rs @@ -67,6 +67,10 @@ impl LiveParam { self.inner.write().unwrap().target = target; } + pub fn set_speed(&self, speed: f32) { + self.inner.write().unwrap().speed = speed; + } + pub fn set_immediate(&self, value: T) { let mut inner = self.inner.write().unwrap(); inner.current = value.clone(); diff --git a/crates/effects/src/scene_builder.rs b/crates/effects/src/scene_builder.rs index 7bf9a31..b4181f0 100644 --- a/crates/effects/src/scene_builder.rs +++ b/crates/effects/src/scene_builder.rs @@ -17,6 +17,7 @@ pub struct LayerSpec<'a> { pub zone_start: usize, pub zone_end: usize, pub zone_transition: usize, + pub opacity: Arc>, } pub fn build_composite( @@ -49,6 +50,7 @@ pub fn build_composite( end_pixel: spec.zone_end, transition_length: spec.zone_transition, }, + opacity: Arc::clone(&spec.opacity), }) }) .collect::, EffectError>>()?; diff --git a/crates/effects/tests/composite.rs b/crates/effects/tests/composite.rs index eed4128..335cbf8 100644 --- a/crates/effects/tests/composite.rs +++ b/crates/effects/tests/composite.rs @@ -27,6 +27,21 @@ fn full_layer(color: Rgb, mode: BlendMode, strip_len: usize) -> CompositeLayer { effect: Arc::new(Solid(color)), mode, zone: ZoneGradient::full_strip(strip_len), + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), + } +} + +fn full_layer_with_opacity( + color: Rgb, + mode: BlendMode, + strip_len: usize, + opacity: f32, +) -> CompositeLayer { + CompositeLayer { + effect: Arc::new(Solid(color)), + mode, + zone: ZoneGradient::full_strip(strip_len), + opacity: Arc::new(LiveParam::new(opacity, f32::MAX)), } } @@ -78,6 +93,7 @@ fn zone_restricts_rendering_to_pixel_range() { effect: Arc::new(Solid(Rgb::new(255, 0, 0))), mode: BlendMode::Override, zone, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }], brightness: brightness(255.0), primary_color: primary(), @@ -104,6 +120,7 @@ fn transition_extends_outside_logical_zone() { effect: Arc::new(Solid(Rgb::new(255, 0, 0))), mode: BlendMode::Override, zone, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }], brightness: brightness(255.0), primary_color: primary(), @@ -154,11 +171,13 @@ fn adjacent_zones_blend_without_black() { effect: Arc::new(Solid(Rgb::new(255, 0, 0))), mode: BlendMode::Override, zone: zone_a, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }, CompositeLayer { effect: Arc::new(Solid(Rgb::new(0, 0, 255))), mode: BlendMode::Override, zone: zone_b, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }, ], brightness: brightness(255.0), @@ -181,6 +200,131 @@ fn adjacent_zones_blend_without_black() { } } +#[test] +fn layer_opacity_zero_passes_through_base() { + let effect = CompositeEffect { + layers: vec![ + full_layer(Rgb::new(255, 0, 0), BlendMode::Override, 4), + full_layer_with_opacity(Rgb::new(0, 0, 255), BlendMode::Override, 4, 0.0), + ], + brightness: brightness(255.0), + primary_color: primary(), + }; + let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); + assert!(frame.iter().all(|p| *p == Rgb::new(255, 0, 0))); +} + +#[test] +fn layer_opacity_half_blends_with_base() { + let effect = CompositeEffect { + layers: vec![ + full_layer(Rgb::new(0, 0, 0), BlendMode::Override, 4), + full_layer_with_opacity(Rgb::new(200, 0, 0), BlendMode::Override, 4, 0.5), + ], + brightness: brightness(255.0), + primary_color: primary(), + }; + let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); + // Override at opacity 0.5 lerps base toward overlay halfway. + assert!( + frame + .iter() + .all(|p| p.r > 40 && p.r < 120 && p.g == 0 && p.b == 0) + ); +} + +#[test] +fn layer_opacity_one_equals_full_strength() { + let with = CompositeEffect { + layers: vec![full_layer_with_opacity( + Rgb::new(123, 45, 67), + BlendMode::Override, + 4, + 1.0, + )], + brightness: brightness(255.0), + primary_color: primary(), + }; + let without = CompositeEffect { + layers: vec![full_layer(Rgb::new(123, 45, 67), BlendMode::Override, 4)], + brightness: brightness(255.0), + primary_color: primary(), + }; + assert_eq!( + with.frames(&[Rgb::BLACK; 4]).next().unwrap(), + without.frames(&[Rgb::BLACK; 4]).next().unwrap() + ); +} + +#[test] +fn layer_opacity_scales_add_mode_contribution() { + let full = CompositeEffect { + layers: vec![ + full_layer(Rgb::new(50, 0, 0), BlendMode::Override, 4), + full_layer(Rgb::new(100, 0, 0), BlendMode::Add, 4), + ], + brightness: brightness(255.0), + primary_color: primary(), + }; + let dimmed = CompositeEffect { + layers: vec![ + full_layer(Rgb::new(50, 0, 0), BlendMode::Override, 4), + full_layer_with_opacity(Rgb::new(100, 0, 0), BlendMode::Add, 4, 0.5), + ], + brightness: brightness(255.0), + primary_color: primary(), + }; + let full_px = full.frames(&[Rgb::BLACK; 4]).next().unwrap()[0]; + let dim_px = dimmed.frames(&[Rgb::BLACK; 4]).next().unwrap()[0]; + assert!( + dim_px.r < full_px.r, + "opacity 0.5 should shrink Add contribution: full={} dim={}", + full_px.r, + dim_px.r + ); +} + +#[test] +fn opacity_live_param_tween_ramps_overlay_across_frames() { + // speed=0.25 means 4 ticks to reach target 1.0 starting from 0.0. + let opacity = Arc::new(LiveParam::new(0.0, 0.25)); + opacity.set(1.0); + let layer = CompositeLayer { + effect: Arc::new(Solid(Rgb::new(200, 0, 0))), + mode: BlendMode::Override, + zone: ZoneGradient::full_strip(1), + opacity: Arc::clone(&opacity), + }; + let effect = CompositeEffect { + layers: vec![layer], + brightness: brightness(255.0), + primary_color: primary(), + }; + let mut frames = effect.frames(&[Rgb::BLACK; 1]); + + let r1 = frames.next().unwrap()[0].r; + let r2 = frames.next().unwrap()[0].r; + let r3 = frames.next().unwrap()[0].r; + let r4 = frames.next().unwrap()[0].r; + assert!(r1 < r2, "frame1 ({r1}) should be dimmer than frame2 ({r2})"); + assert!(r2 < r3, "frame2 ({r2}) should be dimmer than frame3 ({r3})"); + assert!(r3 < r4, "frame3 ({r3}) should be dimmer than frame4 ({r4})"); + assert_eq!(r4, 200, "frame4 should reach full target"); +} + +#[test] +fn opacity_live_param_set_speed_changes_tween_rate() { + let opacity = Arc::new(LiveParam::new(0.0, 0.1)); + opacity.set(1.0); + // Tick once at speed 0.1: current = 0.1 + opacity.tick(); + assert!((opacity.get() - 0.1).abs() < 1e-5); + // Bump speed to 1.0; next tick snaps to target + opacity.set_speed(1.0); + opacity.tick(); + assert_eq!(opacity.get(), 1.0); +} + #[test] fn frames_iterator_is_infinite() { let effect = CompositeEffect { diff --git a/crates/effects/tests/script.rs b/crates/effects/tests/script.rs index 4927b0d..43cb26f 100644 --- a/crates/effects/tests/script.rs +++ b/crates/effects/tests/script.rs @@ -15,6 +15,7 @@ fn render(code: &str, len: usize) -> Vec { zone_start: 0, zone_end: len, zone_transition: 0, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }]; let brightness = Arc::new(LiveParam::new(255.0f32, f32::MAX)); let primary = Arc::new(LiveParam::new(Rgb::BLACK, f32::MAX)); @@ -33,6 +34,7 @@ fn script_compile_error_returns_err() { zone_start: 0, zone_end: 4, zone_transition: 0, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }]; let brightness = Arc::new(LiveParam::new(255.0f32, f32::MAX)); let primary = Arc::new(LiveParam::new(Rgb::BLACK, f32::MAX)); @@ -115,6 +117,7 @@ fn render_with_primary( zone_start: 0, zone_end: len, zone_transition: 0, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }]; let brightness = Arc::new(LiveParam::new(255.0f32, f32::MAX)); let composite = build_composite(&specs, len, brightness, primary).unwrap(); @@ -146,10 +149,11 @@ fn device_color_param_updates_when_primary_changes() { zone_start: 0, zone_end: 1, zone_transition: 0, + opacity: Arc::new(LiveParam::new(1.0, f32::MAX)), }]; let brightness = Arc::new(LiveParam::new(255.0f32, f32::MAX)); let composite = build_composite(&specs, 1, brightness, Arc::clone(&primary)).unwrap(); - let mut frames = composite.frames(&vec![Rgb::BLACK; 1]); + let mut frames = composite.frames(&[Rgb::BLACK; 1]); assert_eq!(frames.next().unwrap()[0], Rgb::new(255, 0, 0)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 84013be..824411f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2,8 +2,10 @@ pub mod effect; pub use effect::{Effect, FrameIter}; pub mod runtime; pub use runtime::command::RenderCommand; -pub use runtime::spawn; +pub use runtime::{blend, fade_to_solid, solid_frames, spawn}; pub mod output; pub use output::Output; pub mod queue; pub use queue::EffectQueue; + +pub const FRAME_DURATION_MS: u32 = 16; diff --git a/crates/engine/src/queue.rs b/crates/engine/src/queue.rs index b9ed145..d7ff25a 100644 --- a/crates/engine/src/queue.rs +++ b/crates/engine/src/queue.rs @@ -1,39 +1,84 @@ use std::sync::mpsc; -use crate::{Effect, RenderCommand}; +use domain::{Rgb, TransitionSpec}; + +use crate::{Effect, FRAME_DURATION_MS, RenderCommand}; #[derive(Clone)] pub struct EffectQueue { sender: mpsc::Sender, - crossfade_frames: usize, + default_spec: TransitionSpec, } impl EffectQueue { - pub fn new(crossfade_ms: u32) -> (Self, mpsc::Receiver) { + pub fn new(default_spec: TransitionSpec) -> (Self, mpsc::Receiver) { let (sender, receiver) = mpsc::channel(); - let crossfade_frames = crossfade_ms as usize / 16; ( Self { sender, - crossfade_frames, + default_spec, }, receiver, ) } + pub fn default_spec(&self) -> TransitionSpec { + self.default_spec + } + pub fn brightness_speed(&self) -> f32 { - 255.0 / self.crossfade_frames.max(1) as f32 + 255.0 / self.default_frames().max(1) as f32 } pub fn color_speed(&self) -> f32 { - 255.0 / self.crossfade_frames.max(1) as f32 + 255.0 / self.default_frames().max(1) as f32 } pub fn enqueue(&self, effect: Box) { - self.sender.send(RenderCommand::Execute(effect)).unwrap(); + self.enqueue_with(effect, self.default_spec); + } + + pub fn enqueue_with(&self, effect: Box, spec: TransitionSpec) { + self.dispatch(RenderCommand::Execute(effect, spec)); + } + + pub fn set_color(&self, color: Rgb) { + self.set_color_with(color, self.default_spec); + } + + pub fn set_color_with(&self, color: Rgb, spec: TransitionSpec) { + self.dispatch(RenderCommand::SetColor(color, spec)); + } + + pub fn set_brightness(&self, brightness: u8) { + self.set_brightness_with(brightness, self.default_spec); + } + + pub fn set_brightness_with(&self, brightness: u8, spec: TransitionSpec) { + self.dispatch(RenderCommand::SetBrightness(brightness, spec)); + } + + pub fn set_on_off(&self, on: bool) { + self.set_on_off_with(on, self.default_spec); + } + + pub fn set_on_off_with(&self, on: bool, spec: TransitionSpec) { + self.dispatch(RenderCommand::SetOnOff(on, spec)); + } + + pub fn halt(&self) { + self.dispatch(RenderCommand::Halt); } pub fn send(&self, cmd: RenderCommand) { + self.dispatch(cmd); + } + + fn dispatch(&self, cmd: RenderCommand) { self.sender.send(cmd).unwrap(); } + + fn default_frames(&self) -> u32 { + self.default_spec.frames(FRAME_DURATION_MS) + } } diff --git a/crates/engine/src/runtime/command.rs b/crates/engine/src/runtime/command.rs index 618b79b..65713d9 100644 --- a/crates/engine/src/runtime/command.rs +++ b/crates/engine/src/runtime/command.rs @@ -1,10 +1,10 @@ use crate::Effect; -use domain::Rgb; +use domain::{Rgb, TransitionSpec}; pub enum RenderCommand { - Execute(Box), - SetBrightness(u8), - SetOnOff(bool), - SetColor(Rgb), + Execute(Box, TransitionSpec), + SetBrightness(u8, TransitionSpec), + SetOnOff(bool, TransitionSpec), + SetColor(Rgb, TransitionSpec), Halt, } diff --git a/crates/engine/src/runtime/executor.rs b/crates/engine/src/runtime/executor.rs index 8de376e..1c9a006 100644 --- a/crates/engine/src/runtime/executor.rs +++ b/crates/engine/src/runtime/executor.rs @@ -1,22 +1,18 @@ use super::{ ActiveEffect, RenderCommand, - transition::{crossfade, transition_to_state}, + transition::{blend, fade_to_solid, solid_frames}, }; -use crate::Output; -use domain::DeviceState; +use crate::{FRAME_DURATION_MS, Output}; +use domain::{DeviceState, Rgb, TransitionSpec}; use std::sync::mpsc::{Receiver, TryRecvError}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -const FRAME_DURATION: Duration = Duration::from_millis(16); +const FRAME_DURATION: Duration = Duration::from_millis(FRAME_DURATION_MS as u64); -pub fn spawn( - output: Box, - cmd_rx: Receiver, - crossfade_frames: usize, -) -> JoinHandle<()> { +pub fn spawn(output: Box, cmd_rx: Receiver) -> JoinHandle<()> { thread::spawn(move || { - let mut ex = Executor::new(output, crossfade_frames); + let mut ex = Executor::new(output); loop { let frame_start = Instant::now(); @@ -51,58 +47,69 @@ struct Executor { output: Box, state: DeviceState, current: ActiveEffect, - crossfade_frames: usize, } impl Executor { - fn new(output: Box, crossfade_frames: usize) -> Self { + fn new(output: Box) -> Self { Self { output, state: DeviceState::default(), current: None, - crossfade_frames, } } fn handle(&mut self, cmd: RenderCommand) { match cmd { - RenderCommand::Execute(effect) => { - let from = self.output.pixels().to_vec(); - let inner = effect.frames(self.output.pixels()); - self.current = Some(crossfade(from, inner, self.crossfade_frames)); + RenderCommand::Execute(effect, spec) => { + let to = effect.frames(self.output.pixels()); + let from = self.take_from(); + self.current = Some(blend(from, to, spec.frames(FRAME_DURATION_MS), spec.curve)); } RenderCommand::Halt => self.current = None, - RenderCommand::SetBrightness(b) => { + RenderCommand::SetBrightness(b, spec) => { self.state.brightness = b; - transition_to_state( - &self.state, - &mut self.current, - &mut *self.output, - self.crossfade_frames, - ); + self.transition_to_state(spec); } - RenderCommand::SetOnOff(on) => { + RenderCommand::SetOnOff(on, spec) => { self.state.on = on; - transition_to_state( - &self.state, - &mut self.current, - &mut *self.output, - self.crossfade_frames, - ); + self.transition_to_state(spec); } - RenderCommand::SetColor(color) => { + RenderCommand::SetColor(color, spec) => { self.state.color = color; self.state.on = true; - transition_to_state( - &self.state, - &mut self.current, - &mut *self.output, - self.crossfade_frames, - ); + self.transition_to_state(spec); } } } + fn transition_to_state(&mut self, spec: TransitionSpec) { + let target = self.target_pixels(); + let frames = spec.frames(FRAME_DURATION_MS); + if frames == 0 { + self.output.pixels_mut().copy_from_slice(&target); + self.output.show(); + self.current = None; + return; + } + let from = self.take_from(); + self.current = Some(fade_to_solid(from, target, frames, spec.curve)); + } + + fn take_from(&mut self) -> crate::FrameIter { + self.current + .take() + .unwrap_or_else(|| solid_frames(self.output.pixels().to_vec())) + } + + fn target_pixels(&self) -> Vec { + let color = if self.state.on { + self.state.color.with_brightness(self.state.brightness) + } else { + Rgb::BLACK + }; + vec![color; self.output.pixels().len()] + } + fn tick(&mut self) { if let Some(frame) = self.current.as_mut().and_then(|it| it.next()) { if frame.as_slice() != self.output.pixels() { diff --git a/crates/engine/src/runtime/mod.rs b/crates/engine/src/runtime/mod.rs index 129b87e..69c62b4 100644 --- a/crates/engine/src/runtime/mod.rs +++ b/crates/engine/src/runtime/mod.rs @@ -1,9 +1,10 @@ pub mod command; mod executor; -mod transition; +pub mod transition; use crate::FrameIter; type ActiveEffect = Option; pub use command::RenderCommand; pub use executor::spawn; +pub use transition::{blend, fade_to_solid, solid_frames}; diff --git a/crates/engine/src/runtime/transition.rs b/crates/engine/src/runtime/transition.rs deleted file mode 100644 index dbcf00a..0000000 --- a/crates/engine/src/runtime/transition.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::ActiveEffect; -use crate::{FrameIter, Output}; -use domain::{DeviceState, Rgb}; - -pub fn transition_to_state( - state: &DeviceState, - current: &mut ActiveEffect, - output: &mut dyn Output, - crossfade_frames: usize, -) { - let target = if state.on { - state.color.with_brightness(state.brightness) - } else { - Rgb::BLACK - }; - - if crossfade_frames == 0 { - output.pixels_mut().fill(target); - output.show(); - *current = None; - return; - } - - let from = output.pixels().to_vec(); - *current = Some(fade_to_solid(from, target, crossfade_frames)); -} - -pub fn crossfade(from: Vec, mut inner: FrameIter, frames: usize) -> FrameIter { - if frames == 0 { - return inner; - } - let mut progress = 0; - Box::new(std::iter::from_fn(move || { - let frame = inner.next()?; - if progress >= frames { - return Some(frame); - } - let t = (progress + 1) as f32 / frames as f32; - progress += 1; - Some( - from.iter() - .zip(frame.iter()) - .map(|(a, b)| a.lerp(*b, t)) - .collect(), - ) - })) -} - -fn fade_to_solid(from: Vec, target: Rgb, frames: usize) -> FrameIter { - let mut i = 0; - Box::new(std::iter::from_fn(move || { - if i >= frames { - return None; - } - let t = (i + 1) as f32 / frames as f32; - i += 1; - Some(from.iter().map(|p| p.lerp(target, t)).collect()) - })) -} diff --git a/crates/engine/src/runtime/transition/blend.rs b/crates/engine/src/runtime/transition/blend.rs new file mode 100644 index 0000000..ff77faa --- /dev/null +++ b/crates/engine/src/runtime/transition/blend.rs @@ -0,0 +1,34 @@ +use domain::{Rgb, TransitionCurve}; + +use crate::FrameIter; + +pub fn blend( + mut from: FrameIter, + mut to: FrameIter, + frames: u32, + curve: TransitionCurve, +) -> FrameIter { + if frames == 0 { + return to; + } + + let mut progress: u32 = 0; + let mut last_from: Option> = None; + Box::new(std::iter::from_fn(move || { + let to_frame = to.next()?; + if progress >= frames { + return Some(to_frame); + } + let from_frame = from.next().or_else(|| last_from.clone())?; + last_from = Some(from_frame.clone()); + progress += 1; + let t = curve.weight(progress as f32 / frames as f32); + Some( + from_frame + .iter() + .zip(to_frame.iter()) + .map(|(a, b)| a.lerp(*b, t)) + .collect(), + ) + })) +} diff --git a/crates/engine/src/runtime/transition/mod.rs b/crates/engine/src/runtime/transition/mod.rs new file mode 100644 index 0000000..028f497 --- /dev/null +++ b/crates/engine/src/runtime/transition/mod.rs @@ -0,0 +1,5 @@ +pub mod blend; +pub mod solid; + +pub use blend::blend; +pub use solid::{fade_to_solid, solid_frames}; diff --git a/crates/engine/src/runtime/transition/solid.rs b/crates/engine/src/runtime/transition/solid.rs new file mode 100644 index 0000000..88ba0d8 --- /dev/null +++ b/crates/engine/src/runtime/transition/solid.rs @@ -0,0 +1,37 @@ +use domain::{Rgb, TransitionCurve}; + +use crate::FrameIter; + +pub fn solid_frames(pixels: Vec) -> FrameIter { + Box::new(std::iter::repeat(pixels)) +} + +pub fn fade_to_solid( + mut from: FrameIter, + target: Vec, + frames: u32, + curve: TransitionCurve, +) -> FrameIter { + if frames == 0 { + return Box::new(std::iter::once(target)); + } + + let mut progress: u32 = 0; + let mut last_from: Option> = None; + Box::new(std::iter::from_fn(move || { + if progress >= frames { + return None; + } + let from_frame = from.next().or_else(|| last_from.clone())?; + last_from = Some(from_frame.clone()); + progress += 1; + let t = curve.weight(progress as f32 / frames as f32); + Some( + from_frame + .iter() + .zip(target.iter()) + .map(|(a, b)| a.lerp(*b, t)) + .collect(), + ) + })) +} diff --git a/crates/engine/tests/queue.rs b/crates/engine/tests/queue.rs index 4651b8e..a563125 100644 --- a/crates/engine/tests/queue.rs +++ b/crates/engine/tests/queue.rs @@ -1,4 +1,4 @@ -use domain::Rgb; +use domain::{Rgb, TransitionCurve, TransitionSpec}; use engine::{Effect, EffectQueue, FrameIter, RenderCommand}; struct Noop; @@ -8,29 +8,80 @@ impl Effect for Noop { } } +fn spec(duration_ms: u32) -> TransitionSpec { + TransitionSpec::new(duration_ms, TransitionCurve::Linear) +} + #[test] -fn brightness_speed_is_proportional_to_crossfade_ms() { - let (fast, _) = EffectQueue::new(160); - let (slow, _) = EffectQueue::new(1600); +fn brightness_speed_is_proportional_to_default_fade_duration() { + let (fast, _) = EffectQueue::new(spec(160)); + let (slow, _) = EffectQueue::new(spec(1600)); assert!(fast.brightness_speed() > slow.brightness_speed()); } #[test] -fn zero_crossfade_gives_max_brightness_speed() { - let (q, _) = EffectQueue::new(0); +fn zero_fade_duration_gives_max_brightness_speed() { + let (q, _) = EffectQueue::new(TransitionSpec::INSTANT); assert_eq!(q.brightness_speed(), 255.0); } #[test] -fn enqueue_sends_execute_command() { - let (q, rx) = EffectQueue::new(0); +fn enqueue_sends_execute_command_with_default_spec() { + let default = spec(320); + let (q, rx) = EffectQueue::new(default); q.enqueue(Box::new(Noop)); - assert!(matches!(rx.try_recv().unwrap(), RenderCommand::Execute(_))); + match rx.try_recv().unwrap() { + RenderCommand::Execute(_, s) => assert_eq!(s, default), + _ => panic!("expected Execute command"), + } +} + +#[test] +fn enqueue_with_overrides_default_spec() { + let (q, rx) = EffectQueue::new(spec(0)); + let override_spec = TransitionSpec::new(500, TransitionCurve::EaseOut); + q.enqueue_with(Box::new(Noop), override_spec); + match rx.try_recv().unwrap() { + RenderCommand::Execute(_, s) => assert_eq!(s, override_spec), + _ => panic!("expected Execute command"), + } +} + +#[test] +fn set_color_emits_command_with_default_spec() { + let default = spec(80); + let (q, rx) = EffectQueue::new(default); + q.set_color(Rgb::new(10, 20, 30)); + match rx.try_recv().unwrap() { + RenderCommand::SetColor(color, s) => { + assert_eq!(color, Rgb::new(10, 20, 30)); + assert_eq!(s, default); + } + _ => panic!("expected SetColor command"), + } } #[test] -fn send_delivers_command_directly() { - let (q, rx) = EffectQueue::new(0); - q.send(RenderCommand::Halt); +fn set_color_with_threads_caller_spec_through() { + let (q, rx) = EffectQueue::new(spec(0)); + let custom = TransitionSpec::new(800, TransitionCurve::EaseInOut); + q.set_color_with(Rgb::new(1, 2, 3), custom); + match rx.try_recv().unwrap() { + RenderCommand::SetColor(_, s) => assert_eq!(s, custom), + _ => panic!("expected SetColor command"), + } +} + +#[test] +fn halt_emits_halt_command() { + let (q, rx) = EffectQueue::new(spec(0)); + q.halt(); assert!(matches!(rx.try_recv().unwrap(), RenderCommand::Halt)); } + +#[test] +fn default_spec_returns_constructor_value() { + let s = spec(420); + let (q, _) = EffectQueue::new(s); + assert_eq!(q.default_spec(), s); +} diff --git a/crates/engine/tests/runtime.rs b/crates/engine/tests/runtime.rs index a332390..41b0286 100644 --- a/crates/engine/tests/runtime.rs +++ b/crates/engine/tests/runtime.rs @@ -1,4 +1,4 @@ -use domain::Rgb; +use domain::{Rgb, TransitionCurve, TransitionSpec}; use engine::{Effect, FrameIter, Output, RenderCommand}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; @@ -54,10 +54,13 @@ fn wait_for(shown: &Arc>>>, count: usize) -> Vec> { fn set_color_renders_solid_frame() { let (mock, shown) = MockOutput::new(4); let (tx, rx) = mpsc::channel(); - engine::spawn(Box::new(mock), rx, 0); + engine::spawn(Box::new(mock), rx); - tx.send(RenderCommand::SetColor(Rgb::new(255, 0, 0))) - .unwrap(); + tx.send(RenderCommand::SetColor( + Rgb::new(255, 0, 0), + TransitionSpec::INSTANT, + )) + .unwrap(); let frames = wait_for(&shown, 1); assert!( frames @@ -72,23 +75,25 @@ fn set_color_renders_solid_frame() { fn set_on_false_renders_black() { let (mock, shown) = MockOutput::new(4); let (tx, rx) = mpsc::channel(); - engine::spawn(Box::new(mock), rx, 0); + engine::spawn(Box::new(mock), rx); - tx.send(RenderCommand::SetColor(Rgb::new(0, 255, 0))) - .unwrap(); + tx.send(RenderCommand::SetColor( + Rgb::new(0, 255, 0), + TransitionSpec::INSTANT, + )) + .unwrap(); wait_for(&shown, 1); - tx.send(RenderCommand::SetOnOff(false)).unwrap(); + tx.send(RenderCommand::SetOnOff(false, TransitionSpec::INSTANT)) + .unwrap(); let frames = wait_for(&shown, 2); assert!(frames.last().unwrap().iter().all(|p| *p == Rgb::BLACK)); } #[test] fn execute_renders_effect_frames() { - use engine::Effect; - struct ThreeFrames; impl Effect for ThreeFrames { - fn frames(&self, pixels: &[Rgb]) -> engine::FrameIter { + fn frames(&self, pixels: &[Rgb]) -> FrameIter { let len = pixels.len(); let colors = [ Rgb::new(255, 0, 0), @@ -110,10 +115,13 @@ fn execute_renders_effect_frames() { let (mock, shown) = MockOutput::new(4); let (tx, rx) = mpsc::channel(); - engine::spawn(Box::new(mock), rx, 0); + engine::spawn(Box::new(mock), rx); - tx.send(RenderCommand::Execute(Box::new(ThreeFrames))) - .unwrap(); + tx.send(RenderCommand::Execute( + Box::new(ThreeFrames), + TransitionSpec::INSTANT, + )) + .unwrap(); let frames = wait_for(&shown, 3); let expected = [ Rgb::new(255, 0, 0), @@ -126,7 +134,7 @@ fn execute_renders_effect_frames() { } #[test] -fn crossfade_blends_first_frame_between_current_and_target() { +fn execute_with_fade_blends_first_frame_between_current_and_target() { struct Solid(Rgb); impl Effect for Solid { fn frames(&self, pixels: &[Rgb]) -> FrameIter { @@ -138,19 +146,20 @@ fn crossfade_blends_first_frame_between_current_and_target() { let (mock, shown) = MockOutput::new(2); let (tx, rx) = mpsc::channel(); - // Use 4 crossfade frames so the first rendered frame is a blend - engine::spawn(Box::new(mock), rx, 4); + engine::spawn(Box::new(mock), rx); - // Set current pixels to white - tx.send(RenderCommand::SetColor(Rgb::new(255, 255, 255))) - .unwrap(); + tx.send(RenderCommand::SetColor( + Rgb::new(255, 255, 255), + TransitionSpec::INSTANT, + )) + .unwrap(); wait_for(&shown, 1); - // Crossfade to black over 4 frames; first blended frame should be grey-ish - tx.send(RenderCommand::Execute(Box::new(Solid(Rgb::BLACK)))) + // 4-frame fade from white to black (4 * 16ms = 64ms) + let fade = TransitionSpec::new(64, TransitionCurve::Linear); + tx.send(RenderCommand::Execute(Box::new(Solid(Rgb::BLACK)), fade)) .unwrap(); let frames = wait_for(&shown, 2); let blended = &frames[1]; - // First frame of crossfade: t = 1/4 → should be darker than white but not black assert!(blended[0].r < 255 && blended[0].r > 0); } diff --git a/crates/engine/tests/transition_blend.rs b/crates/engine/tests/transition_blend.rs new file mode 100644 index 0000000..c376ed4 --- /dev/null +++ b/crates/engine/tests/transition_blend.rs @@ -0,0 +1,99 @@ +use domain::{Rgb, TransitionCurve}; +use engine::{FrameIter, blend, solid_frames}; + +fn white() -> Rgb { + Rgb::new(255, 255, 255) +} + +fn black() -> Rgb { + Rgb::BLACK +} + +fn finite(frames: Vec>) -> FrameIter { + Box::new(frames.into_iter()) +} + +#[test] +fn zero_frames_passes_through_to_iter_unchanged() { + let from = solid_frames(vec![white(); 1]); + let to = finite(vec![vec![black(); 1], vec![black(); 1]]); + let collected: Vec<_> = blend(from, to, 0, TransitionCurve::Linear).collect(); + assert_eq!(collected, vec![vec![black(); 1], vec![black(); 1]]); +} + +#[test] +fn linear_blend_lerps_endpoints_proportionally() { + let from = solid_frames(vec![white(); 1]); + let to = solid_frames(vec![black(); 1]); + let mut iter = blend(from, to, 4, TransitionCurve::Linear); + let first = iter.next().unwrap(); + assert_eq!(first[0], white().lerp(black(), 0.25)); + let second = iter.next().unwrap(); + assert_eq!(second[0], white().lerp(black(), 0.5)); +} + +#[test] +fn last_blend_frame_reaches_to_value_at_progress_n() { + let from = solid_frames(vec![white(); 1]); + let to = solid_frames(vec![black(); 1]); + let mut iter = blend(from, to, 3, TransitionCurve::Linear); + let mut last = vec![white(); 1]; + for _ in 0..3 { + last = iter.next().unwrap(); + } + assert_eq!(last, vec![black(); 1]); +} + +#[test] +fn after_blend_completes_iter_passes_through_to_values() { + let from = solid_frames(vec![white(); 1]); + let to = finite(vec![ + vec![Rgb::new(10, 10, 10); 1], + vec![Rgb::new(20, 20, 20); 1], + vec![Rgb::new(30, 30, 30); 1], + ]); + let mut iter = blend(from, to, 1, TransitionCurve::Linear); + iter.next(); + assert_eq!(iter.next().unwrap(), vec![Rgb::new(20, 20, 20); 1]); + assert_eq!(iter.next().unwrap(), vec![Rgb::new(30, 30, 30); 1]); +} + +#[test] +fn iter_ends_when_to_iter_ends() { + let from = solid_frames(vec![white(); 1]); + let to = finite(vec![vec![black(); 1]; 2]); + let mut iter = blend(from, to, 5, TransitionCurve::Linear); + iter.next(); + iter.next(); + assert!(iter.next().is_none()); +} + +#[test] +fn ease_in_curve_starts_closer_to_from_than_linear() { + let from = solid_frames(vec![white(); 1]); + let to = solid_frames(vec![black(); 1]); + let linear_first = blend( + solid_frames(vec![white(); 1]), + solid_frames(vec![black(); 1]), + 4, + TransitionCurve::Linear, + ) + .next() + .unwrap()[0] + .r; + let ease_first = blend(from, to, 4, TransitionCurve::EaseIn).next().unwrap()[0].r; + assert!( + ease_first > linear_first, + "ease_in should remain whiter (higher r) at t=1/4 than linear" + ); +} + +#[test] +fn exhausted_from_iter_holds_last_frame_for_remaining_blend() { + let from: FrameIter = Box::new(std::iter::once(vec![white(); 1])); + let to = solid_frames(vec![black(); 1]); + let mut iter = blend(from, to, 3, TransitionCurve::Linear); + for _ in 0..3 { + assert!(iter.next().is_some()); + } +} diff --git a/crates/engine/tests/transition_solid.rs b/crates/engine/tests/transition_solid.rs new file mode 100644 index 0000000..3784557 --- /dev/null +++ b/crates/engine/tests/transition_solid.rs @@ -0,0 +1,62 @@ +use domain::{Rgb, TransitionCurve}; +use engine::{fade_to_solid, solid_frames}; + +fn red() -> Rgb { + Rgb::new(255, 0, 0) +} + +fn black() -> Rgb { + Rgb::BLACK +} + +#[test] +fn solid_frames_repeats_the_same_pixel_buffer() { + let mut iter = solid_frames(vec![red(); 3]); + for _ in 0..5 { + assert_eq!(iter.next().unwrap(), vec![red(); 3]); + } +} + +#[test] +fn fade_to_solid_with_zero_frames_yields_target_once_then_ends() { + let from = solid_frames(vec![red(); 2]); + let mut iter = fade_to_solid(from, vec![black(); 2], 0, TransitionCurve::Linear); + assert_eq!(iter.next().unwrap(), vec![black(); 2]); + assert!(iter.next().is_none()); +} + +#[test] +fn fade_to_solid_emits_exactly_n_frames_then_ends() { + let from = solid_frames(vec![red(); 1]); + let mut iter = fade_to_solid(from, vec![black(); 1], 4, TransitionCurve::Linear); + for _ in 0..4 { + assert!(iter.next().is_some()); + } + assert!(iter.next().is_none()); +} + +#[test] +fn fade_to_solid_final_frame_equals_target_with_linear_curve() { + let from = solid_frames(vec![red(); 1]); + let mut iter = fade_to_solid(from, vec![black(); 1], 4, TransitionCurve::Linear); + let last = (0..4).filter_map(|_| iter.next()).last().unwrap(); + assert_eq!(last, vec![black(); 1]); +} + +#[test] +fn fade_to_solid_intermediate_frame_lies_between_endpoints_with_linear_curve() { + let from = solid_frames(vec![red(); 1]); + let mut iter = fade_to_solid(from, vec![black(); 1], 4, TransitionCurve::Linear); + let first = iter.next().unwrap(); + assert!(first[0].r > 0 && first[0].r < 255); +} + +#[test] +fn fade_to_solid_with_exhausted_from_iter_holds_last_frame() { + let from: engine::FrameIter = Box::new(std::iter::once(vec![red(); 1])); + let mut iter = fade_to_solid(from, vec![black(); 1], 3, TransitionCurve::Linear); + for _ in 0..3 { + assert!(iter.next().is_some()); + } + assert!(iter.next().is_none()); +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 71681c3..8aebb77 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use actix_web::{App, HttpServer, web}; use application::{SceneRuntime, StateEventBus}; +use domain::TransitionSpec; use effects::load_builtins; use engine::EffectQueue; use settings::Settings; @@ -31,8 +32,12 @@ async fn main() -> std::io::Result<()> { let strip_len = app_settings.led_controller.driver.pixel_count; - let (effect_queue, rx_effect_processor) = - EffectQueue::new(app_settings.led_controller.crossfade_ms); + let default_spec = TransitionSpec::new( + app_settings.transitions.default_fade_ms, + app_settings.transitions.default_curve, + ); + + let (effect_queue, rx_effect_processor) = EffectQueue::new(default_spec); let state_file = app_settings.state_dir.join("state.json"); let initial_state = persistence::load(&state_file).unwrap_or_else(|e| { @@ -84,8 +89,7 @@ async fn main() -> std::io::Result<()> { #[cfg(feature = "google")] let command_dispatcher = api_google::commands::CommandDispatcher::new(); - let crossfade_frames = app_settings.led_controller.crossfade_ms as usize / 16; - engine::spawn(Box::new(led), rx_effect_processor, crossfade_frames); + engine::spawn(Box::new(led), rx_effect_processor); let ip_address = app_settings.server.ip_address; let port = app_settings.server.port; @@ -114,7 +118,9 @@ async fn main() -> std::io::Result<()> { .configure(api::effects::config) .configure(api::events::config) .configure(api::zones::config) - // active_scene must precede scenes: /scenes/active must match before /scenes/{id} + // state_stack and active_scene must precede scenes: /scenes/active/* must + // match before the wildcard /scenes/{id} + .configure(api::state_stack::config) .configure(api::active_scene::config) .configure(api::scenes::config) .configure(api_legacy::config); diff --git a/crates/server/src/scene_runtime/mod.rs b/crates/server/src/scene_runtime/mod.rs index bf4ad46..5bb1562 100644 --- a/crates/server/src/scene_runtime/mod.rs +++ b/crates/server/src/scene_runtime/mod.rs @@ -6,12 +6,12 @@ use std::sync::{Arc, Mutex}; #[cfg(feature = "google")] use chrono::Utc; -use engine::{EffectQueue, RenderCommand}; +use engine::EffectQueue; #[cfg(feature = "google")] use api_google::effects::{ColorLoop, Sleep, Wake}; use application::{SceneRuntime, SceneSnapshot, StateEventBus}; -use domain::Rgb; +use domain::{Rgb, TransitionSpec}; use effects::{BuiltinEffect, LiveParam}; use persistence::PersistedState; use store::Store; @@ -28,6 +28,7 @@ pub struct RenderTaskRuntime { pub(crate) builtins: Arc>, pub(crate) primary_color: Arc>, pub(crate) strip_len: usize, + pub(crate) live_opacities: Arc>>>>, } impl RenderTaskRuntime { @@ -55,6 +56,7 @@ impl RenderTaskRuntime { builtins: Arc::new(builtins.into_iter().map(|b| (b.id(), b)).collect()), primary_color: Arc::new(LiveParam::new(initial.color, color_speed)), strip_len, + live_opacities: Arc::new(Mutex::new(HashMap::new())), } } @@ -76,35 +78,39 @@ impl RenderTaskRuntime { } impl SceneRuntime for RenderTaskRuntime { - fn set_color(&self, color: Rgb) { + fn default_spec(&self) -> TransitionSpec { + self.queue.default_spec() + } + + fn set_color_with(&self, color: Rgb, spec: TransitionSpec) { self.primary_color.set(color); self.with_state(|state, queue| { state.color = color; state.on = true; if matches!(state.scene, ActiveScene::Idle) { - queue.send(RenderCommand::SetColor(color)); + queue.set_color_with(color, spec); } }); } - fn set_brightness(&self, brightness: u8) { + fn set_brightness_with(&self, brightness: u8, spec: TransitionSpec) { self.with_state(|state, queue| { state.brightness = brightness; if let ActiveScene::Running { brightness: param } = &state.scene { param.set(brightness as f32); } else { - queue.send(RenderCommand::SetBrightness(brightness)); + queue.set_brightness_with(brightness, spec); } }); } - fn set_on_off(&self, on: bool) { + fn set_on_off_with(&self, on: bool, spec: TransitionSpec) { self.with_state(|state, queue| { state.on = on; if let ActiveScene::Running { brightness } = &state.scene { brightness.set(if on { state.brightness as f32 } else { 0.0 }); } else { - queue.send(RenderCommand::SetOnOff(on)); + queue.set_on_off_with(on, spec); } }); } @@ -112,7 +118,7 @@ impl SceneRuntime for RenderTaskRuntime { fn halt(&self) { self.with_state(|state, queue| { state.scene = ActiveScene::Idle; - queue.send(RenderCommand::Halt); + queue.halt(); }); } @@ -120,7 +126,7 @@ impl SceneRuntime for RenderTaskRuntime { self.with_state(|state, queue| { let on = state.on; state.scene = ActiveScene::Idle; - queue.send(RenderCommand::SetOnOff(on)); + queue.set_on_off(on); }); } @@ -184,8 +190,8 @@ impl SceneRuntime for RenderTaskRuntime { }); } - fn reload_active(&self) { - SceneReload::from_runtime(self).spawn(); + fn reload_active_with(&self, spec: TransitionSpec) { + SceneReload::from_runtime(self, spec).spawn(); } fn snapshot(&self) -> SceneSnapshot { diff --git a/crates/server/src/scene_runtime/reload.rs b/crates/server/src/scene_runtime/reload.rs index 9a39603..79151d1 100644 --- a/crates/server/src/scene_runtime/reload.rs +++ b/crates/server/src/scene_runtime/reload.rs @@ -1,12 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use application::StateEventBus; -use domain::{BlendMode, ParamDef, ParamValue, Rgb}; +use domain::{BlendMode, ParamDef, ParamValue, Rgb, TransitionSpec}; use effects::composite::CompositeEffect; use effects::scene_builder::LayerSpec; use effects::{BuiltinEffect, LiveParam, build_composite}; -use engine::{EffectQueue, RenderCommand}; +use engine::{EffectQueue, FRAME_DURATION_MS}; use persistence::PersistedState; use store::{LayerRecord, Store, ZoneRecord}; use tokio::sync::watch; @@ -20,6 +20,7 @@ type LayerSpecData = ( HashMap, BlendMode, ZoneRecord, + Arc>, ); pub(super) struct SceneReload { @@ -32,10 +33,12 @@ pub(super) struct SceneReload { primary_color: Arc>, strip_len: usize, brightness_val: f32, + spec: TransitionSpec, + live_opacities: Arc>>>>, } impl SceneReload { - pub(super) fn from_runtime(rt: &RenderTaskRuntime) -> Self { + pub(super) fn from_runtime(rt: &RenderTaskRuntime, spec: TransitionSpec) -> Self { let brightness_val = { let s = rt.state.lock().unwrap(); if s.on { s.brightness as f32 } else { 0.0 } @@ -50,6 +53,8 @@ impl SceneReload { primary_color: Arc::clone(&rt.primary_color), strip_len: rt.strip_len, brightness_val, + spec, + live_opacities: Arc::clone(&rt.live_opacities), } } @@ -59,11 +64,14 @@ impl SceneReload { async fn run(self) { let Some(enabled) = self.enabled_layers().await else { + self.gc_opacities(&HashSet::new()); self.go_idle(); return; }; + let kept_ids: HashSet = enabled.iter().map(|l| l.id.clone()).collect(); let specs_data = self.resolve_specs(&enabled).await; + self.gc_opacities(&kept_ids); if specs_data.is_empty() { self.go_idle(); return; @@ -90,7 +98,7 @@ impl SceneReload { let (snapshot, persisted) = { let mut s = self.state.lock().unwrap(); s.scene = ActiveScene::Idle; - self.queue.send(RenderCommand::Halt); + self.queue.halt(); (build_snapshot(&s), extract_persisted(&s)) }; self.emit(snapshot, persisted); @@ -102,7 +110,7 @@ impl SceneReload { s.scene = ActiveScene::Running { brightness }; (build_snapshot(&s), extract_persisted(&s)) }; - self.queue.enqueue(Box::new(composite)); + self.queue.enqueue_with(Box::new(composite), self.spec); self.emit(snapshot, persisted); } @@ -137,7 +145,35 @@ impl SceneReload { async fn resolve_one(&self, layer: &LayerRecord) -> Option { let (script, defs) = self.resolve_effect(&layer.effect_id).await?; let zone = self.resolve_zone(&layer.zone_id).await?; - Some((script, defs, layer.params.clone(), layer.blend_mode, zone)) + let opacity = self.live_opacity_for(&layer.id, layer.opacity); + Some(( + script, + defs, + layer.params.clone(), + layer.blend_mode, + zone, + opacity, + )) + } + + fn live_opacity_for(&self, layer_id: &str, target: f32) -> Arc> { + let frames = self.spec.frames(FRAME_DURATION_MS).max(1) as f32; + let speed = (1.0 / frames).max(f32::EPSILON); + let mut map = self.live_opacities.lock().unwrap(); + let lp = map + .entry(layer_id.to_string()) + .or_insert_with(|| Arc::new(LiveParam::new(0.0, speed))) + .clone(); + lp.set_speed(speed); + lp.set(target); + lp + } + + fn gc_opacities(&self, kept_ids: &HashSet) { + self.live_opacities + .lock() + .unwrap() + .retain(|id, _| kept_ids.contains(id)); } async fn resolve_effect(&self, effect_id: &str) -> Option<(String, Vec)> { @@ -172,7 +208,7 @@ impl SceneReload { fn to_layer_specs(data: &[LayerSpecData]) -> Vec> { data.iter() - .map(|(script, defs, params, mode, zone)| LayerSpec { + .map(|(script, defs, params, mode, zone, opacity)| LayerSpec { script: script.as_str(), param_defs: defs.as_slice(), params, @@ -180,6 +216,7 @@ fn to_layer_specs(data: &[LayerSpecData]) -> Vec> { zone_start: zone.start_pixel as usize, zone_end: zone.end_pixel as usize, zone_transition: zone.transition_length as usize, + opacity: Arc::clone(opacity), }) .collect() } diff --git a/crates/server/src/settings.rs b/crates/server/src/settings.rs index a86b57e..65ed912 100644 --- a/crates/server/src/settings.rs +++ b/crates/server/src/settings.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use clap::Parser; use config::{Config, ConfigError, Environment, File}; +use domain::TransitionCurve; use drivers::DriverConfig; use serde::{Deserialize, Serialize}; @@ -12,6 +13,13 @@ pub struct LedControllerConfig { pub crossfade_ms: u32, } +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TransitionsConfig { + pub default_fade_ms: u32, + #[serde(default)] + pub default_curve: TransitionCurve, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ServerConfig { pub ip_address: String, @@ -28,6 +36,7 @@ pub struct MdnsConfig { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Settings { pub led_controller: LedControllerConfig, + pub transitions: TransitionsConfig, pub server: ServerConfig, pub mdns: MdnsConfig, #[serde(skip)] diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 680d378..d3e2107 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +chrono = { workspace = true } domain = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } diff --git a/crates/store/migrations/0002_state_stack.sql b/crates/store/migrations/0002_state_stack.sql new file mode 100644 index 0000000..ba6639e --- /dev/null +++ b/crates/store/migrations/0002_state_stack.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS state_stack ( + position INTEGER PRIMARY KEY AUTOINCREMENT, + layers_json TEXT NOT NULL, + device_json TEXT NOT NULL, + pushed_at INTEGER NOT NULL +); diff --git a/crates/store/migrations/0003_layer_opacity.sql b/crates/store/migrations/0003_layer_opacity.sql new file mode 100644 index 0000000..48c3a59 --- /dev/null +++ b/crates/store/migrations/0003_layer_opacity.sql @@ -0,0 +1 @@ +ALTER TABLE scene_layers ADD COLUMN opacity REAL NOT NULL DEFAULT 1.0; diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index e0e88da..f1533bd 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,9 +1,11 @@ mod effect; mod scene; +pub mod stack; mod zone; pub use effect::EffectRecord; pub use scene::{ACTIVE_SCENE_ID, LayerRecord, NewLayer, SceneRecord}; +pub use stack::{StackDevice, StackEntry, StackLayer}; pub use zone::ZoneRecord; use std::path::Path; @@ -186,6 +188,13 @@ impl Store { pub async fn remove_active_layer(&self, id: &str) -> Result<(), StoreError> { scene::remove_layer(&self.pool, id, ACTIVE_SCENE_ID).await } + pub async fn update_active_layer_opacity( + &self, + id: &str, + opacity: f32, + ) -> Result { + scene::update_layer_opacity(&self.pool, id, ACTIVE_SCENE_ID, opacity).await + } pub async fn reorder_layers( &self, scene_id: &str, @@ -236,4 +245,31 @@ impl Store { pub async fn overwrite_scene_from_active(&self, id: &str) -> Result<(), StoreError> { scene::overwrite_from_active(&self.pool, id).await } + + pub async fn restore_active_from_stack(&self, layers: &[StackLayer]) -> Result<(), StoreError> { + scene::restore_active_from_stack(&self.pool, layers).await + } + + pub async fn push_state( + &self, + layers: &[StackLayer], + device: &StackDevice, + ) -> Result<(), StoreError> { + stack::push(&self.pool, layers, device).await + } + pub async fn pop_state(&self) -> Result, StoreError> { + stack::pop(&self.pool).await + } + pub async fn peek_state(&self) -> Result, StoreError> { + stack::peek(&self.pool).await + } + pub async fn list_state_stack(&self) -> Result, StoreError> { + stack::list(&self.pool).await + } + pub async fn state_stack_depth(&self) -> Result { + stack::depth(&self.pool).await + } + pub async fn clear_state_stack(&self) -> Result<(), StoreError> { + stack::clear(&self.pool).await + } } diff --git a/crates/store/src/scene/active.rs b/crates/store/src/scene/active.rs index dcdb948..9ce29d3 100644 --- a/crates/store/src/scene/active.rs +++ b/crates/store/src/scene/active.rs @@ -2,6 +2,7 @@ use sqlx::SqlitePool; use uuid::Uuid; use crate::StoreError; +use crate::stack::StackLayer; use super::layers::{NewLayer, blend_mode_to_str, clear_layers, copy_layers}; use super::scenes::{SceneRecord, create, get_one}; @@ -29,7 +30,7 @@ pub async fn replace_active_layers( if !entries.is_empty() { sqlx::QueryBuilder::new( - "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) ", + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position, opacity) ", ) .push_values(&entries, |mut b, (id, layer, params, pos)| { b.push_bind(id.as_str()) @@ -39,7 +40,8 @@ pub async fn replace_active_layers( .push_bind(blend_mode_to_str(layer.blend_mode)) .push_bind(params.as_str()) .push_bind(1i64) - .push_bind(*pos); + .push_bind(*pos) + .push_bind(layer.opacity as f64); }) .build() .execute(&mut *tx) @@ -70,3 +72,48 @@ pub async fn overwrite_from_active(pool: &SqlitePool, id: &str) -> Result<(), St copy_layers(pool, super::ACTIVE_SCENE_ID, id).await?; Ok(()) } + +pub async fn restore_active_from_stack( + pool: &SqlitePool, + layers: &[StackLayer], +) -> Result<(), StoreError> { + let entries = layers + .iter() + .enumerate() + .map(|(pos, l)| { + serde_json::to_string(&l.params) + .map(|params| (Uuid::new_v4().to_string(), l, params, pos as i64)) + .map_err(StoreError::from) + }) + .collect::, _>>()?; + + let mut tx = pool.begin().await?; + + sqlx::query("DELETE FROM scene_layers WHERE scene_id = ?") + .bind(super::ACTIVE_SCENE_ID) + .execute(&mut *tx) + .await?; + + if !entries.is_empty() { + sqlx::QueryBuilder::new( + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position, opacity) ", + ) + .push_values(&entries, |mut b, (id, layer, params, pos)| { + b.push_bind(id.as_str()) + .push_bind(super::ACTIVE_SCENE_ID) + .push_bind(layer.effect_id.as_str()) + .push_bind(layer.zone_id.as_str()) + .push_bind(blend_mode_to_str(layer.blend_mode)) + .push_bind(params.as_str()) + .push_bind(layer.enabled as i64) + .push_bind(*pos) + .push_bind(layer.opacity as f64); + }) + .build() + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) +} diff --git a/crates/store/src/scene/layers.rs b/crates/store/src/scene/layers.rs index 2a4905b..da943fb 100644 --- a/crates/store/src/scene/layers.rs +++ b/crates/store/src/scene/layers.rs @@ -17,6 +17,7 @@ pub struct LayerRecord { pub params: HashMap, pub enabled: bool, pub position: u32, + pub opacity: f32, } #[derive(FromRow)] @@ -29,6 +30,7 @@ pub(super) struct LayerRow { pub params: String, pub enabled: i64, pub position: i64, + pub opacity: f64, } pub struct NewLayer { @@ -36,6 +38,7 @@ pub struct NewLayer { pub zone_id: String, pub blend_mode: BlendMode, pub params: HashMap, + pub opacity: f32, } pub(super) fn layer_from_row(row: LayerRow) -> Result { @@ -44,6 +47,7 @@ pub(super) fn layer_from_row(row: LayerRow) -> Result { params: serde_json::from_str(&row.params)?, enabled: row.enabled != 0, position: row.position as u32, + opacity: row.opacity as f32, id: row.id, scene_id: row.scene_id, effect_id: row.effect_id, @@ -62,7 +66,7 @@ pub(super) fn blend_mode_to_str(mode: BlendMode) -> &'static str { pub async fn get_layers(pool: &SqlitePool, scene_id: &str) -> Result, StoreError> { let rows: Vec = sqlx::query_as::<_, LayerRow>( - "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position + "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position, opacity FROM scene_layers WHERE scene_id = ? ORDER BY position", ) .bind(scene_id) @@ -73,7 +77,7 @@ pub async fn get_layers(pool: &SqlitePool, scene_id: &str) -> Result Result { let row: Option = sqlx::query_as::<_, LayerRow>( - "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position + "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position, opacity FROM scene_layers WHERE id = ?", ) .bind(id) @@ -88,7 +92,7 @@ pub async fn get_layer_in_scene( scene_id: &str, ) -> Result { let row: Option = sqlx::query_as::<_, LayerRow>( - "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position + "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position, opacity FROM scene_layers WHERE id = ? AND scene_id = ?", ) .bind(id) @@ -166,6 +170,27 @@ pub async fn update_layer( get_layer(pool, id).await } +pub async fn update_layer_opacity( + pool: &SqlitePool, + id: &str, + scene_id: &str, + opacity: f32, +) -> Result { + let rows_affected = + sqlx::query("UPDATE scene_layers SET opacity = ? WHERE id = ? AND scene_id = ?") + .bind(opacity as f64) + .bind(id) + .bind(scene_id) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + get_layer(pool, id).await +} + pub async fn remove_layer(pool: &SqlitePool, id: &str, scene_id: &str) -> Result<(), StoreError> { let rows_affected = sqlx::query("DELETE FROM scene_layers WHERE id = ? AND scene_id = ?") .bind(id) @@ -236,7 +261,7 @@ pub(super) async fn copy_layers( .collect::, _>>()?; sqlx::QueryBuilder::new( - "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) ", + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position, opacity) ", ) .push_values(&entries, |mut b, (id, layer, params)| { b.push_bind(id.as_str()) @@ -246,7 +271,8 @@ pub(super) async fn copy_layers( .push_bind(blend_mode_to_str(layer.blend_mode)) .push_bind(params.as_str()) .push_bind(layer.enabled as i64) - .push_bind(layer.position as i64); + .push_bind(layer.position as i64) + .push_bind(layer.opacity as f64); }) .build() .execute(pool) diff --git a/crates/store/src/scene/mod.rs b/crates/store/src/scene/mod.rs index 6249d0f..b4d9524 100644 --- a/crates/store/src/scene/mod.rs +++ b/crates/store/src/scene/mod.rs @@ -4,9 +4,12 @@ mod scenes; pub const ACTIVE_SCENE_ID: &str = "__active__"; -pub use active::{load_into_active, overwrite_from_active, replace_active_layers, save_active_as}; +pub use active::{ + load_into_active, overwrite_from_active, replace_active_layers, restore_active_from_stack, + save_active_as, +}; pub use layers::{ LayerRecord, NewLayer, add_layer, clear_layers, get_layer, get_layer_in_scene, get_layers, - remove_layer, reorder_layers, update_layer, + remove_layer, reorder_layers, update_layer, update_layer_opacity, }; pub use scenes::{SceneRecord, create, delete, get_all, get_one, update}; diff --git a/crates/store/src/stack.rs b/crates/store/src/stack.rs new file mode 100644 index 0000000..91aadcd --- /dev/null +++ b/crates/store/src/stack.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +use domain::{BlendMode, ParamValue, Rgb}; + +use crate::StoreError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StackLayer { + pub effect_id: String, + pub zone_id: String, + pub blend_mode: BlendMode, + #[serde(default)] + pub enabled: bool, + pub params: HashMap, + #[serde(default = "default_opacity")] + pub opacity: f32, +} + +fn default_opacity() -> f32 { + 1.0 +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct StackDevice { + pub on: bool, + pub brightness: u8, + pub color: Rgb, +} + +#[derive(Debug, Clone)] +pub struct StackEntry { + pub position: i64, + pub layers: Vec, + pub device: StackDevice, + pub pushed_at: i64, +} + +#[derive(FromRow)] +struct StackRow { + position: i64, + layers_json: String, + device_json: String, + pushed_at: i64, +} + +fn entry_from_row(row: StackRow) -> Result { + Ok(StackEntry { + position: row.position, + layers: serde_json::from_str(&row.layers_json)?, + device: serde_json::from_str(&row.device_json)?, + pushed_at: row.pushed_at, + }) +} + +pub async fn push( + pool: &SqlitePool, + layers: &[StackLayer], + device: &StackDevice, +) -> Result<(), StoreError> { + let layers_json = serde_json::to_string(layers)?; + let device_json = serde_json::to_string(device)?; + let pushed_at = Utc::now().timestamp(); + + sqlx::query("INSERT INTO state_stack (layers_json, device_json, pushed_at) VALUES (?, ?, ?)") + .bind(layers_json) + .bind(device_json) + .bind(pushed_at) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn pop(pool: &SqlitePool) -> Result, StoreError> { + let mut tx = pool.begin().await?; + + let row: Option = sqlx::query_as::<_, StackRow>( + "SELECT position, layers_json, device_json, pushed_at + FROM state_stack ORDER BY position DESC LIMIT 1", + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(row) = row else { + tx.commit().await?; + return Ok(None); + }; + + sqlx::query("DELETE FROM state_stack WHERE position = ?") + .bind(row.position) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(entry_from_row(row)?)) +} + +pub async fn peek(pool: &SqlitePool) -> Result, StoreError> { + let row: Option = sqlx::query_as::<_, StackRow>( + "SELECT position, layers_json, device_json, pushed_at + FROM state_stack ORDER BY position DESC LIMIT 1", + ) + .fetch_optional(pool) + .await?; + row.map(entry_from_row).transpose() +} + +pub async fn list(pool: &SqlitePool) -> Result, StoreError> { + let rows: Vec = sqlx::query_as::<_, StackRow>( + "SELECT position, layers_json, device_json, pushed_at + FROM state_stack ORDER BY position DESC", + ) + .fetch_all(pool) + .await?; + rows.into_iter().map(entry_from_row).collect() +} + +pub async fn depth(pool: &SqlitePool) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM state_stack") + .fetch_one(pool) + .await?; + Ok(count as u32) +} + +pub async fn clear(pool: &SqlitePool) -> Result<(), StoreError> { + sqlx::query("DELETE FROM state_stack").execute(pool).await?; + Ok(()) +} diff --git a/crates/store/tests/helpers.rs b/crates/store/tests/helpers.rs index adf4d09..413dbaa 100644 --- a/crates/store/tests/helpers.rs +++ b/crates/store/tests/helpers.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use domain::ParamValue; use sqlx::sqlite::SqlitePoolOptions; use store::Store; diff --git a/crates/store/tests/layers.rs b/crates/store/tests/layers.rs index 9a9f8d9..c50e09e 100644 --- a/crates/store/tests/layers.rs +++ b/crates/store/tests/layers.rs @@ -140,6 +140,80 @@ async fn reorder_layers() { assert_eq!(layers[2].id, l0.id); } +#[tokio::test] +async fn add_layer_defaults_opacity_to_one() { + let store = helpers::in_memory_store().await; + let effect = store.create_effect("fx", "code", &[]).await.unwrap(); + let zone = store.create_zone("z", 0, 10, 4).await.unwrap(); + let scene = store.create_scene("s").await.unwrap(); + let layer = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + assert_eq!(layer.opacity, 1.0); +} + +#[tokio::test] +async fn replace_active_layers_persists_opacity() { + use store::NewLayer; + let store = helpers::in_memory_store().await; + let effect = store.create_effect("fx", "code", &[]).await.unwrap(); + let zone = store.create_zone("z", 0, 10, 4).await.unwrap(); + + store + .replace_active_layers(&[NewLayer { + effect_id: effect.id.clone(), + zone_id: zone.id.clone(), + blend_mode: BlendMode::Override, + params: [].into(), + opacity: 0.42, + }]) + .await + .unwrap(); + + let layers = store.get_active_layers().await.unwrap(); + assert_eq!(layers.len(), 1); + assert!((layers[0].opacity - 0.42).abs() < 1e-5); +} + +#[tokio::test] +async fn update_active_layer_opacity_persists() { + use store::NewLayer; + let store = helpers::in_memory_store().await; + let effect = store.create_effect("fx", "code", &[]).await.unwrap(); + let zone = store.create_zone("z", 0, 10, 4).await.unwrap(); + + store + .replace_active_layers(&[NewLayer { + effect_id: effect.id.clone(), + zone_id: zone.id.clone(), + blend_mode: BlendMode::Override, + params: [].into(), + opacity: 1.0, + }]) + .await + .unwrap(); + let id = store.get_active_layers().await.unwrap().remove(0).id; + + let updated = store.update_active_layer_opacity(&id, 0.0).await.unwrap(); + assert_eq!(updated.opacity, 0.0); + let reread = store.get_layer_by_id(&id).await.unwrap(); + assert_eq!(reread.opacity, 0.0); +} + +#[tokio::test] +async fn update_active_layer_opacity_unknown_id_returns_not_found() { + let store = helpers::in_memory_store().await; + let err = store.update_active_layer_opacity("nope", 0.5).await; + assert!(matches!(err, Err(store::StoreError::NotFound))); +} + #[tokio::test] async fn deleting_scene_cascades_to_layers() { let store = helpers::in_memory_store().await; diff --git a/crates/store/tests/state_stack.rs b/crates/store/tests/state_stack.rs new file mode 100644 index 0000000..9254376 --- /dev/null +++ b/crates/store/tests/state_stack.rs @@ -0,0 +1,141 @@ +mod helpers; + +use domain::{BlendMode, Rgb}; +use helpers::in_memory_store; +use store::{StackDevice, StackLayer}; + +fn sample_layers() -> Vec { + vec![StackLayer { + effect_id: "builtin:rainbow".to_string(), + zone_id: "all".to_string(), + blend_mode: BlendMode::Override, + enabled: true, + params: Default::default(), + opacity: 1.0, + }] +} + +fn sample_device() -> StackDevice { + StackDevice { + on: true, + brightness: 200, + color: Rgb::new(10, 20, 30), + } +} + +#[tokio::test] +async fn depth_starts_at_zero() { + let store = in_memory_store().await; + assert_eq!(store.state_stack_depth().await.unwrap(), 0); +} + +#[tokio::test] +async fn push_increments_depth() { + let store = in_memory_store().await; + store + .push_state(&sample_layers(), &sample_device()) + .await + .unwrap(); + store + .push_state(&sample_layers(), &sample_device()) + .await + .unwrap(); + assert_eq!(store.state_stack_depth().await.unwrap(), 2); +} + +#[tokio::test] +async fn pop_returns_last_pushed_entry() { + let store = in_memory_store().await; + let layers_a = sample_layers(); + let device_a = sample_device(); + let layers_b = vec![StackLayer { + effect_id: "builtin:aurora".to_string(), + zone_id: "all".to_string(), + blend_mode: BlendMode::Add, + enabled: false, + params: Default::default(), + opacity: 0.75, + }]; + let device_b = StackDevice { + on: false, + brightness: 50, + color: Rgb::new(255, 0, 0), + }; + + store.push_state(&layers_a, &device_a).await.unwrap(); + store.push_state(&layers_b, &device_b).await.unwrap(); + + let popped = store.pop_state().await.unwrap().expect("entry expected"); + assert_eq!(popped.layers, layers_b); + assert_eq!(popped.device, device_b); + assert_eq!(store.state_stack_depth().await.unwrap(), 1); +} + +#[tokio::test] +async fn pop_returns_none_when_empty() { + let store = in_memory_store().await; + assert!(store.pop_state().await.unwrap().is_none()); +} + +#[tokio::test] +async fn peek_returns_last_pushed_without_removing() { + let store = in_memory_store().await; + store + .push_state(&sample_layers(), &sample_device()) + .await + .unwrap(); + let peeked = store.peek_state().await.unwrap().expect("entry expected"); + assert_eq!(peeked.device, sample_device()); + assert_eq!(store.state_stack_depth().await.unwrap(), 1); +} + +#[tokio::test] +async fn list_returns_entries_in_lifo_order() { + let store = in_memory_store().await; + let device_first = sample_device(); + let device_second = StackDevice { + on: false, + brightness: 0, + color: Rgb::BLACK, + }; + store + .push_state(&sample_layers(), &device_first) + .await + .unwrap(); + store + .push_state(&sample_layers(), &device_second) + .await + .unwrap(); + + let entries = store.list_state_stack().await.unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].device, device_second); + assert_eq!(entries[1].device, device_first); +} + +#[tokio::test] +async fn clear_drops_every_entry() { + let store = in_memory_store().await; + store + .push_state(&sample_layers(), &sample_device()) + .await + .unwrap(); + store + .push_state(&sample_layers(), &sample_device()) + .await + .unwrap(); + store.clear_state_stack().await.unwrap(); + assert_eq!(store.state_stack_depth().await.unwrap(), 0); + assert!(store.peek_state().await.unwrap().is_none()); +} + +#[tokio::test] +async fn pushed_at_is_populated_with_unix_timestamp() { + let store = in_memory_store().await; + store + .push_state(&sample_layers(), &sample_device()) + .await + .unwrap(); + let entry = store.peek_state().await.unwrap().unwrap(); + assert!(entry.pushed_at > 0); +}