From be14fb562a0bc5b25ec69608262cb30b721cc7c0 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:52:05 +0100 Subject: [PATCH 01/16] feat: add SQLite store, typed param model, and layer data model --- .gitignore | 1 + Cargo.lock | 892 ++++++++++++++++++++- Cargo.toml | 4 + crates/domain/src/blend_mode.rs | 7 +- crates/domain/src/lib.rs | 2 + crates/domain/src/param.rs | 40 + crates/effects/src/compositor/composite.rs | 17 +- crates/store/Cargo.toml | 14 + crates/store/migrations/0001_initial.sql | 32 + crates/store/src/effect.rs | 104 +++ crates/store/src/lib.rs | 181 +++++ crates/store/src/scene.rs | 325 ++++++++ crates/store/src/zone.rs | 116 +++ crates/store/tests/helpers.rs | 11 + crates/store/tests/store.rs | 513 ++++++++++++ 15 files changed, 2238 insertions(+), 21 deletions(-) create mode 100644 crates/domain/src/param.rs create mode 100644 crates/store/Cargo.toml create mode 100644 crates/store/migrations/0001_initial.sql create mode 100644 crates/store/src/effect.rs create mode 100644 crates/store/src/lib.rs create mode 100644 crates/store/src/scene.rs create mode 100644 crates/store/src/zone.rs create mode 100644 crates/store/tests/helpers.rs create mode 100644 crates/store/tests/store.rs diff --git a/.gitignore b/.gitignore index c63c633..63fedce 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ result # project specific state.json +lumehub.db diff --git a/Cargo.lock b/Cargo.lock index 51e794c..25fa535 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.9.2", "sha1", "smallvec", "tokio", @@ -229,6 +229,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -361,6 +367,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -373,6 +388,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" @@ -418,6 +439,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -516,6 +543,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "config" version = "0.15.21" @@ -536,6 +572,12 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -600,6 +642,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -609,6 +666,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -625,6 +697,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -664,7 +747,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -701,6 +786,12 @@ dependencies = [ "serde", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "drivers" version = "0.1.0" @@ -727,6 +818,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -770,6 +870,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -824,12 +946,50 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -860,8 +1020,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -943,6 +1106,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -967,6 +1132,39 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hsv" version = "0.1.1" @@ -1224,6 +1422,15 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1236,6 +1443,35 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1303,6 +1539,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "mdns-sd" version = "0.18.2" @@ -1373,12 +1619,48 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1386,6 +1668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1413,6 +1696,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1431,7 +1720,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1442,6 +1731,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1508,12 +1806,39 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1584,14 +1909,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1601,7 +1947,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1622,6 +1977,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1686,6 +2050,20 @@ dependencies = [ "syn", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.0" @@ -1700,6 +2078,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -1733,23 +2131,57 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "rustls" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "rustls-pki-types" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "rustls-webpki" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -1870,6 +2302,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1887,6 +2329,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smartstring" @@ -1956,6 +2401,206 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1968,12 +2613,41 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "store" +version = "0.1.0" +dependencies = [ + "domain", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2085,6 +2759,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -2098,9 +2787,32 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2 0.6.3", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2195,12 +2907,33 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -2213,6 +2946,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2237,6 +2976,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2267,6 +3023,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -2356,6 +3118,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2415,6 +3205,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2442,6 +3241,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2475,6 +3289,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2487,6 +3307,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2499,6 +3325,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2523,6 +3355,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2535,6 +3373,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2547,6 +3391,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2559,6 +3409,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2749,6 +3605,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 09ddc57..3c48989 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/api/legacy", "crates/discovery", "crates/persistence", + "crates/store", "crates/server", ] resolver = "2" @@ -26,6 +27,7 @@ api-google = { path = "crates/api/google" } api-legacy = { path = "crates/api/legacy" } discovery = { path = "crates/discovery" } persistence = { path = "crates/persistence" } +store = { path = "crates/store" } # External crates actix-web = "4.11.0" @@ -43,3 +45,5 @@ thiserror = "2" mdns-sd = "0.18" tokio = { version = "1", features = ["sync", "fs"] } futures-util = "0.3" +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "migrate"] } +uuid = { version = "1", features = ["v4"] } diff --git a/crates/domain/src/blend_mode.rs b/crates/domain/src/blend_mode.rs index 22fcf9e..7222a2c 100644 --- a/crates/domain/src/blend_mode.rs +++ b/crates/domain/src/blend_mode.rs @@ -1,14 +1,19 @@ -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum BlendMode { #[default] Override, Add, + Screen, + Multiply, } impl From<&str> for BlendMode { fn from(s: &str) -> Self { match s { "add" => BlendMode::Add, + "screen" => BlendMode::Screen, + "multiply" => BlendMode::Multiply, _ => BlendMode::Override, } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index fc08ab1..41a1caf 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,8 +1,10 @@ pub mod blend_mode; pub mod color; pub mod device_state; +pub mod param; 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 zone::Zone; diff --git a/crates/domain/src/param.rs b/crates/domain/src/param.rs new file mode 100644 index 0000000..404fe67 --- /dev/null +++ b/crates/domain/src/param.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +use crate::Rgb; + +/// What UI control the app should render for a parameter. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ParamControl { + Slider { + min: f32, + max: f32, + step: Option, + }, + Color, + Toggle, + Select { + options: Vec, + }, +} + +/// A typed parameter value — used both for effect defaults and layer overrides. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum ParamValue { + Number(f32), + Color(Rgb), + Bool(bool), + Select(String), +} + +/// Definition of a single parameter on an effect. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParamDef { + /// Variable name injected into the Rhai script scope. + pub name: String, + /// Human-readable label shown in the app. + pub label: String, + pub control: ParamControl, + pub default: ParamValue, +} diff --git a/crates/effects/src/compositor/composite.rs b/crates/effects/src/compositor/composite.rs index 7ba1f76..b6d97d8 100644 --- a/crates/effects/src/compositor/composite.rs +++ b/crates/effects/src/compositor/composite.rs @@ -52,12 +52,19 @@ fn blend_layer( } fn blend_pixel(base: Rgb, overlay: Rgb, mode: BlendMode, opacity: f32) -> Rgb { + let o = overlay.dim(opacity); match mode { - BlendMode::Override => base.lerp(overlay, opacity), - BlendMode::Add => Rgb { - r: base.r.saturating_add((overlay.r as f32 * opacity) as u8), - g: base.g.saturating_add((overlay.g as f32 * opacity) as u8), - b: base.b.saturating_add((overlay.b as f32 * opacity) as u8), + BlendMode::Override => base.lerp(o, opacity), + BlendMode::Add => base + o, + 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, + }, + 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, }, } } diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml new file mode 100644 index 0000000..680d378 --- /dev/null +++ b/crates/store/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "store" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/store/migrations/0001_initial.sql b/crates/store/migrations/0001_initial.sql new file mode 100644 index 0000000..7f012f2 --- /dev/null +++ b/crates/store/migrations/0001_initial.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS zones ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + start_pixel INTEGER NOT NULL, + end_pixel INTEGER NOT NULL, + transition_length INTEGER NOT NULL DEFAULT 8 +); + +CREATE TABLE IF NOT EXISTS effects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + script TEXT NOT NULL, + params TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS scenes ( + id TEXT PRIMARY KEY, + name TEXT +); + +INSERT OR IGNORE INTO scenes (id, name) VALUES ('__active__', NULL); + +CREATE TABLE IF NOT EXISTS scene_layers ( + id TEXT PRIMARY KEY, + scene_id TEXT NOT NULL REFERENCES scenes(id) ON DELETE CASCADE, + effect_id TEXT NOT NULL, + zone_id TEXT NOT NULL DEFAULT 'all', + blend_mode TEXT NOT NULL DEFAULT 'override', + params TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL +); diff --git a/crates/store/src/effect.rs b/crates/store/src/effect.rs new file mode 100644 index 0000000..7eabbf1 --- /dev/null +++ b/crates/store/src/effect.rs @@ -0,0 +1,104 @@ +use sqlx::{FromRow, SqlitePool}; +use uuid::Uuid; + +use domain::ParamDef; + +use crate::StoreError; + +#[derive(Debug, Clone)] +pub struct EffectRecord { + pub id: String, + pub name: String, + pub script: String, + pub params: Vec, +} + +#[derive(FromRow)] +struct EffectRow { + id: String, + name: String, + script: String, + params: String, +} + +fn from_row(row: EffectRow) -> Result { + Ok(EffectRecord { + params: serde_json::from_str(&row.params)?, + id: row.id, + name: row.name, + script: row.script, + }) +} + +pub async fn get_all(pool: &SqlitePool) -> Result, StoreError> { + let rows: Vec = sqlx::query_as::<_, EffectRow>( + "SELECT id, name, script, params FROM effects ORDER BY name", + ) + .fetch_all(pool) + .await?; + rows.into_iter().map(from_row).collect() +} + +pub async fn get_one(pool: &SqlitePool, id: &str) -> Result { + let row: Option = + sqlx::query_as::<_, EffectRow>("SELECT id, name, script, params FROM effects WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await?; + from_row(row.ok_or(StoreError::NotFound)?) +} + +pub async fn create( + pool: &SqlitePool, + name: &str, + script: &str, + params: &[ParamDef], +) -> Result { + let id = Uuid::new_v4().to_string(); + let params_json = serde_json::to_string(params)?; + sqlx::query("INSERT INTO effects (id, name, script, params) VALUES (?, ?, ?, ?)") + .bind(&id) + .bind(name) + .bind(script) + .bind(¶ms_json) + .execute(pool) + .await?; + get_one(pool, &id).await +} + +pub async fn update( + pool: &SqlitePool, + id: &str, + name: &str, + script: &str, + params: &[ParamDef], +) -> Result { + let params_json = serde_json::to_string(params)?; + let rows_affected = + sqlx::query("UPDATE effects SET name = ?, script = ?, params = ? WHERE id = ?") + .bind(name) + .bind(script) + .bind(¶ms_json) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + get_one(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { + let rows_affected = sqlx::query("DELETE FROM effects WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + Ok(()) +} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs new file mode 100644 index 0000000..305c1db --- /dev/null +++ b/crates/store/src/lib.rs @@ -0,0 +1,181 @@ +mod effect; +mod scene; +mod zone; + +pub use effect::EffectRecord; +pub use scene::{ACTIVE_SCENE_ID, LayerRecord, SceneRecord}; +pub use zone::ZoneRecord; + +use std::path::Path; + +use sqlx::SqlitePool; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + + #[error("migration error: {0}")] + Migrate(#[from] sqlx::migrate::MigrateError), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("not found")] + NotFound, +} + +#[derive(Clone)] +pub struct Store { + pool: SqlitePool, +} + +impl Store { + pub async fn open(path: &Path) -> Result { + let url = format!("sqlite://{}?mode=rwc", path.display()); + let pool = SqlitePool::connect(&url).await?; + Self::from_pool(pool).await + } + + pub async fn from_pool(pool: SqlitePool) -> Result { + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(Self { pool }) + } + + pub fn pool(&self) -> &SqlitePool { + &self.pool + } +} + +// Delegate CRUD methods so callers only need the Store handle. +impl Store { + pub async fn get_zones(&self) -> Result, StoreError> { + zone::get_all(&self.pool).await + } + pub async fn get_zone(&self, id: &str) -> Result { + zone::get_one(&self.pool, id).await + } + pub async fn create_zone( + &self, + name: &str, + start_pixel: u32, + end_pixel: u32, + transition_length: u32, + ) -> Result { + zone::create(&self.pool, name, start_pixel, end_pixel, transition_length).await + } + pub async fn update_zone( + &self, + id: &str, + name: &str, + start_pixel: u32, + end_pixel: u32, + transition_length: u32, + ) -> Result { + zone::update( + &self.pool, + id, + name, + start_pixel, + end_pixel, + transition_length, + ) + .await + } + pub async fn delete_zone(&self, id: &str) -> Result<(), StoreError> { + zone::delete(&self.pool, id).await + } + + pub async fn get_effects(&self) -> Result, StoreError> { + effect::get_all(&self.pool).await + } + pub async fn get_effect(&self, id: &str) -> Result { + effect::get_one(&self.pool, id).await + } + pub async fn create_effect( + &self, + name: &str, + script: &str, + params: &[domain::ParamDef], + ) -> Result { + effect::create(&self.pool, name, script, params).await + } + pub async fn update_effect( + &self, + id: &str, + name: &str, + script: &str, + params: &[domain::ParamDef], + ) -> Result { + effect::update(&self.pool, id, name, script, params).await + } + pub async fn delete_effect(&self, id: &str) -> Result<(), StoreError> { + effect::delete(&self.pool, id).await + } + + pub async fn get_scenes(&self) -> Result, StoreError> { + scene::get_all(&self.pool).await + } + pub async fn get_scene(&self, id: &str) -> Result { + scene::get_one(&self.pool, id).await + } + pub async fn create_scene(&self, name: &str) -> Result { + scene::create(&self.pool, name).await + } + pub async fn update_scene(&self, id: &str, name: &str) -> Result { + scene::update(&self.pool, id, name).await + } + pub async fn delete_scene(&self, id: &str) -> Result<(), StoreError> { + scene::delete(&self.pool, id).await + } + + pub async fn get_layers(&self, scene_id: &str) -> Result, StoreError> { + scene::get_layers(&self.pool, scene_id).await + } + pub async fn add_layer( + &self, + scene_id: &str, + effect_id: &str, + zone_id: &str, + blend_mode: domain::BlendMode, + params: &std::collections::HashMap, + ) -> Result { + scene::add_layer(&self.pool, scene_id, effect_id, zone_id, blend_mode, params).await + } + pub async fn update_layer( + &self, + id: &str, + enabled: bool, + blend_mode: domain::BlendMode, + params: &std::collections::HashMap, + ) -> Result { + scene::update_layer(&self.pool, id, enabled, blend_mode, params).await + } + pub async fn remove_layer(&self, id: &str) -> Result<(), StoreError> { + scene::remove_layer(&self.pool, id).await + } + pub async fn reorder_layers( + &self, + scene_id: &str, + ordered_ids: &[String], + ) -> Result<(), StoreError> { + scene::reorder_layers(&self.pool, scene_id, ordered_ids).await + } + + pub async fn get_active_layers(&self) -> Result, StoreError> { + scene::get_layers(&self.pool, ACTIVE_SCENE_ID).await + } + pub async fn clear_active_scene(&self) -> Result<(), StoreError> { + scene::clear_layers(&self.pool, ACTIVE_SCENE_ID).await + } + pub async fn load_scene_into_active(&self, scene_id: &str) -> Result<(), StoreError> { + scene::load_into_active(&self.pool, scene_id).await + } + pub async fn save_active_as_scene(&self, name: &str) -> Result { + scene::save_active_as(&self.pool, name).await + } + pub async fn overwrite_scene_from_active(&self, id: &str) -> Result<(), StoreError> { + scene::overwrite_from_active(&self.pool, id).await + } +} diff --git a/crates/store/src/scene.rs b/crates/store/src/scene.rs new file mode 100644 index 0000000..d199f17 --- /dev/null +++ b/crates/store/src/scene.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; + +use sqlx::{FromRow, SqlitePool}; +use uuid::Uuid; + +use domain::{BlendMode, ParamValue}; + +use crate::StoreError; + +pub const ACTIVE_SCENE_ID: &str = "__active__"; + +#[derive(Debug, Clone)] +pub struct SceneRecord { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct LayerRecord { + pub id: String, + pub scene_id: String, + pub effect_id: String, + pub zone_id: String, + pub blend_mode: BlendMode, + pub params: HashMap, + pub enabled: bool, + pub position: u32, +} + +#[derive(FromRow)] +struct SceneRow { + id: String, + name: String, +} + +#[derive(FromRow)] +struct LayerRow { + id: String, + scene_id: String, + effect_id: String, + zone_id: String, + blend_mode: String, + params: String, + enabled: i64, + position: i64, +} + +fn layer_from_row(row: LayerRow) -> Result { + Ok(LayerRecord { + blend_mode: BlendMode::from(row.blend_mode.as_str()), + params: serde_json::from_str(&row.params)?, + enabled: row.enabled != 0, + position: row.position as u32, + id: row.id, + scene_id: row.scene_id, + effect_id: row.effect_id, + zone_id: row.zone_id, + }) +} + +fn blend_mode_to_str(mode: BlendMode) -> &'static str { + match mode { + BlendMode::Override => "override", + BlendMode::Add => "add", + BlendMode::Screen => "screen", + BlendMode::Multiply => "multiply", + } +} + +pub async fn get_all(pool: &SqlitePool) -> Result, StoreError> { + let rows: Vec = sqlx::query_as::<_, SceneRow>( + "SELECT id, name FROM scenes WHERE id != ? AND name IS NOT NULL ORDER BY name", + ) + .bind(ACTIVE_SCENE_ID) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| SceneRecord { + id: r.id, + name: r.name, + }) + .collect()) +} + +pub async fn get_one(pool: &SqlitePool, id: &str) -> Result { + let row: Option = + sqlx::query_as::<_, SceneRow>("SELECT id, name FROM scenes WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await?; + row.map(|r| SceneRecord { + id: r.id, + name: r.name, + }) + .ok_or(StoreError::NotFound) +} + +pub async fn create(pool: &SqlitePool, name: &str) -> Result { + let id = Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO scenes (id, name) VALUES (?, ?)") + .bind(&id) + .bind(name) + .execute(pool) + .await?; + get_one(pool, &id).await +} + +pub async fn update(pool: &SqlitePool, id: &str, name: &str) -> Result { + let rows_affected = sqlx::query("UPDATE scenes SET name = ? WHERE id = ? AND id != ?") + .bind(name) + .bind(id) + .bind(ACTIVE_SCENE_ID) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + get_one(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { + let rows_affected = sqlx::query("DELETE FROM scenes WHERE id = ? AND id != ?") + .bind(id) + .bind(ACTIVE_SCENE_ID) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + Ok(()) +} + +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 + FROM scene_layers WHERE scene_id = ? ORDER BY position", + ) + .bind(scene_id) + .fetch_all(pool) + .await?; + rows.into_iter().map(layer_from_row).collect() +} + +pub async fn add_layer( + pool: &SqlitePool, + scene_id: &str, + effect_id: &str, + zone_id: &str, + blend_mode: BlendMode, + params: &HashMap, +) -> Result { + let id = Uuid::new_v4().to_string(); + let blend_str = blend_mode_to_str(blend_mode); + let params_json = serde_json::to_string(params)?; + + let position: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(position), -1) + 1 FROM scene_layers WHERE scene_id = ?", + ) + .bind(scene_id) + .fetch_one(pool) + .await?; + + sqlx::query( + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) + VALUES (?, ?, ?, ?, ?, ?, 1, ?)", + ) + .bind(&id) + .bind(scene_id) + .bind(effect_id) + .bind(zone_id) + .bind(blend_str) + .bind(¶ms_json) + .bind(position) + .execute(pool) + .await?; + + get_layer(pool, &id).await +} + +pub async fn update_layer( + pool: &SqlitePool, + id: &str, + enabled: bool, + blend_mode: BlendMode, + params: &HashMap, +) -> Result { + let blend_str = blend_mode_to_str(blend_mode); + let params_json = serde_json::to_string(params)?; + let enabled_int = enabled as i64; + + let rows_affected = + sqlx::query("UPDATE scene_layers SET enabled = ?, blend_mode = ?, params = ? WHERE id = ?") + .bind(enabled_int) + .bind(blend_str) + .bind(¶ms_json) + .bind(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) -> Result<(), StoreError> { + let rows_affected = sqlx::query("DELETE FROM scene_layers WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + Ok(()) +} + +pub async fn reorder_layers( + pool: &SqlitePool, + scene_id: &str, + ordered_ids: &[String], +) -> Result<(), StoreError> { + let mut tx = pool.begin().await?; + for (position, id) in ordered_ids.iter().enumerate() { + sqlx::query("UPDATE scene_layers SET position = ? WHERE id = ? AND scene_id = ?") + .bind(position as i64) + .bind(id) + .bind(scene_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +pub async fn clear_layers(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { + sqlx::query("DELETE FROM scene_layers WHERE scene_id = ?") + .bind(scene_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn load_into_active(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { + let layers = get_layers(pool, scene_id).await?; + clear_layers(pool, ACTIVE_SCENE_ID).await?; + for layer in layers { + let params_json = serde_json::to_string(&layer.params)?; + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) + VALUES (?, '__active__', ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&layer.effect_id) + .bind(&layer.zone_id) + .bind(blend_mode_to_str(layer.blend_mode)) + .bind(¶ms_json) + .bind(layer.enabled as i64) + .bind(layer.position as i64) + .execute(pool) + .await?; + } + Ok(()) +} + +pub async fn save_active_as(pool: &SqlitePool, name: &str) -> Result { + let scene = create(pool, name).await?; + copy_layers(pool, ACTIVE_SCENE_ID, &scene.id).await?; + Ok(scene) +} + +pub async fn overwrite_from_active(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { + if id == ACTIVE_SCENE_ID { + return Err(StoreError::NotFound); + } + clear_layers(pool, id).await?; + copy_layers(pool, ACTIVE_SCENE_ID, id).await?; + Ok(()) +} + +async fn copy_layers( + pool: &SqlitePool, + from_scene_id: &str, + to_scene_id: &str, +) -> Result<(), StoreError> { + let layers = get_layers(pool, from_scene_id).await?; + for layer in layers { + let params_json = serde_json::to_string(&layer.params)?; + let new_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&new_id) + .bind(to_scene_id) + .bind(&layer.effect_id) + .bind(&layer.zone_id) + .bind(blend_mode_to_str(layer.blend_mode)) + .bind(¶ms_json) + .bind(layer.enabled as i64) + .bind(layer.position as i64) + .execute(pool) + .await?; + } + Ok(()) +} + +async fn get_layer(pool: &SqlitePool, id: &str) -> Result { + let row: Option = sqlx::query_as::<_, LayerRow>( + "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position + FROM scene_layers WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await?; + layer_from_row(row.ok_or(StoreError::NotFound)?) +} diff --git a/crates/store/src/zone.rs b/crates/store/src/zone.rs new file mode 100644 index 0000000..4eb5fa6 --- /dev/null +++ b/crates/store/src/zone.rs @@ -0,0 +1,116 @@ +use sqlx::{FromRow, SqlitePool}; +use uuid::Uuid; + +use crate::StoreError; + +#[derive(Debug, Clone)] +pub struct ZoneRecord { + pub id: String, + pub name: String, + pub start_pixel: u32, + pub end_pixel: u32, + pub transition_length: u32, +} + +#[derive(FromRow)] +struct ZoneRow { + id: String, + name: String, + start_pixel: i64, + end_pixel: i64, + transition_length: i64, +} + +impl From for ZoneRecord { + fn from(r: ZoneRow) -> Self { + Self { + id: r.id, + name: r.name, + start_pixel: r.start_pixel as u32, + end_pixel: r.end_pixel as u32, + transition_length: r.transition_length as u32, + } + } +} + +pub async fn get_all(pool: &SqlitePool) -> Result, StoreError> { + let rows: Vec = sqlx::query_as::<_, ZoneRow>( + "SELECT id, name, start_pixel, end_pixel, transition_length FROM zones ORDER BY name", + ) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(ZoneRecord::from).collect()) +} + +pub async fn get_one(pool: &SqlitePool, id: &str) -> Result { + sqlx::query_as::<_, ZoneRow>( + "SELECT id, name, start_pixel, end_pixel, transition_length FROM zones WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await? + .map(ZoneRecord::from) + .ok_or(StoreError::NotFound) +} + +pub async fn create( + pool: &SqlitePool, + name: &str, + start_pixel: u32, + end_pixel: u32, + transition_length: u32, +) -> Result { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO zones (id, name, start_pixel, end_pixel, transition_length) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(name) + .bind(start_pixel) + .bind(end_pixel) + .bind(transition_length) + .execute(pool) + .await?; + get_one(pool, &id).await +} + +pub async fn update( + pool: &SqlitePool, + id: &str, + name: &str, + start_pixel: u32, + end_pixel: u32, + transition_length: u32, +) -> Result { + let rows_affected = sqlx::query( + "UPDATE zones SET name = ?, start_pixel = ?, end_pixel = ?, transition_length = ? + WHERE id = ?", + ) + .bind(name) + .bind(start_pixel) + .bind(end_pixel) + .bind(transition_length) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + get_one(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { + let rows_affected = sqlx::query("DELETE FROM zones WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + Ok(()) +} diff --git a/crates/store/tests/helpers.rs b/crates/store/tests/helpers.rs new file mode 100644 index 0000000..1620402 --- /dev/null +++ b/crates/store/tests/helpers.rs @@ -0,0 +1,11 @@ +use sqlx::sqlite::SqlitePoolOptions; +use store::Store; + +pub async fn in_memory_store() -> Store { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + Store::from_pool(pool).await.unwrap() +} diff --git a/crates/store/tests/store.rs b/crates/store/tests/store.rs new file mode 100644 index 0000000..f71e343 --- /dev/null +++ b/crates/store/tests/store.rs @@ -0,0 +1,513 @@ +mod helpers; + +use domain::{BlendMode, ParamControl, ParamDef, ParamValue, Rgb}; + +#[tokio::test] +async fn create_and_get_zone() { + let store = helpers::in_memory_store().await; + let zone = store.create_zone("living room", 0, 59, 8).await.unwrap(); + assert_eq!(zone.name, "living room"); + assert_eq!(zone.start_pixel, 0); + assert_eq!(zone.end_pixel, 59); + assert_eq!(zone.transition_length, 8); + + let fetched = store.get_zone(&zone.id).await.unwrap(); + assert_eq!(fetched.id, zone.id); + assert_eq!(fetched.name, "living room"); +} + +#[tokio::test] +async fn list_zones() { + let store = helpers::in_memory_store().await; + store.create_zone("a", 0, 10, 4).await.unwrap(); + store.create_zone("b", 11, 20, 4).await.unwrap(); + let zones = store.get_zones().await.unwrap(); + assert_eq!(zones.len(), 2); +} + +#[tokio::test] +async fn update_zone() { + let store = helpers::in_memory_store().await; + let zone = store.create_zone("old", 0, 10, 8).await.unwrap(); + let updated = store.update_zone(&zone.id, "new", 5, 50, 16).await.unwrap(); + assert_eq!(updated.name, "new"); + assert_eq!(updated.start_pixel, 5); + assert_eq!(updated.end_pixel, 50); + assert_eq!(updated.transition_length, 16); +} + +#[tokio::test] +async fn delete_zone() { + let store = helpers::in_memory_store().await; + let zone = store.create_zone("tmp", 0, 10, 8).await.unwrap(); + store.delete_zone(&zone.id).await.unwrap(); + let err = store.get_zone(&zone.id).await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn zone_not_found() { + let store = helpers::in_memory_store().await; + let err = store.get_zone("nonexistent").await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +fn sample_params() -> Vec { + vec![ + ParamDef { + name: "speed".to_string(), + label: "Speed".to_string(), + control: ParamControl::Slider { + min: 0.5, + max: 5.0, + step: Some(0.5), + }, + default: ParamValue::Number(1.0), + }, + ParamDef { + name: "color".to_string(), + label: "Color".to_string(), + control: ParamControl::Color, + default: ParamValue::Color(Rgb { r: 255, g: 0, b: 0 }), + }, + ParamDef { + name: "reverse".to_string(), + label: "Reverse".to_string(), + control: ParamControl::Toggle, + default: ParamValue::Bool(false), + }, + ParamDef { + name: "mode".to_string(), + label: "Mode".to_string(), + control: ParamControl::Select { + options: vec!["linear".to_string(), "ease".to_string()], + }, + default: ParamValue::Select("linear".to_string()), + }, + ] +} + +#[tokio::test] +async fn create_effect_with_params_round_trips() { + let store = helpers::in_memory_store().await; + let params = sample_params(); + let effect = store + .create_effect("wave", "let c = primary_color; c", ¶ms) + .await + .unwrap(); + + assert_eq!(effect.name, "wave"); + assert_eq!(effect.script, "let c = primary_color; c"); + assert_eq!(effect.params.len(), 4); + + let fetched = store.get_effect(&effect.id).await.unwrap(); + assert_eq!(fetched.params[0].name, "speed"); + assert!(matches!( + fetched.params[0].control, + ParamControl::Slider { min, max, .. } if (min - 0.5).abs() < f32::EPSILON && (max - 5.0).abs() < f32::EPSILON + )); + assert_eq!(fetched.params[0].default, ParamValue::Number(1.0)); + assert_eq!( + fetched.params[1].default, + ParamValue::Color(Rgb { r: 255, g: 0, b: 0 }) + ); + assert_eq!(fetched.params[2].default, ParamValue::Bool(false)); + assert_eq!( + fetched.params[3].default, + ParamValue::Select("linear".to_string()) + ); +} + +#[tokio::test] +async fn list_effects() { + let store = helpers::in_memory_store().await; + store + .create_effect("a", "#{r:0,g:0,b:0}", &[]) + .await + .unwrap(); + store + .create_effect("b", "#{r:0,g:0,b:0}", &[]) + .await + .unwrap(); + let effects = store.get_effects().await.unwrap(); + assert_eq!(effects.len(), 2); +} + +#[tokio::test] +async fn update_effect() { + let store = helpers::in_memory_store().await; + let effect = store.create_effect("old", "code", &[]).await.unwrap(); + let updated = store + .update_effect(&effect.id, "new", "new_code", &sample_params()) + .await + .unwrap(); + assert_eq!(updated.name, "new"); + assert_eq!(updated.script, "new_code"); + assert_eq!(updated.params.len(), 4); +} + +#[tokio::test] +async fn delete_effect() { + let store = helpers::in_memory_store().await; + let effect = store.create_effect("tmp", "code", &[]).await.unwrap(); + store.delete_effect(&effect.id).await.unwrap(); + let err = store.get_effect(&effect.id).await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn active_scene_excluded_from_list() { + let store = helpers::in_memory_store().await; + let scenes = store.get_scenes().await.unwrap(); + assert!(scenes.iter().all(|s| s.id != store::ACTIVE_SCENE_ID)); +} + +#[tokio::test] +async fn create_and_list_scenes() { + let store = helpers::in_memory_store().await; + store.create_scene("night").await.unwrap(); + store.create_scene("party").await.unwrap(); + let scenes = store.get_scenes().await.unwrap(); + assert_eq!(scenes.len(), 2); +} + +#[tokio::test] +async fn update_scene() { + let store = helpers::in_memory_store().await; + let scene = store.create_scene("old").await.unwrap(); + let updated = store.update_scene(&scene.id, "new").await.unwrap(); + assert_eq!(updated.name, "new"); +} + +#[tokio::test] +async fn delete_scene() { + let store = helpers::in_memory_store().await; + let scene = store.create_scene("tmp").await.unwrap(); + store.delete_scene(&scene.id).await.unwrap(); + let err = store.get_scene(&scene.id).await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn cannot_delete_active_scene() { + let store = helpers::in_memory_store().await; + let err = store + .delete_scene(store::ACTIVE_SCENE_ID) + .await + .unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +fn make_params() -> std::collections::HashMap { + [("speed".to_string(), ParamValue::Number(2.0))].into() +} + +#[tokio::test] +async fn add_and_get_layers() { + 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 l0 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &make_params(), + ) + .await + .unwrap(); + let l1 = store + .add_layer(&scene.id, &effect.id, &zone.id, BlendMode::Add, &[].into()) + .await + .unwrap(); + + assert_eq!(l0.position, 0); + assert_eq!(l1.position, 1); + assert!(matches!(l0.blend_mode, BlendMode::Override)); + assert!(matches!(l1.blend_mode, BlendMode::Add)); + assert_eq!(l0.params["speed"], ParamValue::Number(2.0)); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].id, l0.id); + assert_eq!(layers[1].id, l1.id); +} + +#[tokio::test] +async fn update_layer() { + 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(); + + let updated = store + .update_layer(&layer.id, false, BlendMode::Screen, &make_params()) + .await + .unwrap(); + assert!(!updated.enabled); + assert!(matches!(updated.blend_mode, BlendMode::Screen)); + assert_eq!(updated.params["speed"], ParamValue::Number(2.0)); +} + +#[tokio::test] +async fn reorder_layers() { + 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 l0 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + let l1 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + let l2 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + + // Reverse the order + store + .reorder_layers(&scene.id, &[l2.id.clone(), l1.id.clone(), l0.id.clone()]) + .await + .unwrap(); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert_eq!(layers[0].id, l2.id); + assert_eq!(layers[1].id, l1.id); + assert_eq!(layers[2].id, l0.id); +} + +#[tokio::test] +async fn remove_layer() { + 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(); + + store.remove_layer(&layer.id).await.unwrap(); + let layers = store.get_layers(&scene.id).await.unwrap(); + assert!(layers.is_empty()); +} + +#[tokio::test] +async fn save_active_creates_named_scene() { + 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 + .add_layer( + store::ACTIVE_SCENE_ID, + &effect.id, + &zone.id, + BlendMode::Override, + &make_params(), + ) + .await + .unwrap(); + + let saved = store.save_active_as_scene("my scene").await.unwrap(); + assert_eq!(saved.name, "my scene"); + + let layers = store.get_layers(&saved.id).await.unwrap(); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].params["speed"], ParamValue::Number(2.0)); +} + +#[tokio::test] +async fn load_scene_replaces_active() { + 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(); + + store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Add, + &make_params(), + ) + .await + .unwrap(); + + // Put something in active first to verify it gets replaced + store + .add_layer( + store::ACTIVE_SCENE_ID, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + + store.load_scene_into_active(&scene.id).await.unwrap(); + + let active = store.get_active_layers().await.unwrap(); + assert_eq!(active.len(), 1); + assert!(matches!(active[0].blend_mode, BlendMode::Add)); + assert_eq!(active[0].params["speed"], ParamValue::Number(2.0)); +} + +#[tokio::test] +async fn clear_active_removes_all_layers() { + 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 + .add_layer( + store::ACTIVE_SCENE_ID, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + store + .add_layer( + store::ACTIVE_SCENE_ID, + &effect.id, + &zone.id, + BlendMode::Add, + &[].into(), + ) + .await + .unwrap(); + + store.clear_active_scene().await.unwrap(); + let active = store.get_active_layers().await.unwrap(); + assert!(active.is_empty()); +} + +#[tokio::test] +async fn overwrite_scene_from_active() { + 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(); + + // Scene has one layer + store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + + // Active has two layers with custom params + store + .add_layer( + store::ACTIVE_SCENE_ID, + &effect.id, + &zone.id, + BlendMode::Add, + &make_params(), + ) + .await + .unwrap(); + store + .add_layer( + store::ACTIVE_SCENE_ID, + &effect.id, + &zone.id, + BlendMode::Screen, + &[].into(), + ) + .await + .unwrap(); + + store.overwrite_scene_from_active(&scene.id).await.unwrap(); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert_eq!(layers.len(), 2); + assert!(matches!(layers[0].blend_mode, BlendMode::Add)); +} + +#[tokio::test] +async fn cannot_overwrite_active_with_itself() { + let store = helpers::in_memory_store().await; + let err = store + .overwrite_scene_from_active(store::ACTIVE_SCENE_ID) + .await + .unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn deleting_scene_cascades_to_layers() { + 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(); + + store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + + store.delete_scene(&scene.id).await.unwrap(); + + // After scene deleted, layers should be gone (cascade) + let layers = store.get_layers(&scene.id).await.unwrap(); + assert!(layers.is_empty()); +} From 5816e41dd77bce269a15be321889b75c81d07e40 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:38:19 +0100 Subject: [PATCH 02/16] feat(effects): redesign builtin system, compositor, and Rhai script layer --- Cargo.lock | 62 ++++- Cargo.toml | 12 + crates/effects/Cargo.toml | 3 +- crates/effects/config/effects/aurora.toml | 8 +- crates/effects/config/effects/breathing.toml | 45 +++- crates/effects/config/effects/comet.toml | 32 ++- crates/effects/config/effects/deep_ocean.toml | 8 +- crates/effects/config/effects/dual_scan.toml | 75 +++++- crates/effects/config/effects/lava.toml | 19 +- crates/effects/config/effects/nebula.toml | 8 +- crates/effects/config/effects/ocean.rhai | 11 - crates/effects/config/effects/ocean.toml | 27 ++ .../effects/config/effects/phase_shift.toml | 39 ++- crates/effects/config/effects/plasma.rhai | 5 - crates/effects/config/effects/plasma.toml | 21 ++ crates/effects/config/effects/pulse.toml | 53 +++- crates/effects/config/effects/rainbow.toml | 19 +- crates/effects/config/effects/scanner.toml | 40 ++- crates/effects/config/effects/solar_wind.toml | 8 +- crates/effects/config/effects/tv_effect.toml | 8 - crates/effects/src/builtin.rs | 68 +++++ crates/effects/src/compositor/builder.rs | 232 ------------------ crates/effects/src/compositor/bus.rs | 159 ------------ crates/effects/src/compositor/composite.rs | 89 ++++--- crates/effects/src/compositor/layer.rs | 35 ++- crates/effects/src/compositor/mod.rs | 2 - crates/effects/src/compositor/registry.rs | 10 +- crates/effects/src/lib.rs | 26 +- crates/effects/src/preset/config.rs | 108 -------- crates/effects/src/preset/mod.rs | 60 ----- crates/effects/src/rhai/mod.rs | 9 +- crates/effects/src/rhai/script.rs | 122 ++++----- crates/effects/src/scene_builder.rs | 58 +++++ crates/effects/tests/composite.rs | 160 +++++++++--- crates/effects/tests/config.rs | 74 +++--- crates/effects/tests/live_param.rs | 65 +---- crates/effects/tests/preset.rs | 47 ---- crates/effects/tests/registry.rs | 12 +- crates/effects/tests/script.rs | 115 +++------ 39 files changed, 894 insertions(+), 1060 deletions(-) delete mode 100644 crates/effects/config/effects/ocean.rhai create mode 100644 crates/effects/config/effects/ocean.toml delete mode 100644 crates/effects/config/effects/plasma.rhai create mode 100644 crates/effects/config/effects/plasma.toml delete mode 100644 crates/effects/config/effects/tv_effect.toml create mode 100644 crates/effects/src/builtin.rs delete mode 100644 crates/effects/src/compositor/builder.rs delete mode 100644 crates/effects/src/compositor/bus.rs delete mode 100644 crates/effects/src/preset/config.rs delete mode 100644 crates/effects/src/preset/mod.rs create mode 100644 crates/effects/src/scene_builder.rs delete mode 100644 crates/effects/tests/preset.rs diff --git a/Cargo.lock b/Cargo.lock index 25fa535..8c0520b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,6 +312,8 @@ dependencies = [ "futures-util", "serde", "serde_json", + "sqlx", + "store", "tokio", ] @@ -567,7 +569,7 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml", + "toml 1.0.6+spec-1.1.0", "winnow", "yaml-rust2", ] @@ -806,8 +808,6 @@ dependencies = [ name = "effects" version = "0.1.0" dependencies = [ - "application", - "config", "domain", "engine", "include_dir", @@ -816,6 +816,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror", + "toml 0.8.23", ] [[package]] @@ -1536,6 +1537,7 @@ dependencies = [ "persistence", "serde", "serde_json", + "store", "tokio", ] @@ -2243,6 +2245,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2826,6 +2837,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "1.0.6+spec-1.1.0" @@ -2833,12 +2856,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" dependencies = [ "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.4", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -2848,6 +2880,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.9+spec-1.1.0" @@ -2857,6 +2903,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" diff --git a/Cargo.toml b/Cargo.toml index 3c48989..fb6aa34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,4 +46,16 @@ mdns-sd = "0.18" tokio = { version = "1", features = ["sync", "fs"] } futures-util = "0.3" sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "migrate"] } +toml = "0.8" uuid = { version = "1", features = ["v4"] } + +# Optimize render-critical crates even in debug builds — Rhai per-pixel eval +# is too slow at opt-level=0 for real-time frame rates. +[profile.dev.package.rhai] +opt-level = 3 +[profile.dev.package.effects] +opt-level = 3 +[profile.dev.package.engine] +opt-level = 3 +[profile.dev.package.domain] +opt-level = 3 diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 8b75c52..d7dd10e 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -6,13 +6,12 @@ edition = "2024" [dependencies] domain = { workspace = true } engine = { workspace = true } -application = { workspace = true } rhai = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -config = { workspace = true } include_dir = { workspace = true } thiserror = { workspace = true } +toml = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/effects/config/effects/aurora.toml b/crates/effects/config/effects/aurora.toml index 5f1b61a..3ceec2a 100644 --- a/crates/effects/config/effects/aurora.toml +++ b/crates/effects/config/effects/aurora.toml @@ -1,7 +1,7 @@ -# Flowing northern lights — overlapping sine waves with organic color shifts. -[[layers]] -effect = "script" -code = """ +name = "Aurora" + +[script] +code = """ let p = pixel.to_float(); let w1 = wave(p * 0.09 + time * 0.020); let w2 = wave(p * 0.05 - time * 0.013 + 1.9); diff --git a/crates/effects/config/effects/breathing.toml b/crates/effects/config/effects/breathing.toml index 5c3d40e..752eaf0 100644 --- a/crates/effects/config/effects/breathing.toml +++ b/crates/effects/config/effects/breathing.toml @@ -1,8 +1,37 @@ -[[layers]] -effect = "script" -speed = 0.03 -min_brightness = 0.0 -code = """ -let b = min_brightness + (1.0 - min_brightness) * wave(time * speed); -rgb_f(pr * b, pg * b, pb * b) -""" +name = "Breathing" + +[[params]] +name = "color" +label = "Color" +[params.control] +type = "color" +[params.default] +type = "color" +value = {r = 255, g = 255, b = 255} + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.005 +max = 0.2 +step = 0.005 +[params.default] +type = "number" +value = 0.03 + +[[params]] +name = "min_brightness" +label = "Min Brightness" +[params.control] +type = "slider" +min = 0.0 +max = 0.9 +step = 0.05 +[params.default] +type = "number" +value = 0.0 + +[script] +code = "dim(color, min_brightness + (1.0 - min_brightness) * wave(time * speed))" diff --git a/crates/effects/config/effects/comet.toml b/crates/effects/config/effects/comet.toml index 994d81f..e6e7596 100644 --- a/crates/effects/config/effects/comet.toml +++ b/crates/effects/config/effects/comet.toml @@ -1,9 +1,29 @@ -# A scanner dot that cycles through spectrum colors, with a soft Gaussian glow. -[[layers]] -effect = "script" -speed = 0.4 -width = 5.0 -code = """ +name = "Comet" + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.1 +max = 2.0 +[params.default] +type = "number" +value = 0.4 + +[[params]] +name = "width" +label = "Width" +[params.control] +type = "slider" +min = 2.0 +max = 20.0 +[params.default] +type = "number" +value = 5.0 + +[script] +code = """ let sf = scanner_factor(pixel, len, time, speed, width); let hue = fract(t + time * 0.004) * 360.0; dim(from_hue(hue), sf * sf) diff --git a/crates/effects/config/effects/deep_ocean.toml b/crates/effects/config/effects/deep_ocean.toml index d2888b4..6a491d2 100644 --- a/crates/effects/config/effects/deep_ocean.toml +++ b/crates/effects/config/effects/deep_ocean.toml @@ -1,7 +1,7 @@ -# Bioluminescent deep ocean — aurora pattern in teal and blue, with a faint base glow. -[[layers]] -effect = "script" -code = """ +name = "Deep Ocean" + +[script] +code = """ let p = pixel.to_float(); let w1 = wave(p * 0.06 + time * 0.012); let w2 = wave(p * 0.10 - time * 0.008 + 1.7); diff --git a/crates/effects/config/effects/dual_scan.toml b/crates/effects/config/effects/dual_scan.toml index 107e00e..40ab5d8 100644 --- a/crates/effects/config/effects/dual_scan.toml +++ b/crates/effects/config/effects/dual_scan.toml @@ -1,11 +1,70 @@ -[[layers]] -effect = "script" -speed1 = 0.3 -speed2 = 0.7 -w1 = 5.0 -w2 = 4.0 -code = """ +name = "Dual Scan" + +[[params]] +name = "color1" +label = "Color 1" +[params.control] +type = "color" +[params.default] +type = "color" +value = {r = 255, g = 255, b = 255} + +[[params]] +name = "color2" +label = "Color 2" +[params.control] +type = "color" +[params.default] +type = "color" +value = {r = 0, g = 100, b = 255} + +[[params]] +name = "speed1" +label = "Speed 1" +[params.control] +type = "slider" +min = 0.1 +max = 2.0 +[params.default] +type = "number" +value = 0.3 + +[[params]] +name = "speed2" +label = "Speed 2" +[params.control] +type = "slider" +min = 0.1 +max = 2.0 +[params.default] +type = "number" +value = 0.7 + +[[params]] +name = "w1" +label = "Width 1" +[params.control] +type = "slider" +min = 2.0 +max = 20.0 +[params.default] +type = "number" +value = 5.0 + +[[params]] +name = "w2" +label = "Width 2" +[params.control] +type = "slider" +min = 2.0 +max = 20.0 +[params.default] +type = "number" +value = 4.0 + +[script] +code = """ let b1 = scanner_factor(pixel, len, time, speed1, w1); let b2 = scanner_factor(pixel, len, time, speed2, w2); -add_colors(dim(primary_color, b1), dim(secondary_color, b2)) +add_colors(dim(color1, b1), dim(color2, b2)) """ diff --git a/crates/effects/config/effects/lava.toml b/crates/effects/config/effects/lava.toml index ce40476..c223b08 100644 --- a/crates/effects/config/effects/lava.toml +++ b/crates/effects/config/effects/lava.toml @@ -1,7 +1,18 @@ -[[layers]] -effect = "script" -speed = 1.0 -code = """ +name = "Lava" + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.1 +max = 3.0 +[params.default] +type = "number" +value = 1.0 + +[script] +code = """ let p = pixel.to_float() / len.to_float(); let a = sin(p * 11.3 + time * speed * 0.040); let b = sin(p * 17.7 - time * speed * 0.031 + 2.0); diff --git a/crates/effects/config/effects/nebula.toml b/crates/effects/config/effects/nebula.toml index fbdba90..0f8abc0 100644 --- a/crates/effects/config/effects/nebula.toml +++ b/crates/effects/config/effects/nebula.toml @@ -1,7 +1,7 @@ -# Cosmic nebula — aurora pattern shifted into deep purples, magentas and blues. -[[layers]] -effect = "script" -code = """ +name = "Nebula" + +[script] +code = """ let p = pixel.to_float(); let w1 = wave(p * 0.07 + time * 0.016); let w2 = wave(p * 0.04 - time * 0.010 + 2.3); diff --git a/crates/effects/config/effects/ocean.rhai b/crates/effects/config/effects/ocean.rhai deleted file mode 100644 index eff7efc..0000000 --- a/crates/effects/config/effects/ocean.rhai +++ /dev/null @@ -1,11 +0,0 @@ -let x = pixel.to_float(); -let t = time * speed * 0.03; -let w = sin(x * 0.12 - t) * 0.50 - + sin(x * 0.31 - t * 1.37) * 0.28 - + sin(x * 0.55 + t * 0.82) * 0.13; -let depth = pow((w + 0.91) / 1.82, 0.7); -let r = depth * depth * 0.18; -let g = depth * 0.55; -let b = 0.18 + depth * 0.82; -let foam = clamp((depth - 0.80) * 6.0, 0.0, 1.0); -rgb_f(mix(r, 1.0, foam * 0.9), mix(g, 1.0, foam), mix(b, 1.0, foam * 0.7)) diff --git a/crates/effects/config/effects/ocean.toml b/crates/effects/config/effects/ocean.toml new file mode 100644 index 0000000..8f6d3eb --- /dev/null +++ b/crates/effects/config/effects/ocean.toml @@ -0,0 +1,27 @@ +name = "Ocean" + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.1 +max = 3.0 +[params.default] +type = "number" +value = 1.0 + +[script] +code = """ +let x = pixel.to_float(); +let s = time * speed * 0.03; +let w = sin(x * 0.12 - s) * 0.50 + + sin(x * 0.31 - s * 1.37) * 0.28 + + sin(x * 0.55 + s * 0.82) * 0.13; +let depth = pow((w + 0.91) / 1.82, 0.7); +let r = depth * depth * 0.18; +let g = depth * 0.55; +let b = 0.18 + depth * 0.82; +let foam = clamp((depth - 0.80) * 6.0, 0.0, 1.0); +rgb_f(mix(r, 1.0, foam * 0.9), mix(g, 1.0, foam), mix(b, 1.0, foam * 0.7)) +""" diff --git a/crates/effects/config/effects/phase_shift.toml b/crates/effects/config/effects/phase_shift.toml index b86f80c..3a7cd95 100644 --- a/crates/effects/config/effects/phase_shift.toml +++ b/crates/effects/config/effects/phase_shift.toml @@ -1,10 +1,31 @@ -[[layers]] -effect = "script" -speed = 1.0 -code = "from_hue(pixel.to_float() / len.to_float() * 360.0 + (time * speed * 0.2) % 360.0)" +name = "Phase Shift" -[[layers]] -effect = "script" -mode = "add" -speed = 1.0 -code = "from_hue(pixel.to_float() / len.to_float() * 360.0 + (time * speed * 2.7) % 360.0)" +[[params]] +name = "speed1" +label = "Speed 1" +[params.control] +type = "slider" +min = 0.1 +max = 3.0 +[params.default] +type = "number" +value = 1.0 + +[[params]] +name = "speed2" +label = "Speed 2" +[params.control] +type = "slider" +min = 0.1 +max = 3.0 +[params.default] +type = "number" +value = 1.0 + +[script] +code = """ +let p = pixel.to_float() / len.to_float(); +let a = from_hue(p * 360.0 + (time * speed1 * 0.2) % 360.0); +let b = from_hue(p * 360.0 + (time * speed2 * 2.7) % 360.0); +add_colors(dim(a, 0.5), dim(b, 0.5)) +""" diff --git a/crates/effects/config/effects/plasma.rhai b/crates/effects/config/effects/plasma.rhai deleted file mode 100644 index 137efdf..0000000 --- a/crates/effects/config/effects/plasma.rhai +++ /dev/null @@ -1,5 +0,0 @@ -let x = pixel.to_float() / len.to_float(); -let t = time * speed * 0.05; -let v = sin(x * 10.0 + t) + sin(x * 4.0 - t * 0.7) - + sin(x * 7.0 + t * 0.5) + sin(x * 2.0 + t * 1.3); -from_hue((v + 4.0) / 8.0 * 360.0) diff --git a/crates/effects/config/effects/plasma.toml b/crates/effects/config/effects/plasma.toml new file mode 100644 index 0000000..cd0240e --- /dev/null +++ b/crates/effects/config/effects/plasma.toml @@ -0,0 +1,21 @@ +name = "Plasma" + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.1 +max = 3.0 +[params.default] +type = "number" +value = 1.0 + +[script] +code = """ +let x = pixel.to_float() / len.to_float(); +let s = time * speed * 0.05; +let v = sin(x * 10.0 + s) + sin(x * 4.0 - s * 0.7) + + sin(x * 7.0 + s * 0.5) + sin(x * 2.0 + s * 1.3); +from_hue((v + 4.0) / 8.0 * 360.0) +""" diff --git a/crates/effects/config/effects/pulse.toml b/crates/effects/config/effects/pulse.toml index cc36314..cace3f0 100644 --- a/crates/effects/config/effects/pulse.toml +++ b/crates/effects/config/effects/pulse.toml @@ -1,11 +1,44 @@ -[[layers]] -effect = "plasma" - -[[layers]] -effect = "script" -mode = "add" -speed = 0.04 -code = """ -let b = 0.5 + 0.5 * sin(time * speed); -rgb_f(pr * b, pg * b, pb * b) +name = "Pulse" + +[[params]] +name = "color" +label = "Color" +[params.control] +type = "color" +[params.default] +type = "color" +value = {r = 255, g = 255, b = 255} + +[[params]] +name = "speed" +label = "Plasma Speed" +[params.control] +type = "slider" +min = 0.1 +max = 3.0 +[params.default] +type = "number" +value = 1.0 + +[[params]] +name = "breathe_speed" +label = "Breathe Speed" +[params.control] +type = "slider" +min = 0.005 +max = 0.15 +step = 0.005 +[params.default] +type = "number" +value = 0.04 + +[script] +code = """ +let x = pixel.to_float() / len.to_float(); +let s = time * speed * 0.05; +let v = sin(x * 10.0 + s) + sin(x * 4.0 - s * 0.7) + + sin(x * 7.0 + s * 0.5) + sin(x * 2.0 + s * 1.3); +let plasma = from_hue((v + 4.0) / 8.0 * 360.0); +let b = wave(time * breathe_speed); +add_colors(plasma, dim(color, b * 0.6)) """ diff --git a/crates/effects/config/effects/rainbow.toml b/crates/effects/config/effects/rainbow.toml index 9f72939..f8b9869 100644 --- a/crates/effects/config/effects/rainbow.toml +++ b/crates/effects/config/effects/rainbow.toml @@ -1,7 +1,18 @@ -[[layers]] -effect = "script" -speed = 1.5 -code = """ +name = "Rainbow" + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.1 +max = 5.0 +[params.default] +type = "number" +value = 1.5 + +[script] +code = """ let hue_offset = (time * speed) % 360.0; from_hue(pixel.to_float() / len.to_float() * 360.0 + hue_offset) """ diff --git a/crates/effects/config/effects/scanner.toml b/crates/effects/config/effects/scanner.toml index e0aeb4d..4b284f7 100644 --- a/crates/effects/config/effects/scanner.toml +++ b/crates/effects/config/effects/scanner.toml @@ -1,5 +1,35 @@ -[[layers]] -effect = "script" -speed = 0.4 -width = 8.0 -code = "dim(rgb_f(pr, pg, pb), scanner_factor(pixel, len, time, speed, width))" +name = "Scanner" + +[[params]] +name = "color" +label = "Color" +[params.control] +type = "color" +[params.default] +type = "color" +value = {r = 255, g = 255, b = 255} + +[[params]] +name = "speed" +label = "Speed" +[params.control] +type = "slider" +min = 0.1 +max = 2.0 +[params.default] +type = "number" +value = 0.4 + +[[params]] +name = "width" +label = "Width" +[params.control] +type = "slider" +min = 2.0 +max = 30.0 +[params.default] +type = "number" +value = 8.0 + +[script] +code = "dim(color, scanner_factor(pixel, len, time, speed, width))" diff --git a/crates/effects/config/effects/solar_wind.toml b/crates/effects/config/effects/solar_wind.toml index bebef5c..c7e2f87 100644 --- a/crates/effects/config/effects/solar_wind.toml +++ b/crates/effects/config/effects/solar_wind.toml @@ -1,7 +1,7 @@ -# Solar wind — aurora structure in warm oranges and yellows, like a chromosphere. -[[layers]] -effect = "script" -code = """ +name = "Solar Wind" + +[script] +code = """ let p = pixel.to_float(); let w1 = wave(p * 0.09 + time * 0.021); let w2 = wave(p * 0.05 - time * 0.014 + 1.3); diff --git a/crates/effects/config/effects/tv_effect.toml b/crates/effects/config/effects/tv_effect.toml deleted file mode 100644 index 4234dd8..0000000 --- a/crates/effects/config/effects/tv_effect.toml +++ /dev/null @@ -1,8 +0,0 @@ -[[layers]] -effect = "script" -fade_start = 35.0 -fade_end = 50.0 -code = """ -let opacity = smoothstep(fade_start, fade_end, pixel.to_float()); -rgb_f(pr * opacity, pg * opacity, pb * opacity) -""" diff --git a/crates/effects/src/builtin.rs b/crates/effects/src/builtin.rs new file mode 100644 index 0000000..f2ebfdc --- /dev/null +++ b/crates/effects/src/builtin.rs @@ -0,0 +1,68 @@ +use include_dir::{Dir, include_dir}; +use serde::Deserialize; + +use domain::ParamDef; + +static BUILTIN_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/config/effects"); + +#[derive(Debug, Clone)] +pub struct BuiltinEffect { + pub slug: String, + pub name: String, + pub script: String, + pub params: Vec, +} + +impl BuiltinEffect { + pub fn id(&self) -> String { + format!("builtin:{}", self.slug) + } +} + +#[derive(Deserialize)] +struct EffectFile { + name: String, + #[serde(default)] + params: Vec, + script: ScriptSection, +} + +#[derive(Deserialize)] +struct ScriptSection { + code: String, +} + +pub fn load_builtins() -> Vec { + let mut effects = Vec::new(); + for file in BUILTIN_DIR.files() { + let Some(name) = file.path().file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Some(ext) = file.path().extension().and_then(|s| s.to_str()) else { + continue; + }; + if ext != "toml" { + continue; + } + let Some(contents) = file.contents_utf8() else { + eprintln!( + "warning: builtin effect '{}' is not valid UTF-8, skipping", + name + ); + continue; + }; + match toml::from_str::(contents) { + Ok(ef) => effects.push(BuiltinEffect { + slug: name.to_string(), + name: ef.name, + script: ef.script.code.trim().to_string(), + params: ef.params, + }), + Err(e) => { + eprintln!("warning: failed to parse builtin effect '{}': {}", name, e); + } + } + } + effects.sort_by(|a, b| a.name.cmp(&b.name)); + effects +} diff --git a/crates/effects/src/compositor/builder.rs b/crates/effects/src/compositor/builder.rs deleted file mode 100644 index 88100f0..0000000 --- a/crates/effects/src/compositor/builder.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use serde_json::Value; - -use super::bus::{DEFAULT_SIGNAL_SPEED, PRIMARY_COLOR, ParameterBus, SECONDARY_COLOR}; -use super::composite::CompositeEffect; -use super::layer::CompositeLayer; -use super::registry::EffectRegistry; -use crate::error::EffectError; -use crate::preset::{EffectPreset, LayerPreset, SignalDef}; -use domain::{BlendMode, Rgb}; - -pub struct InitialState { - pub color: Rgb, - pub brightness: u8, - pub brightness_speed: f32, - pub signal_colors: HashMap, -} - -struct SignalInit { - color: Rgb, - speed: f32, -} - -const MAX_EFFECT_NESTING_DEPTH: usize = 8; - -pub fn build_prelude(functions: &HashMap) -> String { - functions.values().cloned().collect::>().join("\n") -} - -pub fn build_composite( - registry: &EffectRegistry, - preset: &EffectPreset, - state: &InitialState, - prelude: String, - all_presets: &HashMap, -) -> Result<(CompositeEffect, Arc), EffectError> { - let all_signals = collect_all_signals(preset, all_presets, 0); - - let mut signal_defs: HashMap = state - .signal_colors - .iter() - .map(|(name, &color)| { - ( - name.clone(), - SignalInit { - color, - speed: DEFAULT_SIGNAL_SPEED, - }, - ) - }) - .chain([ - ( - PRIMARY_COLOR.to_string(), - SignalInit { - color: state.color, - speed: DEFAULT_SIGNAL_SPEED, - }, - ), - ( - SECONDARY_COLOR.to_string(), - SignalInit { - color: state - .signal_colors - .get(SECONDARY_COLOR) - .copied() - .unwrap_or(Rgb::BLACK), - speed: DEFAULT_SIGNAL_SPEED, - }, - ), - ]) - .collect(); - signal_defs.extend( - all_signals - .iter() - .map(|(name, def)| (name.clone(), resolve_signal_init(name, def, state))), - ); - - let bus = Arc::new(signal_defs.into_iter().fold( - ParameterBus::new(state.brightness as f32, state.brightness_speed), - |mut bus, (name, init)| { - bus.register_color(name, init.color, init.speed); - bus - }, - )); - - all_signals - .iter() - .filter_map(|(name, def)| match def { - SignalDef::Script(code) => Some((name, code)), - _ => None, - }) - .try_for_each(|(name, code)| { - bus.set_animated(name, code) - .map_err(|e| EffectError::SignalScript { - signal: name.clone(), - source: Box::new(e), - }) - })?; - - let layers = build_layers(registry, &preset.layers, &bus, &prelude, all_presets, 0)?; - Ok(( - CompositeEffect { - layers, - bus: Arc::clone(&bus), - }, - bus, - )) -} - -fn resolve_signal_init(name: &str, def: &SignalDef, state: &InitialState) -> SignalInit { - match def { - SignalDef::Color(c) if name == PRIMARY_COLOR => SignalInit { - color: state.color, - speed: c.speed.unwrap_or(DEFAULT_SIGNAL_SPEED), - }, - SignalDef::Color(c) => SignalInit { - color: state - .signal_colors - .get(name) - .copied() - .unwrap_or(Rgb::from(c)), - speed: c.speed.unwrap_or(DEFAULT_SIGNAL_SPEED), - }, - SignalDef::Script(_) => SignalInit { - color: state.signal_colors.get(name).copied().unwrap_or(Rgb::BLACK), - speed: DEFAULT_SIGNAL_SPEED, - }, - } -} - -fn collect_all_signals( - preset: &EffectPreset, - all_presets: &HashMap, - depth: usize, -) -> HashMap { - if depth > MAX_EFFECT_NESTING_DEPTH { - return HashMap::new(); - } - let mut signals = preset - .layers - .iter() - .filter_map(|layer| all_presets.get(&layer.effect)) - .flat_map(|sub| collect_all_signals(sub, all_presets, depth + 1)) - .fold(HashMap::new(), |mut acc, (k, v)| { - acc.entry(k).or_insert(v); - acc - }); - signals.extend(preset.signals.iter().map(|(k, v)| (k.clone(), v.clone()))); - signals -} - -fn build_layers( - registry: &EffectRegistry, - layers: &[LayerPreset], - bus: &Arc, - prelude: &str, - all_presets: &HashMap, - depth: usize, -) -> Result, EffectError> { - if depth > MAX_EFFECT_NESTING_DEPTH { - return Err(EffectError::NestingTooDeep(MAX_EFFECT_NESTING_DEPTH)); - } - layers.iter().try_fold(Vec::new(), |mut acc, layer| { - if let Some(sub_preset) = all_presets.get(&layer.effect) { - let merged = merge_sub_layers(&sub_preset.layers, &layer.params); - let sub = build_layers(registry, &merged, bus, prelude, all_presets, depth + 1)?; - acc.extend(with_parent_overrides(sub, layer)); - } else { - acc.push(leaf_layer(registry, layer, bus, prelude)?); - } - Ok(acc) - }) -} - -fn merge_sub_layers( - sub: &[LayerPreset], - parent_params: &HashMap, -) -> Vec { - sub.iter() - .map(|sl| LayerPreset { - params: sl - .params - .iter() - .chain(parent_params.iter()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - ..sl.clone() - }) - .collect() -} - -fn with_parent_overrides( - mut layers: Vec, - parent: &LayerPreset, -) -> Vec { - if let Some(ref mode_str) = parent.mode { - let mode = BlendMode::from(mode_str.as_str()); - layers.iter_mut().for_each(|l| l.mode = mode); - } - if let Some(og) = parent.opacity_gradient { - layers - .iter_mut() - .for_each(|l| l.opacity_gradient = Some(og)); - } - layers -} - -fn leaf_layer( - registry: &EffectRegistry, - layer: &LayerPreset, - bus: &Arc, - prelude: &str, -) -> Result { - let params = Value::Object(layer.params.clone().into_iter().collect()); - registry - .build_layer(&layer.effect, params, Arc::clone(bus), prelude) - .map(|effect| CompositeLayer { - effect, - mode: layer - .mode - .as_deref() - .map(BlendMode::from) - .unwrap_or(BlendMode::Override), - opacity_gradient: layer.opacity_gradient, - }) - .map_err(|e| EffectError::LayerBuild { - effect: layer.effect.clone(), - source: Box::new(e), - }) -} diff --git a/crates/effects/src/compositor/bus.rs b/crates/effects/src/compositor/bus.rs deleted file mode 100644 index e9e83fa..0000000 --- a/crates/effects/src/compositor/bus.rs +++ /dev/null @@ -1,159 +0,0 @@ -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; - -use rhai::{AST, Engine, Scope}; - -use application::{BusProxy, SignalError}; -use domain::Rgb; - -use super::live_param::LiveParam; -use crate::error::EffectError; -use crate::rhai::{make_signal_engine, parse_color}; - -pub const PRIMARY_COLOR: &str = "primary_color"; -pub(crate) const SECONDARY_COLOR: &str = "secondary_color"; -pub(crate) const DEFAULT_SIGNAL_SPEED: f32 = 5.0; - -struct SignalScript { - engine: Engine, - ast: AST, - live: LiveParam, - frame: AtomicU64, - from_color: Rgb, - fade_frames: usize, -} - -impl SignalScript { - fn new(code: &str, from_color: Rgb, fade_frames: usize) -> Result, EffectError> { - let engine = make_signal_engine(); - let ast = engine.compile(code).map_err(EffectError::ScriptCompile)?; - Ok(Arc::new(Self { - engine, - ast, - live: LiveParam::new(from_color, f32::MAX), - frame: AtomicU64::new(0), - from_color, - fade_frames, - })) - } - - fn tick(&self) { - let frame = self.frame.fetch_add(1, Ordering::Relaxed); - let mut scope = Scope::new(); - scope.push("time", frame as f64); - scope.push("frame", frame as i64); - scope.push("pi", std::f64::consts::PI); - let script_color = self - .engine - .eval_ast_with_scope::(&mut scope, &self.ast) - .map(parse_color) - .unwrap_or(Rgb::BLACK); - let color = if self.fade_frames > 0 && (frame as usize) < self.fade_frames { - let t = (frame as f32 + 1.0) / self.fade_frames as f32; - self.from_color.lerp(script_color, t) - } else { - script_color - }; - self.live.set_immediate(color); - } -} - -pub struct ParameterBus { - pub brightness: LiveParam, - colors: RwLock>>, - animated: RwLock>>, - transition_frames: usize, -} - -impl ParameterBus { - pub fn new(brightness: f32, brightness_speed: f32) -> Self { - let transition_frames = (255.0 / brightness_speed.max(1.0)).round() as usize; - ParameterBus { - brightness: LiveParam::new(brightness, brightness_speed), - colors: RwLock::new(HashMap::new()), - animated: RwLock::new(HashMap::new()), - transition_frames, - } - } - - pub fn register_color(&mut self, name: String, initial: Rgb, speed: f32) { - self.colors - .write() - .unwrap() - .insert(name, LiveParam::new(initial, speed)); - } - - pub fn all_colors(&self) -> HashMap { - self.colors - .read() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.get())) - .collect() - } - - pub fn set_color(&self, name: &str, value: Rgb) { - let was_animated = self.animated.write().unwrap().remove(name).is_some(); - let mut colors = self.colors.write().unwrap(); - if was_animated { - // Animation params snap immediately; create a normal-speed param to fade from current. - let current = colors.get(name).map(|p| p.get()).unwrap_or(Rgb::BLACK); - let param = LiveParam::new(current, DEFAULT_SIGNAL_SPEED); - param.set(value); - colors.insert(name.to_string(), param); - } else { - colors - .entry(name.to_string()) - .or_insert_with(|| LiveParam::new(value, DEFAULT_SIGNAL_SPEED)) - .set(value); - } - } - - pub fn set_animated(&self, name: &str, code: &str) -> Result<(), EffectError> { - let current = self - .colors - .read() - .unwrap() - .get(name) - .map(|p| p.get()) - .unwrap_or(Rgb::BLACK); - let script = SignalScript::new(code, current, self.transition_frames)?; - let live = script.live.clone(); - self.colors.write().unwrap().insert(name.to_string(), live); - self.animated - .write() - .unwrap() - .insert(name.to_string(), script); - Ok(()) - } - - pub fn tick(&self) { - self.brightness.tick(); - self.animated - .read() - .unwrap() - .values() - .for_each(|s| s.tick()); - self.colors.read().unwrap().values().for_each(|p| p.tick()); - } -} - -impl BusProxy for ParameterBus { - fn set_color(&self, name: &str, color: Rgb) { - ParameterBus::set_color(self, name, color); - } - - fn set_brightness(&self, value: f32) { - self.brightness.set(value); - } - - fn set_animated(&self, name: &str, code: &str) -> Result<(), SignalError> { - ParameterBus::set_animated(self, name, code) - .map_err(|e| SignalError::ScriptCompile(e.to_string())) - } - - fn all_colors(&self) -> HashMap { - ParameterBus::all_colors(self) - } -} diff --git a/crates/effects/src/compositor/composite.rs b/crates/effects/src/compositor/composite.rs index b6d97d8..cbe38cb 100644 --- a/crates/effects/src/compositor/composite.rs +++ b/crates/effects/src/compositor/composite.rs @@ -3,51 +3,61 @@ use std::sync::Arc; use domain::{BlendMode, Rgb}; use engine::Effect; -use super::bus::ParameterBus; -use super::layer::{CompositeLayer, OpacityGradient}; +use super::layer::{CompositeLayer, ZoneGradient}; +use super::live_param::LiveParam; pub struct CompositeEffect { pub layers: Vec, - pub bus: Arc, + pub brightness: Arc>, } impl Effect for CompositeEffect { fn frames(&self, pixels: &[Rgb]) -> Box> + Send + 'static> { - let len = pixels.len(); + let strip_len = pixels.len(); let layers = self.layers.clone(); - let bus = Arc::clone(&self.bus); + let brightness = Arc::clone(&self.brightness); Box::new(std::iter::from_fn(move || { - bus.tick(); - let frame = layers.iter().fold(vec![Rgb::BLACK; len], |base, layer| { - blend_layer( - base, - &layer.effect.render(len), - layer.mode, - layer.opacity_gradient, - ) - }); - let brightness = bus.brightness.get() as u8; - Some( - frame - .into_iter() - .map(|c| c.with_brightness(brightness)) - .collect(), - ) + brightness.tick(); + let frame = layers + .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) + }); + let b = brightness.get() as u8; + Some(frame.into_iter().map(|c| c.with_brightness(b)).collect()) })) } } -fn blend_layer( - base: Vec, - overlay: &[Rgb], - mode: BlendMode, - gradient: Option, -) -> Vec { +fn blend_layer(base: Vec, overlay: &[Rgb], mode: BlendMode, zone: &ZoneGradient) -> Vec { + if overlay.is_empty() { + return base; + } + let strip_len = base.len(); + let (rstart, rend) = ( + zone.render_start().min(strip_len), + zone.render_end(strip_len), + ); base.into_iter() - .zip(overlay) .enumerate() - .map(|(i, (b, &o))| blend_pixel(b, o, mode, gradient.map_or(1.0, |g| pixel_opacity(g, i)))) + .map( + |(strip_px, base_px)| match strip_px < rstart || strip_px >= rend { + true => base_px, + false => { + let idx = strip_px + .saturating_sub(zone.start_pixel) + .min(overlay.len() - 1); + blend_pixel( + base_px, + overlay[idx], + mode, + pixel_opacity(zone, strip_px, strip_len), + ) + } + }, + ) .collect() } @@ -69,12 +79,21 @@ fn blend_pixel(base: Rgb, overlay: Rgb, mode: BlendMode, opacity: f32) -> Rgb { } } -fn pixel_opacity(gradient: OpacityGradient, i: usize) -> f32 { - if i < gradient.start_pixel { - return 0.0; - } - if gradient.end_pixel <= gradient.start_pixel || i >= gradient.end_pixel { +fn pixel_opacity(zone: &ZoneGradient, strip_px: usize, strip_len: usize) -> f32 { + let tl = zone.transition_length; + if tl == 0 { return 1.0; } - (i - gradient.start_pixel) as f32 / (gradient.end_pixel - gradient.start_pixel) as f32 + let render_start = zone.start_pixel.saturating_sub(tl); + let render_end = (zone.end_pixel + tl).min(strip_len); + + let fade_in = match render_start == zone.start_pixel || strip_px >= zone.start_pixel { + true => 1.0, + false => (strip_px - render_start) as f32 / (zone.start_pixel - render_start) as f32, + }; + let fade_out = match render_end == zone.end_pixel || strip_px < zone.end_pixel { + true => 1.0, + false => (render_end - strip_px) as f32 / (render_end - zone.end_pixel) as f32, + }; + fade_in.min(fade_out) } diff --git a/crates/effects/src/compositor/layer.rs b/crates/effects/src/compositor/layer.rs index 57e51b0..7d5273c 100644 --- a/crates/effects/src/compositor/layer.rs +++ b/crates/effects/src/compositor/layer.rs @@ -1,22 +1,43 @@ use std::sync::Arc; -use serde::{Deserialize, Serialize}; - use domain::{BlendMode, Rgb}; -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -pub struct OpacityGradient { +pub trait LayerEffect: Send + Sync + 'static { + fn render(&self, len: usize) -> Vec; +} + +#[derive(Clone, Copy, Debug)] +pub struct ZoneGradient { pub start_pixel: usize, pub end_pixel: usize, + pub transition_length: usize, } -pub trait LayerEffect: Send + Sync + 'static { - fn render(&self, len: usize) -> Vec; +impl ZoneGradient { + pub fn full_strip(len: usize) -> Self { + Self { + start_pixel: 0, + end_pixel: len, + transition_length: 0, + } + } + + pub fn zone_len(&self) -> usize { + self.end_pixel.saturating_sub(self.start_pixel) + } + + pub fn render_start(&self) -> usize { + self.start_pixel.saturating_sub(self.transition_length) + } + + pub fn render_end(&self, strip_len: usize) -> usize { + (self.end_pixel + self.transition_length).min(strip_len) + } } #[derive(Clone)] pub struct CompositeLayer { pub effect: Arc, pub mode: BlendMode, - pub opacity_gradient: Option, + pub zone: ZoneGradient, } diff --git a/crates/effects/src/compositor/mod.rs b/crates/effects/src/compositor/mod.rs index 0e174eb..8c6fab5 100644 --- a/crates/effects/src/compositor/mod.rs +++ b/crates/effects/src/compositor/mod.rs @@ -1,5 +1,3 @@ -pub mod builder; -pub mod bus; pub mod composite; pub mod layer; pub mod live_param; diff --git a/crates/effects/src/compositor/registry.rs b/crates/effects/src/compositor/registry.rs index 8543e30..22fb249 100644 --- a/crates/effects/src/compositor/registry.rs +++ b/crates/effects/src/compositor/registry.rs @@ -3,12 +3,10 @@ use std::sync::Arc; use serde_json::Value; -use super::bus::ParameterBus; use super::layer::LayerEffect; use crate::error::EffectError; -pub type LayerBuilder = - fn(Value, Arc, &str) -> Result, EffectError>; +pub type LayerBuilder = fn(Value, usize, usize) -> Result, EffectError>; #[derive(Clone, Default)] pub struct EffectRegistry { @@ -24,13 +22,13 @@ impl EffectRegistry { &self, name: &str, params: Value, - bus: Arc, - prelude: &str, + strip_len: usize, + zone_start: usize, ) -> Result, EffectError> { let builder = self .layer_builders .get(name) .ok_or_else(|| EffectError::UnknownEffect(name.to_string()))?; - builder(params, bus, prelude) + builder(params, strip_len, zone_start) } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f148666..5d90f45 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1,11 +1,14 @@ +pub mod builtin; pub(crate) mod compositor; pub mod error; -pub mod preset; pub(crate) mod rhai; +pub mod scene_builder; -pub use compositor::{builder, bus, composite, layer, live_param, registry}; +pub use builtin::{BuiltinEffect, load_builtins}; +pub use compositor::live_param::LiveParam; +pub use compositor::{composite, layer, live_param, registry}; pub use error::EffectError; -pub use preset::config; +pub use scene_builder::build_composite; pub fn build_registry() -> registry::EffectRegistry { let mut registry = registry::EffectRegistry::default(); @@ -13,21 +16,12 @@ pub fn build_registry() -> registry::EffectRegistry { registry } -pub fn validate_scripts(config: &config::EffectsConfig, prelude: &str) -> Vec { +pub fn validate_builtin_scripts() -> Vec { let engine = rhai::make_script_engine(); let mut errors = Vec::new(); - for (preset_name, preset) in &config.presets { - for (i, layer) in preset.layers.iter().enumerate() { - if layer.effect != "script" { - continue; - } - let Some(code) = layer.params.get("code").and_then(|v| v.as_str()) else { - continue; - }; - let full_code = format!("{prelude}\n{code}"); - if let Err(e) = engine.compile(&full_code) { - errors.push(format!("preset '{}' layer {}: {}", preset_name, i, e)); - } + for effect in load_builtins() { + if let Err(e) = engine.compile(&effect.script) { + errors.push(format!("builtin '{}': {}", effect.slug, e)); } } errors diff --git a/crates/effects/src/preset/config.rs b/crates/effects/src/preset/config.rs deleted file mode 100644 index a54b881..0000000 --- a/crates/effects/src/preset/config.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::collections::HashMap; -use std::path::Path; - -use include_dir::{Dir, include_dir}; -use serde::{Deserialize, Serialize}; - -use super::EffectPreset; - -static STOCK_FUNCTIONS: Dir = include_dir!("$CARGO_MANIFEST_DIR/config/functions"); -static STOCK_EFFECTS: Dir = include_dir!("$CARGO_MANIFEST_DIR/config/effects"); - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct EffectsConfig { - #[serde(default)] - pub presets: HashMap, - #[serde(default)] - pub functions: HashMap, -} - -impl EffectsConfig { - pub fn from_dir(dir: &Path) -> Self { - let mut config = load_embedded(); - let fs = load_from_fs(dir); - config.functions.extend(fs.functions); - config.presets.extend(fs.presets); - config - } -} - -fn load_embedded() -> EffectsConfig { - let functions = STOCK_FUNCTIONS - .files() - .filter(|f| f.path().extension().and_then(|e| e.to_str()) == Some("rhai")) - .filter_map(|f| { - Some(( - f.path().file_stem()?.to_str()?.to_string(), - f.contents_utf8()?.to_string(), - )) - }) - .collect(); - - let presets = STOCK_EFFECTS - .files() - .filter_map(|f| { - let name = f.path().file_stem()?.to_str()?.to_string(); - let content = f.contents_utf8()?; - let preset = match f.path().extension()?.to_str()? { - "toml" => parse_preset(content).ok()?, - "rhai" => EffectPreset::single_script(content), - _ => return None, - }; - Some((name, preset)) - }) - .collect(); - - EffectsConfig { presets, functions } -} - -fn load_from_fs(dir: &Path) -> EffectsConfig { - let functions = std::fs::read_dir(dir.join("functions")) - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().extension().and_then(|e| e.to_str()) == Some("rhai")) - .filter_map(|e| { - let path = e.path(); - let name = path.file_stem()?.to_str()?.to_string(); - Some((name, std::fs::read_to_string(&path).ok()?)) - }) - .collect(); - - let presets = std::fs::read_dir(dir.join("effects")) - .into_iter() - .flatten() - .flatten() - .filter(|e| { - matches!( - e.path().extension().and_then(|e| e.to_str()), - Some("toml" | "rhai") - ) - }) - .filter_map(|e| { - let path = e.path(); - let name = path.file_stem()?.to_str()?.to_string(); - let content = std::fs::read_to_string(&path).ok()?; - let preset = match path.extension().and_then(|e| e.to_str())? { - "toml" => match parse_preset(&content) { - Ok(p) => p, - Err(e) => { - eprintln!("warning: skipping {:?}: {}", path, e); - return None; - } - }, - _ => EffectPreset::single_script(&content), - }; - Some((name, preset)) - }) - .collect(); - - EffectsConfig { presets, functions } -} - -fn parse_preset(content: &str) -> Result { - config::Config::builder() - .add_source(config::File::from_str(content, config::FileFormat::Toml)) - .build() - .and_then(|c| c.try_deserialize::()) -} diff --git a/crates/effects/src/preset/mod.rs b/crates/effects/src/preset/mod.rs deleted file mode 100644 index dc96591..0000000 --- a/crates/effects/src/preset/mod.rs +++ /dev/null @@ -1,60 +0,0 @@ -pub mod config; - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::layer::OpacityGradient; -use domain::Rgb; - -#[derive(Debug, Deserialize, Serialize, Clone)] -#[serde(untagged)] -pub enum SignalDef { - Color(SignalColorDef), - Script(String), -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct SignalColorDef { - pub r: u8, - pub g: u8, - pub b: u8, - pub speed: Option, -} - -impl From<&SignalColorDef> for Rgb { - fn from(c: &SignalColorDef) -> Self { - Rgb::new(c.r, c.g, c.b) - } -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LayerPreset { - pub effect: String, - pub mode: Option, - pub opacity_gradient: Option, - #[serde(flatten)] - pub params: HashMap, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct EffectPreset { - #[serde(default)] - pub signals: HashMap, - pub layers: Vec, -} - -impl EffectPreset { - pub(crate) fn single_script(code: &str) -> Self { - EffectPreset { - signals: HashMap::new(), - layers: vec![LayerPreset { - effect: "script".to_string(), - mode: None, - opacity_gradient: None, - params: [("code".to_string(), Value::String(code.to_string()))].into(), - }], - } - } -} diff --git a/crates/effects/src/rhai/mod.rs b/crates/effects/src/rhai/mod.rs index 1f9576a..b120d9b 100644 --- a/crates/effects/src/rhai/mod.rs +++ b/crates/effects/src/rhai/mod.rs @@ -10,16 +10,9 @@ pub use color::parse_color; pub fn make_script_engine() -> Engine { let mut engine = Engine::new(); engine.set_max_operations(0); + engine.set_fast_operators(true); math::register(&mut engine); color::register(&mut engine); effects::register(&mut engine); engine } - -pub fn make_signal_engine() -> Engine { - let mut engine = Engine::new(); - engine.set_max_operations(1_000); - math::register(&mut engine); - color::register(&mut engine); - engine -} diff --git a/crates/effects/src/rhai/script.rs b/crates/effects/src/rhai/script.rs index e942cca..5ece9ab 100644 --- a/crates/effects/src/rhai/script.rs +++ b/crates/effects/src/rhai/script.rs @@ -2,62 +2,38 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use rhai::{AST, Engine, Scope}; -use serde::Deserialize; -use serde_json::Value; +use rhai::{AST, Dynamic, Engine, ImmutableString, Scope}; + +use domain::{ParamDef, ParamValue, Rgb}; use super::{make_script_engine, parse_color}; -use crate::compositor::bus::{PRIMARY_COLOR, ParameterBus, SECONDARY_COLOR}; use crate::compositor::layer::LayerEffect; use crate::compositor::registry::EffectRegistry; use crate::error::EffectError; -use domain::Rgb; - -#[derive(Deserialize)] -struct ScriptParams { - code: String, - #[serde(flatten)] - vars: HashMap, -} -pub struct Script { +pub struct ScriptLayer { engine: Engine, ast: AST, - bus: Arc, - extra_vars: Vec<(String, f64)>, frame: AtomicU64, + params: Vec<(ImmutableString, Dynamic)>, + strip_len: usize, + zone_start: usize, } -impl LayerEffect for Script { +impl LayerEffect for ScriptLayer { fn render(&self, len: usize) -> Vec { let frame = self.frame.fetch_add(1, Ordering::Relaxed); let mut scope = Scope::new(); scope.push("len", len as i64); + scope.push("strip_len", self.strip_len as i64); + scope.push("zone_start", self.zone_start as i64); scope.push("time", frame as f64); scope.push("frame", frame as i64); scope.push("pi", std::f64::consts::PI); - let colors = self.bus.all_colors(); - for (name, color) in &colors { - scope.push(name.clone(), *color); - } - - let primary = colors - .get(PRIMARY_COLOR) - .copied() - .unwrap_or(Rgb::new(255, 255, 255)); - let secondary = colors.get(SECONDARY_COLOR).copied().unwrap_or(Rgb::BLACK); - let to_f = |c: u8| c as f64 / 255.0; - scope.push("pr", to_f(primary.r)); - scope.push("pg", to_f(primary.g)); - scope.push("pb", to_f(primary.b)); - scope.push("sr", to_f(secondary.r)); - scope.push("sg", to_f(secondary.g)); - scope.push("sb", to_f(secondary.b)); - - for (name, val) in &self.extra_vars { - scope.push(name.as_str(), *val); + for (name, val) in &self.params { + scope.push_dynamic(name.as_str(), val.clone()); } let base_len = scope.len(); @@ -67,7 +43,7 @@ impl LayerEffect for Script { scope.push("t", i as f64 / (len as f64 - 1.0).max(1.0)); let color = self .engine - .eval_ast_with_scope::(&mut scope, &self.ast) + .eval_ast_with_scope::(&mut scope, &self.ast) .map(parse_color) .unwrap_or(Rgb::BLACK); scope.rewind(base_len); @@ -77,35 +53,61 @@ impl LayerEffect for Script { } } -pub fn register(registry: &mut EffectRegistry) { - registry.register_layer("script", |params, bus, prelude| { - let p: ScriptParams = - serde_json::from_value(params).map_err(|e| EffectError::InvalidParams { - effect: "script".to_string(), - source: e, - })?; +pub fn build_layer( + script: &str, + param_defs: &[ParamDef], + layer_params: &HashMap, + strip_len: usize, + zone_start: usize, +) -> Result, EffectError> { + let params: Vec<(ImmutableString, Dynamic)> = param_defs + .iter() + .map(|def| { + let value = layer_params.get(&def.name).unwrap_or(&def.default); + (ImmutableString::from(&def.name), param_to_dynamic(value)) + }) + .collect(); - let mut extra_vars: Vec<(String, f64)> = p - .vars - .into_iter() - .filter_map(|(k, v)| v.as_f64().map(|f| (k, f))) - .collect(); - if !extra_vars.iter().any(|(k, _)| k == "speed") { - extra_vars.push(("speed".to_string(), 1.0)); - } + let engine = make_script_engine(); + let ast = engine.compile(script).map_err(EffectError::ScriptCompile)?; + + Ok(Arc::new(ScriptLayer { + engine, + ast, + frame: AtomicU64::new(0), + params, + strip_len, + zone_start, + })) +} + +pub fn register(registry: &mut EffectRegistry) { + registry.register_layer("script", |params, strip_len, zone_start| { + let code = params + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let engine = make_script_engine(); - let full_code = format!("{prelude}\n{code}", code = p.code); - let ast = engine - .compile(&full_code) - .map_err(EffectError::ScriptCompile)?; + let ast = engine.compile(&code).map_err(EffectError::ScriptCompile)?; - Ok(Arc::new(Script { + Ok(Arc::new(ScriptLayer { engine, ast, - bus, - extra_vars, frame: AtomicU64::new(0), - }) as Arc) + params: Vec::new(), + strip_len, + zone_start, + })) }); } + +pub fn param_to_dynamic(value: &ParamValue) -> Dynamic { + match value { + ParamValue::Number(n) => Dynamic::from(*n as f64), + ParamValue::Color(rgb) => Dynamic::from(*rgb), + ParamValue::Bool(b) => Dynamic::from(*b), + ParamValue::Select(s) => Dynamic::from(s.clone()), + } +} diff --git a/crates/effects/src/scene_builder.rs b/crates/effects/src/scene_builder.rs new file mode 100644 index 0000000..4f8235f --- /dev/null +++ b/crates/effects/src/scene_builder.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use domain::{BlendMode, ParamDef, ParamValue}; + +use crate::compositor::composite::CompositeEffect; +use crate::compositor::layer::{CompositeLayer, ZoneGradient}; +use crate::compositor::live_param::LiveParam; +use crate::error::EffectError; +use crate::rhai::script; + +pub struct LayerSpec<'a> { + pub script: &'a str, + pub param_defs: &'a [ParamDef], + pub params: &'a HashMap, + pub blend_mode: BlendMode, + pub zone_start: usize, + pub zone_end: usize, + pub zone_transition: usize, +} + +pub fn build_composite( + layers: &[LayerSpec<'_>], + strip_len: usize, + brightness: Arc>, +) -> Result { + let composite_layers = layers + .iter() + .map(|spec| { + let effect = script::build_layer( + spec.script, + spec.param_defs, + spec.params, + strip_len, + spec.zone_start, + ) + .map_err(|e| EffectError::LayerBuild { + effect: spec.script.chars().take(40).collect(), + source: Box::new(e), + })?; + + Ok(CompositeLayer { + effect, + mode: spec.blend_mode, + zone: ZoneGradient { + start_pixel: spec.zone_start, + end_pixel: spec.zone_end, + transition_length: spec.zone_transition, + }, + }) + }) + .collect::, EffectError>>()?; + + Ok(CompositeEffect { + layers: composite_layers, + brightness, + }) +} diff --git a/crates/effects/tests/composite.rs b/crates/effects/tests/composite.rs index 061ed4e..a883f4c 100644 --- a/crates/effects/tests/composite.rs +++ b/crates/effects/tests/composite.rs @@ -1,36 +1,37 @@ use std::sync::Arc; use domain::{BlendMode, Rgb}; -use effects::bus::ParameterBus; +use effects::LiveParam; use effects::composite::CompositeEffect; -use effects::layer::{CompositeLayer, LayerEffect, OpacityGradient}; +use effects::layer::{CompositeLayer, LayerEffect, ZoneGradient}; use engine::Effect; struct Solid(Rgb); + impl LayerEffect for Solid { fn render(&self, len: usize) -> Vec { vec![self.0; len] } } -fn bus(brightness: f32) -> Arc { - Arc::new(ParameterBus::new(brightness, 255.0)) +fn brightness(val: f32) -> Arc> { + Arc::new(LiveParam::new(val, f32::MAX)) } -fn single_layer(color: Rgb, mode: BlendMode) -> CompositeEffect { - CompositeEffect { - layers: vec![CompositeLayer { - effect: Arc::new(Solid(color)), - mode, - opacity_gradient: None, - }], - bus: bus(255.0), +fn full_layer(color: Rgb, mode: BlendMode, strip_len: usize) -> CompositeLayer { + CompositeLayer { + effect: Arc::new(Solid(color)), + mode, + zone: ZoneGradient::full_strip(strip_len), } } #[test] fn override_mode_fills_frame_with_layer_color() { - let effect = single_layer(Rgb::new(255, 0, 0), BlendMode::Override); + let effect = CompositeEffect { + layers: vec![full_layer(Rgb::new(255, 0, 0), BlendMode::Override, 4)], + brightness: brightness(255.0), + }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert!(frame.iter().all(|p| *p == Rgb::new(255, 0, 0))); } @@ -39,18 +40,10 @@ fn override_mode_fills_frame_with_layer_color() { fn add_mode_saturates_channels() { let effect = CompositeEffect { layers: vec![ - CompositeLayer { - effect: Arc::new(Solid(Rgb::new(200, 0, 0))), - mode: BlendMode::Override, - opacity_gradient: None, - }, - CompositeLayer { - effect: Arc::new(Solid(Rgb::new(100, 0, 0))), - mode: BlendMode::Add, - opacity_gradient: None, - }, + full_layer(Rgb::new(200, 0, 0), BlendMode::Override, 4), + full_layer(Rgb::new(100, 0, 0), BlendMode::Add, 4), ], - bus: bus(255.0), + brightness: brightness(255.0), }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert!(frame.iter().all(|p| p.r == 255 && p.g == 0 && p.b == 0)); @@ -58,34 +51,131 @@ fn add_mode_saturates_channels() { #[test] fn zero_brightness_yields_black_frame() { - let effect = single_layer(Rgb::new(255, 255, 255), BlendMode::Override); let effect = CompositeEffect { - bus: bus(0.0), - ..effect + layers: vec![full_layer(Rgb::new(255, 255, 255), BlendMode::Override, 4)], + brightness: brightness(0.0), }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert!(frame.iter().all(|p| *p == Rgb::BLACK)); } #[test] -fn opacity_gradient_fades_in_layer_across_pixels() { +fn zone_restricts_rendering_to_pixel_range() { + let zone = ZoneGradient { + start_pixel: 2, + end_pixel: 4, + transition_length: 0, + }; let effect = CompositeEffect { layers: vec![CompositeLayer { effect: Arc::new(Solid(Rgb::new(255, 0, 0))), mode: BlendMode::Override, - opacity_gradient: Some(OpacityGradient { - start_pixel: 0, - end_pixel: 4, - }), + zone, }], - bus: bus(255.0), + brightness: brightness(255.0), }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); - assert!(frame[3].r > frame[0].r); + assert_eq!(frame[0], Rgb::BLACK); + assert_eq!(frame[1], Rgb::BLACK); + assert_eq!(frame[2], Rgb::new(255, 0, 0)); + assert_eq!(frame[3], Rgb::new(255, 0, 0)); +} + +#[test] +fn transition_extends_outside_logical_zone() { + // Zone covers pixels 3-7 on a 10-pixel strip with tl=2. + // Render range: 1-9. Interior 3-6 is full opacity. + // Leading extension 1-2 fades in; trailing extension 7-8 fades out. + let zone = ZoneGradient { + start_pixel: 3, + end_pixel: 7, + transition_length: 2, + }; + let effect = CompositeEffect { + layers: vec![CompositeLayer { + effect: Arc::new(Solid(Rgb::new(255, 0, 0))), + mode: BlendMode::Override, + zone, + }], + brightness: brightness(255.0), + }; + let frame = effect.frames(&[Rgb::BLACK; 10]).next().unwrap(); + // Logical interior: full red + assert_eq!(frame[3], Rgb::new(255, 0, 0)); + assert_eq!(frame[6], Rgb::new(255, 0, 0)); + // Leading extension fades in (dimmer than logical interior) + assert!( + frame[1].r < frame[3].r, + "fade-in: pixel 1 dimmer than interior" + ); + assert!( + frame[2].r < frame[3].r, + "fade-in: pixel 2 dimmer than interior" + ); + // Trailing extension fades out (render_end=9, fade over 7-8; pixel 7 is first extended pixel) + assert!( + frame[8].r < frame[6].r, + "fade-out: pixel 8 dimmer than interior" + ); + // Outside render range: untouched black + assert_eq!(frame[0], Rgb::BLACK); + assert_eq!(frame[9], Rgb::BLACK); +} + +#[test] +fn adjacent_zones_blend_without_black() { + // Zone A: 0-5 (red), Zone B: 5-10 (blue), tl=2. + // In blend region 3-6 (A's trailing ext 5-6, B's leading ext 3-4): + // Zone A's last pixel extends into B's territory and fades out. + // Zone B's first pixel extends into A's territory and fades in over fully-opaque A. + // No pixel in 3-6 should be black. + let zone_a = ZoneGradient { + start_pixel: 0, + end_pixel: 5, + transition_length: 2, + }; + let zone_b = ZoneGradient { + start_pixel: 5, + end_pixel: 10, + transition_length: 2, + }; + let effect = CompositeEffect { + layers: vec![ + CompositeLayer { + effect: Arc::new(Solid(Rgb::new(255, 0, 0))), + mode: BlendMode::Override, + zone: zone_a, + }, + CompositeLayer { + effect: Arc::new(Solid(Rgb::new(0, 0, 255))), + mode: BlendMode::Override, + zone: zone_b, + }, + ], + brightness: brightness(255.0), + }; + let frame = effect.frames(&[Rgb::BLACK; 10]).next().unwrap(); + // Full red in A's interior + assert_eq!(frame[0], Rgb::new(255, 0, 0)); + assert_eq!(frame[2], Rgb::new(255, 0, 0)); + // Full blue in B's interior + assert_eq!(frame[7], Rgb::new(0, 0, 255)); + assert_eq!(frame[9], Rgb::new(0, 0, 255)); + // Blend region: no black (both channels together > 0 at all blend pixels) + for (px, p) in frame.iter().enumerate().take(7).skip(3) { + assert!( + p.r > 0 || p.b > 0, + "pixel {px} should not be black in blend region, got {:?}", + p + ); + } } #[test] fn frames_iterator_is_infinite() { - let effect = single_layer(Rgb::new(0, 255, 0), BlendMode::Override); + let effect = CompositeEffect { + layers: vec![full_layer(Rgb::new(0, 255, 0), BlendMode::Override, 2)], + brightness: brightness(255.0), + }; assert_eq!(effect.frames(&[Rgb::BLACK; 2]).take(1000).count(), 1000); } diff --git a/crates/effects/tests/config.rs b/crates/effects/tests/config.rs index 773850e..88b5537 100644 --- a/crates/effects/tests/config.rs +++ b/crates/effects/tests/config.rs @@ -1,66 +1,52 @@ -use effects::config::EffectsConfig; -use effects::validate_scripts; -use std::path::PathBuf; +use effects::{load_builtins, validate_builtin_scripts}; #[test] -fn embedded_presets_are_loaded() { - let dir = PathBuf::from("/nonexistent"); - let config = EffectsConfig::from_dir(&dir); +fn load_builtins_returns_nonempty_list() { + let builtins = load_builtins(); assert!( - !config.presets.is_empty(), - "embedded presets should be non-empty" + !builtins.is_empty(), + "expected at least one built-in effect" ); } #[test] -fn known_stock_preset_is_present() { - let config = EffectsConfig::from_dir(&PathBuf::from("/nonexistent")); +fn rainbow_builtin_is_present() { + let builtins = load_builtins(); assert!( - config.presets.contains_key("rainbow"), - "rainbow preset should be present" + builtins.iter().any(|b| b.slug == "rainbow"), + "rainbow builtin should be present" ); } #[test] -fn validate_scripts_passes_for_all_stock_presets() { - let config = EffectsConfig::from_dir(&PathBuf::from("/nonexistent")); - let errors = validate_scripts(&config, ""); - assert!( - errors.is_empty(), - "stock presets have script errors: {:?}", - errors - ); +fn builtin_ids_have_builtin_prefix() { + for b in load_builtins() { + assert!( + b.id().starts_with("builtin:"), + "expected id to start with 'builtin:', got '{}'", + b.id() + ); + } } #[test] -fn validate_scripts_catches_broken_rhai() { - let dir = tempfile::tempdir().unwrap(); - let effects_dir = dir.path().join("effects"); - std::fs::create_dir_all(&effects_dir).unwrap(); - std::fs::write( - effects_dir.join("broken.rhai"), - "this is @@@ not valid rhai", - ) - .unwrap(); - - let config = EffectsConfig::from_dir(dir.path()); - let errors = validate_scripts(&config, ""); +fn validate_builtin_scripts_passes_for_all_stock_effects() { + let errors = validate_builtin_scripts(); assert!( - errors.iter().any(|e| e.contains("broken")), - "expected an error for broken preset, got: {:?}", + errors.is_empty(), + "stock builtin scripts have errors: {:?}", errors ); } #[test] -fn fs_presets_override_embedded_with_same_name() { - let dir = tempfile::tempdir().unwrap(); - let effects_dir = dir.path().join("effects"); - std::fs::create_dir_all(&effects_dir).unwrap(); - std::fs::write(effects_dir.join("rainbow.rhai"), "rgb(0, 0, 0)").unwrap(); - - let config = EffectsConfig::from_dir(dir.path()); - let preset = config.presets.get("rainbow").unwrap(); - // The fs preset replaced the embedded one (single script layer, code matches) - assert_eq!(preset.layers.len(), 1); +fn all_builtins_have_nonempty_name_and_script() { + for b in load_builtins() { + assert!(!b.name.is_empty(), "builtin '{}' has empty name", b.slug); + assert!( + !b.script.is_empty(), + "builtin '{}' has empty script", + b.slug + ); + } } diff --git a/crates/effects/tests/live_param.rs b/crates/effects/tests/live_param.rs index 7fce7b7..c7e2674 100644 --- a/crates/effects/tests/live_param.rs +++ b/crates/effects/tests/live_param.rs @@ -1,29 +1,26 @@ -use domain::Rgb; -use effects::bus::ParameterBus; -use effects::live_param::LiveParam; +use effects::LiveParam; #[test] fn set_only_updates_target_not_current() { - let p = LiveParam::new(Rgb::BLACK, 5.0); - p.set(Rgb::new(255, 0, 0)); - assert_eq!(p.get(), Rgb::BLACK); + let p = LiveParam::new(0.0f32, 5.0); + p.set(100.0); + assert_eq!(p.get(), 0.0); } #[test] fn set_immediate_updates_current_without_tick() { - let p = LiveParam::new(Rgb::BLACK, 5.0); - p.set_immediate(Rgb::new(255, 0, 0)); - assert_eq!(p.get(), Rgb::new(255, 0, 0)); + let p = LiveParam::new(0.0f32, 5.0); + p.set_immediate(100.0); + assert_eq!(p.get(), 100.0); } #[test] -fn tick_moves_rgb_toward_target() { - let p = LiveParam::new(Rgb::BLACK, 5.0); - let target = Rgb::new(100, 0, 0); - p.set(target); +fn tick_moves_f32_toward_target() { + let p = LiveParam::new(0.0f32, 10.0); + p.set(100.0); p.tick(); let after = p.get(); - assert!(after.distance(target) < Rgb::BLACK.distance(target)); + assert!(after > 0.0 && after <= 100.0); } #[test] @@ -35,43 +32,3 @@ fn tick_reaches_target_for_f32() { } assert_eq!(p.get(), 100.0); } - -#[test] -fn bus_set_color_reaches_target_after_tick() { - let mut bus = ParameterBus::new(255.0, 255.0); - bus.register_color("sig".to_string(), Rgb::BLACK, 255.0); - bus.set_color("sig", Rgb::new(255, 0, 0)); - bus.tick(); - assert_eq!(bus.all_colors()["sig"], Rgb::new(255, 0, 0)); -} - -#[test] -fn bus_animated_signal_drives_color_via_rhai() { - let mut bus = ParameterBus::new(255.0, 255.0); - bus.register_color("sig".to_string(), Rgb::BLACK, 255.0); - bus.set_animated("sig", "rgb(255, 0, 0)").unwrap(); - bus.tick(); - assert_eq!(bus.all_colors()["sig"], Rgb::new(255, 0, 0)); -} - -#[test] -fn bus_set_color_after_animated_clears_script() { - let mut bus = ParameterBus::new(255.0, 255.0); - bus.register_color("sig".to_string(), Rgb::BLACK, 255.0); - bus.set_animated("sig", "rgb(255, 0, 0)").unwrap(); - bus.tick(); - - bus.set_color("sig", Rgb::new(0, 255, 0)); - for _ in 0..60 { - bus.tick(); - } - assert_eq!(bus.all_colors()["sig"], Rgb::new(0, 255, 0)); -} - -#[test] -fn bus_brightness_advances_via_tick() { - let bus = ParameterBus::new(0.0, 255.0); - bus.brightness.set(255.0); - bus.tick(); - assert_eq!(bus.brightness.get() as u8, 255); -} diff --git a/crates/effects/tests/preset.rs b/crates/effects/tests/preset.rs deleted file mode 100644 index d257345..0000000 --- a/crates/effects/tests/preset.rs +++ /dev/null @@ -1,47 +0,0 @@ -use domain::Rgb; -use effects::preset::{EffectPreset, SignalColorDef, SignalDef}; - -#[test] -fn signal_color_def_converts_to_rgb() { - let def = SignalColorDef { - r: 10, - g: 20, - b: 30, - speed: None, - }; - let rgb = Rgb::from(&def); - assert_eq!(rgb, Rgb::new(10, 20, 30)); -} - -#[test] -fn signal_def_deserializes_as_color_from_object() { - let json = r#"{"r": 100, "g": 50, "b": 0}"#; - let def: SignalDef = serde_json::from_str(json).unwrap(); - assert!(matches!(def, SignalDef::Color(_))); -} - -#[test] -fn signal_def_deserializes_as_script_from_string() { - let json = r#""from_hue(time * 0.1)""#; - let def: SignalDef = serde_json::from_str(json).unwrap(); - assert!(matches!(def, SignalDef::Script(_))); -} - -#[test] -fn effect_preset_deserializes_from_toml() { - let toml = r#" -[[layers]] -effect = "script" -mode = "add" -code = "rgb(255, 0, 0)" -"#; - let preset: EffectPreset = config::Config::builder() - .add_source(config::File::from_str(toml, config::FileFormat::Toml)) - .build() - .unwrap() - .try_deserialize() - .unwrap(); - assert_eq!(preset.layers.len(), 1); - assert_eq!(preset.layers[0].effect, "script"); - assert_eq!(preset.layers[0].mode.as_deref(), Some("add")); -} diff --git a/crates/effects/tests/registry.rs b/crates/effects/tests/registry.rs index e4f6d64..bd2c694 100644 --- a/crates/effects/tests/registry.rs +++ b/crates/effects/tests/registry.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use domain::Rgb; use effects::EffectError; -use effects::bus::ParameterBus; use effects::layer::LayerEffect; use effects::registry::EffectRegistry; use serde_json::Value; struct Solid; + impl LayerEffect for Solid { fn render(&self, len: usize) -> Vec { vec![Rgb::BLACK; len] @@ -16,8 +16,8 @@ impl LayerEffect for Solid { fn solid_builder( _params: Value, - _bus: Arc, - _prelude: &str, + _strip_len: usize, + _zone_start: usize, ) -> Result, EffectError> { Ok(Arc::new(Solid)) } @@ -27,16 +27,14 @@ fn registered_layer_builds_successfully() { let mut registry = EffectRegistry::default(); registry.register_layer("solid", solid_builder); - let bus = Arc::new(ParameterBus::new(255.0, 255.0)); - let result = registry.build_layer("solid", Value::Null, bus, ""); + let result = registry.build_layer("solid", Value::Null, 10, 0); assert!(result.is_ok()); } #[test] fn unknown_layer_returns_error() { let registry = EffectRegistry::default(); - let bus = Arc::new(ParameterBus::new(255.0, 255.0)); - match registry.build_layer("does_not_exist", Value::Null, bus, "") { + match registry.build_layer("does_not_exist", Value::Null, 10, 0) { Err(EffectError::UnknownEffect(name)) => assert!(name.contains("does_not_exist")), Err(e) => panic!("expected UnknownEffect, got: {:?}", e), Ok(_) => panic!("expected error for unknown layer"), diff --git a/crates/effects/tests/script.rs b/crates/effects/tests/script.rs index 79d683a..1301c00 100644 --- a/crates/effects/tests/script.rs +++ b/crates/effects/tests/script.rs @@ -1,42 +1,15 @@ -use std::collections::HashMap; -use std::sync::Arc; - use domain::Rgb; use effects::build_registry; -use effects::builder::{InitialState, build_composite}; -use effects::bus::ParameterBus; -use effects::preset::{EffectPreset, LayerPreset}; use engine::Effect; -fn make_bus() -> Arc { - Arc::new(ParameterBus::new(255.0, 255.0)) -} - -fn preset(code: &str) -> EffectPreset { - let mut params = HashMap::new(); - params.insert( - "code".to_string(), - serde_json::Value::String(code.to_string()), - ); - EffectPreset { - signals: HashMap::new(), - layers: vec![LayerPreset { - effect: "script".to_string(), - mode: None, - opacity_gradient: None, - params, - }], - } -} - #[test] fn script_renders_solid_color() { let layer = build_registry() .build_layer( "script", serde_json::json!({ "code": "rgb(255, 0, 0)" }), - make_bus(), - "", + 4, + 0, ) .unwrap(); assert!(layer.render(4).iter().all(|p| *p == Rgb::new(255, 0, 0))); @@ -48,8 +21,8 @@ fn script_pixel_variable_is_in_scope() { .build_layer( "script", serde_json::json!({ "code": "if pixel == 0 { rgb(255, 0, 0) } else { rgb(0, 0, 0) }" }), - make_bus(), - "", + 4, + 0, ) .unwrap(); let frame = layer.render(4); @@ -59,12 +32,8 @@ fn script_pixel_variable_is_in_scope() { #[test] fn script_compile_error_returns_err() { - let result = build_registry().build_layer( - "script", - serde_json::json!({ "code": "this is not valid rhai !!!" }), - make_bus(), - "", - ); + let result = + build_registry().build_layer("script", serde_json::json!({ "code": "@@@ invalid" }), 4, 0); assert!(result.is_err()); } @@ -72,41 +41,19 @@ fn script_compile_error_returns_err() { fn script_unknown_layer_name_returns_err() { assert!( build_registry() - .build_layer("nonexistent", serde_json::json!({}), make_bus(), "") + .build_layer("nonexistent", serde_json::json!({}), 4, 0) .is_err() ); } -#[test] -fn build_composite_initializes_primary_color_from_initial() { - let initial = Rgb::new(100, 50, 25); - let state = InitialState { - color: initial, - brightness: 255, - brightness_speed: 255.0, - signal_colors: HashMap::new(), - }; - let (composite, bus) = build_composite( - &build_registry(), - &preset("rgb(0, 0, 0)"), - &state, - String::new(), - &HashMap::new(), - ) - .unwrap(); - - assert_eq!(bus.all_colors()["primary_color"], initial); - assert!(composite.frames(&[Rgb::BLACK; 4]).next().is_some()); -} - #[test] fn wave_returns_normalized_sine() { let layer = build_registry() .build_layer( "script", serde_json::json!({ "code": "if wave(0.0) > 0.49 && wave(0.0) < 0.51 { rgb(255, 0, 0) } else { rgb(0, 0, 0) }" }), - make_bus(), - "", + 1, + 0, ) .unwrap(); assert_eq!(layer.render(1)[0], Rgb::new(255, 0, 0)); @@ -118,8 +65,8 @@ fn smoothstep_clamps_below_lo() { .build_layer( "script", serde_json::json!({ "code": "if smoothstep(10.0, 20.0, 5.0) == 0.0 { rgb(255, 0, 0) } else { rgb(0, 0, 0) }" }), - make_bus(), - "", + 1, + 0, ) .unwrap(); assert_eq!(layer.render(1)[0], Rgb::new(255, 0, 0)); @@ -131,8 +78,8 @@ fn smoothstep_clamps_above_hi() { .build_layer( "script", serde_json::json!({ "code": "if smoothstep(10.0, 20.0, 25.0) == 1.0 { rgb(255, 0, 0) } else { rgb(0, 0, 0) }" }), - make_bus(), - "", + 1, + 0, ) .unwrap(); assert_eq!(layer.render(1)[0], Rgb::new(255, 0, 0)); @@ -143,24 +90,34 @@ fn remap_maps_range() { let layer = build_registry() .build_layer( "script", - // remap 0.5 from [0,1] to [0,100] → 50 serde_json::json!({ "code": "if remap(0.5, 0.0, 1.0, 0.0, 100.0) == 50.0 { rgb(255, 0, 0) } else { rgb(0, 0, 0) }" }), - make_bus(), - "", + 1, + 0, ) .unwrap(); assert_eq!(layer.render(1)[0], Rgb::new(255, 0, 0)); } #[test] -fn prelude_functions_are_available_in_script() { - let layer = build_registry() - .build_layer( - "script", - serde_json::json!({ "code": "rgb(double_red(100), 0, 0)" }), - make_bus(), - "fn double_red(r) { r * 2 }", - ) - .unwrap(); - assert!(layer.render(1).iter().all(|p| p.r == 200)); +fn build_composite_creates_valid_effect() { + use domain::BlendMode; + use effects::scene_builder::LayerSpec; + use effects::{LiveParam, build_composite}; + use std::collections::HashMap; + use std::sync::Arc; + + let params = HashMap::new(); + let specs = [LayerSpec { + script: "rgb(255, 0, 0)", + param_defs: &[], + params: ¶ms, + blend_mode: BlendMode::Override, + zone_start: 0, + zone_end: 4, + zone_transition: 0, + }]; + let brightness = Arc::new(LiveParam::new(255.0f32, f32::MAX)); + let composite = build_composite(&specs, 4, brightness).unwrap(); + let frame = composite.frames(&[Rgb::BLACK; 4]).next().unwrap(); + assert!(frame.iter().all(|p| *p == Rgb::new(255, 0, 0))); } From 322c6420b21b0380ade8aeea71b4c90a0a49aaff Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:38:48 +0100 Subject: [PATCH 03/16] refactor(application): remove bus/signal system, simplify SceneRuntime trait --- crates/application/src/bus.rs | 17 ----------------- crates/application/src/error.rs | 7 ------- crates/application/src/lib.rs | 6 +----- crates/application/src/runtime.rs | 10 ++-------- crates/application/src/snapshot.rs | 7 ------- crates/persistence/src/lib.rs | 7 ------- crates/persistence/tests/persistence.rs | 24 +++++++++++++++--------- 7 files changed, 18 insertions(+), 60 deletions(-) delete mode 100644 crates/application/src/bus.rs delete mode 100644 crates/application/src/error.rs diff --git a/crates/application/src/bus.rs b/crates/application/src/bus.rs deleted file mode 100644 index de30d16..0000000 --- a/crates/application/src/bus.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::collections::HashMap; - -use domain::Rgb; - -use crate::SignalError; - -pub trait BusProxy: Send + Sync { - fn set_color(&self, name: &str, color: Rgb); - fn set_brightness(&self, value: f32); - fn set_animated(&self, name: &str, code: &str) -> Result<(), SignalError>; - fn all_colors(&self) -> HashMap; -} - -pub enum SignalValue { - Color(Rgb), - Script(String), -} diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs deleted file mode 100644 index 3be7729..0000000 --- a/crates/application/src/error.rs +++ /dev/null @@ -1,7 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum SignalError { - #[error("script compile error: {0}")] - ScriptCompile(String), -} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 5314ad2..e99a36b 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,11 +1,7 @@ -mod bus; -mod error; mod events; mod runtime; mod snapshot; -pub use bus::{BusProxy, SignalValue}; -pub use error::SignalError; pub use events::StateEventBus; pub use runtime::SceneRuntime; -pub use snapshot::{SceneSnapshot, SignalOverrides}; +pub use snapshot::SceneSnapshot; diff --git a/crates/application/src/runtime.rs b/crates/application/src/runtime.rs index a626412..e809391 100644 --- a/crates/application/src/runtime.rs +++ b/crates/application/src/runtime.rs @@ -1,9 +1,6 @@ -use std::collections::HashMap; -use std::sync::Arc; - use domain::Rgb; -use crate::{BusProxy, SceneSnapshot, SignalError, SignalOverrides, SignalValue}; +use crate::SceneSnapshot; pub trait SceneRuntime: Send + Sync { fn set_color(&self, color: Rgb); @@ -14,9 +11,6 @@ pub trait SceneRuntime: Send + Sync { fn start_color_loop(&self, _duration: u64) {} fn start_sleep(&self, _duration: u64) {} fn start_wake(&self, _duration: u64) {} - fn set_signal(&self, name: &str, value: SignalValue) -> Result<(), SignalError>; - fn attach_bus(&self, bus: Arc, effect_name: String); + fn reload_active(&self); fn snapshot(&self) -> SceneSnapshot; - fn bus_colors(&self) -> HashMap; - fn signal_overrides(&self) -> SignalOverrides; } diff --git a/crates/application/src/snapshot.rs b/crates/application/src/snapshot.rs index 3718506..347efb4 100644 --- a/crates/application/src/snapshot.rs +++ b/crates/application/src/snapshot.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use domain::Rgb; use serde::Serialize; @@ -11,8 +9,3 @@ pub struct SceneSnapshot { pub active_effect: Option, pub light_effect_end_unix_timestamp_sec: Option, } - -pub struct SignalOverrides { - pub colors: HashMap, - pub scripts: HashMap, -} diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs index 9116750..4979afb 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::Path; use domain::Rgb; @@ -21,10 +20,6 @@ pub struct PersistedState { pub color: Rgb, #[serde(default)] pub active_effect: Option, - #[serde(default)] - pub signal_colors: HashMap, - #[serde(default)] - pub signal_scripts: HashMap, } impl Default for PersistedState { @@ -34,8 +29,6 @@ impl Default for PersistedState { brightness: 255, color: Rgb::BLACK, active_effect: None, - signal_colors: HashMap::new(), - signal_scripts: HashMap::new(), } } } diff --git a/crates/persistence/tests/persistence.rs b/crates/persistence/tests/persistence.rs index 59755b4..c172ec6 100644 --- a/crates/persistence/tests/persistence.rs +++ b/crates/persistence/tests/persistence.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use domain::Rgb; use persistence::{PersistedState, load, save}; @@ -24,8 +22,6 @@ fn save_and_load_round_trips() { brightness: 128, color: Rgb::new(10, 20, 30), active_effect: Some("lava".to_string()), - signal_colors: HashMap::from([("primary_color".to_string(), Rgb::new(255, 0, 0))]), - signal_scripts: HashMap::from([("wave".to_string(), "sin(t)".to_string())]), }; save(&path, &original).expect("save failed"); @@ -35,8 +31,6 @@ fn save_and_load_round_trips() { assert_eq!(restored.brightness, original.brightness); assert_eq!(restored.color, original.color); assert_eq!(restored.active_effect, original.active_effect); - assert_eq!(restored.signal_colors, original.signal_colors); - assert_eq!(restored.signal_scripts, original.signal_scripts); } #[test] @@ -52,7 +46,6 @@ fn load_with_corrupt_file_returns_error() { fn missing_optional_fields_deserialize_to_defaults() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.json"); - // Old state file without signal fields std::fs::write( &path, r#"{"on":true,"brightness":200,"color":{"r":0,"g":0,"b":0}}"#, @@ -61,6 +54,19 @@ fn missing_optional_fields_deserialize_to_defaults() { let state = load(&path).expect("load failed"); assert!(state.active_effect.is_none()); - assert!(state.signal_colors.is_empty()); - assert!(state.signal_scripts.is_empty()); +} + +#[test] +fn old_state_with_signal_fields_ignored_gracefully() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + // Old state.json with signal_colors and signal_scripts fields — should still load + std::fs::write( + &path, + r#"{"on":true,"brightness":255,"color":{"r":0,"g":0,"b":0},"signal_colors":{},"signal_scripts":{}}"#, + ) + .unwrap(); + + let state = load(&path).expect("load failed with old state format"); + assert!(state.on); } From 436c4db782a3a118ddacf96871b1073752c58690 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:39:11 +0100 Subject: [PATCH 04/16] fix(store): simplify load_into_active, bind ACTIVE_SCENE_ID constant --- crates/store/src/lib.rs | 4 ++++ crates/store/src/scene.rs | 23 +++-------------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 305c1db..81de21e 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -163,6 +163,10 @@ impl Store { scene::reorder_layers(&self.pool, scene_id, ordered_ids).await } + pub async fn get_layer_by_id(&self, id: &str) -> Result { + scene::get_layer(&self.pool, id).await + } + pub async fn get_active_layers(&self) -> Result, StoreError> { scene::get_layers(&self.pool, ACTIVE_SCENE_ID).await } diff --git a/crates/store/src/scene.rs b/crates/store/src/scene.rs index d199f17..da7191a 100644 --- a/crates/store/src/scene.rs +++ b/crates/store/src/scene.rs @@ -249,26 +249,9 @@ pub async fn clear_layers(pool: &SqlitePool, scene_id: &str) -> Result<(), Store } pub async fn load_into_active(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { - let layers = get_layers(pool, scene_id).await?; + get_one(pool, scene_id).await?; clear_layers(pool, ACTIVE_SCENE_ID).await?; - for layer in layers { - let params_json = serde_json::to_string(&layer.params)?; - let id = Uuid::new_v4().to_string(); - sqlx::query( - "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) - VALUES (?, '__active__', ?, ?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&layer.effect_id) - .bind(&layer.zone_id) - .bind(blend_mode_to_str(layer.blend_mode)) - .bind(¶ms_json) - .bind(layer.enabled as i64) - .bind(layer.position as i64) - .execute(pool) - .await?; - } - Ok(()) + copy_layers(pool, scene_id, ACTIVE_SCENE_ID).await } pub async fn save_active_as(pool: &SqlitePool, name: &str) -> Result { @@ -313,7 +296,7 @@ async fn copy_layers( Ok(()) } -async fn get_layer(pool: &SqlitePool, id: &str) -> Result { +pub async fn get_layer(pool: &SqlitePool, id: &str) -> Result { let row: Option = sqlx::query_as::<_, LayerRow>( "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position FROM scene_layers WHERE id = ?", From 3b0e5a698ca52b1bdd7c394a16c26aaf533dae93 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:39:37 +0100 Subject: [PATCH 05/16] feat(api): add zones, scenes, and active-scene endpoints with unit tests --- crates/api/Cargo.toml | 4 + crates/api/src/active_scene.rs | 164 ++++++++++++++++++++++ crates/api/src/effects.rs | 224 ++++++++++++++----------------- crates/api/src/error.rs | 39 ++++++ crates/api/src/lib.rs | 5 + crates/api/src/scenes.rs | 82 +++++++++++ crates/api/src/types.rs | 45 +++++++ crates/api/src/zones.rs | 108 +++++++++++++++ crates/api/tests/active_scene.rs | 187 ++++++++++++++++++++++++++ crates/api/tests/common/mod.rs | 39 ++++++ crates/api/tests/device.rs | 51 +++++++ crates/api/tests/effects.rs | 164 ++++++++++++++++++++++ crates/api/tests/scenes.rs | 91 +++++++++++++ crates/api/tests/zones.rs | 97 +++++++++++++ 14 files changed, 1176 insertions(+), 124 deletions(-) create mode 100644 crates/api/src/active_scene.rs create mode 100644 crates/api/src/error.rs create mode 100644 crates/api/src/scenes.rs create mode 100644 crates/api/src/types.rs create mode 100644 crates/api/src/zones.rs create mode 100644 crates/api/tests/active_scene.rs create mode 100644 crates/api/tests/common/mod.rs create mode 100644 crates/api/tests/device.rs create mode 100644 crates/api/tests/effects.rs create mode 100644 crates/api/tests/scenes.rs create mode 100644 crates/api/tests/zones.rs diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 2a8673f..01495c9 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -8,8 +8,12 @@ domain = { workspace = true } application = { workspace = true } engine = { workspace = true } effects = { workspace = true } +store = { workspace = true } actix-web = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } futures-util = { workspace = true } + +[dev-dependencies] +sqlx = { workspace = true } diff --git a/crates/api/src/active_scene.rs b/crates/api/src/active_scene.rs new file mode 100644 index 0000000..efcb241 --- /dev/null +++ b/crates/api/src/active_scene.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use actix_web::{HttpResponse, Responder, delete, get, patch, post, put, web}; +use application::SceneRuntime; +use domain::{BlendMode, ParamValue}; +use serde::Deserialize; +use store::{ACTIVE_SCENE_ID, Store}; + +use crate::error::ApiError; +use crate::types::{LayerResponse, SceneResponse}; + +#[get("/scenes/active")] +pub async fn get_active_scene(store: web::Data>) -> Result { + let layers = store.get_active_layers().await?; + Ok(HttpResponse::Ok().json( + layers + .into_iter() + .map(LayerResponse::from) + .collect::>(), + )) +} + +#[delete("/scenes/active")] +pub async fn clear_active_scene( + store: web::Data>, + runtime: web::Data, +) -> Result { + store.clear_active_scene().await?; + runtime.halt(); + Ok(HttpResponse::NoContent().finish()) +} + +#[derive(Deserialize)] +struct AddLayerRequest { + effect_id: String, + zone_id: String, + #[serde(default)] + blend_mode: BlendMode, + #[serde(default)] + params: HashMap, +} + +#[post("/scenes/active/layers")] +pub async fn add_layer( + store: web::Data>, + runtime: web::Data, + body: web::Json, +) -> Result { + let layer = store + .add_layer( + ACTIVE_SCENE_ID, + &body.effect_id, + &body.zone_id, + body.blend_mode, + &body.params, + ) + .await?; + runtime.reload_active(); + Ok(HttpResponse::Created().json(LayerResponse::from(layer))) +} + +#[derive(Deserialize)] +struct PatchLayerRequest { + enabled: Option, + blend_mode: Option, + params: Option>, +} + +#[patch("/scenes/active/layers/{id}")] +pub async fn patch_layer( + store: web::Data>, + runtime: web::Data, + path: web::Path, + body: web::Json, +) -> Result { + let id = path.into_inner(); + let current = store.get_layer_by_id(&id).await?; + 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 + .update_layer(&id, enabled, blend_mode, ¶ms) + .await?; + runtime.reload_active(); + Ok(HttpResponse::Ok().json(LayerResponse::from(layer))) +} + +#[delete("/scenes/active/layers/{id}")] +pub async fn remove_layer( + store: web::Data>, + runtime: web::Data, + path: web::Path, +) -> Result { + store.remove_layer(&path.into_inner()).await?; + runtime.reload_active(); + Ok(HttpResponse::NoContent().finish()) +} + +#[derive(Deserialize)] +struct ReorderRequest { + ordered_ids: Vec, +} + +#[put("/scenes/active/layers/reorder")] +pub async fn reorder_layers( + store: web::Data>, + runtime: web::Data, + body: web::Json, +) -> Result { + store + .reorder_layers(ACTIVE_SCENE_ID, &body.ordered_ids) + .await?; + runtime.reload_active(); + Ok(HttpResponse::NoContent().finish()) +} + +#[derive(Deserialize)] +struct SaveSceneRequest { + name: String, +} + +#[post("/scenes/active/save")] +pub async fn save_active_scene( + store: web::Data>, + body: web::Json, +) -> Result { + let scene = store.save_active_as_scene(&body.name).await?; + Ok(HttpResponse::Created().json(SceneResponse::from(scene))) +} + +#[post("/scenes/active/load/{scene_id}")] +pub async fn load_scene_into_active( + store: web::Data>, + runtime: web::Data, + path: web::Path, +) -> Result { + store.load_scene_into_active(&path.into_inner()).await?; + runtime.reload_active(); + Ok(HttpResponse::NoContent().finish()) +} + +#[put("/scenes/active/overwrite/{scene_id}")] +pub async fn overwrite_scene_from_active( + store: web::Data>, + path: web::Path, +) -> Result { + store + .overwrite_scene_from_active(&path.into_inner()) + .await?; + Ok(HttpResponse::NoContent().finish()) +} + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get_active_scene) + .service(clear_active_scene) + .service(add_layer) + .service(reorder_layers) + .service(patch_layer) + .service(remove_layer) + .service(save_active_scene) + .service(load_scene_into_active) + .service(overwrite_scene_from_active); +} diff --git a/crates/api/src/effects.rs b/crates/api/src/effects.rs index e3118c3..c04a24f 100644 --- a/crates/api/src/effects.rs +++ b/crates/api/src/effects.rs @@ -1,153 +1,129 @@ -use std::collections::HashMap; use std::sync::Arc; use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use domain::ParamDef; +use effects::BuiltinEffect; use serde::{Deserialize, Serialize}; +use store::{EffectRecord, Store}; -use application::{BusProxy, SceneRuntime, SignalError, SignalValue}; -use domain::Rgb; -use effects::EffectError; -use effects::builder::{InitialState, build_composite, build_prelude}; -use effects::config::EffectsConfig; -use effects::preset::EffectPreset; -use effects::registry::EffectRegistry; -use engine::EffectQueue; +use crate::error::ApiError; #[derive(Serialize)] -struct PresetsResponse<'a> { - presets: &'a HashMap, +struct EffectResponse { + id: String, + name: String, + script: String, + params: Vec, + builtin: bool, } -#[post("/effects/presets/{name}/execute")] -pub async fn execute_preset( - effect_queue: web::Data, - registry: web::Data, - runtime: web::Data, - effects: web::Data, - path: web::Path, -) -> impl Responder { - let preset_name = path.into_inner(); - let preset = match effects.presets.get(&preset_name) { - Some(p) => p.clone(), - None => return HttpResponse::NotFound().body("unknown preset"), - }; - - let snap = runtime.snapshot(); - let overrides = runtime.signal_overrides(); - let state = InitialState { - color: snap.color, - brightness: if snap.on { snap.brightness } else { 0 }, - brightness_speed: effect_queue.brightness_speed(), - signal_colors: overrides.colors, - }; - let signal_scripts = overrides.scripts; - - let prelude = build_prelude(&effects.functions); - let (composite, bus) = - match build_composite(®istry, &preset, &state, prelude, &effects.presets) { - Ok(result) => result, - Err(e) => { - eprintln!("error: preset '{}' failed to build: {}", preset_name, e); - return match e { - EffectError::UnknownEffect(name) => HttpResponse::InternalServerError() - .body(format!("unknown effect type '{}' in preset config", name)), - EffectError::NestingTooDeep(max) => HttpResponse::InternalServerError() - .body(format!("preset nesting too deep (max {})", max)), - EffectError::ScriptCompile(ref err) => HttpResponse::InternalServerError() - .body(format!("script compile error in preset: {}", err)), - EffectError::InvalidParams { - ref effect, - ref source, - } => HttpResponse::InternalServerError() - .body(format!("invalid params for '{}': {}", effect, source)), - EffectError::LayerBuild { - ref effect, - ref source, - } => HttpResponse::InternalServerError() - .body(format!("layer '{}': {}", effect, source)), - EffectError::SignalScript { - ref signal, - ref source, - } => HttpResponse::InternalServerError() - .body(format!("signal '{}': {}", signal, source)), - }; - } - }; - - // Apply user animated overrides — these beat preset animations. - for (name, code) in &signal_scripts { - if let Err(e) = bus.set_animated(name, code) { - eprintln!("warning: signal override '{}' script error: {}", name, e); +impl From for EffectResponse { + fn from(r: EffectRecord) -> Self { + Self { + id: r.id, + name: r.name, + script: r.script, + params: r.params, + builtin: false, } } - // Apply user static overrides — these beat everything (incl. animations). - for (name, &color) in &state.signal_colors { - bus.set_color(name, color); - } - - runtime.attach_bus(Arc::clone(&bus) as Arc, preset_name); - effect_queue.enqueue(Box::new(composite)); - HttpResponse::Ok().finish() } -#[get("/effects/presets")] -pub async fn list_presets(effects: web::Data) -> impl Responder { - HttpResponse::Ok().json(PresetsResponse { - presets: &effects.presets, - }) +impl From<&BuiltinEffect> for EffectResponse { + fn from(b: &BuiltinEffect) -> Self { + Self { + id: b.id(), + name: b.name.clone(), + script: b.script.clone(), + params: b.params.clone(), + builtin: true, + } + } } -#[delete("/effects/active")] -pub async fn delete_active(runtime: web::Data) -> impl Responder { - runtime.halt(); - HttpResponse::NoContent().finish() +#[get("/effects")] +pub async fn list_effects( + store: web::Data>, + builtins: web::Data>, +) -> Result { + let user_effects = store.get_effects().await?; + let mut effects: Vec = builtins.iter().map(EffectResponse::from).collect(); + effects.extend(user_effects.into_iter().map(EffectResponse::from)); + Ok(HttpResponse::Ok().json(effects)) } -#[get("/effects/signals")] -pub async fn get_signals(runtime: web::Data) -> impl Responder { - HttpResponse::Ok().json(runtime.bus_colors()) +#[derive(Deserialize)] +struct EffectBody { + name: String, + script: String, + #[serde(default)] + params: Vec, } -#[derive(Deserialize)] -#[serde(untagged)] -enum SetSignalRequest { - Color { r: u8, g: u8, b: u8 }, - Script { code: String }, +#[post("/effects")] +pub async fn create_effect( + store: web::Data>, + body: web::Json, +) -> Result { + let record = store + .create_effect(&body.name, &body.script, &body.params) + .await?; + Ok(HttpResponse::Created().json(EffectResponse::from(record))) } -#[put("/effects/signals/{name}")] -pub async fn set_signal( - runtime: web::Data, +#[get("/effects/{id}")] +pub async fn get_effect( + store: web::Data>, + builtins: web::Data>, path: web::Path, - body: web::Bytes, -) -> impl Responder { - let req: SetSignalRequest = match serde_json::from_slice(&body) { - Ok(r) => r, - Err(e) => return HttpResponse::BadRequest().body(e.to_string()), - }; - let signal_name = path.into_inner(); +) -> Result { + let id = path.into_inner(); + if let Some(b) = find_builtin(&builtins, &id) { + return Ok(HttpResponse::Ok().json(EffectResponse::from(b))); + } + let record = store.get_effect(&id).await?; + Ok(HttpResponse::Ok().json(EffectResponse::from(record))) +} - let result = match req { - SetSignalRequest::Color { r, g, b } => { - runtime.set_signal(&signal_name, SignalValue::Color(Rgb { r, g, b })) - } - SetSignalRequest::Script { code } => { - runtime.set_signal(&signal_name, SignalValue::Script(code)) - } - }; +#[put("/effects/{id}")] +pub async fn update_effect( + store: web::Data>, + builtins: web::Data>, + path: web::Path, + body: web::Json, +) -> Result { + let id = path.into_inner(); + if find_builtin(&builtins, &id).is_some() { + return Err(ApiError::Forbidden("cannot modify a built-in effect")); + } + let record = store + .update_effect(&id, &body.name, &body.script, &body.params) + .await?; + Ok(HttpResponse::Ok().json(EffectResponse::from(record))) +} - match result { - Ok(()) => HttpResponse::Ok().finish(), - Err(SignalError::ScriptCompile(msg)) => { - HttpResponse::BadRequest().body(format!("script compile error: {}", msg)) - } +#[delete("/effects/{id}")] +pub async fn delete_effect( + store: web::Data>, + builtins: web::Data>, + path: web::Path, +) -> Result { + let id = path.into_inner(); + if find_builtin(&builtins, &id).is_some() { + return Err(ApiError::Forbidden("cannot delete a built-in effect")); } + store.delete_effect(&id).await?; + Ok(HttpResponse::NoContent().finish()) +} + +fn find_builtin<'a>(builtins: &'a [BuiltinEffect], id: &str) -> Option<&'a BuiltinEffect> { + builtins.iter().find(|b| b.id() == id) } pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(execute_preset) - .service(list_presets) - .service(delete_active) - .service(get_signals) - .service(set_signal); + cfg.service(list_effects) + .service(create_effect) + .service(get_effect) + .service(update_effect) + .service(delete_effect); } diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs new file mode 100644 index 0000000..26ab31e --- /dev/null +++ b/crates/api/src/error.rs @@ -0,0 +1,39 @@ +use actix_web::HttpResponse; +use actix_web::ResponseError; +use store::StoreError; + +#[derive(Debug)] +pub enum ApiError { + NotFound, + Forbidden(&'static str), + Internal(String), +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "not found"), + Self::Forbidden(msg) => write!(f, "{msg}"), + Self::Internal(msg) => write!(f, "{msg}"), + } + } +} + +impl ResponseError for ApiError { + fn error_response(&self) -> HttpResponse { + match self { + Self::NotFound => HttpResponse::NotFound().finish(), + Self::Forbidden(msg) => HttpResponse::Forbidden().body(*msg), + Self::Internal(msg) => HttpResponse::InternalServerError().body(msg.clone()), + } + } +} + +impl From for ApiError { + fn from(e: StoreError) -> Self { + match e { + StoreError::NotFound => Self::NotFound, + e => Self::Internal(e.to_string()), + } + } +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index b035ae5..07c84f6 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,3 +1,8 @@ +pub mod active_scene; pub mod device; pub mod effects; +pub mod error; pub mod events; +pub mod scenes; +pub mod types; +pub mod zones; diff --git a/crates/api/src/scenes.rs b/crates/api/src/scenes.rs new file mode 100644 index 0000000..0ba6af0 --- /dev/null +++ b/crates/api/src/scenes.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use serde::{Deserialize, Serialize}; +use store::Store; + +use crate::error::ApiError; +use crate::types::{LayerResponse, SceneResponse}; + +#[derive(Serialize)] +struct SceneDetailResponse { + id: String, + name: String, + layers: Vec, +} + +#[get("/scenes")] +pub async fn list_scenes(store: web::Data>) -> Result { + let scenes = store.get_scenes().await?; + Ok(HttpResponse::Ok().json( + scenes + .into_iter() + .map(SceneResponse::from) + .collect::>(), + )) +} + +#[derive(Deserialize)] +struct SceneBody { + name: String, +} + +#[post("/scenes")] +pub async fn create_scene( + store: web::Data>, + body: web::Json, +) -> Result { + let scene = store.create_scene(&body.name).await?; + Ok(HttpResponse::Created().json(SceneResponse::from(scene))) +} + +#[get("/scenes/{id}")] +pub async fn get_scene( + store: web::Data>, + path: web::Path, +) -> Result { + let id = path.into_inner(); + let scene = store.get_scene(&id).await?; + let layers = store.get_layers(&id).await?; + Ok(HttpResponse::Ok().json(SceneDetailResponse { + id: scene.id, + name: scene.name, + layers: layers.into_iter().map(LayerResponse::from).collect(), + })) +} + +#[put("/scenes/{id}")] +pub async fn update_scene( + store: web::Data>, + path: web::Path, + body: web::Json, +) -> Result { + let scene = store.update_scene(&path.into_inner(), &body.name).await?; + Ok(HttpResponse::Ok().json(SceneResponse::from(scene))) +} + +#[delete("/scenes/{id}")] +pub async fn delete_scene( + store: web::Data>, + path: web::Path, +) -> Result { + store.delete_scene(&path.into_inner()).await?; + Ok(HttpResponse::NoContent().finish()) +} + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(list_scenes) + .service(create_scene) + .service(get_scene) + .service(update_scene) + .service(delete_scene); +} diff --git a/crates/api/src/types.rs b/crates/api/src/types.rs new file mode 100644 index 0000000..812741b --- /dev/null +++ b/crates/api/src/types.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use domain::{BlendMode, ParamValue}; +use serde::Serialize; +use store::{LayerRecord, SceneRecord}; + +#[derive(Serialize)] +pub struct LayerResponse { + pub id: String, + pub effect_id: String, + pub zone_id: String, + pub blend_mode: BlendMode, + pub params: HashMap, + pub enabled: bool, + pub position: u32, +} + +impl From for LayerResponse { + fn from(r: LayerRecord) -> Self { + Self { + id: r.id, + effect_id: r.effect_id, + zone_id: r.zone_id, + blend_mode: r.blend_mode, + params: r.params, + enabled: r.enabled, + position: r.position, + } + } +} + +#[derive(Serialize)] +pub struct SceneResponse { + pub id: String, + pub name: String, +} + +impl From for SceneResponse { + fn from(r: SceneRecord) -> Self { + Self { + id: r.id, + name: r.name, + } + } +} diff --git a/crates/api/src/zones.rs b/crates/api/src/zones.rs new file mode 100644 index 0000000..fc8aded --- /dev/null +++ b/crates/api/src/zones.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use serde::{Deserialize, Serialize}; +use store::{Store, ZoneRecord}; + +use crate::error::ApiError; + +#[derive(Serialize)] +struct ZoneResponse { + id: String, + name: String, + start_pixel: u32, + end_pixel: u32, + transition_length: u32, +} + +impl From for ZoneResponse { + fn from(r: ZoneRecord) -> Self { + Self { + id: r.id, + name: r.name, + start_pixel: r.start_pixel, + end_pixel: r.end_pixel, + transition_length: r.transition_length, + } + } +} + +#[get("/zones")] +pub async fn list_zones(store: web::Data>) -> Result { + let zones = store.get_zones().await?; + Ok(HttpResponse::Ok().json( + zones + .into_iter() + .map(ZoneResponse::from) + .collect::>(), + )) +} + +#[derive(Deserialize)] +struct ZoneBody { + name: String, + start_pixel: u32, + end_pixel: u32, + #[serde(default)] + transition_length: u32, +} + +#[post("/zones")] +pub async fn create_zone( + store: web::Data>, + body: web::Json, +) -> Result { + let zone = store + .create_zone( + &body.name, + body.start_pixel, + body.end_pixel, + body.transition_length, + ) + .await?; + Ok(HttpResponse::Created().json(ZoneResponse::from(zone))) +} + +#[get("/zones/{id}")] +pub async fn get_zone( + store: web::Data>, + path: web::Path, +) -> Result { + let zone = store.get_zone(&path.into_inner()).await?; + Ok(HttpResponse::Ok().json(ZoneResponse::from(zone))) +} + +#[put("/zones/{id}")] +pub async fn update_zone( + store: web::Data>, + path: web::Path, + body: web::Json, +) -> Result { + let zone = store + .update_zone( + &path.into_inner(), + &body.name, + body.start_pixel, + body.end_pixel, + body.transition_length, + ) + .await?; + Ok(HttpResponse::Ok().json(ZoneResponse::from(zone))) +} + +#[delete("/zones/{id}")] +pub async fn delete_zone( + store: web::Data>, + path: web::Path, +) -> Result { + store.delete_zone(&path.into_inner()).await?; + Ok(HttpResponse::NoContent().finish()) +} + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(list_zones) + .service(create_zone) + .service(get_zone) + .service(update_zone) + .service(delete_zone); +} diff --git a/crates/api/tests/active_scene.rs b/crates/api/tests/active_scene.rs new file mode 100644 index 0000000..1d7f43d --- /dev/null +++ b/crates/api/tests/active_scene.rs @@ -0,0 +1,187 @@ +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::{make_store, mock_runtime}; +use serde_json::{Value, json}; + +// active_scene routes must come BEFORE scenes routes: /scenes/active must match +// before the wildcard /scenes/{id}, because actix-web uses FIFO route matching. +macro_rules! svc { + ($store:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new($store)) + .app_data(web::Data::from(mock_runtime() as Arc)) + .configure(api::active_scene::config) + .configure(api::scenes::config), + ) + .await + }}; +} + +#[actix_web::test] +async fn starts_empty() { + let svc = svc!(make_store().await); + + let resp = + test::call_service(&svc, TestRequest::get().uri("/scenes/active").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 layer_lifecycle() { + let svc = svc!(make_store().await); + + // add + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json(json!({ + "effect_id": "builtin:rainbow", + "zone_id": "all", + "blend_mode": "override" + })) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["effect_id"], "builtin:rainbow"); + assert_eq!(body["enabled"], true); + let layer_id = body["id"].as_str().unwrap().to_string(); + + // patch + let resp = test::call_service( + &svc, + TestRequest::patch() + .uri(&format!("/scenes/active/layers/{layer_id}")) + .set_json(json!({"enabled": false})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["enabled"], false); + + // still 1 layer in GET /scenes/active + 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); + + // remove + let resp = test::call_service( + &svc, + TestRequest::delete() + .uri(&format!("/scenes/active/layers/{layer_id}")) + .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!(body.is_empty()); +} + +#[actix_web::test] +async fn clear() { + let svc = svc!(make_store().await); + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json( + json!({"effect_id": "builtin:aurora", "zone_id": "all", "blend_mode": "override"}), + ) + .to_request(), + ) + .await; + + let resp = test::call_service( + &svc, + TestRequest::delete().uri("/scenes/active").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!(body.is_empty()); +} + +#[actix_web::test] +async fn save_and_load() { + let svc = svc!(make_store().await); + + test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/layers") + .set_json( + json!({"effect_id": "builtin:rainbow", "zone_id": "all", "blend_mode": "override"}), + ) + .to_request(), + ) + .await; + + // save + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/save") + .set_json(json!({"name": "Saved Rainbow"})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let body: Value = test::read_body_json(resp).await; + let scene_id = body["id"].as_str().unwrap().to_string(); + + // clear, then load + test::call_service( + &svc, + TestRequest::delete().uri("/scenes/active").to_request(), + ) + .await; + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri(&format!("/scenes/active/load/{scene_id}")) + .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 load_nonexistent_returns_404() { + let svc = svc!(make_store().await); + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes/active/load/no-such-scene") + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/api/tests/common/mod.rs b/crates/api/tests/common/mod.rs new file mode 100644 index 0000000..68595e9 --- /dev/null +++ b/crates/api/tests/common/mod.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use application::{SceneRuntime, SceneSnapshot}; +use domain::Rgb; +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 halt(&self) {} + fn stop_effect(&self) {} + fn reload_active(&self) {} + fn snapshot(&self) -> SceneSnapshot { + SceneSnapshot { + on: true, + brightness: 255, + color: Rgb::BLACK, + active_effect: None, + light_effect_end_unix_timestamp_sec: None, + } + } +} + +pub async fn make_store() -> Arc { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + Arc::new(Store::from_pool(pool).await.unwrap()) +} + +pub fn mock_runtime() -> Arc { + Arc::new(MockRuntime) +} diff --git a/crates/api/tests/device.rs b/crates/api/tests/device.rs new file mode 100644 index 0000000..fea4313 --- /dev/null +++ b/crates/api/tests/device.rs @@ -0,0 +1,51 @@ +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::{make_store, mock_runtime}; +use serde_json::{Value, json}; + +macro_rules! svc { + ($store:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new($store)) + .app_data(web::Data::from(mock_runtime() as Arc)) + .configure(api::device::config), + ) + .await + }}; +} + +#[actix_web::test] +async fn get_state() { + let svc = svc!(make_store().await); + + let resp = test::call_service(&svc, TestRequest::get().uri("/device/state").to_request()).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body["on"].is_boolean()); + assert!(body["brightness"].is_number()); + assert!(body["color"].is_object()); +} + +#[actix_web::test] +async fn patch_state() { + let svc = svc!(make_store().await); + + let resp = test::call_service( + &svc, + TestRequest::patch() + .uri("/device/state") + .set_json(json!({"on": false, "brightness": 100})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert!(body["on"].is_boolean()); +} diff --git a/crates/api/tests/effects.rs b/crates/api/tests/effects.rs new file mode 100644 index 0000000..3cc28de --- /dev/null +++ b/crates/api/tests/effects.rs @@ -0,0 +1,164 @@ +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::{make_store, mock_runtime}; +use effects::load_builtins; +use serde_json::{Value, json}; + +macro_rules! svc { + ($store:expr, $rt:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new($store)) + .app_data(web::Data::from($rt as Arc)) + .app_data(web::Data::new(load_builtins())) + .configure(api::effects::config), + ) + .await + }}; +} + +#[actix_web::test] +async fn list_includes_builtins() { + let svc = svc!(make_store().await, mock_runtime()); + + let resp = test::call_service(&svc, TestRequest::get().uri("/effects").to_request()).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = test::read_body_json(resp).await; + assert!(body.iter().any(|e| e["builtin"] == true)); +} + +#[actix_web::test] +async fn create_and_get() { + let svc = svc!(make_store().await, mock_runtime()); + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/effects") + .set_json(json!({"name": "My Effect", "script": "rgb(255,0,0)", "params": []})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["name"], "My Effect"); + assert_eq!(body["builtin"], false); + let id = body["id"].as_str().unwrap().to_string(); + + let resp = test::call_service( + &svc, + TestRequest::get() + .uri(&format!("/effects/{id}")) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["id"], id); +} + +#[actix_web::test] +async fn update_user_effect() { + let svc = svc!(make_store().await, mock_runtime()); + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/effects") + .set_json(json!({"name": "Old", "script": "rgb(0,0,0)"})) + .to_request(), + ) + .await; + let body: Value = test::read_body_json(resp).await; + let id = body["id"].as_str().unwrap().to_string(); + + let resp = test::call_service( + &svc, + TestRequest::put() + .uri(&format!("/effects/{id}")) + .set_json(json!({"name": "New", "script": "rgb(255,255,255)"})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["name"], "New"); +} + +#[actix_web::test] +async fn delete_user_effect() { + let svc = svc!(make_store().await, mock_runtime()); + + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/effects") + .set_json(json!({"name": "Temp", "script": "rgb(0,0,0)"})) + .to_request(), + ) + .await; + let body: Value = test::read_body_json(resp).await; + let id = body["id"].as_str().unwrap().to_string(); + + let del = test::call_service( + &svc, + TestRequest::delete() + .uri(&format!("/effects/{id}")) + .to_request(), + ) + .await; + assert_eq!(del.status(), StatusCode::NO_CONTENT); + + let get = test::call_service( + &svc, + TestRequest::get() + .uri(&format!("/effects/{id}")) + .to_request(), + ) + .await; + assert_eq!(get.status(), StatusCode::NOT_FOUND); +} + +#[actix_web::test] +async fn builtin_is_immutable() { + let svc = svc!(make_store().await, mock_runtime()); + + let put = test::call_service( + &svc, + TestRequest::put() + .uri("/effects/builtin:rainbow") + .set_json(json!({"name": "Hacked", "script": "rgb(0,0,0)"})) + .to_request(), + ) + .await; + assert_eq!(put.status(), StatusCode::FORBIDDEN); + + let del = test::call_service( + &svc, + TestRequest::delete() + .uri("/effects/builtin:rainbow") + .to_request(), + ) + .await; + assert_eq!(del.status(), StatusCode::FORBIDDEN); +} + +#[actix_web::test] +async fn get_nonexistent_returns_404() { + let svc = svc!(make_store().await, mock_runtime()); + + let resp = test::call_service( + &svc, + TestRequest::get() + .uri("/effects/does-not-exist") + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/api/tests/scenes.rs b/crates/api/tests/scenes.rs new file mode 100644 index 0000000..d1517d4 --- /dev/null +++ b/crates/api/tests/scenes.rs @@ -0,0 +1,91 @@ +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::{make_store, mock_runtime}; +use serde_json::{Value, json}; + +macro_rules! svc { + ($store:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new($store)) + .app_data(web::Data::from(mock_runtime() as Arc)) + .configure(api::scenes::config), + ) + .await + }}; +} + +#[actix_web::test] +async fn crud() { + let svc = svc!(make_store().await); + + // create + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/scenes") + .set_json(json!({"name": "Night"})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["name"], "Night"); + let id = body["id"].as_str().unwrap().to_string(); + + // get with empty layers + let resp = test::call_service( + &svc, + TestRequest::get() + .uri(&format!("/scenes/{id}")) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["layers"].as_array().unwrap().len(), 0); + + // list + let resp = test::call_service(&svc, TestRequest::get().uri("/scenes").to_request()).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body.len(), 1); + + // rename + let resp = test::call_service( + &svc, + TestRequest::put() + .uri(&format!("/scenes/{id}")) + .set_json(json!({"name": "Day"})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["name"], "Day"); + + // delete + let resp = test::call_service( + &svc, + TestRequest::delete() + .uri(&format!("/scenes/{id}")) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = test::call_service( + &svc, + TestRequest::get() + .uri(&format!("/scenes/{id}")) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/api/tests/zones.rs b/crates/api/tests/zones.rs new file mode 100644 index 0000000..b755ce0 --- /dev/null +++ b/crates/api/tests/zones.rs @@ -0,0 +1,97 @@ +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::{make_store, mock_runtime}; +use serde_json::{Value, json}; + +macro_rules! svc { + ($store:expr) => {{ + test::init_service( + App::new() + .app_data(web::Data::new($store)) + .app_data(web::Data::from(mock_runtime() as Arc)) + .configure(api::zones::config), + ) + .await + }}; +} + +#[actix_web::test] +async fn crud() { + let svc = svc!(make_store().await); + + // create + let resp = test::call_service( + &svc, + TestRequest::post() + .uri("/zones") + .set_json(json!({"name": "Full", "start_pixel": 0, "end_pixel": 100})) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["name"], "Full"); + assert_eq!(body["transition_length"], 0); + let id = body["id"].as_str().unwrap().to_string(); + + // get + let resp = test::call_service( + &svc, + TestRequest::get().uri(&format!("/zones/{id}")).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // update + let resp = test::call_service( + &svc, + TestRequest::put() + .uri(&format!("/zones/{id}")) + .set_json(json!({ + "name": "Renamed", + "start_pixel": 0, + "end_pixel": 50, + "transition_length": 8 + })) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["name"], "Renamed"); + assert_eq!(body["transition_length"], 8); + + // list + let resp = test::call_service(&svc, TestRequest::get().uri("/zones").to_request()).await; + assert_eq!(resp.status(), StatusCode::OK); + let body: Vec = test::read_body_json(resp).await; + assert_eq!(body.len(), 1); + + // delete + let resp = test::call_service( + &svc, + TestRequest::delete() + .uri(&format!("/zones/{id}")) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +} + +#[actix_web::test] +async fn get_nonexistent_returns_404() { + let svc = svc!(make_store().await); + + let resp = test::call_service( + &svc, + TestRequest::get().uri("/zones/no-such-zone").to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} From c4ffeb924c547f2b6e86f702aa78fe4cf34c10e1 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:39:51 +0100 Subject: [PATCH 06/16] refactor(server): split RenderTaskRuntime into focused modules --- crates/server/Cargo.toml | 1 + crates/server/src/main.rs | 38 ++- crates/server/src/scene_runtime.rs | 318 ---------------------- crates/server/src/scene_runtime/mod.rs | 190 +++++++++++++ crates/server/src/scene_runtime/reload.rs | 178 ++++++++++++ crates/server/src/scene_runtime/state.rs | 55 ++++ 6 files changed, 452 insertions(+), 328 deletions(-) delete mode 100644 crates/server/src/scene_runtime.rs create mode 100644 crates/server/src/scene_runtime/mod.rs create mode 100644 crates/server/src/scene_runtime/reload.rs create mode 100644 crates/server/src/scene_runtime/state.rs diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index c506c01..8375189 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -27,5 +27,6 @@ config = { workspace = true } serde = { workspace = true } discovery = { workspace = true } persistence = { workspace = true } +store = { workspace = true } tokio = { workspace = true } serde_json = { workspace = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index a95d0ae..dc75658 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -5,9 +5,10 @@ use std::sync::Arc; use actix_web::{App, HttpServer, web}; use application::{SceneRuntime, StateEventBus}; -use effects::config::EffectsConfig; +use effects::load_builtins; use engine::EffectQueue; use settings::Settings; +use store::Store; use scene_runtime::RenderTaskRuntime; @@ -28,9 +29,10 @@ 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 effect_registry = effects::build_registry(); let state_file = app_settings.state_dir.join("state.json"); let initial_state = persistence::load(&state_file).unwrap_or_else(|e| { @@ -38,6 +40,18 @@ async fn main() -> std::io::Result<()> { persistence::PersistedState::default() }); + let db_path = app_settings.state_dir.join("lumehub.db"); + let store = Arc::new(Store::open(&db_path).await.unwrap_or_else(|e| { + eprintln!("error: failed to open database: {}", e); + std::process::exit(1); + })); + + let builtins = load_builtins(); + for e in effects::validate_builtin_scripts() { + eprintln!("warning: {}", e); + } + let builtins_data = web::Data::new(builtins.clone()); + let (state_tx, mut state_rx) = tokio::sync::watch::channel(initial_state.clone()); tokio::spawn(async move { loop { @@ -59,8 +73,14 @@ async fn main() -> std::io::Result<()> { event_bus.clone(), initial_state, Some(state_tx), + Arc::clone(&store), + builtins, + strip_len, )); + // Restore active scene from __active__ on startup + runtime.reload_active(); + #[cfg(feature = "google")] let command_dispatcher = api_google::commands::CommandDispatcher::new(); @@ -82,22 +102,20 @@ async fn main() -> std::io::Result<()> { .ok() }); - let effects_config = EffectsConfig::from_dir(&app_settings.config_dir); - let prelude = effects::builder::build_prelude(&effects_config.functions); - for e in effects::validate_scripts(&effects_config, &prelude) { - eprintln!("warning: script error in {}", e); - } - HttpServer::new(move || { let app = App::new() .app_data(web::Data::new(effect_queue.clone())) - .app_data(web::Data::new(effect_registry.clone())) - .app_data(web::Data::new(effects_config.clone())) + .app_data(web::Data::new(store.clone())) + .app_data(builtins_data.clone()) .app_data(web::Data::from(Arc::clone(&runtime))) .app_data(web::Data::new(event_bus.clone())) .configure(api::device::config) .configure(api::effects::config) .configure(api::events::config) + .configure(api::zones::config) + // active_scene must precede scenes: /scenes/active must match before /scenes/{id} + .configure(api::active_scene::config) + .configure(api::scenes::config) .configure(api_legacy::config); #[cfg(feature = "google")] diff --git a/crates/server/src/scene_runtime.rs b/crates/server/src/scene_runtime.rs deleted file mode 100644 index d7e8db1..0000000 --- a/crates/server/src/scene_runtime.rs +++ /dev/null @@ -1,318 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -#[cfg(feature = "google")] -use chrono::Utc; -use engine::{EffectQueue, RenderCommand}; - -use effects::bus::PRIMARY_COLOR; - -#[cfg(feature = "google")] -use api_google::effects::{ColorLoop, Sleep, Wake}; -use application::{ - BusProxy, SceneRuntime, SceneSnapshot, SignalError, SignalOverrides, SignalValue, StateEventBus, -}; -use domain::Rgb; -use persistence::PersistedState; - -enum ActiveScene { - Idle, - Composite { - name: String, - bus: Arc, - }, - Timed { - name: String, - end_unix_timestamp_sec: u64, - }, -} - -struct SceneState { - on: bool, - brightness: u8, - color: Rgb, - scene: ActiveScene, - signal_colors: HashMap, - signal_scripts: HashMap, -} - -pub struct RenderTaskRuntime { - queue: EffectQueue, - state: Arc>, - bus: StateEventBus, - state_tx: Option>, -} - -impl RenderTaskRuntime { - pub fn new( - queue: EffectQueue, - bus: StateEventBus, - initial: PersistedState, - state_tx: Option>, - ) -> Self { - let state = SceneState { - on: initial.on, - brightness: initial.brightness, - color: initial.color, - scene: ActiveScene::Idle, - signal_colors: initial.signal_colors, - signal_scripts: initial.signal_scripts, - }; - Self { - queue, - state: Arc::new(Mutex::new(state)), - bus, - state_tx, - } - } - - fn push_state(&self, persisted: PersistedState) { - if let Some(tx) = &self.state_tx { - let _ = tx.send(persisted); - } - } -} - -fn extract_persisted(state: &SceneState) -> PersistedState { - let active_effect = match &state.scene { - ActiveScene::Idle => None, - ActiveScene::Composite { name, .. } | ActiveScene::Timed { name, .. } => Some(name.clone()), - }; - PersistedState { - on: state.on, - brightness: state.brightness, - color: state.color, - active_effect, - signal_colors: state.signal_colors.clone(), - signal_scripts: state.signal_scripts.clone(), - } -} - -fn build_snapshot(state: &SceneState) -> SceneSnapshot { - let (active_effect, light_effect_end_unix_timestamp_sec) = match &state.scene { - ActiveScene::Idle => (None, None), - ActiveScene::Composite { name, .. } => (Some(name.clone()), None), - ActiveScene::Timed { - name, - end_unix_timestamp_sec, - } => (Some(name.clone()), Some(*end_unix_timestamp_sec)), - }; - SceneSnapshot { - on: state.on, - brightness: state.brightness, - color: state.color, - active_effect, - light_effect_end_unix_timestamp_sec, - } -} - -impl SceneRuntime for RenderTaskRuntime { - fn set_color(&self, color: Rgb) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.color = color; - state.on = true; - if let ActiveScene::Composite { bus, .. } = &state.scene { - bus.set_color(PRIMARY_COLOR, color); - } else { - state.scene = ActiveScene::Idle; - state.signal_scripts.remove(PRIMARY_COLOR); - state.signal_colors.remove(PRIMARY_COLOR); - self.queue.send(RenderCommand::SetColor(color)); - } - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - fn set_brightness(&self, brightness: u8) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.brightness = brightness; - if let ActiveScene::Composite { bus, .. } = &state.scene { - bus.set_brightness(brightness as f32); - } else { - state.scene = ActiveScene::Idle; - self.queue.send(RenderCommand::SetBrightness(brightness)); - } - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - fn set_on_off(&self, on: bool) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.on = on; - if let ActiveScene::Composite { bus, .. } = &state.scene { - bus.set_brightness(if on { state.brightness as f32 } else { 0.0 }); - } else { - state.scene = ActiveScene::Idle; - self.queue.send(RenderCommand::SetOnOff(on)); - } - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - fn halt(&self) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.scene = ActiveScene::Idle; - self.queue.send(RenderCommand::Halt); - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - fn stop_effect(&self) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.scene = ActiveScene::Idle; - let on = state.on; - self.queue.send(RenderCommand::SetOnOff(on)); - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - #[cfg(feature = "google")] - fn start_color_loop(&self, duration: u64) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.on = true; - state.scene = ActiveScene::Timed { - name: "colorLoop".to_string(), - end_unix_timestamp_sec: Utc::now().timestamp() as u64 + duration, - }; - let start_color = state.color; - self.queue.enqueue(Box::new(ColorLoop { - duration, - start_color, - colors: vec![ - Rgb::new(255, 0, 0), - Rgb::new(255, 127, 0), - Rgb::new(255, 255, 0), - Rgb::new(0, 255, 0), - Rgb::new(0, 0, 255), - Rgb::new(75, 0, 130), - Rgb::new(148, 0, 211), - ], - })); - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - #[cfg(feature = "google")] - fn start_sleep(&self, duration: u64) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.on = true; - state.scene = ActiveScene::Timed { - name: "sleep".to_string(), - end_unix_timestamp_sec: Utc::now().timestamp() as u64 + duration, - }; - let start_brightness = state.brightness; - self.queue.enqueue(Box::new(Sleep { - duration, - start_brightness, - target_color: Rgb::BLACK, - })); - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - #[cfg(feature = "google")] - fn start_wake(&self, duration: u64) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.on = true; - state.scene = ActiveScene::Timed { - name: "wake".to_string(), - end_unix_timestamp_sec: Utc::now().timestamp() as u64 + duration, - }; - let end_brightness = state.brightness; - let start_color = state.color; - self.queue.enqueue(Box::new(Wake { - duration, - end_brightness, - start_color, - })); - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - fn set_signal(&self, name: &str, value: SignalValue) -> Result<(), SignalError> { - let persisted = { - let mut state = self.state.lock().unwrap(); - match value { - SignalValue::Color(color) => { - state.signal_scripts.remove(name); - if name == PRIMARY_COLOR { - state.color = color; - } else { - state.signal_colors.insert(name.to_string(), color); - } - if let ActiveScene::Composite { bus, .. } = &state.scene { - bus.set_color(name, color); - } - } - SignalValue::Script(code) => { - if let ActiveScene::Composite { bus, .. } = &state.scene { - bus.set_animated(name, &code)?; - } - state.signal_scripts.insert(name.to_string(), code); - } - } - extract_persisted(&state) - }; - self.push_state(persisted); - Ok(()) - } - - fn attach_bus(&self, bus: Arc, effect_name: String) { - let (snapshot, persisted) = { - let mut state = self.state.lock().unwrap(); - state.scene = ActiveScene::Composite { - name: effect_name, - bus, - }; - (build_snapshot(&state), extract_persisted(&state)) - }; - self.bus.notify(snapshot); - self.push_state(persisted); - } - - fn snapshot(&self) -> SceneSnapshot { - let state = self.state.lock().unwrap(); - build_snapshot(&state) - } - - fn bus_colors(&self) -> HashMap { - let state = self.state.lock().unwrap(); - if let ActiveScene::Composite { bus, .. } = &state.scene { - bus.all_colors() - } else { - HashMap::new() - } - } - - fn signal_overrides(&self) -> SignalOverrides { - let state = self.state.lock().unwrap(); - SignalOverrides { - colors: state.signal_colors.clone(), - scripts: state.signal_scripts.clone(), - } - } -} diff --git a/crates/server/src/scene_runtime/mod.rs b/crates/server/src/scene_runtime/mod.rs new file mode 100644 index 0000000..c46e6f0 --- /dev/null +++ b/crates/server/src/scene_runtime/mod.rs @@ -0,0 +1,190 @@ +mod reload; +mod state; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "google")] +use chrono::Utc; +use engine::{EffectQueue, RenderCommand}; + +#[cfg(feature = "google")] +use api_google::effects::{ColorLoop, Sleep, Wake}; +use application::{SceneRuntime, SceneSnapshot, StateEventBus}; +use domain::Rgb; +use effects::BuiltinEffect; +use persistence::PersistedState; +use store::Store; + +use reload::SceneReload; +use state::{ActiveScene, SceneState, build_snapshot, extract_persisted}; + +pub struct RenderTaskRuntime { + pub(crate) queue: EffectQueue, + pub(crate) state: Arc>, + pub(crate) bus: StateEventBus, + pub(crate) state_tx: Option>, + pub(crate) store: Arc, + pub(crate) builtins: Arc>, + pub(crate) strip_len: usize, +} + +impl RenderTaskRuntime { + pub fn new( + queue: EffectQueue, + bus: StateEventBus, + initial: PersistedState, + state_tx: Option>, + store: Arc, + builtins: Vec, + strip_len: usize, + ) -> Self { + Self { + queue, + state: Arc::new(Mutex::new(SceneState { + on: initial.on, + brightness: initial.brightness, + color: initial.color, + scene: ActiveScene::Idle, + })), + bus, + state_tx, + store, + builtins: Arc::new(builtins.into_iter().map(|b| (b.id(), b)).collect()), + strip_len, + } + } + + fn emit(&self, snapshot: SceneSnapshot, persisted: PersistedState) { + self.bus.notify(snapshot); + if let Some(tx) = &self.state_tx { + let _ = tx.send(persisted); + } + } + + fn with_state(&self, f: F) { + let (snapshot, persisted) = { + let mut s = self.state.lock().unwrap(); + f(&mut s, &self.queue); + (build_snapshot(&s), extract_persisted(&s)) + }; + self.emit(snapshot, persisted); + } +} + +impl SceneRuntime for RenderTaskRuntime { + fn set_color(&self, color: Rgb) { + self.with_state(|state, queue| { + state.color = color; + state.on = true; + if matches!(state.scene, ActiveScene::Idle) { + queue.send(RenderCommand::SetColor(color)); + } + }); + } + + fn set_brightness(&self, brightness: u8) { + 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)); + } + }); + } + + fn set_on_off(&self, on: bool) { + 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)); + } + }); + } + + fn halt(&self) { + self.with_state(|state, queue| { + state.scene = ActiveScene::Idle; + queue.send(RenderCommand::Halt); + }); + } + + fn stop_effect(&self) { + self.with_state(|state, queue| { + let on = state.on; + state.scene = ActiveScene::Idle; + queue.send(RenderCommand::SetOnOff(on)); + }); + } + + #[cfg(feature = "google")] + fn start_color_loop(&self, duration: u64) { + self.with_state(|state, queue| { + state.on = true; + let start_color = state.color; + state.scene = ActiveScene::Timed { + name: "colorLoop".to_string(), + end_unix_timestamp_sec: Utc::now().timestamp() as u64 + duration, + }; + queue.enqueue(Box::new(ColorLoop { + duration, + start_color, + colors: vec![ + Rgb::new(255, 0, 0), + Rgb::new(255, 127, 0), + Rgb::new(255, 255, 0), + Rgb::new(0, 255, 0), + Rgb::new(0, 0, 255), + Rgb::new(75, 0, 130), + Rgb::new(148, 0, 211), + ], + })); + }); + } + + #[cfg(feature = "google")] + fn start_sleep(&self, duration: u64) { + self.with_state(|state, queue| { + state.on = true; + let start_brightness = state.brightness; + state.scene = ActiveScene::Timed { + name: "sleep".to_string(), + end_unix_timestamp_sec: Utc::now().timestamp() as u64 + duration, + }; + queue.enqueue(Box::new(Sleep { + duration, + start_brightness, + target_color: Rgb::BLACK, + })); + }); + } + + #[cfg(feature = "google")] + fn start_wake(&self, duration: u64) { + self.with_state(|state, queue| { + state.on = true; + let end_brightness = state.brightness; + let start_color = state.color; + state.scene = ActiveScene::Timed { + name: "wake".to_string(), + end_unix_timestamp_sec: Utc::now().timestamp() as u64 + duration, + }; + queue.enqueue(Box::new(Wake { + duration, + end_brightness, + start_color, + })); + }); + } + + fn reload_active(&self) { + SceneReload::from_runtime(self).spawn(); + } + + fn snapshot(&self) -> SceneSnapshot { + build_snapshot(&self.state.lock().unwrap()) + } +} diff --git a/crates/server/src/scene_runtime/reload.rs b/crates/server/src/scene_runtime/reload.rs new file mode 100644 index 0000000..d88db39 --- /dev/null +++ b/crates/server/src/scene_runtime/reload.rs @@ -0,0 +1,178 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use application::StateEventBus; +use domain::{BlendMode, ParamDef, ParamValue}; +use effects::composite::CompositeEffect; +use effects::scene_builder::LayerSpec; +use effects::{BuiltinEffect, LiveParam, build_composite}; +use engine::{EffectQueue, RenderCommand}; +use persistence::PersistedState; +use store::{LayerRecord, Store, ZoneRecord}; +use tokio::sync::watch; + +use super::RenderTaskRuntime; +use super::state::{ActiveScene, SceneState, build_snapshot, extract_persisted}; + +type LayerSpecData = ( + String, + Vec, + HashMap, + BlendMode, + ZoneRecord, +); + +pub(super) struct SceneReload { + store: Arc, + builtins: Arc>, + state: Arc>, + queue: EffectQueue, + bus: StateEventBus, + state_tx: Option>, + strip_len: usize, + brightness_val: f32, +} + +impl SceneReload { + pub(super) fn from_runtime(rt: &RenderTaskRuntime) -> Self { + let brightness_val = { + let s = rt.state.lock().unwrap(); + if s.on { s.brightness as f32 } else { 0.0 } + }; + Self { + store: Arc::clone(&rt.store), + builtins: Arc::clone(&rt.builtins), + state: Arc::clone(&rt.state), + queue: rt.queue.clone(), + bus: rt.bus.clone(), + state_tx: rt.state_tx.clone(), + strip_len: rt.strip_len, + brightness_val, + } + } + + pub(super) fn spawn(self) { + tokio::spawn(async move { self.run().await }); + } + + async fn run(self) { + let Some(enabled) = self.enabled_layers().await else { + self.go_idle(); + return; + }; + + let specs_data = self.resolve_specs(&enabled).await; + if specs_data.is_empty() { + self.go_idle(); + return; + } + + let brightness = Arc::new(LiveParam::new( + self.brightness_val, + self.queue.brightness_speed(), + )); + let specs = to_layer_specs(&specs_data); + + match build_composite(&specs, self.strip_len, Arc::clone(&brightness)) { + Ok(composite) => self.start(brightness, composite), + Err(e) => eprintln!("warning: failed to build composite: {e}"), + } + } + + fn go_idle(&self) { + let (snapshot, persisted) = { + let mut s = self.state.lock().unwrap(); + s.scene = ActiveScene::Idle; + self.queue.send(RenderCommand::Halt); + (build_snapshot(&s), extract_persisted(&s)) + }; + self.emit(snapshot, persisted); + } + + fn start(&self, brightness: Arc>, composite: CompositeEffect) { + let (snapshot, persisted) = { + let mut s = self.state.lock().unwrap(); + s.scene = ActiveScene::Running { brightness }; + (build_snapshot(&s), extract_persisted(&s)) + }; + self.queue.enqueue(Box::new(composite)); + self.emit(snapshot, persisted); + } + + fn emit(&self, snapshot: application::SceneSnapshot, persisted: PersistedState) { + self.bus.notify(snapshot); + if let Some(tx) = &self.state_tx { + let _ = tx.send(persisted); + } + } + + async fn enabled_layers(&self) -> Option> { + let layers = self + .store + .get_active_layers() + .await + .inspect_err(|e| eprintln!("warning: failed to load active layers: {e}")) + .ok()?; + let enabled: Vec<_> = layers.into_iter().filter(|l| l.enabled).collect(); + (!enabled.is_empty()).then_some(enabled) + } + + async fn resolve_specs(&self, layers: &[LayerRecord]) -> Vec { + let mut specs = Vec::with_capacity(layers.len()); + for layer in layers { + if let Some(s) = self.resolve_one(layer).await { + specs.push(s); + } + } + specs + } + + 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)) + } + + async fn resolve_effect(&self, effect_id: &str) -> Option<(String, Vec)> { + if let Some(b) = self.builtins.get(effect_id) { + return Some((b.script.clone(), b.params.clone())); + } + self.store + .get_effect(effect_id) + .await + .inspect_err(|e| eprintln!("warning: effect '{effect_id}' not found: {e}")) + .ok() + .map(|e| (e.script, e.params)) + } + + async fn resolve_zone(&self, zone_id: &str) -> Option { + if zone_id == "all" { + return Some(ZoneRecord { + id: "all".into(), + name: "Full Strip".into(), + start_pixel: 0, + end_pixel: self.strip_len as u32, + transition_length: 0, + }); + } + self.store + .get_zone(zone_id) + .await + .inspect_err(|e| eprintln!("warning: zone '{zone_id}' not found: {e}")) + .ok() + } +} + +fn to_layer_specs(data: &[LayerSpecData]) -> Vec> { + data.iter() + .map(|(script, defs, params, mode, zone)| LayerSpec { + script: script.as_str(), + param_defs: defs.as_slice(), + params, + blend_mode: *mode, + zone_start: zone.start_pixel as usize, + zone_end: zone.end_pixel as usize, + zone_transition: zone.transition_length as usize, + }) + .collect() +} diff --git a/crates/server/src/scene_runtime/state.rs b/crates/server/src/scene_runtime/state.rs new file mode 100644 index 0000000..9663632 --- /dev/null +++ b/crates/server/src/scene_runtime/state.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use application::SceneSnapshot; +use domain::Rgb; +use effects::LiveParam; +use persistence::PersistedState; + +pub(crate) enum ActiveScene { + Idle, + Running { + brightness: Arc>, + }, + Timed { + name: String, + end_unix_timestamp_sec: u64, + }, +} + +pub(crate) struct SceneState { + pub(crate) on: bool, + pub(crate) brightness: u8, + pub(crate) color: Rgb, + pub(crate) scene: ActiveScene, +} + +pub(crate) fn build_snapshot(state: &SceneState) -> SceneSnapshot { + let (active_effect, light_effect_end_unix_timestamp_sec) = match &state.scene { + ActiveScene::Idle => (None, None), + ActiveScene::Running { .. } => (Some("custom".to_string()), None), + ActiveScene::Timed { + name, + end_unix_timestamp_sec, + } => (Some(name.clone()), Some(*end_unix_timestamp_sec)), + }; + SceneSnapshot { + on: state.on, + brightness: state.brightness, + color: state.color, + active_effect, + light_effect_end_unix_timestamp_sec, + } +} + +pub(crate) fn extract_persisted(state: &SceneState) -> PersistedState { + PersistedState { + on: state.on, + brightness: state.brightness, + color: state.color, + active_effect: match &state.scene { + ActiveScene::Idle => None, + ActiveScene::Running { .. } => Some("custom".to_string()), + ActiveScene::Timed { name, .. } => Some(name.clone()), + }, + } +} From 1f6fad884b5ff882fd14510bb6cd0fc2ccc30702 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:16:05 +0100 Subject: [PATCH 07/16] feat(api): add endpoint for replacing the currently active scene --- crates/api/src/active_scene.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/api/src/active_scene.rs b/crates/api/src/active_scene.rs index efcb241..ec8913c 100644 --- a/crates/api/src/active_scene.rs +++ b/crates/api/src/active_scene.rs @@ -41,6 +41,34 @@ struct AddLayerRequest { params: HashMap, } +#[put("/scenes/active")] +pub async fn set_active_scene( + store: web::Data>, + runtime: web::Data, + body: web::Json>, +) -> Result { + store.clear_active_scene().await?; + for layer in body.iter() { + store + .add_layer( + ACTIVE_SCENE_ID, + &layer.effect_id, + &layer.zone_id, + layer.blend_mode, + &layer.params, + ) + .await?; + } + runtime.reload_active(); + let layers = store.get_active_layers().await?; + Ok(HttpResponse::Ok().json( + layers + .into_iter() + .map(LayerResponse::from) + .collect::>(), + )) +} + #[post("/scenes/active/layers")] pub async fn add_layer( store: web::Data>, From 8e81bc9531e816edf1402cdf463aa3ce1c46b32d Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:25:32 +0100 Subject: [PATCH 08/16] fix: cull layers to skip deeper layers that are overridden by the override blend mode --- crates/api/src/active_scene.rs | 1 + crates/effects/src/compositor/composite.rs | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/api/src/active_scene.rs b/crates/api/src/active_scene.rs index ec8913c..ee7e686 100644 --- a/crates/api/src/active_scene.rs +++ b/crates/api/src/active_scene.rs @@ -181,6 +181,7 @@ pub async fn overwrite_scene_from_active( pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get_active_scene) + .service(set_active_scene) .service(clear_active_scene) .service(add_layer) .service(reorder_layers) diff --git a/crates/effects/src/compositor/composite.rs b/crates/effects/src/compositor/composite.rs index cbe38cb..5d85f74 100644 --- a/crates/effects/src/compositor/composite.rs +++ b/crates/effects/src/compositor/composite.rs @@ -14,7 +14,7 @@ pub struct CompositeEffect { impl Effect for CompositeEffect { fn frames(&self, pixels: &[Rgb]) -> Box> + Send + 'static> { let strip_len = pixels.len(); - let layers = self.layers.clone(); + let layers = cull_layers(&self.layers, strip_len); let brightness = Arc::clone(&self.brightness); Box::new(std::iter::from_fn(move || { @@ -31,6 +31,22 @@ impl Effect for CompositeEffect { } } +fn cull_layers(layers: &[CompositeLayer], strip_len: usize) -> Vec { + let first_visible = layers + .iter() + .enumerate() + .rev() + .find(|(_, l)| { + l.mode == BlendMode::Override + && l.zone.start_pixel == 0 + && l.zone.end_pixel >= strip_len + && l.zone.transition_length == 0 + }) + .map(|(i, _)| i) + .unwrap_or(0); + layers[first_visible..].to_vec() +} + fn blend_layer(base: Vec, overlay: &[Rgb], mode: BlendMode, zone: &ZoneGradient) -> Vec { if overlay.is_empty() { return base; From 260872fced9b1be2de072c15ae740445fcdcd0ea Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:05:11 +0100 Subject: [PATCH 09/16] feat: wire up primary color into rhai effects --- crates/effects/src/compositor/composite.rs | 3 +++ crates/effects/src/rhai/script.rs | 6 ++++++ crates/effects/src/scene_builder.rs | 5 ++++- crates/effects/tests/composite.rs | 11 +++++++++++ crates/effects/tests/script.rs | 3 ++- crates/engine/src/queue.rs | 4 ++++ crates/server/src/scene_runtime/mod.rs | 6 +++++- crates/server/src/scene_runtime/reload.rs | 11 +++++++++-- 8 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/compositor/composite.rs b/crates/effects/src/compositor/composite.rs index 5d85f74..3f64637 100644 --- a/crates/effects/src/compositor/composite.rs +++ b/crates/effects/src/compositor/composite.rs @@ -9,6 +9,7 @@ use super::live_param::LiveParam; pub struct CompositeEffect { pub layers: Vec, pub brightness: Arc>, + pub primary_color: Arc>, } impl Effect for CompositeEffect { @@ -16,9 +17,11 @@ impl Effect for CompositeEffect { let strip_len = pixels.len(); let layers = cull_layers(&self.layers, strip_len); 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 .iter() .fold(vec![Rgb::BLACK; strip_len], |base, layer| { diff --git a/crates/effects/src/rhai/script.rs b/crates/effects/src/rhai/script.rs index 5ece9ab..3af4f29 100644 --- a/crates/effects/src/rhai/script.rs +++ b/crates/effects/src/rhai/script.rs @@ -8,6 +8,7 @@ use domain::{ParamDef, ParamValue, Rgb}; use super::{make_script_engine, parse_color}; use crate::compositor::layer::LayerEffect; +use crate::compositor::live_param::LiveParam; use crate::compositor::registry::EffectRegistry; use crate::error::EffectError; @@ -16,6 +17,7 @@ pub struct ScriptLayer { ast: AST, frame: AtomicU64, params: Vec<(ImmutableString, Dynamic)>, + primary_color: Arc>, strip_len: usize, zone_start: usize, } @@ -35,6 +37,7 @@ impl LayerEffect for ScriptLayer { for (name, val) in &self.params { scope.push_dynamic(name.as_str(), val.clone()); } + scope.push("primary", self.primary_color.get()); let base_len = scope.len(); (0..len) @@ -57,6 +60,7 @@ pub fn build_layer( script: &str, param_defs: &[ParamDef], layer_params: &HashMap, + primary_color: Arc>, strip_len: usize, zone_start: usize, ) -> Result, EffectError> { @@ -76,6 +80,7 @@ pub fn build_layer( ast, frame: AtomicU64::new(0), params, + primary_color, strip_len, zone_start, })) @@ -97,6 +102,7 @@ pub fn register(registry: &mut EffectRegistry) { ast, frame: AtomicU64::new(0), params: Vec::new(), + primary_color: Arc::new(LiveParam::new(Rgb::BLACK, 5.0)), strip_len, zone_start, })) diff --git a/crates/effects/src/scene_builder.rs b/crates/effects/src/scene_builder.rs index 4f8235f..7bf9a31 100644 --- a/crates/effects/src/scene_builder.rs +++ b/crates/effects/src/scene_builder.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use domain::{BlendMode, ParamDef, ParamValue}; +use domain::{BlendMode, ParamDef, ParamValue, Rgb}; use crate::compositor::composite::CompositeEffect; use crate::compositor::layer::{CompositeLayer, ZoneGradient}; @@ -23,6 +23,7 @@ pub fn build_composite( layers: &[LayerSpec<'_>], strip_len: usize, brightness: Arc>, + primary_color: Arc>, ) -> Result { let composite_layers = layers .iter() @@ -31,6 +32,7 @@ pub fn build_composite( spec.script, spec.param_defs, spec.params, + Arc::clone(&primary_color), strip_len, spec.zone_start, ) @@ -54,5 +56,6 @@ pub fn build_composite( Ok(CompositeEffect { layers: composite_layers, brightness, + primary_color, }) } diff --git a/crates/effects/tests/composite.rs b/crates/effects/tests/composite.rs index a883f4c..eed4128 100644 --- a/crates/effects/tests/composite.rs +++ b/crates/effects/tests/composite.rs @@ -18,6 +18,10 @@ fn brightness(val: f32) -> Arc> { Arc::new(LiveParam::new(val, f32::MAX)) } +fn primary() -> Arc> { + Arc::new(LiveParam::new(Rgb::BLACK, f32::MAX)) +} + fn full_layer(color: Rgb, mode: BlendMode, strip_len: usize) -> CompositeLayer { CompositeLayer { effect: Arc::new(Solid(color)), @@ -31,6 +35,7 @@ fn override_mode_fills_frame_with_layer_color() { let effect = CompositeEffect { layers: vec![full_layer(Rgb::new(255, 0, 0), BlendMode::Override, 4)], 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))); @@ -44,6 +49,7 @@ fn add_mode_saturates_channels() { full_layer(Rgb::new(100, 0, 0), BlendMode::Add, 4), ], brightness: brightness(255.0), + primary_color: primary(), }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert!(frame.iter().all(|p| p.r == 255 && p.g == 0 && p.b == 0)); @@ -54,6 +60,7 @@ fn zero_brightness_yields_black_frame() { let effect = CompositeEffect { layers: vec![full_layer(Rgb::new(255, 255, 255), BlendMode::Override, 4)], brightness: brightness(0.0), + primary_color: primary(), }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert!(frame.iter().all(|p| *p == Rgb::BLACK)); @@ -73,6 +80,7 @@ fn zone_restricts_rendering_to_pixel_range() { zone, }], brightness: brightness(255.0), + primary_color: primary(), }; let frame = effect.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert_eq!(frame[0], Rgb::BLACK); @@ -98,6 +106,7 @@ fn transition_extends_outside_logical_zone() { zone, }], brightness: brightness(255.0), + primary_color: primary(), }; let frame = effect.frames(&[Rgb::BLACK; 10]).next().unwrap(); // Logical interior: full red @@ -153,6 +162,7 @@ fn adjacent_zones_blend_without_black() { }, ], brightness: brightness(255.0), + primary_color: primary(), }; let frame = effect.frames(&[Rgb::BLACK; 10]).next().unwrap(); // Full red in A's interior @@ -176,6 +186,7 @@ fn frames_iterator_is_infinite() { let effect = CompositeEffect { layers: vec![full_layer(Rgb::new(0, 255, 0), BlendMode::Override, 2)], brightness: brightness(255.0), + primary_color: primary(), }; assert_eq!(effect.frames(&[Rgb::BLACK; 2]).take(1000).count(), 1000); } diff --git a/crates/effects/tests/script.rs b/crates/effects/tests/script.rs index 1301c00..059f314 100644 --- a/crates/effects/tests/script.rs +++ b/crates/effects/tests/script.rs @@ -117,7 +117,8 @@ fn build_composite_creates_valid_effect() { zone_transition: 0, }]; let brightness = Arc::new(LiveParam::new(255.0f32, f32::MAX)); - let composite = build_composite(&specs, 4, brightness).unwrap(); + let primary = Arc::new(LiveParam::new(Rgb::BLACK, f32::MAX)); + let composite = build_composite(&specs, 4, brightness, primary).unwrap(); let frame = composite.frames(&[Rgb::BLACK; 4]).next().unwrap(); assert!(frame.iter().all(|p| *p == Rgb::new(255, 0, 0))); } diff --git a/crates/engine/src/queue.rs b/crates/engine/src/queue.rs index e76223d..b9ed145 100644 --- a/crates/engine/src/queue.rs +++ b/crates/engine/src/queue.rs @@ -25,6 +25,10 @@ impl EffectQueue { 255.0 / self.crossfade_frames.max(1) as f32 } + pub fn color_speed(&self) -> f32 { + 255.0 / self.crossfade_frames.max(1) as f32 + } + pub fn enqueue(&self, effect: Box) { self.sender.send(RenderCommand::Execute(effect)).unwrap(); } diff --git a/crates/server/src/scene_runtime/mod.rs b/crates/server/src/scene_runtime/mod.rs index c46e6f0..bf4ad46 100644 --- a/crates/server/src/scene_runtime/mod.rs +++ b/crates/server/src/scene_runtime/mod.rs @@ -12,7 +12,7 @@ use engine::{EffectQueue, RenderCommand}; use api_google::effects::{ColorLoop, Sleep, Wake}; use application::{SceneRuntime, SceneSnapshot, StateEventBus}; use domain::Rgb; -use effects::BuiltinEffect; +use effects::{BuiltinEffect, LiveParam}; use persistence::PersistedState; use store::Store; @@ -26,6 +26,7 @@ pub struct RenderTaskRuntime { pub(crate) state_tx: Option>, pub(crate) store: Arc, pub(crate) builtins: Arc>, + pub(crate) primary_color: Arc>, pub(crate) strip_len: usize, } @@ -39,6 +40,7 @@ impl RenderTaskRuntime { builtins: Vec, strip_len: usize, ) -> Self { + let color_speed = queue.color_speed(); Self { queue, state: Arc::new(Mutex::new(SceneState { @@ -51,6 +53,7 @@ impl RenderTaskRuntime { state_tx, store, builtins: Arc::new(builtins.into_iter().map(|b| (b.id(), b)).collect()), + primary_color: Arc::new(LiveParam::new(initial.color, color_speed)), strip_len, } } @@ -74,6 +77,7 @@ impl RenderTaskRuntime { impl SceneRuntime for RenderTaskRuntime { fn set_color(&self, color: Rgb) { + self.primary_color.set(color); self.with_state(|state, queue| { state.color = color; state.on = true; diff --git a/crates/server/src/scene_runtime/reload.rs b/crates/server/src/scene_runtime/reload.rs index d88db39..9a39603 100644 --- a/crates/server/src/scene_runtime/reload.rs +++ b/crates/server/src/scene_runtime/reload.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use application::StateEventBus; -use domain::{BlendMode, ParamDef, ParamValue}; +use domain::{BlendMode, ParamDef, ParamValue, Rgb}; use effects::composite::CompositeEffect; use effects::scene_builder::LayerSpec; use effects::{BuiltinEffect, LiveParam, build_composite}; @@ -29,6 +29,7 @@ pub(super) struct SceneReload { queue: EffectQueue, bus: StateEventBus, state_tx: Option>, + primary_color: Arc>, strip_len: usize, brightness_val: f32, } @@ -46,6 +47,7 @@ impl SceneReload { queue: rt.queue.clone(), bus: rt.bus.clone(), state_tx: rt.state_tx.clone(), + primary_color: Arc::clone(&rt.primary_color), strip_len: rt.strip_len, brightness_val, } @@ -73,7 +75,12 @@ impl SceneReload { )); let specs = to_layer_specs(&specs_data); - match build_composite(&specs, self.strip_len, Arc::clone(&brightness)) { + match build_composite( + &specs, + self.strip_len, + Arc::clone(&brightness), + Arc::clone(&self.primary_color), + ) { Ok(composite) => self.start(brightness, composite), Err(e) => eprintln!("warning: failed to build composite: {e}"), } From 32be4d58dff5705a4237a9d7a133e8c233eeaf2e Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:15:41 +0100 Subject: [PATCH 10/16] fix(store): scope active-layer mutations to active scene --- crates/api/src/active_scene.rs | 18 ++++++-------- crates/store/src/lib.rs | 43 +++++++++++++++++++++++++++++++--- crates/store/src/scene.rs | 42 ++++++++++++++++++++++++--------- crates/store/tests/store.rs | 10 ++++++-- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/crates/api/src/active_scene.rs b/crates/api/src/active_scene.rs index ee7e686..56aa58e 100644 --- a/crates/api/src/active_scene.rs +++ b/crates/api/src/active_scene.rs @@ -5,7 +5,7 @@ use actix_web::{HttpResponse, Responder, delete, get, patch, post, put, web}; use application::SceneRuntime; use domain::{BlendMode, ParamValue}; use serde::Deserialize; -use store::{ACTIVE_SCENE_ID, Store}; +use store::Store; use crate::error::ApiError; use crate::types::{LayerResponse, SceneResponse}; @@ -50,8 +50,7 @@ pub async fn set_active_scene( store.clear_active_scene().await?; for layer in body.iter() { store - .add_layer( - ACTIVE_SCENE_ID, + .add_active_layer( &layer.effect_id, &layer.zone_id, layer.blend_mode, @@ -76,8 +75,7 @@ pub async fn add_layer( body: web::Json, ) -> Result { let layer = store - .add_layer( - ACTIVE_SCENE_ID, + .add_active_layer( &body.effect_id, &body.zone_id, body.blend_mode, @@ -103,12 +101,12 @@ pub async fn patch_layer( body: web::Json, ) -> Result { let id = path.into_inner(); - let current = store.get_layer_by_id(&id).await?; + let current = store.get_active_layer(&id).await?; 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 - .update_layer(&id, enabled, blend_mode, ¶ms) + .update_active_layer(&id, enabled, blend_mode, ¶ms) .await?; runtime.reload_active(); Ok(HttpResponse::Ok().json(LayerResponse::from(layer))) @@ -120,7 +118,7 @@ pub async fn remove_layer( runtime: web::Data, path: web::Path, ) -> Result { - store.remove_layer(&path.into_inner()).await?; + store.remove_active_layer(&path.into_inner()).await?; runtime.reload_active(); Ok(HttpResponse::NoContent().finish()) } @@ -136,9 +134,7 @@ pub async fn reorder_layers( runtime: web::Data, body: web::Json, ) -> Result { - store - .reorder_layers(ACTIVE_SCENE_ID, &body.ordered_ids) - .await?; + store.reorder_active_layers(&body.ordered_ids).await?; runtime.reload_active(); Ok(HttpResponse::NoContent().finish()) } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 81de21e..021fdc3 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -144,16 +144,33 @@ impl Store { scene::add_layer(&self.pool, scene_id, effect_id, zone_id, blend_mode, params).await } pub async fn update_layer( + &self, + id: &str, + scene_id: &str, + enabled: bool, + blend_mode: domain::BlendMode, + params: &std::collections::HashMap, + ) -> Result { + scene::update_layer(&self.pool, id, scene_id, enabled, blend_mode, params).await + } + pub async fn remove_layer(&self, id: &str, scene_id: &str) -> Result<(), StoreError> { + scene::remove_layer(&self.pool, id, scene_id).await + } + + pub async fn get_active_layer(&self, id: &str) -> Result { + scene::get_layer_in_scene(&self.pool, id, ACTIVE_SCENE_ID).await + } + pub async fn update_active_layer( &self, id: &str, enabled: bool, blend_mode: domain::BlendMode, params: &std::collections::HashMap, ) -> Result { - scene::update_layer(&self.pool, id, enabled, blend_mode, params).await + scene::update_layer(&self.pool, id, ACTIVE_SCENE_ID, enabled, blend_mode, params).await } - pub async fn remove_layer(&self, id: &str) -> Result<(), StoreError> { - scene::remove_layer(&self.pool, id).await + pub async fn remove_active_layer(&self, id: &str) -> Result<(), StoreError> { + scene::remove_layer(&self.pool, id, ACTIVE_SCENE_ID).await } pub async fn reorder_layers( &self, @@ -170,6 +187,26 @@ impl Store { pub async fn get_active_layers(&self) -> Result, StoreError> { scene::get_layers(&self.pool, ACTIVE_SCENE_ID).await } + pub async fn add_active_layer( + &self, + effect_id: &str, + zone_id: &str, + blend_mode: domain::BlendMode, + params: &std::collections::HashMap, + ) -> Result { + scene::add_layer( + &self.pool, + ACTIVE_SCENE_ID, + effect_id, + zone_id, + blend_mode, + params, + ) + .await + } + pub async fn reorder_active_layers(&self, ordered_ids: &[String]) -> Result<(), StoreError> { + scene::reorder_layers(&self.pool, ACTIVE_SCENE_ID, ordered_ids).await + } pub async fn clear_active_scene(&self) -> Result<(), StoreError> { scene::clear_layers(&self.pool, ACTIVE_SCENE_ID).await } diff --git a/crates/store/src/scene.rs b/crates/store/src/scene.rs index da7191a..c96ff16 100644 --- a/crates/store/src/scene.rs +++ b/crates/store/src/scene.rs @@ -185,6 +185,7 @@ pub async fn add_layer( pub async fn update_layer( pool: &SqlitePool, id: &str, + scene_id: &str, enabled: bool, blend_mode: BlendMode, params: &HashMap, @@ -193,15 +194,17 @@ pub async fn update_layer( let params_json = serde_json::to_string(params)?; let enabled_int = enabled as i64; - let rows_affected = - sqlx::query("UPDATE scene_layers SET enabled = ?, blend_mode = ?, params = ? WHERE id = ?") - .bind(enabled_int) - .bind(blend_str) - .bind(¶ms_json) - .bind(id) - .execute(pool) - .await? - .rows_affected(); + let rows_affected = sqlx::query( + "UPDATE scene_layers SET enabled = ?, blend_mode = ?, params = ? WHERE id = ? AND scene_id = ?", + ) + .bind(enabled_int) + .bind(blend_str) + .bind(¶ms_json) + .bind(id) + .bind(scene_id) + .execute(pool) + .await? + .rows_affected(); if rows_affected == 0 { return Err(StoreError::NotFound); @@ -209,9 +212,10 @@ pub async fn update_layer( get_layer(pool, id).await } -pub async fn remove_layer(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { - let rows_affected = sqlx::query("DELETE FROM scene_layers WHERE id = ?") +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) + .bind(scene_id) .execute(pool) .await? .rows_affected(); @@ -306,3 +310,19 @@ pub async fn get_layer(pool: &SqlitePool, id: &str) -> Result Result { + let row: Option = sqlx::query_as::<_, LayerRow>( + "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position + FROM scene_layers WHERE id = ? AND scene_id = ?", + ) + .bind(id) + .bind(scene_id) + .fetch_optional(pool) + .await?; + layer_from_row(row.ok_or(StoreError::NotFound)?) +} diff --git a/crates/store/tests/store.rs b/crates/store/tests/store.rs index f71e343..62b9809 100644 --- a/crates/store/tests/store.rs +++ b/crates/store/tests/store.rs @@ -254,7 +254,13 @@ async fn update_layer() { .unwrap(); let updated = store - .update_layer(&layer.id, false, BlendMode::Screen, &make_params()) + .update_layer( + &layer.id, + &scene.id, + false, + BlendMode::Screen, + &make_params(), + ) .await .unwrap(); assert!(!updated.enabled); @@ -329,7 +335,7 @@ async fn remove_layer() { .await .unwrap(); - store.remove_layer(&layer.id).await.unwrap(); + store.remove_layer(&layer.id, &scene.id).await.unwrap(); let layers = store.get_layers(&scene.id).await.unwrap(); assert!(layers.is_empty()); } From dd4d765df1caac1412ccfbce72be0f860b82ff5f Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:40:15 +0100 Subject: [PATCH 11/16] fix(store): atomic replace_active_layers with QueryBuilder batch insert --- crates/api/src/active_scene.rs | 26 +++++++++-------- crates/store/src/lib.rs | 5 +++- crates/store/src/scene.rs | 51 ++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/api/src/active_scene.rs b/crates/api/src/active_scene.rs index 56aa58e..ff79446 100644 --- a/crates/api/src/active_scene.rs +++ b/crates/api/src/active_scene.rs @@ -5,7 +5,7 @@ use actix_web::{HttpResponse, Responder, delete, get, patch, post, put, web}; use application::SceneRuntime; use domain::{BlendMode, ParamValue}; use serde::Deserialize; -use store::Store; +use store::{NewLayer, Store}; use crate::error::ApiError; use crate::types::{LayerResponse, SceneResponse}; @@ -41,23 +41,25 @@ struct AddLayerRequest { params: HashMap, } +impl From for NewLayer { + fn from(r: AddLayerRequest) -> Self { + Self { + effect_id: r.effect_id, + zone_id: r.zone_id, + blend_mode: r.blend_mode, + params: r.params, + } + } +} + #[put("/scenes/active")] pub async fn set_active_scene( store: web::Data>, runtime: web::Data, body: web::Json>, ) -> Result { - store.clear_active_scene().await?; - for layer in body.iter() { - store - .add_active_layer( - &layer.effect_id, - &layer.zone_id, - layer.blend_mode, - &layer.params, - ) - .await?; - } + let layers: Vec = body.into_inner().into_iter().map(Into::into).collect(); + store.replace_active_layers(&layers).await?; runtime.reload_active(); let layers = store.get_active_layers().await?; Ok(HttpResponse::Ok().json( diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 021fdc3..f86ac40 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -3,7 +3,7 @@ mod scene; mod zone; pub use effect::EffectRecord; -pub use scene::{ACTIVE_SCENE_ID, LayerRecord, SceneRecord}; +pub use scene::{ACTIVE_SCENE_ID, LayerRecord, NewLayer, SceneRecord}; pub use zone::ZoneRecord; use std::path::Path; @@ -187,6 +187,9 @@ impl Store { pub async fn get_active_layers(&self) -> Result, StoreError> { scene::get_layers(&self.pool, ACTIVE_SCENE_ID).await } + pub async fn replace_active_layers(&self, layers: &[NewLayer]) -> Result<(), StoreError> { + scene::replace_active_layers(&self.pool, layers).await + } pub async fn add_active_layer( &self, effect_id: &str, diff --git a/crates/store/src/scene.rs b/crates/store/src/scene.rs index c96ff16..bf16560 100644 --- a/crates/store/src/scene.rs +++ b/crates/store/src/scene.rs @@ -244,6 +244,57 @@ pub async fn reorder_layers( Ok(()) } +pub struct NewLayer { + pub effect_id: String, + pub zone_id: String, + pub blend_mode: BlendMode, + pub params: HashMap, +} + +pub async fn replace_active_layers( + pool: &SqlitePool, + layers: &[NewLayer], +) -> 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(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) ", + ) + .push_values(&entries, |mut b, (id, layer, params, pos)| { + b.push_bind(id.as_str()) + .push_bind(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(1i64) + .push_bind(*pos); + }) + .build() + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) +} + pub async fn clear_layers(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { sqlx::query("DELETE FROM scene_layers WHERE scene_id = ?") .bind(scene_id) From 7e367c247895338d7e6363866ecabbf9099a1e76 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:44:16 +0100 Subject: [PATCH 12/16] fix(store): validate layer IDs before reorder, return NotFound on mismatch --- crates/store/src/scene.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/store/src/scene.rs b/crates/store/src/scene.rs index bf16560..9f0ba58 100644 --- a/crates/store/src/scene.rs +++ b/crates/store/src/scene.rs @@ -231,6 +231,20 @@ pub async fn reorder_layers( scene_id: &str, ordered_ids: &[String], ) -> Result<(), StoreError> { + if ordered_ids.is_empty() { + return Ok(()); + } + + let existing: std::collections::HashSet = get_layers(pool, scene_id) + .await? + .into_iter() + .map(|l| l.id) + .collect(); + + if !ordered_ids.iter().all(|id| existing.contains(id)) { + return Err(StoreError::NotFound); + } + let mut tx = pool.begin().await?; for (position, id) in ordered_ids.iter().enumerate() { sqlx::query("UPDATE scene_layers SET position = ? WHERE id = ? AND scene_id = ?") From e0ae5cffa097370d7796111dc81dd8fbf84fc7c8 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:45:30 +0100 Subject: [PATCH 13/16] fix(effects): log script eval errors instead of silently producing black --- crates/effects/src/rhai/script.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/rhai/script.rs b/crates/effects/src/rhai/script.rs index 3af4f29..24116f7 100644 --- a/crates/effects/src/rhai/script.rs +++ b/crates/effects/src/rhai/script.rs @@ -48,7 +48,12 @@ impl LayerEffect for ScriptLayer { .engine .eval_ast_with_scope::(&mut scope, &self.ast) .map(parse_color) - .unwrap_or(Rgb::BLACK); + .unwrap_or_else(|e| { + if i == 0 { + eprintln!("warning: script eval error: {e}"); + } + Rgb::BLACK + }); scope.rewind(base_len); color }) From 5b6567a2579bb98d8f9e9a55ac3f08d00cf4d116 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:53:02 +0100 Subject: [PATCH 14/16] refactor: spread scenes into multiple files --- crates/store/src/scene/active.rs | 74 ++++++ .../store/src/{scene.rs => scene/layers.rs} | 231 ++++-------------- crates/store/src/scene/mod.rs | 12 + crates/store/src/scene/scenes.rs | 84 +++++++ 4 files changed, 212 insertions(+), 189 deletions(-) create mode 100644 crates/store/src/scene/active.rs rename crates/store/src/{scene.rs => scene/layers.rs} (58%) create mode 100644 crates/store/src/scene/mod.rs create mode 100644 crates/store/src/scene/scenes.rs diff --git a/crates/store/src/scene/active.rs b/crates/store/src/scene/active.rs new file mode 100644 index 0000000..181b828 --- /dev/null +++ b/crates/store/src/scene/active.rs @@ -0,0 +1,74 @@ +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::StoreError; + +use super::layers::{NewLayer, blend_mode_to_str, clear_layers, copy_layers}; +use super::scenes::{SceneRecord, create, get_one}; + +pub const ACTIVE_SCENE_ID: &str = "__active__"; + +pub async fn replace_active_layers( + pool: &SqlitePool, + layers: &[NewLayer], +) -> 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(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) ", + ) + .push_values(&entries, |mut b, (id, layer, params, pos)| { + b.push_bind(id.as_str()) + .push_bind(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(1i64) + .push_bind(*pos); + }) + .build() + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) +} + +pub async fn load_into_active(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { + get_one(pool, scene_id).await?; + clear_layers(pool, ACTIVE_SCENE_ID).await?; + copy_layers(pool, scene_id, ACTIVE_SCENE_ID).await +} + +pub async fn save_active_as(pool: &SqlitePool, name: &str) -> Result { + let scene = create(pool, name).await?; + copy_layers(pool, ACTIVE_SCENE_ID, &scene.id).await?; + Ok(scene) +} + +pub async fn overwrite_from_active(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { + if id == ACTIVE_SCENE_ID { + return Err(StoreError::NotFound); + } + clear_layers(pool, id).await?; + copy_layers(pool, ACTIVE_SCENE_ID, id).await?; + Ok(()) +} diff --git a/crates/store/src/scene.rs b/crates/store/src/scene/layers.rs similarity index 58% rename from crates/store/src/scene.rs rename to crates/store/src/scene/layers.rs index 9f0ba58..c16ac86 100644 --- a/crates/store/src/scene.rs +++ b/crates/store/src/scene/layers.rs @@ -7,14 +7,6 @@ use domain::{BlendMode, ParamValue}; use crate::StoreError; -pub const ACTIVE_SCENE_ID: &str = "__active__"; - -#[derive(Debug, Clone)] -pub struct SceneRecord { - pub id: String, - pub name: String, -} - #[derive(Debug, Clone)] pub struct LayerRecord { pub id: String, @@ -28,24 +20,25 @@ pub struct LayerRecord { } #[derive(FromRow)] -struct SceneRow { - id: String, - name: String, +pub(super) struct LayerRow { + pub id: String, + pub scene_id: String, + pub effect_id: String, + pub zone_id: String, + pub blend_mode: String, + pub params: String, + pub enabled: i64, + pub position: i64, } -#[derive(FromRow)] -struct LayerRow { - id: String, - scene_id: String, - effect_id: String, - zone_id: String, - blend_mode: String, - params: String, - enabled: i64, - position: i64, +pub struct NewLayer { + pub effect_id: String, + pub zone_id: String, + pub blend_mode: BlendMode, + pub params: HashMap, } -fn layer_from_row(row: LayerRow) -> Result { +pub(super) fn layer_from_row(row: LayerRow) -> Result { Ok(LayerRecord { blend_mode: BlendMode::from(row.blend_mode.as_str()), params: serde_json::from_str(&row.params)?, @@ -58,7 +51,7 @@ fn layer_from_row(row: LayerRow) -> Result { }) } -fn blend_mode_to_str(mode: BlendMode) -> &'static str { +pub(super) fn blend_mode_to_str(mode: BlendMode) -> &'static str { match mode { BlendMode::Override => "override", BlendMode::Add => "add", @@ -67,83 +60,42 @@ fn blend_mode_to_str(mode: BlendMode) -> &'static str { } } -pub async fn get_all(pool: &SqlitePool) -> Result, StoreError> { - let rows: Vec = sqlx::query_as::<_, SceneRow>( - "SELECT id, name FROM scenes WHERE id != ? AND name IS NOT NULL ORDER BY name", +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 + FROM scene_layers WHERE scene_id = ? ORDER BY position", ) - .bind(ACTIVE_SCENE_ID) + .bind(scene_id) .fetch_all(pool) .await?; - Ok(rows - .into_iter() - .map(|r| SceneRecord { - id: r.id, - name: r.name, - }) - .collect()) -} - -pub async fn get_one(pool: &SqlitePool, id: &str) -> Result { - let row: Option = - sqlx::query_as::<_, SceneRow>("SELECT id, name FROM scenes WHERE id = ?") - .bind(id) - .fetch_optional(pool) - .await?; - row.map(|r| SceneRecord { - id: r.id, - name: r.name, - }) - .ok_or(StoreError::NotFound) -} - -pub async fn create(pool: &SqlitePool, name: &str) -> Result { - let id = Uuid::new_v4().to_string(); - sqlx::query("INSERT INTO scenes (id, name) VALUES (?, ?)") - .bind(&id) - .bind(name) - .execute(pool) - .await?; - get_one(pool, &id).await -} - -pub async fn update(pool: &SqlitePool, id: &str, name: &str) -> Result { - let rows_affected = sqlx::query("UPDATE scenes SET name = ? WHERE id = ? AND id != ?") - .bind(name) - .bind(id) - .bind(ACTIVE_SCENE_ID) - .execute(pool) - .await? - .rows_affected(); - - if rows_affected == 0 { - return Err(StoreError::NotFound); - } - get_one(pool, id).await + rows.into_iter().map(layer_from_row).collect() } -pub async fn delete(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { - let rows_affected = sqlx::query("DELETE FROM scenes WHERE id = ? AND id != ?") - .bind(id) - .bind(ACTIVE_SCENE_ID) - .execute(pool) - .await? - .rows_affected(); - - if rows_affected == 0 { - return Err(StoreError::NotFound); - } - Ok(()) +pub async fn get_layer(pool: &SqlitePool, id: &str) -> Result { + let row: Option = sqlx::query_as::<_, LayerRow>( + "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position + FROM scene_layers WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await?; + layer_from_row(row.ok_or(StoreError::NotFound)?) } -pub async fn get_layers(pool: &SqlitePool, scene_id: &str) -> Result, StoreError> { - let rows: Vec = sqlx::query_as::<_, LayerRow>( +pub async fn get_layer_in_scene( + pool: &SqlitePool, + id: &str, + scene_id: &str, +) -> Result { + let row: Option = sqlx::query_as::<_, LayerRow>( "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position - FROM scene_layers WHERE scene_id = ? ORDER BY position", + FROM scene_layers WHERE id = ? AND scene_id = ?", ) + .bind(id) .bind(scene_id) - .fetch_all(pool) + .fetch_optional(pool) .await?; - rows.into_iter().map(layer_from_row).collect() + layer_from_row(row.ok_or(StoreError::NotFound)?) } pub async fn add_layer( @@ -258,57 +210,6 @@ pub async fn reorder_layers( Ok(()) } -pub struct NewLayer { - pub effect_id: String, - pub zone_id: String, - pub blend_mode: BlendMode, - pub params: HashMap, -} - -pub async fn replace_active_layers( - pool: &SqlitePool, - layers: &[NewLayer], -) -> 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(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) ", - ) - .push_values(&entries, |mut b, (id, layer, params, pos)| { - b.push_bind(id.as_str()) - .push_bind(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(1i64) - .push_bind(*pos); - }) - .build() - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - Ok(()) -} - pub async fn clear_layers(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { sqlx::query("DELETE FROM scene_layers WHERE scene_id = ?") .bind(scene_id) @@ -317,28 +218,7 @@ pub async fn clear_layers(pool: &SqlitePool, scene_id: &str) -> Result<(), Store Ok(()) } -pub async fn load_into_active(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { - get_one(pool, scene_id).await?; - clear_layers(pool, ACTIVE_SCENE_ID).await?; - copy_layers(pool, scene_id, ACTIVE_SCENE_ID).await -} - -pub async fn save_active_as(pool: &SqlitePool, name: &str) -> Result { - let scene = create(pool, name).await?; - copy_layers(pool, ACTIVE_SCENE_ID, &scene.id).await?; - Ok(scene) -} - -pub async fn overwrite_from_active(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { - if id == ACTIVE_SCENE_ID { - return Err(StoreError::NotFound); - } - clear_layers(pool, id).await?; - copy_layers(pool, ACTIVE_SCENE_ID, id).await?; - Ok(()) -} - -async fn copy_layers( +pub(super) async fn copy_layers( pool: &SqlitePool, from_scene_id: &str, to_scene_id: &str, @@ -364,30 +244,3 @@ async fn copy_layers( } Ok(()) } - -pub async fn get_layer(pool: &SqlitePool, id: &str) -> Result { - let row: Option = sqlx::query_as::<_, LayerRow>( - "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position - FROM scene_layers WHERE id = ?", - ) - .bind(id) - .fetch_optional(pool) - .await?; - layer_from_row(row.ok_or(StoreError::NotFound)?) -} - -pub async fn get_layer_in_scene( - pool: &SqlitePool, - id: &str, - scene_id: &str, -) -> Result { - let row: Option = sqlx::query_as::<_, LayerRow>( - "SELECT id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position - FROM scene_layers WHERE id = ? AND scene_id = ?", - ) - .bind(id) - .bind(scene_id) - .fetch_optional(pool) - .await?; - layer_from_row(row.ok_or(StoreError::NotFound)?) -} diff --git a/crates/store/src/scene/mod.rs b/crates/store/src/scene/mod.rs new file mode 100644 index 0000000..34c6aea --- /dev/null +++ b/crates/store/src/scene/mod.rs @@ -0,0 +1,12 @@ +mod active; +mod layers; +mod scenes; + +pub use active::{ + ACTIVE_SCENE_ID, load_into_active, overwrite_from_active, replace_active_layers, 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, +}; +pub use scenes::{SceneRecord, create, delete, get_all, get_one, update}; diff --git a/crates/store/src/scene/scenes.rs b/crates/store/src/scene/scenes.rs new file mode 100644 index 0000000..fab2d8d --- /dev/null +++ b/crates/store/src/scene/scenes.rs @@ -0,0 +1,84 @@ +use sqlx::{FromRow, SqlitePool}; +use uuid::Uuid; + +use crate::StoreError; + +#[derive(Debug, Clone)] +pub struct SceneRecord { + pub id: String, + pub name: String, +} + +#[derive(FromRow)] +pub(super) struct SceneRow { + pub id: String, + pub name: String, +} + +pub async fn get_all(pool: &SqlitePool) -> Result, StoreError> { + let rows: Vec = sqlx::query_as::<_, SceneRow>( + "SELECT id, name FROM scenes WHERE id != ? AND name IS NOT NULL ORDER BY name", + ) + .bind(super::active::ACTIVE_SCENE_ID) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| SceneRecord { + id: r.id, + name: r.name, + }) + .collect()) +} + +pub async fn get_one(pool: &SqlitePool, id: &str) -> Result { + let row: Option = + sqlx::query_as::<_, SceneRow>("SELECT id, name FROM scenes WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await?; + row.map(|r| SceneRecord { + id: r.id, + name: r.name, + }) + .ok_or(StoreError::NotFound) +} + +pub async fn create(pool: &SqlitePool, name: &str) -> Result { + let id = Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO scenes (id, name) VALUES (?, ?)") + .bind(&id) + .bind(name) + .execute(pool) + .await?; + get_one(pool, &id).await +} + +pub async fn update(pool: &SqlitePool, id: &str, name: &str) -> Result { + let rows_affected = sqlx::query("UPDATE scenes SET name = ? WHERE id = ? AND id != ?") + .bind(name) + .bind(id) + .bind(super::active::ACTIVE_SCENE_ID) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + get_one(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { + let rows_affected = sqlx::query("DELETE FROM scenes WHERE id = ? AND id != ?") + .bind(id) + .bind(super::active::ACTIVE_SCENE_ID) + .execute(pool) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Err(StoreError::NotFound); + } + Ok(()) +} From 23ee6b7ebfe1b1e8943af42132f84249b8bbf1c5 Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:36:56 +0100 Subject: [PATCH 15/16] refactor: spread store tests into multiple files --- crates/store/tests/active_scene.rs | 147 ++++++++ crates/store/tests/effects.rs | 106 ++++++ crates/store/tests/helpers.rs | 5 + crates/store/tests/layers.rs | 164 +++++++++ crates/store/tests/scenes.rs | 44 +++ crates/store/tests/store.rs | 519 ----------------------------- crates/store/tests/zones.rs | 51 +++ 7 files changed, 517 insertions(+), 519 deletions(-) create mode 100644 crates/store/tests/active_scene.rs create mode 100644 crates/store/tests/effects.rs create mode 100644 crates/store/tests/layers.rs create mode 100644 crates/store/tests/scenes.rs delete mode 100644 crates/store/tests/store.rs create mode 100644 crates/store/tests/zones.rs diff --git a/crates/store/tests/active_scene.rs b/crates/store/tests/active_scene.rs new file mode 100644 index 0000000..4b7d7a4 --- /dev/null +++ b/crates/store/tests/active_scene.rs @@ -0,0 +1,147 @@ +pub mod helpers; + +use domain::{BlendMode, ParamValue}; + +#[tokio::test] +async fn starts_empty() { + let store = helpers::in_memory_store().await; + let active = store.get_active_layers().await.unwrap(); + assert!(active.is_empty()); +} + +#[tokio::test] +async fn layer_lifecycle() { + 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 layer = store + .add_active_layer( + &effect.id, + &zone.id, + BlendMode::Override, + &helpers::make_params(), + ) + .await + .unwrap(); + assert_eq!(layer.params["speed"], ParamValue::Number(2.0)); + + let updated = store + .update_active_layer(&layer.id, false, BlendMode::Add, &[].into()) + .await + .unwrap(); + assert!(!updated.enabled); + assert!(matches!(updated.blend_mode, BlendMode::Add)); + + store.remove_active_layer(&layer.id).await.unwrap(); + let active = store.get_active_layers().await.unwrap(); + assert!(active.is_empty()); +} + +#[tokio::test] +async fn clear() { + 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 + .add_active_layer(&effect.id, &zone.id, BlendMode::Override, &[].into()) + .await + .unwrap(); + store + .add_active_layer(&effect.id, &zone.id, BlendMode::Add, &[].into()) + .await + .unwrap(); + + store.clear_active_scene().await.unwrap(); + let active = store.get_active_layers().await.unwrap(); + assert!(active.is_empty()); +} + +#[tokio::test] +async fn save_and_load() { + 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 + .add_active_layer( + &effect.id, + &zone.id, + BlendMode::Override, + &helpers::make_params(), + ) + .await + .unwrap(); + + let saved = store.save_active_as_scene("my scene").await.unwrap(); + assert_eq!(saved.name, "my scene"); + let layers = store.get_layers(&saved.id).await.unwrap(); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].params["speed"], ParamValue::Number(2.0)); + + let other = store.create_scene("other").await.unwrap(); + store + .add_layer(&other.id, &effect.id, &zone.id, BlendMode::Add, &[].into()) + .await + .unwrap(); + store.load_scene_into_active(&other.id).await.unwrap(); + + let active = store.get_active_layers().await.unwrap(); + assert_eq!(active.len(), 1); + assert!(matches!(active[0].blend_mode, BlendMode::Add)); +} + +#[tokio::test] +async fn load_nonexistent_returns_not_found() { + let store = helpers::in_memory_store().await; + let err = store + .load_scene_into_active("nonexistent") + .await + .unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn overwrite_and_cannot_overwrite_self() { + 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(); + + store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + store + .add_active_layer( + &effect.id, + &zone.id, + BlendMode::Add, + &helpers::make_params(), + ) + .await + .unwrap(); + store + .add_active_layer(&effect.id, &zone.id, BlendMode::Screen, &[].into()) + .await + .unwrap(); + + store.overwrite_scene_from_active(&scene.id).await.unwrap(); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert_eq!(layers.len(), 2); + assert!(matches!(layers[0].blend_mode, BlendMode::Add)); + + let err = store + .overwrite_scene_from_active(store::ACTIVE_SCENE_ID) + .await + .unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} diff --git a/crates/store/tests/effects.rs b/crates/store/tests/effects.rs new file mode 100644 index 0000000..02c5ba9 --- /dev/null +++ b/crates/store/tests/effects.rs @@ -0,0 +1,106 @@ +pub mod helpers; + +use domain::{ParamControl, ParamDef, ParamValue, Rgb}; + +fn sample_params() -> Vec { + vec![ + ParamDef { + name: "speed".to_string(), + label: "Speed".to_string(), + control: ParamControl::Slider { + min: 0.5, + max: 5.0, + step: Some(0.5), + }, + default: ParamValue::Number(1.0), + }, + ParamDef { + name: "color".to_string(), + label: "Color".to_string(), + control: ParamControl::Color, + default: ParamValue::Color(Rgb { r: 255, g: 0, b: 0 }), + }, + ParamDef { + name: "reverse".to_string(), + label: "Reverse".to_string(), + control: ParamControl::Toggle, + default: ParamValue::Bool(false), + }, + ParamDef { + name: "mode".to_string(), + label: "Mode".to_string(), + control: ParamControl::Select { + options: vec!["linear".to_string(), "ease".to_string()], + }, + default: ParamValue::Select("linear".to_string()), + }, + ] +} + +#[tokio::test] +async fn create_with_params_round_trips() { + let store = helpers::in_memory_store().await; + let params = sample_params(); + let effect = store + .create_effect("wave", "let c = primary_color; c", ¶ms) + .await + .unwrap(); + + assert_eq!(effect.name, "wave"); + assert_eq!(effect.script, "let c = primary_color; c"); + assert_eq!(effect.params.len(), 4); + + let fetched = store.get_effect(&effect.id).await.unwrap(); + assert_eq!(fetched.params[0].name, "speed"); + assert!(matches!( + fetched.params[0].control, + ParamControl::Slider { min, max, .. } if (min - 0.5).abs() < f32::EPSILON && (max - 5.0).abs() < f32::EPSILON + )); + assert_eq!(fetched.params[0].default, ParamValue::Number(1.0)); + assert_eq!( + fetched.params[1].default, + ParamValue::Color(Rgb { r: 255, g: 0, b: 0 }) + ); + assert_eq!(fetched.params[2].default, ParamValue::Bool(false)); + assert_eq!( + fetched.params[3].default, + ParamValue::Select("linear".to_string()) + ); +} + +#[tokio::test] +async fn list_effects() { + let store = helpers::in_memory_store().await; + store + .create_effect("a", "#{r:0,g:0,b:0}", &[]) + .await + .unwrap(); + store + .create_effect("b", "#{r:0,g:0,b:0}", &[]) + .await + .unwrap(); + let effects = store.get_effects().await.unwrap(); + assert_eq!(effects.len(), 2); +} + +#[tokio::test] +async fn update_effect() { + let store = helpers::in_memory_store().await; + let effect = store.create_effect("old", "code", &[]).await.unwrap(); + let updated = store + .update_effect(&effect.id, "new", "new_code", &sample_params()) + .await + .unwrap(); + assert_eq!(updated.name, "new"); + assert_eq!(updated.script, "new_code"); + assert_eq!(updated.params.len(), 4); +} + +#[tokio::test] +async fn delete_effect() { + let store = helpers::in_memory_store().await; + let effect = store.create_effect("tmp", "code", &[]).await.unwrap(); + store.delete_effect(&effect.id).await.unwrap(); + let err = store.get_effect(&effect.id).await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} diff --git a/crates/store/tests/helpers.rs b/crates/store/tests/helpers.rs index 1620402..adf4d09 100644 --- a/crates/store/tests/helpers.rs +++ b/crates/store/tests/helpers.rs @@ -1,3 +1,4 @@ +use domain::ParamValue; use sqlx::sqlite::SqlitePoolOptions; use store::Store; @@ -9,3 +10,7 @@ pub async fn in_memory_store() -> Store { .unwrap(); Store::from_pool(pool).await.unwrap() } + +pub fn make_params() -> std::collections::HashMap { + [("speed".to_string(), ParamValue::Number(2.0))].into() +} diff --git a/crates/store/tests/layers.rs b/crates/store/tests/layers.rs new file mode 100644 index 0000000..5473275 --- /dev/null +++ b/crates/store/tests/layers.rs @@ -0,0 +1,164 @@ +pub mod helpers; + +use domain::{BlendMode, ParamValue}; + +#[tokio::test] +async fn add_and_get() { + 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 l0 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &helpers::make_params(), + ) + .await + .unwrap(); + let l1 = store + .add_layer(&scene.id, &effect.id, &zone.id, BlendMode::Add, &[].into()) + .await + .unwrap(); + + assert_eq!(l0.position, 0); + assert_eq!(l1.position, 1); + assert!(matches!(l0.blend_mode, BlendMode::Override)); + assert!(matches!(l1.blend_mode, BlendMode::Add)); + assert_eq!(l0.params["speed"], ParamValue::Number(2.0)); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].id, l0.id); + assert_eq!(layers[1].id, l1.id); +} + +#[tokio::test] +async fn update_layer() { + 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(); + + let updated = store + .update_layer( + &layer.id, + &scene.id, + false, + BlendMode::Screen, + &helpers::make_params(), + ) + .await + .unwrap(); + assert!(!updated.enabled); + assert!(matches!(updated.blend_mode, BlendMode::Screen)); + assert_eq!(updated.params["speed"], ParamValue::Number(2.0)); +} + +#[tokio::test] +async fn remove_layer() { + 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(); + + store.remove_layer(&layer.id, &scene.id).await.unwrap(); + let layers = store.get_layers(&scene.id).await.unwrap(); + assert!(layers.is_empty()); +} + +#[tokio::test] +async fn reorder_layers() { + 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 l0 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + let l1 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + let l2 = store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + + store + .reorder_layers(&scene.id, &[l2.id.clone(), l1.id.clone(), l0.id.clone()]) + .await + .unwrap(); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert_eq!(layers[0].id, l2.id); + assert_eq!(layers[1].id, l1.id); + assert_eq!(layers[2].id, l0.id); +} + +#[tokio::test] +async fn deleting_scene_cascades_to_layers() { + 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(); + + store + .add_layer( + &scene.id, + &effect.id, + &zone.id, + BlendMode::Override, + &[].into(), + ) + .await + .unwrap(); + + store.delete_scene(&scene.id).await.unwrap(); + + let layers = store.get_layers(&scene.id).await.unwrap(); + assert!(layers.is_empty()); +} diff --git a/crates/store/tests/scenes.rs b/crates/store/tests/scenes.rs new file mode 100644 index 0000000..f12214e --- /dev/null +++ b/crates/store/tests/scenes.rs @@ -0,0 +1,44 @@ +pub mod helpers; + +#[tokio::test] +async fn active_excluded_from_list() { + let store = helpers::in_memory_store().await; + let scenes = store.get_scenes().await.unwrap(); + assert!(scenes.iter().all(|s| s.id != store::ACTIVE_SCENE_ID)); +} + +#[tokio::test] +async fn create_and_list() { + let store = helpers::in_memory_store().await; + store.create_scene("night").await.unwrap(); + store.create_scene("party").await.unwrap(); + let scenes = store.get_scenes().await.unwrap(); + assert_eq!(scenes.len(), 2); +} + +#[tokio::test] +async fn update_scene() { + let store = helpers::in_memory_store().await; + let scene = store.create_scene("old").await.unwrap(); + let updated = store.update_scene(&scene.id, "new").await.unwrap(); + assert_eq!(updated.name, "new"); +} + +#[tokio::test] +async fn delete_scene() { + let store = helpers::in_memory_store().await; + let scene = store.create_scene("tmp").await.unwrap(); + store.delete_scene(&scene.id).await.unwrap(); + let err = store.get_scene(&scene.id).await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn cannot_delete_active() { + let store = helpers::in_memory_store().await; + let err = store + .delete_scene(store::ACTIVE_SCENE_ID) + .await + .unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} diff --git a/crates/store/tests/store.rs b/crates/store/tests/store.rs deleted file mode 100644 index 62b9809..0000000 --- a/crates/store/tests/store.rs +++ /dev/null @@ -1,519 +0,0 @@ -mod helpers; - -use domain::{BlendMode, ParamControl, ParamDef, ParamValue, Rgb}; - -#[tokio::test] -async fn create_and_get_zone() { - let store = helpers::in_memory_store().await; - let zone = store.create_zone("living room", 0, 59, 8).await.unwrap(); - assert_eq!(zone.name, "living room"); - assert_eq!(zone.start_pixel, 0); - assert_eq!(zone.end_pixel, 59); - assert_eq!(zone.transition_length, 8); - - let fetched = store.get_zone(&zone.id).await.unwrap(); - assert_eq!(fetched.id, zone.id); - assert_eq!(fetched.name, "living room"); -} - -#[tokio::test] -async fn list_zones() { - let store = helpers::in_memory_store().await; - store.create_zone("a", 0, 10, 4).await.unwrap(); - store.create_zone("b", 11, 20, 4).await.unwrap(); - let zones = store.get_zones().await.unwrap(); - assert_eq!(zones.len(), 2); -} - -#[tokio::test] -async fn update_zone() { - let store = helpers::in_memory_store().await; - let zone = store.create_zone("old", 0, 10, 8).await.unwrap(); - let updated = store.update_zone(&zone.id, "new", 5, 50, 16).await.unwrap(); - assert_eq!(updated.name, "new"); - assert_eq!(updated.start_pixel, 5); - assert_eq!(updated.end_pixel, 50); - assert_eq!(updated.transition_length, 16); -} - -#[tokio::test] -async fn delete_zone() { - let store = helpers::in_memory_store().await; - let zone = store.create_zone("tmp", 0, 10, 8).await.unwrap(); - store.delete_zone(&zone.id).await.unwrap(); - let err = store.get_zone(&zone.id).await.unwrap_err(); - assert!(matches!(err, store::StoreError::NotFound)); -} - -#[tokio::test] -async fn zone_not_found() { - let store = helpers::in_memory_store().await; - let err = store.get_zone("nonexistent").await.unwrap_err(); - assert!(matches!(err, store::StoreError::NotFound)); -} - -fn sample_params() -> Vec { - vec![ - ParamDef { - name: "speed".to_string(), - label: "Speed".to_string(), - control: ParamControl::Slider { - min: 0.5, - max: 5.0, - step: Some(0.5), - }, - default: ParamValue::Number(1.0), - }, - ParamDef { - name: "color".to_string(), - label: "Color".to_string(), - control: ParamControl::Color, - default: ParamValue::Color(Rgb { r: 255, g: 0, b: 0 }), - }, - ParamDef { - name: "reverse".to_string(), - label: "Reverse".to_string(), - control: ParamControl::Toggle, - default: ParamValue::Bool(false), - }, - ParamDef { - name: "mode".to_string(), - label: "Mode".to_string(), - control: ParamControl::Select { - options: vec!["linear".to_string(), "ease".to_string()], - }, - default: ParamValue::Select("linear".to_string()), - }, - ] -} - -#[tokio::test] -async fn create_effect_with_params_round_trips() { - let store = helpers::in_memory_store().await; - let params = sample_params(); - let effect = store - .create_effect("wave", "let c = primary_color; c", ¶ms) - .await - .unwrap(); - - assert_eq!(effect.name, "wave"); - assert_eq!(effect.script, "let c = primary_color; c"); - assert_eq!(effect.params.len(), 4); - - let fetched = store.get_effect(&effect.id).await.unwrap(); - assert_eq!(fetched.params[0].name, "speed"); - assert!(matches!( - fetched.params[0].control, - ParamControl::Slider { min, max, .. } if (min - 0.5).abs() < f32::EPSILON && (max - 5.0).abs() < f32::EPSILON - )); - assert_eq!(fetched.params[0].default, ParamValue::Number(1.0)); - assert_eq!( - fetched.params[1].default, - ParamValue::Color(Rgb { r: 255, g: 0, b: 0 }) - ); - assert_eq!(fetched.params[2].default, ParamValue::Bool(false)); - assert_eq!( - fetched.params[3].default, - ParamValue::Select("linear".to_string()) - ); -} - -#[tokio::test] -async fn list_effects() { - let store = helpers::in_memory_store().await; - store - .create_effect("a", "#{r:0,g:0,b:0}", &[]) - .await - .unwrap(); - store - .create_effect("b", "#{r:0,g:0,b:0}", &[]) - .await - .unwrap(); - let effects = store.get_effects().await.unwrap(); - assert_eq!(effects.len(), 2); -} - -#[tokio::test] -async fn update_effect() { - let store = helpers::in_memory_store().await; - let effect = store.create_effect("old", "code", &[]).await.unwrap(); - let updated = store - .update_effect(&effect.id, "new", "new_code", &sample_params()) - .await - .unwrap(); - assert_eq!(updated.name, "new"); - assert_eq!(updated.script, "new_code"); - assert_eq!(updated.params.len(), 4); -} - -#[tokio::test] -async fn delete_effect() { - let store = helpers::in_memory_store().await; - let effect = store.create_effect("tmp", "code", &[]).await.unwrap(); - store.delete_effect(&effect.id).await.unwrap(); - let err = store.get_effect(&effect.id).await.unwrap_err(); - assert!(matches!(err, store::StoreError::NotFound)); -} - -#[tokio::test] -async fn active_scene_excluded_from_list() { - let store = helpers::in_memory_store().await; - let scenes = store.get_scenes().await.unwrap(); - assert!(scenes.iter().all(|s| s.id != store::ACTIVE_SCENE_ID)); -} - -#[tokio::test] -async fn create_and_list_scenes() { - let store = helpers::in_memory_store().await; - store.create_scene("night").await.unwrap(); - store.create_scene("party").await.unwrap(); - let scenes = store.get_scenes().await.unwrap(); - assert_eq!(scenes.len(), 2); -} - -#[tokio::test] -async fn update_scene() { - let store = helpers::in_memory_store().await; - let scene = store.create_scene("old").await.unwrap(); - let updated = store.update_scene(&scene.id, "new").await.unwrap(); - assert_eq!(updated.name, "new"); -} - -#[tokio::test] -async fn delete_scene() { - let store = helpers::in_memory_store().await; - let scene = store.create_scene("tmp").await.unwrap(); - store.delete_scene(&scene.id).await.unwrap(); - let err = store.get_scene(&scene.id).await.unwrap_err(); - assert!(matches!(err, store::StoreError::NotFound)); -} - -#[tokio::test] -async fn cannot_delete_active_scene() { - let store = helpers::in_memory_store().await; - let err = store - .delete_scene(store::ACTIVE_SCENE_ID) - .await - .unwrap_err(); - assert!(matches!(err, store::StoreError::NotFound)); -} - -fn make_params() -> std::collections::HashMap { - [("speed".to_string(), ParamValue::Number(2.0))].into() -} - -#[tokio::test] -async fn add_and_get_layers() { - 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 l0 = store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Override, - &make_params(), - ) - .await - .unwrap(); - let l1 = store - .add_layer(&scene.id, &effect.id, &zone.id, BlendMode::Add, &[].into()) - .await - .unwrap(); - - assert_eq!(l0.position, 0); - assert_eq!(l1.position, 1); - assert!(matches!(l0.blend_mode, BlendMode::Override)); - assert!(matches!(l1.blend_mode, BlendMode::Add)); - assert_eq!(l0.params["speed"], ParamValue::Number(2.0)); - - let layers = store.get_layers(&scene.id).await.unwrap(); - assert_eq!(layers.len(), 2); - assert_eq!(layers[0].id, l0.id); - assert_eq!(layers[1].id, l1.id); -} - -#[tokio::test] -async fn update_layer() { - 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(); - - let updated = store - .update_layer( - &layer.id, - &scene.id, - false, - BlendMode::Screen, - &make_params(), - ) - .await - .unwrap(); - assert!(!updated.enabled); - assert!(matches!(updated.blend_mode, BlendMode::Screen)); - assert_eq!(updated.params["speed"], ParamValue::Number(2.0)); -} - -#[tokio::test] -async fn reorder_layers() { - 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 l0 = store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - let l1 = store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - let l2 = store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - - // Reverse the order - store - .reorder_layers(&scene.id, &[l2.id.clone(), l1.id.clone(), l0.id.clone()]) - .await - .unwrap(); - - let layers = store.get_layers(&scene.id).await.unwrap(); - assert_eq!(layers[0].id, l2.id); - assert_eq!(layers[1].id, l1.id); - assert_eq!(layers[2].id, l0.id); -} - -#[tokio::test] -async fn remove_layer() { - 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(); - - store.remove_layer(&layer.id, &scene.id).await.unwrap(); - let layers = store.get_layers(&scene.id).await.unwrap(); - assert!(layers.is_empty()); -} - -#[tokio::test] -async fn save_active_creates_named_scene() { - 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 - .add_layer( - store::ACTIVE_SCENE_ID, - &effect.id, - &zone.id, - BlendMode::Override, - &make_params(), - ) - .await - .unwrap(); - - let saved = store.save_active_as_scene("my scene").await.unwrap(); - assert_eq!(saved.name, "my scene"); - - let layers = store.get_layers(&saved.id).await.unwrap(); - assert_eq!(layers.len(), 1); - assert_eq!(layers[0].params["speed"], ParamValue::Number(2.0)); -} - -#[tokio::test] -async fn load_scene_replaces_active() { - 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(); - - store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Add, - &make_params(), - ) - .await - .unwrap(); - - // Put something in active first to verify it gets replaced - store - .add_layer( - store::ACTIVE_SCENE_ID, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - - store.load_scene_into_active(&scene.id).await.unwrap(); - - let active = store.get_active_layers().await.unwrap(); - assert_eq!(active.len(), 1); - assert!(matches!(active[0].blend_mode, BlendMode::Add)); - assert_eq!(active[0].params["speed"], ParamValue::Number(2.0)); -} - -#[tokio::test] -async fn clear_active_removes_all_layers() { - 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 - .add_layer( - store::ACTIVE_SCENE_ID, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - store - .add_layer( - store::ACTIVE_SCENE_ID, - &effect.id, - &zone.id, - BlendMode::Add, - &[].into(), - ) - .await - .unwrap(); - - store.clear_active_scene().await.unwrap(); - let active = store.get_active_layers().await.unwrap(); - assert!(active.is_empty()); -} - -#[tokio::test] -async fn overwrite_scene_from_active() { - 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(); - - // Scene has one layer - store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - - // Active has two layers with custom params - store - .add_layer( - store::ACTIVE_SCENE_ID, - &effect.id, - &zone.id, - BlendMode::Add, - &make_params(), - ) - .await - .unwrap(); - store - .add_layer( - store::ACTIVE_SCENE_ID, - &effect.id, - &zone.id, - BlendMode::Screen, - &[].into(), - ) - .await - .unwrap(); - - store.overwrite_scene_from_active(&scene.id).await.unwrap(); - - let layers = store.get_layers(&scene.id).await.unwrap(); - assert_eq!(layers.len(), 2); - assert!(matches!(layers[0].blend_mode, BlendMode::Add)); -} - -#[tokio::test] -async fn cannot_overwrite_active_with_itself() { - let store = helpers::in_memory_store().await; - let err = store - .overwrite_scene_from_active(store::ACTIVE_SCENE_ID) - .await - .unwrap_err(); - assert!(matches!(err, store::StoreError::NotFound)); -} - -#[tokio::test] -async fn deleting_scene_cascades_to_layers() { - 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(); - - store - .add_layer( - &scene.id, - &effect.id, - &zone.id, - BlendMode::Override, - &[].into(), - ) - .await - .unwrap(); - - store.delete_scene(&scene.id).await.unwrap(); - - // After scene deleted, layers should be gone (cascade) - let layers = store.get_layers(&scene.id).await.unwrap(); - assert!(layers.is_empty()); -} diff --git a/crates/store/tests/zones.rs b/crates/store/tests/zones.rs new file mode 100644 index 0000000..7db83f3 --- /dev/null +++ b/crates/store/tests/zones.rs @@ -0,0 +1,51 @@ +pub mod helpers; + +#[tokio::test] +async fn create_and_get_zone() { + let store = helpers::in_memory_store().await; + let zone = store.create_zone("living room", 0, 59, 8).await.unwrap(); + assert_eq!(zone.name, "living room"); + assert_eq!(zone.start_pixel, 0); + assert_eq!(zone.end_pixel, 59); + assert_eq!(zone.transition_length, 8); + + let fetched = store.get_zone(&zone.id).await.unwrap(); + assert_eq!(fetched.id, zone.id); + assert_eq!(fetched.name, "living room"); +} + +#[tokio::test] +async fn list_zones() { + let store = helpers::in_memory_store().await; + store.create_zone("a", 0, 10, 4).await.unwrap(); + store.create_zone("b", 11, 20, 4).await.unwrap(); + let zones = store.get_zones().await.unwrap(); + assert_eq!(zones.len(), 2); +} + +#[tokio::test] +async fn update_zone() { + let store = helpers::in_memory_store().await; + let zone = store.create_zone("old", 0, 10, 8).await.unwrap(); + let updated = store.update_zone(&zone.id, "new", 5, 50, 16).await.unwrap(); + assert_eq!(updated.name, "new"); + assert_eq!(updated.start_pixel, 5); + assert_eq!(updated.end_pixel, 50); + assert_eq!(updated.transition_length, 16); +} + +#[tokio::test] +async fn delete_zone() { + let store = helpers::in_memory_store().await; + let zone = store.create_zone("tmp", 0, 10, 8).await.unwrap(); + store.delete_zone(&zone.id).await.unwrap(); + let err = store.get_zone(&zone.id).await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} + +#[tokio::test] +async fn get_nonexistent_returns_404() { + let store = helpers::in_memory_store().await; + let err = store.get_zone("nonexistent").await.unwrap_err(); + assert!(matches!(err, store::StoreError::NotFound)); +} From 97e313813daeb443564dd2cdff7f7977586f43be Mon Sep 17 00:00:00 2001 From: anders130 <93037023+anders130@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:44:43 +0100 Subject: [PATCH 16/16] fix(store): move ACTIVE_SCENE_ID to scene/mod.rs and clean up get_all query --- crates/store/src/scene/active.rs | 16 ++++++------ crates/store/src/scene/layers.rs | 42 +++++++++++++++++++------------- crates/store/src/scene/mod.rs | 6 ++--- crates/store/src/scene/scenes.rs | 15 ++++++------ 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/crates/store/src/scene/active.rs b/crates/store/src/scene/active.rs index 181b828..dcdb948 100644 --- a/crates/store/src/scene/active.rs +++ b/crates/store/src/scene/active.rs @@ -6,8 +6,6 @@ use crate::StoreError; use super::layers::{NewLayer, blend_mode_to_str, clear_layers, copy_layers}; use super::scenes::{SceneRecord, create, get_one}; -pub const ACTIVE_SCENE_ID: &str = "__active__"; - pub async fn replace_active_layers( pool: &SqlitePool, layers: &[NewLayer], @@ -25,7 +23,7 @@ pub async fn replace_active_layers( let mut tx = pool.begin().await?; sqlx::query("DELETE FROM scene_layers WHERE scene_id = ?") - .bind(ACTIVE_SCENE_ID) + .bind(super::ACTIVE_SCENE_ID) .execute(&mut *tx) .await?; @@ -35,7 +33,7 @@ pub async fn replace_active_layers( ) .push_values(&entries, |mut b, (id, layer, params, pos)| { b.push_bind(id.as_str()) - .push_bind(ACTIVE_SCENE_ID) + .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)) @@ -54,21 +52,21 @@ pub async fn replace_active_layers( pub async fn load_into_active(pool: &SqlitePool, scene_id: &str) -> Result<(), StoreError> { get_one(pool, scene_id).await?; - clear_layers(pool, ACTIVE_SCENE_ID).await?; - copy_layers(pool, scene_id, ACTIVE_SCENE_ID).await + clear_layers(pool, super::ACTIVE_SCENE_ID).await?; + copy_layers(pool, scene_id, super::ACTIVE_SCENE_ID).await } pub async fn save_active_as(pool: &SqlitePool, name: &str) -> Result { let scene = create(pool, name).await?; - copy_layers(pool, ACTIVE_SCENE_ID, &scene.id).await?; + copy_layers(pool, super::ACTIVE_SCENE_ID, &scene.id).await?; Ok(scene) } pub async fn overwrite_from_active(pool: &SqlitePool, id: &str) -> Result<(), StoreError> { - if id == ACTIVE_SCENE_ID { + if id == super::ACTIVE_SCENE_ID { return Err(StoreError::NotFound); } clear_layers(pool, id).await?; - copy_layers(pool, ACTIVE_SCENE_ID, id).await?; + copy_layers(pool, super::ACTIVE_SCENE_ID, id).await?; Ok(()) } diff --git a/crates/store/src/scene/layers.rs b/crates/store/src/scene/layers.rs index c16ac86..0ee1a4d 100644 --- a/crates/store/src/scene/layers.rs +++ b/crates/store/src/scene/layers.rs @@ -224,23 +224,31 @@ pub(super) async fn copy_layers( to_scene_id: &str, ) -> Result<(), StoreError> { let layers = get_layers(pool, from_scene_id).await?; - for layer in layers { - let params_json = serde_json::to_string(&layer.params)?; - let new_id = Uuid::new_v4().to_string(); - sqlx::query( - "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(&new_id) - .bind(to_scene_id) - .bind(&layer.effect_id) - .bind(&layer.zone_id) - .bind(blend_mode_to_str(layer.blend_mode)) - .bind(¶ms_json) - .bind(layer.enabled as i64) - .bind(layer.position as i64) - .execute(pool) - .await?; + if layers.is_empty() { + return Ok(()); } + + let entries = layers + .iter() + .map(|l| serde_json::to_string(&l.params).map(|p| (Uuid::new_v4().to_string(), l, p))) + .collect::, _>>()?; + + sqlx::QueryBuilder::new( + "INSERT INTO scene_layers (id, scene_id, effect_id, zone_id, blend_mode, params, enabled, position) ", + ) + .push_values(&entries, |mut b, (id, layer, params)| { + b.push_bind(id.as_str()) + .push_bind(to_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(layer.position as i64); + }) + .build() + .execute(pool) + .await?; + Ok(()) } diff --git a/crates/store/src/scene/mod.rs b/crates/store/src/scene/mod.rs index 34c6aea..6249d0f 100644 --- a/crates/store/src/scene/mod.rs +++ b/crates/store/src/scene/mod.rs @@ -2,9 +2,9 @@ mod active; mod layers; mod scenes; -pub use active::{ - ACTIVE_SCENE_ID, load_into_active, overwrite_from_active, replace_active_layers, save_active_as, -}; +pub const ACTIVE_SCENE_ID: &str = "__active__"; + +pub use active::{load_into_active, overwrite_from_active, replace_active_layers, 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, diff --git a/crates/store/src/scene/scenes.rs b/crates/store/src/scene/scenes.rs index fab2d8d..190f8eb 100644 --- a/crates/store/src/scene/scenes.rs +++ b/crates/store/src/scene/scenes.rs @@ -16,12 +16,11 @@ pub(super) struct SceneRow { } pub async fn get_all(pool: &SqlitePool) -> Result, StoreError> { - let rows: Vec = sqlx::query_as::<_, SceneRow>( - "SELECT id, name FROM scenes WHERE id != ? AND name IS NOT NULL ORDER BY name", - ) - .bind(super::active::ACTIVE_SCENE_ID) - .fetch_all(pool) - .await?; + let rows: Vec = + sqlx::query_as::<_, SceneRow>("SELECT id, name FROM scenes WHERE id != ? ORDER BY name") + .bind(super::ACTIVE_SCENE_ID) + .fetch_all(pool) + .await?; Ok(rows .into_iter() .map(|r| SceneRecord { @@ -58,7 +57,7 @@ pub async fn update(pool: &SqlitePool, id: &str, name: &str) -> Result Result Result<(), StoreError> { let rows_affected = sqlx::query("DELETE FROM scenes WHERE id = ? AND id != ?") .bind(id) - .bind(super::active::ACTIVE_SCENE_ID) + .bind(super::ACTIVE_SCENE_ID) .execute(pool) .await? .rows_affected();