Skip to content

feature: Capture video from a USB grabber on the ESP32-P4 - #139

Closed
Gohnnyman wants to merge 3 commits into
MoonModules:mainfrom
Gohnnyman:feat/usb-video-capture
Closed

Gohnnyman wants to merge 3 commits into
MoonModules:mainfrom
Gohnnyman:feat/usb-video-capture

Conversation

@Gohnnyman

@Gohnnyman Gohnnyman commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What's new

  • USB capture - read USB video input as UVC webcam, MJPEG decoded by the P4's JPEG hardware. hdr declares the source curve (PQ / HLG), since MJPEG carries no metadata. Gated on the chip and both SoC capabilities; every other target links a failing stub.
  • White balance — balanceRed/Green/Blue trim a die down to correct a cast; whiteLevel trims the white die, which the RGB trims cannot reach. Folded into the same table fill as the curve, so the hot path stays one lookup.

Built for esp32p4rev3-eth-wifi, which links the UVC driver, and esp32s3-n16r8, which links stubs and no component. Verified on a P4: capture, decode and the trims all live.

Note

This PR is a part of #138 and #140
This PR Stacked on #138 , so that PR should be merged first

Gohnnyman and others added 3 commits October 12, 2026 16:21
Lights around a display now glow the color of the picture nearest them: a video source publishes one decoded RGB frame per tick, and an effect gives each light the mean of the rectangle mapping to it. The frame comes from a synthesized test pattern or a binary PPM, so the whole path is reviewable and testable on a desktop build. Capturing a real picture from an HDMI grabber over USB follows in its own change.

**Core**
- VideoService: two sources, a strict Netpbm P6 header parser, and a one-active-source seat so a service added or removed at runtime hands over rather than going dark
- VideoFrame: the frame contract, borrowing the producer's buffer for one tick
- Every frame carries its tone curve, sRGB here, so a consumer averages light rather than encoded bytes

**Light domain**
- AmbilightEffect: a per-light box filter averaged in LINEAR light, then encoded once per light, so a part-lit zone reads as bright rather than as a dim mush
- Positions reaching no LED are skipped, which on a border layout is most of the box, so a backlight strip costs its perimeter rather than its bounding box
- edgeDepth widens the outermost zones past the overscan junk; detectBlackBars moves them inside a letterbox, which edgeDepth cannot do
- Smoothing keeps 8.8 fixed-point state per channel, with snapAbove letting a cut land whole
- MappingLUT: hasDestination(), the one question the effect asks per position

**Tests**
- unit_VideoService: 13 cases over the header grammar, the seat election and the pattern's sweep rate
- unit_AmbilightEffect: 23 cases over orientation, coverage, the linear-light mean, smoothing in both directions, bar detection and the sparse-mapping skip, with a hollow-rectangle layout local to the file
- scenario_Video_source: add, configure and remove the service and its consumer under a running pipeline, which also pins the type registry, since the runner constructs every module through src/module_types.cpp

**Docs/CI**
- A Video card under core/services.md and an Ambilight card under light/effects.md, each with its details section
- screenshot_modules captures both

A strip is placed on a wall's edge pixels by the MoonLive library's backlight.mll, so this adds no layout. docgen: appendix lines 5617 to 5643, for the three new headers, which is the one rule that rises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The video source gains a third option: an HDMI grabber presenting itself as a UVC webcam, streaming MJPEG that the P4's JPEG hardware decodes. A console or a PC on the grabber's input now drives the lights directly. Every other target links a stub whose init fails, which the service reports as a status rather than an error.

**Platform**
- A UVC capture seam in platform.h: the device handle, the advertised format rows, the frame accessor and the drop counters
- platform_esp32_usbvideo.cpp: the UVC stream and the hardware JPEG decode across three threads, with the decode off the render tick and the newest frame winning when one is still pending
- Buffers belong to the platform for the life of one open, so a published frame borrows rather than copies, and the renderer drops it before deinit
- Bulk payload headers are stripped by a stride read off the first header, which usb_host_uvc leaves in when a device's payload is a multiple of the packet size
- Gated on the chip AND both SoC capabilities, since only the P4 has a High-Speed USB PHY and a JPEG decoder

