diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 095096b3..8cb2041a 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -229,6 +229,16 @@ Two improvements when this matters: Not blocking — MoonDeck is a developer tool, not a production server. Pick this up when MoonDeck is in scope for hardening. +### A tagged release does not reach the web installer until the next main deploy (bug) + +`deploy-pages` in `.github/workflows/release.yml` is gated `if: github.ref == 'refs/heads/main'`, because the `github-pages` environment's protection rule only allows main. The installer's release list is **staged into the Pages site at deploy time** (`install.js` self-hosts the last 5 stable + 5 prerelease releases; the release-asset URLs redirect to a host that sends no CORS header, so the browser cannot read them from the Pages origin). Together those mean **pushing a `vX.Y.Z` tag publishes the release but never updates the installer** — the new version reaches the picker only when something later pushes to main. + +Hit on v4.0.0 (2026-08-24): the release published at 16:15:59, a main deploy ran at 16:16 and enumerated releases *before* it existed, and the installer offered v3.0.0 as newest for hours. Re-running the workflow with the tag fixed it, and that manual re-run is the current workaround. + +Note the device's own OTA picker is unaffected — it reads `api.github.com` live ([app.js](../../src/ui/app.js) `RELEASES_API`), which is why a device could offer v4.0.0 while the installer could not. Two independent paths to the same release list. + +The fix is to let a tag deploy Pages: either relax the environment's branch protection to include tags, or have the tag release trigger a Pages deploy as a follow-on (`workflow_run`, or a repository_dispatch from the release job). Until then, every stable release needs a manual `gh workflow run release.yml -f tag=vX.Y.Z` afterwards, which is exactly the kind of remember-to-do-it step a release ritual should not carry. + ### CI: pin GitHub Actions to commit SHAs (supply-chain hardening) `.github/workflows/release.yml` references all 9 action types by mutable `@vN` tag (`actions/checkout@v4`, `astral-sh/setup-uv@v3`, `softprops/action-gh-release@v2`, `espressif/esp-idf-ci-action@v1`, …). A mutable tag can be force-moved to malicious code by a compromised publisher; pinning each `uses:` to a full commit SHA (with a `# vN` trailing comment) removes that vector. **Done already (cheaper half):** `persist-credentials: false` on every checkout that doesn't push, so the `GITHUB_TOKEN` isn't left in `.git/config` for later steps to read (the `release` job keeps it — it force-pushes the `latest` tag). **Not done (this item):** SHA-pinning, because it carries an ongoing cost — pinned SHAs go stale and miss security patches, so it only pays for itself **alongside Dependabot** (or a Renovate config) to auto-bump them. Pick this up as a deliberate "CI hardening + Dependabot" pass, not piecemeal. Low risk today: every action pinned is a first-party `actions/*` or a well-known publisher (astral, espressif, softprops), not an obscure third-party action. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 25a24b56..0ab38f9d 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -122,6 +122,36 @@ projectMM already speaks DMX **over the network** (Art-Net / sACN via `NetworkRe Sequencing: it's a **driver** (`src/light/drivers/`) + a platform UART-RS485 seam + a fixture model shared with the Art-Net path — the buffer→channel encode is already done. Plan when a DMX fixture is actually on the bench and a catalog board's `supported`/`planned` list points at wired DMX. The [PinsModule pin-assignment work](backlog-core.md#pinsmodule-strict-reject-on-add-mode-the-one-remaining-increment) covers the RS485/DMX TX/RX/DE slot; this is the driver that consumes it. +## Integration with other LED and visuals tools + +Distilled from a Discord thread with panel-card users (2026-08-24), where two people drove ColorLight walls from projectMM and described the pipelines they already run. + +### projectMM as a video source — NDI first, Spout/Syphon only if proven (open) + +Users asked for projectMM's rendered output to feed *their* tools, not the other way round. One runs OBS → Spout → his own VLAN-tagged card driver; he asked whether projectMM could be a Spout source. Input is not the gap: `NetworkReceiveEffect` already binds Art-Net, E1.31/sACN and DDP at once and answers ArtPoll, so any controller can already drive projectMM. + +**NDI is the recommended first implementation.** It is the AV industry's standard for video over IP, one implementation covers Windows, macOS, Linux and ARM, it discovers by name, and it crosses machines. Spout (Windows, DirectX/OpenGL) and Syphon (macOS, Metal/OpenGL) share a GPU texture zero-copy, so they are lower latency and bit-exact, but they are **same-machine only**, are **two** platform implementations, and leave **Linux and the Pi with nothing**. At LED-wall pixel counts (a 256x256 wall is 65K pixels) the latency difference is far below one frame of the render loop, so it does not decide the choice; coverage does. A Spout user is also reachable through NDI in one hop, since OBS, Resolume and TouchDesigner all speak both. + +**The licence shapes the design, and the shape is already established here.** projectMM is GPL-3.0 and the NDI runtime is proprietary, so projectMM must not *redistribute* it: bundling would require projectMM's own licence to carry NDI's restrictions downstream, which GPL-3 forbids. The user installs the NDI runtime themselves, exactly as they already install **Npcap** for the panel-card driver, and projectMM calls whatever is present. + +That is the arrangement `platform_desktop.cpp` uses for Npcap today: resolve the library with `LoadLibrary`/`dlopen` rather than linking it, declare the handful of functions with the library's own signatures rather than including its headers (so the SDK never becomes a build requirement for CI or contributors), and report the feature unavailable when it is absent instead of failing to link. Two independent installs that talk to each other, like Resolume on the same desktop. + +Also note projectMM renders into a CPU buffer, so a Spout/Syphon path would upload to the GPU purely to hand off, spending the zero-copy advantage it was chosen for. + +### Multi-card walls — does a daisy chain work today? (open, ask before building) + +The ColorLight format has **no card addressing**: the destination MAC is a fixed constant and every card filters on it, so every card on a segment shows the same image. A user with six cards on a switch observed exactly that. + +The industry-standard answer is **daisy-chaining** — a sending card's ports each drive a chain, and each card takes its region by position in the chain. That user works around it with per-card VLANs and a managed switch instead, which he built for throughput and for per-card colour-temperature grouping across mixed panel batches; he described it as his own solution, not a standard. + +**Establish first whether a daisy chain already works with projectMM** (one contact has a 96K daisy-chained rig). If the cards self-assign by chain position, the standard multi-card case is already solved and nothing is needed. Only if it does not work is there a feature here, and it should follow the daisy-chain standard rather than the VLAN workaround. 802.1Q tagging is technically a clean fit for a raw-L2 sender (the tag is part of the Ethernet header, the switch strips it before the card, so card firmware is unaffected), but it serves one bespoke architecture. + +### Smaller asks from the same thread + +- **Read the wall layout from the ColorLight cards.** The cards can report their configuration and at least one user's own tool already does it; it would remove the manual layout step. +- **Per-card colour temperature and brightness**, via the ColorLight sync-packet bytes, grouped by sync group — used to colour-match mixed panel batches live. +- **Docker image**, asked for by a user tracking updates in an IoT system. The Linux binary and `.deb` already ship, so this is packaging rather than new capability. + ## Sensors and audio-reactive input ### Audio-reactive follow-ups diff --git a/docs/history/plans/Plan-20260824 - NDI output.md b/docs/history/plans/Plan-20260824 - NDI output.md new file mode 100644 index 00000000..e3b3306d --- /dev/null +++ b/docs/history/plans/Plan-20260824 - NDI output.md @@ -0,0 +1,125 @@ +# Plan — NDI output: projectMM as a video source + +## Context + +Panel-card users on Discord (2026-08-24) asked for projectMM's rendered output to feed *their* +tools. One runs OBS → Spout → his own card driver and asked whether projectMM could be a Spout +source; another observed that OBS, Resolume and TouchDesigner all speak NDI, so projectMM could be +an NDI source and reach a Spout pipeline through one hop. + +Input is not the gap: `NetworkReceiveEffect` already binds Art-Net, E1.31/sACN and DDP at once. +What is missing is the other direction — projectMM's pixels reaching a production visuals rig. + +Decision recorded in [backlog-light § Integration with other LED and visuals tools](../../backlog/backlog-light.md): +**NDI first.** One implementation covers Windows, macOS, Linux and ARM, it discovers by name, and +it crosses machines. Spout (Windows) and Syphon (macOS) are lower latency and bit-exact but are +same-machine only, are two platform implementations, and leave Linux and the Pi with nothing. At +LED-wall pixel counts (a 256x256 wall is 65K pixels) the latency difference sits far below one +frame of the render loop, so coverage decides, not latency. + +**Scope: output only.** NDI is bidirectional and an `NdiReceiveEffect` is a real second feature, +but it is not this branch. + +## The licensing constraint, and what it dictates + +projectMM is GPL-3.0. The NDI runtime is proprietary and its licence requires a redistributor's own +EULA to carry NDI's terms forward, which GPL-3 forbids. **So projectMM must not redistribute it.** + +This is not a blocker; it is a design constraint projectMM has already met once. **Npcap** is the +precedent: proprietary, required for raw L2 on Windows, and +[platform_desktop.cpp:814](../../../src/platform/desktop/platform_desktop.cpp) resolves `wpcap.dll` +with `LoadLibrary` rather than linking it, declaring the five functions with pcap's own signatures +rather than including `pcap.h`. The user installs Npcap; the panel-card tutorial says so; the binary +builds and runs identically without it and reports raw send unavailable. + +NDI follows exactly that arrangement: + +- **The user installs the NDI runtime.** We bundle nothing and ship no SDK. +- **Resolve at run time** (`LoadLibrary` on Windows, `dlopen` elsewhere), never link. +- **Declare the needed functions with the SDK's own signatures**, never include its headers — so the + SDK is not a build requirement for CI or for any contributor. +- **Degrade visibly** when it is absent (ADR 0002, allocate-and-degrade): a status line, not a + failure. + +## Design + +### The platform seam + +NDI is a host capability, so it lives behind `platform::` like every other one, and the driver never +sees a `dlopen`. Following `hasNamedNetInterfaces`, each platform declares a `constexpr bool hasNdi` +in its own `platform_config.h`: **true on desktop, false on ESP32** (no runtime to load, and the +encode cost does not belong on a microcontroller). + +The seam is deliberately tiny — four functions, mirroring the pcap surface: + +``` +bool ndiAvailable(); // runtime present and loaded +bool ndiSenderOpen(const char* name); // create a named source +void ndiSenderClose(); +bool ndiSendFrame(const uint8_t* rgb, uint16_t w, uint16_t h, uint8_t fps); +``` + +`ndiSendFrame` takes a tightly-packed RGB buffer and the geometry; the platform layer converts to +NDI's frame struct. A stub in the no-NDI build returns false from everything, which is what keeps +`mm_tests` and every ESP32 target compiling untouched. + +### The driver + +`NdiDriver : DriverBase`, registered like the others in `main.cpp`, gated on `hasNdi` so it is +offered only where it can run. It follows `PreviewDriver` closely, which is the existing driver that +also turns the rendered buffer into a frame for a remote consumer: + +- **Controls**: `sourceName` (what appears in OBS's source list, defaulting to the device name), + `fps` (a ceiling, as in PreviewDriver), plus the inherited correction controls. +- **`tick()`**: rate-limit to `fps`, read the source buffer, hand it to `platform::ndiSendFrame`. + `MM_NONBLOCKING`, and no allocation in the tick — the RGB staging buffer is sized in `prepare()`. +- **`prepare()`**: size the staging buffer to the layer, open the sender, set the status. Re-runs on + a geometry change, exactly as `affectsPrepare` governs elsewhere. +- **Status**, which is the whole user-facing diagnostic: + - no runtime → `NDI runtime not installed` + - open failed → the reason + - running → `sending x at fps as ''` + +### What this deliberately does not do + +- **No audio.** NDI carries it; projectMM has no video-audio pairing to send. +- **No NDI HX / compression choice.** Ship the default; add a control only if a user needs it. +- **No receive.** Its own feature. + +## Steps + +1. **The platform seam.** Declare `hasNdi` in both `platform_config.h` files and the four functions + in `platform.h`. Implement the runtime load in `platform_desktop.cpp` beside the Npcap block, + reusing its shape. ESP32 needs no implementation (the flag is false). + *Tests:* the stub path — `ndiAvailable()` false with no runtime, and every call safe. +2. **`NdiDriver`.** The driver above, registered in `main.cpp` behind the `hasNdi` gate. + *Tests:* controls round-trip; a prepare with no runtime reports the status and does not crash; + the frame conversion is pinned against a known buffer. +3. **Docs.** A driver card in `docs/moonmodules/light/drivers.md`, and a short section in the + panel-cards tutorial's sibling — where to install the runtime per OS, exactly as §6.1 does for + Npcap. +4. **Bench.** Install the NDI runtime and OBS with the DistroAV plugin; confirm projectMM appears as + a source by name and that the wall's image arrives. **This is the gate: an output path is not + verified until a receiver shows the frames.** + +## Risks + +1. **Nothing is verifiable without a receiver.** Steps 1 and 2 can be written and unit-tested blind, + but "it works" requires step 4. The plan is ordered so the untestable claim comes last. +2. **The SDK's function signatures must be right without including its headers.** Getting one wrong + is a silent crash rather than a compile error — the same hazard the Npcap block carries, and the + reason its comment names the five functions explicitly. Take them from the SDK's public docs and + record where each came from. +3. **Frame format.** NDI wants a specific FourCC and stride; a mismatched stride shows as a skewed + image rather than an error. Pin the conversion with a unit test. +4. **CPU cost of the encode**, which runs on the desktop render thread. Measure before claiming a + frame rate; the `fps` ceiling is the mitigation. + +## Verification + +- `cmake --build build` (zero warnings) and `ctest` on a machine with **no** NDI runtime, proving the + degrade path is the default one CI sees. +- The same, on a machine **with** the runtime. +- OBS (DistroAV) on the same machine, then on a second machine, confirming the cross-machine claim + that chose NDI over Spout in the first place. +- The product owner's eyes on the OBS preview. diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 682053fc..c29dbc71 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -152,6 +152,33 @@ Origin: projectMM, on [MoonLight](https://github.com/ewowi/MoonLight/blob/main/s Detail: [technical](moxygen/PreviewDriver.md) + + +### NDI 🖥️ · video out + +Publishes the layer as an **NDI video source**, so OBS, Resolume, TouchDesigner, MadMapper or any other NDI receiver can pick projectMM up by name — on this machine or another one on the network. Where the Preview driver draws the lights for a person, this hands the same frame to a production tool as video. + +The grid's `physicalWidth` × `physicalHeight` becomes the frame; each light is one pixel, with the driver's own output correction applied so a receiver sees what the wall sees. + +**Desktop only.** The NDI runtime is a desktop library with no microcontroller build, so the driver is offered on macOS, Windows and Linux and not on an ESP32. + +**You install the runtime; projectMM never ships it.** projectMM is GPL-3.0 and the NDI runtime is proprietary, so it is loaded on demand and never bundled — the same arrangement as Npcap for the [Panel Card](#panelcard) driver. Without it the driver simply reports `NDI runtime not installed`; nothing else changes. + +- **macOS** — install [NDI Tools](https://ndi.video/tools/) (free). It ships the runtime inside its app bundles rather than system-wide, which projectMM knows to look for. A Resolume install also carries one. +- **Windows** — the [NDI Tools](https://ndi.video/tools/) or SDK installer puts `Processing.NDI.Lib.x64.dll` on the PATH. +- **Linux** — install the NDI SDK; projectMM looks for `libndi.so.5`, `libndi.so.6` and `libndi.so`. + +To watch the output you need a receiver: **NDI Video Monitor** (part of NDI Tools) is the simplest, and OBS gains an "NDI Source" via the [DistroAV](https://github.com/DistroAV/DistroAV) plugin. + +- `sourceName` — the name a receiver lists. Blank uses the device's own name. +- `fps` — frame-rate ceiling (default 30, 1–120). NDI paces the receiver from this. + +Status tells you where you are: `NDI runtime not installed` (install it), `could not create the NDI source` (the runtime is there but refused), or `sending x at fps` when it is live. + +Origin: projectMM, against NewTek/Vizrt's documented NDI C API + +Detail: [technical](moxygen/NdiDriver.md) + ## LED driver — details **Which driver?** diff --git a/docs/tutorials/panel-cards.md b/docs/tutorials/panel-cards.md index d67eeadb..7c58c588 100644 --- a/docs/tutorials/panel-cards.md +++ b/docs/tutorials/panel-cards.md @@ -6,6 +6,23 @@ You bought a panel receiving card, most likely a **ColorLight** one, which is th --- +## The short version + +If you have the card wired and projectMM running, this is the whole sequence. Every step has its own section below. + +1. **Layouts** → describe your wall ([§5.4](#54-describe-the-wall)) +2. **Effects** → pick an effect, so there is something to send +3. **Drivers** → press **+** → choose **PanelCard** ([§5.5](#55-add-the-driver)) +4. **`interface`** → desktop only: `en0` (macOS), `eth0` (Linux), or any part of the adapter name on Windows. Leave blank on an ESP32 ([§6](#6-from-a-desktop)) +5. **`format`** and **`firmware`** → match your card ([§7](#7-card-firmware-and-the-flicker)) +6. **brightness** → turn it up, in the Drivers card itself + +The status line on the PanelCard card tells you where you are: it should read a link speed and a packet rate. Anything else is in [§8](#8-when-it-does-not-light-up). + +**PanelCard missing from the driver list?** It is compiled only into firmwares for gigabit-capable boards (S3, P4, S31). Check the Firmware page for the variant you are running. + +--- + ## 1. What you actually bought An LED wall is normally driven by two boxes. A **sending card** (a PCI-E board in a PC, or a standalone unit) takes video in and puts a specialised signal on Ethernet. A **receiving card** sits in each cabinet, decodes that signal, and drives the panels over HUB75 ribbon cables. The card you bought is a receiving card. @@ -344,6 +361,8 @@ The mismatch is not subtle in one direction: leave a downgraded card on `v13 and | Panels in the wrong places | Same: the chain order is set on the card. | | Right image, wrong colors | `lightPreset` on the driver, which is where channel order and RGBW synthesis live for every driver. There is no separate color-order control here. | | Works on ESP32, not on desktop | Permission ([§6](#6-from-a-desktop)). The driver shows a warning that says so. | +| **PanelCard is not in the Drivers list** | The driver is compiled only into firmwares for gigabit-capable boards (S3, P4, S31), so a classic-ESP32 build does not offer it. Check the **Firmware** page for the variant and version you are actually running: an OTA upgrade keeps the installed variant, so a device first flashed with a different one keeps that one. Re-flash from the [web installer](https://moonmodules.org/projectMM/install/) to change variant. | +| **Link is up and packets are on the wire, but the wall stays dark** | The frames are leaving correctly, so the fault is downstream of the send. Check, in order: global **brightness** *and* the driver's own `localBrightness`, both of which must be up; a **layout** actually configured and an **effect** selected, since an empty layer sends valid black frames; and `format` / `firmware` matching the card ([§7](#7-card-firmware-and-the-flicker)). If all of that is right, remove the PanelCard driver and add it again: a re-add re-runs the bind and the geometry from scratch. | --- diff --git a/moonlive/effects/dot.mle b/moonlive/effects/dot.mle new file mode 100644 index 00000000..faa1d210 --- /dev/null +++ b/moonlive/effects/dot.mle @@ -0,0 +1,17 @@ +// Dot: one green light walking down each tube, everything else black. +// The lightest possible frame: two lit lights out of 288, so supply current is near zero. + +class DotEffect { + byte bpm = 30; + + defineControls() { + addControl("bpm", bpm, 1, 240); + } + + tick() { + fill(0, 0, 0); + for (x = 0; x < width; x = x + 1) { + setRGB(x + scale(beat(bpm, t), height) * width, 0, 255, 0); + } + } +} diff --git a/src/light/drivers/NdiDriver.h b/src/light/drivers/NdiDriver.h new file mode 100644 index 00000000..caa45944 --- /dev/null +++ b/src/light/drivers/NdiDriver.h @@ -0,0 +1,158 @@ +#pragma once +// NdiDriver — projectMM as an NDI video source. +// +// The rendered frame reaches OBS, Resolume, TouchDesigner or any other NDI receiver, on this +// machine or another. That last part is why NDI and not Spout/Syphon: a shared GPU texture cannot +// leave the box, and one NDI implementation covers Windows, macOS, Linux and ARM where Spout and +// Syphon are two platform-specific ones covering two of them. +// +// **Desktop only** (`platform::hasNdi`): the NDI runtime is a desktop shared library with no build +// for a microcontroller, and the per-frame conversion does not belong on one. +// +// **The runtime is the user's.** projectMM is GPL-3.0 and the NDI runtime is proprietary, so it is +// never bundled or linked — the platform layer resolves it on demand, exactly as it does Npcap for +// the panel-card driver. A machine without it runs normally and this driver says so in its status. +// The whole NDI surface lives behind `platform::` (see platform.h § NDI); no NDI type appears here. +// +// Prior art: the NDI protocol and SDK are NewTek/Vizrt's; this driver is our own code against the +// documented C API. The frame-pacing and status shape follow PreviewDriver, the other driver that +// turns the rendered buffer into frames for a remote consumer. +// Author: projectMM original + +#include "core/Control.h" +#include "core/ScratchBuffer.h" +#include "light/drivers/DriverBase.h" +#include "platform/platform.h" + +#include +#include + +namespace mm { + +/// Driver that publishes the layer as an NDI video source. +class NdiDriver : public DriverBase { +public: + static constexpr const char* kTags = "🖥️"; + + // ScratchBuffer registers with its owning module, so it takes *this — that registration is what + // puts these buffers in the memory report rather than leaving them untracked. + NdiDriver() : rgb_(*this), corrScratch_(*this) {} + + const char* tags() const override { return kTags; } + + void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; } + + void defineDriverControls() override { + // The name a receiver lists. Blank means the device's own name, which is what a user + // scanning OBS's source list expects to see. + controls_.addText("sourceName", sourceName, sizeof(sourceName)); + controls_.addControl("fps", fps, 1, 120); + } + + /// A geometry change resizes the frame; a name change re-creates the sender (NDI has no rename). + bool affectsPrepare(const char* name) const override { + return std::strcmp(name, "sourceName") == 0 || isCorrectionControl(name); + } + + void prepare() override { + release(); + if (!layer_) return; + + width_ = layer_->physicalWidth() > 0 ? layer_->physicalWidth() : 1; + height_ = layer_->physicalHeight() > 0 ? layer_->physicalHeight() : 1; + + if (!platform::ndiAvailable()) { + // Not an error: the feature is simply not installed, and the fix is a user action. + setStatus("NDI runtime not installed - see the docs", Severity::Warning); + return; + } + if (!platform::ndiSenderOpen(sourceName[0] ? sourceName : nullptr)) { + setStatus("could not create the NDI source", Severity::Error); + return; + } + // Size the staging off the hot path: one tight-RGB frame, plus a per-light correction + // scratch when the wiring emits more channels than the three NDI carries. + const size_t pixels = static_cast(width_) * height_; + if (!rgb_.resize(pixels * 3)) { + setStatus("out of memory for the NDI frame", Severity::Error); + return; + } + if (correction_.outChannels > 3) corrScratch_.resize(correction_.outChannels); + + open_ = true; + std::snprintf(statusBuf_, sizeof(statusBuf_), "sending %ux%u at %u fps", + static_cast(width_), static_cast(height_), + static_cast(fps)); + setStatus(statusBuf_, Severity::Status); + } + + void release() override { + if (open_) { platform::ndiSenderClose(); open_ = false; } + } + + void tick() MM_NONBLOCKING override { + if (!open_ || fps == 0 || !sourceBuffer_ || !sourceBuffer_->data()) return; + + // fps is a CEILING, as in PreviewDriver: NDI's own clock_video paces the receiver, this + // stops us building frames faster than we declared. + const uint32_t now = platform::millis(); + if (now - lastSendMs_ < 1000u / fps) return; + lastSendMs_ = now; + + const nrOfLightsType want = static_cast(width_) * height_; + const nrOfLightsType have = sourceBuffer_->count(); + const nrOfLightsType n = want < have ? want : have; + if (n == 0) return; + + // Pack to tight RGB for the seam. The correction (brightness, preset, white mode) is the + // per-driver output correction every driver applies, so an NDI receiver sees what the wall + // sees. Falls back to the raw channels when correction is not usable, matching + // NetworkSendDriver's guard rather than inventing a second policy. + if (rgb_.count() < static_cast(want) * 3) return; + uint8_t* dst = &rgb_[0]; + const uint8_t* src = sourceBuffer_->data(); + const uint8_t srcCh = sourceBuffer_->channelsPerLight(); + const uint8_t outCh = correction_.outChannels; + if (srcCh < 3) return; // a non-color buffer (DMX roles) has no frame to send + + // outChannels == 3 corrects straight into the destination; a wider wiring (RGBW and up) + // corrects into a one-light scratch and takes the first three, since NDI carries RGB only. + const bool wide = outCh > 3 && corrScratch_.count() >= outCh; + for (nrOfLightsType i = 0; i < n; i++) { + const uint8_t* s = src + static_cast(i) * srcCh; + uint8_t* d = dst + static_cast(i) * 3; + if (outCh == 3) { + correction_.apply(s, d); + } else if (wide) { + uint8_t* c = &corrScratch_[0]; + correction_.apply(s, c); + d[0] = c[0]; d[1] = c[1]; d[2] = c[2]; + } else { + d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; // passthrough, same fallback as NetworkSend + } + } + // A layer smaller than the frame leaves the tail from the previous send; blank it so a + // shrunk layout cannot show stale pixels. + if (n < want) std::memset(dst + static_cast(n) * 3, 0, + static_cast(want - n) * 3); + + platform::ndiSendFrame(dst, static_cast(width_), + static_cast(height_), fps); + } + + // Controls + char sourceName[32] = ""; // blank = the device name + uint8_t fps = 30; + +private: + Buffer* sourceBuffer_ = nullptr; + lengthType width_ = 0; + lengthType height_ = 0; + bool open_ = false; + uint32_t lastSendMs_ = 0; + ScratchBuffer rgb_; // tight RGB staging, sized in prepare() + ScratchBuffer corrScratch_; // one corrected light, when the wiring is wider than RGB + char statusBuf_[64]{}; +}; + +} // namespace mm diff --git a/src/light/drivers/PanelCardDriver.h b/src/light/drivers/PanelCardDriver.h index 9918f1a8..da2190f7 100644 --- a/src/light/drivers/PanelCardDriver.h +++ b/src/light/drivers/PanelCardDriver.h @@ -391,7 +391,21 @@ class PanelCardDriver : public DriverBase { } if (!platform::ethLinkUp()) { - setStatus("no ethernet link", Severity::Warning); + // On a host the same "link down" reads back for an unplugged cable AND for an `interface` + // that matches no adapter, which is the far more common mistake and is invisible from the + // status alone. Name the field in that case so the message carries its own fix; an ESP32 + // has one MAC and no such ambiguity, so it keeps the plain wording. + if constexpr (platform::hasNamedNetInterfaces) { + if (interface[0] == '\0') { + setStatus("no ethernet link - set 'interface' to a network adapter", Severity::Warning); + } else { + std::snprintf(statusBuf_, sizeof(statusBuf_), + "no ethernet link - cable, or no adapter matches '%s'", interface); + setStatus(statusBuf_, Severity::Warning); + } + } else { + setStatus("no ethernet link", Severity::Warning); + } framesReported_ = framesSent_; return; } diff --git a/src/main.cpp b/src/main.cpp index a6a1ce54..f09070fd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -126,6 +126,7 @@ // S31, which is RGMII gigabit, and the P4, where the 100 Mbit wire-time limit is worth measuring). // Everything else would carry ~2.8 KB of flash for a driver it cannot use, so it does not link it. #if defined(MM_PANEL_CARDS) || MM_LINKS_ALL_LED_DRIVERS +#include "light/drivers/NdiDriver.h" #include "light/drivers/PanelCardDriver.h" #endif #include "core/HttpServerModule.h" @@ -261,6 +262,9 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("HueDriver", "light/drivers.md#hue"); mm::ModuleFactory::registerType("NetworkSendDriver", "light/drivers.md#networksend"); mm::ModuleFactory::registerType("PreviewDriver", "light/drivers.md#preview"); + // NDI needs a desktop runtime to load, so the picker offers it only where one can exist. + if constexpr (mm::platform::hasNdi) + mm::ModuleFactory::registerType("NdiDriver", "light/drivers.md#ndi"); // Same firmware gate as the include above. #if defined(MM_PANEL_CARDS) || MM_LINKS_ALL_LED_DRIVERS mm::ModuleFactory::registerType("PanelCardDriver", "light/drivers.md#panelcard"); diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 203d0f3f..24cdbefa 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -96,6 +96,13 @@ constexpr bool hasEthernet = false; // True on desktop, false on a microcontroller with a single MAC, where the name would be a control // that does nothing. Drivers use it to hide the field rather than to choose behaviour. constexpr bool hasNamedNetInterfaces = true; + +// hasNdi — the host can be an NDI video source. True on desktop, false on every ESP32: the NDI +// runtime is a desktop shared library with no microcontroller build, and the per-frame encode does +// not belong on one. Gates NdiDriver's registration, so the type picker only offers it where it can +// actually run. The runtime itself is loaded on demand and is NOT redistributed (see +// platform_desktop.cpp), so this flag says "the platform supports it", not "it is installed". +constexpr bool hasNdi = true; // Some-IP-stack flag (WiFi OR Ethernet) — mirrors the esp32 config so shared code // (WLED audio sync, UDP interop) gates on "has network" uniformly. True on desktop // via the WiFi stubs (UdpSocket has a desktop implementation). diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 4f88dc7c..72eab065 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -9,6 +9,9 @@ #include #include #include +#ifndef _WIN32 +#include // dlopen/dlsym — the NDI runtime is resolved on demand, never linked +#endif #include // HostBus frame buffers — the memory-backed parallel bus #include #include @@ -2049,4 +2052,209 @@ bool irRead(uint16_t /*pin*/, uint32_t& /*codeOut*/) { return false; } void irStop() {} // no IR hardware on desktop bool irChannelReady(uint16_t /*pin*/) { return true; } // no channel to fail on desktop + +// --- NDI video output --------------------------------------------------------------------------- +// +// projectMM as an NDI source (contract + the licensing reason for this shape: platform.h § NDI). +// The runtime is resolved on demand and NEVER linked, bundled, or its headers included — the same +// arrangement as the Npcap block above, for the same GPL-3 reason. The user installs the NDI +// runtime; a machine without it builds and runs identically and reports the feature unavailable. +// +// The three declarations below are transcribed from the SDK's own public headers +// (Processing.NDI.structs.h and Processing.NDI.Send.h). Getting a field's type or ORDER wrong here +// is a silent crash or a skewed image rather than a compile error, because these are passed by +// pointer into a binary that was built against the real definitions. They are quoted verbatim in +// the plan (docs/history/plans) with their source, and must not be "tidied". +namespace { + +using NdiSendInstance = void*; + +// Processing.NDI.structs.h — NDIlib_video_frame_v2_t, verbatim field order. +struct NdiVideoFrameV2 { + int xres, yres; + int FourCC; // NDIlib_FourCC_video_type_e — an int-sized enum + int frame_rate_N, frame_rate_D; + float picture_aspect_ratio; + int frame_format_type; // NDIlib_frame_format_type_e + int64_t timecode; + uint8_t* p_data; + union { int line_stride_in_bytes; int data_size_in_bytes; }; + const char* p_metadata; + int64_t timestamp; +}; + +// Processing.NDI.Send.h — NDIlib_send_create_t, verbatim field order. +struct NdiSendCreate { + const char* p_ndi_name; + const char* p_groups; + bool clock_video, clock_audio; +}; + +// NDI_LIB_FOURCC('B','G','R','X') — X, not A: projectMM has no alpha to send, and an ignored +// alpha channel is exactly what the X variants mean. Little-endian packing, as the macro builds it. +constexpr int kFourCCBgrx = 'B' | ('G' << 8) | ('R' << 16) | (static_cast('X') << 24); +constexpr int kFrameFormatProgressive = 1; // NDIlib_frame_format_type_progressive + +using NdiInitFn = bool (*)(); +using NdiDestroyFn = void (*)(); +using NdiSendCreateFn = NdiSendInstance (*)(const NdiSendCreate*); +using NdiSendVideoFn = void (*)(NdiSendInstance, const NdiVideoFrameV2*); +using NdiSendDestroyFn= void (*)(NdiSendInstance); + +NdiInitFn ndiInit_ = nullptr; +NdiDestroyFn ndiDestroy_ = nullptr; +NdiSendCreateFn ndiSendCreate_ = nullptr; +NdiSendVideoFn ndiSendVideo_ = nullptr; +NdiSendDestroyFn ndiSendDestroy_ = nullptr; + +// Test capture (platform.h § NDI test seam). Recording is OFF unless a test turns it on, so a +// desktop build with a real runtime behaves exactly as it would in production. +struct NdiCapturedFrame { uint16_t w, h; uint8_t fps; std::vector rgb; }; +NdiTestMode ndiTestMode_ = NdiTestMode::Off; +std::vector ndiCaptured_; +std::string ndiCapturedName_; + +void* ndiLib_ = nullptr; +NdiSendInstance ndiSender_ = nullptr; +std::string ndiName_; // owned: NDIlib_send_create_t holds the pointer, not a copy +std::vector ndiFrame_; // BGRX staging, resized only on a geometry change + +/// The runtime's file name per platform, tried in order. The SONAME first, then the plain name a +/// manual install leaves; Windows resolves through PATH, which the NDI installer sets. +const char* const kNdiLibNames[] = { +#if defined(_WIN32) + "Processing.NDI.Lib.x64.dll", "Processing.NDI.Lib.x86.dll", +#elif defined(__APPLE__) + // NDI Tools for macOS ships the runtime INSIDE its app bundles rather than installing a + // system-wide dylib, so a plain name resolves nothing however complete the install is. The + // bundle paths are tried by name (verified to export the send API on a real NDI Tools install); + // `libndi_advanced` is the file NDI Tools ships, `libndi` the one bundled with Resolume. + "libndi.dylib", "/usr/local/lib/libndi.dylib", "/opt/homebrew/lib/libndi.dylib", + "/Applications/NDI Video Monitor.app/Contents/Frameworks/libndi_advanced.dylib", + "/Applications/NDI Studio Monitor.app/Contents/Frameworks/libndi_advanced.dylib", + "/Applications/NDI Discovery.app/Contents/Frameworks/libndi_advanced.dylib", + "/Applications/Resolume Arena/libndi.dylib", + "/Applications/Resolume Avenue/libndi.dylib", +#else + "libndi.so.5", "libndi.so.6", "libndi.so", +#endif +}; + +bool ndiLoad() { + if (ndiSendVideo_) return true; // already resolved + if (!ndiLib_) { + for (const char* name : kNdiLibNames) { +#if defined(_WIN32) + ndiLib_ = reinterpret_cast(::LoadLibraryA(name)); +#else + ndiLib_ = ::dlopen(name, RTLD_LAZY | RTLD_LOCAL); +#endif + if (ndiLib_) break; + } + } + if (!ndiLib_) return false; + auto sym = [](void* m, const char* n) -> void* { +#if defined(_WIN32) + return reinterpret_cast(::GetProcAddress(static_cast(m), n)); +#else + return ::dlsym(m, n); +#endif + }; + ndiInit_ = reinterpret_cast(sym(ndiLib_, "NDIlib_initialize")); + ndiDestroy_ = reinterpret_cast(sym(ndiLib_, "NDIlib_destroy")); + ndiSendCreate_ = reinterpret_cast(sym(ndiLib_, "NDIlib_send_create")); + ndiSendVideo_ = reinterpret_cast(sym(ndiLib_, "NDIlib_send_send_video_v2")); + ndiSendDestroy_ = reinterpret_cast(sym(ndiLib_, "NDIlib_send_destroy")); + if (!ndiInit_ || !ndiSendCreate_ || !ndiSendVideo_ || !ndiSendDestroy_) { + ndiSendVideo_ = nullptr; // treat a partial resolve as absent + return false; + } + // NDIlib_initialize returns false when the CPU is unsupported — a real "cannot use it" that + // must not read as "installed and working". + if (!ndiInit_()) { ndiSendVideo_ = nullptr; return false; } + return true; +} + +} // namespace + +bool ndiAvailable() { + if (ndiTestMode_ == NdiTestMode::ForceAvailable) return true; + if (ndiTestMode_ == NdiTestMode::ForceMissing) return false; + return ndiLoad(); +} + +bool ndiSenderOpen(const char* name) { + if (ndiTestMode_ == NdiTestMode::ForceMissing) return false; + if (ndiTestMode_ == NdiTestMode::ForceAvailable) { ndiCapturedName_ = (name && name[0]) ? name : "projectMM"; return true; } + if (!ndiLoad()) return false; + ndiSenderClose(); + ndiName_ = (name && name[0]) ? name : "projectMM"; + NdiSendCreate create{}; + create.p_ndi_name = ndiName_.c_str(); // the string must outlive the sender, hence ndiName_ + create.p_groups = nullptr; + // FALSE deliberately: clock_video makes send_send_video_v2 BLOCK to pace the caller, and this + // is called from the render thread which must never block. The driver already rate-limits to + // its fps control, so the pacing is ours to do. + create.clock_video = false; + create.clock_audio = false; // no audio is sent + ndiSender_ = ndiSendCreate_(&create); + return ndiSender_ != nullptr; +} + +void ndiSenderClose() { + if (ndiTestMode_ != NdiTestMode::Off) { ndiCapturedName_.clear(); return; } + if (ndiSender_) { ndiSendDestroy_(ndiSender_); ndiSender_ = nullptr; } + ndiFrame_.clear(); + ndiFrame_.shrink_to_fit(); +} + +bool ndiSendFrame(const uint8_t* rgb, uint16_t w, uint16_t h, uint8_t fps) { + if (!rgb || w == 0 || h == 0) return false; + if (ndiTestMode_ == NdiTestMode::ForceAvailable) { + // Record what the driver produced, tight RGB, exactly as handed over. + NdiCapturedFrame f{w, h, fps, {}}; + f.rgb.assign(rgb, rgb + static_cast(w) * h * 3); + ndiCaptured_.push_back(std::move(f)); + return true; + } + if (!ndiSender_) return false; + const size_t pixels = static_cast(w) * h; + ndiFrame_.resize(pixels * 4); // no-op once warm; the only allocation, never per frame + // RGB -> BGRX. The 4th byte is the ignored X, written once as 0xFF so a receiver that reads it + // as alpha sees opaque rather than transparent. + for (size_t i = 0; i < pixels; ++i) { + ndiFrame_[i * 4 + 0] = rgb[i * 3 + 2]; + ndiFrame_[i * 4 + 1] = rgb[i * 3 + 1]; + ndiFrame_[i * 4 + 2] = rgb[i * 3 + 0]; + ndiFrame_[i * 4 + 3] = 0xFF; + } + NdiVideoFrameV2 f{}; + f.xres = w; + f.yres = h; + f.FourCC = kFourCCBgrx; + f.frame_rate_N = fps ? fps : 30; + f.frame_rate_D = 1; + f.picture_aspect_ratio = 0.0f; // 0 = square pixels, which a light grid has + f.frame_format_type = kFrameFormatProgressive; + f.timecode = INT64_MAX; // NDIlib_send_timecode_synthesize: let NDI stamp it + f.p_data = ndiFrame_.data(); + f.line_stride_in_bytes = static_cast(w) * 4; + ndiSendVideo_(ndiSender_, &f); // synchronous, and clocked by clock_video above + return true; +} + +void setTestNdiMode(NdiTestMode mode) { + ndiTestMode_ = mode; + if (mode != NdiTestMode::ForceAvailable) { ndiCaptured_.clear(); ndiCapturedName_.clear(); } +} +size_t ndiTestFrameCount() { return ndiCaptured_.size(); } +uint16_t ndiTestFrameWidth(size_t i) { return i < ndiCaptured_.size() ? ndiCaptured_[i].w : 0; } +uint16_t ndiTestFrameHeight(size_t i) { return i < ndiCaptured_.size() ? ndiCaptured_[i].h : 0; } +uint8_t ndiTestFrameFps(size_t i) { return i < ndiCaptured_.size() ? ndiCaptured_[i].fps : 0; } +const uint8_t* ndiTestFrameData(size_t i) { + return i < ndiCaptured_.size() ? ndiCaptured_[i].rgb.data() : nullptr; +} +const char* ndiTestSenderName() { return ndiCapturedName_.c_str(); } +void ndiTestClearFrames() { ndiCaptured_.clear(); } + } // namespace mm::platform diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 722eab24..0a43c1f6 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -306,6 +306,10 @@ constexpr bool hasHeavyCompute = SOC_CPU_HAS_FPU; // choose between and a NIC-name control would do nothing. See the desktop config for the true case. constexpr bool hasNamedNetInterfaces = false; +// hasNdi — false on every ESP32: the NDI runtime is a desktop shared library with no build for +// these chips. See the desktop config for the true case. +constexpr bool hasNdi = false; + #if defined(CONFIG_ETH_USE_SPI_ETHERNET) && !defined(CONFIG_ETH_USE_ESP32_EMAC) constexpr bool hasEthW5500 = true; #else diff --git a/src/platform/platform.h b/src/platform/platform.h index 78876962..1de5deaf 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -461,6 +461,58 @@ uint16_t ethLinkSpeedMbps() MM_NONBLOCKING; // nullptr or "" to return to capture mode. bool ethBindRawInterface(const char* ifName); +// --- NDI video output ------------------------------------------------------------------------- +// +// projectMM as an NDI source: the rendered frame reaches OBS, Resolume, TouchDesigner or any other +// NDI receiver, on this machine or another. Gated by `hasNdi` (desktop true, ESP32 false). +// +// **The runtime is the USER'S, never ours.** projectMM is GPL-3.0 and the NDI runtime is +// proprietary with redistribution terms GPL cannot carry downstream, so it is resolved on demand +// (dlopen / LoadLibrary) and never linked, never bundled, and its headers are never included — the +// same arrangement, and for the same reason, as Npcap for raw Ethernet. A machine without it builds +// and runs identically; ndiAvailable() simply reads false and the driver says so. + +// Is the NDI runtime present and loaded? False when it is not installed, which is not an error — +// the driver reports it as a status. Loads on first call. +bool ndiAvailable(); + +// Create a named NDI source. `name` is what a receiver lists (a device name, typically). Returns +// false when the runtime is absent or creation fails. Replaces any sender already open. +bool ndiSenderOpen(const char* name); + +// Destroy the sender. Safe with none open, so a driver's release() need not track state. +void ndiSenderClose(); + +// Send one frame: tightly-packed RGB, 3 bytes per pixel, w*h*3 bytes. `fps` is declared to the +// receiver as the frame rate. Returns false when no sender is open. The platform layer owns the +// conversion to NDI's own frame layout, so no NDI type reaches the light domain. +bool ndiSendFrame(const uint8_t* rgb, uint16_t w, uint16_t h, uint8_t fps); + +// Desktop-only test seam, mirroring ethTestFrame* above: with no NDI runtime installed there is +// nothing to send into, and CI never has one, so the frames the driver produced are RECORDED +// instead. That is what lets the conversion be pinned — the geometry, the packing, the pacing — +// without the proprietary runtime, leaving the bench to confirm only that a receiver sees it. +#ifndef ESP_PLATFORM +// Force the runtime's apparent presence, overriding whatever is really installed. A developer +// machine may well HAVE a real NDI runtime (Resolume and NDI Tools both ship one), so a test that +// wants the not-installed path must be able to say so rather than rely on the machine lacking it. +// `true` also makes ndiSenderOpen()/ndiSendFrame() record instead of touching the real runtime. +enum class NdiTestMode : uint8_t { Off, ForceAvailable, ForceMissing }; +void setTestNdiMode(NdiTestMode mode); +inline void setTestNdiAvailable(bool available) { + setTestNdiMode(available ? NdiTestMode::ForceAvailable : NdiTestMode::Off); +} +size_t ndiTestFrameCount(); +// The recorded frame's geometry and its tight-RGB bytes (w*h*3), as the driver handed them over. +uint16_t ndiTestFrameWidth(size_t i); +uint16_t ndiTestFrameHeight(size_t i); +uint8_t ndiTestFrameFps(size_t i); +const uint8_t* ndiTestFrameData(size_t i); +// The name the sender was opened with, so a test can pin the blank-means-device-name rule. +const char* ndiTestSenderName(); +void ndiTestClearFrames(); +#endif + // Desktop-only test seam: the frames ethSendRaw() captured, so a host test can pin what the driver // put on the wire. Active whenever no raw interface is bound, which is the default and the only // mode an unprivileged test process ever sees. Count resets with ethTestClearFrames(); a frame diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a8529572..07857f21 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -71,6 +71,7 @@ add_executable(mm_tests unit/light/unit_AudioBands.cpp unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp unit/light/unit_NetworkSendDriver_packet.cpp + unit/light/unit_NdiDriver.cpp unit/light/unit_PanelCardDriver.cpp unit/light/unit_PanelCardDriver_packet.cpp unit/light/unit_WledAudioSyncPacket.cpp diff --git a/test/unit/light/unit_NdiDriver.cpp b/test/unit/light/unit_NdiDriver.cpp new file mode 100644 index 00000000..83373bde --- /dev/null +++ b/test/unit/light/unit_NdiDriver.cpp @@ -0,0 +1,193 @@ +// @module NdiDriver +// @also Drivers, Correction + +// The NDI driver's frames, pinned without the proprietary runtime. CI never has one installed, so +// the desktop platform RECORDS what the driver handed over (platform.h § NDI test seam) exactly as +// it records raw-Ethernet frames for the panel driver. These tests state what a receiver would see; +// the bench then only has to confirm that a receiver does see it. + +#include "doctest.h" +#include "light/drivers/NdiDriver.h" +#include "light/drivers/Correction.h" +#include "correction_presets.h" +#include "light/layers/Buffer.h" +#include "light/layouts/Layouts.h" +#include "light/layouts/GridLayout.h" +#include + +namespace { + +// A wall of `width` x `height`, wired as production wires it: the Layout gives the Layer its +// physical size and the driver reads that. The driver has no geometry of its own. +struct Wall { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + + Wall(uint16_t width, uint16_t height) { + grid.width = width; + grid.height = height; + grid.depth = 1; + layouts.addChild(&grid); + layouts.applyState(); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.applyState(); + } +}; + +void setUp(mm::NdiDriver& driver, mm::Buffer& source, Wall& wall, mm::nrOfLightsType count) { + REQUIRE(source.allocate(count, 3)); + mm::Correction correction; + mm::test::rebuildFromPreset(correction, 255, mm::test::PresetOrder::RGB); + driver.setLayer(&wall.layer); + driver.setSourceBuffer(&source); + driver.correctionForTest() = correction; + driver.applyState(); + mm::platform::ndiTestClearFrames(); +} + +// Paint light `i` a flat colour, so a test can name the bytes it expects back. +void paint(mm::Buffer& b, mm::nrOfLightsType i, uint8_t r, uint8_t g, uint8_t bl) { + uint8_t* p = b.data() + static_cast(i) * 3; + p[0] = r; p[1] = g; p[2] = bl; +} + +} // namespace + +// Without the runtime the driver is inert but SAFE, and says why. This is the state every machine +// without NDI installed is in, including CI, so it is the default path rather than an edge case. +TEST_CASE("NdiDriver reports a missing NDI runtime instead of failing") { + mm::platform::setTestNdiMode(mm::platform::NdiTestMode::ForceMissing); + mm::Buffer source; + mm::NdiDriver driver; + Wall wall(4, 2); + setUp(driver, source, wall, 8); + + driver.prepare(); + CHECK(std::string(driver.status()).find("not installed") != std::string::npos); + + mm::platform::setTestNowMs(1000); + driver.tick(); // must not crash, must send nothing + CHECK(mm::platform::ndiTestFrameCount() == 0); + mm::platform::setTestNdiAvailable(false); +} + +// The frame a receiver gets is the grid: one pixel per light, at the layer's physical size. +TEST_CASE("NdiDriver sends one pixel per light at the layer's size") { + mm::platform::setTestNdiAvailable(true); + mm::Buffer source; + mm::NdiDriver driver; + Wall wall(4, 2); + setUp(driver, source, wall, 8); + driver.prepare(); + + paint(source, 0, 10, 20, 30); + paint(source, 7, 40, 50, 60); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ndiTestFrameCount() == 1); + CHECK(mm::platform::ndiTestFrameWidth(0) == 4); + CHECK(mm::platform::ndiTestFrameHeight(0) == 2); + + const uint8_t* f = mm::platform::ndiTestFrameData(0); + REQUIRE(f != nullptr); + CHECK(f[0] == 10); CHECK(f[1] == 20); CHECK(f[2] == 30); // first light + CHECK(f[21] == 40); CHECK(f[22] == 50); CHECK(f[23] == 60); // eighth light, at 7*3 + mm::platform::setTestNdiAvailable(false); +} + +// The per-driver output correction is what makes a receiver see what the WALL sees: halving +// brightness must reach the NDI frame, not just the LEDs. +TEST_CASE("NdiDriver applies the driver's own brightness correction") { + mm::platform::setTestNdiAvailable(true); + mm::Buffer source; + mm::NdiDriver driver; + Wall wall(2, 1); + setUp(driver, source, wall, 2); + mm::Correction half; + mm::test::rebuildFromPreset(half, 128, mm::test::PresetOrder::RGB); + driver.correctionForTest() = half; + driver.prepare(); + + paint(source, 0, 200, 200, 200); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ndiTestFrameCount() == 1); + const uint8_t* f = mm::platform::ndiTestFrameData(0); + REQUIRE(f != nullptr); + CHECK(f[0] < 200); // dimmed, not passed through raw + CHECK(f[0] > 0); + mm::platform::setTestNdiAvailable(false); +} + +// fps is a CEILING: a second tick inside the interval must not produce a second frame, or a fast +// render loop would flood the receiver with frames it never asked for. +TEST_CASE("NdiDriver holds its frame rate to the fps ceiling") { + mm::platform::setTestNdiAvailable(true); + mm::Buffer source; + mm::NdiDriver driver; + Wall wall(2, 1); + setUp(driver, source, wall, 2); + driver.fps = 10; // one frame per 100 ms + driver.prepare(); + + mm::platform::setTestNowMs(1000); + driver.tick(); + REQUIRE(mm::platform::ndiTestFrameCount() == 1); + + mm::platform::setTestNowMs(1050); // inside the interval + driver.tick(); + CHECK(mm::platform::ndiTestFrameCount() == 1); + + mm::platform::setTestNowMs(1150); // past it + driver.tick(); + CHECK(mm::platform::ndiTestFrameCount() == 2); + mm::platform::setTestNdiAvailable(false); +} + +// A blank sourceName means the device's own name — what a user scanning a receiver's source list +// expects to find, rather than an empty entry. +TEST_CASE("NdiDriver names the source after the device when left blank") { + mm::platform::setTestNdiAvailable(true); + mm::Buffer source; + mm::NdiDriver driver; + Wall wall(2, 1); + setUp(driver, source, wall, 2); + driver.prepare(); + CHECK(std::string(mm::platform::ndiTestSenderName()).length() > 0); + + driver.sourceName[0] = 'W'; driver.sourceName[1] = 'a'; driver.sourceName[2] = 'l'; + driver.sourceName[3] = 'l'; driver.sourceName[4] = '\0'; + driver.prepare(); + CHECK(std::string(mm::platform::ndiTestSenderName()) == "Wall"); + mm::platform::setTestNdiAvailable(false); +} + +// A layer smaller than the frame must not leak the previous frame's pixels into the tail — a +// shrunk layout should go dark there, not show stale image. +TEST_CASE("NdiDriver blanks the tail when the layer is smaller than the frame") { + mm::platform::setTestNdiAvailable(true); + mm::Buffer source; + mm::NdiDriver driver; + Wall wall(4, 2); // an 8-pixel frame + setUp(driver, source, wall, 4); // but only 4 lights behind it + driver.prepare(); + + for (mm::nrOfLightsType i = 0; i < 4; i++) paint(source, i, 99, 99, 99); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ndiTestFrameCount() == 1); + const uint8_t* f = mm::platform::ndiTestFrameData(0); + REQUIRE(f != nullptr); + CHECK(f[0] == 99); // a real light + CHECK(f[12] == 0); // light 4 onward: blanked, not stale + CHECK(f[23] == 0); + mm::platform::setTestNdiAvailable(false); +}