diff --git a/.gitignore b/.gitignore
index f9eee7e49..84bf76469 100644
--- a/.gitignore
+++ b/.gitignore
@@ -147,6 +147,9 @@ __pycache__/
# nested path of that name anywhere in the tree.
/.snapshots/
+# Knowledge-graph output (the /graphify skill)
+graphify-out/
+
# Video-production OUTPUT: the music a cut is scored against, the raw takes, the
# finished videos, and the intermediates between them. Nothing here is redistributable
# (licensed music, large binaries), and a repository is not a media library: what ships
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/assets/light/layouts/RectangleLayout.gif b/docs/assets/light/layouts/RectangleLayout.gif
new file mode 100644
index 000000000..badbd5334
Binary files /dev/null and b/docs/assets/light/layouts/RectangleLayout.gif differ
diff --git a/docs/assets/light/layouts/RectangleLayout.png b/docs/assets/light/layouts/RectangleLayout.png
new file mode 100644
index 000000000..3d5e18746
Binary files /dev/null and b/docs/assets/light/layouts/RectangleLayout.png differ
diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md
index da87670f6..af87b3c9e 100644
--- a/docs/moonmodules/core/services.md
+++ b/docs/moonmodules/core/services.md
@@ -1,5 +1,7 @@
# Core services
+
+
The user-added **Service** modules β capability bridges the device provides or consumes, added and removed at runtime in the `Services` container (the core-domain twin of the light domain's `Effects`/`Drivers`). Fixed device infrastructure (identity, network, inspection tools) lives under **System** β see [core/system.md](system.md). Every row links to its generated technical page (the full API, from the `.h`) and its tests.
@@ -38,6 +40,28 @@ Detail: [technical](moxygen/AudioService.md) Β· [the sync packet](../light/moxyg
[Tests](../../reference/tests/unit-tests.md#audioservice)
+
+
+### Video
+
+
+
+A user-added Service: the video source screen-follow effects read. 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
@@ -258,3 +282,35 @@ One key binds to one row. Learning a key that another row already holds moves th
The status line reports setup state ("set pin to receive" / "ready"), the learn prompt, a binding ("learned 0x..."), what a press did or why it did not, and an unbound code ("received 0x...
(unassigned)").
+
+## Video, details
+
+**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 mis-set `startCorner` or `clockwise` on the [Rectangle](../light/layouts.md#rectangle) layout shows as the wrong physical edge lighting, rather than a subtly wrong picture. The sweeping block shows liveness and which way "forward" runs.
+
+**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.
+
+**Why PPM for the file source.** The capture path decodes MJPEG in hardware, behind the platform layer. 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 sources
+
+`test pattern` synthesizes a frame, so it needs no hardware and no files: four colored border bands and a white block sweeping the top edge. `file` reads a binary PPM off the filesystem. `usb` captures from an HDMI grabber, and is offered only on a target with a High-Speed USB host and a JPEG decoder.
+
+Parking the sweep with `patternSpeed` 0 turns the pattern into a still reference: a border light can be compared against a known color without the block passing through its zone mid-read.
+
+#### HDR
+
+MJPEG carries no HDR metadata and a grabber strips what the console sends, so the curve is declared rather than detected. 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
+
+A UVC device streams continuously whatever is on the wire, so a gap in frames means the grabber stopped rather than that the content paused. `staleMs` is how long a gap is tolerated before the lights go dark.
diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md
index 84445b55f..84969100f 100644
--- a/docs/moonmodules/light/drivers.md
+++ b/docs/moonmodules/light/drivers.md
@@ -10,7 +10,7 @@ Several drivers can share one buffer, each driving its own slice. Every driver s
### Shared π« Β· every driver
-The block every driver card opens with, shown here on RMT LED. Added once by [`DriverBase`](moxygen/DriverBase.md) so no driver re-implements it: a per-driver **output correction** (how this driver's slice looks) and a **source window** (which slice of the shared buffer it reads). A driver card leads with whichever half applies, and its own controls follow. Hue and Preview correct nothing, since the fixture and the browser do it; HUB75, Preview, NDI and HLS take the whole buffer rather than a window.
+The block every driver card opens with, shown here on RMT LED. Added once by [`DriverBase`](moxygen/DriverBase.md): a per-driver **output correction** and a **source window**. Which half applies, and the current limiter: β [details](#shared-details).
@@ -18,6 +18,11 @@ The block every driver card opens with, shown here on RMT LED. Added once by [`D
- `curve`: brightness to output: `CIE 1931`, `gamma 2.2`, `gamma 2.8`, or `linear`.
- `lightPreset`: the [light preset](supporting.md) applied per light, for order and white.
- `whiteMode`: how W is derived, shown when the preset carries a W channel.
+- `balanceRed` / `balanceGreen` / `balanceBlue`: per-channel trim, `255` = untouched.
+- `whiteLevel`: the white die's own trim (0 to 255, `255` = untouched).
+- `maxCurrentMa`: cap the current a frame may draw, in milliamps. 0 (default) is off.
+- `mAPerColorChannel` / `mAPerWhiteChannel`: what one channel draws at full, for that estimate.
+- `mAPerYellowChannel` / `mAPerUvChannel`: the same for a 6-channel lightbar's two extra emitters.
- `start`: first light of the shared buffer this driver reads (default `0`).
- `count`: how many lights from `start`. **Blank drives all of them.**
@@ -220,6 +225,24 @@ Detail: [technical](moxygen/RtspDriver.md) Β· [the RTP packetiser](moxygen/RtpH2
## Shared, details
+
+#### What each half is for
+
+Output correction is how this driver's slice looks; the source window is which slice of the shared buffer it reads. A driver card leads with whichever half applies, and its own controls follow. Hue and Preview correct nothing, since the fixture and the browser do it; HUB75, Preview, NDI and HLS take the whole buffer rather than a window.
+
+#### White balance and the white die
+
+Trim the balances **down** to pull a white point neutral: leave the weakest channel at 255 and lower the other two to match. There is no headroom above 255, so raising clips rather than balances.
+
+`whiteLevel` is separate, because the RGB trims cannot reach the W die: on an RGBW strip the white phosphor is its own hardware, often brighter than the RGB trio, so whites blow out while colors look right. It trims the white channel alone and leaves RGB untouched. 0 gives the same output as `whiteMode: None`. Shown only where the referenced preset carries a white channel.
+
+#### The current limiter
+
+The driver prices each frame before emitting it and scales the whole frame down if it is over budget, so a white screen cannot brown out the supply whatever the brightness. Set `maxCurrentMa` to the supply's rating less what the board itself uses.
+
+The per-channel figures are per **channel**, not per light: a white die draws about twice a color one, so a single per-light figure under-reports white-heavy frames, which is the direction that browns out a supply. Defaults (8 and 16) are measured on a 5 m SK6812 RGBW strip. WLED carries the per-light version of this as an open issue, [#3707](https://github.com/wled/WLED/issues/3707). The lightbar figures default to 8 by assumption rather than measurement: amber sits near red, while a UV die usually draws more.
+
+Only drivers whose lights run off **this board's supply** offer these at all: the LED drivers do, while [Network Send](#networksend), [Panel Card](#panelcard) and [Hue](#hue) feed fixtures with their own power and so have nothing to cap. A master dimmer is not counted: on the fixtures that declare one it is a control value drawing nothing from this rail, like the motion channels beside it.
**A `lightPreset` reference survives some changes and not others.** The driver holds the preset's stable id at runtime, so **reordering** presets never disturbs it, and the reference **survives a reboot** because the preset'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 preset no longer matches the persisted name and the driver falls back to the default. Re-pick it if you rename a preset 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 ede303f74..965537a25 100644
--- a/docs/moonmodules/light/effects.md
+++ b/docs/moonmodules/light/effects.md
@@ -367,7 +367,6 @@ Shapes placed between pixels rather than on them. A clock hand drawn on whole pi
- `drift`: how far the scene wanders from center, in pixels; 0 pins it.
- `zoom`: the camera pushes in and settles back; 0 holds it fixed.
-
Origin: MoonLight (Sutaburosu)
Detail: [technical](moxygen/FixedPointEffect.md)
@@ -1174,7 +1173,21 @@ Detail: [technical](moxygen/NoiseEffect.md)
## MoonLight-native effects
-
+
+
+### Ambilight πΊ
+
+
+
+Paints the layer with the live frame from the [Video](../core/services.md#video) service, so lights around a display glow the color of the picture nearest them. Each light shows the **mean** of the rectangle mapping to it. Sampling, black bars and the layout's part: β details.
+
+- `brightness`: scales the sampled color, dimming the video not the output.
+- `saturation`: how far each channel is pushed from its zone's luma.
+- `smoothing`: how much of the gap to a new color closes per frame.
+- `snapAbove`: a channel moving further than this jumps instead of easing.
+- `edgeDepth`: how far into the picture the outermost lights look.
+- `detectBlackBars`: map the lights across the picture, not the frame.
+- `barLevel`: how dark a pixel counts as bar (0 to 64).
### MoonLive π Β· any
@@ -1336,3 +1349,33 @@ Origin: MoonLight (Sinus, AI-generated) Β· via [MoonLight](https://github.com/Mo
Detail: [technical](moxygen/SineEffect.md)
[Tests](../../reference/tests/unit-tests.md#sineeffect)
+
+## Ambilight, details
+
+#### Why the mean
+
+A single sampled pixel flickers on film grain and moving edges. The mean of the whole rectangle is steady, which is what makes the light read as belonging to the picture rather than chasing it. This is the box filter Hyperion uses.
+
+#### Sampling depth
+
+`edgeDepth` **sets** the depth rather than raising a floor, so a value below a position's own share makes its zone thinner instead. The outermost lights' own share is 1/height of the frame, a sliver at the very edge where compression is worst; Hyperion samples about 8%.
+
+#### Black bars
+
+`barLevel` defaults to 12, about 5%, which matches Hyperion and suits MJPEG: that is conventionally full-range, so black arrives near 0. **If bars are never detected**, suspect a grabber passing limited range through: black then sits at 16 and nothing below 12 ever matches, so raise this above 16. Lower it if dark scenes get cropped instead.
+
+`edgeDepth` cannot substitute for detection: it widens a zone from the edge, so a bar stays inside it. Detection resists a dark *scene* two ways: a reading is adopted only after 30 agreeing frames, and anything deeper than 40% of an axis is refused as a scene rather than a bar.
+
+#### The layout decides the shape
+
+The effect fills a logical box and knows nothing else. On a [Rectangle](layouts.md#rectangle) the interior maps to no LED, so a border strip shows the frame's border; on a [Grid](layouts.md#grid) the same effect is a video wall. Where your strip starts and which way it runs are `startCorner`, `offset` and `clockwise` on the layout, not settings here.
+
+With no video source it paints black. Every effect owns its background, so returning early would leave the previous effect's picture frozen on the strip.
+
+Origin: MoonLight
+
+Detail: [technical](moxygen/AmbilightEffect.md)
+
+[Tests](../../reference/tests/unit-tests.md#ambilighteffect)
+
+
diff --git a/docs/moonmodules/light/layouts.md b/docs/moonmodules/light/layouts.md
index e2b450497..50889d8eb 100644
--- a/docs/moonmodules/light/layouts.md
+++ b/docs/moonmodules/light/layouts.md
@@ -256,6 +256,24 @@ Detail: [technical](moxygen/GridBlacksLayout.md)
[Tests](../../reference/tests/unit-tests.md#gridblackslayout)
+
+
+### Rectangle
+
+
+
+Lights around the **perimeter** of a `width` x `height` box, nothing inside it: the strip-around-a-frame primitive. A box one light thick degenerates to a plain line. Use [Grid](#grid) when the interior has LEDs too.
+
+- `width` / `height`: box extent in lights along each edge (1 to 500).
+- `startCorner`: which corner light 0 sits at.
+- `offset`: lights past that corner where the strip begins.
+- `clockwise`: direction the indices run from that corner.
+- `sharedCorners`: on (default), one light per corner: `2Β·(width + height) β 4`.
+
+Detail: [technical](moxygen/RectangleLayout.md)
+
+[Tests](../../reference/tests/unit-tests.md#rectanglelayout)
+
### Sphere
@@ -310,3 +328,9 @@ A script calling `random16` breaks that determinism. The passes disagree on the
`t` is the one system variable a layout reads, and it is always 0: the script runs twice per rebuild and must agree with itself. `width`, `height` and `depth` read 0, since a layout is upstream of the grid it defines.
A script names its own size controls, such as `cols` and `rows`. The pipeline derives the bounding box from the coordinates actually placed, so a size passed in from outside would be a second answer that could disagree with the first.
+
+## Rectangle, details
+
+`startCorner`, `offset` and `clockwise` describe the **wiring, not the shape**: they reorder indices while every coordinate stays identical, so set them to match your build and an effect's "top edge" lights the physical top edge. Same split [Single Row](#singlerow) draws with `reversed order` and [Grid](#grid) with `serpentine`. `sharedCorners` is the exception, since it changes how many lights there are.
+
+Origin: MoonLight
diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json
index 52ca02e00..a789658fe 100644
--- a/docs/reference/metrics/repo-health.json
+++ b/docs/reference/metrics/repo-health.json
@@ -1,11 +1,11 @@
{
- "commit": "7072824b",
+ "commit": "a8d1aa65",
"flash": {
"esp32s3-n16r8": 2142976,
- "desktop": 1993480,
+ "desktop": 2033096,
"esp32": 2091488,
"esp32p4rev1-eth": 2045840,
- "esp32p4rev1-eth-wifi": 2331904,
+ "esp32p4rev1-eth-wifi": 2493504,
"esp32s3-n8r8": 2087168,
"esp32s31": 2428080,
"esp32-16mb": 2060368,
@@ -14,7 +14,8 @@
"qemu": 1383648,
"esp32p4rev3-eth": 1643760,
"esp32s3-zero": 2024192,
- "esp32-pico": 2107168
+ "esp32-pico": 2107168,
+ "esp32p4rev3-eth-wifi": 2498304
},
"measured": {
"esp32p4rev1-eth": "2026-09-22",
@@ -22,23 +23,24 @@
"esp32": "2026-09-25",
"esp32-pico": "2026-09-09",
"esp32s3-n16r8": "2026-09-25",
- "desktop": "2026-09-25",
+ "desktop": "2026-09-27",
"esp32s3-n8r8": "2026-09-08",
"esp32s3-zero": "2026-09-08",
"esp32-16mb": "2026-09-09",
- "esp32p4rev1-eth-wifi": "2026-09-22",
- "esp32-eth": "2026-09-11"
+ "esp32p4rev1-eth-wifi": "2026-09-27",
+ "esp32-eth": "2026-09-11",
+ "esp32p4rev3-eth-wifi": "2026-09-27"
},
"perf": {
"desktop": {
- "tick_us": 1,
- "fps": 1000000,
+ "tick_us": 28,
+ "fps": 35714,
"scenario_p50": {
"Effects_pipeline_builds_and_renders": {
"p50": 8,
"p95": 19,
"n": 27,
- "last": "2026-09-25"
+ "last": "2026-09-27"
},
"Layouts_resize_reallocates_live": {
"p50": 66,
@@ -49,8 +51,8 @@
}
},
"esp32": {
- "tick_us": 8354,
- "fps": 119
+ "tick_us": 6832,
+ "fps": 146
},
"scenario_matrix": {
"Firmware_reports_what_is_running": {
@@ -74,7 +76,7 @@
"p50": 8,
"p95": 19,
"n": 27,
- "last": "2026-09-25"
+ "last": "2026-09-27"
}
},
"Effects_swap_while_running": {
@@ -92,58 +94,66 @@
"n": 8,
"last": "2026-09-24"
}
+ },
+ "Video_mutation": {
+ "desktop-macos": {
+ "p50": 30,
+ "p95": 30,
+ "n": 1,
+ "last": "2026-09-27"
+ }
}
}
},
"loc": {
- "core": 22174,
- "light": 30956,
- "platform": 16776,
+ "core": 22736,
+ "light": 31637,
+ "platform": 17439,
"ui": 11360,
- "test": 57352,
- "moondeck": 27952
+ "test": 58703,
+ "moondeck": 27964
},
"comments": {
"core": {
- "lines": 5659,
- "ratio": 0.282
+ "lines": 5792,
+ "ratio": 0.281
},
"light": {
- "lines": 7456,
+ "lines": 7604,
"ratio": 0.27
},
"platform": {
- "lines": 3605,
- "ratio": 0.24
+ "lines": 3710,
+ "ratio": 0.238
},
"ui": {
"lines": 3396,
"ratio": 0.316
},
"test": {
- "lines": 6675,
+ "lines": 6830,
"ratio": 0.135
},
"moondeck": {
- "lines": 4673,
+ "lines": 4675,
"ratio": 0.19
}
},
"tests": {
- "cases": 2100,
- "scenarios": 11
+ "cases": 2171,
+ "scenarios": 12
},
"docs": {
"md_files": 134,
- "md_lines": 28075,
+ "md_lines": 28222,
"plans_files": 37,
"backlog_lines": 3110,
"lessons_lines": 526,
"claude_md_lines": 281
},
"complexity": {
- "functions": 3703,
- "over_threshold": 277,
+ "functions": 3819,
+ "over_threshold": 283,
"worst_ccn": 128
}
}
diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md
index da7ff8636..224473333 100644
--- a/docs/reference/metrics/repo-health.md
+++ b/docs/reference/metrics/repo-health.md
@@ -1,6 +1,6 @@
# Repo health
-Measured at `7072824b`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
+Measured at `a8d1aa65`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.
@@ -8,19 +8,20 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Target | Flash | Capacity | Used | Built |
|---|---:|---:|---:|:--:|
-| desktop | 1,947 KB (+1 KB) β | - | - | yes |
-| esp32 | 2,042 KB (+368 B) β | 2,496 KB | 82% | yes |
-| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 16d** |
-| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 14d** |
-| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 16d** |
+| desktop | 1,985 KB (+39 KB) β | - | - | yes |
+| esp32 | 2,042 KB | 2,496 KB | 82% | carried 2d |
+| esp32-16mb | 2,012 KB | - | - | **STALE 18d** |
+| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 16d** |
+| esp32-pico | 2,058 KB | - | - | **STALE 18d** |
| esp32-wrover | 1,801 KB | - | - | carried (age?) |
-| esp32p4rev1-eth | 1,998 KB | 4,096 KB | 49% | carried 3d |
-| esp32p4rev1-eth-wifi | 2,277 KB | 4,096 KB | 56% | carried 3d |
-| esp32p4rev3-eth | 1,605 KB | - | - | carried (age?) |
-| esp32s3-n16r8 | 2,093 KB | 4,096 KB | 51% | carried 0d |
-| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 17d** |
-| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 17d** |
-| esp32s31 | 2,371 KB | 4,096 KB | 58% | carried 3d |
+| esp32p4rev1-eth | 1,998 KB | 4,096 KB | 49% | carried 5d |
+| esp32p4rev1-eth-wifi | 2,435 KB (+158 KB) β | 4,096 KB | 59% | yes |
+| esp32p4rev3-eth | 1,605 KB | 4,096 KB | 39% | carried (age?) |
+| esp32p4rev3-eth-wifi | 2,440 KB | 4,096 KB | 60% | yes |
+| esp32s3-n16r8 | 2,093 KB | 4,096 KB | 51% | carried 2d |
+| esp32s3-n8r8 | 2,038 KB | - | - | **STALE 19d** |
+| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 19d** |
+| esp32s31 | 2,371 KB | - | - | carried 5d |
| qemu | 1,351 KB | - | - | carried (age?) |
`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table.
@@ -29,8 +30,8 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Target | Tick | FPS |
|---|---:|---:|
-| desktop | 1 Β΅s | 1,000,000 |
-| esp32 | 8,354 Β΅s | 119 |
+| desktop | 28 Β΅s (+27 Β΅s) β | 35,714 (β964,286) β |
+| esp32 | 6,832 Β΅s (β1,522 Β΅s) β | 146 (+27) β |
### Scenario tick by target (p50 of each sample window)
@@ -41,10 +42,11 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Effects_swap_while_running | 48 |
| Firmware_reports_what_is_running | 38 |
| Layouts_resize_reallocates_live | 66 |
+| Video_mutation | 30 ? |
Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario.
-**Coverage: 5/5 cells measured (100%), 5 of them with 4+ samples (100%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off.
+**Coverage: 6/6 cells measured (100%), 5 of them with 4+ samples (83%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off.
### desktop: isolated scenarios (p50 of the sample window)
@@ -59,26 +61,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan
| Area | Lines | Comments | Comment share |
|---|---:|---:|---:|
-| core | 22,174 | 5,659 | 28.2 % |
-| light | 30,956 | 7,456 | 27.0 % |
-| platform | 16,776 | 3,605 | 24.0 % |
+| core | 22,736 (+562) β | 5,792 | 28.1 % (β0.1 %) β |
+| light | 31,637 (+681) β | 7,604 | 27.0 % |
+| platform | 17,439 (+663) β | 3,710 | 23.8 % (β0.2 %) β |
| ui | 11,360 | 3,396 | 31.6 % |
-| test | 57,352 | 6,675 | 13.5 % |
-| moondeck | 27,952 | 4,673 | 19.0 % |
+| test | 58,703 (+1,351) β | 6,830 | 13.5 % |
+| moondeck | 27,964 (+12) β | 4,675 | 19.0 % |
## Tests
| Kind | Count |
|---|---:|
-| unit cases | 2,100 |
-| scenarios | 11 |
+| unit cases | 2,171 (+71) β |
+| scenarios | 12 (+1) β |
## Complexity
| Metric | Value |
|---|---:|
-| functions | 3,703 (+1) β |
-| over threshold | 277 |
+| functions | 3,819 (+116) β |
+| over threshold | 283 (+6) β |
| worst CCN | 128 |
## Documentation
@@ -86,7 +88,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan
| Metric | Value |
|---|---:|
| markdown files | 134 |
-| markdown lines | 28,075 (+19) β |
+| markdown lines | 28,222 (+147) β |
| plan files | 37 |
| backlog lines | 3,110 |
| lessons lines | 526 |
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index 293cc937a..f6288cf4a 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -36,6 +36,7 @@ idf_component_register(
"../../src/platform/esp32/platform_esp32_moon_i80.cpp"
"../../src/platform/esp32/platform_esp32_parlio.cpp"
"../../src/platform/esp32/platform_esp32_i2s.cpp"
+ "../../src/platform/esp32/platform_esp32_usbvideo.cpp"
"../../src/platform/esp32/platform_esp32_i2c.cpp"
"../../src/platform/esp32/platform_esp32_ir.cpp"
"../../src/platform/esp32/platform_esp32_es8311.cpp"
diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml
index 334c744d9..7e514a2a4 100644
--- a/esp32/main/idf_component.yml
+++ b/esp32/main/idf_component.yml
@@ -83,3 +83,9 @@ dependencies:
version: "~2.12.9"
rules:
- if: "$CONFIG{MM_P4_WIFI} == True"
+ # usb_host_uvc β UVC (webcam) host, allows to read video from USB device. P4 only: 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..b256c4293 100644
--- a/esp32/sdkconfig.defaults.esp32p4rev1-eth
+++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth
@@ -92,3 +92,9 @@ 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 enumerated. Costs 2 KB of heap per
+# attached device. Here because every P4 image layers on this fragment and USB video is P4-only.
+CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE=2048
diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py
index 808a557c3..fca00b83e 100644
--- a/moondeck/docs/screenshot_modules.py
+++ b/moondeck/docs/screenshot_modules.py
@@ -98,11 +98,17 @@ def asset_dir_for(type_name: str) -> Path:
MODULES = [
# Layouts
("GridLayout", "Layouts", {}, True),
+ # The border layout the screen-follow effect is built for: a ring with the corners shared, so
+ # the preview shows the perimeter rather than a filled box.
+ ("RectangleLayout", "Layouts", {"width": 24, "height": 16}, True),
# The scripted layout, whose preview is the shape a script places rather than a control
# panel: it runs `rose.mll`, a rhodonea curve whose petals no compiled layout defines,
# so the capture shows what a script buys that a C++ class does not.
("MoonLiveLayout", "Layouts", {"script": "rose.mll"}, True),
# Effects
+ # 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),
("RainbowEffect", "Layer", {}, True),
("NoiseEffect", "Layer", {}, True),
# The generative-fields showcases: each is Dim::D3, so the preview shows a volume.
@@ -178,6 +184,7 @@ def asset_dir_for(type_name: str) -> Path:
# (not in state); capture these against a board when needed.
"ImprovProvisioningModule",
"AudioService",
+ "VideoService",
"AnalogService",
"I2cScanModule",
"InfraredService",
@@ -203,6 +210,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/moondeck/scenario/run_network_live.py b/moondeck/scenario/run_network_live.py
index 2be185417..e02e94af9 100644
--- a/moondeck/scenario/run_network_live.py
+++ b/moondeck/scenario/run_network_live.py
@@ -51,16 +51,20 @@
ROUND_COLORS = [(255, 128, 0), (0, 255, 128), (128, 0, 255),
(255, 0, 128), (128, 255, 0), (0, 128, 255)]
-# Mirrors src/light/drivers/Correction.h (briLut scale + order[] reorder) β a
-# listener sees the sender's corrected bytes, so the expected color replicates
-# that transform. 3-channel presets only; RGBW senders emit 4 bytes/light which
-# misaligns a 3-channel listener buffer, so those legs are skipped. Keep in sync.
+# 3-channel presets only: an RGBW sender emits 4 bytes/light, which misaligns a
+# 3-channel listener buffer, so those legs are skipped.
PRESET_ORDER = {"RGB": (0, 1, 2), "RBG": (0, 2, 1), "GRB": (1, 0, 2),
"GBR": (1, 2, 0), "BRG": (2, 0, 1), "BGR": (2, 1, 0)}
PRESET_NAMES = ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR", "RGBW", "GRBW"]
def corrected(rgb, brightness, preset):
+ """Mirror src/light/drivers/Correction.h so a listener's expected color matches the
+ sender's corrected bytes. Keep in sync.
+
+ Models the plain brightness scale only: `curve` Linear and white-balance trims at 255.
+ A sender on another curve or with a trim set would have to be modeled here too.
+ """
scaled = [(v * int(brightness)) // 255 for v in rgb]
order = PRESET_ORDER[preset]
return tuple(scaled[order[i]] for i in range(3))
diff --git a/src/core/services/VideoService.h b/src/core/services/VideoService.h
new file mode 100644
index 000000000..9d2770e3d
--- /dev/null
+++ b/src/core/services/VideoService.h
@@ -0,0 +1,517 @@
+#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
+///
+/// Decoded once here however many effects read it, and effects hold no pointer back to this module.
+/// Not auto-wired: the user adds it under the `Services` container.
+///
+/// ## Three sources
+///
+/// `test pattern` is a DIAGNOSTIC rather than decoration: its colored border bands make a border-mapped effect's orientation self-evident.
+/// A mis-set `startCorner` then shows as the wrong physical edge lighting rather than as a subtly wrong picture.
+/// `file` reads a binary PPM, and `usb` captures from an HDMI grabber where the platform has the hardware for it.
+/// PPM rather than JPEG because there is no software JPEG decoder here, and the capture path uses the P4's JPEG hardware behind the platform layer.
+///
+/// ## Who owns the pixels
+///
+/// The software sources render into `buf_`, which this module owns.
+/// `usb` BORROWS the decoder's output buffer, which the platform owns 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.
+ 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{};
+ }
+
+ 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..83c45e307
--- /dev/null
+++ b/src/core/util/VideoFrame.h
@@ -0,0 +1,45 @@
+#pragma once
+
+#include
+
+namespace mm {
+
+/// One decoded video frame, produced by VideoService and read by video-reactive effects.
+///
+/// @moreinfo
+///
+/// The same plain-struct contract as AudioFrame, except that a frame is hundreds of kilobytes, so this borrows a pointer to the producer's buffer rather than carrying the pixels.
+/// `rgb` is valid only until VideoService's next tick, so hold it for one effect tick and never across frames.
+/// Before any frame exists it is null, which every consumer must tolerate.
+///
+/// ## The tone curve
+///
+/// `tone` maps the source's encoding to linear light, and SDR is sRGB, a curve like any other, 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, which is what encodings exist for.
+/// 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.
+ 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 0106bbec6..445c5f6d8 100644
--- a/src/light/drivers/Correction.h
+++ b/src/light/drivers/Correction.h
@@ -21,7 +21,9 @@ namespace mm {
/// More than one algorithm is accepted, so white derivation is a mode rather than a formula.
enum class WhiteMode : uint8_t { None, Min, Accurate };
+/// Dropdown labels for WhiteMode, in enum order.
inline constexpr const char* kWhiteModeOptions[] = {"None", "Min", "Accurate"};
+/// How many modes are on offer.
inline constexpr uint8_t kWhiteModeCount =
sizeof(kWhiteModeOptions) / sizeof(kWhiteModeOptions[0]);
@@ -44,7 +46,17 @@ struct Correction {
}
- uint8_t briLut[256] = {}; // briLut[v] = curve(v * brightness / 255)
+ // White, amber and UV are their own dies, so an RGB trim must not reach them; 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;
+ /// briLut[ch][v] = curve(v * brightness * balance[ch]), with rows 0=R, 1=G, 2=B, 3=untrimmed, 4=white.
+ uint8_t briLut[5][256] = {};
+ /// 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;
+ /// The white die's trim, 255 = untouched: a separate emitter, often brighter than the RGB trio, that the three trims above cannot reach. Pre-scales like them, 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.
@@ -54,7 +66,8 @@ struct Correction {
uint8_t offGreen = 0;
/// Output byte position of the blue role.
uint8_t offBlue = 2;
- uint8_t offWhite = kAbsent; // derived white at this offset (kAbsent = light has no white)
+ /// Output byte position of the derived white, or kAbsent when the light has none.
+ uint8_t offWhite = kAbsent;
// Warm white has a real achromatic basis; amber and UV are eyeball approximations, honestly so.
/// Output byte positions of the extra emitters beside cold white.
uint8_t offWarmWhite = kAbsent;
@@ -76,25 +89,46 @@ struct Correction {
uint8_t offYellow = kAbsent;
/// Output byte position of the UV emitter.
uint8_t offUV = kAbsent;
- uint8_t outChannels = 3; // bytes emitted per light (= channelsPerLight of the wiring)
- WhiteMode whiteMode = WhiteMode::Min; // how white is synthesized from RGB (white lights only)
+ /// Bytes emitted per light, which is the wiring's channelsPerLight.
+ uint8_t outChannels = 3;
+ /// How white is synthesized from RGB, on lights that carry a white die.
+ WhiteMode whiteMode = WhiteMode::Min;
+
+ // Priced per CHANNEL rather than per light, since under-reporting the draw browns out a supply.
+ /// The supply budget a frame is priced against, in milliamps; 0 disables the limiter.
+ uint16_t budgetMa = 0;
+ /// Draw of one R/G/B channel at 255, in milliamps.
+ uint8_t mAColor = 8;
+ /// Draw of one white channel at 255, which is about twice a color one.
+ uint8_t mAWhite = 16;
+ /// Draw of one amber channel at 255, assumed rather than measured.
+ uint8_t mAYellow = 8;
+ /// Draw of one UV channel at 255, assumed rather than measured.
+ uint8_t mAUV = 8;
+ /// What measure() set; 256 = unity, so an unlimited frame is bit-exact.
+ uint16_t limit = 256;
// 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) {
- for (int v = 0; v < 256; v++) {
- const float linear = static_cast(v) * brightness / 255.0f; // scale first
- float out = linear;
- switch (curve) {
- case Curve::Cie: out = cieLuminance(linear) * 255.0f; break;
- case Curve::Gamma22: out = powf(linear / 255.0f, 2.2f) * 255.0f; break;
- case Curve::Gamma28: out = powf(linear / 255.0f, 2.8f) * 255.0f; break;
- case Curve::Linear: break;
+ // The trim pre-scales like brightness, so the curve still lands last.
+ const uint8_t balance[5] = {balRed, balGreen, balBlue, 255, whiteLevel};
+ for (int ch = 0; ch < 5; ch++) {
+ const float scale = static_cast(brightness) * balance[ch] / 255.0f;
+ for (int v = 0; v < 256; v++) {
+ const float linear = static_cast(v) * scale / 255.0f; // scale first
+ float out = linear;
+ switch (curve) {
+ case Curve::Cie: out = cieLuminance(linear) * 255.0f; break;
+ case Curve::Gamma22: out = powf(linear / 255.0f, 2.2f) * 255.0f; break;
+ case Curve::Gamma28: out = powf(linear / 255.0f, 2.8f) * 255.0f; break;
+ case Curve::Linear: break;
+ }
+ int q = static_cast(out + 0.5f);
+ // A non-zero input never lands on black, or a fade-out snaps off partway down.
+ if (q <= 0 && v > 0 && scale > 0.0f) q = 1;
+ briLut[ch][v] = static_cast(q > 255 ? 255 : q);
}
- int q = static_cast(out + 0.5f);
- // A non-zero input never lands on black, or a fade-out snaps off partway down.
- if (q <= 0 && v > 0 && brightness > 0) q = 1;
- briLut[v] = static_cast(q > 255 ? 255 : q);
}
}
@@ -129,6 +163,53 @@ struct Correction {
outChannels = nChannels;
}
+ /// The white component of a source triple, min(r,g,b); 0 when nothing is synthesized. Shared by measure() and apply(), so the estimate cannot drift from what is emitted.
+ uint8_t whiteOf(uint8_t r, uint8_t g, uint8_t b) const {
+ if (whiteMode == WhiteMode::None) return 0;
+ return r < g ? (r < b ? r : b) : (g < b ? g : b);
+ }
+
+ /// Scale one emitted byte by the frame's current limit.
+ uint8_t lim(uint8_t v) const { return static_cast((v * limit) >> 8); }
+
+ // Prices what apply() EMITS, curve included: the draw follows the die, not the source byte.
+ /// Once per frame before the emit loop: price `n` lights and set `limit`.
+ void measure(const uint8_t* src, uint8_t srcCh, uint32_t n) {
+ limit = 256;
+ if (budgetMa == 0) return;
+
+ uint32_t whiteMa = 0;
+ if (offWhite != kAbsent) whiteMa += mAWhite;
+ if (offWarmWhite != kAbsent) whiteMa += mAWhite;
+ const bool subtractWhite = offWhite != kAbsent && whiteMode == WhiteMode::Accurate;
+ const bool anyWhite = whiteMode != WhiteMode::None;
+ const uint32_t yellowMa = (anyWhite && offYellow != kAbsent) ? mAYellow : 0;
+ const uint32_t uvMa = (anyWhite && offUV != kAbsent) ? mAUV : 0;
+
+ uint64_t sum = 0;
+ for (uint32_t i = 0; i < n; i++, src += srcCh) {
+ uint8_t r = src[0], g = src[1], b = src[2];
+ const uint8_t w = whiteOf(r, g, b);
+ // Off the PRE-subtraction values, as apply() reads them.
+ if (yellowMa) sum += static_cast(briLut[kNeutral][r < g ? r : g]) * yellowMa;
+ if (uvMa) {
+ const uint8_t rg = r > g ? r : g;
+ sum += static_cast(briLut[kNeutral][b > rg ? static_cast(b - rg) : 0]) * uvMa;
+ }
+ if (subtractWhite) {
+ r -= w;
+ g -= w;
+ b -= w;
+ }
+ sum += (static_cast(briLut[0][r]) + briLut[1][g] + briLut[2][b]) * mAColor
+ + static_cast(briLut[kWhite][w]) * whiteMa;
+ }
+ // Rounded UP: a cap that understates is not a cap.
+ const uint64_t scalableMa = (sum + 254) / 255;
+ // The dimmer and motion are not priced: DMX control values draw nothing from this rail.
+ if (scalableMa > budgetMa) limit = static_cast((budgetMa * 256u) / scalableMa);
+ }
+
// A REMAP, not a copy: the layer's packed slots become the fixture's own offsets.
/// 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 {
@@ -148,33 +229,33 @@ struct Correction {
uint8_t r = src[0];
uint8_t g = src[1];
uint8_t b = src[2];
- // One gate for every synthesised emitter; None zeroes them, since the buffer is reused.
+ // One gate for every synthesized emitter; None zeroes them, since the buffer is reused.
if (whiteMode == WhiteMode::None) {
if (offWhite != kAbsent) out[offWhite] = 0;
if (offWarmWhite != kAbsent) out[offWarmWhite] = 0;
if (offYellow != kAbsent) out[offYellow] = 0;
if (offUV != kAbsent) out[offUV] = 0;
} else {
- const uint8_t w = r < g ? (r < b ? r : b) : (g < b ? g : b); // min(r,g,b): the white component
+ const uint8_t w = whiteOf(r, g, b);
// Computed off the PRE-subtraction values, which only rebalance the RGB emitters.
- if (offWarmWhite != kAbsent) out[offWarmWhite] = briLut[w];
+ if (offWarmWhite != kAbsent) out[offWarmWhite] = lim(briLut[kWhite][w]);
// yellow β min(R,G) (the shared red+green component).
- if (offYellow != kAbsent) out[offYellow] = briLut[r < g ? r : g];
+ if (offYellow != kAbsent) out[offYellow] = lim(briLut[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] = briLut[b > rg ? static_cast(b - rg) : 0];
+ out[offUV] = lim(briLut[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] = briLut[w];
+ out[offWhite] = lim(briLut[kWhite][w]);
}
}
// The curve, applied ONCE: everything above this line is linear light.
- if (offRed != kAbsent) out[offRed] = briLut[r];
- if (offGreen != kAbsent) out[offGreen] = briLut[g];
- if (offBlue != kAbsent) out[offBlue] = briLut[b];
+ if (offRed != kAbsent) out[offRed] = lim(briLut[0][r]);
+ if (offGreen != kAbsent) out[offGreen] = lim(briLut[1][g]);
+ if (offBlue != kAbsent) out[offBlue] = lim(briLut[2][b]);
}
};
diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h
index c6a061249..c4f495cd6 100644
--- a/src/light/drivers/DriverBase.h
+++ b/src/light/drivers/DriverBase.h
@@ -77,6 +77,15 @@ class DriverBase : public MoonModule {
static_cast((globalBrightness * localBrightness_) / 255);
correction_.whiteMode = static_cast(whiteMode_);
correction_.curve = static_cast(curveSel_);
+ correction_.balRed = balRed_;
+ correction_.balGreen = balGreen_;
+ correction_.balBlue = balBlue_;
+ correction_.whiteLevel = whiteLevel_;
+ correction_.budgetMa = budgetMa_;
+ correction_.mAColor = mAColor_;
+ correction_.mAWhite = mAWhite_;
+ correction_.mAYellow = mAYellow_;
+ correction_.mAUV = mAUV_;
// A missing id falls back to the default, so a driver degrades rather than crashing.
if (auto* lib = LightPresetsModule::active()) {
if (presetId_ == 0) presetId_ = lib->defaultId();
@@ -160,6 +169,19 @@ class DriverBase : public MoonModule {
uint8_t presetSel_ = 0; // the preset Select's chosen INDEX (mapped to an id in onControlChanged)
uint8_t whiteMode_ = static_cast(WhiteMode::Min); // index into kWhiteModeOptions
uint8_t localBrightness_ = 255; // per-driver dim, multiplied with the global brightness
+ /// Per-channel white balance; trims the stronger dies DOWN to match the weakest.
+ uint8_t balRed_ = 255, balGreen_ = 255, balBlue_ = 255;
+ /// The white die's own trim; the RGB trims do not reach it.
+ uint8_t whiteLevel_ = 255;
+ /// The current budget and the per-channel draw it is priced with.
+ uint16_t budgetMa_ = 0; // 0 = no current limiting
+ uint8_t mAColor_ = 8; // measured on SK6812 RGBW: R 7.98, G 8.11, B 7.98
+ uint8_t mAWhite_ = 16; // measured: W 16.11
+ uint8_t mAYellow_ = 8; // assumed: an amber die sits near red
+ uint8_t mAUV_ = 8; // assumed: a UV die usually draws more
+ /// Whether this driver calls Correction::measure() before its emit loop.
+ virtual bool limitsCurrent() const { return false; }
+
/// Which perceptual curve the output LUT is built through; CIE lightness by default.
uint8_t curveSel_ = 0; // index into kCurveOptions; 0 = CIE
static constexpr uint8_t kCurveCount = 4;
@@ -184,6 +206,29 @@ class DriverBase : public MoonModule {
// Hidden unless the referenced preset carries a channel there is something to synthesise for.
auto* lib = LightPresetsModule::active();
controls_.setHidden(controls_.count() - 1, !(lib && lib->presetHasSynthChannel(presetId_)));
+ controls_.addControl("balanceRed", balRed_, 0, 255);
+ controls_.addControl("balanceGreen", balGreen_, 0, 255);
+ controls_.addControl("balanceBlue", balBlue_, 0, 255);
+ // Narrower than whiteMode's gate, which also counts amber and UV: this trims the white dies alone, so on a fixture with only those the slider would reach nothing.
+ const bool hasWhite = lib && (lib->presetHasRole(presetId_, ChannelRole::White) ||
+ lib->presetHasRole(presetId_, ChannelRole::WarmWhite));
+ controls_.addControl("whiteLevel", whiteLevel_, 0, 255);
+ controls_.setHidden(controls_.count() - 1, !hasWhite);
+ // Only offered by the drivers that measure: a network sender feeds another board's supply.
+ const bool limits = limitsCurrent();
+ controls_.addControl("maxCurrentMa", budgetMa_, 0, 60000);
+ controls_.setHidden(controls_.count() - 1, !limits);
+ controls_.addControl("mAPerColorChannel", mAColor_, 1, 60);
+ controls_.setHidden(controls_.count() - 1, !limits);
+ controls_.addControl("mAPerWhiteChannel", mAWhite_, 1, 60);
+ controls_.setHidden(controls_.count() - 1, !limits);
+ // Only where the fixture carries them.
+ const bool wide = lib && (lib->presetHasRole(presetId_, ChannelRole::Yellow) ||
+ lib->presetHasRole(presetId_, ChannelRole::UV));
+ controls_.addControl("mAPerYellowChannel", mAYellow_, 1, 60);
+ controls_.setHidden(controls_.count() - 1, !(limits && wide));
+ controls_.addControl("mAPerUvChannel", mAUV_, 1, 60);
+ controls_.setHidden(controls_.count() - 1, !(limits && wide));
// Persisted but not shown: the selector above is what the user sees.
controls_.addText("presetRef", presetRef_, sizeof(presetRef_));
controls_.setHidden(controls_.count() - 1, true);
@@ -194,8 +239,13 @@ 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, "lightPreset") == 0 || std::strcmp(name, "localBrightness") == 0
- || std::strcmp(name, "whiteMode") == 0 || std::strcmp(name, "curve") == 0;
+ return std::strcmp(name, "lightPreset") == 0 || std::strcmp(name, "localBrightness") == 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 ||
+ std::strcmp(name, "maxCurrentMa") == 0 || std::strcmp(name, "mAPerColorChannel") == 0 ||
+ std::strcmp(name, "mAPerWhiteChannel") == 0 || std::strcmp(name, "mAPerYellowChannel") == 0 ||
+ std::strcmp(name, "mAPerUvChannel") == 0;
}
private:
diff --git a/src/light/drivers/LightPresetsModule.h b/src/light/drivers/LightPresetsModule.h
index 9afdde4ca..9743aec51 100644
--- a/src/light/drivers/LightPresetsModule.h
+++ b/src/light/drivers/LightPresetsModule.h
@@ -65,6 +65,16 @@ class LightPresetsModule : public MoonModule, public ListSource {
return 0; // a dangling id renders at the first preset; rebuildCorrection falls back to it too
}
+ /// Whether the preset carries `role`, for a caller that needs one emitter rather than any.
+ bool presetHasRole(uint32_t id, ChannelRole role) const {
+ const Preset* p = find(id);
+ if (!p) return false;
+ const uint8_t* r = roleAt(*p);
+ for (uint8_t c = 0; c < p->channelCount; c++)
+ if (static_cast(r[c]) == role) return true;
+ return false;
+ }
+
// Any channel apply() synthesises from RGB counts, since one control governs them all.
/// Whether the preset carries a channel the white mode would synthesise.
bool presetHasSynthChannel(uint32_t id) const {
diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h
index aaf6b4302..ae444f7b5 100644
--- a/src/light/drivers/ParallelLedDriver.h
+++ b/src/light/drivers/ParallelLedDriver.h
@@ -32,6 +32,9 @@ namespace mm {
/// The source holds each light's bytes together; the wire needs each bus WORD to carry one bit of EVERY strand at once. The encoder turns 8 lights on their side, an 8x8 bit matrix transpose, writing one word per slot fused with the correction. A Parlio bus word and an i80 bus word have the same meaning.
class ParallelLedDriver : public DriverBase {
public:
+ /// This driver prices its own frame, so the shared path leaves the limiter to it.
+ bool limitsCurrent() const override { return true; }
+
/// Test-only: borrow a mock backend, dropping any existing one. The caller keeps ownership.
void setPeripheralForTest(LedPeripheral* p) {
if (peripheral_ && peripheralOwned_) { peripheral_->busDeinit(); delete peripheral_; }
@@ -288,6 +291,16 @@ class ParallelLedDriver : public DriverBase {
else tickSync(outCh); // synchronous (doubleBuffer OFF / no 2nd buf)
}
+ /// Price the frame against the budget before encodeRows forks across both cores, so both halves read a `limit` that is already settled.
+ void measureFrame() {
+ if (!sourceBuffer_ || !sourceBuffer_->data()) return;
+ const uint8_t* src = encodeSrc_ ? encodeSrc_ : sourceBuffer_->data();
+ const uint8_t srcCh = sourceBuffer_->channelsPerLight();
+ nrOfLightsType lights = 0;
+ for (uint8_t lane = 0; lane < laneCount_; lane++) lights += laneCounts_[lane];
+ correction_.measure(src + static_cast(winStart_) * srcCh, srcCh, lights);
+ }
+
// One DMA buffer, no alternation, no deferred-wait bookkeeping, zero added latency.
/// Blocking path: encode, send, wait out the wire, so a tick costs encode plus wire.
void tickSync(uint8_t outCh) {
@@ -296,6 +309,7 @@ class ParallelLedDriver : public DriverBase {
if (!busWaitIfBusy(0)) return;
uint8_t* buf = peripheral_->busBuffer(0);
if (!buf) return;
+ measureFrame();
// Branch on the BUS WIDTH, not the strand count: 48 strands on 6 pins is still 8-bit.
if (slotBytes() == 1) encodeRows(outCh, buf);
else encodeRows(outCh, buf);
@@ -317,6 +331,7 @@ class ParallelLedDriver : public DriverBase {
// 2. Fused per-ROW encode into buffer `active_`, one branch on the bus width (see encodeRows).
uint8_t* buf = peripheral_->busBuffer(active_);
if (!buf) return;
+ measureFrame();
// Branch on the BUS WIDTH, not the strand count: 48 strands on 6 pins is still 8-bit.
if (slotBytes() == 1) encodeRows(outCh, buf);
else encodeRows(outCh, buf);
@@ -342,6 +357,7 @@ class ParallelLedDriver : public DriverBase {
if (ringSnapshot) { if (!snapshotSourceForRing()) return; }
else encodeSrc_ = nullptr; // OFF: encodeRows reads the live sourceBuffer_
const uint32_t tkW2 = platform::cycleCount();
+ measureFrame();
if (peripheral_->busTransmitRing()) {
inFlight_[0] = true; // kicked; DO NOT wait here: the next tick waits, freeing the core now
} else if (deadFrames_ < kDeadFramesBeforeGiveUp) {
diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h
index e9eb4a9b9..82e2826e9 100644
--- a/src/light/drivers/RmtLedDriver.h
+++ b/src/light/drivers/RmtLedDriver.h
@@ -29,6 +29,9 @@ namespace mm {
/// @card RmtLedDriver.png
class RmtLedDriver : public DriverBase {
public:
+ /// This driver prices its own frame, so the shared path leaves the limiter to it.
+ bool limitsCurrent() const override { return true; }
+
/// Default to the GRB preset, which is how WS2812 and SK6812 strips are physically wired.
RmtLedDriver() { setDefaultPresetName("GRB"); }
@@ -200,6 +203,8 @@ class RmtLedDriver : public DriverBase {
// One pass: correct a light into wire bytes and encode them straight into the buffer.
const uint8_t* src = sourceBuffer_->data();
const uint8_t srcCh = sourceBuffer_->channelsPerLight();
+ // Price the window first, so the whole frame scales together.
+ correction_.measure(src + winStart_ * srcCh, srcCh, n);
// The bit expansion happens on the way out, so this buffer is outCh bytes per light.
for (nrOfLightsType i = 0; i < n; i++) {
correction_.apply(src + (winStart_ + i) * srcCh, frame_ + static_cast(i) * outCh, srcCh);
diff --git a/src/light/effects/AmbilightEffect.h b/src/light/effects/AmbilightEffect.h
new file mode 100644
index 000000000..4d6d83b75
--- /dev/null
+++ b/src/light/effects/AmbilightEffect.h
@@ -0,0 +1,383 @@
+#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 Ambilight / Hyperion behavior. Two spaces are in play, and every name below says which one it is in.
+///
+/// | Space | Counted in | Extent |
+/// |---|---|---|
+/// | SOURCE | pixels | frame.width x frame.height |
+/// | DESTINATION | light positions | lightsX x lightsY |
+///
+/// The source is far the bigger, a 640x480 picture onto a strip of 60 positions, so each light position owns a whole rectangle of pixels and shows their average.
+///
+/// ## The layout decides the shape
+///
+/// On a RectangleLayout the interior maps to no LED, so a border strip shows the frame's border for free, and on a GridLayout the same effect is a video wall.
+/// The effect asks the mapping only ONE question, whether this position lights anything, and skips the averaging where the answer is no.
+/// On a border layout that is most of the box.
+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.
+ 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/light/layouts/RectangleLayout.h b/src/light/layouts/RectangleLayout.h
new file mode 100644
index 000000000..47ef82655
--- /dev/null
+++ b/src/light/layouts/RectangleLayout.h
@@ -0,0 +1,129 @@
+#pragma once
+
+#include "light/layouts/LayoutBase.h"
+
+namespace mm {
+
+/// A hollow rectangle: lights around the PERIMETER of a `width` x `height` box, nothing inside it.
+///
+/// @moreinfo
+///
+/// The strip-around-a-frame primitive: a TV backlight, a mirror surround, a sign border.
+///
+/// - Each corner counts once by default, so the count is `2(width + height) - 4`. A strip bent around a frame has ONE LED in the corner, though that corner belongs to two edges
+/// - Four separate strips instead have their own end there, so `sharedCorners` off gives `2(width+height)`, with two lights on each corner coordinate
+/// - `offset` slides the wiring around the perimeter, for a strip that starts partway along an edge rather than at a corner
+/// - `startCorner` and `clockwise` change the WIRING rather than the shape, rotating and reversing the index order while every emitted coordinate stays identical
+/// - Perimeter only, since a filled rectangle is already GridLayout, and this exists for the case where the interior has no LEDs in it at all
+class RectangleLayout : public LayoutBase {
+public:
+ /// Extent in LIGHTS along the horizontal edges; 32x18 is 16:9.
+ uint16_t width = 32;
+ /// Extent in LIGHTS along the vertical edges.
+ uint16_t height = 18;
+ /// Which corner the wiring starts at, as an index into kStartCornerOptions.
+ uint8_t startCorner = 0;
+ /// Which way the index order runs around the perimeter.
+ bool clockwise = true;
+ /// One light per corner; off means four strips, each with its own end.
+ bool sharedCorners = true;
+ /// Lights past startCorner where the strip actually begins.
+ uint16_t offset = 0;
+
+ /// Dropdown labels for `startCorner`, in index order.
+ static constexpr const char* kStartCornerOptions[] = {"top-left", "top-right", "bottom-right",
+ "bottom-left"};
+ /// How many corners are on offer, which is four.
+ static constexpr uint8_t kStartCornerCount = sizeof(kStartCornerOptions) / sizeof(kStartCornerOptions[0]);
+
+ /// The box's extent, plus the four controls that set how the strip is wired around it.
+ void defineControls() override {
+ controls_.addControl("width", width, 1, 500);
+ controls_.addControl("height", height, 1, 500);
+ controls_.addSelect("startCorner", startCorner, kStartCornerOptions, kStartCornerCount);
+ controls_.addControl("clockwise", clockwise);
+ // Off when the corners are four separate strip ends rather than one bent light.
+ controls_.addControl("sharedCorners", sharedCorners);
+ // For a strip that starts partway along an edge instead of at the corner.
+ controls_.addControl("offset", offset, 0, 1999);
+ }
+
+ /// Lights on the perimeter, which is what this layout allocates for.
+ nrOfLightsType lightCount() const override { return perimeter(); }
+
+ /// Walk the perimeter in wiring order, handing each index its coordinate.
+ void placeLights(const CoordSink& sink) const override {
+ const nrOfLightsType n = perimeter();
+ for (nrOfLightsType i = 0; i < n; i++) {
+ const Coord3D c = coordAt(i, n);
+ sink.pixel(i, c.x, c.y, c.z);
+ }
+ }
+
+private:
+ // A box one light thick has no interior to go around, so it degenerates to a line, and the rectangle formula would walk those cells twice and light phantom positions.
+ /// Perimeter cell count, where the -4 is the four corners, each belonging to two edges.
+ nrOfLightsType perimeter() const {
+ if (width == 0 || height == 0) return 0;
+ if (height == 1) return width;
+ if (width == 1) return height;
+ // Unshared corners give each edge its full length, so the four edges just sum.
+ return static_cast(sharedCorners ? 2 * width + 2 * height - 4
+ : 2 * width + 2 * height);
+ }
+
+ // Unshared, each edge keeps its own corner. The right edge starts AT the top-right rather than below it, so two lights land on each corner coordinate: four strip ends meeting there.
+ /// Coordinate of physical light `i` of `n`, in four segments, each dropping the corner the previous one emitted.
+ ///
+ /// top left to right w cells
+ /// right top to bottom h-1 cells (h with unshared corners)
+ /// bottom right to left w-1 cells (w "")
+ /// left bottom to top h-2 cells (h "")
+ Coord3D coordAt(nrOfLightsType i, nrOfLightsType n) const {
+
+ const int w = width, h = height, k = static_cast(walkIndex(i, n));
+ const int drop = sharedCorners ? 1 : 0; // cells each edge gives up to the one before it
+
+ const auto at = [](int x, int y) {
+ return Coord3D{static_cast(x), static_cast(y), 0};
+ };
+
+ if (h == 1) return at(k, 0);
+ if (w == 1) return at(0, k);
+
+ const int topEnd = w; // steps [0, topEnd) top edge
+ const int rightEnd = topEnd + h - drop; // [topEnd, rightEnd) right edge
+ const int bottomEnd = rightEnd + w - drop; // [rightEnd, bottomEnd) bottom edge
+
+ if (k < topEnd) return at(k, 0); // x rises, y = 0
+ if (k < rightEnd) return at(w - 1, k - topEnd + drop); // y rises, x = w-1
+ if (k < bottomEnd) return at(w - 1 - drop - (k - rightEnd), h - 1); // x falls, y = h-1
+ return at(0, h - 1 - (k - bottomEnd) - drop); // y falls, x = 0
+ }
+
+ /// Step at which each start corner sits on the reference walk: its segment boundaries, so a corner resolves to an exact index rather than a search.
+ nrOfLightsType startIndex() const {
+ const int w = width, h = height;
+ if (h == 1) return static_cast((startCorner == 1 || startCorner == 2) ? w - 1 : 0);
+ if (w == 1) return static_cast(startCorner >= 2 ? h - 1 : 0);
+ const int drop = sharedCorners ? 1 : 0;
+ const int rightEnd = w + h - drop;
+ const int bottomEnd = rightEnd + w - drop;
+ switch (startCorner) {
+ case 1: return static_cast(w - 1); // top-right
+ case 2: return static_cast(rightEnd - 1); // bottom-right
+ case 3: return static_cast(bottomEnd - 1); // bottom-left
+ default: return 0; // top-left
+ }
+ }
+
+ /// Indexing lights starts from top-left, then walks the perimeter clockwise. `startCorner`, `offset` and `clockwise` move where index 0 sits and which way it runs; this maps a driver index back onto the canonical walk.
+ nrOfLightsType walkIndex(nrOfLightsType i, nrOfLightsType n) const {
+ if (n == 0) return 0;
+ const nrOfLightsType s = static_cast((startIndex() + offset) % n);
+ return clockwise ? static_cast((s + i) % n)
+ : static_cast((s + n - (i % n)) % n);
+ }
+};
+
+} // namespace mm
diff --git a/src/module_types.cpp b/src/module_types.cpp
index e6258a6bf..74bb3e5cc 100644
--- a/src/module_types.cpp
+++ b/src/module_types.cpp
@@ -55,6 +55,7 @@
#include "light/layouts/RingLayout.h"
#include "light/layouts/Rings241Layout.h"
#include "light/layouts/SpiralLayout.h"
+#include "light/layouts/RectangleLayout.h"
#include "light/layouts/PanelsLayout.h"
#include "light/layouts/HumanSizedCubeLayout.h"
#include "light/layouts/TorontoBarGourdsLayout.h"
@@ -84,6 +85,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"
@@ -175,6 +177,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/I2cScanModule.h"
#include "core/system/TasksModule.h"
@@ -219,6 +222,7 @@ void mm::registerModuleTypes() {
mm::ModuleFactory::registerType("GridLayout", "light/layouts.md#grid");
mm::ModuleFactory::registerType("GridBlacksLayout", "light/layouts.md#gridblacks");
mm::ModuleFactory::registerType("PanelLayout", "light/layouts.md#panel");
+ mm::ModuleFactory::registerType("RectangleLayout", "light/layouts.md#rectangle");
mm::ModuleFactory::registerType("RingLayout", "light/layouts.md#ring");
mm::ModuleFactory::registerType("Rings241Layout", "light/layouts.md#rings241");
mm::ModuleFactory::registerType("SingleColumnLayout", "light/layouts.md#singlecolumn");
@@ -228,6 +232,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");
@@ -344,6 +349,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("I2cScanModule", "core/system.md#i2c-scan");
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 685835041..011fd948b 100644
--- a/src/platform/desktop/platform_config.h
+++ b/src/platform/desktop/platform_config.h
@@ -58,6 +58,9 @@ constexpr uint8_t i2sLanes = 0;
/// No pin-wired microphone on a host; live audio arrives through capture devices instead.
constexpr bool hasI2sMic = false;
+// No USB host on desktop; VideoService keeps its test-pattern and file sources.
+constexpr bool hasUsbVideo = false;
+
/// OS capture devices, reaching the same audioMicRead seam a wired microphone would.
constexpr bool hasAudioCapture = true;
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 063a5472c..ff35b065b 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -2316,6 +2316,20 @@ RmtLoopbackResult parlioWs2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*l
// The codec and capture live in their own file: the codec succeeds with nothing to bring up, and the microphone reads the system capture device.
+// 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 {}; }
+
// The textbook in-place radix-2 transform, the production kernel now that live capture runs blocks dozens of times a second on the render tick. The contract is unchanged and it is numerically equivalent to the direct form, pinned against one by a test.
void audioFft(const float* windowed, size_t n, float* outMag) {
if (!windowed || !outMag || n == 0 || (n & (n - 1)) != 0) return;
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 c57b43f45..020159e19 100644
--- a/src/platform/esp32/platform_config.h
+++ b/src/platform/esp32/platform_config.h
@@ -143,7 +143,15 @@ constexpr bool hasI2sMic = true;
constexpr bool hasI2sMic = false;
#endif
-/// False: capture devices are a host concept, and a board uses the wired microphone above.
+// 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)
+constexpr bool hasUsbVideo = true;
+#else
+constexpr bool hasUsbVideo = false;
+#endif
+
+// OS capture devices are a desktop concept; boards use the pin-wired I2S mic above.
constexpr bool hasAudioCapture = false;
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 3d7c09618..d085e7d2d 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -520,7 +520,35 @@ static uint8_t staStaticMask_[4] = {};
static uint8_t staStaticDns_[4] = {};
static esp_netif_t* staNetif_ = nullptr;
static esp_netif_t* apNetif_ = nullptr;
-static bool wifiInitDone_ = false;
+static std::atomic wifiInitDone_{false}; // atomic: the radio poller reads it from its own task
+
+// Radio telemetry, read on the render tick and refreshed off it. With a co-processor radio every esp_wifi query is a blocking RPC, 55-90 ms measured, and the state push made one a second. A task polls instead.
+static std::atomic radioRssi_{0};
+static std::atomic radioTxPowerQ_{0}; // quarter dBm, the unit the stack uses
+static std::atomic radioAp_{0}; // BSSID << 8 | channel, one word so a reader never sees half a connect
+constexpr uint32_t kRadioPollMs = 2000; // a signal-strength readout, not an instrument
+
+static void radioPollTask(void*) {
+ for (;;) {
+ vTaskDelay(pdMS_TO_TICKS(kRadioPollMs));
+ if (!wifiInitDone_.load(std::memory_order_relaxed)) continue;
+ if (wifiStaConnected_.load(std::memory_order_relaxed)) {
+ int rssi = 0;
+ if (esp_wifi_sta_get_rssi(&rssi) == ESP_OK) radioRssi_.store(static_cast(rssi), std::memory_order_relaxed);
+ }
+ int8_t power = 0;
+ if (esp_wifi_get_max_tx_power(&power) == ESP_OK) radioTxPowerQ_.store(power, std::memory_order_relaxed);
+ }
+}
+
+// Spawned once, on the first WiFi init, and kept across a deinit: wifiInitDone_ gates the asking.
+static void ensureRadioPoller() {
+ static bool started = false;
+ if (started) return;
+ // 4 KB: the hosted RPC path serializes through protobuf below this call.
+ started = xTaskCreate(&radioPollTask, "mmradio", 4096, nullptr, tskIDLE_PRIORITY + 1, nullptr) == pdPASS;
+ if (!started) ESP_LOGW(NET_TAG, "no radio poller task: RSSI and TX power will read 0");
+}
#endif
static void ensureNetifInit() {
@@ -1060,12 +1088,20 @@ static void wifiEventHandler(void* /*arg*/, esp_event_base_t base,
if (id == WIFI_EVENT_STA_CONNECTED) {
// L2 association complete (before DHCP). In Static mode, pin the stored config now and mark connected, a DHCP-less network never fires GOT_IP, so waiting for it would strand a static STA. Mirrors the eth CONNECTED handler's ethStatic_ re-pin. DHCP mode is a no-op here (the DHCP client runs and GOT_IP sets wifiStaConnected_ as before).
wifiStaAssociated_.store(true, std::memory_order_relaxed);
+ // The event carries the AP's identity, so nobody ever has to ask the radio for it.
+ if (const auto* ev = static_cast(data)) {
+ uint64_t ap = 0;
+ for (int i = 0; i < 6; i++) ap = (ap << 8) | ev->bssid[i];
+ radioAp_.store((ap << 8) | ev->channel, std::memory_order_relaxed);
+ }
if (staStatic_.load(std::memory_order_acquire)) {
netSetStaticIPv4(NetIface::Sta, staStaticIp_, staStaticGw_, staStaticMask_, staStaticDns_);
}
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
wifiStaConnected_.store(false, std::memory_order_relaxed);
wifiStaAssociated_.store(false, std::memory_order_relaxed);
+ radioAp_.store(0, std::memory_order_relaxed);
+ radioRssi_.store(0, std::memory_order_relaxed);
// The reconnect is ours to make, and unbounded: @xref{the-reconnect-is-ours-to-make-and-unbounded|why}.
if (!wifiStaStopping_.load(std::memory_order_relaxed)) {
// Immediately, and without sleeping to pace it: the pacing is free and blocking here would stall the whole stack. The counter is diagnostic and does not gate the retry.
@@ -1139,6 +1175,7 @@ static bool ensureWifiInit() {
}
wifiInitDone_ = true;
+ ensureRadioPoller();
return true;
}
@@ -1224,25 +1261,19 @@ void wifiStaStop() {
ESP_LOGI(NET_TAG, "WiFi STA stopped + deinit");
}
+// Cached, see radioPollTask: none of the readouts below asks the radio.
int wifiStaRssi() {
if (!wifiStaConnected_.load(std::memory_order_relaxed)) return 0;
- wifi_ap_record_t info{};
- if (esp_wifi_sta_get_ap_info(&info) != ESP_OK) return 0;
- return info.rssi;
+ return radioRssi_.load(std::memory_order_relaxed);
}
void wifiStaBssid(uint8_t out[6]) {
- std::memset(out, 0, 6);
- if (!wifiStaConnected_.load(std::memory_order_relaxed)) return;
- wifi_ap_record_t info{};
- if (esp_wifi_sta_get_ap_info(&info) == ESP_OK) std::memcpy(out, info.bssid, 6);
+ const uint64_t ap = radioAp_.load(std::memory_order_relaxed) >> 8;
+ for (int i = 0; i < 6; i++) out[i] = static_cast(ap >> (8 * (5 - i)));
}
int wifiStaChannel() {
- if (!wifiStaConnected_.load(std::memory_order_relaxed)) return 0;
- wifi_ap_record_t info{};
- if (esp_wifi_sta_get_ap_info(&info) != ESP_OK) return 0;
- return info.primary;
+ return static_cast(radioAp_.load(std::memory_order_relaxed) & 0xFF);
}
bool wifiApInit(const char* apName, const char* ip) {
@@ -1321,10 +1352,8 @@ void wifiApStop() {
int wifiTxPower() {
if (!wifiInitDone_) return 0;
- int8_t power = 0;
- if (esp_wifi_get_max_tx_power(&power) != ESP_OK) return 0;
- // ESP-IDF returns TX power in units of 0.25 dBm; round to nearest whole dBm.
- return (power + 2) / 4;
+ // ESP-IDF reports TX power in units of 0.25 dBm; round to nearest whole dBm.
+ return (radioTxPowerQ_.load(std::memory_order_relaxed) + 2) / 4;
}
bool wifiSetTxPower(int8_t quarterDbm) {
@@ -1338,6 +1367,7 @@ bool wifiSetTxPower(int8_t quarterDbm) {
ESP_LOGW(NET_TAG, "WiFi set TX power %d (q-dBm) failed: %s", quarterDbm, esp_err_to_name(err));
return false;
}
+ radioTxPowerQ_.store(quarterDbm, std::memory_order_relaxed); // so the readout follows the set at once
ESP_LOGI(NET_TAG, "WiFi TX power capped to %d (q-dBm) β %d dBm", quarterDbm, (quarterDbm + 2) / 4);
return true;
}
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 5bbaad00e..f51d10978 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -489,7 +489,8 @@ void wifiStaGetIPv4(uint8_t out[4]);
/// Tear the station down.
void wifiStaStop();
-/// Station RSSI in dBm, a negative number; 0 when the station is not associated.
+// Refreshed off the render task every few seconds, since on a co-processor radio the query is a blocking RPC, and every reading below shares that contract.
+/// Station RSSI in dBm, a negative number, and 0 when the station is not associated.
int wifiStaRssi();
/// The associated access point's BSSID, zeroed when the station is not associated.
@@ -521,7 +522,7 @@ uint32_t wifiApClientCount();
/// True when a socket is safe to open: the stack is initialized and an interface holds an IP.
bool networkReady();
-/// Current WiFi transmit power in dBm, 0 when WiFi is not initialized.
+/// Current WiFi transmit power in dBm, 0 when WiFi is not initialized. Cached like wifiStaRssi.
int wifiTxPower();
/// Cap the WiFi transmit power in quarter-dBm units, 8 to 84; 0 keeps the stack default.
@@ -1034,6 +1035,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();
+
// I2C bus diagnostics: the standard i2cdetect operation, domain-neutral rather than audio-specific.
/// The bus could not be opened, which is distinct from a scan that found nothing.
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 385e1fae7..bfb9f1cce 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -19,6 +19,7 @@ add_executable(mm_tests
unit/core/unit_fields.cpp
unit/core/unit_crc.cpp
unit/core/unit_AudioService_sync.cpp
+ unit/core/unit_VideoService.cpp
unit/core/unit_ButtonService.cpp
unit/core/unit_MoonLiveService.cpp
unit/core/unit_InfraredService.cpp
@@ -103,6 +104,7 @@ add_executable(mm_tests
unit/light/unit_ColorTrailsEffect.cpp
unit/light/unit_fluid.cpp
unit/light/unit_draw.cpp
+ unit/light/unit_AmbilightEffect.cpp
unit/light/unit_GameOfLifeEffect.cpp
unit/light/unit_DemoReelEffect.cpp
unit/light/unit_TextEffect.cpp
@@ -140,6 +142,7 @@ add_executable(mm_tests
unit/light/unit_WaveEffect.cpp
unit/light/unit_FireEffect.cpp
unit/light/unit_GridLayout.cpp
+ unit/light/unit_RectangleLayout.cpp
unit/light/unit_Rings241Layout.cpp
unit/light/unit_SingleColumnLayout.cpp
unit/light/unit_SphereLayout.cpp
diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp
index 5d5745903..cb944a7c4 100644
--- a/test/scenario_runner.cpp
+++ b/test/scenario_runner.cpp
@@ -173,6 +173,7 @@
#include "core/util/JsonSink.h"
#include "light/layouts/GridLayout.h"
#include "light/layouts/GridBlacksLayout.h"
+#include "light/layouts/RectangleLayout.h"
#include "light/layouts/Layouts.h"
#include "light/layers/Layer.h"
#include "light/layers/Effects.h"
@@ -463,6 +464,11 @@ struct ScenarioContext {
if (props.has("width")) grid->width = static_cast(props["width"].num);
if (props.has("height")) grid->height = static_cast(props["height"].num);
if (props.has("depth")) grid->depth = static_cast(props["depth"].num);
+ } else if (std::strcmp(type, "RectangleLayout") == 0) {
+ // The perimeter is computed from these, so they apply at construct time too, and the wiring controls stay on set_control: @xref{why-fixtures-wire-props-at-construct-time}.
+ auto* rect = static_cast(mod);
+ if (props.has("width")) rect->width = static_cast(props["width"].num);
+ if (props.has("height")) rect->height = static_cast(props["height"].num);
}
}
diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json
index 576032e60..b0da27ae8 100644
--- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json
+++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json
@@ -103,10 +103,10 @@
"p95": 19,
"min": 5,
"max": 21,
- "n": 26,
- "samples": [5, 5, 5, 5, 13, 8, 14, 17, 8, 13, 19, 6, 14, 5, 11, 12, 8, 6, 8, 7, 18, 21, 6, 9, 6, 13]
+ "n": 27,
+ "samples": [5, 5, 5, 5, 13, 8, 14, 17, 8, 13, 19, 6, 14, 5, 11, 12, 8, 6, 8, 7, 18, 21, 6, 9, 6, 13, 7]
},
- "last_updated": "2026-09-25"
+ "last_updated": "2026-09-27"
}
}
},
diff --git a/test/scenarios/light/scenario_Video_mutation.json b/test/scenarios/light/scenario_Video_mutation.json
new file mode 100644
index 000000000..f1db881b8
--- /dev/null
+++ b/test/scenarios/light/scenario_Video_mutation.json
@@ -0,0 +1,325 @@
+{
+ "name": "scenario_Video_mutation",
+ "module": "VideoService",
+ "mode": "mutate",
+ "also": [
+ "SystemModule",
+ "Services",
+ "Layouts",
+ "RectangleLayout",
+ "Effects",
+ "Layer",
+ "RainbowEffect",
+ "AmbilightEffect",
+ "Drivers",
+ "PreviewDriver"
+ ],
+ "description": "Add / configure / remove the VideoService and its AmbilightEffect consumer while the render pipeline runs, the video half of what scenario_Audio_mutation pins for audio. The effect reads the service through the static VideoService::latestFrame() accessor rather than a boot-time pointer, so add and remove may happen in any order at runtime. Each step asserts the pipeline keeps RENDERING: adding the service, writing its controls one at a time (source is an affectsPrepare control, so that write drives a full prepareTree mid-render), adding and configuring the consumer, and REMOVING the service while the consumer is still live. That last step is what the 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; 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-rect",
+ "description": "The border the ambilight actually drives: 16:9, a 296-light perimeter, about the 300-LED strip this feature was built for. The Layer still allocates the whole 96x54 bounding box, so this also exercises the sparse-list path that keeps the effect off the 5184 unlit positions.",
+ "op": "add_module",
+ "id": "Rect",
+ "type": "RectangleLayout",
+ "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": 20,
+ "p95": 20,
+ "min": 20,
+ "max": 20,
+ "n": 1,
+ "samples": [20]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ },
+ {
+ "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": 20,
+ "p95": 20,
+ "min": 20,
+ "max": 20,
+ "n": 1,
+ "samples": [20]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ },
+ {
+ "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": "configure-stale",
+ "description": "Set the frame-gap tolerance. A plain (non-prepare) control write completing the add-then-configure flow; the prepareTree before it must not have disturbed the running pipeline.",
+ "op": "set_control",
+ "id": "Video",
+ "key": "staleMs",
+ "value": 2000
+ },
+ {
+ "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": 20,
+ "p95": 20,
+ "min": 20,
+ "max": 20,
+ "n": 1,
+ "samples": [20]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ },
+ {
+ "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": 25,
+ "p95": 25,
+ "min": 25,
+ "max": 25,
+ "n": 1,
+ "samples": [25]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ },
+ {
+ "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": 30,
+ "p95": 30,
+ "min": 30,
+ "max": 30,
+ "n": 1,
+ "samples": [30]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ },
+ {
+ "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": 28,
+ "p95": 28,
+ "min": 28,
+ "max": 28,
+ "n": 1,
+ "samples": [28]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ },
+ {
+ "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": 24,
+ "p95": 24,
+ "min": 24,
+ "max": 24,
+ "n": 1,
+ "samples": [24]
+ },
+ "last_updated": "2026-09-27"
+ }
+ }
+ }
+ ]
+}
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/core/unit_moonlive_ir.cpp b/test/unit/core/unit_moonlive_ir.cpp
index 3feb38845..fc2a70ec1 100644
--- a/test/unit/core/unit_moonlive_ir.cpp
+++ b/test/unit/core/unit_moonlive_ir.cpp
@@ -139,7 +139,7 @@ TEST_CASE("MoonLive control: a declared control reads the arena live (no recompi
arena[0] = 5; std::fill(buf.begin(), buf.end(), 0); fn(buf.data(), 16, 3, 0, arena);
CHECK(firstLit(buf) == 5); // control value selects the pixel
arena[0] = 9; std::fill(buf.begin(), buf.end(), 0); fn(buf.data(), 16, 3, 0, arena);
- CHECK(firstLit(buf) == 9); // changed the arena slot only β NO recompile
+ CHECK(firstLit(buf) == 9); // changed the arena slot only, NO recompile
arena[0] = 0; std::fill(buf.begin(), buf.end(), 0); fn(buf.data(), 16, 3, 0, arena);
CHECK(firstLit(buf) == 0);
platform::freeExec(blk, r.len);
diff --git a/test/unit/light/correction_presets.h b/test/unit/light/correction_presets.h
index 71aade490..1deddd0c2 100644
--- a/test/unit/light/correction_presets.h
+++ b/test/unit/light/correction_presets.h
@@ -18,11 +18,9 @@ namespace mm::test {
/// The curated wire orders a test drives, mapped to role arrays in one place below.
enum class PresetOrder : uint8_t { RGB, GRB, BGR, RGBW, GRBW };
-// Fill a Correction from a named order at `brightness`, via the production role-array rebuild.
-inline void rebuildFromPreset(mm::Correction& c, uint8_t brightness, PresetOrder order) {
+// Fill a Correction from a named order at `brightness`, leaving `curve` as the caller set it.
+inline void rebuildFromPresetKeepingCurve(mm::Correction& c, uint8_t brightness, PresetOrder order) {
using R = mm::ChannelRole;
- // Linear: the ordering and white-maths tests are not about the curve, which has its own.
- c.curve = mm::Correction::Curve::Linear;
switch (order) {
case PresetOrder::RGB: { R r[] = {R::Red, R::Green, R::Blue}; c.rebuild(brightness, r, 3); break; }
case PresetOrder::GRB: { R r[] = {R::Green, R::Red, R::Blue}; c.rebuild(brightness, r, 3); break; }
@@ -32,5 +30,12 @@ inline void rebuildFromPreset(mm::Correction& c, uint8_t brightness, PresetOrder
}
}
+// Fill a Correction from a named order at `brightness`, via the production role-array rebuild.
+inline void rebuildFromPreset(mm::Correction& c, uint8_t brightness, PresetOrder order) {
+ // Linear: the ordering and white-maths tests are not about the curve, which has its own.
+ c.curve = mm::Correction::Curve::Linear;
+ rebuildFromPresetKeepingCurve(c, brightness, order);
+}
+
/// @}
} // 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..501aa0afc
--- /dev/null
+++ b/test/unit/light/unit_AmbilightEffect.cpp
@@ -0,0 +1,548 @@
+/// @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/RectangleLayout.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 -------------------------------------------------------
+// On a RectangleLayout the interior maps to nothing, so the effect asks the LUT and skips those positions. GridLayout is identity, so the video-wall case is unaffected.
+
+namespace {
+
+// The Rig above, with a hollow rectangle in place of the grid: perimeter lit, interior dead.
+struct RectRig {
+ mm::Layouts layouts;
+ mm::RectangleLayout 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 85431c1e2..ecd82f940 100644
--- a/test/unit/light/unit_Correction.cpp
+++ b/test/unit/light/unit_Correction.cpp
@@ -1,6 +1,8 @@
/// @module Correction
#include "doctest.h"
+
+#include
#include "light/effects/MovingHeadEffect.h"
#include "light/layers/Layer.h"
#include "light/layouts/GridLayout.h"
@@ -21,7 +23,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).
@@ -29,10 +31,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).
@@ -185,7 +187,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);
@@ -300,6 +302,111 @@ TEST_CASE("Correction: UV dark on warm colors; whiteMode None zeroes WW/Y/UV") {
}
}
+// A trim scales one channel only: how a white point is pulled neutral on mismatched dies.
+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}; // a "white" triple the strip would render green-cast
+ 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 RGB dies, so it must not reach the separate W 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 = min of the RAW channels, untrimmed
+}
+
+// Two inputs to one fill, not two stages: 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);
+}
+
+// --- Current limiting ------------------------------------------------------------------------
+// These check the NUMBERS, not just that something got smaller: the arithmetic is what stands between a white frame and a browned-out supply.
+
+// An unset budget must leave every channel bit-exact, or the feature would dim existing installs.
+TEST_CASE("Correction: no budget leaves the frame untouched") {
+ Correction c;
+ mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB);
+ const uint8_t src[3] = {255, 255, 255};
+ c.measure(src, 3, 1);
+ CHECK(c.limit == 256); // unity, so the shift gives the table value back exactly
+ uint8_t out[3] = {};
+ c.apply(src, out, 3);
+ CHECK(out[0] == 255);
+ CHECK(out[1] == 255);
+ CHECK(out[2] == 255);
+}
+
+// A limiter that trims when it needn't is just a dimmer.
+TEST_CASE("Correction: a frame within budget is not scaled") {
+ Correction c;
+ c.budgetMa = 1000;
+ mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB);
+ const uint8_t src[3] = {255, 255, 255}; // one light, 3 channels x 8 mA = 24 mA
+ c.measure(src, 3, 1);
+ CHECK(c.limit == 256);
+}
+
+// The headline case: white at full brightness on more lights than the supply can carry.
+TEST_CASE("Correction: an over-budget frame is scaled to fit") {
+ Correction c;
+ c.budgetMa = 1200; // half of what the frame below wants
+ mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB);
+ uint8_t frame[100 * 3];
+ std::memset(frame, 255, sizeof(frame)); // 100 white lights
+ c.measure(frame, 3, 100); // 100 x 3 channels x 8 mA = 2400 mA
+ CHECK(c.limit == 128); // 1200/2400 -> half
+ uint8_t out[3] = {};
+ c.apply(frame, out, 3);
+ CHECK(out[0] == 127); // (255 * 128) >> 8
+}
+
+// Why a per-LIGHT figure cannot describe RGBW. Accurate moves the draw off R/G/B and onto W, which is cheaper for the same color, so one budget halves a Min frame and leaves an Accurate one alone.
+TEST_CASE("Correction: the estimate follows whiteMode, not a per-light constant") {
+ uint8_t frame[100 * 3];
+ std::memset(frame, 255, sizeof(frame));
+
+ Correction min;
+ min.whiteMode = WhiteMode::Min;
+ min.budgetMa = 2000;
+ mm::test::rebuildFromPreset(min, 255, mm::test::PresetOrder::RGBW);
+ min.measure(frame, 3, 100); // RGB 3x8 + W 16 = 40 mA/light = 4000 mA
+ CHECK(min.limit == 128); // 2000/4000 -> half
+
+ Correction acc;
+ acc.whiteMode = WhiteMode::Accurate;
+ acc.budgetMa = 2000;
+ mm::test::rebuildFromPreset(acc, 255, mm::test::PresetOrder::RGBW);
+ acc.measure(frame, 3, 100); // RGB drops to 0, W alone = 16 mA/light = 1600 mA
+ CHECK(acc.limit == 256); // inside budget, so untouched
+}
+
+// A fixture with a master dimmer channel must actually be LIT, since the dimmer is a real output rather than a motion role. A moving head mapping Pan/Tilt/Dimmer/RGBW stayed dark on the bench with a correct color map, because nothing wrote its dimmer.
+
// A fixture with a master dimmer channel must actually be LIT. The dimmer is a real output, not a motion role: a moving head whose preset maps Pan/Tilt/Dimmer/RGBW stayed completely dark on the bench with a perfectly correct color map, because nothing ever wrote its dimmer and a linear dimmer at 0 emits nothing. The pre-existing "IRGB" preset had the same defect.
TEST_CASE("A preset's master dimmer channel is driven, so the fixture actually lights") {
using R = mm::ChannelRole;
@@ -449,6 +556,85 @@ TEST_CASE("Each moving-head formation aims the rig differently") {
mm::platform::setTestNowMs(0); // back to the real clock for every later test
}
+// Yellow and UV are real emitted channels on some fixtures, so the limiter has to price them too.
+TEST_CASE("Correction: the current estimate counts Yellow and UV channels") {
+ using R = mm::ChannelRole;
+ const R roles[] = {R::Red, R::Green, R::Blue, R::Yellow, R::UV};
+ const uint8_t frame[3] = {255, 255, 255}; // Y = 255, UV = 0, so the extra channel adds 8 mA
+
+ Correction c;
+ c.budgetMa = 24; // plain RGB fits exactly; the extra Yellow channel must trip the limiter
+ c.rebuild(255, roles, 5);
+ c.measure(frame, 3, 1);
+
+ CHECK(c.limit < 256);
+}
+
+// A dimmer costs the estimate NOTHING: on every fixture that declares one it is a DMX control value drawing nothing from this rail, like the motion roles beside it. Pricing it squeezed the colors to make room for draw that does not exist.
+TEST_CASE("Correction: a master dimmer costs the current estimate nothing") {
+ uint8_t frame[10 * 3];
+ std::memset(frame, 255, sizeof(frame)); // 10 white lights, 3 x 8 mA = 240 mA
+
+ Correction plain;
+ plain.budgetMa = 200;
+ mm::test::rebuildFromPreset(plain, 255, mm::test::PresetOrder::RGB);
+ plain.measure(frame, 3, 10);
+
+ Correction dimmed;
+ dimmed.budgetMa = 200;
+ mm::test::rebuildFromPreset(dimmed, 255, mm::test::PresetOrder::RGB);
+ dimmed.offDimmer = 3; // the fixture carries one
+ dimmed.measure(frame, 3, 10);
+
+ CHECK(plain.limit == 213); // 200/240 of unity: the colors, and only those
+ CHECK(dimmed.limit == plain.limit); // declaring a dimmer changed nothing
+
+ // And a black frame draws nothing whether or not a dimmer is declared. Priced, this tripped the limiter on a strip showing no light at all.
+ const uint8_t black[3] = {0, 0, 0};
+ dimmed.budgetMa = 1;
+ dimmed.measure(black, 3, 1);
+ CHECK(dimmed.limit == 256);
+}
+
+// Yellow and UV are emitted from the same corrected RGB as everything else, so a fixture carrying them draws more than an RGB one on the same frame. Uncounted, an RGBY preset would exceed its cap.
+TEST_CASE("Correction: the estimate counts the Yellow and UV emitters") {
+ uint8_t frame[10 * 3];
+ std::memset(frame, 255, sizeof(frame));
+
+ Correction plain;
+ plain.budgetMa = 100;
+ mm::test::rebuildFromPreset(plain, 255, mm::test::PresetOrder::RGB);
+ plain.measure(frame, 3, 10);
+
+ Correction wide;
+ wide.budgetMa = 100;
+ mm::test::rebuildFromPreset(wide, 255, mm::test::PresetOrder::RGB);
+ wide.offYellow = 3; // min(r,g) = 255 on a white frame, so a real extra draw
+ wide.measure(frame, 3, 10);
+
+ CHECK(wide.limit < plain.limit); // the same frame costs more, so it is trimmed harder
+
+ // And each emitter carries its own figure. A UV die usually draws more than a visible one, so pricing it as a color channel would under-report, the direction that browns out a supply.
+ Correction thirsty;
+ thirsty.budgetMa = 100;
+ mm::test::rebuildFromPreset(thirsty, 255, mm::test::PresetOrder::RGB);
+ thirsty.offYellow = 3;
+ thirsty.mAYellow = 40;
+ thirsty.measure(frame, 3, 10);
+ CHECK(thirsty.limit < wide.limit);
+}
+
+// A cap that understates is not a cap: flooring the modelled draw lets a frame sit fractionally over the budget with no limit applied.
+TEST_CASE("Correction: the modelled draw is rounded up, so the cap stays an upper bound") {
+ const uint8_t frame[3] = {254, 0, 0}; // one channel at 254: 7.97 mA at 8 mA full scale
+
+ Correction c;
+ c.budgetMa = 7; // floored the draw reads as exactly 7 and passes
+ mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB);
+ c.measure(frame, 3, 1);
+ CHECK(c.limit < 256); // rounded up it is 8, over budget, so it is trimmed
+}
+
// --- The perceptual curve -------------------------------------------------------------------
// CIE 1931 lightness (CIE 15 / ISO 11664-4) is the default because it models the thing actually being corrected: the eye's response to luminance. The endpoints are what a user notices first, and the shape between them is what makes a fade look even.
@@ -458,13 +644,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.
@@ -474,7 +660,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);
}
}
@@ -484,7 +670,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.
@@ -494,11 +680,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);
@@ -521,9 +707,52 @@ 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[0][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]);
+}
+
+// The W die is separate hardware the RGB trims say nothing about, so it has a trim of its own that pre-scales like theirs. The white channel moves through the curve, RGB not at all.
+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 LUT row, curve last
+ CHECK(b[0] == a[0]); // and RGB untouched
+ CHECK(b[1] == a[1]);
+ CHECK(b[2] == a[2]);
+}
+
+// The limiter prices what is EMITTED, so a trimmed white must cost less: otherwise the budget is spent on current the strip never draws, squeezing the colors for nothing.
+TEST_CASE("Correction whiteLevel: a trimmed white is priced at what it draws") {
+ uint8_t frame[10 * 3];
+ std::memset(frame, 255, sizeof(frame)); // 10 white lights
+
+ // White everywhere costs 400 mA at these defaults (10 x (3x255x8 + 255x16) / 255); with no white emitted it is 240. A budget between the two is what makes the difference visible.
+ Correction full;
+ full.budgetMa = 300;
+ mm::test::rebuildFromPreset(full, 255, mm::test::PresetOrder::RGBW);
+ full.measure(frame, 3, 10);
+
+ Correction dim;
+ dim.budgetMa = 300;
+ dim.whiteLevel = 0; // no white emitted at all
+ mm::test::rebuildFromPreset(dim, 255, mm::test::PresetOrder::RGBW);
+ dim.measure(frame, 3, 10);
+
+ CHECK(full.limit < 256); // over budget with the white die lit
+ CHECK(dim.limit == 256); // and inside it with the white die off
}
diff --git a/test/unit/light/unit_Drivers_container.cpp b/test/unit/light/unit_Drivers_container.cpp
index cc491c6d6..905996726 100644
--- a/test/unit/light/unit_Drivers_container.cpp
+++ b/test/unit/light/unit_Drivers_container.cpp
@@ -78,19 +78,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);
}
// Regression (the localBrightness bug): a per-driver localBrightness change must RE-SCALE that driver's correction LUT, global Γ local, just like a global brightness change does. The bug was that localBrightness edits didn't reach the LUT (only global did). Both sliders must reach output.
@@ -104,17 +104,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", 3); // 3 = 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.
diff --git a/test/unit/light/unit_I80Peripheral.cpp b/test/unit/light/unit_I80Peripheral.cpp
index 34ef8990a..af76453ed 100644
--- a/test/unit/light/unit_I80Peripheral.cpp
+++ b/test/unit/light/unit_I80Peripheral.cpp
@@ -447,6 +447,31 @@ TEST_CASE("I80Peripheral gives the host bus two distinct buffers when asked") {
mm::test::checkHostBusDoubleBuffer();
}
+// --- Current limiting ---------------------------------------------------------------------------
+// measureFrame() has to walk exactly the lights the encode will touch, so the total must be the SUM of the lanes rather than the buffer size or the longest lane. Under-counting is the dangerous direction.
+TEST_CASE("ParallelLedDriver prices every lane, not just the longest") {
+ mm::I80Peripheral peripheral;
+ mm::ParallelLedDriver d;
+ mm::Buffer src;
+ mm::Correction corr;
+ std::strcpy(d.ledsPerPin, "50,20,20");
+ wire(d, peripheral, src, corr, 90);
+
+ // White everywhere: 90 lights x 3 channels x 8 mA = 2160 mA against a 1080 budget.
+ std::memset(src.data(), 255, static_cast(90) * 3);
+ d.correctionForTest().budgetMa = 1080;
+ d.tick();
+
+ // Halved. Sizing the pass by the longest lane would price it at 1200 mA and set limit to 230, the under-counting direction that reports a frame safe while the supply sags.
+ CHECK(d.correctionForTest().limit == 128);
+}
+
+// A driver that never measures must not offer the controls: see DriverBase::limitsCurrent.
+TEST_CASE("ParallelLedDriver offers the current controls") {
+ mm::ParallelLedDriver d;
+ CHECK(d.limitsCurrent());
+}
+
TEST_CASE("I80Peripheral counts the DMA buffers in its memory readout") {
mm::test::checkHostBusCountedInHeapReadout();
}
diff --git a/test/unit/light/unit_LightPresetsModule.cpp b/test/unit/light/unit_LightPresetsModule.cpp
index 370934748..15f4f5328 100644
--- a/test/unit/light/unit_LightPresetsModule.cpp
+++ b/test/unit/light/unit_LightPresetsModule.cpp
@@ -464,3 +464,32 @@ TEST_CASE("A driver referencing a missing preset falls back to the default built
CHECK(lib.deriveCorrection(lib.defaultId(), 255, c)); // β¦and the default always does
CHECK(c.outChannels == 3);
}
+
+// whiteLevel reaches the White and WarmWhite dies only, so its gate is narrower than whiteMode's. A preset carrying amber but no white would otherwise show a slider that changes nothing.
+TEST_CASE("whiteLevel is shown for a white preset and hidden for a no-white one") {
+ LightPresetsModule lib;
+ lib.defineControls(); // seeds RGB(0) GRB(1) BGR(2) RGBW(3) GRBW(4)
+
+ mm::NetworkSendDriver drv;
+ auto whiteLevelHidden = [&]() -> int {
+ drv.rebuildControls();
+ for (uint8_t i = 0; i < drv.controls().count(); i++)
+ if (std::strcmp(drv.controls()[i].name, "whiteLevel") == 0)
+ return drv.controls()[i].hidden ? 1 : 0;
+ return -1;
+ };
+ auto pickPreset = [&](uint8_t idx) {
+ for (uint8_t i = 0; i < drv.controls().count(); i++)
+ if (std::strcmp(drv.controls()[i].name, "lightPreset") == 0)
+ *static_cast(drv.controls()[i].ptr) = idx;
+ drv.rebuildControls();
+ drv.onControlChanged("lightPreset");
+ };
+ drv.defineControls();
+
+ pickPreset(1); // GRB - no white die
+ CHECK(whiteLevelHidden() == 1);
+
+ pickPreset(4); // GRBW - carries White
+ CHECK(whiteLevelHidden() == 0);
+}
diff --git a/test/unit/light/unit_ParallelLedDriver_ring.cpp b/test/unit/light/unit_ParallelLedDriver_ring.cpp
index 88e0bfa10..384287f22 100644
--- a/test/unit/light/unit_ParallelLedDriver_ring.cpp
+++ b/test/unit/light/unit_ParallelLedDriver_ring.cpp
@@ -71,7 +71,10 @@ class MockRingPeripheral : public mm::LedPeripheral {
// --- whole-frame bus (used to produce the reference frame the ring output is compared against) ---
bool busInit(size_t frameBytes, bool) override { cap_ = frameBytes; buf_.assign(frameBytes, 0); return true; }
- uint8_t* busBuffer(uint8_t i) override { return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr; }
+ uint8_t* busBuffer(uint8_t i) override {
+ if (ringActive_) return i < kMockRingBufs && !ring_[i].empty() ? ring_[i].data() : nullptr;
+ return (i == 0 && !buf_.empty()) ? buf_.data() : nullptr;
+ }
size_t busCapacity() const override { return cap_; }
bool busTransmit(uint8_t, size_t) override { return true; }
bool busWait(uint8_t, uint32_t) override { return true; }
@@ -561,6 +564,31 @@ TEST_CASE("MoonI80 ring: the windowed snapshot bias reads this driver's slice, n
CHECK(std::memcmp(refFrame.data(), winFrame.data(), refFrame.size()) == 0);
}
+// The ring path chooses the source copy first, then prices that same frame before arming the transfer.
+TEST_CASE("MoonI80 ring: current limiting prices the frame before the transfer starts") {
+ MockRingDriver d;
+ MockRingPeripheral peripheral;
+ mm::Buffer src;
+ mm::Correction corr;
+ wireShift(d, peripheral, src, corr, 90, "1,2");
+
+ peripheral.setWantRing(true);
+ d.applyState();
+ REQUIRE(peripheral.busIsRing());
+
+ std::memset(src.data(), 255, static_cast(src.count()) * src.channelsPerLight());
+ d.correctionForTest().budgetMa = 17280; // half of 90 lights Γ 2 pins Γ 8 strands Γ 24 mA
+ d.tick();
+
+ CHECK(d.correctionForTest().limit == 128);
+}
+
+// 7. TERMINATION β the DMA-chain contract the byte-tiling tests (1β6) never touch, and the exact site of
+// the 2026-07-16 "green dot per panel" bug: the looping chain must clock past the last real slice into
+// a clean LOW tail and stop deterministically, WITHOUT re-clocking a buffer that still holds a real
+// (non-tail) slice at a tail clock position. These drive the termination model, which replays
+// moonI80EofCb's prime β refill-behind β stop-on-drain-counter, and records what the DMA actually
+// clocks to the wire (including the laps past the last slice).
// 7.
// TERMINATION, the DMA-chain contract the byte-tiling tests (1β6) never touch, and the exact site of the 2026-07-16 "green dot per panel" bug: the looping chain must clock past the last real slice into a clean LOW tail and stop deterministically, WITHOUT re-clocking a buffer that still holds a real (non-tail) slice at a tail clock position.
// These drive the termination model, which replays moonI80EofCb's prime β refill-behind β stop-on-drain-counter, and records what the DMA actually clocks to the wire (including the laps past the last slice).
diff --git a/test/unit/light/unit_RectangleLayout.cpp b/test/unit/light/unit_RectangleLayout.cpp
new file mode 100644
index 000000000..2fc2d300a
--- /dev/null
+++ b/test/unit/light/unit_RectangleLayout.cpp
@@ -0,0 +1,266 @@
+/// @module RectangleLayout
+///
+/// Pins the hollow-rectangle perimeter walk: the corner-counted-once light count, the reference clockwise-from-top-left order, the degenerate line cases, and the eight wiring permutations.
+///
+/// @moreinfo
+///
+/// The wiring controls must reorder INDICES only, since the set of emitted coordinates is a property of the box and must be byte-identical however the strip is wired.
+
+#include "doctest.h"
+#include "light/layouts/RectangleLayout.h"
+
+#include
+#include
+#include
+#include
+
+using mm::RectangleLayout;
+
+namespace {
+
+// Collect (index β position) by walking the layout the way the Layer's LUT build does.
+std::vector> walk(const RectangleLayout& r) {
+ std::vector> out;
+ mm::CoordSink sink{
+ [](void* ctx, mm::nrOfLightsType, mm::lengthType x, mm::lengthType y, mm::lengthType) {
+ static_cast>*>(ctx)->push_back({x, y});
+ },
+ nullptr, &out};
+ r.placeLights(sink);
+ return out;
+}
+
+} // namespace
+
+// A rectangle is FLAT, with every light at z = 0. Nothing in the x/y checks below would notice a stray depth, but a non-zero z inflates the bounding box, so the Layer allocates `depth` times over.
+TEST_CASE("RectangleLayout: every light is emitted flat at z = 0") {
+ RectangleLayout r;
+ r.width = 7; r.height = 5;
+ int maxZ = -1;
+ mm::CoordSink sink{
+ [](void* ctx, mm::nrOfLightsType, mm::lengthType, mm::lengthType, mm::lengthType z) {
+ int& m = *static_cast(ctx);
+ if (z > m) m = static_cast(z);
+ },
+ nullptr, &maxZ};
+ r.placeLights(sink);
+ CHECK(maxZ == 0);
+}
+
+// The perimeter of a wΓh box counts each corner once: 2Β·(w+h) β 4. A strip bent around a frame has exactly one LED in each corner, even though that corner belongs to two edges.
+TEST_CASE("RectangleLayout: light count is the perimeter with corners counted once") {
+ RectangleLayout r;
+ r.width = 4; r.height = 3;
+ CHECK(r.lightCount() == 10); // 2*(4+3) - 4
+ r.width = 32; r.height = 18; // the 16:9 default
+ CHECK(r.lightCount() == 96);
+ r.width = 2; r.height = 2;
+ CHECK(r.lightCount() == 4); // the smallest real rectangle
+}
+
+// lightCount() must agree with what placeLights() actually emits, or the Layer allocates a buffer of one size and the LUT build walks another.
+TEST_CASE("RectangleLayout: emitted light count matches lightCount()") {
+ RectangleLayout r;
+ r.width = 7; r.height = 5;
+ CHECK(walk(r).size() == r.lightCount());
+}
+
+// The reference walk: clockwise from the top-left corner, along the top edge first.
+TEST_CASE("RectangleLayout: default walk runs clockwise from the top-left corner") {
+ RectangleLayout r;
+ r.width = 4; r.height = 3; // 10 lights
+ const auto p = walk(r);
+ REQUIRE(p.size() == 10);
+ // top edge, left to right
+ CHECK(p[0] == std::pair{0, 0});
+ CHECK(p[1] == std::pair{1, 0});
+ CHECK(p[2] == std::pair{2, 0});
+ CHECK(p[3] == std::pair{3, 0});
+ // right edge, top to bottom (the top-right corner was already emitted)
+ CHECK(p[4] == std::pair{3, 1});
+ CHECK(p[5] == std::pair{3, 2});
+ // bottom edge, right to left
+ CHECK(p[6] == std::pair{2, 2});
+ CHECK(p[7] == std::pair{1, 2});
+ CHECK(p[8] == std::pair{0, 2});
+ // left edge, bottom to top: one cell, both its corners already placed
+ CHECK(p[9] == std::pair{0, 1});
+}
+
+// Every light lands on its own cell: the walk goes round the frame exactly once. A duplicate would mean two LEDs mapped to one logical position (one of them dark), a gap would mean an unlit LED.
+TEST_CASE("RectangleLayout: every light occupies a distinct perimeter cell") {
+ RectangleLayout r;
+ r.width = 9; r.height = 6;
+ const auto p = walk(r);
+ const std::set> unique(p.begin(), p.end());
+ CHECK(unique.size() == p.size());
+ // and none of them is an interior cell: this is a HOLLOW rectangle
+ for (const auto& [x, y] : p)
+ CHECK((x == 0 || x == r.width - 1 || y == 0 || y == r.height - 1));
+}
+
+// startCorner and clockwise change the WIRING rather than the shape, so only the index order differs. Every position is compared rather than the set, since a set has no order.
+TEST_CASE("RectangleLayout: each of the eight wirings emits the reference walk in its own order") {
+ const int w = 6, h = 4;
+ RectangleLayout ref;
+ ref.width = w; ref.height = h; // top-left, clockwise, shared corners: the reference
+ const auto base = walk(ref);
+ const size_t n = base.size();
+ REQUIRE(n == static_cast(2 * w + 2 * h - 4));
+
+ // kStartCornerOptions order: top-left, top-right, bottom-right, bottom-left.
+ const std::pair corners[4] = {{0, 0}, {w - 1, 0}, {w - 1, h - 1}, {0, h - 1}};
+
+ for (uint8_t corner = 0; corner < RectangleLayout::kStartCornerCount; corner++) {
+ size_t s = 0; // where that corner sits in the reference
+ while (s < n && base[s] != corners[corner]) s++;
+ REQUIRE(s < n);
+ for (bool cw : {true, false}) {
+ RectangleLayout r;
+ r.width = w; r.height = h;
+ r.startCorner = corner;
+ r.clockwise = cw;
+ const auto p = walk(r);
+ REQUIRE(p.size() == n);
+ for (size_t i = 0; i < n; i++) {
+ const size_t j = cw ? (s + i) % n : (s + n - i) % n;
+ CHECK(p[i] == base[j]);
+ }
+ const std::set> got(p.begin(), p.end());
+ CHECK(got.size() == n); // and still each cell exactly once
+ }
+ }
+}
+
+// Light 0 lands on the corner the user named: the control's whole purpose. (x, y) origin is top-left, so "bottom" is y = height β 1.
+TEST_CASE("RectangleLayout: light 0 sits on the chosen start corner") {
+ const int w = 6, h = 4;
+ const std::pair corners[4] = {
+ {0, 0}, {w - 1, 0}, {w - 1, h - 1}, {0, h - 1}}; // TL, TR, BR, BL: kStartCornerOptions order
+ for (uint8_t c = 0; c < 4; c++) {
+ RectangleLayout r;
+ r.width = w; r.height = h;
+ r.startCorner = c;
+ CHECK(walk(r)[0] == corners[c]);
+ }
+}
+
+// Counter-clockwise reverses the direction of travel while keeping the same first light: from the top-left corner it heads DOWN the left edge instead of right along the top.
+TEST_CASE("RectangleLayout: counter-clockwise reverses travel from the same corner") {
+ RectangleLayout r;
+ r.width = 4; r.height = 3;
+ r.clockwise = false;
+ const auto p = walk(r);
+ REQUIRE(p.size() == 10);
+ CHECK(p[0] == std::pair{0, 0}); // same start corner
+ CHECK(p[1] == std::pair{0, 1}); // ...but down the left edge (clockwise gave (1,0))
+ CHECK(p[2] == std::pair{0, 2});
+ CHECK(p[3] == std::pair{1, 2}); // then right along the bottom
+}
+
+// A box one light thick has no interior to go around, so it degenerates to a plain line. The rectangle formula would walk those cells twice and light phantom positions, so it is not used.
+TEST_CASE("RectangleLayout: a one-light-thick box degenerates to a line, not a doubled-back frame") {
+ RectangleLayout row;
+ row.width = 5; row.height = 1;
+ CHECK(row.lightCount() == 5); // not 2*5 + 2*1 - 4 == 8
+ const auto rp = walk(row);
+ REQUIRE(rp.size() == 5);
+ CHECK(rp[0] == std::pair{0, 0});
+ CHECK(rp[4] == std::pair{4, 0});
+
+ RectangleLayout col;
+ col.width = 1; col.height = 5;
+ CHECK(col.lightCount() == 5);
+ const auto cp = walk(col);
+ REQUIRE(cp.size() == 5);
+ CHECK(cp[0] == std::pair{0, 0});
+ CHECK(cp[4] == std::pair{0, 4});
+}
+
+// A zero side is not a shape. It emits nothing rather than dividing by zero in the modular walk.
+TEST_CASE("RectangleLayout: a zero-sided box emits no lights") {
+ RectangleLayout r;
+ r.width = 0; r.height = 10;
+ CHECK(r.lightCount() == 0);
+ CHECK(walk(r).empty());
+}
+
+// --- Four separate strips (sharedCorners off) ---------------------------------------------------
+// One strip bent around a frame has ONE light in each corner. Four strips have their own end there, so the count is the plain sum of the edges and two lights share each corner coordinate.
+
+TEST_CASE("RectangleLayout: unshared corners count every edge in full") {
+ RectangleLayout r;
+ r.width = 20; r.height = 10;
+ r.sharedCorners = false;
+ CHECK(r.lightCount() == 60); // 20 + 20 + 10 + 10, no corners deducted
+ CHECK(walk(r).size() == 60);
+
+ r.sharedCorners = true;
+ CHECK(r.lightCount() == 56); // the same box, four corners folded away
+}
+
+// The extra lights must land ON the corners rather than past them, since an edge running its full length is one step from walking outside the box.
+TEST_CASE("RectangleLayout: unshared corners double the corner cells and stay in the box") {
+ RectangleLayout r;
+ r.width = 4; r.height = 3;
+ r.sharedCorners = false;
+ const auto pts = walk(r);
+ REQUIRE(pts.size() == 14);
+
+ int corners = 0;
+ for (const auto& p : pts) {
+ CHECK(p.first >= 0);
+ CHECK(p.first < 4);
+ CHECK(p.second >= 0);
+ CHECK(p.second < 3);
+ const bool onCorner = (p.first == 0 || p.first == 3) && (p.second == 0 || p.second == 2);
+ if (onCorner) corners++;
+ }
+ CHECK(corners == 8); // four corners, two lights each
+}
+
+// With four separate strips, light 0 still has to land on the named corner rather than one cell past it.
+TEST_CASE("RectangleLayout: unshared corners still honor the chosen start corner") {
+ const int w = 4, h = 3;
+ const std::pair corners[4] = {
+ {0, 0}, {w - 1, 0}, {w - 1, h - 1}, {0, h - 1}};
+ for (uint8_t c = 0; c < 4; c++) {
+ RectangleLayout r;
+ r.width = w; r.height = h;
+ r.sharedCorners = false;
+ r.startCorner = c;
+ CHECK(walk(r)[0] == corners[c]);
+ }
+}
+
+// --- offset --------------------------------------------------------------------------------------
+// A strip rarely starts exactly at a corner. offset slides where index 0 sits WITHOUT moving any light: the same coordinates come out, rotated in the wiring order.
+
+TEST_CASE("RectangleLayout: offset rotates the wiring and emits the same coordinates") {
+ RectangleLayout plain, shifted;
+ plain.width = shifted.width = 7;
+ plain.height = shifted.height = 5;
+ shifted.offset = 3;
+
+ const auto a = walk(plain);
+ const auto b = walk(shifted);
+ REQUIRE(a.size() == b.size());
+ CHECK(a != b); // the order moved
+ CHECK(b[0] == a[3]); // by exactly three steps
+ CHECK(std::set>(a.begin(), a.end()) ==
+ std::set>(b.begin(), b.end())); // the shape did not
+}
+
+// A full lap is a no-op, and anything beyond it wraps: the walk is modular, so an offset larger than the perimeter must not run off the end of it.
+TEST_CASE("RectangleLayout: an offset of a full lap or more wraps") {
+ RectangleLayout plain, lap;
+ plain.width = lap.width = 7;
+ plain.height = lap.height = 5;
+ lap.offset = static_cast(plain.lightCount());
+ CHECK(walk(lap) == walk(plain));
+
+ lap.offset = static_cast(plain.lightCount() + 2);
+ RectangleLayout two;
+ two.width = 7; two.height = 5; two.offset = 2;
+ CHECK(walk(lap) == walk(two));
+}