**Core**
- VideoService: the usb source, the offered-format picker, staleMs, and hdr with the PQ and HLG curves, since MJPEG carries no HDR metadata and a grabber strips what the console sends
- A hotplug is noticed by one atomic load a second, answered by a tree rebuild on the thread that may open the device

**Tests**
- unit_VideoService: the capture-capability pair and the PQ curve recovering a measured source's linear ratio

**Docs/CI**
- The Video card gains its four capture controls; details gain the P4 requirement, format choice, HDR and staleness
- esp32: the usb_host_uvc component for the P4 alone, and a 2 KB control-transfer buffer, without which an MS2130 never enumerates
- .clangd points the ESP32 platform directory at a firmware compile database

Built for esp32p4rev3-eth-wifi, which links the UVC driver, and for esp32s3-n16r8, which links stubs and no component. docgen: appendix lines 5643 to 5669, four for VideoService's restored capture sections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An LED's three emitters are not equally efficient, so a neutral triple can render with a cast: on an SK6812 the green die runs hot and grey scenes come out tinted. Four trims correct it at the output, once per strip, rather than leaving every effect to guess.

**Light domain**
- balanceRed, balanceGreen and balanceBlue pull one die down to meet the others. Trim DOWN only, since there is no headroom above 255 and raising a channel clips rather than balances
- whiteLevel trims the white die, which the RGB trims cannot reach: a white phosphor is separate hardware, often brighter than the trio beside it
- Folded into the SAME table fill that applies the curve, as a linear pre-scale, so the hot path stays one lookup per channel and the curve still lands last
- briLut carries a row per trim, and the 16-bit table a driver allocates for fine channels carries the same rows, so a 16-bit output is balanced exactly as an 8-bit one is
- Amber and UV read an untrimmed row, being their own dies that an RGB trim says nothing about
- FixtureProfilesModule: profileHasWhiteDie(), narrower than profileHasSynthChannel, so a profile carrying amber but no white does not offer a slider that changes nothing

**Tests**
- Six cases: one trim leaves the others untouched, a trim does not reach the white die, the curve and a trim compose into one table, whiteLevel moves the white alone, and whiteLevel of 0 puts the die out rather than leaving it at the never-black floor

**Docs/CI**
- The Shared driver card gains the four controls, with the reasoning in its details section

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: MoonModules/MoonLight/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 951193f3-7331-44bf-81ab-f9235c5c0a23

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Gohnnyman Gohnnyman changed the title Feat: Capture video from a USB grabber on the ESP32-P4 feature: Capture video from a USB grabber on the ESP32-P4 Oct 13, 2026
@ewowi

ewowi commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

This fits the plan (linked in #138) as a picture source: a USB transport plus the P4's MJPEG decoder. Like #138, it stays open while I research the plan further; comments on the plan are very welcome.

(As in my comment on #138: one topic per PR, roughly 400 lines and ten files, so each can be reviewed properly.)

The color trim ("Trim each LED die…") is a separate idea from capture: output calibration, touching every board's LED output. I'm considering it on its own as well, and haven't decided on it yet. Two notes for when we get there: the brightness table is rebuilt 1280 times instead of 256 every 20 ms during the 5 s soft start, which filling the neutral row once and copying it would avoid; and the textbook form is a 3×3 correction matrix rather than three gains.

Please hold off on updating this PR until I've done the groundwork; then we'll look together at where the capture fits, so no effort goes into a shape that changes. For reference only, the review points so far on the capture part:

  • the Teensy 4.1 stubs (hasUsbVideo = false and the six videoCapture* functions, like the desktop's), since the Teensy merged meanwhile;
  • no 3 s freeze when no grabber is attached (skip the stream open until a device has enumerated);
  • the three USB tasks pinned to core 1;
  • the UVC payload header length derived from its flags (UVC 1.5 §2.4.3.3), so a grabber without timestamps works;
  • the flash cost measured, and capture linked only into the P4 images with room, as the SD card was done;
  • mm::formatTo instead of snprintf under MM_NONBLOCKING.

Good work on the capture seam itself: the opaque handle, the generation counter and formats learned at enumeration are exactly the shape we want.

@ewowi

ewowi commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

One more note for future PRs: please branch each one from current main rather than from your previous PR's branch. #139 and #140 each carry the commits of the PRs before them (#140 shows 34 files for its own ~160 lines), so a review can't see one topic on its own, and a change in an earlier PR ripples into every later one. Only when one topic needs several steps should a step build on the one below it; it then gets reviewed once that one merges.

ewowi added a commit that referenced this pull request Oct 14, 2026
A stream mapped by its sender (xLights, FSEQ, Network Send) now reaches the LEDs in one copy on any layout, halving the S3's tick at 64x64, and a modifier on its layer transforms it. A new Picture service and Picture effect show a test card on any layout, fitted, filled or stretched, ready for USB capture and a browser source to feed them. The installer resends settings the board refused, lets a mistyped WiFi password be corrected in place, and applies a device's defaults without reflashing.

KPI: 256lights | Desktop:2326KB | tick:6/9/7/2/12/8/274/7/14/14us(FPS:166666/111111/142857/500000/83333/125000/3649/142857/71428/71428) | src:331(84711) | test:243(55778) | functions-over:353
Commit: src +820/-143 lines | test +659/-106 lines | duplicated lines 3654 -> 3654 (+0)

**Core**
- PictureFrame and PictureService (G3): a picture source in Audio's pattern, the PM5544 test card drawn at any size, a Reader that holds the picture for one gather, and two pictures swapped by pointer for a source writing from its own task, degrading to one locked picture when the second does not fit (the groundwork #139's USB capture lands on).
- WiFi join window shared by NetworkModule and the Improv answer (JoinWindow.h): 10 s to join, plus 15 s for the lease once joined, so a slow router no longer drops a joined board to its access point, and a wrong password is reported in about 12 s instead of an installer timeout.
- Platform: wifiStaAssociated() on ESP32, desktop and the no-WiFi stub; the USB-Serial-JTAG console moved onto the driver, so S3 and P4 boards log on their native USB port in the standard build.
- ES7210 microphone codec beside the ES8311, behind a new MM_AUDIO_CODEC Kconfig gate (on for S31, P4 and the Waveshare ESP32-S3-RGB-Matrix image), so boards with an INMP441-style mic carry no codec library.

**Light domain**
- G5: Layer::wiringSource decides when a wiring-order frame goes straight to the output, and Drivers copies it there; on the S3 at 64x64 serpentine the tick fell from 5.83 to 2.96 ms. Under a static modifier the frame is placed by the layout's positions (Layer::placeByLayout), so a mirror or transpose transforms it.
- G6: ChannelDataSink holds, places and copies channel data for Network Receive and the new FseqPlayerEffect, a registered stub with file, loop and speed that reads no file yet (TouchMyLight's contribution).
- G4: PictureEffect samples the picture per light, fit, fill or stretch, point or area in linear light, from separable range tables refilled without allocating.
- Drivers composites through one composite() helper; BeatRipples walks the whole palette, its index wrapping with height and shifting per stone.

**UI**
- Installer: stop-and-wait APPLY_OP that reads the board's replies and resends a refused or unanswered document; a WiFi form that comes back with the reason after a failed join; "Apply device defaults now, without flashing"; an erase progress bar estimated at 3 s per MB.
- Tooltips for the picture and recorded-show emoji.
- Waveshare ESP32-S3-RGB-Matrix device model: ES7210 audio and BeatRipples as its default effect.

**Scripts/MoonDeck**
- fseq_play: writes and plays uncompressed FSEQ v2 over DDP, the reference sender for FSEQ.
- monitor_esp32 raises only the board on its port to Info, in the background, restores it on exit or SIGTERM, and opens the port without asserting DTR/RTS; collect_kpi raises only its own board and says when none is registered.
- screenshot_modules lets the device name the modules it adds, which the 15-character name limit refused for long type names.

**Tests**
- G5: pass-through on serpentine and 3D layouts, a mirror and a transpose on sender-mapped frames, both failing with the placement switched off.
- PictureService: the test card's landmarks, the generation, the empty frame, and a writer task racing a reader with two pictures and with one, both reading every picture whole (the one-picture case fails with its lock removed).
- PictureEffect: point against area by hand, fit, fill and stretch, a cube, no resampling without a new picture; FseqPlayerEffect: its output path and status; BeatRipples: the palette spread; NetworkModule: the join window and a rejoin after a dropout; installer reply parsing.

**Docs/CI**
- Streams plan: G3 to G7 built, G8 (the browser as a picture source) added with recording and baking as follow-ups; the how-to for adding a picture or channel-data source (G7); cards and images for Picture, FseqPlayer and the new Network Receive card.
- CLAUDE.md: every pull request is based on main, the one exception a step of a larger topic.

**Reviews**
- 👾 the background log-level raise could hold the port for about 75 s on an unreachable board: fixed, it stops with the monitor.
- 👾 collect_kpi skipped a board with no recorded port silently: fixed, it says so.
- 👾 wiringSource computed per frame: kept, since a state document that changes mapped_by re-prepares only the effect, so a cached answer would go stale.
- 👾 any Improv error aborted the defaults push: fixed, only "unknown RPC" means unsupported, the rest is resent.
- 👾 the two-picture writer path has no writer yet: accepted, it is #139's groundwork, built on request before this commit; the double generation bump in prepare fixed.
- 👾 FseqPlayerEffect's default dimensions override and PictureEffect's unexplained include: fixed.
- 🐇 CLAUDE.md's generated-file wording, the plan's pacing paragraph, a long test comment, an unsynchronized staging read in a test: fixed; the repo-health coverage count: not a defect, marker and summary share the n >= 4 threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ewowi

ewowi commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

The groundwork for USB capture is now on main (#141), so here is what to build on.

What is set up

  • PictureService is the picture source, and each source is an option of its source setting. A source that runs in its own task, as a capture does, writes like this:
    • beginWrite() hands it the back picture, and endWrite() swaps it to the front, so effects never see half a picture.
    • Short of memory, the service falls back to one picture locked while written, and says so in its status.
  • PictureEffect samples whatever the service publishes onto any layout.

What to do
A new PR on current main that adds USB capture as a source:

  • the option in Source, marked as running in its own task in kRunsInTask;
  • the capture task decoding each MJPEG frame into beginWrite(), then endWrite();
  • the UVC transport and the MJPEG decoder behind a platform seam in src/platform, with stubs on the desktop and the Teensy.

The review points from my earlier comment still apply: no 3 s freeze without a grabber, the USB tasks on core 1, the UVC payload header length from its flags, the flash cost measured, and mm::formatTo under MM_NONBLOCKING.

This PR can then close. The color trim is a separate idea and a separate PR whenever you want to take it up.

Where to read

For the PR: one topic, based on current main with only its own commits, max 400 changed lines and ten files (the rules).

Your capture seam was the right shape from the start, and it now has a place to land.

@ewowi

ewowi commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Thanks @Gohnnyman, closing this one: its capture half landed as the groundwork in #151, and the USB transport follows in the UVC PR you announced there. The color trims (balanceRed/Green/Blue, whiteLevel) are a separate topic, output calibration for every board, so they'd come back as a PR of their own if we take them up; I've noted them in our backlog.

@ewowi ewowi closed this Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants