Skip to content

feat(xmf): expose decoded VPX image planes and color metadata - #85

Merged
irvingouj@Devolutions (irvingoujAtDevolution) merged 13 commits into
masterfrom
feat/vpx-image-copy-to-bgra
Sep 25, 2026
Merged

irvingouj@Devolutions (irvingoujAtDevolution) merged 13 commits into
masterfrom
feat/vpx-image-copy-to-bgra

Conversation

@irvingoujAtDevolution

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Expose the planes and color metadata of decoded VPX images, so XMF consumers can read the pixels that XmfVpxDecoder produces.

Today a decoded XmfVpxImage exposes only its width and height (#84); XmfVpxImage_GetData is internal. That works for Gateway, which hands decoded images straight back to the encoder. A player also needs the Y/U/V planes to render. The first consumer is the experimental Cadeau backend for Avalonia.Controls.MediaPlayer in RDM, which converts I420 to BGRA itself. Consumer PR: Devolutions/RDM#17997.

Changes

Native

  • export read-only accessors next to GetWidth / GetHeight:
    • XmfVpxImage_GetFormat returns the libvpx vpx_img_fmt_t, or VPX_IMG_FMT_NONE when the image is unavailable.
    • XmfVpxImage_GetPlane(image, plane) and XmfVpxImage_GetStride(image, plane) return the plane pointer and row stride, or NULL / 0 for an invalid index.
    • XmfVpxImage_GetColorSpace and XmfVpxImage_GetColorRange return the libvpx vpx_color_space_t / vpx_color_range_t reported by the decoder: VP8 reports unknown / studio. When the image is unavailable they return VPX_CS_UNKNOWN and -1.

No existing signature changes and no conversion code is added. Each accessor is its own symbol.

Rust

  • xmf-sys loads the new exports and re-exports the libvpx format, plane, color-space and range constants at the crate root.
  • VpxImage::format(), color_space() and color_range() return the VpxImageFormat, VpxColorSpace and VpxColorRange newtypes.
  • VpxImage::i420_planes() returns VpxI420Planes { y, u, v } of VpxPlane, borrowed until the next decoder call. libvpx may leave the padding between rows uninitialized, so a plane is never exposed as one &[u8]:
    • rows() yields each row's width() pixel bytes as safe slices.
    • width(), height() and stride() describe the plane.
    • unsafe fn as_ptr() returns the first pixel for code that takes a base pointer and a stride, such as a C or SIMD converter. Its # Safety section lists what the caller must uphold: read only, read just the pixel bytes of each row, and stop before the image is dropped. It includes a worked example.
    • Debug prints the plane geometry, not its bytes.

.NET (Devolutions.Cadeau, which had no VPX decoder bindings)

  • XmfVpxDecoder: Decode, GetNextFrame, GetLastError.
  • Decode throws ArgumentNullException / ArgumentException for a null or empty frame. Native XmfVpxDecoder_Decode rejects those without setting lastError, so returning false would leave GetLastError stale.
  • GetNextFrame() is zero-copy, like XmfVpxDecoder_GetNextFrame and Rust's next_frame(). It returns an XmfVpxImage view with Width, Height, Format, ColorSpace, ColorRange, GetPlane and GetStride over the decoder's buffers. libvpx yields at most one frame per Decode, so a second call returns null.
  • XmfVpxImage.Copy() is the explicit copy. It returns an XmfVpxImageCopy with tightly packed managed Y/U/V planes that outlive the decoder. Like the Rust i420_planes(), it handles 8-bit I420; other formats are read through GetPlane / GetStride.
  • Memory safety:
    • Each image handle holds a reference on its decoder handle, so XmfVpxDecoder_Destroy waits until every image is released, even if the decoder is disposed, finalized, or its Handle is disposed directly.
    • After a later Decode, or once the decoder or its handle is disposed, the image's GetPlane, GetStride and Copy throw instead of reading reused buffers.
    • The decoder's own methods throw ObjectDisposedException as soon as either the decoder or its handle is disposed.
    • Decode, GetNextFrame, GetLastError and the image methods share one lock per decoder.
    • A pointer returned by GetPlane is valid until the next Decode while the image is alive; the XML docs say so.
  • SafeHandle ownership follows XmfBipBuffer. Builds for netstandard2.0/2.1 and net10.0.

Tests

  • rust/cadeau/src/xmf/vpx/tests.rs:
    • Synthetic-plane tests cover rows(), as_ptr(), padding and Debug; they pass under Miri with strict provenance.
    • A decode test checks a real 321×241 VP8 frame through both rows() and as_ptr().
  • Devolutions.Cadeau.Test vpx runs the .NET binding checks:
    • copies outliving the decoder
    • stale images
    • images and the decoder throwing after decoder.Dispose() and after decoder.Handle.Dispose()
    • Dispose waiting for an in-progress read
    • copy racing with disposal
    • an orphaned image surviving GC
    • one frame per Decode, then null
    • null and empty frames rejected without invalidating the last image
  • Devolutions.Cadeau.Test did not build on master because XmfBipBuffer.Handle became a SafeHandle. It now passes the buffer to the existing SetBipBuffer(XmfBipBuffer) overload.

Validation

  • Built the Windows XMF library locally and confirmed the five new symbols in the DLL export table.
  • cargo clippy is clean for cadeau (default and dlopen) and xmf-sys, except the existing encoder.rs warning.
  • cargo test -p cadeau passes: 6 unit tests and 3 doctests. It needs XMF_SEARCH_PATH pointing at the XMF build, and the decode test is skipped with dlopen.
  • Devolutions.Cadeau.Test vpx passes 9/9 against this DLL, three runs in a row. The disposed-handle check fails without the ObjectDisposedException fix.
  • Checked locally with a scratch .NET console, not committed:
    • frame 0 of VP8 1080p, VP9 1080p and odd-size VP8 321×241, taken through Copy(), is byte-identical to ffmpeg's raw decode
    • on a VP9 4:4:4 image, Copy() throws NotSupportedException while GetPlane / GetStride work
    • decoding on one thread while another calls GetNextFrame / GetPlane / Copy for 4 seconds produced only the expected stale-image exceptions
  • Decoded 1080p and 1440p VP8/VP9 WebM fixtures end to end through the RDM backend, converting planes in C#: about 6.2 ms/frame at 1080p, nonblank output.
  • CI does not run cargo test or the .NET test program yet.
  • Unrelated to this change: xmf's multi-threaded VP9 decode (libvpx 1.10, threads > 1) differs from single-threaded and ffmpeg output by ±1 on 40 of 3.1M bytes in the 1080p fixture. Single-threaded is bit-exact.

Notes

  • With dlopen, xmf-sys resolves every listed symbol at load time. It requires an XMF build that includes these exports.

Export XmfVpxImage_GetFormat, GetPlane, GetStride, GetColorSpace and GetColorRange so consumers can read the pixels of decoded images instead of only passing them back to the encoder. Each accessor is a separate read-only symbol, and no existing signature changes.

Load them through xmf-sys and expose VpxImage::format, color_space, color_range and i420_planes in the safe crate.
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) changed the title feat(xmf): add XmfVpxImage_CopyToBgra feat(xmf): expose decoded VPX image planes and color metadata Sep 24, 2026
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) marked this pull request as ready for review September 24, 2026 18:11
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) requested review from Benoît Cortier (CBenoit) and a balanced review from Copilot and removed request for Copilot September 24, 2026 18:11
Devolutions.Cadeau had no C# binding for XmfVpxDecoder or XmfVpxImage, so .NET consumers had to write their own P/Invoke to decode VP8/VP9. Add XmfVpxDecoder (Create, Decode, GetNextFrame, GetLastError, Destroy) and XmfVpxImage (width, height, format, planes, strides, color space and range) with SafeHandle ownership, following the XmfBipBuffer pattern.
XmfVpxImage exposed raw plane pointers into buffers owned by the decoder, so reading an image after a later Decode, or after the decoder was disposed or finalized, read stale or freed memory. GetNextFrame now copies the planes into managed arrays while holding a reference on the decoder handle, frees the native wrapper, and returns an immutable XmfVpxImage with Y, U and V planes, format and color metadata.

