Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
033bc31
Paint the lights from a video frame
Gohnnyman Aug 26, 2026
5f05ce5
Capture USB video on the ESP32-P4
Gohnnyman Aug 31, 2026
a556093
Cap the current a frame can draw
Gohnnyman Sep 1, 2026
b3e4c99
Survive a source that stops, and a grabber that is unplugged
Gohnnyman Sep 1, 2026
3a1a118
Smooth the ambilight, and bring it up softly
Gohnnyman Sep 2, 2026
e3bbfe9
Let the border lights sample deeper than their own share
Gohnnyman Sep 2, 2026
4a170d9
Price the frame on ParallelLedDriver too
Gohnnyman Sep 2, 2026
f460c00
Find the letterbox and map the lights across the picture
Gohnnyman Sep 2, 2026
618066b
Request 16:9 capture by default
Gohnnyman Sep 2, 2026
1d21a3a
Skip the positions no LED reaches
Gohnnyman Sep 2, 2026
9062eb7
Walk a list of the lit positions instead of the whole box
Gohnnyman Sep 2, 2026
2265dab
Add four-strip corners and a wiring offset to Rectangle
Gohnnyman Sep 2, 2026
0d7ba55
Skip a frame already on the strip
Gohnnyman Sep 2, 2026
1f56602
Merge origin/main (v4.0.0)
Gohnnyman Sep 2, 2026
f6db9f6
Count a master dimmer's draw, and drop the em-dashes
Gohnnyman Sep 2, 2026
b387433
Fix review regressions in current limiting
Copilot Sep 2, 2026
753e3a7
Process the review findings on current limiting and capture
Gohnnyman Sep 3, 2026
2e06701
Close the second review round on capture and current limiting
Gohnnyman Sep 3, 2026
35df972
Let the platform own capture buffers for the life of one open
Gohnnyman Sep 3, 2026
8e6304d
Keep a working capture open; fix tests that passed on bugs
Gohnnyman Sep 3, 2026
560e08f
Stop pricing a dimmer, and stop reading a dark scene as bars
Gohnnyman Sep 3, 2026
e87c3a1
Apply a restored capture format, add a pattern speed, drop the fade-in
Gohnnyman Sep 12, 2026
886288c
Read an HDR capture through its own transfer curve
Gohnnyman Sep 22, 2026
ff236a6
Merge branch 'main' into feat/screen-follow-ambilight
Gohnnyman Sep 22, 2026
e1d92fb
Average the picture as light, and give the white die its own level
Gohnnyman Sep 23, 2026
4e6f97b
Merge remote-tracking branch 'upstream/main' into feat/screen-follow-…
Gohnnyman Sep 23, 2026
318b824
Document the ambilight branch to the docgen standard
Gohnnyman Sep 24, 2026
a8d1aa6
Merge upstream/main into the ambilight branch
Gohnnyman Sep 27, 2026
7cfe6f4
Fix the card image path and record the post-merge metrics
Gohnnyman Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/assets/core/VideoService.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/AmbilightEffect.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/AmbilightEffect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/layouts/RectangleLayout.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/layouts/RectangleLayout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 56 additions & 0 deletions docs/moonmodules/core/services.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Core services

![services controls](../../assets/core/Services.png)

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.

<a id="services"></a>
Expand Down Expand Up @@ -38,6 +40,28 @@ Detail: [technical](moxygen/AudioService.md) · [the sync packet](../light/moxyg

[Tests](../../reference/tests/unit-tests.md#audioservice)

<a id="video"></a>

### Video

<img src="../../assets/core/VideoService.png" width="300" alt="Video service card">

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)

<a id="osc"></a>

### OSC
Expand Down Expand Up @@ -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.
25 changes: 24 additions & 1 deletion docs/moonmodules/light/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ 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).

<img src="../../assets/light/drivers/RmtLedDriver.png" width="300" alt="The shared block at the top of a driver card, here on RMT LED">

- `localBrightness`: this driver's dim (0–255), multiplied with the global brightness.
- `curve`: brightness to output: `CIE 1931`, `gamma 2.2`, `gamma 2.8`, or `linear`.
- `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.**

Expand Down Expand Up @@ -220,6 +225,24 @@ Detail: [technical](moxygen/RtspDriver.md) · [the RTP packetiser](moxygen/RtpH2
<a id="shared-details"></a>

## 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.
Expand Down
47 changes: 45 additions & 2 deletions docs/moonmodules/light/effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1174,7 +1173,21 @@ Detail: [technical](moxygen/NoiseEffect.md)

## MoonLight-native effects

<a id="moonlive"></a>
<a id="ambilight"></a>

### Ambilight 📺

<img src="../../assets/light/effects/AmbilightEffect.gif" width="300" alt="Ambilight effect preview">

Paints the layer with the live frame from the [Video](../core/services.md#video) service, so lights around a display glow the color of the picture nearest them. Each light shows the **mean** of the rectangle mapping to it. Sampling, black bars and the layout's part: ⌄ details.

- `brightness`: scales the sampled color, dimming the video not the output.
- `saturation`: how far each channel is pushed from its zone's luma.
- `smoothing`: how much of the gap to a new color closes per frame.
- `snapAbove`: a channel moving further than this jumps instead of easing.
- `edgeDepth`: how far into the picture the outermost lights look.
- `detectBlackBars`: map the lights across the picture, not the frame.
- `barLevel`: how dark a pixel counts as bar (0 to 64).

### MoonLive 📝 · any

Expand Down Expand Up @@ -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)

<a id="moonlive"></a>
24 changes: 24 additions & 0 deletions docs/moonmodules/light/layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,24 @@ Detail: [technical](moxygen/GridBlacksLayout.md)

[Tests](../../reference/tests/unit-tests.md#gridblackslayout)

<a id="rectangle"></a>

### Rectangle

<img src="../../assets/light/layouts/RectangleLayout.gif" width="300" alt="Rectangle layout preview">

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)

<a id="sphere"></a>

### Sphere
Expand Down Expand Up @@ -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
70 changes: 40 additions & 30 deletions docs/reference/metrics/repo-health.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,31 +14,33 @@
"qemu": 1383648,
"esp32p4rev3-eth": 1643760,
"esp32s3-zero": 2024192,
"esp32-pico": 2107168
"esp32-pico": 2107168,
"esp32p4rev3-eth-wifi": 2498304
},
"measured": {
"esp32p4rev1-eth": "2026-09-22",
"esp32s31": "2026-09-22",
"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,
Expand All @@ -49,8 +51,8 @@
}
},
"esp32": {
"tick_us": 8354,
"fps": 119
"tick_us": 6832,
"fps": 146
},
"scenario_matrix": {
"Firmware_reports_what_is_running": {
Expand All @@ -74,7 +76,7 @@
"p50": 8,
"p95": 19,
"n": 27,
"last": "2026-09-25"
"last": "2026-09-27"
}
},
"Effects_swap_while_running": {
Expand All @@ -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
}
}
Loading