From 10b86c9972bcc2b5e9c2d033e5f4501b4da8d090 Mon Sep 17 00:00:00 2001 From: ewowi Date: Thu, 3 Sep 2026 13:01:41 +0200 Subject: [PATCH 1/3] Add MoonLive palettes and perceptual brightness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palettes can now be scripts: a .mlp recomputes its sixteen entries every frame, so a palette can follow audio or drift where a gradient stop list is frozen. They are chosen from the same picker as the sixty built-ins. Brightness now follows the CIE 1931 lightness curve, so a slider at half reads as half. Core - /api/scripts served only three of the five script roles, so services and palettes reached no picker and could never be downloaded. Light domain - MoonLivePalette: the fifth MoonLive binding, filling a scratch palette per frame and swapping it whole, so a sampler sees old or new, never both. - Scripted palettes sort LAST, after the built-ins: a palette selection is an index that is persisted, rides seg[0].pal over the WLED API, and is rendered positionally by Home Assistant, so only the scripted tail may renumber. - LivePalettes references its publisher's arrays rather than copying them, and publication moved from defineControls() to prepare(). /api/modules builds a throwaway module, runs defineControls() and destroys it; that probe took the seam and died holding it, so /api/state read freed memory and crashed (SIGSEGV in strlen) on any device carrying .mlp files. clear() now takes the caller's array, so a departing publisher can only ever unpublish itself. - The palette control is sized from the instance's own scan, not the seam: seam-sized, it capped at the built-ins and silently rejected every scripted index. - Correction: white balance and brightness stay linear gains, the perceptual curve applies last before quantize (Cie, Gamma22, Gamma28, Linear). UI - The palette dropdown is now the shared picker: search, emoji chips and the gradient swatch column, rather than a second hand-rolled list. Catalog palettes the device does not hold yet appear alongside the local ones and download on select, the contract every other MoonLive role already had; their swatch is a dashed placeholder, because a script has no colors until it runs. Tests - Three regressions pin the seam: a departing publisher cannot unpublish its successor, an unconditional clear detaches whoever owns it, and a probe module leaves the seam exactly as it found it. Docs/CI - Desktop first is now a rule in CLAUDE.md: verify on the desktop before any ESP32 build or flash. Two portability bugs here were invisible to clang and fatal under the ESP32 toolchain (a uint32_t callback parameter spelled size_t, and a %s snprintf GCC cannot prove fits), so the palette feature had never compiled for any device. Gates: esp32 and esp32s3-n16r8 rebuilt; esp32s3-n8r8 and esp32s31 binaries NOT rebuilt (stopped at the product owner's request to commit) — the next commit covers the download path and rebuilds all four. Improv smoke test not run: the diff does not touch the provisioning path. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 + CMakeLists.txt | 3 +- .../Plan-20260903 - MoonLive palettes.md | 136 +++++++++++ moonlive/palettes/beat-flash.mlp | 26 +++ moonlive/palettes/drift.mlp | 20 ++ moonlive/palettes/fire.mlp | 33 +++ moonlive/palettes/spectrum.mlp | 22 ++ moonlive/palettes/temperature.mlp | 37 +++ src/core/HttpServerModule.cpp | 6 + src/light/Palette.h | 177 +++++++++++--- src/light/drivers/Correction.h | 102 +++++++-- src/light/drivers/DriverBase.h | 20 +- src/light/drivers/Drivers.h | 124 +++++++++- src/light/layers/Effects.h | 6 + src/light/moonlive/MoonLiveBuiltins_light.h | 61 ++++- src/light/moonlive/MoonLivePalette.h | 108 +++++++++ src/light/moonlive/MoonLiveScriptFile.h | 29 ++- src/light/moonlive/catalog_scripts.cmake | 3 +- src/light/moonlive/catalog_scripts.py | 6 +- src/light/moonlive/script_catalog.h | 31 ++- src/ui/app.js | 215 ++++++++++++------ src/ui/style.css | 22 ++ test/CMakeLists.txt | 1 + .../scenario_MoonModule_control_change.json | 16 +- .../light/scenario_Audio_mutation.json | 34 +-- .../light/scenario_Driver_mutation.json | 22 +- .../light/scenario_Effects_composition.json | 8 +- .../light/scenario_GridBlacks_blackpixel.json | 10 +- .../light/scenario_GridLayout_resize.json | 12 +- .../light/scenario_Layer_base_pipeline.json | 6 +- .../light/scenario_Layer_memory_1to1.json | 4 +- .../light/scenario_Layouts_mutation.json | 24 +- .../scenario_MoonLiveEffect_livescript.json | 54 ++--- .../light/scenario_MoonLive_pipeline.json | 24 +- .../scenario_MultiplyModifier_memory_lut.json | 4 +- .../scenario_MultiplyModifier_pipeline.json | 4 +- .../light/scenario_modifier_chain.json | 26 +-- .../light/scenario_modifier_swap.json | 18 +- test/scenarios/light/scenario_perf_full.json | 120 +++++----- test/scenarios/light/scenario_perf_light.json | 36 +-- .../light/scenario_peripheral_grid_sweep.json | 96 ++++---- .../light/scenario_peripheral_switch.json | 36 +-- test/unit/light/correction_presets.h | 5 + test/unit/light/unit_Correction.cpp | 105 +++++++++ test/unit/light/unit_Drivers_container.cpp | 29 +++ test/unit/light/unit_MoonLivePalette.cpp | 149 ++++++++++++ test/unit/light/unit_MoonLiveScripts.cpp | 8 +- test/unit/light/unit_Palette.cpp | 32 +++ 48 files changed, 1662 insertions(+), 413 deletions(-) create mode 100644 docs/history/plans/Plan-20260903 - MoonLive palettes.md create mode 100644 moonlive/palettes/beat-flash.mlp create mode 100644 moonlive/palettes/drift.mlp create mode 100644 moonlive/palettes/fire.mlp create mode 100644 moonlive/palettes/spectrum.mlp create mode 100644 moonlive/palettes/temperature.mlp create mode 100644 src/light/moonlive/MoonLivePalette.h create mode 100644 test/unit/light/unit_MoonLivePalette.cpp diff --git a/CLAUDE.md b/CLAUDE.md index a53be6ca..56d5501c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,11 @@ The product owner commits. **Delegate the mechanical roles**: parallelizable or **Anti-stalling.** If a build error or test failure survives 2 fix attempts: STOP. Ask, or roll back and re-approach (rolling back is itself a revert: ask). +**Desktop first, always.** Build and verify on the desktop before any ESP32 build or flash: it is +the fastest loop, and anything the desktop can prove (UI, logic, tests) is proven there rather than +through a multi-minute compile and a 60-second flash. A device build comes after the desktop is +clean, and only for what the desktop cannot show: the platform layer, timing, memory, real hardware. + **Bench boards are free test rigs.** Build and flash freely to verify work; re-probe ports first. A *rigorous* change (anything that could brick, boot-loop, or wipe a board: flash erases, boot/partition/build-config changes, a first flash of an untested board) gets a one-sentence heads-up and a go-ahead first — the test is reversibility. **Invite the product owner to test, then STOP.** If the PO could see or judge the result, hand it over ("running on X, look at Y") and wait for their observation before concluding, documenting, or moving on. Leave the state running; don't revert, reflash, or reconfigure what they were about to look at. diff --git a/CMakeLists.txt b/CMakeLists.txt index a9648e93..160c40d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -211,7 +211,8 @@ file(GLOB MOONLIVE_SCRIPTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/moonlive/effects/*.mle ${CMAKE_SOURCE_DIR}/moonlive/layouts/*.mll ${CMAKE_SOURCE_DIR}/moonlive/modifiers/*.mlm - ${CMAKE_SOURCE_DIR}/moonlive/services/*.mls) + ${CMAKE_SOURCE_DIR}/moonlive/services/*.mls + ${CMAKE_SOURCE_DIR}/moonlive/palettes/*.mlp) # The file LIST itself is a dependency, not just each file's timestamp: DEPENDS notices an edited # script but not a DELETED one, so removing a script left it in the catalog and the device went on # offering a name that no longer exists upstream. The stamp is written at configure time from the diff --git a/docs/history/plans/Plan-20260903 - MoonLive palettes.md b/docs/history/plans/Plan-20260903 - MoonLive palettes.md new file mode 100644 index 00000000..f848031c --- /dev/null +++ b/docs/history/plans/Plan-20260903 - MoonLive palettes.md @@ -0,0 +1,136 @@ +# Plan: MoonLive palettes (`.mlp`) + +## What this is + +A palette that is CODE rather than data. A `.mlp` script fills the 16 active palette entries once +per frame, so a palette can respond to audio, drift over time, or compute from an algorithm. +A gradient stop list cannot express any of that: it is frozen the moment it is saved. + +The idea is MoonLight's, and it is a good one. Their implementation is not the part to copy. + +## Why our engine makes this cheap, where MoonLight's does not + +MoonLight runs a scripted palette through hpwit's LiveScript in a **separate FreeRTOS task**, at +8 KB of stack per script plus an on-device compiler, which is why their live scripts are documented +as needing a PSRAM-class board. Changing sixteen colors should not cost that. + +We already JIT to native code and run every binding INLINE on the render thread. A `.mlp` is the +fifth binding beside effect (`.mle`), layout (`.mll`), modifier (`.mlm`) and service (`.mls`): the +same `MoonLiveScript` member, the same `sync()` on prepare, the same file picker. The pattern is +established four times over, so this adds a role rather than a mechanism. + +## Cost, which is what makes the design work + +A palette is **16 entries, once per frame**. Not per light. A 16x16 grid runs an effect body 256 +times a frame; a `.mlp` runs 16 iterations of a loop, whatever the rig size. So the hot-path cost is +independent of light count, which is the property that makes running it in the render path +reasonable at all. + +Measure it anyway on the classic ESP32, where the margin is thinnest: the acceptance number is that +a scripted palette costs less than a scripted EFFECT on the same board, since the effect is the +thing already considered affordable. + +## The shape + +```c +class Fire { + byte speed = 40; + + void defineControls() { + addControl("speed", speed, 1, 120); // how fast the fire breathes + } + + void tick() { + for (int i = 0; i < 16; i = i + 1) { + int heat = beatsin(speed, t + i * 16, 255); + setPalEntryHSV(i, div(heat, 8), 255, heat); + } + } +} +``` + +- **Entry point**: `tick`, the same name an effect uses. A palette and an effect are both + per-frame producers, so the name means the same thing in both, and a script author moving between + them learns nothing new. +- **Two new builtins**: `setPalEntry(i, r, g, b)` and `setPalEntryHSV(i, h, s, v)`. Both are + MoonLight's names, kept deliberately: a MoonLight user's palette script should read as familiar. + Index bounded to 0..15, out of range ignored, so a script cannot write past the palette. +- **Everything else it already has**: `beat`, `beatsin`, `noise`, `sin`, `random16`, and crucially + `audioBand()` / `audioBeat()`. The audio vocabulary is where "a palette that reacts" actually + lands, and it costs nothing to expose because the common builtins are already shared. +- **`addControl` works**, as it does in every other binding, so a scripted palette is configurable + from its card without editing the source. + +## Where it plugs in + +`Palettes::active_` is already a single 48-byte global (`Palette`, 16 x RGB) that every effect +samples through `colorFromPalette()`. `setActiveDirect()` already exists. So a `.mlp` fills those +48 bytes where `fromBuiltin()` otherwise would: **no effect changes, and the sampling path does not +change at all.** + +The `palette` control on `Drivers` gains the discovered `.mlp` files after the built-ins, exactly as +the effect list already appends scripted effects. One selector, one mental model, and a scripted +palette is picked the same way a built-in is. + +## The three decisions, and their answers + +**1. Tearing.** Today `Palettes::setActive()` runs on a control change, so it never overlaps a +frame. A per-frame script does. The script therefore fills a SCRATCH `Palette` and the 48 bytes are +assigned once at the end, so an effect samples either the old palette or the new one, never half of +each. Cheap, and it removes the whole class of problem rather than narrowing the window. + +**2. Where the per-frame call sits.** Ahead of the effect pass, so every effect in the frame sees +the same palette. `Drivers::tick()` owns the palette control today, which makes it the honest owner +of the per-frame refresh too. + +**3. Ordering against the perceptual curve.** A `.mlp` writes entries in LINEAR light; the CIE curve +is applied downstream in the driver's output LUT. This is already correct and needs no code, but it +belongs in the docs: a palette author who pre-compensates would double-correct, which is the same +trap the curve work just documented one layer down. + +## Steps + +1. **The role**: `.mlp` extension, `kPaletteExt`, `kPalettePick`, a template, and the catalog glob. + Four globs need it (CMakeLists, `catalog_scripts.py` x2, `catalog_scripts.cmake`), which is the + step that was missed when `.mls` was added, so it is called out here rather than discovered. +2. **The builtins**: `setPalEntry` / `setPalEntryHSV` in the light table, writing through a sink + installed for exactly one `run()`, the same bracket `MoonLiveModifier` uses for its coordinate + sink. A script cannot reach the palette outside its own tick. +3. **The binding**: `MoonLivePalette`, holding a `MoonLiveScript` and filling a scratch `Palette`. +4. **The wiring**: the `palette` select gains the `.mlp` files; `Drivers::tick()` runs the active + one per frame; the scratch is assigned into `Palettes::active_`. +5. **Two shipped scripts**, because a feature with no example is a feature nobody finds: one + algorithmic (a drifting fire) and one AUDIO-REACTIVE, which is the case that justifies the whole + design over a stop list. + +## Tests + +- Unit: a `.mlp` fills all 16 entries; an out-of-range index writes nothing; a script with no `tick` + leaves the palette untouched; a broken script leaves the LAST GOOD palette rather than black + (the same degrade-visibly rule the other bindings follow). +- Scenario: a scripted palette drives a real effect end to end, which is the integration the unit + tests cannot reach. +- Bench: the audio-reactive palette on a board with a mic, which is the only way to judge whether + the idea actually looks good. +- Cost: the per-frame tick measured on a classic ESP32 against a scripted effect on the same rig. + +## Backlogged from the build + +**A swatch for every picker row, not just palettes.** The shared picker learned an optional swatch +column for palettes (a row carrying `colors` paints a gradient; every other list is untouched +because it carries none). The same column could preview an EFFECT or a script, which would make the +picker readable at a glance rather than a list of names. + +What to draw is the open question, and it is why this is backlogged rather than built: an effect has +no single color, and a still frame of a moving effect may be its least representative moment. The +candidates worth trying are a strip of the effect's first rendered row, its palette usage, or a +tiny animated preview once the picker can afford one. Worth prototyping against real effects before +committing to any of them, because the wrong preview is worse than no preview. + +## Not in this plan + +- **Crossfading between palettes.** WLED blends over a transition and it is the visible quality + difference, but it is orthogonal: it improves built-in palette CHANGES, scripted or not, and + belongs in its own change. +- **A stop-list editor.** WLED's `cpal.htm` is a good visual editor for FROZEN palettes. It solves a + different problem from this one and is worth its own decision later. diff --git a/moonlive/palettes/beat-flash.mlp b/moonlive/palettes/beat-flash.mlp new file mode 100644 index 00000000..71f93f0f --- /dev/null +++ b/moonlive/palettes/beat-flash.mlp @@ -0,0 +1,26 @@ +// Beat flash: the palette sits in a deep color and jumps to white on every drum hit, so any effect +// using it pulses with the room. Decays between beats rather than snapping back. + +class BeatFlash { + byte hue = 160; + byte decay = 24; + int level = 0; + + string tags() { return "🎨🎶"; } + + void defineControls() { + addControl("hue", hue, 0, 255); // the resting color + addControl("decay", decay, 1, 80); // how fast the flash falls away + } + + void tick() { + if (audioBeat() > 0) { level = 255; } + if (level > decay) { level = level - decay; } + else { level = 0; } + + for (int i = 0; i < 16; i = i + 1) { + int sat = 255 - level; + setPalEntryHSV(i, hue + i * 4, sat, 128 + div(level, 2)); + } + } +} diff --git a/moonlive/palettes/drift.mlp b/moonlive/palettes/drift.mlp new file mode 100644 index 00000000..879b888a --- /dev/null +++ b/moonlive/palettes/drift.mlp @@ -0,0 +1,20 @@ +// Drift: the whole palette walks the color wheel, so every effect using it slowly changes hue. + +class Drift { + byte bpm = 4; + byte spread = 16; + + string tags() { return "🎨"; } + + void defineControls() { + addControl("bpm", bpm, 1, 60); // how fast the wheel turns + addControl("spread", spread, 1, 64); // hue distance between entries + } + + void tick() { + int base = scale(beat(bpm, t), 256); + for (int i = 0; i < 16; i = i + 1) { + setPalEntryHSV(i, base + i * spread, 255, 255); + } + } +} diff --git a/moonlive/palettes/fire.mlp b/moonlive/palettes/fire.mlp new file mode 100644 index 00000000..1326e1ec --- /dev/null +++ b/moonlive/palettes/fire.mlp @@ -0,0 +1,33 @@ +// Fire: a heat ramp from black through red and orange into white, breathing as it burns. + +class Fire { + byte bpm = 12; + byte coolest = 40; + + string tags() { return "🎨"; } + + void defineControls() { + addControl("bpm", bpm, 1, 60); // how fast the fire breathes + addControl("coolest", coolest, 0, 128); // how dark the coldest entry falls + } + + void tick() { + int breath = beatsin(bpm, t, 60); + for (int i = 0; i < 16; i = i + 1) { + int heat = div(i * 255, 15) + breath - 30; + if (heat < 0) { heat = 0; } + if (heat > 255) { heat = 255; } + if (heat < coolest) { heat = 0; } + + int red = heat * 3; + if (red > 255) { red = 255; } + int green = 0; + if (heat > 85) { green = (heat - 85) * 3; } + if (green > 255) { green = 255; } + int blue = 0; + if (heat > 170) { blue = (heat - 170) * 3; } + + setPalEntry(i, red, green, blue); + } + } +} diff --git a/moonlive/palettes/spectrum.mlp b/moonlive/palettes/spectrum.mlp new file mode 100644 index 00000000..7f00d0c1 --- /dev/null +++ b/moonlive/palettes/spectrum.mlp @@ -0,0 +1,22 @@ +// Spectrum: the palette IS the room. Each entry takes its brightness from one frequency band, so +// every effect using this palette reacts to the music without knowing anything about audio. + +class Spectrum { + byte gain = 100; + byte hue = 0; + + string tags() { return "🎨🎶"; } + + void defineControls() { + addControl("gain", gain, 10, 255); // how hard the bands drive the palette + addControl("hue", hue, 0, 255); // where in the wheel the colors sit + } + + void tick() { + for (int i = 0; i < 16; i = i + 1) { + int mag = div(audioBand(i) * gain, 100); + if (mag > 255) { mag = 255; } + setPalEntryHSV(i, hue + i * 8, 255, mag); + } + } +} diff --git a/moonlive/palettes/temperature.mlp b/moonlive/palettes/temperature.mlp new file mode 100644 index 00000000..d8bd9ebc --- /dev/null +++ b/moonlive/palettes/temperature.mlp @@ -0,0 +1,37 @@ +// Temperature: a physical color ramp from deep blue through white to amber, the way a heated +// object actually changes color. Slide `kelvin` to move the whole palette along that curve. + +class Temperature { + byte kelvin = 128; + byte spread = 90; + + string tags() { return "🎨"; } + + void defineControls() { + addControl("kelvin", kelvin, 0, 255); // cold blue at 0, warm amber at 255 + addControl("spread", spread, 0, 255); // how much of the curve the sixteen entries cover + } + + void tick() { + for (int i = 0; i < 16; i = i + 1) { + int k = kelvin + div((i - 8) * spread, 16); + if (k < 0) { k = 0; } + if (k > 255) { k = 255; } + + int red = 255; + int green = 255; + int blue = 255; + if (k < 128) { + red = 128 + k; + green = 170 + div(k, 3); + } + if (k > 128) { + blue = 255 - (k - 128) * 2; + green = 255 - div(k - 128, 3); + } + if (blue < 0) { blue = 0; } + + setPalEntry(i, red, green, blue); + } + } +} diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index c98b2f47..44e342c5 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -2361,6 +2361,12 @@ void HttpServerModule::serveScriptCatalog(platform::TcpConnection& conn) { emit("modifiers", moonlive::kModifierFolder, moonlive::kModifierCatalog, moonlive::kModifierCatalogDim, moonlive::kModifierCatalogTags, moonlive::kModifierCatalogCount); + emit("services", moonlive::kServiceFolder, moonlive::kServiceCatalog, + moonlive::kServiceCatalogDim, moonlive::kServiceCatalogTags, + moonlive::kServiceCatalogCount); + emit("palettes", moonlive::kPaletteFolder, moonlive::kPaletteCatalog, + moonlive::kPaletteCatalogDim, moonlive::kPaletteCatalogTags, + moonlive::kPaletteCatalogCount); sink.append("}"); sink.flush(); } diff --git a/src/light/Palette.h b/src/light/Palette.h index 178364c0..8b23aab2 100644 --- a/src/light/Palette.h +++ b/src/light/Palette.h @@ -5,6 +5,7 @@ #include #include +#include // snprintf: the seam copies the names it is handed namespace mm { @@ -166,41 +167,52 @@ inline constexpr uint8_t kYelmag[] = {0,4,1,70, 31,55,1,30, 63,255,4,7, 95, inline constexpr uint8_t kYellowout[] = {0,0,1,255, 63,0,55,255, 127,0,255,255, 191,42,255,45, 255,255,0,0}; // A built-in is a gradient ({stops,len}) or the special "rainbow" (generated via hsvToRgb). -struct Builtin { const char* name; const uint8_t* stops; size_t len; bool rainbow; }; +/// A built-in palette: its name, its gradient, and the tags the picker filters on. +/// +/// `tags` describes what the palette LOOKS LIKE, which is the question someone scrolling sixty +/// entries is actually asking: warm or cold, one hue or many, calm or loud. It is not a category +/// system to be completed, and an untagged palette is a valid state rather than an omission: an +/// empty string simply means the palette is findable by name and swatch, as it was before. +struct Builtin { const char* name; const uint8_t* stops; size_t len; bool rainbow; + const char* tags = ""; }; #define MM_PAL(name, arr) {name, arr, sizeof(arr), false} +/// The same, with tags. Two macros rather than a default argument in one, because the table is +/// written two entries per line and a trailing string on every row would cost the readability that +/// layout exists for. +#define MM_PAL_T(name, arr, tags) {name, arr, sizeof(arr), false, tags} inline constexpr Builtin kBuiltins[] = { - {"Rainbow", nullptr, 0, true}, - MM_PAL("Party", kParty), MM_PAL("Lava", kLava), - MM_PAL("Ocean", kOceanBreeze), MM_PAL("Forest", kForest), - MM_PAL("Fierce Ice", kFierceIce), MM_PAL("Sunset", kSunset), - MM_PAL("Sunset 2", kSunset2), MM_PAL("Orange & Teal",kOrangeTeal), - MM_PAL("Aurora", kAurora), MM_PAL("Aurora 2", kAurora2), - MM_PAL("Atlantica", kAtlantica), MM_PAL("Analogous", kAnalogous), - MM_PAL("April Night", kAprilNight), MM_PAL("Aqua Flash", kAquaFlash), - MM_PAL("Autumn", kAutumn), MM_PAL("Beech", kBeech), - MM_PAL("Blink Red", kBlinkRed), MM_PAL("C9", kC9), - MM_PAL("C9 2", kC9_2), MM_PAL("C9 New", kC9New), - MM_PAL("Candy", kCandy), MM_PAL("Candy2", kCandy2), - MM_PAL("Colorfull", kColorfull), MM_PAL("Departure", kDeparture), - MM_PAL("Drywet", kDrywet), MM_PAL("Fairy Reaf", kFairyReaf), - MM_PAL("Grintage", kGrintage), MM_PAL("Hult", kHult), - MM_PAL("Hult 64", kHult64), MM_PAL("Jul", kJul), - MM_PAL("Landscape", kLandscape), MM_PAL("Light Pink", kLightPink), - MM_PAL("Lite Light", kLiteLight), MM_PAL("Magenta", kMagenta), - MM_PAL("Magred", kMagred), MM_PAL("Orangery", kOrangery), - MM_PAL("Pastel", kPastel), MM_PAL("Pink Candy", kPinkCandy), - MM_PAL("Red & Blue", kRedBlue), MM_PAL("Red Flash", kRedFlash), - MM_PAL("Red Reaf", kRedReaf), MM_PAL("Red Shift", kRedShift), - MM_PAL("Red Tide", kRedTide), MM_PAL("Retro Clown", kRetroClown), - MM_PAL("Rewhi", kRewhi), MM_PAL("Rivendell", kRivendell), - MM_PAL("Sakura", kSakura), MM_PAL("Semi Blue", kSemiBlue), - MM_PAL("Sherbet", kSherbet), MM_PAL("Splash", kSplash), - MM_PAL("Temperature", kTemperature), MM_PAL("Tertiary", kTertiary), - MM_PAL("Tiamat", kTiamat), MM_PAL("Toxy Reaf", kToxyReaf), - MM_PAL("Vintage", kVintage), MM_PAL("Yelblu Hot", kYelbluHot), - MM_PAL("Yelblu", kYelblu), MM_PAL("Yelmag", kYelmag), - MM_PAL("Yellowout", kYellowout), + {"Rainbow", nullptr, 0, true, "🌈⚡️"}, + MM_PAL_T("Party", kParty, "🌈⚡️"), MM_PAL_T("Lava", kLava, "🔥💫"), + MM_PAL_T("Ocean", kOceanBreeze, "❄️💫"), MM_PAL_T("Forest", kForest, "❄️💫"), + MM_PAL_T("Fierce Ice", kFierceIce, "❄️💫"), MM_PAL_T("Sunset", kSunset, "🔥💫"), + MM_PAL_T("Sunset 2", kSunset2, "🔥💫"), MM_PAL_T("Orange & Teal", kOrangeTeal, "🌈💫"), + MM_PAL_T("Aurora", kAurora, "❄️💫"), MM_PAL_T("Aurora 2", kAurora2, "🌈💫"), + MM_PAL_T("Atlantica", kAtlantica, "❄️💫"), MM_PAL_T("Analogous", kAnalogous, "🌈💫"), + MM_PAL_T("April Night", kAprilNight, "🌑💫"), MM_PAL_T("Aqua Flash", kAquaFlash, "❄️💫"), + MM_PAL_T("Autumn", kAutumn, "🔥💫"), MM_PAL_T("Beech", kBeech, "🌑💫"), + MM_PAL_T("Blink Red", kBlinkRed, "🔥💫"), MM_PAL_T("C9", kC9, "🌈💫"), + MM_PAL_T("C9 2", kC9_2, "💫"), MM_PAL_T("C9 New", kC9New, "💫"), + MM_PAL_T("Candy", kCandy, "💫"), MM_PAL_T("Candy2", kCandy2, "💫"), + MM_PAL_T("Colorfull", kColorfull, "💫"), MM_PAL_T("Departure", kDeparture, "💫"), + MM_PAL_T("Drywet", kDrywet, "💫"), MM_PAL_T("Fairy Reaf", kFairyReaf, "💫"), + MM_PAL_T("Grintage", kGrintage, "💫"), MM_PAL_T("Hult", kHult, "💫"), + MM_PAL_T("Hult 64", kHult64, "💫"), MM_PAL_T("Jul", kJul, "💫"), + MM_PAL_T("Landscape", kLandscape, "💫"), MM_PAL_T("Light Pink", kLightPink, "💫"), + MM_PAL_T("Lite Light", kLiteLight, "💫"), MM_PAL_T("Magenta", kMagenta, "💫"), + MM_PAL_T("Magred", kMagred, "💫"), MM_PAL_T("Orangery", kOrangery, "💫"), + MM_PAL_T("Pastel", kPastel, "💫"), MM_PAL_T("Pink Candy", kPinkCandy, "💫"), + MM_PAL_T("Red & Blue", kRedBlue, "💫"), MM_PAL_T("Red Flash", kRedFlash, "💫"), + MM_PAL_T("Red Reaf", kRedReaf, "💫"), MM_PAL_T("Red Shift", kRedShift, "💫"), + MM_PAL_T("Red Tide", kRedTide, "💫"), MM_PAL_T("Retro Clown", kRetroClown, "💫"), + MM_PAL_T("Rewhi", kRewhi, "💫"), MM_PAL_T("Rivendell", kRivendell, "💫"), + MM_PAL_T("Sakura", kSakura, "💫"), MM_PAL_T("Semi Blue", kSemiBlue, "💫"), + MM_PAL_T("Sherbet", kSherbet, "💫"), MM_PAL_T("Splash", kSplash, "💫"), + MM_PAL_T("Temperature", kTemperature, "💫"), MM_PAL_T("Tertiary", kTertiary, "💫"), + MM_PAL_T("Tiamat", kTiamat, "💫"), MM_PAL_T("Toxy Reaf", kToxyReaf, "💫"), + MM_PAL_T("Vintage", kVintage, "💫"), MM_PAL_T("Yelblu Hot", kYelbluHot, "💫"), + MM_PAL_T("Yelblu", kYelblu, "💫"), MM_PAL_T("Yelmag", kYelmag, "💫"), + MM_PAL_T("Yellowout", kYellowout, "💫"), }; #undef MM_PAL inline constexpr uint8_t kCount = sizeof(kBuiltins) / sizeof(kBuiltins[0]); @@ -344,20 +356,117 @@ class Palettes { // Emit the palette dropdown's options for a ControlType::Palette control (the PaletteOptionsFn): // one {"name":…,"colors":"rrggbb rrggbb …"} object per built-in, the colors being the 16 entries // as space-separated hex so the UI renders each option as a gradient swatch. +/// How many SCRIPTED palettes the picker offers, and their names. Set by Drivers from the catalog +/// plus whatever `.mlp` files the device carries, because Palette.h knows nothing about the +/// filesystem and must not learn: this is the same one-pointer seam `Palettes::active()` is. +/// +/// They sort FIRST in the picker, ahead of the sixty built-ins. A scripted palette is the thing a +/// user just wrote or downloaded, so it is what they are looking for; a built-in is always there. +struct LivePalettes { + static uint8_t count() { return count_; } + static const char* nameAt(uint8_t i) { return (i < count_ && names_ && names_[i]) ? names_[i] : ""; } + /// What the script declared about itself, for the picker's emoji filter. Empty when the device + /// carries a `.mlp` the catalog does not know, which is a script the user wrote: it is findable + /// by name, it just carries no chips. + static const char* tagsAt(uint8_t i) { return (i < count_ && tags_ && tags_[i]) ? tags_[i] : ""; } + + /// REFERENCES the caller's arrays; it does not copy them. + /// + /// The names live in the publisher's own member array, which outlives the seam in practice: the + /// module that owns the palette control is the one that publishes, and it republishes whenever + /// its controls are rebuilt. A copy here would cost ~640 bytes of static RAM on every board, + /// including the ones with no PSRAM, to duplicate strings that already exist. + /// + /// The contract that makes this safe: a publisher must clear the seam before its arrays go. + static void set(const char* const* names, const char* const* tags, uint8_t n) { + names_ = names; + tags_ = tags; + count_ = names ? (n > kMax ? kMax : n) : 0; + } + /// Detach, for a publisher whose storage is about to go away. + /// + /// Takes the publisher's OWN array so a departing publisher cannot unpublish somebody else's: + /// a second Drivers (a probe, a module the user added and removed, a tree rebuilt around the + /// live one) publishes and is then destroyed, and an unconditional clear() would leave the + /// running Drivers' palettes silently missing from the picker. Clearing only when the seam + /// still points at the caller's storage makes publish/clear pair per owner rather than + /// globally, which is what made this a dangling pointer in the first place: whoever released + /// last decided what the seam held. + static void clear(const char* const* names = nullptr) { + if (names && names_ != names) return; // someone else owns the seam now: leave it + names_ = nullptr; + tags_ = nullptr; + count_ = 0; + } + + /// Scripted palettes sort LAST, after the built-ins, and the position is the whole point. + /// + /// A palette selection is an INDEX: it is persisted, it rides `seg[0].pal` over the WLED API, + /// and Home Assistant's integration renders `paletteNames` positionally. So an index that means + /// a different palette tomorrow is a silent corruption of every saved setting. + /// + /// Putting the scripted ones first would do exactly that: saving one more `.mlp` shifts every + /// built-in up by one, and a stored `palette: 12` quietly becomes palette 11. Last means the + /// sixty built-in indices are fixed forever and only the scripted tail renumbers, which is the + /// same trade WLED makes by growing its custom palettes downward from 255. + static bool isLive(uint8_t pickerIndex) { return pickerIndex >= palettes::kCount; } + /// The index into the underlying array (scripted or built-in) behind a picker index. + static uint8_t sourceIndex(uint8_t pickerIndex) { + return isLive(pickerIndex) ? static_cast(pickerIndex - palettes::kCount) + : pickerIndex; + } + + /// Case-insensitive name order, for sorting the scripted names among themselves. + static int cmpName(const char* a, const char* b) { + for (; *a && *b; ++a, ++b) { + const char la = (*a >= 'A' && *a <= 'Z') ? static_cast(*a + 32) : *a; + const char lb = (*b >= 'A' && *b <= 'Z') ? static_cast(*b + 32) : *b; + if (la != lb) return la < lb ? -1 : 1; + } + return *a ? 1 : (*b ? -1 : 0); + } + + static constexpr uint8_t kMax = 16; // a device carries a handful; the picker stays readable +private: + static inline const char* const* names_ = nullptr; + static inline const char* const* tags_ = nullptr; + static inline uint8_t count_ = 0; +}; + inline void paletteOptions(JsonSink& sink) { // A NAME REQUEST rather than an options dump: core sets nameIndex to ask "what is palette N // called", because it has no palette table of its own and this function pointer is the only // channel it has into the light domain (Control.h, PaletteOptionsFn). Answering here costs one // branch and no extra descriptor field, where dumping all 60 options just to read one name out // of ~9 KB of JSON would need a buffer no ESP32 task stack can spare. + const uint8_t live = LivePalettes::count(); if (sink.nameIndex() >= 0) { const uint8_t i = static_cast(sink.nameIndex()); - if (i < palettes::kCount) sink.append(palettes::kBuiltins[i].name); + const uint8_t src = LivePalettes::sourceIndex(i); + if (LivePalettes::isLive(i)) { sink.append(LivePalettes::nameAt(src)); return; } + if (src < palettes::kCount) sink.append(palettes::kBuiltins[src].name); return; } + // The BUILT-INS first, keeping the indices every saved selection and the WLED API depend on. for (uint8_t i = 0; i < palettes::kCount; i++) { const Palette p = Palettes::fromBuiltin(i); - sink.appendf("%s{\"name\":\"%s\",\"colors\":\"", i > 0 ? "," : "", palettes::kBuiltins[i].name); + sink.appendf("%s{\"name\":\"%s\",\"tags\":\"%s\",\"colors\":\"", i > 0 ? "," : "", + palettes::kBuiltins[i].name, palettes::kBuiltins[i].tags); + for (uint8_t e = 0; e < Palette::kEntries; e++) + sink.appendf("%s%02x%02x%02x", e > 0 ? " " : "", p.entry[e].r, p.entry[e].g, p.entry[e].b); + sink.append("\"}"); + } + // Then the scripted tail, each marked with 🎨 so the list says at a glance which entries are + // code rather than a fixed gradient. A scripted palette's swatch is whatever is CURRENTLY + // active: its colors exist only once it has ticked, so any other guess would not match the wall. + for (uint8_t i = 0; i < live; i++) { + const Palette& p = *Palettes::active(); + // The name alone: the 🎨 marker rides in `tags`, which the picker renders as a chip and + // filters on. Repeating it in the name showed it twice on every scripted row. + // `live` says this row runs a script, so the UI can mark it the way it marks a scripted + // effect without inferring it from the file extension. + sink.appendf(",{\"name\":\"%s\",\"live\":true,\"tags\":\"%s\",\"colors\":\"", + LivePalettes::nameAt(i), LivePalettes::tagsAt(i)); for (uint8_t e = 0; e < Palette::kEntries; e++) sink.appendf("%s%02x%02x%02x", e > 0 ? " " : "", p.entry[e].r, p.entry[e].g, p.entry[e].b); sink.append("\"}"); diff --git a/src/light/drivers/Correction.h b/src/light/drivers/Correction.h index 521c49a4..16cd8478 100644 --- a/src/light/drivers/Correction.h +++ b/src/light/drivers/Correction.h @@ -1,5 +1,7 @@ #pragma once +#include // powf: the gamma presets, cold path only + #include #include "light/ChannelRole.h" @@ -52,7 +54,44 @@ inline constexpr uint8_t kWhiteModeCount = struct Correction { static constexpr uint8_t kAbsent = 255; // color role not carried by this light - uint8_t briLut[256] = {}; // briLut[v] = (v * brightness) / 255 (scale8) + /// The perceptual curve the output LUT is filled through. + /// + /// `Cie` is the default and the standards answer: CIE 1931 lightness (CIE 15 / ISO 11664-4) + /// models how the eye responds to luminance, which is exactly what a brightness control should + /// be uniform in. hzeller's rpi-rgb-led-matrix, the reference HUB75 implementation, builds its + /// table the same way. + /// + /// `Linear` is not a fallback but a REQUIREMENT for two cases: a downstream device that applies + /// its own curve (correcting twice darkens as the square of the setting, the same reason the + /// dimmer channel is held open below), and any measurement or calibration that needs the value + /// on the wire to mean duty cycle. + /// + /// Gamma 2.2 is the DISPLAY convention (sRGB's effective exponent) and belongs to content that + /// is genuinely encoded that way. Gamma 2.8 is the stage-lighting convention: it emulates the + /// feel of a tungsten dimmer, whose flux rises as roughly the 3.4th power of voltage, rather + /// than modeling perception. Both are offered because curve choice is a legitimate preference, + /// and both are labeled for what they actually do. + enum class Curve : uint8_t { Cie = 0, Gamma22, Gamma28, Linear }; + + /// CIE 1931 lightness, inverted: a 0..255 control position to a 0..1 luminance fraction. + /// + /// The constants are load-bearing rather than tuning: 8 and 903.3 place the linear toe at + /// (6/29)^3 so the two segments meet with a continuous SLOPE, and the cube root alone has + /// infinite slope at zero, which is both numerically unstable and wrong for near-black. NOTE + /// 903.3 and 116: the widely copied LED snippets carry 902.3 and 119 from a Wikipedia typo, + /// including hzeller's own table, and the reported effect is a visibly worse low end. + static float cieLuminance(float control255) { + const float L = control255 * 100.0f / 255.0f; + return (L <= 8.0f) ? (L / 903.3f) + : ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f); + } + + + uint8_t briLut[256] = {}; // briLut[v] = curve(v * brightness / 255) + /// Which perceptual curve rebuildBrightness fills through. A DRIVER's setting, because whether + /// a curve belongs depends on what is downstream: a panel card that corrects its own pixels + /// needs Linear here or the picture is corrected twice. + Curve curve = Curve::Cie; // Derived hot-path cache: the output-byte position of each color role. Source is // always RGB (src[0]=R, src[1]=G, src[2]=B); the offset says where in `out` that // role's byte lands. Recomputed from the role array by rebuild(); GRB by default. @@ -116,11 +155,36 @@ struct Correction { uint8_t outChannels = 3; // bytes emitted per light (= channelsPerLight of the wiring) WhiteMode whiteMode = WhiteMode::Min; // how white is synthesized from RGB (white lights only) - // Refresh just the brightness LUT (briLut[v] = v * brightness / 255). Split out so a brightness- - // only change re-scales the LUT without touching the channel offsets, and so a driver can apply - // brightness even when the role source (the preset library) isn't available yet. + // Refresh just the brightness LUT. Split out so a brightness-only change re-scales the LUT + // without touching the channel offsets, and so a driver can apply brightness even when the role + // source (the preset library) isn't available yet. + // + // ORDER, and it is the whole design: brightness is a LINEAR pre-scale and the curve is applied + // LAST. A gain only composes correctly in linear light (a 0.8 white balance applied to a curved + // value yields 0.8^2.2, not 80% of the light), and anything reasoning about physical quantities + // (current, power) has to read linear values too. Curving the brightness CONTROL as well as the + // values would correct twice: the fader then feels dead at the bottom and 50% looks like 15%. + // The slider becomes perceptually uniform on its own precisely because the curve sits after it. void rebuildBrightness(uint8_t brightness) { - for (int v = 0; v < 256; v++) briLut[v] = static_cast((v * brightness) / 255); + for (int v = 0; v < 256; v++) { + const float linear = static_cast(v) * brightness / 255.0f; // scale first + float out = linear; + switch (curve) { + case Curve::Cie: out = cieLuminance(linear) * 255.0f; break; + case Curve::Gamma22: out = powf(linear / 255.0f, 2.2f) * 255.0f; break; + case Curve::Gamma28: out = powf(linear / 255.0f, 2.8f) * 255.0f; break; + case Curve::Linear: break; + } + int q = static_cast(out + 0.5f); + // A non-zero input never lands on black. Every curve here crushes the low end into + // zero at 8 bits (CIE maps 1 and 2 to 0, gamma 2.2 maps 1..5 there), so without this a + // fade-out SNAPS off partway down and the dimmest usable settings are simply missing. + // FastLED spells the same guard as the `_video` suffix on its gamma helpers; WLED has + // no equivalent and its table does map 1 to 0. Costs the exactness of black only for + // inputs that were never black. + if (q <= 0 && v > 0 && brightness > 0) q = 1; + briLut[v] = static_cast(q > 255 ? 255 : q); + } } // Cold path: refresh the brightness LUT and DERIVE the color-role offsets from the light's @@ -202,9 +266,14 @@ struct Correction { if (slot < srcChannels) out[chan[role]] = src[slot]; }); } - uint8_t r = briLut[src[0]]; - uint8_t g = briLut[src[1]]; - uint8_t b = briLut[src[2]]; + // The white math runs on the LINEAR source, and the curve is applied to what comes OUT of + // it. min() and the Accurate subtraction are ordinary arithmetic: performed on curved + // values they no longer mean what they say, because the amount subtracted from red does not + // correspond to the light the white emitter adds back. Same rule that puts the curve last + // in the pipeline, one level down. + uint8_t r = src[0]; + uint8_t g = src[1]; + uint8_t b = src[2]; // Every synthesized emitter (white + warm-white/yellow/UV) is gated by the ONE whiteMode: // None zeroes them (never a stale value — corrected_ is reused, not re-zeroed, frame to // frame), otherwise each is a best-effort approximation from RGB. Accurate additionally @@ -222,26 +291,29 @@ struct Correction { // values BEFORE Accurate pulls white out below. Compute them here, off the pre-subtraction // r/g/b, so Accurate's `r -= w` (which only rebalances the RGB emitters) can't corrupt them. // warm white ≈ the white component (same as cold white for a warm-white-only strip). - if (offWarmWhite != kAbsent) out[offWarmWhite] = w; + if (offWarmWhite != kAbsent) out[offWarmWhite] = briLut[w]; // yellow ≈ min(R,G) (the shared red+green component). - if (offYellow != kAbsent) out[offYellow] = r < g ? r : g; + if (offYellow != kAbsent) out[offYellow] = briLut[r < g ? r : g]; // UV is out of gamut (no RGB pre-image), but it reads to the eye as deep blue/violet, so // drive it from the BLUE component that has no red/green to pair with — the violet-ish // excess `max(0, B - max(R,G))`. So UV fires on blues/purples, stays dark on warm colors. if (offUV != kAbsent) { const uint8_t rg = r > g ? r : g; - out[offUV] = b > rg ? static_cast(b - rg) : 0; + out[offUV] = briLut[b > rg ? static_cast(b - rg) : 0]; } // White last: it's the only emitter that (in Accurate) rebalances RGB, so it must run // after the stand-ins have read the pre-subtraction values. if (offWhite != kAbsent) { if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; } // pull white out of RGB - out[offWhite] = w; + out[offWhite] = briLut[w]; } } - if (offRed != kAbsent) out[offRed] = r; - if (offGreen != kAbsent) out[offGreen] = g; - if (offBlue != kAbsent) out[offBlue] = b; + // The curve, applied ONCE, to each emitter as it is written. Everything above this line is + // linear light, which is what lets min(), the subtraction and the stand-in approximations + // mean what they say. + if (offRed != kAbsent) out[offRed] = briLut[r]; + if (offGreen != kAbsent) out[offGreen] = briLut[g]; + if (offBlue != kAbsent) out[offBlue] = briLut[b]; } }; diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index f177963c..f08ad29b 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -127,6 +127,7 @@ class DriverBase : public MoonModule { const uint8_t effective = static_cast((globalBrightness * localBrightness_) / 255); correction_.whiteMode = static_cast(whiteMode_); + correction_.curve = static_cast(curveSel_); // Resolve the referenced preset's channel-role wiring from the library into our flat // Correction (cold path). On a missing id (a deleted preset) fall back to the library default // so a driver degrades to a valid RGB output rather than crashing — Robust-to-any-input. @@ -265,6 +266,19 @@ class DriverBase : public MoonModule { uint8_t presetSel_ = 0; // the preset Select's chosen INDEX (mapped to an id in onControlChanged) uint8_t whiteMode_ = static_cast(WhiteMode::Min); // index into kWhiteModeOptions uint8_t localBrightness_ = 255; // per-driver dim, multiplied with the global brightness + /// Which perceptual curve the output LUT is built through. CIE 1931 lightness by default: it is + /// the standards answer (CIE 15 / ISO 11664-4) and models the thing actually being corrected, + /// the eye's response to luminance. hzeller's rpi-rgb-led-matrix builds its HUB75 table the same + /// way. The alternatives are offered because curve choice is a legitimate preference, and each + /// is named for what it really does rather than presented as equivalent. + uint8_t curveSel_ = 0; // index into kCurveOptions; 0 = CIE + static constexpr uint8_t kCurveCount = 4; + static constexpr const char* kCurveOptions[kCurveCount] = { + "CIE 1931", // perceptual, the standard + "gamma 2.2", // the DISPLAY convention (sRGB's effective exponent) + "gamma 2.8", // the STAGE convention: emulates a tungsten dimmer's feel + "linear" // no curve: for a downstream device that corrects its own output + }; uint8_t lastGlobalBrightness_ = 0; // last global brightness the container pushed (for self-rebuilds) // The PERSISTED reference: a preset id is a runtime handle (reassigned each boot), so what // survives a reboot is the preset NAME — stable, human-readable, and reorder-proof. A hidden @@ -278,6 +292,10 @@ class DriverBase : public MoonModule { /// every defineControls (which re-runs on a control change), so adding/renaming a preset shows up. void defineCorrectionControls() { controls_.addControl("localBrightness", localBrightness_, 0, 255); + // The perceptual curve, per driver rather than global, because whether one belongs depends + // on what is DOWNSTREAM: a panel card or fixture that corrects its own pixels needs Linear + // here, or the picture is corrected twice and darkens as the square of the setting. + controls_.addSelect("curve", curveSel_, kCurveOptions, kCurveCount); buildPresetOptions(); // fill presetOptions_ from the library, sync id/sel/ref controls_.addSelect("lightPreset", presetSel_, presetOptions_, presetOptionCount_); controls_.addSelect("whiteMode", whiteMode_, kWhiteModeOptions, kWhiteModeCount); @@ -302,7 +320,7 @@ class DriverBase : public MoonModule { /// affectsPrepare() and its correction rebuilds in onControlChanged (both handled by DriverBase). static bool isCorrectionControl(const char* name) { return std::strcmp(name, "lightPreset") == 0 || std::strcmp(name, "localBrightness") == 0 - || std::strcmp(name, "whiteMode") == 0; + || std::strcmp(name, "whiteMode") == 0 || std::strcmp(name, "curve") == 0; } private: diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index ba51437a..55d4eb26 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -10,6 +10,8 @@ #include "light/layers/BlendMap.h" #include "light/drivers/Correction.h" #include "light/Palette.h" // the global active palette + its select control +#include "light/moonlive/MoonLivePalette.h" // a palette computed per frame by a script +#include "light/moonlive/script_catalog.h" // the tags each factory palette declares #include "core/LightSummary.h" // the POD published for the domain-neutral WLED/MQTT consumers #include "platform/platform.h" @@ -100,6 +102,10 @@ class Drivers : public MoonModule { stopEncodeTask(); renderSplitActive_ = false; seat_.vacate(); + // Detach the scripted-palette seam for the same reason the summary seat is vacated: it + // REFERENCES this object's liveNames_/livePtrs_/liveTags_ arrays rather than copying them, + // so leaving it published points /api/state at freed memory the moment this Drivers goes. + LivePalettes::clear(livePtrs_); MoonModule::release(); } @@ -108,7 +114,10 @@ class Drivers : public MoonModule { /// release() (a stack instance in a test, a tree torn down by its owner) would leave a thread /// dereferencing freed memory. Same dangling-static guard the summary seat uses — a destructor /// is the only place that can't be skipped. - ~Drivers() override { stopEncodeTask(); } + /// The palette seam is cleared here TOO, not only in release(): a destructor is the one place + /// that cannot be skipped, and a Drivers torn down without an explicit release() would leave + /// the seam pointing into freed member arrays. + ~Drivers() override { stopEncodeTask(); LivePalettes::clear(livePtrs_); } /// Stop the core-1 encode worker so a STRUCTURAL TREE MUTATION (a module replace / delete / add) can /// free tree nodes without the worker dereferencing them mid-tick. The worker ticks the driver @@ -236,6 +245,59 @@ class Drivers : public MoonModule { /// gradient into the active 16-entry palette on `onControlChanged` (cheap, off the hot path). uint8_t palette = 0; + /// Discover the `.mlp` files this device carries and publish their names to the picker. + /// + /// Cold path, on defineControls: the list changes when a user saves or deletes a script, which + /// is exactly when the controls are rebuilt anyway. The names are kept in a member array because + /// the seam holds POINTERS, so a local would dangle the moment this returned. + void refreshLivePalettes() { + liveCount_ = 0; + platform::fsList(moonlive::kScriptDir, [](const char* name, bool isDir, uint32_t, void* ctx) { + auto* self = static_cast(ctx); + if (isDir || self->liveCount_ >= LivePalettes::kMax) return; + const size_t n = std::strlen(name); + if (n < 5 || std::strcmp(name + n - 4, moonlive::kPaletteExt) != 0) return; + // A bounded copy rather than snprintf("%s"): a name longer than the slot is TRUNCATED + // to fit, which is what a picker entry wants, and GCC cannot prove the %s form fits. + char* slot = self->liveNames_[self->liveCount_]; + const size_t cap = sizeof(self->liveNames_[0]) - 1; + const size_t copy = n < cap ? n : cap; + std::memcpy(slot, name, copy); + slot[copy] = '\0'; + self->livePtrs_[self->liveCount_] = self->liveNames_[self->liveCount_]; + self->liveCount_++; + }, this); + // Alphabetical, because the picker MERGES this list with the built-ins by name and that + // merge walks both in order. An unsorted list here would interleave wrongly. + for (uint8_t i = 1; i < liveCount_; i++) + for (uint8_t j = i; j > 0 && LivePalettes::cmpName(livePtrs_[j], livePtrs_[j - 1]) < 0; j--) { + const char* tmp = livePtrs_[j]; livePtrs_[j] = livePtrs_[j - 1]; livePtrs_[j - 1] = tmp; + } + // The tags each script declared, from the catalog. A `.mlp` the catalog does not know is a + // script the user wrote: it keeps its place in the list and simply carries no chips, which + // is why the lookup falls back to an empty string rather than skipping the file. + for (uint8_t i = 0; i < liveCount_; i++) { + liveTags_[i] = ""; + for (size_t c = 0; c < moonlive::kPaletteCatalogCount; c++) + if (std::strcmp(livePtrs_[i], moonlive::kPaletteCatalog[c]) == 0) { + liveTags_[i] = moonlive::kPaletteCatalogTags[c]; + break; + } + } + } + + char liveNames_[LivePalettes::kMax][moonlive::kMaxScriptName + 1] = {}; + const char* livePtrs_[LivePalettes::kMax] = {}; + const char* liveTags_[LivePalettes::kMax] = {}; + uint8_t liveCount_ = 0; + + /// The scripted palette: a `.mlp` name, and the binding that runs it. Empty means the built-in + /// `palette` select above is in charge, which is the default and the common case. The script is + /// owned HERE because this module owns the palette, and reached for its per-frame run through + /// MoonLivePalette's static seam, since the layers sample the palette before this module ticks. + char paletteScript_[moonlive::kMaxScriptName + 1] = {}; + MoonLivePalette paletteScriptModule_; + // Two ways to wire the source Layer: // - setEffects(Effects*): bind the container; layer_ is re-resolved from // activeLayer() at every prepareTree. This makes the link self-healing — @@ -286,7 +348,28 @@ class Drivers : public MoonModule { // in LED pins (architecture.md, deviceModel owns what is wired on the product). controls_.addText("relayPins", relayPins, sizeof(relayPins)); controls_.addControl("brightness", brightness, 0, 255); - controls_.addPalette("palette", palette, mm::paletteOptions, mm::palettes::kCount); + // ONE picker for both kinds. The scripted palettes are published to the seam Palette.h reads + // and merged into the same alphabetical list, so a `.mlp` is chosen exactly like a built-in + // rather than through a second control the user has to know about. + refreshLivePalettes(); + // Sized from THIS instance's scan (liveCount_), not the seam: defineControls also runs + // before prepare() has published, and a seam-sized control would cap at the built-ins and + // reject every scripted index, so selecting a live palette silently did nothing. + controls_.addPalette("palette", palette, mm::paletteOptions, + static_cast(liveCount_ + mm::palettes::kCount)); + // The EDITOR, shown only while a scripted palette is selected: there is nothing to edit in a + // built-in gradient, and offering the pane would suggest otherwise. + // + // It is an EDITOR, not a second selector. `palette` above owns the choice; this names the + // file that choice resolved to, so the two cannot disagree. An earlier version let this + // control pick a script too, which meant a user could select `spectrum` here while `palette` + // still said something else and the card showed two answers to one question. + controls_.addFilePath("paletteScript", paletteScript_, sizeof(paletteScript_), + moonlive::kPalettePick); + controls_.setHidden(controls_.count() - 1, !LivePalettes::isLive(palette)); + controls_.setReadOnly(controls_.count() - 1, true); // the selector is `palette`, above + // And the script's own controls, which only exist while one is running. + if (LivePalettes::isLive(palette)) paletteScriptModule_.publishControls(controls_); // Only where it can DO something: a rig of LED strips has no aim to hold, so the control // would be a question about hardware the user does not have. Same add-then-setHidden shape // the renderWait field below uses. @@ -313,7 +396,34 @@ class Drivers : public MoonModule { // per-driver channel order / white / local brightness live on each driver and rebuild there. void onControlChanged(const char* controlName) override { if (std::strcmp(controlName, "palette") == 0) { - Palettes::setActive(palette); // rebuild the active 16-entry lookup (cheap, off the hot path) + if (LivePalettes::isLive(palette)) { + // A scripted palette: name its file and compile. The script then fills the entries + // every frame, so there is nothing to expand here. + std::snprintf(paletteScript_, sizeof(paletteScript_), "%s", + LivePalettes::nameAt(LivePalettes::sourceIndex(palette))); + MoonLivePalette::setActiveInstance(&paletteScriptModule_); + paletteScriptModule_.setScript(paletteScript_); + paletteScriptModule_.prepare(*this); + } else { + // A built-in: detach any script, or it would keep overwriting the gradient every + // frame and the selection would appear to do nothing. + MoonLivePalette::setActiveInstance(nullptr); + paletteScript_[0] = 0; + Palettes::setActive(LivePalettes::sourceIndex(palette)); + } + rebuildControls(); // the editor and the script's controls appear or disappear with it + return; + } + if (std::strcmp(controlName, "paletteScript") == 0) { + // Published to the seam Effects::tick reads. Only while a name is set, so clearing the + // field detaches the script rather than leaving a compiled one running unseen. + MoonLivePalette::setActiveInstance(paletteScript_[0] ? &paletteScriptModule_ : nullptr); + paletteScriptModule_.setScript(paletteScript_); + paletteScriptModule_.prepare(*this); + rebuildControls(); // the compile re-derives the script's own controls + // Clearing the name hands the palette back to the built-in select, which would otherwise + // keep whatever the script last wrote. + if (paletteScript_[0] == 0) Palettes::setActive(palette); return; } if (std::strcmp(controlName, "multicore") == 0) { @@ -487,6 +597,14 @@ class Drivers : public MoonModule { } void prepare() override { + // Discover and PUBLISH the scripted palettes. Publication lives here and not in + // defineControls() because the seam is one static slot whose last writer wins, and + // defineControls() also runs on detached instances (the /api/modules probe builds one to + // read its controls, then destroys it): such a probe would take the seam and empty it on + // its way out, and every `.mlp` vanished from the running picker. prepare() runs only on a + // module the scheduler actually mounted, which is exactly the owner the seam wants. + refreshLivePalettes(); + LivePalettes::set(livePtrs_, liveTags_, liveCount_); // Re-resolve the active Layer from the bound container so a Layer that // was cleared and rebuilt via the API is picked up here (self-healing). // setLayer() pins a Layer directly and leaves effects_ null — skip then. diff --git a/src/light/layers/Effects.h b/src/light/layers/Effects.h index 68ba922e..51c32979 100644 --- a/src/light/layers/Effects.h +++ b/src/light/layers/Effects.h @@ -1,6 +1,7 @@ #pragma once #include "core/MoonModule.h" +#include "light/moonlive/MoonLivePalette.h" // the per-frame scripted palette, run before the layers #include "light/layers/Layer.h" #include "light/layouts/Layouts.h" #include "platform/platform.h" @@ -49,6 +50,11 @@ class Effects : public MoonModule { /// depth (an Effect that should be ticked inside a Layer). Matches /// the role-filter precedent in setLayouts / activeLayer above. void tick() MM_NONBLOCKING override { + // The scripted palette runs FIRST, so every layer in this frame samples the same sixteen + // entries. It is owned by Drivers (which owns the palette control) and reached through a + // static seam, because Drivers ticks after the layers and a palette applied there would be + // one frame late. A no-op when no `.mlp` is named, which is the common case. + MoonLivePalette::tickActive(platform::millis()); for (uint8_t i = 0; i < childCount(); i++) { MoonModule* c = child(i); if (!c || c->role() != ModuleRole::Layer) continue; diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index 4a50576d..e864c272 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -328,6 +328,11 @@ struct FadeSink { FadeFn fn = nullptr; void* ctx = nullptr; }; using CoordFn = void (*)(void* ctx, uint32_t x, uint32_t y, uint32_t z); struct CoordSink { CoordFn fn = nullptr; void* ctx = nullptr; }; +/// Where setPalEntry writes. A PALETTE script fills sixteen entries once per frame, so the sink +/// carries an index rather than a light: it is a table, not a canvas. +using PalFn = void (*)(void* ctx, uint8_t index, uint8_t r, uint8_t g, uint8_t b); +struct PalSink { PalFn fn = nullptr; void* ctx = nullptr; }; + enum class MotionAxis : uint8_t { Pan = 0, Tilt = 1 }; using MotionFn = void (*)(void* ctx, MotionAxis axis, uint32_t index, uint8_t value); struct MotionSink { MotionFn fn = nullptr; void* ctx = nullptr; }; @@ -352,7 +357,7 @@ namespace detail { // second table would repeat the claim/release machinery for the same lifetime. struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canvas canvas; AddControlSink controls; FadeSink fade; MotionSink motion; CoordSink coord; - PoolSizeSink poolSize; PoolSink pool; }; + PalSink pal; PoolSizeSink poolSize; PoolSink pool; }; /// Two slots: the render task and whichever task edits a control are the two that ever run a script /// at once. A third concurrent runner gets the overflow slot, which holds no sink — so its addLight /// calls no-op instead of writing through someone else's context. @@ -451,6 +456,22 @@ inline void setCoordSink(CoordFn fn, void* ctx) MM_NONBLOCKING { if (!fn) detail::releaseIfEmpty(s); } +/// This thread's palette sink, or an empty one. Reading does not claim a slot. +inline const PalSink& palSink() MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(false); + static constinit PalSink none{}; + return s ? s->pal : none; +} + +/// Point setPalEntry at the palette binding for one run; nullptr to detach. Installed around a +/// single tick, so a script can only ever write the palette it was invoked to fill. +inline void setPalSink(PalFn fn, void* ctx) MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(fn != nullptr); + if (!s) return; + s->pal = {fn, ctx}; + if (!fn) detail::releaseIfEmpty(s); +} + /// This thread's motion sink, or an empty one. Reading does not claim a slot, as fadeSink does not. inline const MotionSink& motionSink() MM_NONBLOCKING { detail::SinkSlot* s = detail::ownedSlot(false); @@ -617,6 +638,39 @@ inline void setDrawCanvas(const draw::Canvas& cv) MM_NONBLOCKING { /// script was writing `mod(bx + dx, width) + mod(by + dy, height) * width` at every call site. /// Out-of-range coordinates are dropped, not wrapped — a "negative" coordinate arrives as a huge /// unsigned value, and wrapping it would paint the wrong edge rather than nothing. +/// setPalEntry(i, r, g, b) - write one of the sixteen active palette entries. +/// +/// The PALETTE binding's only output, and the reason a palette can be code rather than data: an +/// entry recomputed every frame can follow audio, drift, or come out of an algorithm, none of which +/// a gradient stop list can express. +/// +/// The index is BOUNDED rather than wrapped: a script computing an index from a control could +/// otherwise write a neighbouring entry and produce a palette nobody wrote, which reads as an engine +/// fault. Out of range does nothing, which is visible in the picture and blames the script. +extern "C" inline uint32_t mm_light_setPalEntry(const uintptr_t* args, uint32_t, const uint8_t*) { + const PalSink& s = palSink(); + if (!s.fn) return 0; // no palette installed: not a palette script + const uint32_t i = uint32_t(args[0]); + if (i >= Palette::kEntries) return 0; + s.fn(s.ctx, static_cast(i), byteArg(args[1]), byteArg(args[2]), byteArg(args[3])); + return 0; +} + +/// setPalEntryHSV(i, h, s, v) - the same, in the color space a palette is usually reasoned in. +/// +/// A hue sweep is one addition per entry in HSV and a table of magic numbers in RGB, which is why +/// both exist rather than leaving a script to convert. Same name MoonLight uses, so a palette +/// written there reads here. +extern "C" inline uint32_t mm_light_setPalEntryHSV(const uintptr_t* args, uint32_t, const uint8_t*) { + const PalSink& sink = palSink(); + if (!sink.fn) return 0; + const uint32_t i = uint32_t(args[0]); + if (i >= Palette::kEntries) return 0; + const RGB c = hsvToRgb(byteArg(args[1]), byteArg(args[2]), byteArg(args[3])); + sink.fn(sink.ctx, static_cast(i), c.r, c.g, c.b); + return 0; +} + extern "C" inline uint32_t mm_light_setPaletteColor(const uintptr_t* args, uint32_t, const uint8_t*) { const draw::Canvas& cv = drawCanvas(); if (!cv.data) return 0; // no canvas installed (a layout, a modifier) @@ -1116,6 +1170,11 @@ inline const BuiltinTable& lightBuiltins() { // setPaletteColor(x, y, i, bri) → one palette-coloured pixel. The form a script should reach // for: one call, one brightness evaluation, and no buffer-layout arithmetic at the call site. t.add({"setPaletteColor", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPaletteColor, {}}); + // setPalEntry(i,r,g,b) / setPalEntryHSV(i,h,s,v) -> write one of the sixteen ACTIVE palette + // entries. A palette script's only output; a no-op in every other role, where no sink is + // installed, so an effect calling it changes nothing rather than corrupting the palette. + t.add({"setPalEntry", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPalEntry, {}}); + t.add({"setPalEntryHSV", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPalEntryHSV, {}}); // paletteR/G/B(i, bri) → one channel each, for a script that needs the components. Kept // because setPaletteColor writes a pixel and cannot serve a script that wants the value. t.add({"paletteR", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_paletteR, {}}); diff --git a/src/light/moonlive/MoonLivePalette.h b/src/light/moonlive/MoonLivePalette.h new file mode 100644 index 00000000..24bc7f96 --- /dev/null +++ b/src/light/moonlive/MoonLivePalette.h @@ -0,0 +1,108 @@ +#pragma once + +#include "core/moonlive/MoonLive.h" +#include "light/moonlive/MoonLiveScript.h" +#include "light/moonlive/MoonLiveBuiltins_light.h" +#include "light/Palette.h" + +// MoonLivePalette: a PALETTE authored as a script rather than stored as data. +// +// The fifth binding, and the one that is not a shape the other four have. An effect writes a color +// per light, a layout a position, a modifier a coordinate, a service a control. A palette writes +// SIXTEEN ENTRIES, once per frame, and every effect in that frame then samples them. +// +// **Why a script and not a gradient.** A stop list is frozen the moment it is saved: it cannot +// follow the music, drift, or be computed. A palette that is code can do all three, which is the +// whole reason this exists rather than a JSON palette file. The audio builtins are already in the +// shared table, so `audioBand()` in a palette costs nothing to expose and is the case that +// justifies the design. +// +// **Cost is independent of rig size**, which is what makes running it in the render path +// reasonable. An effect body runs once per light (256 times on a 16x16 grid); a palette runs a +// sixteen-iteration loop whatever the wall is. So the per-frame cost of a scripted palette is +// closer to a single light's effect work than to a frame of it. +// +// **It fills a SCRATCH palette, not the live one.** Effects sample `Palettes::active()` on the same +// thread, between palette ticks, but a script writing the global directly would leave a half-written +// table visible for the length of its own loop. Filling a local and assigning the 48 bytes once +// means an effect sees either the previous palette or the new one, never a mixture, and it removes +// the whole class of problem rather than narrowing the window. +// +// **A broken script keeps the last good palette.** The other bindings degrade to dark, which is the +// honest answer when the script IS the picture. Here it is not: the effects still run, so dropping +// the palette to black would blame the effect for the palette's fault. The last good table stays, +// and the error goes to the status line. + +namespace mm { + +/// A palette whose sixteen entries are computed by a MoonLive script, once per frame. +class MoonLivePalette { +public: + /// Point the palette at a script. The next prepare() compiles it. + void setScript(const char* name) { script_.setName(name); } + const char* scriptName() const { return script_.name(); } + + /// Compile if the named file changed. Reports status and dynamic bytes through `owner`, the + /// same way every other binding does, so a scripted palette's errors appear on a card. + void prepare(MoonModule& owner) { + script_.sync(moonlive::effectSysVars(), owner, moonlive::lightBuiltins()); + } + + /// Publish the script's controls, so a scripted palette is configurable without editing it. + void publishControls(ControlList& controls) { script_.publishDeclaredControls(controls); } + + bool ok() const { return script_.ok(); } + + void release() { + script_.engine().free(); + script_.invalidate(); + } + + /// Run the script for this frame and install the result. Returns false when there is nothing to + /// run, so the caller can leave the built-in palette alone. + /// + /// `t` is the elapsed milliseconds every other binding is handed, so `beat` and `beatsin` mean + /// the same thing in a palette as in an effect. + bool tick(uint32_t nowMs) MM_NONBLOCKING { + if (!script_.ok()) return false; + if (!script_.engine().hasEntry(moonlive::kEntryTick)) return false; + + // Seeded from the palette currently active, so a script that writes only some entries + // leaves the rest as they were rather than showing whatever was on the stack. + Palette scratch = *Palettes::active(); + moonlive::setPalSink([](void* ctx, uint8_t i, uint8_t r, uint8_t g, uint8_t b) { + static_cast(ctx)->entry[i] = RGB{r, g, b}; + }, &scratch); + // runValue, not run(): run() refuses a call with no light buffer, which is the right + // precondition for an effect and wrong here, because a palette paints nothing. Same reason + // MoonLiveService uses it. + script_.engine().runValue(moonlive::kEntryTick, moonlive::RetType::Void, 0, + nullptr, 0, 0, nowMs); + moonlive::setPalSink(nullptr, nullptr); + + // One assignment of 48 bytes: an effect samples a whole palette or the previous one. + Palettes::setActiveDirect(scratch); + return true; + } + + /// The palette script the frame should run, or nullptr. + /// + /// A static seam, the same shape `Palettes::active()` and `AudioService::latestFrame()` use, and + /// for the same reason: the script is OWNED by Drivers (which owns the palette control), but it + /// has to run BEFORE the effects sample the palette, and Drivers ticks after them. A seam is one + /// pointer rather than a wire from the effects container to a driver container it otherwise + /// knows nothing about. + static MoonLivePalette* active() { return active_; } + static void setActiveInstance(MoonLivePalette* p) { active_ = p; } + + /// Run the active palette script, if there is one. Called once per frame before the layers. + static void tickActive(uint32_t nowMs) MM_NONBLOCKING { + if (active_) active_->tick(nowMs); + } + +private: + moonlive::MoonLiveScript script_; + static inline MoonLivePalette* active_ = nullptr; +}; + +} // namespace mm diff --git a/src/light/moonlive/MoonLiveScriptFile.h b/src/light/moonlive/MoonLiveScriptFile.h index e9320512..caab691f 100644 --- a/src/light/moonlive/MoonLiveScriptFile.h +++ b/src/light/moonlive/MoonLiveScriptFile.h @@ -27,8 +27,8 @@ inline constexpr const char* kScriptDir = "/moonlive"; /// looks. A library you learn from has to be readable. inline constexpr const char* kFactoryScriptDir = "/.moonlive"; -/// A script's ROLE, in its file name. One language, three extensions: an effect is `.mle`, a -/// layout `.mll`, a modifier `.mlm`, a service `.mls`. +/// A script's ROLE, in its file name. One language, five extensions: an effect is `.mle`, a +/// layout `.mll`, a modifier `.mlm`, a service `.mls`, a palette `.mlp`. /// /// Stated by the author rather than derived from the script's contents. Deriving it is tempting /// (the entry point a class defines already tells the engine which moment to call), but that @@ -43,6 +43,7 @@ inline constexpr const char* kEffectExt = ".mle"; inline constexpr const char* kLayoutExt = ".mll"; inline constexpr const char* kModifierExt = ".mlm"; inline constexpr const char* kServiceExt = ".mls"; +inline constexpr const char* kPaletteExt = ".mlp"; /// What a NEW script starts out as, per role. A created file is a WORKING example rather than an /// empty one: an empty file fails to parse the moment it is made, so the first thing a new script @@ -98,14 +99,13 @@ inline constexpr const char* kServiceTemplate = // last starts at 1, the level an idle pull-up reads: starting at 0 made the first tick see a // change that had not happened and write the control before anyone touched the button. " int last = 1;\n" - " int now = 0;\n" "\n" " void defineControls() {\n" " addControl(\"pin\", pin, 0, 48);\n" " }\n" "\n" " void tick20ms() {\n" - " now = gpioRead(pin);\n" + " int now = gpioRead(pin);\n" " if (now != last) {\n" " last = now;\n" // INVERTED: the wiring is active-low (a switch to ground with a pull-up), so a pressed button @@ -118,10 +118,26 @@ inline constexpr const char* kServiceTemplate = /// What a `script` control tells the UI: where the files are, which of them to offer, and what a /// new one starts as. Borrowed by the control descriptor (addFilePath), so these live here next to /// the directory they name rather than being repeated in each binding. +inline constexpr const char* kPaletteTemplate = + "class NewPalette {\n" + " byte bpm = 20;\n" + "\n" + " void defineControls() {\n" + " addControl(\"bpm\", bpm, 1, 120); // how fast the colors move\n" + " }\n" + "\n" + " void tick() {\n" + " for (int i = 0; i < 16; i = i + 1) {\n" + " setPalEntryHSV(i, scale(beat(bpm, t), 256) + i * 16, 255, 255);\n" + " }\n" + " }\n" + "}\n"; + inline constexpr const char* kEffectPick[3] = {kScriptDir, kEffectExt, kEffectTemplate}; inline constexpr const char* kLayoutPick[3] = {kScriptDir, kLayoutExt, kLayoutTemplate}; inline constexpr const char* kModifierPick[3] = {kScriptDir, kModifierExt, kModifierTemplate}; inline constexpr const char* kServicePick[3] = {kScriptDir, kServiceExt, kServiceTemplate}; +inline constexpr const char* kPalettePick[3] = {kScriptDir, kPaletteExt, kPaletteTemplate}; /// Is `ext` one of the script extensions? One definition, beside the extensions themselves. /// @@ -132,7 +148,8 @@ inline constexpr const char* kServicePick[3] = {kScriptDir, kServiceExt, kServ inline bool isScriptExt(const char* ext) { if (!ext) return false; return std::strcmp(ext, kEffectExt) == 0 || std::strcmp(ext, kLayoutExt) == 0 || - std::strcmp(ext, kModifierExt) == 0 || std::strcmp(ext, kServiceExt) == 0; + std::strcmp(ext, kModifierExt) == 0 || std::strcmp(ext, kServiceExt) == 0 || + std::strcmp(ext, kPaletteExt) == 0; } /// The largest script the loader will read into RAM at once. Not a language limit — the buffer is @@ -257,7 +274,7 @@ inline bool compileScriptFile(MoonLive& engine, const char* name, const size_t len = std::strlen(name); const char* tail = len >= 4 ? name + len - 4 : ""; if (len < 5 || len > kMaxScriptName || !isScriptExt(tail)) { - err = "script name must end in .mle, .mll, .mlm or .mls"; return false; + err = "script name must end in .mle, .mll, .mlm, .mls or .mlp"; return false; } // The user's copy wins over the factory one of the same name: that is what makes editing a diff --git a/src/light/moonlive/catalog_scripts.cmake b/src/light/moonlive/catalog_scripts.cmake index f67a72fb..a7e5a8c2 100644 --- a/src/light/moonlive/catalog_scripts.cmake +++ b/src/light/moonlive/catalog_scripts.cmake @@ -28,7 +28,8 @@ file(GLOB SCRIPT_PATHS "${SCRIPT_DIR}/effects/*.mle" "${SCRIPT_DIR}/layouts/*.mll" "${SCRIPT_DIR}/modifiers/*.mlm" - "${SCRIPT_DIR}/services/*.mls") + "${SCRIPT_DIR}/services/*.mls" + "${SCRIPT_DIR}/palettes/*.mlp") list(SORT SCRIPT_PATHS) # deterministic output: the same input must give a byte-identical header list(LENGTH SCRIPT_PATHS SCRIPT_COUNT) diff --git a/src/light/moonlive/catalog_scripts.py b/src/light/moonlive/catalog_scripts.py index ee48e597..77a77767 100644 --- a/src/light/moonlive/catalog_scripts.py +++ b/src/light/moonlive/catalog_scripts.py @@ -16,11 +16,13 @@ # The role a script plays, from its extension. This mirrors MoonLiveScriptFile.h's kEffectExt / # kLayoutExt / kModifierExt, and it is what a picker filters on: the DEVICE keeps one flat # directory, so the extension is the only role signal once a file lands there. -ROLE_BY_EXT = {".mle": "Effect", ".mll": "Layout", ".mlm": "Modifier", ".mls": "Service"} +ROLE_BY_EXT = {".mle": "Effect", ".mll": "Layout", ".mlm": "Modifier", ".mls": "Service", + ".mlp": "Palette"} # Where each role lives in the repo. The device keeps one flat directory, so this is only ever part # of the download URL. -FOLDER_BY_ROLE = {"Effect": "effects", "Layout": "layouts", "Modifier": "modifiers", "Service": "services"} +FOLDER_BY_ROLE = {"Effect": "effects", "Layout": "layouts", "Modifier": "modifiers", "Service": "services", + "Palette": "palettes"} # What a script DECLARES about itself, read from its source. The script is the one home for this diff --git a/src/light/moonlive/script_catalog.h b/src/light/moonlive/script_catalog.h index 4f6b1841..57a51ab6 100644 --- a/src/light/moonlive/script_catalog.h +++ b/src/light/moonlive/script_catalog.h @@ -178,6 +178,35 @@ constexpr const char* kServiceCatalogTags[] = { }; constexpr const char* kServiceFolder = "services"; ///< its directory upstream -constexpr size_t kCatalogCount = 37; ///< every factory script, all roles +/// Every factory palette, by file name. They live in `moonlive/palettes/` +/// upstream and in the factory script directory on the device. +constexpr const char* kPaletteCatalog[] = { + "beat-flash.mlp", + "drift.mlp", + "fire.mlp", + "spectrum.mlp", + "temperature.mlp", +}; +constexpr size_t kPaletteCatalogCount = 5; +/// What each palette above declares about itself, in the same order. +/// A dimension of 0 means the script says nothing, so the DEVICE decides the default. +constexpr unsigned char kPaletteCatalogDim[] = { + 0, + 0, + 0, + 0, + 0, +}; +/// The emoji each declares, "" when it declares none. +constexpr const char* kPaletteCatalogTags[] = { + "🎨🎶", + "🎨", + "🎨", + "🎨🎶", + "🎨", +}; +constexpr const char* kPaletteFolder = "palettes"; ///< its directory upstream + +constexpr size_t kCatalogCount = 42; ///< every factory script, all roles } // namespace mm::moonlive diff --git a/src/ui/app.js b/src/ui/app.js index 5e859df0..96cc0a1a 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2520,6 +2520,11 @@ function createControl(moduleName, moduleType, ctrl) { picker.className = "control-select fileedit-pick"; picker.dataset.mid = moduleName; picker.dataset.key = ctrl.name; + // A READ-ONLY filepath names the file something else chose, so it must not offer a + // second way to choose: the Drivers palette editor is the case, where `palette` owns the + // selection and this pane only edits what that selection resolved to. Two selectors for + // one value is how they end up disagreeing. + if (ctrl.readonly) { picker.disabled = true; picker.classList.add("is-readonly"); } // The options, as data. `fillPicker` appends option elements exactly as it did to the //