The copy handles 8- and 16-bit I420, YV12, I422, I440 and I444 and rejects other formats.
GetNextFrame copied every frame, unlike XmfVpxDecoder_GetNextFrame and the Rust next_frame, which both return a view of the decoder's buffers. It now returns that view again, and XmfVpxImage.Copy() makes the copy explicit, returning an XmfVpxImageCopy with managed Y, U and V planes.

The view holds its decoder so the decoder cannot be finalized under it, and records the decode generation. After a later Decode or after the decoder is disposed, GetPlane, GetStride and Copy throw instead of reading reused or freed memory.

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust LGTM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Decoder iteration, handle ownership, and invalid-input error reporting currently violate the managed API contracts.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
What changed in this PR

Exposes decoded VPX planes and color metadata across native, Rust, and .NET APIs.

Changes:

  • Adds native plane, stride, format, and color accessors.
  • Exposes safe Rust I420 plane slices.
  • Adds zero-copy and copied-image .NET decoder APIs.
File Description
rust/​xmf-sys/​src/​lib.rs Adds native accessor bindings.
rust/​cadeau/​src/​xmf/​vpx/​mod.rs Adds metadata and I420 plane APIs.
libxmf/​XmfVpxImage.h Declares exported image accessors.
libxmf/​XmfVpxImage.c Implements image accessors.
dotnet/​Devolutions.Cadeau/​XmfVpxImage.cs Adds managed image views and copies.
dotnet/​Devolutions.Cadeau/​XmfVpxDecoder.cs Adds managed VPX decoding support.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread dotnet/Devolutions.Cadeau/XmfVpxDecoder.cs
Comment thread dotnet/Devolutions.Cadeau/XmfVpxDecoder.cs
Comment thread dotnet/Devolutions.Cadeau/XmfVpxDecoder.cs Outdated
Share one plane-index check between GetPlane and GetStride, return -1 from GetColorRange when the image is unavailable because studio range is a real value, document the getters in the @brief/@param/@return style of XmfVpxDecoder.h, and include <stdint.h> for uint8_t explicitly.
The raw libvpx constants live in xmf-sys. The cadeau crate returns VpxImageFormat, VpxColorSpace and VpxColorRange newtypes instead of i32, and bundles each plane's data and stride into a VpxPlane. The plane helper is an unsafe fn with a documented safety contract and no underflow on empty images.
…ccess

