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..b0e7df9aa 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -42,6 +42,28 @@ Detail: [technical](moxygen/AudioService.md) · [the sync packet](moxygen/WLEDAu [Tests](../../reference/tests/unit-tests.md#audioservice) + + +### Video + +Video service card + +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. Sources, HDR and staleness: ⌄ details. + +- `source`: `test pattern` needs no hardware, `file` reads a PPM, `usb` captures from an HDMI grabber. +- `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. +- `offered`: (usb) the resolution and frame rate to request, from what the device advertises. +- `staleMs`: (usb) how long a gap in frames is tolerated before the lights go dark. +- `hdr`: (usb) the source's transfer curve: `off`, `HDR10 (PQ)` or `HLG`. Declared, not detected. +- `hdrNits`: (usb, PQ only) the reference white PQ's absolute luminance is scaled to. +- status: the live frame's dimensions (`640x480`), or the reason there is no frame. + +Detail: [technical](moxygen/VideoService.md) + +[Tests](../../reference/tests/unit-tests.md#videoservice) + ### OSC @@ -249,6 +271,32 @@ 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. + +**The USB source needs an ESP32-P4.** It wants two things at once and only that chip has both: a High-Speed USB PHY (the S3 has USB, but too slow to carry video) and a hardware JPEG decoder. Elsewhere the option is not offered and the two software sources still work. Choosing a format: + +- **MJPEG only.** It is what the JPEG hardware decodes, so other encodings the device advertises are filtered out of `offered` rather than listed and then refused. +- **Pick 16:9.** A 4:3 capture makes a 16:9 source letterbox into it, putting black bars where the top and bottom lights look. For bars that are in the source itself, see [Ambilight](../light/effects.md#ambilight)'s `detectBlackBars`. +- **Then prefer frame rate over resolution.** A border light averages a few hundred pixels whatever the capture size, so resolution buys nothing and bandwidth is the scarce thing. + +**HDR is declared, not detected.** MJPEG carries no HDR metadata and a grabber strips what the console sends, so the curve is a control. With an HDR source left at `off` the lights read washed out and hue-shifted, green lifted against red being the usual sign, because the bytes are averaged on the HDR curve rather than the display's. `hdrNits` sets the reference white PQ's absolute luminance is scaled to: too low and bright channels clamp, dragging saturated hues toward their neighbors; too high and the picture reads dim. HLG is relative and does not use it. + +**Staleness is why `staleMs` exists.** The decoder runs at its own rate, so a gap of one tick is normal and the last frame stays up. A long gap means the source stopped, a console asleep or a cable out, and holding the picture would leave the room lit by a frozen frame. Past `staleMs` the frame is dropped and every effect falls back to black. Floored above a frame interval rather than 0, since the render loop outruns the capture and ordinary gaps would otherwise read as loss and strobe the room. + +**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/drivers.md b/docs/moonmodules/light/drivers.md index 025731725..758bef0e4 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -21,7 +21,9 @@ The block every driver card opens with, shown here on RMT LED. Added once by [`D - `localBrightness`: this driver's dim (0–255), multiplied with the global brightness. - `curve`: brightness to output: `CIE 1931`, `gamma 2.2`, `gamma 2.8`, or `linear`. - `fixture`: the [fixture profile](supporting.md) applied per light, for order and white. +- `balanceRed`, `balanceGreen`, `balanceBlue`: trim one die down (0–255) to correct a color cast. - `whiteMode`: how W is derived, shown when the profile carries a W channel. +- `whiteLevel`: the white die's own trim, shown when the profile carries one. - `start`: first light of the shared buffer this driver reads (default `0`). - `count`: how many lights from `start`. **Blank drives all of them.** @@ -244,6 +246,13 @@ Detail: [technical](moxygen/RtspDriver.md) · [the RTP packetiser](moxygen/RtpH2 ## Shared, details + +**White balance corrects the dies, not the picture.** An LED's three emitters are not equally efficient, and on an SK6812 the green runs hot, so a neutral triple renders green-cast and grey scenes come out tinted. `balanceRed`, `balanceGreen` and `balanceBlue` trim one channel down to meet the others. Trim **down only**: there is no headroom above 255, so raising a channel clips instead of balancing it. Set this once for the strip and every effect is corrected at the output, rather than each one guessing. + +**The white die carries its own trim.** `whiteLevel` reaches the W and warm-W emitters alone, which the three RGB trims cannot: a white phosphor is separate hardware, often brighter than the trio beside it. It is shown only when the referenced profile carries a white channel, a narrower test than `whiteMode`'s, so a profile with amber or UV but no white does not offer a slider that changes nothing. Amber and UV read an untrimmed row for the same reason: they are their own dies, and an RGB trim says nothing about them. + +**One table, not two stages.** A trim is a linear **pre-scale** folded into the same fill that applies the curve, so the hot path stays one lookup per channel and the curve still lands last. The table carries a row per trim, and the 16-bit table a driver allocates for fine channels carries the same rows, so a 16-bit output is balanced exactly as an 8-bit one is. + **A `fixture` reference survives some changes and not others.** The driver holds the profile's stable id at runtime, so **reordering** profiles never disturbs it, and the reference **survives a reboot** because the profile's name is persisted and re-resolved on load. The caveat is **renaming**: within a session the id keeps the link, but after a reboot a renamed profile no longer matches the persisted name and the driver falls back to the default. Re-pick it if you rename a profile a driver uses. **`start` and `count` are how several drivers share one buffer.** Blank `count` drives every light; a number drives only that slice. An onboard status LED takes `start 0, count 1` while the main strip runs from `start 1`, both reading the same buffer. 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 📺 + +Ambilight effect preview + +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/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 391660095..a88dfc5c3 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -42,6 +42,7 @@ idf_component_register( "../../src/platform/esp32/platform_esp32_i2c.cpp" "../../src/platform/esp32/platform_esp32_ir.cpp" "../../src/platform/esp32/platform_esp32_usb_midi.cpp" + "../../src/platform/esp32/platform_esp32_usbvideo.cpp" "../../src/platform/esp32/platform_esp32_es8311.cpp" INCLUDE_DIRS "../../src" diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml index 1ba79df79..df7e23249 100644 --- a/esp32/main/idf_component.yml +++ b/esp32/main/idf_component.yml @@ -87,3 +87,9 @@ dependencies: version: "~2.12.9" rules: - if: "$CONFIG{MM_P4_WIFI} == True" + # usb_host_uvc: the UVC (webcam) host, which is how a grabber's frames are read. P4 only, since no + # other ESP32 has a High-Speed USB host and at Full Speed (~1 MB/s) MJPEG starves. + espressif/usb_host_uvc: + version: "^2.5.2" + rules: + - if: "target == esp32p4" diff --git a/esp32/sdkconfig.defaults.esp32p4rev1-eth b/esp32/sdkconfig.defaults.esp32p4rev1-eth index 43a80033f..39479d839 100644 --- a/esp32/sdkconfig.defaults.esp32p4rev1-eth +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth @@ -92,3 +92,6 @@ CONFIG_HEAP_HAS_EXEC_HEAP=y # nothing (3 reboots in 70 s against 4), which is what a blocked network cascade looks like, not an # FFT fault. Lives in the board fragment because every P4 image (rev1/rev3, eth/eth-wifi) layers on it. # CONFIG_DSP_ANSI=y + +# USB host control-transfer buffer. A UVC configuration descriptor lists every format and frame size, 1-3 KB on a grabber, and enum.c rejects any descriptor longer than this buffer before a class driver is consulted: at the default 256 an MS2130 never enumerates. Costs 2 KB of heap per attached device, and lives here because every P4 image layers on this fragment. +CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE=2048 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..4391d1ea4 --- /dev/null +++ b/src/core/services/VideoService.h @@ -0,0 +1,513 @@ +#pragma once + +#include "core/util/ActiveInstance.h" // the one-active-source seat (RAII vacate on destruct) +#include "core/module/Scheduler.h" // requestPrepareTree: a hotplug needs a cold-path rebuild +#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 +/// +/// ## Three 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, and `usb` captures from a grabber through the P4's JPEG hardware. +/// +/// ## Who owns the pixels +/// +/// The software sources render into `buf_`, which this module owns, while `usb` BORROWS the decoder's output buffer, owned by the platform while the device is open. +/// So the device is only ever closed through closeCapture(), which drops the frame first. +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; + /// `source` value for an HDMI grabber, offered only where platform::hasUsbVideo. + static constexpr uint8_t kSourceUsb = 2; + /// Dropdown labels for `source`, indexed by the constants above. + static constexpr const char* kSourceOptions[] = {"test pattern", "file", "usb"}; + // A target with no High-Speed USB host and no JPEG decoder cannot capture, so it is not offered the option, and the two software sources still work everywhere. + /// How many of `kSourceOptions` this build offers. + static constexpr uint8_t kSourceCount = platform::hasUsbVideo ? 3 : 2; + + /// Which of `kSourceOptions` fills the frame: @xref{three-sources|what each one is for}. + uint8_t source = kSourcePattern; + /// Path the `file` source reads. + char file[64] = "/frame.ppm"; + /// Index into the device's advertised format list, and the only USB setting persisted. + uint8_t usbFormat = 0; + /// Gap tolerated before the lights go dark, in milliseconds. + uint16_t staleMs = 2000; + + // MJPEG carries no HDR metadata, so the source's transfer curve is declared here rather than detected. + /// `hdr` value for a display-encoded source, which is sRGB. + static constexpr uint8_t kHdrOff = 0; + /// `hdr` value for HDR10, the SMPTE ST 2084 curve. + static constexpr uint8_t kHdrPq = 1; + /// `hdr` value for HLG, the ARIB STD-B67 curve. + static constexpr uint8_t kHdrHlg = 2; + /// Dropdown labels for `hdr`, indexed by the constants above. + static constexpr const char* kHdrOptions[] = {"off", "HDR10 (PQ)", "HLG"}; + /// How many of `kHdrOptions` are offered. + static constexpr uint8_t kHdrCount = 3; + /// Which transfer curve the capture source carries. + uint8_t hdr = kHdrOff; + // Too low and bright channels clamp, dragging saturated hues toward their neighbors, and too high and the picture reads dim. + /// Reference white for the PQ curve, in nits, since PQ is absolute luminance. + uint16_t hdrNits = 2000; + // 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); + // The device decides what is on offer, so there is nothing to type. Until one has enumerated the control still renders (read-only, holding a placeholder) rather than appearing out of nowhere once a cable is plugged in. + static constexpr const char* kNoDevice[] = {"no device"}; + const bool known = formatCount_ > 0; + controls_.addSelect("offered", usbFormat, known ? formatOptions_ : kNoDevice, + known ? formatCount_ : 1); + controls_.setHidden(controls_.count() - 1, source != kSourceUsb); + controls_.setReadOnly(controls_.count() - 1, !known); + // How long a gap in frames is tolerated before the lights go dark. Floored above a frame interval, not 0: the render loop outruns the capture, so ordinary gaps between frames would otherwise read as loss and strobe the room. + controls_.addControl("staleMs", staleMs, 100, 10000); + controls_.setHidden(controls_.count() - 1, source != kSourceUsb); + // Live: changes how the buffer is read, not its size. Capture-only; other sources are authored display-encoded. + controls_.addSelect("hdr", hdr, kHdrOptions, kHdrCount); + controls_.setHidden(controls_.count() - 1, source != kSourceUsb); + controls_.addControl("hdrNits", hdrNits, 100, 10000); + controls_.setHidden(controls_.count() - 1, source != kSourceUsb || hdr != kHdrPq); + 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 || + std::strcmp(name, "offered") == 0; + } + + /// Routes the live edits: the reload button, a format pick, and anything that moves the curve. + void onControlChanged(const char* name) override { + if (std::strcmp(name, "reload") == 0) loadFile(); + if (std::strcmp(name, "offered") == 0) applyFormat(); + if (std::strcmp(name, "hdr") == 0 || std::strcmp(name, "hdrNits") == 0 || std::strcmp(name, "source") == 0) + rebuildTone(); + 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 restored from a capture-capable board + rebuildTone(); // every source has a curve, and a restored `hdr` lands as a VALUE, not an edit + if (source == kSourceUsb) { + // Resolve the selected row FIRST: usbWidth/Height are what captureCurrent() compares against, and a restored usbFormat arriving later would never reach them. + applyFormat(); + // Every tree-wide rebuild lands here too, and the device stays open through those, since a reopen drops the published frame and blocks on negotiation. + if (!captureCurrent()) { + closeCapture(); + openCapture(); + } + } else { + closeCapture(); // a source switch releases the device, and the frame borrowed from it + 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(); + else if (source == kSourceUsb) + readCapture(); + MoonModule::tick(); + } + + // One atomic load a second, and the one path by which a device that came back is picked up again. + /// Notices a grabber that enumerated on its own and asks for a rebuild, since prepare() runs on the render thread, the only one that may open or close the device. + void tick1s() MM_NONBLOCKING override { + if (source == kSourceUsb && (platform::videoCaptureFormatGeneration() != formatGen_ || selectionStale())) + if (Scheduler* s = Scheduler::instance()) s->requestPrepareTree(); + // A dropped frame is invisible and just stutters, so name it as faulty/noSlot/busy, where the last two are the newest-wins policy at work rather than a fault. + if (source == kSourceUsb && capture_.impl) { + const platform::VideoCaptureStats st = platform::videoCaptureStats(); + const uint32_t bad = st.infoFail + st.oversize + st.decodeFail; + if (bad || st.noSlot || st.busy) { + std::snprintf(status_, sizeof(status_), "%ux%u drop %u/%u/%u", shownW_, shownH_, + static_cast(bad), static_cast(st.noSlot), + static_cast(st.busy)); + setStatus(status_, bad ? Severity::Warning : Severity::Status); + } + } + MoonModule::tick1s(); + } + + // Watching the format generation alone never catches this, because no device came or went. Four int compares. + /// Whether the selected row disagrees with what is open, which is how a `usbFormat` restored after prepare() reaches the device at all. + bool selectionStale() const MM_NONBLOCKING { + if (!capture_.impl || usbFormat >= formatCount_) return false; + const platform::VideoCaptureFormat& f = formats_[usbFormat]; + return f.width != opened_.width || f.height != opened_.height || f.fps != opened_.fps; + } + + /// Gives up the device and the seat, so a disabled module holds neither. + void release() override { + closeCapture(); // drops the published frame too, whichever source it came from + 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}; + + // --- USB capture source ------------------------------------------------------------------- + // Only the last attempt reports, or a failed probe would leave an error standing over the retry that fixed it. + /// Open the device at the selected format, where the first open doubles as a probe: a device lists its formats only once it enumerates, which happens inside init. + void openCapture() { + bool open = platform::videoCaptureInit(capture_, usbWidth, usbHeight, usbFps); + readFormats(); + if (applyFormat()) { + closeCapture(); + open = platform::videoCaptureInit(capture_, usbWidth, usbHeight, usbFps); + } + if (!open) { + fail("no capture device"); + return; + } + opened_ = {usbWidth, usbHeight, usbFps}; + // What was ASKED for, until a frame arrives: the device negotiates, and readCapture() replaces this with the dimensions actually being decoded. + std::snprintf(status_, sizeof(status_), "asked %ux%u", usbWidth, usbHeight); + setStatus(status_, Severity::Status); + shownW_ = shownH_ = 0; + } + + /// Whether the open device already serves the selection: the same format list (a replug, even of the same grabber, publishes a new generation) and the same requested format. A (re)open is worth its cost only when one of those moved. + bool captureCurrent() const { + return capture_.impl && platform::videoCaptureFormatGeneration() == formatGen_ && + opened_.width == usbWidth && opened_.height == usbHeight && opened_.fps == usbFps; + } + + // This is the one place that order is decided, and every teardown path goes through here. + /// Release the device. The published frame borrows one of ITS buffers (platform.h, videoCaptureFrame), so it is dropped first. + void closeCapture() { + frame_ = VideoFrame{}; + opened_ = {}; + platform::videoCaptureDeinit(capture_); + } + + /// Resolve the selected row into the request fields. True when that changed something: the index survives a reboot but the list behind it does not, so this is how a restored pick reaches the device. + bool applyFormat() { + if (usbFormat >= formatCount_) return false; + const platform::VideoCaptureFormat& f = formats_[usbFormat]; + const bool changed = f.width != usbWidth || f.height != usbHeight || f.fps != usbFps; + usbWidth = f.width; + usbHeight = f.height; + usbFps = f.fps; + return changed; + } + + /// Cold path: cache what the device advertises as dropdown labels. Kept out of defineControls(), which must stay pure. it only reads what this leaves behind. + void readFormats() { + const uint32_t gen = platform::videoCaptureFormatGeneration(); + const bool changed = gen != formatGen_; + formatGen_ = gen; + formatCount_ = static_cast(platform::videoCaptureFormats(formats_, kMaxFormats)); + for (uint8_t i = 0; i < formatCount_; i++) { + std::snprintf(formatLabels_[i], sizeof(formatLabels_[i]), "%ux%u %ufps", formats_[i].width, + formats_[i].height, formats_[i].fps); + formatOptions_[i] = formatLabels_[i]; + } + // Only once there IS a list: an empty one means the device has not enumerated yet, not that the pick is invalid. Clamping against 0 threw away a restored index every boot. + if (formatCount_ && usbFormat >= formatCount_) usbFormat = 0; + // On the GENERATION rather than the count, since a replacement device advertising the same number of different formats overwrites the labels in place. + if (changed) rebuildControls(); + } + + // The borrow is safe for as long as the device stays open, which closeCapture() alone ends, and it drops the frame first. + /// Publish the newest decoded frame, which unlike the other sources does not fill buf_: the JPEG decoder owns its DMA output buffer and the frame borrows that. + void readCapture() MM_NONBLOCKING { + uint16_t w = 0, h = 0; + const uint8_t* rgb = platform::videoCaptureFrame(capture_, w, h); + if (rgb) { + lastFrameMs_ = platform::millis(); + frame_.rgb = rgb; + frame_.width = w; + frame_.height = h; + publish(); + // What the device actually negotiated, once per change (so, in practice, once): compared against what was last SHOWN rather than the frame, which a stale drop resets. + if (w != shownW_ || h != shownH_) { + shownW_ = w; + shownH_ = h; + std::snprintf(status_, sizeof(status_), "%ux%u", w, h); + setStatus(status_, Severity::Status); + } + return; + } + // A gap of one tick is normal, since the decoder runs at its own rate. A long one means the source stopped, and holding the picture would light the room from a frozen frame. + if (frame_.rgb && platform::millis() - lastFrameMs_ > staleMs) frame_ = VideoFrame{}; + } + + /// The open device, whose buffers the published frame borrows: @xref{who-owns-the-pixels|the borrow's lifetime}. + platform::VideoCaptureHandle capture_; + platform::VideoCaptureFormat opened_ = {}; // the request the open device was made with + + // Derived from the selected row and never typed, and the opening bid is 640x480 because almost every UVC device offers it. See @moreinfo, "The opening bid". + uint16_t usbWidth = 640; + uint16_t usbHeight = 480; + uint8_t usbFps = 60; + + uint32_t lastFrameMs_ = 0; + uint16_t shownW_ = 0, shownH_ = 0; // the dimensions the status last reported + + static constexpr int kMaxHeaderBytes = 256; // room for a comment, and its own error if not + static constexpr uint8_t kMaxFormats = platform::kVideoCaptureMaxFormats; + platform::VideoCaptureFormat formats_[kMaxFormats] = {}; + char formatLabels_[kMaxFormats][24] = {}; + const char* formatOptions_[kMaxFormats] = {}; + uint8_t formatCount_ = 0; + uint32_t formatGen_ = 0; // the platform generation formats_ was read at + 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; + float lin; + if (source == kSourceUsb && hdr == kHdrHlg) { + // ARIB STD-B67 inverse OETF. Relative, so hdrNits does not apply. No OOTF: a second-order tilt that border averages do not need. + constexpr float a = 0.17883277f, b = 0.28466892f, c = 0.55991073f; + lin = e <= 0.5f ? (e * e) / 3.0f : (std::exp((e - c) / a) + b) / 12.0f; + } else if (source == kSourceUsb && hdr == kHdrPq) { + // SMPTE ST 2084 (PQ) EOTF: absolute nits, referred to hdrNits. + constexpr float m1 = 2610.0f / 16384.0f; + constexpr float m2 = 2523.0f / 4096.0f * 128.0f; + constexpr float c1 = 3424.0f / 4096.0f; + constexpr float c2 = 2413.0f / 4096.0f * 32.0f; + constexpr float c3 = 2392.0f / 4096.0f * 32.0f; + const float p = std::pow(e, 1.0f / m2); + const float num = p > c1 ? p - c1 : 0.0f; + const float den = c2 - c3 * p; // > 0 across the whole domain + const float nits = 10000.0f * std::pow(num / den, 1.0f / m1); + lin = nits / static_cast(hdrNits ? hdrNits : 1); + } else { + // sRGB EOTF (IEC 61966-2-1). The default, and what a file or the pattern carries. + 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/drivers/Correction.h b/src/light/drivers/Correction.h index 69dc0cd3a..67f567a89 100644 --- a/src/light/drivers/Correction.h +++ b/src/light/drivers/Correction.h @@ -45,11 +45,24 @@ struct Correction { } - uint8_t briLut[256] = {}; // briLut[v] = curve(v * brightness / 255) - /// The same curve to 16 bits, filled only when the table is wanted; the driver owns the 256 entries. + // White, amber and UV are their own dies, so an RGB trim must not reach them, and the white dies carry a trim of their own. + /// Row of `briLut` carrying no trim, for a channel no RGB balance may reach. + static constexpr uint8_t kNeutral = 3; + /// Row of `briLut` carrying the white die's own trim. + static constexpr uint8_t kWhite = 4; + /// How many trim rows the tables carry, which is the stride of both of them. + static constexpr uint8_t kRows = 5; + /// briLut[ch][v] = curve(v * brightness * balance[ch]), with rows 0=R, 1=G, 2=B, 3=untrimmed, 4=white. + uint8_t briLut[kRows][256] = {}; + /// The same curves to 16 bits, row-major with a stride of 256, filled only when the table is wanted; the driver owns the kRows * 256 entries. uint16_t* lut16 = nullptr; /// Set by a driver whose output resolves more than a byte, so the 16-bit table is filled whatever the profile. bool wide = false; + /// Per-channel white balance, 255 = untouched. Trim DOWN only: there is no headroom above 255, so raising clips instead of balancing. + uint8_t balRed = 255, balGreen = 255, balBlue = 255; + // A separate emitter, often brighter than the RGB trio, that the three trims above cannot reach. + /// The white die's trim, 255 = untouched, pre-scaling like the others so the curve still lands last. + uint8_t whiteLevel = 255; /// Which curve the brightness rebuild fills through; a driver's setting, not a global one. Curve curve = Curve::Cie; // The output-byte position of each color role, recomputed from the role array. @@ -93,11 +106,17 @@ struct Correction { // ORDER is the whole design: brightness is a linear pre-scale and the curve is applied LAST. /// Refresh the brightness LUT alone, leaving the channel offsets untouched. void rebuildBrightness(uint8_t brightness) MM_NONBLOCKING { - for (int v = 0; v < 256; v++) { - const float out = shape(static_cast(v) * brightness / 255.0f); // scale first, then curve - const bool lit = v > 0 && brightness > 0; - briLut[v] = static_cast(quantize(out, 255, lit)); - if (wantsTable() && lut16) lut16[v] = static_cast(quantize(out * 257.0f, 65535, lit)); // 255 * 257 = 65535 + // The trim pre-scales like brightness, so the curve still lands last. + const uint8_t balance[kRows] = {balRed, balGreen, balBlue, 255, whiteLevel}; + for (uint8_t ch = 0; ch < kRows; ch++) { + const float scale = static_cast(brightness) * balance[ch] / 255.0f; + for (int v = 0; v < 256; v++) { + const float out = shape(static_cast(v) * scale / 255.0f); // scale first, then curve + const bool lit = v > 0 && scale > 0.0f; + briLut[ch][v] = static_cast(quantize(out, 255, lit)); + if (wantsTable() && lut16) + lut16[ch * 256 + v] = static_cast(quantize(out * 257.0f, 65535, lit)); // 255 * 257 = 65535 + } } } @@ -154,11 +173,16 @@ struct Correction { } /// Hot path: transform one source light into its output bytes, integer-only and allocation-free. - inline void apply(const uint8_t* src, uint8_t* out, uint8_t srcChannels) const { applyThrough(briLut, src, out, srcChannels); } + inline void apply(const uint8_t* src, uint8_t* out, uint8_t srcChannels) const { applyThrough(&briLut[0][0], src, out, srcChannels); } /// Hot path, wide drivers only: the same transform through the 16-bit table, one value per channel; needs `lut16`. inline void applyWide(const uint8_t* src, uint16_t* out, uint8_t srcChannels) const { applyThrough(lut16, src, out, srcChannels); } + // Both tables are row-major with a stride of 256, so one base pointer serves either width. + /// The trim row `ch` of a table, which is how a channel reaches its own balance. + template + static const T* row(const T* lut, uint8_t ch) MM_NONBLOCKING { return lut + static_cast(ch) * 256; } + // A REMAP, not a copy: the layer's packed slots become the fixture's own offsets. /// The transform through a curve table of either width, so the 8-bit and 16-bit outputs share every line. template @@ -193,24 +217,24 @@ struct Correction { } else { w = r < g ? (r < b ? r : b) : (g < b ? g : b); // min(r,g,b): the white component // Computed off the PRE-subtraction values, which only rebalance the RGB emitters. - if (offWarmWhite != kAbsent) out[offWarmWhite] = lut[w]; + if (offWarmWhite != kAbsent) out[offWarmWhite] = row(lut, kWhite)[w]; // yellow ≈ min(R,G) (the shared red+green component). - if (offYellow != kAbsent) out[offYellow] = lut[r < g ? r : g]; + if (offYellow != kAbsent) out[offYellow] = row(lut, kNeutral)[r < g ? r : g]; // Driven from the blue with no red or green to pair with, so it stays dark on warm colors. if (offUV != kAbsent) { const uint8_t rg = r > g ? r : g; - out[offUV] = lut[b > rg ? static_cast(b - rg) : 0]; + out[offUV] = row(lut, kNeutral)[b > rg ? static_cast(b - rg) : 0]; } // White last: it is the only emitter that rebalances RGB. if (offWhite != kAbsent) { if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; } // pull white out of RGB - out[offWhite] = lut[w]; + out[offWhite] = row(lut, kWhite)[w]; } } // The curve, applied ONCE: everything above this line is linear light. - if (offRed != kAbsent) out[offRed] = lut[r]; - if (offGreen != kAbsent) out[offGreen] = lut[g]; - if (offBlue != kAbsent) out[offBlue] = lut[b]; + if (offRed != kAbsent) out[offRed] = row(lut, 0)[r]; + if (offGreen != kAbsent) out[offGreen] = row(lut, 1)[g]; + if (offBlue != kAbsent) out[offBlue] = row(lut, 2)[b]; if constexpr (sizeof(T) == 1) { if (hasFine) applyFine(r, g, b, w, out); } } @@ -221,10 +245,13 @@ struct Correction { const uint8_t value[5] = {r, g, b, w, w}; // both whites carry the white component, 0 when none is synthesized const uint8_t coarse[5] = {offRed, offGreen, offBlue, offWhite, offWarmWhite}; const uint8_t fine[5] = {offRedFine, offGreenFine, offBlueFine, offWhiteFine, offWarmWhiteFine}; + // The trim row each channel reads: its own for R/G/B, the white die's for both whites. + const uint8_t row[5] = {0, 1, 2, kWhite, kWhite}; for (uint8_t k = 0; k < 5; k++) { if (fine[k] == kAbsent) continue; // Without its table (an allocation that failed) the channel stays 8-bit: the coarse byte as written, the fine one 0. - const uint16_t q = lut16 ? lut16[value[k]] : static_cast(briLut[value[k]] << 8); + const uint16_t q = lut16 ? lut16[row[k] * 256 + value[k]] + : static_cast(briLut[row[k]][value[k]] << 8); if (coarse[k] != kAbsent) out[coarse[k]] = static_cast(q >> 8); out[fine[k]] = static_cast(q & 0xFF); } diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index d701a8ee7..1b978a306 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -72,6 +72,10 @@ class DriverBase : public MoonModule { // Applied unconditionally, so brightness works even before the fixture-profile library is up. const uint8_t tableBrightness = adoptBrightness(globalBrightness); correction_.whiteMode = static_cast(whiteMode_); + correction_.balRed = balanceRed_; + correction_.balGreen = balanceGreen_; + correction_.balBlue = balanceBlue_; + correction_.whiteLevel = whiteLevel_; correction_.curve = static_cast(curveSel_); // A missing id falls back to the default, so a driver degrades rather than crashing. if (auto* lib = FixtureProfilesModule::active()) { @@ -87,7 +91,7 @@ class DriverBase : public MoonModule { } // Allocated the first time the table is wanted and kept until release, since the encode task may be reading it while this rebuild runs. const bool firstTable = correction_.wantsTable() && !lut16_; - if (firstTable) lut16_.resize(256); + if (firstTable) lut16_.resize(Correction::kRows * 256); // one row per trim, as briLut carries correction_.lut16 = lut16_.data(); if (firstTable) correction_.rebuildBrightness(tableBrightness); // a table that existed was filled by the rebuild above onCorrectionChanged(); // let a driver resize its correction-applied buffer @@ -183,6 +187,8 @@ class DriverBase : public MoonModule { uint32_t profileId_ = 0; // stable id into the FixtureProfiles library (0 → resolve to default) uint8_t fixtureSel_ = 0; // the fixture Select's chosen INDEX (mapped to an id in onControlChanged) uint8_t whiteMode_ = static_cast(WhiteMode::Min); // index into kWhiteModeOptions + uint8_t balanceRed_ = 255, balanceGreen_ = 255, balanceBlue_ = 255; // per-channel white balance, 255 = untouched + uint8_t whiteLevel_ = 255; // the white die's own trim uint8_t localBrightness_ = 255; // per-driver dim, multiplied with the global brightness /// Which perceptual curve the output LUT is built through; CIE lightness by default. uint8_t curveSel_ = 0; // index into kCurveOptions; 0 = CIE @@ -208,10 +214,17 @@ class DriverBase : public MoonModule { buildFixtureOptions(); // fill fixtureOptions_ from the library, sync id and row controls_.addSelect("fixture", fixtureSel_, fixtureOptions_, fixtureOptionCount_); controls_.setPersistLabel(controls_.count() - 1); // saved by name, so a longer or reordered library never moves it + // Trim DOWN only: there is no headroom above 255, so raising clips rather than balancing. A strip whose green die runs hot is corrected here rather than in every effect. + controls_.addControl("balanceRed", balanceRed_, 0, 255); + controls_.addControl("balanceGreen", balanceGreen_, 0, 255); + controls_.addControl("balanceBlue", balanceBlue_, 0, 255); controls_.addSelect("whiteMode", whiteMode_, kWhiteModeOptions, kWhiteModeCount); // Hidden unless the referenced profile carries a channel there is something to synthesize for. auto* lib = FixtureProfilesModule::active(); controls_.setHidden(controls_.count() - 1, !(lib && lib->profileHasSynthChannel(profileId_))); + // A separate emitter the three RGB trims cannot reach, so it carries its own. + controls_.addControl("whiteLevel", whiteLevel_, 0, 255); + controls_.setHidden(controls_.count() - 1, !(lib && lib->profileHasWhiteDie(profileId_))); // Hidden, and only read: the name an older config saved. controls_.addText("fixtureRef", fixtureRef_, sizeof(fixtureRef_)); controls_.setHidden(controls_.count() - 1, true); @@ -223,7 +236,9 @@ class DriverBase : public MoonModule { /// Whether `name` is one of the correction controls, for a driver's own prepare test. static bool isCorrectionControl(const char* name) { return std::strcmp(name, "fixture") == 0 || std::strcmp(name, "fixtureRef") == 0 || std::strcmp(name, "localBrightness") == 0 - || std::strcmp(name, "whiteMode") == 0 || std::strcmp(name, "curve") == 0; + || std::strcmp(name, "whiteMode") == 0 || std::strcmp(name, "curve") == 0 + || std::strcmp(name, "balanceRed") == 0 || std::strcmp(name, "balanceGreen") == 0 + || std::strcmp(name, "balanceBlue") == 0 || std::strcmp(name, "whiteLevel") == 0; } private: diff --git a/src/light/drivers/FixtureProfilesModule.h b/src/light/drivers/FixtureProfilesModule.h index fa505dc6a..520c66c20 100644 --- a/src/light/drivers/FixtureProfilesModule.h +++ b/src/light/drivers/FixtureProfilesModule.h @@ -64,6 +64,25 @@ class FixtureProfilesModule : public MoonModule, public ListSource { return 0; // a dangling id renders at the first profile; rebuildCorrection falls back to it too } + // Narrower than profileHasSynthChannel on purpose, since the white trim reaches the white dies alone. A profile with amber but no white would otherwise show a slider that changes nothing. + /// Whether the profile carries a white die, cold or warm, at either precision. + bool profileHasWhiteDie(uint32_t id) const { + const Profile* p = find(id); + if (!p) return false; + const uint8_t* r = roleAt(*p); + for (uint8_t c = 0; c < p->channelCount; c++) { + switch (static_cast(r[c])) { + case ChannelRole::White: + case ChannelRole::WarmWhite: + case ChannelRole::WhiteFine: + case ChannelRole::WarmWhiteFine: + return true; + default: break; + } + } + return false; + } + // Any channel apply() synthesizes from RGB counts, since one control governs them all. /// Whether the profile carries a channel the white mode would synthesize. bool profileHasSynthChannel(uint32_t id) const { 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/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 33d0f69d4..78a72e81c 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -61,6 +61,9 @@ constexpr bool hasI2sMic = false; /// OS capture devices, reaching the same audioMicRead seam a wired microphone would. constexpr bool hasAudioCapture = true; +/// No USB host on a desktop, so VideoService keeps its test-pattern and file sources. +constexpr bool hasUsbVideo = false; + /// True, so the host compiles the WiFi path even though it ships stubs behind it. constexpr bool hasWiFi = true; diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 04fa47142..82d359b96 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -2508,6 +2508,20 @@ void audioFft(const float* windowed, size_t n, float* outMag) { for (size_t k = 0; k < n / 2; k++) outMag[k] = std::sqrt(re[k] * re[k] + im[k] * im[k]); } +// USB video capture: no USB host on desktop, so init fails and VideoService's usb source reports "no capture device" while its other sources keep working. +bool videoCaptureInit(VideoCaptureHandle& /*h*/, uint16_t /*width*/, uint16_t /*height*/, + uint8_t /*fps*/) { + return false; +} +size_t videoCaptureFormats(VideoCaptureFormat* /*out*/, size_t /*max*/) { return 0; } +uint32_t videoCaptureFormatGeneration() { return 0; } +const uint8_t* videoCaptureFrame(VideoCaptureHandle& /*h*/, uint16_t& /*width*/, + uint16_t& /*height*/) MM_NONBLOCKING { + return nullptr; +} +void videoCaptureDeinit(VideoCaptureHandle& /*h*/) {} +VideoCaptureStats videoCaptureStats() { return {}; } + // No wires here, so the bus is state alone: open, closed and its generation behave as on a board, and a scan finds what a test injected. namespace { bool testI2cOpen_ = false; diff --git a/src/platform/esp32/.clangd b/src/platform/esp32/.clangd new file mode 100644 index 000000000..6678b2b72 --- /dev/null +++ b/src/platform/esp32/.clangd @@ -0,0 +1,15 @@ +# ESP32 platform sources are compiled by the ESP-IDF build, not the desktop one, so the root +# .clangd's build/macos database has no entry for them: clangd guesses flags and reports +# `'sdkconfig.h' file not found` on every file here. Point this directory at a firmware build +# instead, and the real flags come with it: CONFIG_IDF_TARGET_*, the managed-component include +# paths, and the generated config/sdkconfig.h that defines the target macro the #if reads. +# +# Needs that firmware built at least once (`uv run moondeck/build/build_esp32.py --firmware +# esp32p4rev1-eth-wifi`); the database is a build product. Pointing at ONE variant means files here +# are shown as that chip sees them, so a file with per-target branches has the others grayed +# out, so change the path when working on a different one. +# +# Scoped to this directory on purpose: the P4 database carries no entry for test/ or +# platform/desktop, so making it the root would cost IntelliSense on all 142 test files. +CompileFlags: + CompilationDatabase: ../../../build/esp32-esp32p4rev1-eth-wifi diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 6a5318b80..c1c33f9a3 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -146,6 +146,15 @@ constexpr bool hasI2sMic = false; /// False: capture devices are a host concept, and a board uses the wired microphone above. constexpr bool hasAudioCapture = false; +// USB video needs a High-Speed USB PHY, which the S3 lacks, and a hardware JPEG decoder. Only the P4 compiles an implementation, so widen all three together or none. +#if defined(CONFIG_IDF_TARGET_ESP32P4) && defined(CONFIG_SOC_USB_UTMI_PHY_NUM) && \ + defined(CONFIG_SOC_JPEG_DECODE_SUPPORTED) +/// Whether this target can capture video off a UVC device. +constexpr bool hasUsbVideo = true; +#else +constexpr bool hasUsbVideo = false; +#endif + // The Ethernet-only profile drops the WiFi components and defines this, there being no SDK switch. #ifdef MM_NO_WIFI diff --git a/src/platform/esp32/platform_esp32_usbvideo.cpp b/src/platform/esp32/platform_esp32_usbvideo.cpp new file mode 100644 index 000000000..f11c8701f --- /dev/null +++ b/src/platform/esp32/platform_esp32_usbvideo.cpp @@ -0,0 +1,548 @@ +/// USB video capture: the peripheral half of VideoService, owning the UVC stream and the JPEG decode. +/// +/// @moreinfo +/// +/// An HDMI grabber presents itself as a UVC webcam. See src/core/services/VideoService.h for the module that drives this. +/// MJPEG, because uncompressed does not fit: 640x480 YUY2 at 60 fps is 37 MB/s against a USB 2.0 host's ~24.6 MB/s. +/// +/// ## Three threads +/// +/// The USB host library is a per-application singleton, installed on first use and left running, with its event loop on a task of its own. +/// uvc_host_stream_open() blocks waiting for an enumeration that loop drives, so it cannot share a thread with init. +/// Decoding runs on a task of its own too, since jpeg_decoder_process() blocks and the render tick is MM_NONBLOCKING. +/// So videoCaptureFrame only reads an index, and the frame it names was decoded earlier by decoderTask. +/// A frame arriving while one is still pending is dropped, the newest being the only one worth having. +/// +/// ## Who owns the buffers +/// +/// The buffers are allocated in videoCaptureInit and freed in videoCaptureDeinit, both on the caller's thread. +/// Nothing in between touches the set, so no task can reallocate while another reads. +/// A device that goes away is not chased from here: its return re-enumerates, bumping the format generation, and the caller re-inits on that. +/// +/// ## Bulk payload headers +/// +/// The header stride is not exposed by the driver, so stripPayloadHeaders reads it off the first header, which can only sit 12 bytes before a packet end. +/// A candidate is confirmed by the header that must follow it one stride on, since entropy-coded bytes pass the field checks about once per 2^18 tries. + +#include "platform/platform.h" + +#include "sdkconfig.h" + +#if defined(CONFIG_IDF_TARGET_ESP32P4) + +#include "driver/jpeg_decode.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "usb/usb_host.h" +#include "usb/uvc_host.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#include +#include +#include + +namespace mm::platform { +namespace { + +constexpr char kTag[] = "usbvideo"; + +constexpr int kSlots = 3; +static_assert(kSlots >= 3, "freeSlot() needs a spare beyond the published and the in-use slot"); + +struct Capture { + uvc_host_stream_hdl_t stream = nullptr; + jpeg_decoder_handle_t jpeg = nullptr; + + // The frame the UVC callback handed over, or null. Exchanged rather than assigned so the callback never blocks and never overwrites one the decoder is already reading. + std::atomic pending{nullptr}; + + TaskHandle_t decoder = nullptr; + SemaphoreHandle_t wake = nullptr; // callback -> decoder + SemaphoreHandle_t stopped = nullptr; // decoder -> deinit + std::atomic running{false}; + + // Decoded RGB888, from jpeg_alloc_decoder_mem for its cache-line and 2D-DMA alignment: a plain malloc shows up as intermittent corruption, not an error. Written once, in init, before the decoder task exists; read-only from then on. + uint8_t* rgb[kSlots] = {}; + size_t rgbCap = 0; + uint16_t width[kSlots] = {}; + uint16_t height[kSlots] = {}; + // ONE word, because reading which slot is newest and claiming it must be a single step, or the decoder could decode into a buffer being displayed. Packed (published+1) << 8 | (inUse+1), where 0 in a field means none. + std::atomic slots{0}; + + static int pubOf(uint16_t s) { return static_cast(s >> 8) - 1; } + static int useOf(uint16_t s) { return static_cast(s & 0xFF) - 1; } + static uint16_t pack(int pub, int use) { + return static_cast(((pub + 1) << 8) | ((use + 1) & 0xFF)); + } +}; + +// What the attached device advertises. File scope rather than inside Capture because it is learned from the driver event, which fires before a stream exists and outlives a failed open. +constexpr size_t kMaxFormats = kVideoCaptureMaxFormats; +uvc_host_frame_info_t frameList[kMaxFormats]; // file scope: too big for the driver task's stack + +// A seqlock whose generation is odd during a write, so a reader that sees it move across the copy retries. The payload is atomic rather than plain bytes, since two threads touching a non-atomic object concurrently is a data race. +struct FormatBank { + std::atomic width[kMaxFormats]; + std::atomic height[kMaxFormats]; + std::atomic fps[kMaxFormats]; + std::atomic interval[kMaxFormats]; // as published: `fps` is rounded for the UI + std::atomic count{0}; +}; +FormatBank formatBank; +std::atomic formatGen{0}; // 0 = nothing published yet; odd = a write in progress +// The device the bank describes, so the open reaches THAT one and not whichever the driver finds first when two are attached. 0 (UVC_HOST_ANY_DEV_ADDR) until a device has enumerated. +std::atomic formatDevAddr{UVC_HOST_ANY_DEV_ADDR}; +// Only the first streaming function of a device is ever opened, so only its list is published: a device exposing several would otherwise describe one function while another gets opened. +constexpr uint8_t kStreamIndex = 0; + +bool hostReady = false; +bool uvcReady = false; + +// usb_host_lib_handle_events() is where enumeration and the port state machine actually run, and it blocks. Nothing else may drive it, so this task owns it for the life of the application. +void pumpTask(void*) { + while (true) { + uint32_t flags = 0; + usb_host_lib_handle_events(portMAX_DELAY, &flags); + if (flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) usb_host_device_free_all(); + } +} + +// Installed once and never uninstalled: the library is a singleton the whole application shares, and tearing it down on a source switch only risks leaving it un-reinstallable. +bool ensureUsbHost() { + if (hostReady) return true; + usb_host_config_t hostCfg = {}; + hostCfg.intr_flags = ESP_INTR_FLAG_LOWMED; + if (usb_host_install(&hostCfg) != ESP_OK) { + ESP_LOGE(kTag, "usb_host_install failed"); + return false; + } + // Priority 4 is below the UVC driver task, which consumes what this one produces. Unpinned, since the render loop is fixed to core 0, so leaving placement to the scheduler keeps USB off it. + if (xTaskCreatePinnedToCore(pumpTask, "usbpump", 4 * 1024, nullptr, 4, nullptr, tskNO_AFFINITY) != + pdPASS) { + ESP_LOGE(kTag, "no USB event task"); + usb_host_uninstall(); + return false; + } + hostReady = true; + return true; +} + +// Counted on the UVC and decode tasks, read from the render tick: relaxed is enough for a diagnostic. +std::atomic statDecoded{0}, statBusy{0}, statInfoFail{0}, statOversize{0}, statNoSlot{0}, + statDecodeFail{0}; + +// Runs on the UVC driver task, so the ordinary FreeRTOS API is safe. It still only hands the frame over, since decoding here would stall the task that collects isochronous packets. +bool onFrame(const uvc_host_frame_t* frame, void* ctx) { + auto* cap = static_cast(ctx); + uvc_host_frame_t* expected = nullptr; + // Take the slot only if it is free. Returning false keeps the frame, so the loser of this race must return true to hand it straight back or the driver runs out of buffers. + if (!cap->pending.compare_exchange_strong(expected, const_cast(frame))) { + statBusy.fetch_add(1, std::memory_order_relaxed); + return true; + } + xSemaphoreGive(cap->wake); + return false; +} + +// UVC states a rate as dwFrameInterval, a period in 100 ns ticks, so one second is 10 million of them. Rounded rather than truncated: 59.94 fps is a real rate and reads better as 60 than 59. +uint8_t fpsFrom(uint32_t interval) { + constexpr uint32_t kTicksPerSecond = 10000000; + return static_cast((kTicksPerSecond + interval / 2) / interval); +} + +// One dropdown row per (resolution, rate) pair. A device that does 320x240 at both 30 and 60 lists the resolution ONCE with several intervals, so without this expansion only its default is reachable from the UI. +void addAdvertised(const uvc_host_frame_info_t& info, uint32_t interval, size_t& n) { + if (n >= kMaxFormats || interval == 0) return; + const uint16_t w = static_cast(info.h_res), h = static_cast(info.v_res); + const uint8_t fps = fpsFrom(interval); + formatBank.width[n].store(w, std::memory_order_relaxed); + formatBank.height[n].store(h, std::memory_order_relaxed); + formatBank.fps[n].store(fps, std::memory_order_relaxed); + formatBank.interval[n].store(interval, std::memory_order_relaxed); + n++; + ESP_LOGI(kTag, "offers MJPEG %ux%u @ %u fps", w, h, fps); +} + +// Runs on the UVC driver task when a device enumerates, before any stream is opened. That is what makes the list available even when the open then fails on an unsupported resolution. +void onDriverEvent(const uvc_host_driver_event_data_t* event, void*) { + if (event->type != UVC_HOST_DRIVER_EVENT_DEVICE_CONNECTED) return; + if (event->device_connected.uvc_stream_index != kStreamIndex) return; // never opened, so not listed + + const uint8_t devAddr = event->device_connected.dev_addr; + size_t count = kMaxFormats; + if (uvc_host_get_frame_list(devAddr, kStreamIndex, reinterpret_cast(frameList), + &count) != ESP_OK) { + ESP_LOGW(kTag, "device connected but its frame list could not be read"); + return; + } + if (count > kMaxFormats) count = kMaxFormats; // it reports what it NEEDS, not what it wrote + + // Bracket the rewrite in an odd generation, so a reader can tell it overlapped one. + formatGen.fetch_add(1, std::memory_order_release); + formatDevAddr.store(devAddr, std::memory_order_relaxed); + std::atomic_thread_fence(std::memory_order_release); + size_t n = 0; + for (size_t i = 0; i < count; i++) { + const uvc_host_frame_info_t& info = frameList[i]; + if (info.format != UVC_VS_FORMAT_MJPEG) continue; // nothing else is decodable here + if (info.interval_type == 0) { // a continuous range: offer both ends, fastest first + addAdvertised(info, info.interval_min, n); + if (info.interval_max != info.interval_min) addAdvertised(info, info.interval_max, n); + continue; + } + const uint8_t rates = info.interval_type < CONFIG_UVC_INTERVAL_ARRAY_SIZE + ? info.interval_type + : CONFIG_UVC_INTERVAL_ARRAY_SIZE; + for (uint8_t j = 0; j < rates; j++) addAdvertised(info, info.interval[j], n); + } + formatBank.count.store(n, std::memory_order_relaxed); + formatGen.fetch_add(1, std::memory_order_release); // even again: the list is settled +} + +// Runs on the UVC driver task, which pauses the stream before this fires, so the renderer sees a gap and its stale timeout takes it from there. Nothing to unwind: the caller re-inits on the next generation. +void onEvent(const uvc_host_stream_event_data_t* event, void*) { + if (event->type == UVC_HOST_DEVICE_DISCONNECTED) ESP_LOGW(kTag, "capture device disconnected"); +} + +// RGB888 bytes the decoder writes for a w x h JPEG: both axes padded to the 16-pixel MCU (jpeg_decoder_process, note 2). Sized w*h*3, 800x600 overruns by 19200 bytes and every frame fails. +size_t decodedBytes(uint16_t w, uint16_t h) { + const size_t aw = (static_cast(w) + 15) & ~static_cast(15); + const size_t ah = (static_cast(h) + 15) & ~static_cast(15); + return aw * ah * 3; +} + +// One set of slots per open, sized from the format the device agreed to. Called from init only, before the decoder task exists, so nothing can be reading a slot while it is (re)written. +bool allocSlots(Capture& cap, uint16_t w, uint16_t h) { + const size_t need = decodedBytes(w, h); + jpeg_decode_memory_alloc_cfg_t memCfg = {}; + memCfg.buffer_direction = JPEG_DEC_ALLOC_OUTPUT_BUFFER; + for (int i = 0; i < kSlots; i++) { + size_t got = 0; + cap.rgb[i] = static_cast(jpeg_alloc_decoder_mem(need, &memCfg, &got)); + if (!cap.rgb[i]) { + cap.rgbCap = 0; // a partial set is no set: decode() must not write into a missing slot + return false; // deinit frees what was allocated + } + cap.rgbCap = got; // every slot gets the same request, so the same rounded-up size + } + return true; +} + +// -1 when every slot is spoken for: unreachable while kSlots is 3, but returning a real index +// anyway would hand the decoder a buffer the render thread is reading. Corruption with no error is worse than a dropped frame. +int freeSlot(const Capture& cap) { + const uint16_t s = cap.slots.load(std::memory_order_acquire); + const int pub = Capture::pubOf(s), use = Capture::useOf(s); + for (int i = 0; i < kSlots; i++) + if (i != pub && i != use) return i; + return -1; +} + +// UVC payload header (UVC 1.5 2.4.3.3): bLength 12 when PTS and SCR are present, EOH set, SCR's top 5 reserved bits zero. `pts` pins later headers to the frame's first one: PTS is constant across one frame's payloads. +constexpr size_t kPayloadHeaderLen = 12; +constexpr size_t kBulkMps = 512; // high-speed bulk + +bool isPayloadHeader(const uint8_t* h, size_t avail, const uint8_t* pts) { + constexpr uint8_t kEoh = 0x80, kErr = 0x40, kScr = 0x08, kPts = 0x04, kEof = 0x02; + return avail >= kPayloadHeaderLen && h[0] == kPayloadHeaderLen && + (h[1] & (kEoh | kErr | kScr | kPts | kEof)) == (kEoh | kScr | kPts) && !(h[11] & 0xF8) && + (!pts || memcmp(h + 2, pts, 4) == 0); +} + +// Drops the payload headers usb_host_uvc leaves inside a bulk frame. uvc_bulk.c strips one only after a short transfer. Where the payload is a multiple of the packet size, every header after the first stays in at stride `payload`. See @moreinfo. +size_t stripPayloadHeaders(uint8_t* d, size_t len) { + size_t hdr = 0; + for (size_t i = kBulkMps - kPayloadHeaderLen; !hdr && i + kPayloadHeaderLen < len; i += kBulkMps) { + if (!isPayloadHeader(d + i, len - i, nullptr)) continue; + const size_t next = 2 * i + kPayloadHeaderLen; + if (next + kPayloadHeaderLen > len || isPayloadHeader(d + next, len - next, d + i + 2)) hdr = i; + } + if (!hdr) return len; + const size_t payload = hdr + kPayloadHeaderLen; + uint8_t pts[4]; // copied: the compaction overwrites the header it came from + memcpy(pts, d + hdr + 2, sizeof pts); + size_t w = hdr, r = hdr; + while (isPayloadHeader(d + r, len - r, pts)) { + r += kPayloadHeaderLen; + const size_t run = std::min(payload - kPayloadHeaderLen, len - r); + memmove(d + w, d + r, run); + w += run; + r += run; + } + memmove(d + w, d + r, len - r); // whatever follows a missing header is left as delivered + return w + (len - r); +} + +// Warn on the FIRST of each kind only. A drop repeats at frame rate, so logging every one buries the log and costs more than the fault; the counters carry the rate. +void warnOnce(bool& said, const char* what) { + if (said) return; + said = true; + ESP_LOGW(kTag, "%s - first occurrence; see the Video status for the running count", what); +} + +void decode(Capture& cap, uvc_host_frame_t* frame) { + static bool saidInfo = false, saidOversize = false, saidNoSlot = false, saidDecode = false; + const size_t len = stripPayloadHeaders(frame->data, frame->data_len); + // Dimensions from the bitstream, not from the request: a device may negotiate something else. + jpeg_decode_picture_info_t info = {}; + if (jpeg_decoder_get_info(frame->data, len, &info) != ESP_OK) { + statInfoFail.fetch_add(1, std::memory_order_relaxed); + warnOnce(saidInfo, "frame is not readable JPEG (payload stride mis-detected?)"); + return; + } + if (decodedBytes(static_cast(info.width), static_cast(info.height)) > cap.rgbCap) { + statOversize.fetch_add(1, std::memory_order_relaxed); + warnOnce(saidOversize, "frame exceeds the buffers sized at open"); + ESP_LOGW(kTag, "frame %ux%u exceeds the buffers sized at open", info.width, info.height); + return; + } + + const int slot = freeSlot(cap); + if (slot < 0) { // drop the frame rather than write over one being read + statNoSlot.fetch_add(1, std::memory_order_relaxed); + warnOnce(saidNoSlot, "renderer holds every slot, dropping the newest frame"); + return; + } + + jpeg_decode_cfg_t decodeCfg = {}; + decodeCfg.output_format = JPEG_DECODE_OUT_FORMAT_RGB888; + decodeCfg.rgb_order = JPEG_DEC_RGB_ELEMENT_ORDER_RGB; + decodeCfg.conv_std = JPEG_YUV_RGB_CONV_STD_BT601; + uint32_t outSize = 0; + if (jpeg_decoder_process(cap.jpeg, &decodeCfg, frame->data, len, cap.rgb[slot], cap.rgbCap, &outSize) != + ESP_OK) { + statDecodeFail.fetch_add(1, std::memory_order_relaxed); + warnOnce(saidDecode, "the decoder refused a bitstream whose header it accepted"); + return; + } + statDecoded.fetch_add(1, std::memory_order_relaxed); + + cap.width[slot] = static_cast(info.width); + cap.height[slot] = static_cast(info.height); + // Preserve whatever the renderer claimed while the decode ran. Pixels and dimensions are written first, and the release makes them visible to whoever acquires this. + uint16_t cur = cap.slots.load(std::memory_order_relaxed); + while (!cap.slots.compare_exchange_weak(cur, Capture::pack(slot, Capture::useOf(cur)), + std::memory_order_release, std::memory_order_relaxed)) { + } +} + +// The blocking half, kept off the render tick. Waits on a finite timeout rather than forever so `running` is seen without the callback having to signal. +void decoderTask(void* arg) { + auto* cap = static_cast(arg); + while (cap->running.load()) { + if (xSemaphoreTake(cap->wake, pdMS_TO_TICKS(100)) != pdTRUE) continue; + if (uvc_host_frame_t* frame = cap->pending.exchange(nullptr)) { + decode(*cap, frame); + uvc_host_frame_return(cap->stream, frame); + } + } + xSemaphoreGive(cap->stopped); + vTaskDelete(nullptr); +} + +// Priority 6 puts it above the UVC driver task: a decode that runs late holds the only free frame buffer, which is what starves the driver. +bool startDecoder(Capture& cap) { + cap.running = true; + if (xTaskCreatePinnedToCore(decoderTask, "usbjpeg", 4 * 1024, &cap, 6, &cap.decoder, tskNO_AFFINITY) == + pdPASS) + return true; + cap.running = false; + ESP_LOGE(kTag, "no decode task"); + return false; +} + +// Installed once, like the host library above it: the format list belongs to the bus, not to one open. Reinstalling per open would re-enumerate the attached device and bump the format generation, which is the very signal the caller re-inits on. +bool ensureUvcHost() { + if (uvcReady) return true; + uvc_host_driver_config_t driverCfg = {}; + driverCfg.driver_task_stack_size = 4 * 1024; + driverCfg.driver_task_priority = 5; + driverCfg.xCoreID = tskNO_AFFINITY; + driverCfg.create_background_task = true; + driverCfg.event_cb = onDriverEvent; // fills the format bank as soon as a device enumerates + if (uvc_host_install(&driverCfg) != ESP_OK) { + ESP_LOGE(kTag, "uvc_host_install failed"); + return false; + } + uvcReady = true; + return true; +} + +bool createJpeg(Capture& cap) { + jpeg_decode_engine_cfg_t jpegCfg = {}; + // 200, not 40: the timeout aborts the 2D-DMA mid-frame, and writing 6.2 MB of 1080p RGB into PSRAM alone takes ~34 ms. At 40 ms only 14% of intact frames survived. + jpegCfg.timeout_ms = 200; + if (jpeg_new_decoder_engine(&jpegCfg, &cap.jpeg) == ESP_OK) return true; + ESP_LOGE(kTag, "no JPEG decoder engine"); + return false; +} + +bool createSignals(Capture& cap) { + cap.wake = xSemaphoreCreateBinary(); + cap.stopped = xSemaphoreCreateBinary(); + if (cap.wake && cap.stopped) return true; + ESP_LOGE(kTag, "no semaphores"); + return false; +} + +// The published interval of a row as the float the driver compares against; 0 (device default) when the row is unknown, so the request still opens at that resolution. +float exactFpsFor(uint16_t w, uint16_t h, uint8_t fps) { + const size_t n = formatBank.count.load(std::memory_order_acquire); + for (size_t i = 0; i < n && i < kMaxFormats; i++) { + if (formatBank.width[i].load(std::memory_order_relaxed) != w) continue; + if (formatBank.height[i].load(std::memory_order_relaxed) != h) continue; + if (formatBank.fps[i].load(std::memory_order_relaxed) != fps) continue; + const uint32_t iv = formatBank.interval[i].load(std::memory_order_relaxed); + if (iv) return 10000000.0f / static_cast(iv); + } + return 0.0f; +} + +bool openStream(Capture& cap, uint16_t width, uint16_t height, uint8_t fps) { + uvc_host_stream_config_t streamCfg = {}; + streamCfg.event_cb = onEvent; + streamCfg.frame_cb = onFrame; + streamCfg.user_ctx = ∩ + // The device whose formats are on offer, once one has enumerated. Any device before that, and its enumeration bumps the generation, so the caller comes back and opens it by address. + streamCfg.usb.dev_addr = formatDevAddr.load(std::memory_order_relaxed); + streamCfg.usb.vid = UVC_HOST_ANY_VID; + streamCfg.usb.pid = UVC_HOST_ANY_PID; + streamCfg.usb.uvc_stream_index = kStreamIndex; + streamCfg.vs_format.h_res = width; + streamCfg.vs_format.v_res = height; + // The device's own interval, not the rounded fps: the driver matches within 0.0001 fps, so a 59.94 mode never matches a requested 60. + streamCfg.vs_format.fps = exactFpsFor(width, height, fps); + streamCfg.vs_format.format = UVC_VS_FORMAT_MJPEG; + // urb_size left at 0 (4x MPS): the driver's default, and every urb is internal SRAM. number_of_urbs has no default: 0 is malloc(0), and the open fails with ESP_ERR_NO_MEM. + streamCfg.advanced.number_of_urbs = 4; + streamCfg.advanced.number_of_frame_buffers = 3; + // frame_size 0 means dwMaxVideoFrameSize, the UNCOMPRESSED size: 3 x 4.1 MB at 1080p for MJPEG frames of 40-76 KB. Half of it still leaves an order of magnitude of headroom. + streamCfg.advanced.frame_size = static_cast(width) * height / 2; + streamCfg.advanced.frame_heap_caps = MALLOC_CAP_SPIRAM; // keep the internal heap for USB and WiFi + + // Wait rather than fail: the host enumerates asynchronously, so a device plugged in at boot is usually not ready when this runs. The driver takes ticks, not milliseconds. + if (uvc_host_stream_open(&streamCfg, pdMS_TO_TICKS(3000), &cap.stream) != ESP_OK) { + ESP_LOGW(kTag, "no UVC device offering MJPEG %ux%u", width, height); + return false; + } + uvc_host_desc_print(cap.stream); // the format list, for the bench + return true; +} + +// Sized from what the device agreed to, not from what we asked for, so no frame can arrive needing more room than the slots have. +bool sizeBuffers(Capture& cap) { + uvc_host_stream_format_t got = {}; + if (uvc_host_stream_format_get(cap.stream, &got) != ESP_OK) { + ESP_LOGE(kTag, "cannot read the negotiated format"); + return false; + } + ESP_LOGI(kTag, "streaming MJPEG %ux%u @ %.1f fps", got.h_res, got.v_res, got.fps); + if (allocSlots(cap, static_cast(got.h_res), static_cast(got.v_res))) return true; + ESP_LOGE(kTag, "no memory for %d decode buffers", kSlots); + return false; +} + +} // namespace + +bool videoCaptureInit(VideoCaptureHandle& handle, uint16_t width, uint16_t height, uint8_t fps) { + if (handle.impl) return true; + if (!ensureUsbHost() || !ensureUvcHost()) return false; + auto* cap = new Capture(); + handle.impl = cap; // every failure below unwinds through videoCaptureDeinit + + // In this order on purpose. The decoder task starts LAST, once every buffer it can reach exists, and the stream after it, so the first frame finds a task to wake. + const bool ok = createJpeg(*cap) && createSignals(*cap) && openStream(*cap, width, height, fps) && + sizeBuffers(*cap) && startDecoder(*cap) && uvc_host_stream_start(cap->stream) == ESP_OK; + if (!ok) videoCaptureDeinit(handle); + return ok; +} + +// Hot path: an index load and two field reads. Everything expensive already happened on decoderTask, which is what lets the render tick stay MM_NONBLOCKING. +const uint8_t* videoCaptureFrame(VideoCaptureHandle& handle, uint16_t& width, + uint16_t& height) MM_NONBLOCKING { + auto* cap = static_cast(handle.impl); + if (!cap) return nullptr; + // Read and claim in one step. The loop runs again only if the decoder published meanwhile, and then hands back that newer frame. + uint16_t cur = cap->slots.load(std::memory_order_acquire); + int slot; + do { + slot = Capture::pubOf(cur); + // Consecutive publishes land in different slots, so an unchanged one means no new frame. + if (slot < 0 || slot == Capture::useOf(cur)) return nullptr; + } while (!cap->slots.compare_exchange_weak(cur, Capture::pack(slot, slot), + std::memory_order_acq_rel, std::memory_order_acquire)); + width = cap->width[slot]; + height = cap->height[slot]; + return cap->rgb[slot]; +} + +uint32_t videoCaptureFormatGeneration() { return formatGen.load(std::memory_order_acquire); } + +size_t videoCaptureFormats(VideoCaptureFormat* out, size_t max) { + for (;;) { + const uint32_t before = formatGen.load(std::memory_order_acquire); + if (before == 0) return 0; // nothing published yet + if (before & 1u) continue; // a write is in progress; let it finish + size_t n = formatBank.count.load(std::memory_order_relaxed); + if (n > kMaxFormats) n = kMaxFormats; // a torn read of a rewrite in flight + if (n > max) n = max; + for (size_t i = 0; i < n; i++) { + out[i].width = formatBank.width[i].load(std::memory_order_relaxed); + out[i].height = formatBank.height[i].load(std::memory_order_relaxed); + out[i].fps = formatBank.fps[i].load(std::memory_order_relaxed); + } + std::atomic_thread_fence(std::memory_order_acquire); + if (formatGen.load(std::memory_order_relaxed) == before) return n; // no write overlapped + } +} + +void videoCaptureDeinit(VideoCaptureHandle& handle) { + auto* cap = static_cast(handle.impl); + if (!cap) return; + // Stop the stream first so no frame lands mid-teardown, then the task that would decode it. The join is unbounded on purpose, and the decoder's own 40 ms timeout bounds how long it takes. + if (cap->stream) uvc_host_stream_stop(cap->stream); + if (cap->decoder) { + cap->running = false; + xSemaphoreGive(cap->wake); // it may be parked on this + xSemaphoreTake(cap->stopped, portMAX_DELAY); + } + if (uvc_host_frame_t* frame = cap->pending.exchange(nullptr)) uvc_host_frame_return(cap->stream, frame); + if (cap->stream) uvc_host_stream_close(cap->stream); + if (cap->jpeg) jpeg_del_decoder_engine(cap->jpeg); + if (cap->wake) vSemaphoreDelete(cap->wake); + if (cap->stopped) vSemaphoreDelete(cap->stopped); + for (uint8_t* buf : cap->rgb) free(buf); // free(nullptr) is a no-op, so a partial set is fine + delete cap; + handle.impl = nullptr; +} + +VideoCaptureStats videoCaptureStats() { + VideoCaptureStats s; + s.decoded = statDecoded.load(std::memory_order_relaxed); + s.busy = statBusy.load(std::memory_order_relaxed); + s.infoFail = statInfoFail.load(std::memory_order_relaxed); + s.oversize = statOversize.load(std::memory_order_relaxed); + s.noSlot = statNoSlot.load(std::memory_order_relaxed); + s.decodeFail = statDecodeFail.load(std::memory_order_relaxed); + return s; +} + +} // namespace mm::platform + +#else // every other target: no High-Speed USB host, no JPEG decoder + +namespace mm::platform { + +bool videoCaptureInit(VideoCaptureHandle&, uint16_t, uint16_t, uint8_t) { return false; } +size_t videoCaptureFormats(VideoCaptureFormat*, size_t) { return 0; } +uint32_t videoCaptureFormatGeneration() { return 0; } +const uint8_t* videoCaptureFrame(VideoCaptureHandle&, uint16_t&, uint16_t&) MM_NONBLOCKING { return nullptr; } +void videoCaptureDeinit(VideoCaptureHandle&) {} +VideoCaptureStats videoCaptureStats() { return {}; } + +} // namespace mm::platform + +#endif diff --git a/src/platform/platform.h b/src/platform/platform.h index 74ec36f9c..afb3f84a8 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -1205,6 +1205,65 @@ void audioMicDeinit(AudioMicHandle& h); /// Fill `outMag` with the magnitude bins of `n` windowed samples, `n` being a power of two. void audioFft(const float* windowed, size_t n, float* outMag); +// --- USB video capture (UVC): an HDMI grabber presenting itself as a webcam --------------------- +// MJPEG off the wire, decoded by the target's JPEG hardware, and ESP32-P4 only: every other target links a stub whose init fails, which VideoService reports as a status. + +/// Opaque per-device state, owned by the platform between init and deinit. +struct VideoCaptureHandle { void* impl = nullptr; }; + +// MJPEG only, since nothing else is decodable here, so there is no format field. +/// One row of what the attached device advertises, so the UI offers real choices rather than guesses. +struct VideoCaptureFormat { + /// Frame width in pixels. + uint16_t width = 0; + /// Frame height in pixels. + uint16_t height = 0; + /// Frames per second the device offers at that size. + uint8_t fps = 0; +}; + +// A grabber lists its modes largest first, so a cap that is too low hides exactly the cheap ones. +/// Rows kept per device. +constexpr size_t kVideoCaptureMaxFormats = 64; + +// Learned when a device enumerates, so it survives a failed videoCaptureInit, which is exactly when it is worth reading. +/// Fill `out` with up to `max` of those rows and return how many were written; 0 means no device has been seen yet. +size_t videoCaptureFormats(VideoCaptureFormat* out, size_t max); + +// A consumer caching the list compares this instead of copying, so noticing a hotplug costs one load. +/// Bumped whenever that list is rewritten, which is when a device enumerates; 0 until then. +uint32_t videoCaptureFormatGeneration(); + +// Width, height and fps are requests rather than promises. A device that goes away is not followed: a reconnect bumps the generation, and the caller answers with a deinit/init pair. +/// Claim the first UVC device on the bus and stream MJPEG, false when nothing is attached or no MJPEG format matches. +bool videoCaptureInit(VideoCaptureHandle& h, uint16_t width, uint16_t height, uint8_t fps); + +// The buffer belongs to the platform, allocated in init and freed in deinit, and the decoder never writes into the one most recently returned. See videoCaptureInit's appendix. +/// Newest decoded frame as RGB888, held until the next call that RETURNS A FRAME, or nullptr when none arrived. +const uint8_t* videoCaptureFrame(VideoCaptureHandle& h, uint16_t& width, uint16_t& height) MM_NONBLOCKING; + +/// Release the device and free its buffers. The caller drops the frame it borrowed first. +void videoCaptureDeinit(VideoCaptureHandle& h); + +// A drop in ones is normal, and a climbing count is a fault worth naming, since in the picture it is only a stutter. +/// Why frames did not reach the renderer, cumulative since boot. +struct VideoCaptureStats { + /// Frames that reached a slot. + uint32_t decoded = 0; + /// Arrived while the previous frame was still waiting to be decoded. + uint32_t busy = 0; + /// Every decode buffer still held by the renderer. + uint32_t noSlot = 0; + /// Not a readable JPEG: a mis-detected payload stride lands here. + uint32_t infoFail = 0; + /// Larger than the buffers sized at open. + uint32_t oversize = 0; + /// The decoder refused a bitstream whose header it had accepted. + uint32_t decodeFail = 0; +}; +/// The counters above, read atomically enough for a once-a-second status line. +VideoCaptureStats videoCaptureStats(); + // The board's I2C bus: one master bus, opened by its owner (I2cBusModule) and shared by every device on it, such as an audio codec. /// Open the bus on these pins, closing one open on other pins first; false when the port cannot be opened. 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..bdad14029 --- /dev/null +++ b/test/unit/core/unit_VideoService.cpp @@ -0,0 +1,215 @@ +/// @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); +} + +// Unit tests link the desktop platform, which cannot capture, so the usb source is not offered. The static_assert fails loudly if the suite ever runs somewhere that CAN. +TEST_CASE("VideoService: a platform that cannot capture does not offer the usb source") { + static_assert(!mm::platform::hasUsbVideo, "tests assume the desktop platform"); + CHECK(VideoService::kSourceCount == 2); + + VideoService v; + v.source = VideoService::kSourceUsb; + v.applyState(); + CHECK(v.source == VideoService::kSourcePattern); // fell back + CHECK(VideoService::latestFrame()->rgb != nullptr); +} + +// A platform with no capture must report an EMPTY format list rather than a placeholder, since a phantom entry would let the user pick a dead format. +TEST_CASE("VideoService: a platform with no capture advertises no formats") { + mm::platform::VideoCaptureFormat formats[4]; + CHECK(mm::platform::videoCaptureFormats(formats, 4) == 0); +} + +// 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 +} + +// Pinned to a measured case: a source showing (255,127,0) captured under PQ as (206,171,0). The curve has to land that pair on the LINEAR ratio the original color has, sRGB(127)/sRGB(255) = 0.212, which is the quantity an average is then taken of. +TEST_CASE("VideoService: the PQ tone table recovers the source's linear ratio") { + VideoService v; + v.source = VideoService::kSourceUsb; + v.hdr = VideoService::kHdrPq; + v.hdrNits = 2000; + v.onControlChanged("hdr"); // the path a UI edit takes + const uint16_t* t = v.toneForTest(); + REQUIRE(t != nullptr); + + CHECK(171.0 / 206.0 > 0.80); // the defect, as the bytes arrive + const double linear = static_cast(t[171]) / t[206]; + CHECK(linear > 0.15); // recovered toward 0.212 + CHECK(linear < 0.30); + + // Monotonic: a curve that reorders levels would posterize. + CHECK(t[0] == 0); + for (int i = 1; i < 256; i++) CHECK(t[i] >= t[i - 1]); +} + +// 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/correction_presets.h b/test/unit/light/correction_presets.h index de6980625..8e24a640f 100644 --- a/test/unit/light/correction_presets.h +++ b/test/unit/light/correction_presets.h @@ -32,5 +32,13 @@ inline void rebuildFromPreset(mm::Correction& c, uint8_t brightness, PresetOrder } } +/// The same, leaving `curve` as the caller set it, for a test about how the curve composes with a trim. +inline void rebuildFromPresetKeepingCurve(mm::Correction& c, uint8_t brightness, PresetOrder order) { + const auto keep = c.curve; + rebuildFromPreset(c, brightness, order); + c.curve = keep; + c.rebuildBrightness(brightness); +} + /// @} } // namespace mm::test 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); +} diff --git a/test/unit/light/unit_Correction.cpp b/test/unit/light/unit_Correction.cpp index 202d2a61a..9f897d22c 100644 --- a/test/unit/light/unit_Correction.cpp +++ b/test/unit/light/unit_Correction.cpp @@ -22,7 +22,7 @@ TEST_CASE("Correction brightness LUT: full brightness is identity") { Correction c; c.curve = Correction::Curve::Linear; // these pin roles and white math, not the curve mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB); - for (int v = 0; v < 256; v++) CHECK(c.briLut[v] == v); + for (int v = 0; v < 256; v++) CHECK(c.briLut[0][v] == v); } // At brightness=128, every entry is roughly halved using scale8 (255→128, 128→64, 2→1). @@ -30,10 +30,10 @@ TEST_CASE("Correction brightness LUT: half brightness halves each value (scale8) Correction c; c.curve = Correction::Curve::Linear; // these pin roles and white math, not the curve mm::test::rebuildFromPreset(c, 128, mm::test::PresetOrder::RGB); - CHECK(c.briLut[0] == 0); - CHECK(c.briLut[255] == 128); // (255*128)/255 = 128 - CHECK(c.briLut[128] == 64); // (128*128)/255 = 64 - CHECK(c.briLut[2] == 1); // (2*128)/255 = 1 + CHECK(c.briLut[0][0] == 0); + CHECK(c.briLut[0][255] == 128); // (255*128)/255 = 128 + CHECK(c.briLut[0][128] == 64); // (128*128)/255 = 64 + CHECK(c.briLut[0][2] == 1); // (2*128)/255 = 1 } // RGB preset at full brightness passes the source RGB through unchanged (3 output channels, no white). @@ -186,7 +186,7 @@ TEST_CASE("Correction roles array: arbitrary Custom wiring derives correct offse CHECK(c.offGreen == 2); CHECK(c.offRed == 3); CHECK(c.outChannels == 4); - CHECK(c.briLut[255] == 128); // LUT refreshed (brightness applied) + CHECK(c.briLut[0][255] == 128); // LUT refreshed (brightness applied) const uint8_t src[3] = {200, 100, 60}; // scaled: 100, 50, 30 → min = 30 uint8_t out[4] = {}; c.apply(src, out, 3); @@ -459,13 +459,13 @@ TEST_CASE("The CIE curve keeps the endpoints and bends the middle down") { c.curve = Correction::Curve::Cie; c.rebuildBrightness(255); - CHECK(c.briLut[0] == 0); // black stays black - CHECK(c.briLut[255] == 255); // and full stays full: the curve redistributes, it does not dim + CHECK(c.briLut[0][0] == 0); // black stays black + CHECK(c.briLut[0][255] == 255); // and full stays full: the curve redistributes, it does not dim // A mid control position is well under half output, which is the whole point: half the PERCEIVED brightness is a small fraction of the luminance. - CHECK(c.briLut[128] < 80); - CHECK(c.briLut[128] > 30); + CHECK(c.briLut[0][128] < 80); + CHECK(c.briLut[0][128] > 30); // Monotonic, or a gradient would band or reverse. - for (int v = 1; v < 256; v++) CHECK(c.briLut[v] >= c.briLut[v - 1]); + for (int v = 1; v < 256; v++) CHECK(c.briLut[0][v] >= c.briLut[0][v - 1]); } // The guard that stops a fade-out snapping to black. Every curve here crushes the low end at 8 bits, so without it the dimmest usable values are simply missing. @@ -475,7 +475,7 @@ TEST_CASE("A non-zero value never curves down to black") { mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB); c.curve = curve; c.rebuildBrightness(255); - for (int v = 1; v < 256; v++) CHECK(c.briLut[v] >= 1); + for (int v = 1; v < 256; v++) CHECK(c.briLut[0][v] >= 1); } } @@ -485,7 +485,7 @@ TEST_CASE("Linear is an exact identity at full brightness, for a device that cor mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB); c.curve = Correction::Curve::Linear; c.rebuildBrightness(255); - for (int v = 0; v < 256; v++) CHECK(c.briLut[v] == v); + for (int v = 0; v < 256; v++) CHECK(c.briLut[0][v] == v); } // Brightness is a LINEAR pre-scale and the curve is applied last. Turning the slider down must not change the shape of the curve, only how far up it reaches. @@ -495,11 +495,11 @@ TEST_CASE("Brightness scales before the curve, so black stays black and the top c.curve = Correction::Curve::Cie; c.rebuildBrightness(255); - const uint8_t fullTop = c.briLut[255]; + const uint8_t fullTop = c.briLut[0][255]; c.rebuildBrightness(128); - const uint8_t halfTop = c.briLut[255]; + const uint8_t halfTop = c.briLut[0][255]; - CHECK(c.briLut[0] == 0); // zero is zero at any brightness + CHECK(c.briLut[0][0] == 0); // zero is zero at any brightness CHECK(halfTop < fullTop); // the slider still reaches the output // Half the SLIDER is well under half the light, because the curve applies after the scale. CHECK(halfTop < fullTop / 2); @@ -522,11 +522,11 @@ TEST_CASE("RGBW white is derived in linear light, then curved once") { mm::test::rebuildFromPreset(ref, 255, mm::test::PresetOrder::RGB); ref.curve = Correction::Curve::Cie; ref.rebuildBrightness(255); - CHECK(out[3] == ref.briLut[100]); + CHECK(out[3] == ref.briLut[Correction::kWhite][100]); // and RGB carry the remainder (200-100, 150-100, 0), each curved. - CHECK(out[0] == ref.briLut[100]); - CHECK(out[1] == ref.briLut[50]); - CHECK(out[2] == ref.briLut[0]); + CHECK(out[0] == ref.briLut[0][100]); + CHECK(out[1] == ref.briLut[0][50]); + CHECK(out[2] == ref.briLut[0][0]); } namespace { @@ -564,7 +564,7 @@ TEST_CASE("At low brightness a 16-bit channel keeps every level the 8-bit table const uint8_t src[3] = {static_cast(v), 0, 0}; uint8_t out[6] = {}; c.apply(src, out, 3); - narrow.insert(c.briLut[v]); + narrow.insert(c.briLut[0][v]); wide.insert(out[0] * 256 + out[1]); } CHECK(narrow.size() <= 4); @@ -625,3 +625,83 @@ TEST_CASE("Dimmer fine is driven wide open with its coarse byte") { c.apply(src, out, 3); CHECK((out[0] == 255 && out[1] == 255)); } + +// --- White balance -------------------------------------------------------------------------- +// An LED's three dies are not equally efficient, and on an SK6812 the green runs hot, so a neutral triple renders green-cast. One trim per channel, folded into the table the hot path already reads. + +TEST_CASE("Correction white balance: trimming one channel leaves the others untouched") { + Correction c; + c.balGreen = 128; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB); + CHECK(c.briLut[0][255] == 255); // red untrimmed + CHECK(c.briLut[1][255] == 128); // green at half + CHECK(c.briLut[2][255] == 255); // blue untrimmed + + const uint8_t src[3] = {200, 200, 200}; // the triple a green-cast strip renders wrong + uint8_t out[3] = {}; + c.apply(src, out, 3); + CHECK(out[0] == 200); + CHECK(out[1] == 100); // pulled down to match the weaker channels + CHECK(out[2] == 200); +} + +// The trim corrects the RGB dies, so it must not reach the separate white phosphor. +TEST_CASE("Correction white balance: an RGB trim does not reach the white die") { + Correction c; + c.balBlue = 128; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGBW); + const uint8_t src[3] = {200, 200, 200}; + uint8_t out[4] = {}; + c.apply(src, out, 3); + CHECK(out[0] == 200); // R + CHECK(out[1] == 200); // G + CHECK(out[2] == 100); // B trimmed + CHECK(out[3] == 200); // W from the RAW minimum, untrimmed +} + +// Two inputs to one fill rather than two stages, so the hot path stays a single lookup. +TEST_CASE("Correction: the curve and white balance compose into the one table") { + Correction c; + c.curve = Correction::Curve::Gamma22; + c.balBlue = 128; + mm::test::rebuildFromPresetKeepingCurve(c, 255, mm::test::PresetOrder::RGB); + CHECK(c.briLut[0][200] == 149); // red: curve only, (200/255)^2.2 * 255 + // Blue takes the trim as a PRE-scale, so the curve still lands last. + CHECK(c.briLut[2][200] < c.briLut[0][200]); + CHECK(c.briLut[2][200] > 0); +} + +// The white die is separate hardware the RGB trims say nothing about, so it carries its own. +TEST_CASE("Correction whiteLevel: trims the white die without touching RGB") { + const uint8_t src[3] = {200, 160, 120}; // white component = min = 120 + + Correction full; + mm::test::rebuildFromPreset(full, 255, mm::test::PresetOrder::RGBW); + uint8_t a[4] = {}; + full.apply(src, a, 3); + + Correction half; + half.whiteLevel = 128; + mm::test::rebuildFromPreset(half, 255, mm::test::PresetOrder::RGBW); + uint8_t b[4] = {}; + half.apply(src, b, 3); + + CHECK(b[3] < a[3]); // white trimmed + CHECK(b[3] == half.briLut[Correction::kWhite][120]); // through its own row, curve last + CHECK(b[0] == a[0]); // and RGB untouched + CHECK(b[1] == a[1]); + CHECK(b[2] == a[2]); +} + +// Zero has to mean dark. The never-black nudge is gated on the SCALE rather than the input, or a fully trimmed die would still emit its floor. +TEST_CASE("Correction whiteLevel: zero puts the white die out, not at its floor") { + Correction c; + c.whiteLevel = 0; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGBW); + for (int v = 0; v < 256; v++) CHECK(c.briLut[Correction::kWhite][v] == 0); + const uint8_t src[3] = {200, 160, 120}; + uint8_t out[4] = {}; + c.apply(src, out, 3); + CHECK(out[3] == 0); + CHECK(out[0] > 0); // the colors are untouched by it +} diff --git a/test/unit/light/unit_Drivers_container.cpp b/test/unit/light/unit_Drivers_container.cpp index 043658bbe..d68790d13 100644 --- a/test/unit/light/unit_Drivers_container.cpp +++ b/test/unit/light/unit_Drivers_container.cpp @@ -137,19 +137,19 @@ TEST_CASE("Drivers::on gates the correction LUT without clobbering brightness") drivers.on = true; drivers.onControlChanged("brightness"); // rebuild LUT at full power CHECK(drivers.effectiveBrightness() == 200); - CHECK(drv.correctionForTest().briLut[255] == 200); // (255 * 200) / 255 == 200 + CHECK(drv.correctionForTest().briLut[0][255] == 200); // (255 * 200) / 255 == 200 // Turn off → LUT scales to black, but the brightness value is untouched. drivers.on = false; drivers.onControlChanged("on"); CHECK(drivers.brightness == 200); // value preserved CHECK(drivers.effectiveBrightness() == 0); - CHECK(drv.correctionForTest().briLut[255] == 0); // output black + CHECK(drv.correctionForTest().briLut[0][255] == 0); // output black // Turn back on → the exact level returns, no stored-value juggling. drivers.on = true; drivers.onControlChanged("on"); - CHECK(drv.correctionForTest().briLut[255] == 200); + CHECK(drv.correctionForTest().briLut[0][255] == 200); } // A per-driver localBrightness change re-scales that driver's correction LUT (global × local), as a global brightness change does. @@ -163,17 +163,17 @@ TEST_CASE("Drivers: a localBrightness change re-scales the driver's correction L // LINEAR, so the arithmetic below reads as the multiplication it is testing. What this pins is that BOTH sliders reach the LUT, which a perceptual curve would leave true but express as table lookups nobody can check by eye. The curve has its own tests. mm::test::setControlValue(drv, "curve", static_cast(mm::Correction::Curve::Linear)); drivers.setup(); // seeds the driver's correction (global 200, local 255) - CHECK(drv.correctionForTest().briLut[255] == 200); // global 200 × local 255/255 = 200 + CHECK(drv.correctionForTest().briLut[0][255] == 200); // global 200 × local 255/255 = 200 // Halve the driver's LOCAL brightness, its own control change must re-bake the LUT to global × local = 200 × 128/255 ≈ 100. This is the path the bug missed. mm::test::setControlValue(drv, "localBrightness", 128); drv.onControlChanged("localBrightness"); - CHECK(drv.correctionForTest().briLut[255] == 100); // (200 * 128) / 255 == 100 + CHECK(drv.correctionForTest().briLut[0][255] == 100); // (200 * 128) / 255 == 100 // And the global slider still composes on top: raising global to 255 with local 128 → 128. drivers.brightness = 255; drivers.onControlChanged("brightness"); - CHECK(drv.correctionForTest().briLut[255] == 128); // (255 * 128) / 255 == 128 + CHECK(drv.correctionForTest().briLut[0][255] == 128); // (255 * 128) / 255 == 128 } // Disabled child drivers don't tick: toggling `enabled` flips whether that driver's tick() runs.