diff --git a/docs/assets/core/VideoService.png b/docs/assets/core/VideoService.png
new file mode 100644
index 000000000..ef4879dcd
Binary files /dev/null and b/docs/assets/core/VideoService.png differ
diff --git a/docs/assets/light/effects/AmbilightEffect.gif b/docs/assets/light/effects/AmbilightEffect.gif
new file mode 100644
index 000000000..226ea3d7d
Binary files /dev/null and b/docs/assets/light/effects/AmbilightEffect.gif differ
diff --git a/docs/assets/light/effects/AmbilightEffect.png b/docs/assets/light/effects/AmbilightEffect.png
new file mode 100644
index 000000000..fc4429f72
Binary files /dev/null and b/docs/assets/light/effects/AmbilightEffect.png differ
diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md
index 51b72dc8b..9825615f2 100644
--- a/docs/moonmodules/core/services.md
+++ b/docs/moonmodules/core/services.md
@@ -42,6 +42,24 @@ Detail: [technical](moxygen/AudioService.md) ยท [the sync packet](moxygen/WLEDAu
[Tests](../../reference/tests/unit-tests.md#audioservice)
+
+
+### Video
+
+
+
+A user-added Service: the video source a screen-follow effect reads. The counterpart of [Audio](#audio) for a picture, decoded once per tick however many effects want it. `source` decides which controls show. The pattern, the file format and the tone curve: โ details.
+
+- `source`: `test pattern` needs no hardware, `file` reads a PPM off the filesystem.
+- `patternSpeed`: (test pattern) sweep rate of the white block, in pixels per second. 0 parks it.
+- `file`: (file) path to a binary PPM (P6, maxval 255), uploaded through the File Manager.
+- `reload`: (file) re-read the file in place, without rebuilding the pipeline.
+- status: the live frame's dimensions (`64x36`), or the reason there is no frame.
+
+Detail: [technical](moxygen/VideoService.md)
+
+[Tests](../../reference/tests/unit-tests.md#videoservice)
+
### OSC
@@ -249,6 +267,22 @@ A received magnitude is clamped to 255, since a real WLED source reaches ~9500 a
Prior art: the WLED-MM audio-reactive usermod by **Frank ([@softhack007](https://github.com/softhack007))**, the most-used open-source audio-reactive LED implementation, whose adaptive noise-gate concept the analysis here descends from (analyzed with his permission); and **[@troyhacks](https://github.com/troyhacks/WLED)**, who reworked that DSP onto Espressif's [esp-dsp](https://github.com/espressif/esp-dsp) FFT, the same choice this service makes. The line-in path exists because **wladi ([myhome-control](https://shop.myhome-control.de))** supplied the hardware and pinout for the [MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md): its onboard PCM1808 I2S ADC is what `mclkPin` is for.
+## Video, details
+
+**What reads it.** The service publishes one decoded RGB frame per tick through a static accessor, and any number of effects read that same frame: it is decoded once here however many want it. A consumer holds the frame for one tick and never across frames, and before any frame exists the pointer is non-null but its pixels are, which every reader has to tolerate. That contract is what lets a source be added and removed under a running pipeline.
+
+**The test pattern is a diagnostic, not decoration.** Four colored border bands (red top, green right, blue bottom, yellow left) and a white block sweeping along the top edge. On a border-mounted strip that makes orientation self-evident: a strip mapped the wrong way round shows as the wrong physical edge lighting, rather than a subtly wrong picture. The sweeping block shows liveness and which way "forward" runs. Parking it with `patternSpeed` 0 turns the pattern into a still reference, so one light can be compared against a known color without the block passing through its zone mid-read.
+
+**Why PPM for the file source.** There is no software JPEG decoder in this codebase, and adding one so the desktop build could open a `.jpg` would buy a dependency for a development convenience. PPM is a raw RGB dump behind a three-line ASCII header, so it needs no decoder and is one command from any source material:
+
+```sh
+ffmpeg -i clip.mp4 -frames:v 1 -vf scale=64:36 -pix_fmt rgb24 frame.ppm
+```
+
+The header is parsed strictly, because the file is whatever a user uploaded: comments and whitespace runs are skipped per Netpbm, a 16-bit maxval is refused rather than misread as 8-bit, and exactly one whitespace byte is consumed before the pixels. Accepting a non-whitespace separator there would eat a pixel and shift every channel one place, which tints the whole image instead of failing.
+
+**Every frame carries its tone curve.** A published frame names the curve from its own encoding to linear light, and a consumer reads pixels through it. SDR is sRGB, a curve like any other, so the frame always carries one rather than leaving the reader to assume. This matters because averaging is what a screen-follow effect does, and the mean of encoded bytes is not the mean of the light: half black and half white averages to 128 as bytes where the light is 188. The table is 12-bit rather than 8, since linear has no headroom at the dark end, which is what encodings exist for.
+
## OSC, details
**Feedback: the device answers.** With `feedback` on, a control that changes anywhere (the web UI, a preset recall, an audio-reactive effect) is mirrored back to the surface, which is what keeps a client honest and what moves a motorized fader. `addressing` picks where it goes: `unicast` to each of `hosts`, addresses or names, or to whoever last wrote when `hosts` is empty; `multicast` to `group`, which every listening device with the same `group` joins; the default, `239.255.77.78`, sits beside discovery's own group, so multicast works with nothing to fill in, and a second rig on the same network picks another. `feedbackPort` is where that client LISTENS, which is not the port we listen on (Open Stage Control calls its own `osc-port`).
diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md
index 0be273c3e..918d6ea6c 100644
--- a/docs/moonmodules/light/effects.md
+++ b/docs/moonmodules/light/effects.md
@@ -10,6 +10,28 @@ Effects are built from the shared [power functions](power-functions.md): the dra
## MoonLight effects
+
+
+### Ambilight ๐บ
+
+
+
+Paints the layer with the live frame from the [Video](../core/services.md#video) service, so lights around a display glow the color of the picture nearest them. Each light shows the **mean** of the rectangle mapping to it. Sampling, black bars and the layout's part: โ details.
+
+- `brightness`: scales the sampled color, dimming the video not the output.
+- `saturation`: how far each channel is pushed from its zone's luma.
+- `smoothing`: how much of the gap to a new color closes per frame.
+- `snapAbove`: a channel moving further than this jumps instead of easing.
+- `edgeDepth`: how far into the picture the outermost lights look.
+- `detectBlackBars`: map the lights across the picture, not the frame.
+- `barLevel`: how dark a pixel counts as bar (0 to 64).
+
+Origin: MoonLight original, on the Ambilight / Hyperion behavior
+
+Detail: [technical](moxygen/AmbilightEffect.md)
+
+[Tests](../../reference/tests/unit-tests.md#ambilighteffect)
+
### ColorTrails ๐ซ๐๏ธ๐จ๐ซ๏ธ ยท 3D
@@ -1367,3 +1389,15 @@ Origin: MoonLight (Sinus, AI-generated) ยท via [MoonLight](https://github.com/ew
Detail: [technical](moxygen/SineEffect.md)
[Tests](../../reference/tests/unit-tests.md#sineeffect)
+
+## Ambilight, details
+
+**Two spaces, and the layout decides the shape.** The SOURCE is the video frame, counted in pixels; the DESTINATION is the layer's logical box, counted in light positions. The source is far the bigger, a 640x480 picture onto a strip of 60 positions, so each position owns a whole rectangle of pixels and shows their average. The effect asks the mapping only one question, whether a position lights anything, and skips the averaging where the answer is no. On a border layout that is most of the box: the interior of a backlight strip's bounding box maps to no LED, so the perimeter shows the frame's border for free. On a full grid the same effect is a video wall. A [MoonLive layout](layouts.md#moonlive) running the library's `backlight.mll` is how a strip gets placed on a wall's edge pixels.
+
+**Averaging happens in linear light.** Each zone's pixels are summed through the frame's tone curve, averaged, then encoded once per light. Averaging the raw bytes instead averages a quantity that is not proportional to light: a zone half black and half white comes out at 128 where the light is 188, which reads as a dim mush rather than a bright scene. The accumulators are 32-bit because a 640x480 frame onto 32x18 positions is about 520 pixels each, and 520 full-scale 12-bit samples overflow 16 bits many times over.
+
+**`edgeDepth` widens the outermost zones.** A border light's own share is one row or column of the frame, a sliver at the very edge, which picks up the letterbox and the overscan junk before it picks up the picture. `edgeDepth` lets the outermost positions reach a percentage of the way in instead, without changing how many of them there are. It costs work in proportion: on a layout where every light is an edge light, 15% sends the pixel count up about sixfold.
+
+**Black bars move the zones, which `edgeDepth` cannot.** A letterboxed film puts bars exactly where the top and bottom lights look, so those lights go dark while the picture is bright. `edgeDepth` only widens a zone from its edge, so the bar stays inside it. `detectBlackBars` finds the bar and maps the lights across the picture within it. A reading is adopted only once several frames agree, because bars come and go at scene changes and a mapping that follows every dark frame twitches worse than one that ignores them. Reaching the scan ceiling reports no bar at all: darkness that deep is a dark scene rather than a letterbox.
+
+**No source paints black.** With nothing publishing, the effect fills its layer with black rather than returning early, or the previous effect's picture would stay frozen on the strip. A merely dropped frame never reaches that path, since the service keeps its buffer and only a removed service drops it.
diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py
index 2af4563a4..99d4759cc 100644
--- a/moondeck/docs/screenshot_modules.py
+++ b/moondeck/docs/screenshot_modules.py
@@ -106,6 +106,9 @@ def asset_dir_for(type_name: str) -> Path:
("RainbowEffect", "Layer", {}, True),
("NoiseEffect", "Layer", {}, True),
# The generative-fields showcases: each is Dim::D3, so the preview shows a volume.
+ # Paints the live video frame, so with no source it photographs black. The capture points it
+ # at the test pattern, whose four coloured bands and sweeping block are what the effect is for.
+ ("AmbilightEffect", "Layer", {}, True),
("AuroraEffect", "Layer", {}, True),
("ColorTrailsEffect", "Layer", {}, True),
("TrailsEffect", "Layer", {}, True),
@@ -178,6 +181,7 @@ def asset_dir_for(type_name: str) -> Path:
# (not in state); capture these against a board when needed.
"ImprovProvisioningModule",
"AudioService",
+ "VideoService",
"AnalogService",
"I2cBusModule",
"InfraredService",
@@ -209,6 +213,7 @@ def asset_dir_for(type_name: str) -> Path:
# the desktop tree โ so they're captured against an ESP32, where these entries route the
# shot to the right nav root.
"AudioService": "Services",
+ "VideoService": "Services",
"AnalogService": "Services",
"InfraredService": "Services",
"ButtonService": "Services",
diff --git a/src/core/services/VideoService.h b/src/core/services/VideoService.h
new file mode 100644
index 000000000..e094ab88e
--- /dev/null
+++ b/src/core/services/VideoService.h
@@ -0,0 +1,304 @@
+#pragma once
+
+#include "core/util/ActiveInstance.h" // the one-active-source seat (RAII vacate on destruct)
+#include "core/util/color.h" // RGB: the pattern's band colors
+#include "core/module/MoonModule.h"
+#include "core/util/ScratchBuffer.h"
+#include "core/util/VideoFrame.h"
+#include "platform/platform.h" // fsSize / fsReadAt / millis
+
+#include
+#include
+#include
+#include
+
+namespace mm {
+
+/// The device's video input: one decoded RGB frame per tick, published through the static `latestFrame()`.
+///
+/// @moreinfo
+///
+/// ## Two sources
+///
+/// Decoded once here however many effects read it, and effects hold no pointer back to this module; the user adds it under `Services`, so it is not auto-wired.
+/// `test pattern` is a DIAGNOSTIC rather than decoration: its colored border bands make a border-mapped effect's orientation self-evident.
+/// `file` reads a binary PPM, chosen over JPEG because there is no software JPEG decoder here.
+/// Both render into `buf_`, which this module owns, so a published frame lives exactly as long as the service does.
+class VideoService : public MoonModule {
+public:
+ /// A service: it produces for others and draws nothing itself.
+ ModuleRole role() const MM_NONBLOCKING override { return ModuleRole::Service; }
+
+ // Appended, never reordered, so a persisted index keeps its meaning.
+ /// `source` value for the synthesized diagnostic pattern.
+ static constexpr uint8_t kSourcePattern = 0;
+ /// `source` value for a binary PPM on the filesystem.
+ static constexpr uint8_t kSourceFile = 1;
+ /// Dropdown labels for `source`, indexed by the constants above.
+ static constexpr const char* kSourceOptions[] = {"test pattern", "file"};
+ /// How many of `kSourceOptions` this build offers.
+ static constexpr uint8_t kSourceCount = 2;
+
+ /// Which of `kSourceOptions` fills the frame: @xref{two-sources|what each one is for}.
+ uint8_t source = kSourcePattern;
+ /// Path the `file` source reads.
+ char file[64] = "/frame.ppm";
+ // Zero parks the block, which makes the pattern a still reference for checking a border light against a known color. Seventeen is the rate it used to be fixed at: a sweep every ~4 s.
+ /// Sweep rate of the test pattern's white block, in pixels per second.
+ uint8_t patternSpeed = 17;
+
+ // Small on purpose: a border effect averages the frame down to a few dozen values, so pixels beyond that buy nothing but bandwidth and decode time.
+ /// Width of the synthesized pattern, 16:9 with kPatternH.
+ static constexpr uint16_t kPatternW = 64;
+ /// Height of the synthesized pattern.
+ static constexpr uint16_t kPatternH = 36;
+ /// Thickness of each colored edge band, in pixels.
+ static constexpr int kBand = kPatternH / 4;
+ // Comfortably past 4K, so a corrupt header is rejected at parse time rather than failing later as "too large for memory". What bounds the allocation is buf_.resize() failing.
+ /// Sanity ceiling on either dimension of a loaded file.
+ static constexpr uint32_t kMaxDim = 4096;
+
+ /// The live frame. The POINTER is never null - with no source this returns kNoVideoFrame, which has a null `rgb`. So callers test the frame's contents, never the pointer
+ static const VideoFrame* latestFrame() MM_NONBLOCKING {
+ VideoService* v = ActiveInstance::active();
+ return v ? &v->frame_ : &kNoVideoFrame;
+ }
+
+ /// Test seam: the curve as built.
+ const uint16_t* toneForTest() const { return tone_; }
+
+ /// Claims the one-active-source seat at construction, before any prepare() runs.
+ VideoService() { seat_.claim(); }
+
+ /// The source picker, plus the settings that belong to whichever source is selected.
+ void defineControls() override {
+ controls_.addSelect("source", source, kSourceOptions, kSourceCount);
+ // Live (not in affectsPrepare): the rate changes what the NEXT frame draws, and nothing about the buffer, so it must not tear the pipeline down to take effect.
+ controls_.addControl("patternSpeed", patternSpeed, 0, 255);
+ controls_.setHidden(controls_.count() - 1, source != kSourcePattern);
+ controls_.addText("file", file, sizeof(file));
+ controls_.setHidden(controls_.count() - 1, source != kSourceFile);
+ controls_.addButton("reload");
+ controls_.setHidden(controls_.count() - 1, source != kSourceFile);
+ MoonModule::defineControls();
+ }
+
+ /// A source switch changes what the buffer must hold, so it re-runs the whole build. The reload button re-reads the same file in place: cheap, and it must NOT tear down the pipeline.
+ bool affectsPrepare(const char* name) const override {
+ return std::strcmp(name, "source") == 0 || std::strcmp(name, "file") == 0;
+ }
+
+ /// Routes the one live edit, the reload button; every other control re-runs prepare().
+ void onControlChanged(const char* name) override {
+ if (std::strcmp(name, "reload") == 0) loadFile();
+ MoonModule::onControlChanged(name);
+ }
+
+ /// Cold path: size the frame buffer for the selected source and fill it once, so a frame exists before the first tick rather than one tick later.
+ void prepare() override {
+ seat_.claim(); // re-take after a disable/enable cycle: release() vacated it
+ if (source >= kSourceCount) source = kSourcePattern; // a config naming a source this build lacks
+ rebuildTone(); // both sources are display-encoded, so the curve is built once and never moves
+ if (source == kSourceFile) {
+ loadFile();
+ } else if (allocate(kPatternW, kPatternH)) {
+ renderPattern();
+ }
+ }
+
+ /// Only the synthesized pattern regenerates per frame, since it animates; a still file keeps the buffer it already holds. File I/O is blocking and belongs nowhere near this function.
+ void tick() MM_NONBLOCKING override {
+ // Take an EMPTY seat, so deleting the elected source while a second one runs hands over rather than going permanently dark. claim() only fills an empty seat, never yanks one.
+ seat_.claim();
+ if (source == kSourcePattern && buf_.data())
+ renderPattern();
+ else if (source == kSourceFile && buf_.data())
+ publish();
+ MoonModule::tick();
+ }
+
+
+
+ /// Drops the published frame and the seat, so a disabled module holds neither.
+ void release() override {
+ frame_ = VideoFrame{};
+ seat_.vacate();
+ MoonModule::release();
+ }
+
+private:
+ // Claimed at CONSTRUCTION, since an effect resolves latestFrame() during its own build, then re-claimed in prepare() after a disable/enable and in tick() so a survivor inherits it.
+ ActiveInstance seat_{*this};
+
+
+ static constexpr int kMaxHeaderBytes = 256; // room for a comment, and its own error if not
+ ScratchBuffer buf_{*this}; // width*height*3, accounted in dynamicBytes()
+ VideoFrame frame_;
+ uint32_t seq_ = 0;
+ uint16_t tone_[256] = {}; // the published curve: source encoding -> linear light
+ // Sweep position in 1/1000 px and the millis() it was last advanced at. Milli-pixels because a per-second rate sampled per tick rounds to zero motion in whole pixels at 1 px/s.
+ uint32_t sweepMilliPx_ = 0;
+ uint32_t sweepAtMs_ = 0;
+ char status_[40] = {};
+
+ /// Drop the published frame and say why. Returns false so every failing path reads as one line, `return fail("...")`, and none can forget to un-publish the stale frame.
+ bool fail(const char* why) {
+ frame_ = VideoFrame{};
+ setStatus(why, Severity::Error);
+ return false;
+ }
+
+ /// Size the buffer and point the published frame at it. False on any failure, so a too-large image degrades to "no video" rather than to a crash.
+ bool allocate(uint16_t w, uint16_t h) {
+ if (w == 0 || h == 0 || w > kMaxDim || h > kMaxDim) return fail("frame size out of range");
+ if (!buf_.resize(static_cast(w) * h * 3u)) return fail("frame too large for memory");
+ frame_.rgb = buf_.data();
+ frame_.width = w;
+ frame_.height = h;
+ // Published here, not from a tick: dimensions only change on a resize, so this keeps the snprintf off the render path.
+ std::snprintf(status_, sizeof(status_), "%ux%u", w, h);
+ setStatus(status_, Severity::Status);
+ return true;
+ }
+
+ /// Publish the buffer as a NEW frame: the sequence bump is what tells a consumer the pixels changed, so every producer path ends here (see VideoFrame::seq).
+ void publish() {
+ frame_.tone = tone_; // per frame, so a curve change lands on the next one with nothing to remember
+ frame_.seq = ++seq_;
+ }
+
+ /// Fill `tone_`: the source's transfer curve undone to linear light, where a consumer averages (the mean of encoded bytes is not the mean of the picture). Cold path: 256 float evaluations per edit or prepare, never per frame.
+ void rebuildTone() {
+ for (int i = 0; i < 256; i++) {
+ const float e = static_cast(i) / 255.0f;
+ // sRGB EOTF (IEC 61966-2-1), which is what both a PPM and the pattern carry.
+ float lin = e <= 0.04045f ? e / 12.92f : std::pow((e + 0.055f) / 1.055f, 2.4f);
+ if (lin < 0.0f) lin = 0.0f;
+ if (lin > 1.0f) lin = 1.0f;
+ tone_[i] = static_cast(lin * VideoFrame::kLinearMax + 0.5f);
+ }
+ }
+
+ // Four colored border bands and a sweeping white block. Integer-only and allocation-free: it runs on the render tick.
+ void renderPattern() {
+ uint8_t* p = buf_.data();
+ if (!p) return;
+ // An accumulator fed by elapsed time, not a position derived from millis(): a derived one jumps the block the instant the rate changes and cannot express "stopped" at all.
+ const uint32_t now = platform::millis();
+ if (sweepAtMs_ == 0) sweepAtMs_ = now; // first frame: no elapsed time to charge for
+ const uint32_t elapsed = now - sweepAtMs_;
+ sweepAtMs_ = now;
+ if (patternSpeed) sweepMilliPx_ = (sweepMilliPx_ + elapsed * patternSpeed) % (kPatternW * 1000u);
+ const int sweepX = static_cast(sweepMilliPx_ / 1000u);
+ for (int y = 0; y < kPatternH; y++) {
+ for (int x = 0; x < kPatternW; x++) {
+ // A white block riding the top edge: shows liveness, and which way "forward" runs.
+ const bool onSweep = y < kBand && x >= sweepX && x < sweepX + 4;
+ const RGB c = onSweep ? RGB{255, 255, 255} : bandColor(x, y);
+ uint8_t* px = p + (static_cast(y) * kPatternW + x) * 3;
+ px[0] = c.r;
+ px[1] = c.g;
+ px[2] = c.b;
+ }
+ }
+ publish();
+ }
+
+ /// Color of the pattern at (x, y): one hue per edge, black interior.
+ static RGB bandColor(int x, int y) {
+ if (y < kBand) return {255, 0, 0}; // top โ red
+ if (y >= kPatternH - kBand) return {0, 0, 255}; // bottom โ blue
+ if (x < kBand) return {255, 255, 0}; // left โ yellow
+ if (x >= kPatternW - kBand) return {0, 255, 0}; // right โ green
+ return {0, 0, 0}; // interior stays dark
+ }
+
+ // --- PPM (P6) file source -----------------------------------------------------------------
+ /// Read the header, size the buffer, then read the pixel block straight into it. Cold path only (prepare / the reload button): this blocks on the filesystem.
+ bool loadFile() {
+ const long size = platform::fsSize(file);
+ if (size <= 0) return fail("file not found");
+
+ // Netpbm allows comments and any run of whitespace between tokens, so a header is not a fixed length, and the ceiling has to clear the comment GIMP writes.
+ char header[kMaxHeaderBytes] = {};
+ const int headerLen = platform::fsReadAt(file, 0, header, sizeof(header) - 1);
+ uint16_t w = 0, h = 0;
+ const int pixOff = parsePpmHeader(header, headerLen, w, h);
+ if (pixOff < 0)
+ return fail(headerLen >= static_cast(sizeof(header)) - 1
+ ? "PPM header too long"
+ : "not a binary PPM (P6, maxval 255)");
+ if (!allocate(w, h)) return false; // allocate() already reported why
+
+ const uint32_t need = static_cast(w) * h * 3u;
+ if (static_cast(size - pixOff) < need) return fail("PPM truncated");
+
+ const int read = platform::fsReadAt(file, pixOff, reinterpret_cast(buf_.data()), need);
+ if (read < 0 || static_cast(read) != need) return fail("PPM read failed");
+
+ setStatus(status_, Severity::Status); // allocate() formatted the size; restore it over an error
+ publish();
+ return true;
+ }
+
+public:
+ /// Parse a binary-PPM header (Netpbm). Returns the byte offset where pixel data begins, or -1 if `buf` is not one. Pure: `len` bounds the read, so a truncated file is rejected rather than parsed into whatever follows it.
+ static int parsePpmHeader(const char* buf, int len, uint16_t& w, uint16_t& h) {
+ // P6
+ HeaderCursor cur{buf, len};
+ cur.skipBlanks();
+ if (cur.pos + 1 >= len || buf[cur.pos] != 'P' || buf[cur.pos + 1] != '6') return -1;
+ cur.pos += 2;
+
+ const long ww = cur.readInt();
+ const long hh = cur.readInt();
+ const long maxval = cur.readInt();
+ if (ww <= 0 || ww > static_cast(kMaxDim)) return -1;
+ if (hh <= 0 || hh > static_cast(kMaxDim)) return -1;
+ if (maxval != 255) return -1; // 16-bit samples are two big-endian bytes: another format
+ // Netpbm requires ONE whitespace byte here. Accepting whatever is present would eat a pixel: "P6\n2 2\n255X" would read as valid with the X swallowed.
+ if (cur.pos >= len || !HeaderCursor::isBlank(buf[cur.pos])) return -1;
+
+ w = static_cast(ww);
+ h = static_cast(hh);
+ return cur.pos + 1; // the pixels begin straight after that one byte
+ }
+
+private:
+ /// Position within an ASCII header. Netpbm separates tokens with any run of whitespace and `#` comments to end-of-line, so both readers skip those first.
+ struct HeaderCursor {
+ const char* buf;
+ int len;
+ int pos = 0;
+
+ /// Spelled out rather than isspace(), which is locale-dependent and undefined for a signed char above 127.
+ static bool isBlank(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
+
+ void skipBlanks() {
+ while (pos < len) {
+ if (buf[pos] == '#') {
+ while (pos < len && buf[pos] != '\n') pos++;
+ } else if (isBlank(buf[pos])) {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ /// Next decimal token, or -1 when the next token is not one. Capped well above any real dimension purely so a long digit run cannot overflow; the true bounds are the caller's.
+ long readInt() {
+ skipBlanks();
+ if (pos >= len || buf[pos] < '0' || buf[pos] > '9') return -1;
+ long v = 0;
+ while (pos < len && buf[pos] >= '0' && buf[pos] <= '9') {
+ v = v * 10 + (buf[pos] - '0');
+ if (v > 100000) return -1;
+ pos++;
+ }
+ return v;
+ }
+ };
+};
+
+} // namespace mm
diff --git a/src/core/util/VideoFrame.h b/src/core/util/VideoFrame.h
new file mode 100644
index 000000000..dfb462537
--- /dev/null
+++ b/src/core/util/VideoFrame.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include
+
+namespace mm {
+
+/// One decoded video frame, produced by VideoService and read by video-reactive effects.
+///
+/// @moreinfo
+///
+/// ## The tone curve
+///
+/// Borrows the producer's buffer rather than carrying the pixels, so `rgb` is valid for one effect tick and never across frames. Before any frame exists it is null, which every consumer must tolerate.
+/// `tone` maps the source's encoding to linear light, and SDR is sRGB, so a published frame always carries one.
+/// Read pixels through channel(): a consumer averaging the encoded bytes averages a quantity that is not proportional to light.
+/// The table is 12-bit rather than 8 because linear has no headroom at the dark end, and small enough that a zone of ~1M pixels still sums inside a uint32.
+struct VideoFrame {
+ /// The pixels: width*height*3, row-major, top-left origin, no padding.
+ const uint8_t* rgb = nullptr;
+ /// Frame width in pixels.
+ uint16_t width = 0;
+ /// Frame height in pixels.
+ uint16_t height = 0;
+ // Compare for INEQUALITY, never ordering: a still PPM bumps it every tick, the way a camera aimed at a still object sends one every period.
+ /// Bumped per published frame.
+ uint32_t seq = 0;
+ // Same one-tick lifetime as `rgb`.
+ /// 256-entry curve from the source's encoding to linear light, 0..kLinearMax: @xref{the-tone-curve|why a reader must use it}.
+ const uint16_t* tone = nullptr;
+ /// The full-scale value of a linear sample.
+ static constexpr uint16_t kLinearMax = 4095;
+
+ // One lookup on a read the caller already makes.
+ /// One channel of the pixel at `px` as linear light, or the encoded byte when no curve is published.
+ uint16_t channel(const uint8_t* px, int c) const { return tone ? tone[px[c]] : px[c]; }
+};
+
+/// The "no source" frame consumers fall back to.
+inline constexpr VideoFrame kNoVideoFrame{};
+
+} // namespace mm
diff --git a/src/light/effects/AmbilightEffect.h b/src/light/effects/AmbilightEffect.h
new file mode 100644
index 000000000..3927d3ade
--- /dev/null
+++ b/src/light/effects/AmbilightEffect.h
@@ -0,0 +1,374 @@
+#pragma once
+
+#include "core/services/VideoService.h"
+#include "light/effects/EffectBase.h"
+
+#include // std::max / std::min
+#include
+#include
+
+namespace mm {
+
+/// Screen-follow ambient light: paints the layer with the live video frame, so lights around a display glow the color of the picture nearest them.
+///
+/// @moreinfo
+///
+/// ## The layout decides the shape
+///
+/// Two spaces are in play. The SOURCE is the frame in pixels, the DESTINATION is the layer's box in light positions. The source is far the bigger, so each position owns a rectangle of pixels and shows their average.
+/// The effect asks the mapping only ONE question, whether a position lights anything, and skips the averaging where the answer is no. On a border layout that is most of the box.
+/// So a backlight strip shows the frame's border for free, and a full grid makes the same effect a video wall.
+class AmbilightEffect : public EffectBase {
+public:
+ /// Flat, because a frame is; the Layer extrudes z.
+ Dim dimensions() const override { return Dim::D2; }
+
+ /// Dims THE VIDEO, where the driver's brightness dims everything.
+ uint8_t brightness = 255;
+ /// Percent of the distance from gray, so 100 leaves the mean untouched.
+ uint8_t saturation = 130;
+ /// How slowly a light moves toward the frame; 0 follows it exactly.
+ uint8_t smoothing = 0;
+ /// Jump rather than smooth when a channel moves further than this; 0 never jumps.
+ uint8_t snapAbove = 80;
+ /// Percent of the frame the OUTERMOST positions look in; 0 gives them their own share.
+ uint8_t edgeDepth = 0;
+ /// Find the letterbox and map the lights across the picture inside it.
+ bool detectBlackBars = false;
+ /// A channel at or below this counts as bar, ~5%, which clears compression noise.
+ uint8_t barLevel = 12;
+
+ /// The video's own brightness and saturation, how fast a light follows it, and the two bar controls.
+ void defineControls() override {
+ controls_.addControl("brightness", brightness, 0, 255);
+ controls_.addControl("saturation", saturation, 0, 200);
+ controls_.addControl("smoothing", smoothing, 0, 255);
+ // A cut is a real jump, and smoothing through it reads as the lights lagging the picture.
+ controls_.addControl("snapAbove", snapAbove, 0, 255);
+ controls_.setHidden(controls_.count() - 1, smoothing == 0);
+ controls_.addControl("edgeDepth", edgeDepth, 0, 50); // Hyperion samples ~8%
+ // A letterboxed film puts bars where the top and bottom lights look, and edgeDepth cannot help since it widens a zone from the edge. This moves the zones instead.
+ controls_.addControl("detectBlackBars", detectBlackBars);
+ // Raise it if bars are missed, lower it if dark scenes get cropped; the doc page has why.
+ controls_.addControl("barLevel", barLevel, 0, 64);
+ controls_.setHidden(controls_.count() - 1, !detectBlackBars);
+ }
+
+ /// Turning smoothing on or off allocates or frees the accumulators, so it has to re-run prepare(): without this the buffer stays empty and the setting does nothing.
+ bool affectsPrepare(const char* name) const override { return std::strcmp(name, "smoothing") == 0; }
+
+ /// Cold path. applyState() prepares a parent before its children, so the Layer's mapping is already built when buildLitList() reads it.
+ void prepare() override {
+ // lengthType is signed: a stray negative would cast to a colossal size_t, not to nothing.
+ const lengthType w = width(), h = height();
+ const size_t positions = (w > 0 && h > 0) ? static_cast(w) * static_cast(h) : 0;
+
+ state_.resize(smoothing != 0 ? positions * 3u : 0); // 8.8 per channel, only while smoothing
+ encodeTable(); // built here, off the tick
+ primed_ = false;
+ buildLitList(positions);
+ }
+
+ /// The positions that reach an LED, packed y<<16|x, so tick() walks a few hundred of tens of thousands on a border layout: @xref{the-layout-decides-the-shape|why most of the box is skipped}.
+ void buildLitList(size_t positions) {
+ litCount_ = 0;
+ const MappingLUT& lut = layer()->lut();
+ // A table-free (identity) mapping lights every position, so the list would be 0,1,2,3, four bytes a position to say "all of them" where the plain loop needs none.
+ allLit_ = !lut.hasLUT();
+ if (allLit_ || positions == 0) {
+ lit_.resize(0);
+ return;
+ }
+ // Count first, then size to the count. Sizing by the box reserves 156 KB on a 200x200 rectangle to hold the 3 KB its perimeter needs.
+ size_t lit = 0;
+ for (size_t i = 0; i < positions; i++)
+ if (columnLit(lut, i, positions)) lit++;
+ if (lit == 0 || !lit_.resize(lit)) return; // no list: tick() paints the whole box instead
+ const lengthType w = width();
+ for (size_t i = 0; i < positions; i++)
+ if (columnLit(lut, i, positions))
+ lit_[litCount_++] = static_cast((i / w) << 16 | (i % w));
+ }
+
+ // This effect is D2 and Layer::extrude() copies z=0 across the depth. On a sparse 3D layout a column whose only LED sits at z > 0 is still lit from here.
+ /// Whether front-face position `i` reaches an LED in ANY z plane, where `slice` is width*height.
+ bool columnLit(const MappingLUT& lut, size_t i, size_t slice) const MM_NONBLOCKING {
+ const lengthType d = depth();
+ for (lengthType z = 0; z < d; z++)
+ if (lut.hasDestination(static_cast(i + static_cast(z) * slice)))
+ return true;
+ return false;
+ }
+
+ /// Average each lit position's share of the frame and paint it, or paint black when no source is publishing.
+ void tick() MM_NONBLOCKING override {
+ const VideoFrame* frame = VideoService::latestFrame();
+ const draw::Canvas out = canvas();
+
+ // No source: paint black rather than return, or the PREVIOUS effect's picture stays frozen on the strip. A merely dropped frame never lands here.
+ if (!frame->rgb || frame->width == 0 || frame->height == 0) {
+ draw::fill(out, {0, 0, 0});
+ primed_ = false; // so the next frame lands whole instead of creeping up out of black
+ return;
+ }
+
+ // The frame already on the strip, which `primed_` is what makes true: prepare() clears the layer and resets it, so a rebuild against a frozen frame would stay black.
+ if (primed_ && frame->seq == lastSeq_) return;
+ lastSeq_ = frame->seq;
+
+ const lengthType lightsX = width(), lightsY = height();
+ if (lightsX <= 0 || lightsY <= 0) return;
+
+ const Region region = regionFor(*frame);
+ if (region.width <= 0 || region.height <= 0) return;
+
+ const bool canSmooth = smootherReady(lightsX, lightsY);
+
+ // Three ways to reach the same set of positions, cheapest first.
+ if (allLit_) {
+ // Nothing to skip: the plain box, no clear, no mapping queries.
+ for (lengthType y = 0; y < lightsY; y++)
+ for (lengthType x = 0; x < lightsX; x++)
+ paint(out, *frame, region, x, y, lightsX, lightsY, canSmooth);
+ } else if (lit_) {
+ // The list. Unlit positions are never written, so they keep the black Layer::prepare() left. BlendMap never reads them, but PreviewDriver shows the raw buffer and must not see a ghost.
+ for (size_t i = 0; i < litCount_; i++)
+ paint(out, *frame, region, static_cast(lit_[i] & 0xFFFF),
+ static_cast(lit_[i] >> 16), lightsX, lightsY, canSmooth);
+ } else {
+ // The list could not be allocated. Same output, asking the mapping per position - which is the cost the list exists to avoid.
+ const MappingLUT& lut = layer()->lut();
+ const size_t slice = static_cast(lightsX) * lightsY;
+ draw::fill(out, {0, 0, 0});
+ for (lengthType y = 0; y < lightsY; y++)
+ for (lengthType x = 0; x < lightsX; x++)
+ if (columnLit(lut, static_cast(y) * lightsX + x, slice))
+ paint(out, *frame, region, x, y, lightsX, lightsY, canSmooth);
+ }
+ primed_ = true;
+ }
+
+private:
+ /// Half-open range of SOURCE pixels `[begin, end)` along one axis.
+ struct Span {
+ int begin, end;
+ Span shifted(int by) const { return {begin + by, end + by}; }
+ };
+
+ // The actual region of the source frame that the lights cover. Could be smaller than the full frame if black bars are detected.
+ struct Region {
+ int left = 0, top = 0; // where the picture starts inside the frame
+ int width = 0, height = 0;
+ int deepX = 0, deepY = 0; // edgeDepth in pixels, so the divide is not per position
+
+ /// Which source pixels one light position covers: its share of the picture, shifted back into frame coordinates. Every input lives here, so the loop only asks.
+ Span cols(int x, int lightsX) const { return spanFor(x, lightsX, width, deepX).shifted(left); }
+ Span rows(int y, int lightsY) const { return spanFor(y, lightsY, height, deepY).shifted(top); }
+ };
+
+ /// One light position: average its pixels, correct, smooth, write.
+ void paint(const draw::Canvas& out, const VideoFrame& frame, const Region& region, lengthType x,
+ lengthType y, lengthType lightsX, lengthType lightsY, bool canSmooth) MM_NONBLOCKING {
+ RGB color = adjust(meanOf(frame, region.cols(x, lightsX), region.rows(y, lightsY)));
+ if (canSmooth) color = smooth(static_cast(y) * lightsX + x, color);
+ draw::pixel(out, {x, y, 0}, color);
+ }
+
+ Region regionFor(const VideoFrame& frame) MM_NONBLOCKING {
+ const Bars bars = trackBars(frame);
+ Region r;
+ r.left = bars.left;
+ r.top = bars.top;
+ r.width = frame.width - bars.left - bars.right;
+ r.height = frame.height - bars.top - bars.bottom;
+ // Rounded UP, so any non-zero percentage is at least one pixel. Flooring would let a small setting on a small frame land on 0, which is the off value: the control would go quiet.
+ r.deepX = (r.width * edgeDepth + 99) / 100;
+ r.deepY = (r.height * edgeDepth + 99) / 100;
+ return r;
+ }
+
+ /// Whether this frame can be smoothed: turned on, and the accumulators are there and big enough.
+ bool smootherReady(lengthType lightsX, lengthType lightsY) const MM_NONBLOCKING {
+ return smoothing != 0 && state_ &&
+ state_.count() >= static_cast(lightsX) * static_cast(lightsY) * 3u;
+ }
+
+ /// How thick the bar at each edge is, in pixels
+ struct Bars {
+ int top = 0, bottom = 0, left = 0, right = 0;
+ bool operator==(const Bars& o) const {
+ return top == o.top && bottom == o.bottom && left == o.left && right == o.right;
+ }
+ };
+
+ static constexpr int kProbes = 8; // sample points per scanned line
+ static constexpr int kMaxBarPercent = 40; // a "bar" deeper than this is a dark scene
+ static constexpr uint8_t kStableFrames = 30;
+
+ /// Which edge a bar is being measured from. Top and Bottom scan rows, Left and Right columns.
+ enum class Edge : uint8_t { Top, Bottom, Left, Right };
+
+ static bool scansRows(Edge e) MM_NONBLOCKING { return e == Edge::Top || e == Edge::Bottom; }
+ static bool scansFromEnd(Edge e) MM_NONBLOCKING { return e == Edge::Bottom || e == Edge::Right; }
+
+ /// Is this line dark all the way across? Sampled at a few evenly spaced points rather than every pixel: a bar is uniform, so a handful of probes settles it for a fraction of the cost.
+ bool lineIsDark(const VideoFrame& frame, int line, Edge edge) const MM_NONBLOCKING {
+ const bool horizontal = scansRows(edge);
+ const int along = horizontal ? frame.width : frame.height;
+ for (int i = 0; i < kProbes; i++) {
+ const int v = along * (2 * i + 1) / (2 * kProbes); // midpoints, so the corners are skipped
+ const int x = horizontal ? v : line;
+ const int y = horizontal ? line : v;
+ const uint8_t* px = frame.rgb + (static_cast(y) * frame.width + x) * 3;
+ if (px[0] > barLevel || px[1] > barLevel || px[2] > barLevel) return false;
+ }
+ return true;
+ }
+
+ // Returning the ceiling cropped a dark frame to its middle, and kStableFrames then held that into the next scene.
+ /// How many dark lines run inward from one edge, where reaching the ceiling reports NO bar: darkness that deep is a dark SCENE rather than a letterbox.
+ int barFrom(const VideoFrame& frame, Edge edge) const MM_NONBLOCKING {
+ const int extent = scansRows(edge) ? frame.height : frame.width;
+ const int limit = extent * kMaxBarPercent / 100;
+ for (int i = 0; i < limit; i++) {
+ const int line = scansFromEnd(edge) ? extent - 1 - i : i;
+ if (!lineIsDark(frame, line, edge)) return i;
+ }
+ return 0;
+ }
+
+ // Bars come and go at scene changes, and a mapping that follows every dark frame twitches worse than one that ignores them. Hence the state.
+ /// Scan this frame and return the bars IN EFFECT, which is what the caller maps across: a reading is adopted only once kStableFrames of them agree.
+ Bars trackBars(const VideoFrame& frame) MM_NONBLOCKING {
+ if (!detectBlackBars) {
+ // All of it, not just the adopted value. A surviving candidate_ with a saturated stable_ would make the next enable agree with itself at once and never re-adopt.
+ bars_ = candidate_ = Bars{};
+ stable_ = 0;
+ return bars_;
+ }
+ Bars found;
+ found.top = barFrom(frame, Edge::Top);
+ found.bottom = barFrom(frame, Edge::Bottom);
+ found.left = barFrom(frame, Edge::Left);
+ found.right = barFrom(frame, Edge::Right);
+ if (!(found == candidate_)) {
+ candidate_ = found;
+ stable_ = 0;
+ } else if (stable_ < kStableFrames && ++stable_ == kStableFrames) {
+ bars_ = found;
+ }
+ return bars_;
+ }
+
+ /// Which source pixels light position `lightId` covers along one axis.
+ /// - `pixels` shared evenly among `lightsSize` positions, cut at the edges so ranges meet exactly
+ /// - a position ON an edge takes exactly `deep` instead of its share, deeper OR shallower, so the control sets the depth rather than raising a floor under it
+ /// - `deep` of 0 leaves the plain division, and interior positions are on no edge either way
+ /// - an empty range widens to one pixel, so a strip finer than the picture still lights up
+ static Span spanFor(int lightId, int lightsSize, int pixels, int deep) {
+ int begin = static_cast((static_cast(lightId) * pixels) / lightsSize);
+ int end = static_cast((static_cast(lightId + 1) * pixels) / lightsSize);
+ if (deep > 0) {
+ if (lightId == 0)
+ end = deep; // down/right, inward
+ else if (lightId == lightsSize - 1)
+ begin = pixels - deep; // up/left, inward
+ }
+ if (begin < 0) begin = 0;
+ if (begin >= pixels) begin = pixels - 1;
+ if (end <= begin) end = begin + 1;
+ if (end > pixels) end = pixels;
+ return {begin, end};
+ }
+
+ /// Linear light back to a display byte (sRGB OETF). Built on first use, which prepare() makes a cold path: 4096 pow() calls have no place in a tick.
+ static const uint8_t* encodeTable() {
+ static const auto table = [] {
+ std::array t{};
+ for (size_t i = 0; i < t.size(); i++) {
+ const float lin = static_cast(i) / VideoFrame::kLinearMax;
+ const float v = lin <= 0.0031308f ? 12.92f * lin : 1.055f * std::pow(lin, 1.0f / 2.4f) - 0.055f;
+ t[i] = static_cast(v * 255.0f + 0.5f);
+ }
+ return t;
+ }();
+ return table.data();
+ }
+
+ // uint32 accumulators, because 640x480 onto 32x18 is ~520 pixels each and 520 x 4095 overflows 16 bits many times over.
+ /// Mean of one light position's pixels: the box filter Hyperion uses, taken in linear light and encoded once per LIGHT.
+ static RGB meanOf(const VideoFrame& frame, Span cols, Span rows) {
+ uint32_t sr = 0, sg = 0, sb = 0;
+ for (int py = rows.begin; py < rows.end; py++) {
+ const uint8_t* px = frame.rgb + (static_cast(py) * frame.width + cols.begin) * 3;
+ for (int pxX = cols.begin; pxX < cols.end; pxX++, px += 3) {
+ sr += frame.channel(px, 0);
+ sg += frame.channel(px, 1);
+ sb += frame.channel(px, 2);
+ }
+ }
+ const uint32_t pixels =
+ static_cast(rows.end - rows.begin) * static_cast(cols.end - cols.begin);
+ const uint32_t mr = sr / pixels, mg = sg / pixels, mb = sb / pixels;
+ if (!frame.tone) return {static_cast(mr), static_cast(mg), static_cast(mb)};
+ const uint8_t* enc = encodeTable();
+ return {enc[mr], enc[mg], enc[mb]};
+ }
+
+ /// Move one channel `saturation` percent of the way out from `luma`, clamped to a byte.
+ uint8_t stretch(uint8_t v, int luma) const MM_NONBLOCKING {
+ const int out = luma + ((static_cast(v) - luma) * static_cast(saturation)) / 100;
+ return static_cast(out < 0 ? 0 : (out > 255 ? 255 : out));
+ }
+
+ /// Saturation runs on the RAW mean, before brightness: stretching around an already-dimmed luma would shrink the boost as the lights were turned down.
+ RGB adjust(RGB c) const {
+ if (saturation != 100) {
+ // Rec.601 weights (77/150/29 of 256). A flat (r+g+b)/3 would brighten greens and dim blues as saturation rose, because it is not what the eye does.
+ const int luma = static_cast((77 * c.r + 150 * c.g + 29 * c.b) >> 8);
+ c = {stretch(c.r, luma), stretch(c.g, luma), stretch(c.b, luma)};
+ }
+ if (brightness != 255) {
+ c = {static_cast((c.r * brightness) / 255),
+ static_cast((c.g * brightness) / 255),
+ static_cast((c.b * brightness) / 255)};
+ }
+ return c;
+ }
+
+ /// Walk each channel a fraction of the way toward `color`. The state is 8.8 so the fraction of a step survives between frames: in whole bytes a slow setting rounds every step to zero.
+ RGB smooth(size_t lightId, RGB color) MM_NONBLOCKING {
+ const uint8_t target[3] = {color.r, color.g, color.b};
+ const int32_t step = 256 - smoothing; // gap closed per frame, of 256
+ const int32_t snap = static_cast(snapAbove) << 8; // 8.8, to compare against delta
+ uint8_t out[3];
+ for (uint8_t ch = 0; ch < 3; ch++) {
+ const size_t slot = lightId * 3 + ch; // three accumulators per position, row-major
+ const int32_t held = state_[slot];
+ const int32_t want = static_cast(target[ch]) << 8;
+ const int32_t delta = want - held;
+ // The first frame after a gap, and any move big enough to be a cut, land whole.
+ const bool jump = !primed_ || (snapAbove && (delta > snap || delta < -snap));
+ // >> floors, so without the nudge a rising channel stalls one count short for ever, rendering white as 254, while a falling one arrives.
+ int32_t move = (delta * step) >> 8;
+ if (move == 0 && delta != 0) move = delta > 0 ? 1 : -1;
+ state_[slot] = static_cast(jump ? want : held + move);
+ out[ch] = static_cast(state_[slot] >> 8);
+ }
+ return {out[0], out[1], out[2]};
+ }
+
+ Bars bars_; // in effect
+ Bars candidate_; // seen most recently
+ uint8_t stable_ = 0;
+
+ ScratchBuffer lit_{*this}; // packed y<<16|x, the positions that reach an LED
+ size_t litCount_ = 0;
+ bool allLit_ = true; // no list: the mapping lights every position
+
+ ScratchBuffer state_{*this}; // 8.8 per channel per light position, while smoothing is on
+ bool primed_ = false; // false until one frame has been written
+ uint32_t lastSeq_ = 0; // the frame already on the strip
+};
+
+} // namespace mm
diff --git a/src/light/layers/MappingLUT.h b/src/light/layers/MappingLUT.h
index 416f283a7..e57bbb3fa 100644
--- a/src/light/layers/MappingLUT.h
+++ b/src/light/layers/MappingLUT.h
@@ -149,6 +149,13 @@ class MappingLUT {
+ static_cast(maxDest) * sizeof(nrOfLightsType);
}
+ /// Hot path: does this logical index reach any physical light? Empty run = no destination.
+ bool hasDestination(nrOfLightsType logicalIdx) const MM_NONBLOCKING {
+ if (identity_) return true;
+ if (!offsets_ || logicalIdx >= logicalCount_) return false;
+ return offsets_[logicalIdx + 1] > offsets_[logicalIdx];
+ }
+
/// Walk the physical destinations of one logical light, on the hot path.
template
void forEachDestination(nrOfLightsType logicalIdx, F&& callback) const {
diff --git a/src/module_types.cpp b/src/module_types.cpp
index edc9d0726..02ca9e010 100644
--- a/src/module_types.cpp
+++ b/src/module_types.cpp
@@ -84,6 +84,7 @@
#include "light/effects/RadialSpectrumEffect.h"
#include "light/effects/VuMetersEffect.h"
#include "light/effects/BeatRipplesEffect.h"
+#include "light/effects/AmbilightEffect.h"
#include "light/effects/AudioSpectrumEffect.h"
#include "light/effects/SineEffect.h"
#include "light/effects/DistortionWavesEffect.h"
@@ -177,6 +178,7 @@
#include "core/system/ControlModule.h"
#include "core/services/Services.h"
#include "core/services/AudioService.h"
+#include "core/services/VideoService.h"
#include "core/services/OscModule.h"
#include "core/system/I2cBusModule.h"
#include "core/system/TasksModule.h"
@@ -237,6 +239,7 @@ void mm::registerModuleTypes() {
mm::ModuleFactory::registerType("TubesLayout", "light/layouts.md#tubes");
mm::ModuleFactory::registerType("WheelLayout", "light/layouts.md#wheel");
// Effects, registered alphabetically by display name: @xref{what-registertype-captures}.
+ mm::ModuleFactory::registerType("AmbilightEffect", "light/effects.md#ambilight");
mm::ModuleFactory::registerType("AudioSpectrumEffect", "light/effects.md#audiospectrum");
mm::ModuleFactory::registerType("RadialSpectrumEffect", "light/effects.md#radialspectrum");
mm::ModuleFactory::registerType("VuMetersEffect", "light/effects.md#vumeters");
@@ -355,6 +358,7 @@ void mm::registerModuleTypes() {
mm::ModuleFactory::registerType("ControlModule", "core/system.md#control");
mm::ModuleFactory::registerType("Services", "core/services.md#services");
mm::ModuleFactory::registerType("AudioService", "core/services.md#audio");
+ mm::ModuleFactory::registerType("VideoService", "core/services.md#video");
mm::ModuleFactory::registerType("OscModule", "core/services.md#osc");
mm::ModuleFactory::registerType("I2cBusModule", "core/system.md#i2c-bus");
mm::ModuleFactory::registerType("TasksModule", "core/system.md#tasks");
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 94626e2e5..0b5b422a3 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -7,6 +7,7 @@ add_executable(mm_tests
unit/core/unit_SpscRing.cpp
unit/core/unit_AnalogService.cpp
unit/core/unit_AudioCapture.cpp
+ unit/core/unit_VideoService.cpp
unit/core/unit_platform_audiofft.cpp
unit/core/unit_Buffer.cpp
unit/core/unit_ScratchBuffer.cpp
@@ -165,6 +166,7 @@ add_executable(mm_tests
unit/light/unit_Shader.cpp
unit/light/unit_PolarLut.cpp
unit/light/unit_PolarLut_equivalence.cpp
+ unit/light/unit_AmbilightEffect.cpp
unit/light/unit_AuroraEffect.cpp
unit/light/unit_NoiseEffect.cpp
unit/light/unit_Effects_framerate.cpp
diff --git a/test/scenarios/light/scenario_Video_source.json b/test/scenarios/light/scenario_Video_source.json
new file mode 100644
index 000000000..aeedbaa14
--- /dev/null
+++ b/test/scenarios/light/scenario_Video_source.json
@@ -0,0 +1,317 @@
+{
+ "name": "scenario_Video_source",
+ "module": "VideoService",
+ "mode": "mutate",
+ "also": [
+ "SystemModule",
+ "Services",
+ "Layouts",
+ "GridLayout",
+ "Effects",
+ "Layer",
+ "RainbowEffect",
+ "Drivers",
+ "PreviewDriver",
+ "AmbilightEffect"
+ ],
+ "description": "Add / configure / remove the VideoService while the render pipeline runs, the video half of what scenario_Audio_mutation pins for audio. A Service is user-added at runtime, so every step asserts the pipeline keeps RENDERING through it: adding the service, writing its controls one at a time (source is an affectsPrepare control, so that write drives a full prepareTree mid-render), and removing it again. This also pins the type REGISTRY, since the runner builds every module through the firmware's own src/module_types.cpp: a service left out of it cannot be named here, where the unit tests construct the class directly and pass either way. It then adds the AmbilightEffect consumer, configures it, and REMOVES the service while the consumer is still live. That last step is what the one-active-source seat exists for: latestFrame() falls back to the static kNoVideoFrame and the effect paints black instead of dereferencing a frame whose buffer went with the service. Pixel correctness is pinned by unit_AmbilightEffect and unit_VideoService; this is the live wired-pipeline gate.",
+ "fixture": [
+ {
+ "name": "fix-system",
+ "description": "SystemModule: the device's fixed identity/vitals root, mirroring main.cpp.",
+ "op": "add_module",
+ "id": "System",
+ "type": "SystemModule"
+ },
+ {
+ "name": "fix-services",
+ "description": "Services: the top-level container user-added capability modules hang under, mirroring main.cpp where VideoService is a Service, not a System child.",
+ "op": "add_module",
+ "id": "Services",
+ "type": "Services"
+ },
+ {
+ "name": "fix-layouts",
+ "op": "add_module",
+ "id": "Layouts",
+ "type": "Layouts"
+ },
+ {
+ "name": "fix-grid",
+ "description": "The grid the pipeline renders on: 16:9 at 96x54. No effect reads the video frame in this scenario, so the layout is here to give the pipeline something real to tick, not to be sampled.",
+ "op": "add_module",
+ "id": "Grid",
+ "type": "GridLayout",
+ "parent_id": "Layouts",
+ "props": {
+ "width": 96,
+ "height": 54
+ }
+ },
+ {
+ "name": "fix-layers",
+ "op": "add_module",
+ "id": "Effects",
+ "type": "Effects",
+ "props": {
+ "layouts": "Layouts"
+ }
+ },
+ {
+ "name": "fix-layer",
+ "op": "add_module",
+ "id": "Layer",
+ "type": "Layer",
+ "parent_id": "Effects",
+ "props": {
+ "channelsPerLight": 3
+ }
+ },
+ {
+ "name": "fix-rainbow",
+ "description": "A non-video effect so the pipeline has something to render before, and after, the video pair exists.",
+ "op": "add_module",
+ "id": "Rainbow",
+ "type": "RainbowEffect",
+ "parent_id": "Layer"
+ },
+ {
+ "name": "fix-drivers",
+ "op": "add_module",
+ "id": "Drivers",
+ "type": "Drivers",
+ "props": {
+ "effects": "Effects"
+ }
+ },
+ {
+ "name": "fix-preview",
+ "op": "add_module",
+ "id": "Preview",
+ "type": "PreviewDriver",
+ "parent_id": "Drivers"
+ }
+ ],
+ "steps": [
+ {
+ "name": "measure-pipeline-only",
+ "description": "Baseline: the render pipeline runs with no video module present.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 16,
+ "p95": 16,
+ "min": 16,
+ "max": 16,
+ "n": 1,
+ "samples": [16]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ },
+ {
+ "name": "add-video-module",
+ "description": "Add the VideoService under the Services container. It claims the one-active-source seat on construction and renders its test pattern; the pipeline must keep rendering.",
+ "op": "add_module",
+ "id": "Video",
+ "type": "VideoService",
+ "parent_id": "Services"
+ },
+ {
+ "name": "measure-video-added",
+ "description": "Pipeline still renders with the video service added and no consumer reading it.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 16,
+ "p95": 16,
+ "min": 16,
+ "max": 16,
+ "n": 1,
+ "samples": [16]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ },
+ {
+ "name": "configure-source",
+ "description": "Select the test-pattern source. This is an affectsPrepare control, so the write drives a full prepareTree mid-render: the buffer is resized and refilled while the pipeline ticks.",
+ "op": "set_control",
+ "id": "Video",
+ "key": "source",
+ "value": 0
+ },
+ {
+ "name": "measure-video-configured",
+ "description": "Pipeline still renders after the config writes, one of which rebuilt the tree.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 16,
+ "p95": 16,
+ "min": 16,
+ "max": 16,
+ "n": 1,
+ "samples": [16]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ },
+ {
+ "name": "add-video-consumer",
+ "description": "Add an AmbilightEffect under the Layer. It reads the service through the static accessor, so it picks up the live frame with nothing having wired the two together.",
+ "op": "add_module",
+ "id": "Ambi",
+ "type": "AmbilightEffect",
+ "parent_id": "Layer"
+ },
+ {
+ "name": "measure-consumer-live",
+ "description": "Producer and consumer both live: the border is being painted from the published frame.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 70,
+ "p95": 70,
+ "min": 70,
+ "max": 70,
+ "n": 1,
+ "samples": [70]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ },
+ {
+ "name": "configure-consumer-brightness",
+ "description": "Configure the consumer while it renders: a plain scalar write that touches no buffer, so the resize in the next step is the only allocation under test.",
+ "op": "set_control",
+ "id": "Ambi",
+ "key": "brightness",
+ "value": 200
+ },
+ {
+ "name": "configure-consumer-smoothing",
+ "description": "Smoothing allocates the 8.8 per-channel state in prepare(), so this write resizes a live effect's buffer mid-render.",
+ "op": "set_control",
+ "id": "Ambi",
+ "key": "smoothing",
+ "value": 64
+ },
+ {
+ "name": "measure-consumer-configured",
+ "description": "Pipeline still renders after both consumer writes, one of which resized a buffer.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 114,
+ "p95": 114,
+ "min": 114,
+ "max": 114,
+ "n": 1,
+ "samples": [114]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ },
+ {
+ "name": "remove-video-module",
+ "description": "Remove the service while the consumer is STILL live. latestFrame() must fall back to the static kNoVideoFrame, and the effect must paint black rather than dereference a frame whose buffer went with the service. This is the robustness rule's hardest case for this pair, and the one the seat and the borrowed-buffer teardown order exist for.",
+ "op": "remove_module",
+ "id": "Video"
+ },
+ {
+ "name": "measure-after-video-removed",
+ "description": "Pipeline still renders with an orphaned consumer and no producer.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 20,
+ "p95": 20,
+ "min": 20,
+ "max": 20,
+ "n": 1,
+ "samples": [20]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ },
+ {
+ "name": "remove-video-consumer",
+ "description": "Remove the orphaned consumer too: clean teardown, pipeline still live.",
+ "op": "remove_module",
+ "id": "Ambi"
+ },
+ {
+ "name": "measure-back-to-baseline",
+ "description": "Back to the baseline tree, still rendering.",
+ "op": "measure",
+ "measure": true,
+ "bounds": {
+ "fps": {
+ "min": 1
+ }
+ },
+ "observed": {
+ "desktop-macos": {
+ "tick_us": {
+ "p50": 16,
+ "p95": 16,
+ "min": 16,
+ "max": 16,
+ "n": 1,
+ "samples": [16]
+ },
+ "last_updated": "2026-10-10"
+ }
+ }
+ }
+ ]
+}
diff --git a/test/unit/core/unit_VideoService.cpp b/test/unit/core/unit_VideoService.cpp
new file mode 100644
index 000000000..ceb7dc915
--- /dev/null
+++ b/test/unit/core/unit_VideoService.cpp
@@ -0,0 +1,177 @@
+/// @module VideoService
+///
+/// Pins the PPM header grammar VideoService's file source accepts, the seat election that decides which service publishes, and the test pattern's sweep rate.
+///
+/// @moreinfo
+///
+/// The parser is the part with real edge cases: comments, whitespace runs, a 16-bit maxval, a truncated header.
+/// It also decides where pixel data starts, so a wrong offset shows as a picture shifted by a few bytes rather than as a clean failure.
+/// Driven directly, since it is a pure static, so these run without a filesystem and a malformed file is testable without writing one.
+
+#include "doctest.h"
+#include "core/services/VideoService.h"
+
+#include
+#include
+
+using mm::VideoService;
+
+namespace {
+// Parse helper: returns the pixel offset, or -1, and reports the dimensions it read.
+int parse(const char* text, uint16_t& w, uint16_t& h) {
+ return VideoService::parsePpmHeader(text, static_cast(std::strlen(text)), w, h);
+}
+} // namespace
+
+// The canonical form ffmpeg and ImageMagick emit: magic, dimensions, maxval, one newline, pixels.
+TEST_CASE("VideoService PPM: a canonical P6 header yields the dimensions and the pixel offset") {
+ uint16_t w = 0, h = 0;
+ const char* hdr = "P6\n64 36\n255\n";
+ CHECK(parse(hdr, w, h) == 13); // pixels begin straight after the final newline
+ CHECK(w == 64);
+ CHECK(h == 36);
+}
+
+// Netpbm allows any run of whitespace between tokens and `#` comments to end of line. Both appear in real files, so both must be skipped without shifting the offset.
+TEST_CASE("VideoService PPM: comments and whitespace runs are skipped, not counted as pixels") {
+ uint16_t w = 0, h = 0;
+ const char* hdr = "P6\n# CREATOR: GIMP\n 16 9 \n255\n";
+ const int off = parse(hdr, w, h);
+ CHECK(off == static_cast(std::strlen(hdr)));
+ CHECK(w == 16);
+ CHECK(h == 9);
+}
+
+// Exactly ONE whitespace byte separates the header from the binary block; any further byte is already a pixel. Consuming two would tint the whole image by shifting every channel one place.
+TEST_CASE("VideoService PPM: only one separator byte is consumed before the pixels") {
+ uint16_t w = 0, h = 0;
+ // A leading pixel byte that happens to be whitespace-valued (0x20) must survive as data.
+ const char hdr[] = {'P', '6', '\n', '2', ' ', '2', '\n', '2', '5', '5', '\n', ' ', 'X'};
+ const int off = VideoService::parsePpmHeader(hdr, static_cast(sizeof(hdr)), w, h);
+ CHECK(off == 11); // after the newline: NOT after the following 0x20
+ CHECK(w == 2);
+ CHECK(h == 2);
+}
+
+// P3 is the ASCII variant: same dimensions, completely different body (decimal text, not bytes). Accepting it would read numerals as pixel values and render noise.
+TEST_CASE("VideoService PPM: the ASCII variant P3 is rejected, not read as binary") {
+ uint16_t w = 0, h = 0;
+ CHECK(parse("P3\n8 8\n255\n", w, h) == -1);
+}
+
+// A 16-bit maxval means two big-endian bytes per sample: a different pixel format. Reading it as 8-bit would show the high bytes as a dim, doubled image, so it is refused rather than guessed at.
+TEST_CASE("VideoService PPM: a 16-bit maxval is rejected rather than misread as 8-bit") {
+ uint16_t w = 0, h = 0;
+ CHECK(parse("P6\n8 8\n65535\n", w, h) == -1);
+}
+
+// Garbage, an empty buffer, and a header cut off mid-token must all fail cleanly. The file source is fed by whatever the user uploads, so this is the ordinary case.
+TEST_CASE("VideoService PPM: malformed and truncated headers fail without reading past the buffer") {
+ uint16_t w = 0, h = 0;
+ CHECK(parse("", w, h) == -1);
+ CHECK(parse("not an image at all", w, h) == -1);
+ CHECK(parse("P6", w, h) == -1); // magic only
+ CHECK(parse("P6\n64", w, h) == -1); // no height
+ CHECK(parse("P6\n64 36\n", w, h) == -1); // no maxval
+ CHECK(parse("P6\n64 36\n255", w, h) == -1); // no separator, so no pixel data can follow
+}
+
+// A zero side has no pixels, and an absurd dimension would overflow the width*height*3 allocation size. Both are refused at the header rather than at the allocation.
+TEST_CASE("VideoService PPM: zero and out-of-range dimensions are refused at the header") {
+ uint16_t w = 0, h = 0;
+ CHECK(parse("P6\n0 36\n255\n", w, h) == -1);
+ CHECK(parse("P6\n64 0\n255\n", w, h) == -1);
+ CHECK(parse("P6\n99999 36\n255\n", w, h) == -1); // past kMaxDim
+}
+
+// With no service instantiated, latestFrame() still returns a readable struct, so an effect null-checks the frame's contents rather than the POINTER. Every device is in that state at boot.
+TEST_CASE("VideoService: latestFrame is readable with no service present and reports no frame") {
+ const mm::VideoFrame* f = VideoService::latestFrame();
+ REQUIRE(f != nullptr);
+ CHECK(f->rgb == nullptr);
+ CHECK(f->seq == 0);
+ CHECK(f->width == 0);
+ CHECK(f->height == 0);
+}
+
+// Deleting the elected source while a second one runs must hand the seat over rather than go dark. The destructor vacates it and a running module re-claims an empty one on its next tick.
+TEST_CASE("VideoService: a survivor takes over the seat when the elected source is destroyed") {
+ auto* elected = new VideoService(); // constructed first, so it claims the seat
+ elected->source = VideoService::kSourcePattern; // needs no file
+ elected->applyState();
+ REQUIRE(VideoService::latestFrame()->rgb != nullptr);
+
+ VideoService survivor; // seat already held, so its claim is a no-op
+ survivor.source = VideoService::kSourcePattern;
+ survivor.applyState();
+
+ delete elected; // ~ActiveInstance vacates: the seat is now empty
+ CHECK(VideoService::latestFrame()->rgb == nullptr);
+
+ survivor.tick(); // the survivor inherits it
+ CHECK(VideoService::latestFrame()->rgb != nullptr);
+}
+
+// Accepting a non-whitespace separator eats a pixel and shifts every channel one place, which tints the whole image rather than failing.
+TEST_CASE("VideoService PPM: the separator must be whitespace, not merely present") {
+ uint16_t w = 0, h = 0;
+ CHECK(parse("P6\n2 2\n255X", w, h) == -1);
+ CHECK(parse("P6\n2 2\n255\n", w, h) == 11); // the same header with a real separator
+}
+
+// The sweep is a RATE rather than a phase of the clock, so a rate change moves smoothly rather than jumping, and 0 parks the block as a still reference.
+TEST_CASE("VideoService: the test pattern sweeps at patternSpeed pixels per second, and 0 parks it") {
+ struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } } guard;
+ // Column of the white block on the top row, which is otherwise the red band.
+ auto sweepX = [] {
+ const mm::VideoFrame* f = VideoService::latestFrame();
+ for (int x = 0; x < f->width; x++)
+ if (f->rgb[static_cast(x) * 3 + 1] == 255) return x;
+ return -1;
+ };
+
+ mm::platform::setTestNowMs(1000);
+ VideoService v;
+ v.source = VideoService::kSourcePattern;
+ v.patternSpeed = 10;
+ v.applyState(); // the first frame takes the clock and charges nothing
+ const int start = sweepX();
+ REQUIRE(start >= 0);
+
+ mm::platform::setTestNowMs(1500); // 0.5 s at 10 px/s
+ v.tick();
+ CHECK(sweepX() == (start + 5) % VideoService::kPatternW);
+
+ v.patternSpeed = 0;
+ mm::platform::setTestNowMs(4000);
+ v.tick();
+ CHECK(sweepX() == (start + 5) % VideoService::kPatternW); // parked, however long passes
+}
+
+// Every source publishes one, SDR included: sRGB is a curve like any other, and a consumer averaging raw bytes averages a quantity that is not proportional to light.
+TEST_CASE("VideoService: an SDR source publishes the sRGB curve, not nothing") {
+ VideoService v;
+ v.source = VideoService::kSourcePattern;
+ v.applyState();
+ const uint16_t* t = VideoService::latestFrame()->tone;
+ REQUIRE(t != nullptr);
+ CHECK(t[0] == 0);
+ CHECK(t[255] == mm::VideoFrame::kLinearMax);
+ // sRGB's midpoint is about 21% of full light: the whole reason averaging bytes is wrong.
+ CHECK(t[128] < mm::VideoFrame::kLinearMax / 3);
+ CHECK(t[128] > mm::VideoFrame::kLinearMax / 8);
+}
+
+// The frame carries its curve and consumers read through channel(), so every reader corrects the same way and none has to know which curve it is. Null means the bytes are taken as they are.
+TEST_CASE("VideoFrame: channel() reads through the tone curve when one is published") {
+ uint8_t px[3] = {10, 20, 30};
+ uint16_t tone[256];
+ for (int i = 0; i < 256; i++) tone[i] = static_cast(255 - i);
+ mm::VideoFrame f;
+ f.rgb = px;
+ CHECK(f.channel(px, 0) == 10);
+ CHECK(f.channel(px, 2) == 30);
+ f.tone = tone;
+ CHECK(f.channel(px, 0) == 245);
+ CHECK(f.channel(px, 1) == 235);
+}
diff --git a/test/unit/light/unit_AmbilightEffect.cpp b/test/unit/light/unit_AmbilightEffect.cpp
new file mode 100644
index 000000000..08ad9070d
--- /dev/null
+++ b/test/unit/light/unit_AmbilightEffect.cpp
@@ -0,0 +1,567 @@
+/// @module AmbilightEffect
+///
+/// Pins the frame to light mapping end to end through the real static seam: a live VideoService publishes a frame, the effect renders it, the buffer is read back.
+///
+/// @moreinfo
+///
+/// The checks are about orientation and coverage, because a picture averaged over the wrong rectangle, or flipped top for bottom, still looks like *a* picture.
+
+#include "doctest.h"
+#include "core/services/VideoService.h"
+#include "light/effects/AmbilightEffect.h"
+#include "light/layouts/GridLayout.h"
+#include "light/layouts/LayoutBase.h"
+#include "light/layouts/SphereLayout.h"
+#include "platform/platform.h" // fsRootPath: ctest roots the filesystem in the build tree
+#include "light/layouts/Layouts.h"
+
+#include
+#include
+#include
+
+using mm::AmbilightEffect;
+using mm::VideoService;
+namespace platform = mm::platform;
+
+namespace {
+
+// A live VideoService in test-pattern mode: red top band, blue bottom, yellow left, green right. Its seat is claimed on construction and vacated on destruction, so each case starts clean.
+struct PatternSource {
+ VideoService svc;
+ PatternSource() {
+ svc.source = 0; // test pattern
+ svc.applyState(); // builds the buffer and renders the first frame
+ }
+};
+
+// Render one frame of the effect over a `w`x`h` grid and hand back the layer for inspection.
+struct Rig {
+ mm::Layouts layouts;
+ mm::GridLayout grid;
+ mm::Layer layer;
+ AmbilightEffect fx;
+
+ Rig(uint16_t w, uint16_t h) {
+ grid.width = w;
+ grid.height = h;
+ grid.depth = 1;
+ layouts.addChild(&grid);
+ layer.setLayouts(&layouts);
+ layer.setChannelsPerLight(3);
+ layer.addChild(&fx);
+ }
+ void render() {
+ layer.applyState();
+ layer.tick();
+ }
+ // applyState() re-runs prepare(), which un-primes the smoother and makes every frame jump. Anything testing the SMOOTHING has to tick without it.
+ void tickOnly() { layer.tick(); }
+ /// A tick with a NEW frame behind it: the effect skips a repeated one, so anything measuring per-frame behavior has to advance the source too, as the scheduler does.
+ void tickOnly(VideoService& source) {
+ source.tick();
+ layer.tick();
+ }
+ const uint8_t* px(int x, int y) const {
+ return layer.buffer().data() + (static_cast(y) * grid.width + x) * 3;
+ }
+};
+
+} // namespace
+
+// Orientation must survive to the buffer: red top band โ red first row. Swapped, the whole picture is upside down: on a TV border, the difference between matching the screen and mirroring it.
+TEST_CASE("AmbilightEffect: the frame's orientation reaches the buffer, top band to top row") {
+ PatternSource src;
+ Rig rig(8, 8);
+ rig.fx.saturation = 100; // identity, so the raw zone means are what we read
+ rig.render();
+
+ const uint8_t* top = rig.px(4, 0);
+ const uint8_t* bottom = rig.px(4, 7);
+ CHECK(top[0] > top[2]); // top row reads red-dominant
+ CHECK(bottom[2] > bottom[0]); // bottom row reads blue-dominant
+}
+
+// The horizontal counterpart of the test above; together they pin all four edges. Red separates the bands: it is in yellow and absent from green.
+TEST_CASE("AmbilightEffect: the frame's left and right bands reach the matching columns") {
+ PatternSource src;
+ Rig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.render();
+
+ const uint8_t* left = rig.px(0, 4); // mid-height, so neither the top nor bottom band
+ const uint8_t* right = rig.px(7, 4);
+ CHECK(left[0] > right[0]); // yellow carries red; green does not
+ CHECK(right[1] > 0); // and the right column is lit at all
+}
+
+// Every light must be written. An empty zone leaves its light holding the previous frame, so a mapping bug shows as dead lights scattered through the strip rather than an obvious failure.
+TEST_CASE("AmbilightEffect: every light is written, none left dark by an empty zone") {
+ PatternSource src;
+ Rig rig(16, 9);
+ rig.fx.saturation = 100;
+ rig.render();
+
+ // WRITTEN, not lit: the pattern's center is black, so counting lit cells cannot tell "every position was painted" from "one was". Fill with a value the effect cannot produce instead.
+ constexpr uint8_t kSentinel = 0x5A;
+ std::memset(rig.layer.buffer().data(), kSentinel, static_cast(16) * 9 * 3);
+ rig.render();
+
+ for (int y = 0; y < 9; y++)
+ for (int x = 0; x < 16; x++) {
+ const uint8_t* p = rig.px(x, y);
+ const bool untouched = p[0] == kSentinel && p[1] == kSentinel && p[2] == kSentinel;
+ CHECK_FALSE(untouched);
+ }
+ // The corners sit inside the colored bands and must never be dark.
+ for (const auto& [x, y] : {std::pair{0, 0}, std::pair{15, 0}, std::pair{0, 8}, std::pair{15, 8}}) {
+ const uint8_t* p = rig.px(x, y);
+ CHECK((p[0] || p[1] || p[2]));
+ }
+}
+
+// More lights than source pixels: neighboring positions must SHARE one rather than resolve to an empty zone. The pattern is 64 wide, so a 128-wide layer forces the guard.
+TEST_CASE("AmbilightEffect: a layer finer than the frame still writes every light") {
+ PatternSource src;
+ Rig rig(128, 4);
+ rig.fx.saturation = 100;
+ rig.render();
+
+ // Top row of the pattern is the red band. At 4 rows tall every row samples some band, so the whole first row must be lit across its full width.
+ for (int x = 0; x < 128; x++) {
+ const uint8_t* p = rig.px(x, 0);
+ CHECK((p[0] || p[1] || p[2]));
+ }
+}
+
+// brightness scales the result down uniformly. Distinct from the driver's brightness: this one dims the video relative to whatever else is composited beside it.
+TEST_CASE("AmbilightEffect: brightness scales the sampled color down") {
+ PatternSource src;
+ Rig full(8, 8);
+ full.fx.saturation = 100;
+ full.fx.brightness = 255;
+ full.render();
+ const int fullRed = full.px(4, 0)[0];
+
+ Rig dim(8, 8);
+ dim.fx.saturation = 100;
+ dim.fx.brightness = 64;
+ dim.render();
+ const int dimRed = dim.px(4, 0)[0];
+
+ CHECK(fullRed > 0);
+ CHECK(dimRed < fullRed);
+ CHECK(dimRed == (fullRed * 64) / 255);
+}
+
+// Saturation stretches each channel away from the zone's luma: above 100 a colored zone gets more saturated, which is what pulls averaged means back off gray.
+TEST_CASE("AmbilightEffect: saturation above 100 pushes a colored zone further from gray") {
+ PatternSource src;
+ Rig flat(8, 8);
+ flat.fx.saturation = 100;
+ flat.render();
+ const uint8_t* a = flat.px(4, 0);
+ const int flatSpread = static_cast(a[0]) - static_cast(a[2]);
+
+ Rig boosted(8, 8);
+ boosted.fx.saturation = 180;
+ boosted.render();
+ const uint8_t* b = boosted.px(4, 0);
+ const int boostedSpread = static_cast(b[0]) - static_cast(b[2]);
+
+ CHECK(flatSpread > 0);
+ CHECK(boostedSpread >= flatSpread); // red pulled further from blue, or already clipped at 255
+}
+
+// No source paints black rather than returning early, which would leave the PREVIOUS effect's picture frozen on the strip. Every effect owns its background (unit_Effects_gridsweep).
+TEST_CASE("AmbilightEffect: no video source paints black, never the previous effect's frame") {
+ // No PatternSource here, so no service holds the seat and latestFrame() reports nothing.
+ REQUIRE(VideoService::latestFrame()->rgb == nullptr);
+
+ Rig rig(4, 4);
+ rig.layer.applyState();
+ // Paint a recognizable frame, standing in for whatever effect ran before this one.
+ uint8_t* buf = rig.layer.buffer().data();
+ REQUIRE(buf != nullptr);
+ for (size_t i = 0; i < rig.layer.buffer().count(); i++) {
+ buf[i * 3 + 0] = 11;
+ buf[i * 3 + 1] = 22;
+ buf[i * 3 + 2] = 33;
+ }
+ rig.layer.tick();
+ CHECK(rig.px(2, 2)[0] == 0);
+ CHECK(rig.px(2, 2)[1] == 0);
+ CHECK(rig.px(2, 2)[2] == 0);
+}
+
+// --- Smoothing ---------------------------------------------------------------------------------
+// The accumulators are 8.8 precisely so a slow setting still ARRIVES: in whole bytes every step rounds to zero and the light stalls short of its target forever. These check that it converges, and that a big move still lands at once.
+
+// Off must be bit-identical to no smoothing at all, since it is the default.
+TEST_CASE("AmbilightEffect: smoothing off follows the frame exactly") {
+ PatternSource src;
+ Rig plain(8, 8), off(8, 8);
+ plain.fx.saturation = 100;
+ off.fx.saturation = 100;
+ off.fx.smoothing = 0;
+ plain.render();
+ off.render();
+ CHECK(std::memcmp(plain.px(4, 0), off.px(4, 0), 3) == 0);
+}
+
+// The first frame after a gap must land immediately: creeping up from black would show as a fade-in every time the source reconnects.
+TEST_CASE("AmbilightEffect: the first frame lands whole, not smoothed up out of black") {
+ PatternSource src;
+ Rig fast(8, 8), slow(8, 8);
+ fast.fx.saturation = 100;
+ slow.fx.saturation = 100;
+ slow.fx.smoothing = 240; // very slow, so a smoothed first frame would be nearly black
+ fast.render();
+ slow.render();
+ CHECK(std::memcmp(fast.px(4, 0), slow.px(4, 0), 3) == 0);
+}
+
+// The one that 8-bit state would fail: with heavy smoothing each step is a fraction of a byte. Both directions, because >> floors and the two arrive by different routes.
+TEST_CASE("AmbilightEffect: a heavily smoothed light converges exactly, rising and falling") {
+ PatternSource src;
+ Rig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.fx.smoothing = 250; // a step of ~6/256 of the remaining distance
+ rig.fx.snapAbove = 0; // never jump, so only the smoothing can get it there
+ rig.render(); // primes on the first frame, so this one lands whole
+
+ const uint8_t bright = rig.px(4, 0)[0];
+ REQUIRE(bright > 8); // the band has somewhere to fall from
+
+ // Falling: drive the target down and let it smooth in.
+ rig.fx.brightness = 8;
+ for (int i = 0; i < 2000; i++) rig.tickOnly(src.svc);
+ const uint8_t dim = rig.px(4, 0)[0];
+ CHECK(dim < bright / 2);
+
+ // Rising back to where it started must land on the SAME value, not one short.
+ rig.fx.brightness = 255;
+ for (int i = 0; i < 2000; i++) rig.tickOnly(src.svc);
+ CHECK(rig.px(4, 0)[0] == bright);
+}
+
+// --- edgeDepth ---------------------------------------------------------------------------------
+// A border light's own share is 1/height of the frame, a sliver at the very edge. edgeDepth lets the outermost positions reach further in without changing how many there are.
+
+// Off is the default, so it must be the plain division exactly.
+TEST_CASE("AmbilightEffect: edgeDepth 0 leaves the zones as the plain division") {
+ PatternSource src;
+ Rig plain(8, 8), zero(8, 8);
+ plain.fx.saturation = 100;
+ zero.fx.saturation = 100;
+ zero.fx.edgeDepth = 0;
+ plain.render();
+ zero.render();
+ CHECK(std::memcmp(plain.px(4, 0), zero.px(4, 0), 3) == 0);
+}
+
+// The point of the control: the top row must see further down the picture than its own 1/8 share.
+TEST_CASE("AmbilightEffect: edgeDepth makes the outer row sample deeper") {
+ PatternSource src;
+ Rig shallow(8, 8), deep(8, 8);
+ shallow.fx.saturation = 100;
+ deep.fx.saturation = 100;
+ deep.fx.edgeDepth = 50; // half the frame, so it reaches well past the top band
+ shallow.render();
+ deep.render();
+ CHECK(std::memcmp(shallow.px(4, 0), deep.px(4, 0), 3) != 0);
+}
+
+// The control SETS the depth, it does not raise a floor: a value below a position's own share must make its zone thinner
+TEST_CASE("AmbilightEffect: edgeDepth below the natural share makes the outer row thinner") {
+ PatternSource src;
+ // Three rows over a 36-tall pattern is a 12-row share, which reaches past the 9-row red band into the black center. A shallower zone stays inside the band, so it reads BRIGHTER.
+ Rig plain(8, 3), thin(8, 3);
+ plain.fx.saturation = 100;
+ thin.fx.saturation = 100;
+ thin.fx.edgeDepth = 10; // ~4 rows, against a natural share of 12
+ plain.render();
+ thin.render();
+ CHECK(thin.px(4, 0)[0] > plain.px(4, 0)[0]);
+}
+
+// Only the outermost ring moves. An interior position has no edge to reach in from, so a video wall is unaffected even with the control turned up.
+TEST_CASE("AmbilightEffect: edgeDepth leaves interior positions alone") {
+ PatternSource src;
+ Rig plain(8, 8), deep(8, 8);
+ plain.fx.saturation = 100;
+ deep.fx.saturation = 100;
+ deep.fx.edgeDepth = 50;
+ plain.render();
+ deep.render();
+ for (int y = 1; y < 7; y++)
+ for (int x = 1; x < 7; x++) CHECK(std::memcmp(plain.px(x, y), deep.px(x, y), 3) == 0);
+}
+
+// --- Black bars --------------------------------------------------------------------------------
+// A letterboxed film puts bars exactly where the top and bottom lights look, so those lights go dark while the picture is bright. Detection moves the zones past the bar, which edgeDepth cannot do.
+
+namespace {
+
+// A VideoService reading a P6 PPM written here: `bar` black rows top and bottom, green between. Written to a file because that is the seam the file source actually uses.
+struct Letterbox {
+ VideoService svc;
+ char path[64] = {};
+ Letterbox(int w, int h, int bar) {
+ // The service resolves a bare name under the desktop filesystem root, which ctest points into the build tree. Ask for the resolved root rather than assuming one.
+ std::snprintf(path, sizeof(path), "mm_letterbox_%dx%d_%d.ppm", w, h, bar);
+ char real[256];
+ std::snprintf(real, sizeof(real), "%s/%s", mm::platform::fsRootPath(), path);
+ mm::platform::fsMkdir("/"); // ctest's root is a path, not necessarily a directory yet
+ std::FILE* f = std::fopen(real, "wb");
+ REQUIRE(f != nullptr);
+ std::fprintf(f, "P6\n%d %d\n255\n", w, h);
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++) {
+ const bool picture = y >= bar && y < h - bar;
+ const uint8_t px[3] = {0, picture ? uint8_t(200) : uint8_t(0), 0};
+ std::fwrite(px, 1, 3, f);
+ }
+ std::fclose(f);
+ svc.source = 1;
+ std::strncpy(svc.file, path, sizeof(svc.file) - 1);
+ svc.applyState();
+ }
+ ~Letterbox() {
+ char real[256];
+ std::snprintf(real, sizeof(real), "%s/%s", mm::platform::fsRootPath(), path);
+ std::remove(real);
+ }
+};
+
+
+// A frame every line of which reads DARK to the bar probe, with brighter outer rows than middle ones, so a wrongly adopted bar changes what the edge lights see. Same shape as Letterbox.
+struct DimFrame {
+ VideoService svc;
+ char path[64] = {};
+ DimFrame(int w, int h, uint8_t outer, uint8_t inner, int band) {
+ std::snprintf(path, sizeof(path), "mm_dim_%dx%d_%d.ppm", w, h, band);
+ char real[256];
+ std::snprintf(real, sizeof(real), "%s/%s", mm::platform::fsRootPath(), path);
+ mm::platform::fsMkdir("/");
+ std::FILE* f = std::fopen(real, "wb");
+ REQUIRE(f != nullptr);
+ std::fprintf(f, "P6\n%d %d\n255\n", w, h);
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++) {
+ const uint8_t v = (y < band || y >= h - band) ? outer : inner;
+ const uint8_t px[3] = {v, v, v};
+ std::fwrite(px, 1, 3, f);
+ }
+ std::fclose(f);
+ svc.source = 1;
+ std::strncpy(svc.file, path, sizeof(svc.file) - 1);
+ svc.applyState();
+ }
+ ~DimFrame() {
+ char real[256];
+ std::snprintf(real, sizeof(real), "%s/%s", mm::platform::fsRootPath(), path);
+ std::remove(real);
+ }
+};
+
+} // namespace
+
+// Off by default: no scan, no shift, identical to before the feature existed.
+TEST_CASE("AmbilightEffect: blackBars off maps across the whole frame") {
+ PatternSource src;
+ Rig plain(8, 8), off(8, 8);
+ plain.fx.saturation = 100;
+ off.fx.saturation = 100;
+ off.fx.detectBlackBars = false;
+ plain.render();
+ off.render();
+ CHECK(std::memcmp(plain.px(4, 0), off.px(4, 0), 3) == 0);
+}
+
+// The top light sits in the bar and reads black; with detection it reads the picture instead.
+TEST_CASE("AmbilightEffect: the top light escapes the letterbox once bars are detected") {
+ Letterbox src(64, 64, 16); // a quarter of the height black at each end
+ Rig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.fx.detectBlackBars = true;
+ rig.render();
+
+ CHECK(rig.px(4, 0)[1] == 0); // first frame: still inside the bar
+ for (int i = 0; i < 60; i++) rig.tickOnly(src.svc); // past the hysteresis window
+ CHECK(rig.px(4, 0)[1] > 100); // now reading the green picture
+}
+
+// Dark EVERYWHERE by the probe, but not uniform. Scanning to the ceiling used to report the deepest bar on all four edges, cropping a dark scene to its middle for kStableFrames after it ended.
+TEST_CASE("AmbilightEffect: an all-dark frame reports no bars, not the deepest ones") {
+ // 40% of 64 is a 25-line ceiling. Rows 0..24 sit at 60, under barLevel 64, so the scan runs the whole way; rows 25..38 are black, which the top light would read if a bar were adopted.
+ DimFrame src(64, 64, 60, 0, 25);
+ Rig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.fx.detectBlackBars = true;
+ rig.fx.barLevel = 64;
+ rig.render();
+
+ const uint8_t first = rig.px(4, 0)[0];
+ REQUIRE(first > 0); // reading the bright outer rows, not the middle
+ for (int i = 0; i < 60; i++) rig.tickOnly(src.svc); // past the hysteresis window
+ CHECK(rig.px(4, 0)[0] == first); // nothing adopted, so nothing moved
+}
+
+// The pattern's center is black and its edges are colored: the OPPOSITE of a letterbox. Nothing must be detected in it, or a picture that fills the frame would get cropped.
+TEST_CASE("AmbilightEffect: a frame that fills the picture reports no bars") {
+ PatternSource src;
+ Rig plain(8, 8), armed(8, 8);
+ plain.fx.saturation = 100;
+ armed.fx.saturation = 100;
+ armed.fx.detectBlackBars = true;
+ plain.render();
+ armed.render();
+ for (int i = 0; i < 60; i++) armed.tickOnly(src.svc);
+ CHECK(std::memcmp(plain.px(4, 0), armed.px(4, 0), 3) == 0);
+}
+
+// --- Skipping positions that reach no LED -------------------------------------------------------
+// A border layout maps its interior to nothing, so the effect asks the LUT and skips those positions. GridLayout is identity, so the video-wall case is unaffected.
+
+namespace {
+
+// A hollow rectangle, local to this file: perimeter lit, interior reaching no LED. Local rather than a shipped layout, since what is under test is the EFFECT asking the mapping.
+struct HollowRect : mm::LayoutBase {
+ uint16_t width = 8;
+ uint16_t height = 8;
+
+ mm::nrOfLightsType lightCount() const override {
+ return static_cast(2 * width + 2 * height - 4); // corners counted once
+ }
+
+ void placeLights(const mm::CoordSink& sink) const override {
+ mm::nrOfLightsType i = 0;
+ const int w = width, h = height;
+ for (int x = 0; x < w; x++) sink.pixel(i++, static_cast(x), 0, 0);
+ for (int y = 1; y < h; y++) sink.pixel(i++, static_cast(w - 1), static_cast(y), 0);
+ for (int x = w - 2; x >= 0; x--) sink.pixel(i++, static_cast(x), static_cast(h - 1), 0);
+ for (int y = h - 2; y >= 1; y--) sink.pixel(i++, 0, static_cast(y), 0);
+ }
+};
+
+// The Rig above, with that hollow rectangle in place of the grid.
+struct RectRig {
+ mm::Layouts layouts;
+ HollowRect rect;
+ mm::Layer layer;
+ AmbilightEffect fx;
+
+ RectRig(uint16_t w, uint16_t h) {
+ rect.width = w;
+ rect.height = h;
+ layouts.addChild(&rect);
+ layer.setLayouts(&layouts);
+ layer.setChannelsPerLight(3);
+ layer.addChild(&fx);
+ }
+ void render() {
+ layer.applyState();
+ layer.tick();
+ }
+ const uint8_t* px(int x, int y) const {
+ return layer.buffer().data() + (static_cast(y) * rect.width + x) * 3;
+ }
+};
+
+} // namespace
+
+TEST_CASE("AmbilightEffect: a border layout paints its perimeter and skips the interior") {
+ PatternSource src;
+ RectRig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.render();
+
+ // The perimeter reaches LEDs, so it carries the picture.
+ const uint8_t* top = rig.px(4, 0);
+ CHECK((top[0] | top[1] | top[2]) != 0);
+
+ // The interior reaches none. Never averaged, never written. it holds the black that Layer::prepare() left in the buffer on the rebuild the effect's own prepare() rode in on.
+ for (int y = 1; y < 7; y++)
+ for (int x = 1; x < 7; x++) {
+ const uint8_t* p = rig.px(x, y);
+ CHECK((p[0] | p[1] | p[2]) == 0);
+ }
+
+ // And it stays black across further frames, rather than only on the first.
+ for (int i = 0; i < 5; i++) rig.layer.tick();
+ for (int y = 1; y < 7; y++)
+ for (int x = 1; x < 7; x++) {
+ const uint8_t* p = rig.px(x, y);
+ CHECK((p[0] | p[1] | p[2]) == 0);
+ }
+}
+
+// The effect is D2 and Layer::extrude() copies its front slice across the depth. On a sparse 3D layout a column whose only LEDs sit at z > 0 must be painted at z = 0 too.
+TEST_CASE("AmbilightEffect: a sparse 3D layout lights an LED behind an empty front-face position") {
+ PatternSource src;
+ mm::Layouts layouts;
+ mm::SphereLayout sphere;
+ mm::Layer layer;
+ AmbilightEffect fx;
+ sphere.radius = 2; // a 5x5x5 box; the shell point (4,2,2) has no LED anywhere at z = 0 in its column
+ layouts.addChild(&sphere);
+ layer.setLayouts(&layouts);
+ layer.setChannelsPerLight(3);
+ layer.addChild(&fx);
+ fx.saturation = 100;
+ layer.applyState();
+ layer.tick();
+
+ const mm::MappingLUT& lut = layer.lut();
+ REQUIRE(layer.depth() == 5);
+ REQUIRE_FALSE(lut.hasDestination(2 * 5 + 4)); // (4,2) at z = 0: nothing
+ REQUIRE(lut.hasDestination((2 * 5 + 2) * 5 + 4)); // (4,2) at z = 2: on the shell
+ const uint8_t* p = layer.buffer().data() + ((2 * 5 + 2) * 5 + 4) * 3;
+ CHECK((p[0] | p[1] | p[2]) != 0);
+}
+
+
+// --- Skipping a repeated frame -----------------------------------------------------------------
+// The render loop outruns the source (60 Hz against 30 fps video, or a still picture) and re-averaging a frame already on the strip buys nothing. What the effect advances then runs at the source's rate, which is the rate it should run at.
+
+// A skipped tick must leave the strip exactly as it was, not half-painted or cleared.
+TEST_CASE("AmbilightEffect: a repeated frame leaves the strip untouched") {
+ PatternSource src;
+ Rig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.render();
+
+ uint8_t before[3];
+ std::memcpy(before, rig.px(4, 0), 3);
+ for (int i = 0; i < 5; i++) rig.tickOnly(); // no new frame published
+ CHECK(std::memcmp(before, rig.px(4, 0), 3) == 0);
+}
+
+// Turning detection off has to clear the hysteresis, not only the adopted bars. A surviving candidate with a saturated counter agrees with itself the moment detection returns, so the adoption branch never fires again and the control looks dead.
+TEST_CASE("AmbilightEffect: detection can be turned off and on again") {
+ Letterbox src(64, 64, 16);
+ Rig rig(8, 8);
+ rig.fx.saturation = 100;
+ rig.fx.detectBlackBars = true;
+ rig.render();
+ for (int i = 0; i < 60; i++) rig.tickOnly(src.svc);
+ REQUIRE(rig.px(4, 0)[1] > 100); // bars adopted, reading the picture
+
+ rig.fx.detectBlackBars = false;
+ rig.tickOnly(src.svc);
+ CHECK(rig.px(4, 0)[1] == 0); // back to the frame, so back inside the bar
+
+ rig.fx.detectBlackBars = true;
+ for (int i = 0; i < 60; i++) rig.tickOnly(src.svc);
+ CHECK(rig.px(4, 0)[1] > 100); // and adopted again rather than stuck
+}
+
+// Averaging happens in LINEAR light rather than in the encoding: half black and half white is half the light, which sRGB writes as 188 where the byte mean is 128.
+TEST_CASE("AmbilightEffect: a part-lit zone averages as light, not as bytes") {
+ PatternSource src;
+ Rig rig(1, 1);
+ rig.fx.saturation = 100; // identity, so the zone mean is what we read
+ rig.render();
+ CHECK(rig.px(0, 0)[0] > 120);
+ CHECK(rig.px(0, 0)[0] < 180);
+}