An XmfVpxImage points into buffers that XmfVpxDecoder_Destroy frees. Each image handle now holds a reference on the decoder handle, so destruction waits until every image is released, even if the decoder is disposed, finalized or its Handle is disposed directly. Such images then report themselves unusable instead of reading freed memory.

Decode, GetNextFrame, GetLastError and the image's GetPlane, GetStride and Copy share one lock per decoder, so concurrent decoding cannot reuse buffers mid-copy. Copy() now handles 8-bit I420, matching the Rust i420_planes; other formats are read through GetPlane and GetStride.
Expose only initialized pixel rows in Rust and keep .NET decoder memory alive through Copy.

Add regressions for row padding, odd-sized VP8 frames, stale views and concurrent disposal. Publish xmf_sys::vpx and clarify the plane-index helper.
XmfBipBuffer.Handle became a SafeHandle, so the four recorder setups that passed it to SetBipBuffer(IntPtr) no longer compiled. Pass the XmfBipBuffer to the existing SetBipBuffer(XmfBipBuffer) overload instead.
VpxPlane only offered per-row slices, so a caller could not hand a plane to a C or SIMD converter that takes a base pointer and a stride without copying it. Add the unsafe VpxPlane::as_ptr, whose safety section spells out what the caller must uphold (read only, pixel bytes only, not after the image is dropped), plus width() and height() to walk the plane with it.

Debug now prints the plane geometry instead of every byte, and xmf_sys::vpx stays private again since its items are already re-exported at the crate root.
Disposing XmfVpxDecoder.Handle directly bypassed the decoder's disposed flag, so Decode and GetNextFrame kept working until the GC finalized every image that still referenced the native decoder. They now throw ObjectDisposedException as soon as either the decoder or its handle is disposed.
Move the checks from the separate Devolutions.Cadeau.Vpx.Test console into the existing test program, run with the vpx argument, and read the decoder lock through InternalsVisibleTo instead of reflection. Add checks that images and the decoder throw after decoder.Dispose() and after decoder.Handle.Dispose().
Native XmfVpxDecoder_Decode returns -1 for a null or empty buffer without setting lastError, so Decode returned false while GetLastError reported no error or a stale one. Decode now throws ArgumentNullException or ArgumentException before reaching native code, and a rejected frame leaves the last decoded image usable.

GetNextFrame now documents that libvpx yields at most one frame per Decode, so a second call returns null; a check pins that behaviour.
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) merged commit 7a25d83 into master Sep 25, 2026
9 checks passed
irvingouj@Devolutions (irvingoujAtDevolution) pushed a commit that referenced this pull request Sep 25, 2026
## 🤖 New release

* `xmf-sys`: 0.4.1 -> 0.4.2 (✓ API compatible changes)
* `cadeau`: 0.5.2 -> 0.5.3 (✓ API compatible changes)

<details><summary><i><b>Changelog</b></i></summary><p>

## `xmf-sys`

<blockquote>

##
[0.4.2](xmf-sys-v0.4.1...xmf-sys-v0.4.2)
- 2026-09-25

### Added

- *(xmf)* expose decoded VPX image planes and color metadata
([#85](#85))
</blockquote>

## `cadeau`

<blockquote>

##
[0.5.3](cadeau-v0.5.2...cadeau-v0.5.3)
- 2026-09-25

### Added

- *(xmf)* expose decoded VPX image planes and color metadata
([#85](#85))
</blockquote>


</p></details>

---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants