feat: add H.264 hardware decoding - #26
Conversation
|
I laughed when I read Thanks for submitting it though, cool stuff. I had a rough implementation of decoding working a long time ago but decided to keep the scope relatively smaller by focusing only on encoding. That said, I'm completely open to adding decoding support. It will probably take some time to go over it. For full transparency: I'll probably review the public API and use LLMs to review the internals. |
|
Take your time @hgaiser - and feel free to be strict on demands/wants! Also a random 🎉 - pixelforge decoding works under Windows as well :P
|
|
@hgaiser Sorry for the bump, I'd just like to know when you believe you'll have the time and energy to look atleast into surface-level changes (i.e. the public/dev-facing API) 🤔 It doesn't have to be reviewed all at once, see what immediately "sticks out" or even irks you and I'll make the changes 🫡 I can also rebase against current |
|
No worries, fair question. I started looking into this yesterday, it's high on my to-do list :) I'll try to get to it "soon" 🙄 |
|
I was wondering: would it make sense to have a similar asynchronous behavior here, comparable to the encode pipeline? It could look something like: // Producer thread: feeds packets, never blocks on output
let producer = std::thread::spawn(move || {
for au in access_units(&stream) {
decoder.decode(au, pts).unwrap(); // just accepts the packet
}
decoder.flush().unwrap(); // signal end of stream
});
// Main/consumer thread: awaits decoded frames
loop {
match decoder.download().await {
Ok(Some(frame)) => render(frame),
Ok(None) => break,
Err(e) => return Err(e.into()),
}
} |
209025c to
3674057
Compare
|
WHEW, okay I made sure to go through and pretty much refactor the PR to be more like the encoding-side.. thank heck for LLM help on this one 😅 Not to mention, the encode-API-like pipelining does give benefits, since I got no RTX 2060 anymore I temporarily rented a cloud server with L4 for testing and verifying, also found out that Intel Arc has Vulkan Video support, but behind sneaky Results on decode speeds with pipelining API'fication:
(Arc sees almost triple speeds, whew) So basically, H.264-only since adding other codecs would bloat this way too much, but I've verified with all 3 major GPU brands and different hardware configs. Decode API is now more codec-agnostic as well to make future codec-support work less painful. @hgaiser - it's a lot, but I tried my best to test various cases and hardware 👍 |
|
Those results look promising :o Regarding the new changes, they look much better, and closer to what the encode pipeline currently does. I have my questions about the I think we might need to split the decoder in a sink and a source, since a packet from the network could, in theory, lead to N frames (where // Split is optional, but necessary for multi threaded applications.
let (mut sink, mut source) = Decoder::new(context, DecodeConfig::h264())?.split();
// Producer (optionally a thread): feed only, blocks only on backpressure
for chunk in network_or_file {
sink.decode(chunk, pts)?; // owns framing; buffers partial pictures
}
sink.finish()?; // EOF: drains reorder buffer, unblocks consumer
// Consumer (optionally a thread) : pull frames as they're ready
while let Some(frame) = source.next_frame().await? {
render(&frame); // zero-copy GPU image
// drop(frame) -> storage returned
}There are currently three ways to get decoded frames ( I would like |
Good to hear it's more to what you want 🙂 I didn't even think of that stream-split syntax, that'd actually work out super-well for decoding here, will get to that ASAP! As for the As for Alas, good input, will get to work 👍 |
|
Ah looks like there's a new Rust version out already, that's why I don't get those clippy issues locally 😅 |
Yay, CI :) |
|
Other than just making the changes requested, there was also room for improvements with the new stream syntax. 1 internal (in-GPU) copy is needed without So, with E: Also the decode ordering logic for frames (since B-frames are fun, getting in-between P and I frames in any order), is now done with direct slot juggling rather than holding separate copies. There may be more optimizations that are possible here, but I'd leave them for future PRs when support for other codec decoding is added? E2: Hm.. since we now return single decoded image from pixelforge, any application needing to split it into "multiple per-planar resources" wouldn't have it easy.. gonna see how doable adding |
|
Alright, verified with Nestri on H.264 1080p60 stream (9060 XT encoding using pixelforge, A310 with debug-env vars enabled so pixelforge Vulkan decoding works on it's side) I'm glad you pushed back on the earlier API @hgaiser - because holy heck I can't even begin to explain how good the latency feels now 😄 If you can try this PR in some experimental way with moonlight, give it a go 👀 |
|
The API looks good 👍 Was there anything you still wanted to do? |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The decode surface spoke H.26x: `access_units` parses Annex B NAL units and slice headers, which AV1 (OBU temporal units) and VP9 (superframes) do not have, and the frame fields named H.264 concepts directly. - Replace the free `access_units` function with `Decoder::split`, dispatched through `DecoderApi` so each codec supplies its own framing and the caller never restates the codec. The H.264 splitter moves to `decoder::h264`. - Rename `DecodedFrame::poc` to `display_order` and document it as the codec's own ordering value (POC, AV1 order hint). - Rename `DecodedFrame::is_idr` to `is_keyframe`, matching the encoder's `EncodedPacket::is_key_frame`. - Neutralize the decode docs: "coded frame" instead of "access unit". Verified against ffmpeg on RADV GFX1200: base, bframes, multislice and zerolatency all decode byte-identical to `ffmpeg -pix_fmt nv12`. That machine has no `VK_LAYER_KHRONOS_validation`, so the run did not exercise the validation layers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frames were valid only until the next `decode`/`flush` call: the reorder pool reclaimed every handed-out image at the start of the next batch. That contract cannot survive a decoder that runs ahead of its consumer, which is what the asynchronous decode API needs. - Split the freshly decoded picture (`DecodedPicture`, internal, lives in a DPB slot) from the frame handed to the caller (`DecodedFrame`, owns its storage). - `DecodedFrame` now carries a `FramePin` that releases its pool image on drop, through a `ReleaseQueue` so the frame can be dropped on any thread. The pool reclaims released images the next time it needs one. - Pool images are reused when their frame is dropped rather than at a fixed point in the decode loop, so a display-order frame stays valid as long as the caller holds it. `DecodedFrame` is no longer `Clone`. - Warn instead of silently dangling if the decoder is dropped while frames are still alive. Decode-order frames still borrow the DPB image directly and keep the old validity rule; pinning those is the next commit. Verified on RADV GFX1200: display-order output for base, bframes, multislice and zerolatency is byte-identical to `ffmpeg -pix_fmt nv12`. That machine has no `VK_LAYER_KHRONOS_validation`, so the run did not exercise the validation layers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… copy Decode-order output handed the caller the decoder's own DPB image and hoped they were done with it before the next `decode` call. Now the slot is pinned for as long as the frame lives, and the session reserves spare slots so decoding can continue while the caller holds frames. - `DecodeConfig::with_output_depth` (default `DEFAULT_OUTPUT_DEPTH` = 2, the encoder's pipeline depth) reserves that many DPB slots beyond what the stream's reference count needs. - `SlotPins` tracks which slots handed-out frames hold. `DecodeDpb` skips them when allocating, however the reference rules mark them, and `decode` blocks on the condition variable when every slot is busy but a pinned one could still come back. Releases are eager, since a decode may be waiting. - Frames fall back to a pool copy when pinning is impossible: a driver without `DPB_AND_OUTPUT_COINCIDE` (the picture lands in one shared output image), or a device whose DPB slot limit leaves no room to spare. - Session creation now asks for `max_active_references` explicitly instead of `slot_count - 1`, so the output reservation does not inflate the active reference count, and reports an error if the device cannot supply what the stream needs. Verified on RADV GFX1200 (7 DPB slots, 2 reserved for output, coincide=true, so the zero-copy path is the one exercised): display-order output for base, bframes, multislice and zerolatency stays byte-identical to `ffmpeg -pix_fmt nv12`, decode-order output is unchanged from before this commit, `decode_adopted` matches on its own device, and encode-to-decode roundtrip does 30 frames in and 30 out. Note: nestripc-1 has no `VK_LAYER_KHRONOS_validation`, so none of these runs exercised the validation layers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`enable_validation` loaded the layer but registered no messenger, so the layer had nowhere to report and its findings were silently dropped. Enabling validation looked like it worked while verifying nothing: a run with a real VUID violation would have been indistinguishable from a clean one. Create a `VK_EXT_debug_utils` messenger alongside the layer and map its severities onto tracing levels (error/warn as-is, layer info at debug, verbose at trace), so `RUST_LOG` controls the volume. The callback always returns `VK_FALSE`, leaving the offending call to proceed. Contexts adopted from a caller's instance get no messenger, since reporting there belongs to whoever created the instance. Falls back with a warning when the extension is unavailable, the same way a missing layer already does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`decode` recorded one picture into a single shared command buffer, submitted it and waited on the fence before returning, so the GPU sat idle while the CPU parsed the next picture and vice versa. It now records, submits and returns a `DecodeFuture`, mirroring `Encoder::encode`. - `decoder::pipeline` holds `DECODE_PIPELINE_DEPTH` (2) slots, each with its own coded-data staging buffer, decode command buffer and fence, plus a transfer command buffer and fence for the reorder copy. A slot is busy from submit until its work completes, which is what makes its buffers safe to record over. - Two timeline semaphores: decodes chain on one to stay in DPB order, and each reorder copy waits on both its own decode and the previous copy. Chaining the copies is what lets one fence stand for a whole batch. - A completion thread waits on fences, accumulates each call's frames and resolves its future. Only the calling thread touches queues and timelines. - `download` and `copy_frame_to_planes` drain in-flight decodes first when the frame borrows a DPB image. Those copies move the image's layout, and later pictures may still be reading it as a reference; the transfer queue has no dependency on the decode queue otherwise. Pool-backed frames skip the drain. - `SlotSync` moves from `encoder::pipeline` to `video`, shared by both directions rather than written twice. `DecodeConfig::output_depth` now also bounds how many futures a decode-order caller should keep pending, since an unresolved future holds frames and each frame holds a DPB slot. Documented on the setter and in `examples/decode_h264`, which keeps two batches in flight. Measured on RADV GFX1200, 600 frames of 320x240 with no readback, best of three: display order 1992 -> 2365 fps, decode order 1799 -> 2005 fps. Output is unchanged: display order stays byte-identical to `ffmpeg -pix_fmt nv12` on base, bframes, multislice and zerolatency, decode order matches the pre-pipeline bytes, and `verify_planes`, `decode_adopted` and the encode-to-decode roundtrip all pass with the validation layers enabled and silent. Adopted devices must now enable `timelineSemaphore` as well as `synchronization2`; documented on `build_from_existing_decode` and `DeviceRequirements`, and enabled in both adopting examples. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Intel's ANV decoded almost nothing: every picture came back as flat gray with a few macroblocks in the top-left corner, no validation error, no driver message. ffmpeg's Vulkan decoder is byte-exact on the same driver, which ruled out the driver being incapable. The difference is the start code. The leading zero byte of `00 00 00 01` is legal Annex B and RADV accepts a slice offset pointing at it, but ANV does not recover from it. ffmpeg emits three bytes for exactly this reason. Also log the negotiated bitstream alignments in the session line, which is what made it possible to rule out an alignment mismatch (ANV asks for 32/1, and the range already starts at 0). Verified byte-identical to `ffmpeg -pix_fmt nv12` on both GPUs now, display and decode order, validation layers enabled and silent: - Intel Arc A310 (ANV, needs `ANV_DEBUG=video-decode,video-encode`): was wrong on all four streams, now correct. - Radeon RX 9060 XT (RADV): unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both directions now run a pipelined submit loop, which made the genuinely common parts visible. Rather than a second copy of each, they move to `video` alongside `SlotSync`: - `TimelineChain` replaces the hand-rolled semaphore/next/last triple in three places (encode submissions, decode submissions, reorder copies). It separates reserving a signal value from committing it, because advancing the chain for a submit that failed would leave every later submission waiting on a value nothing signals. The encoder had that ordering right and the decoder now cannot get it wrong. - `create_command_pool`, `allocate_command_buffers` and `create_fence` replace four hand-rolled copies of the same Vulkan boilerplate across both pipelines and both directions' setup. `decoder/codec.rs` had grown to 1815 lines, well past what AGENTS.md asks for, and mixed three concerns. Split along its seams, and renamed to `common` so `codec` is free for the codec trait that mirrors `encoder::codec`: - `decoder/common.rs` (563): the video session and the per-decoder state. - `decoder/frames.rs` (550): frame ownership, pool images, DPB slot pins, and display-order reordering. - `decoder/transfer.rs` (716): readback and copies, which share the property of running on the transfer queue rather than the decode queue. No behaviour change, and verified as such: byte-identical output on both GPUs, display and decode order, validation layers enabled and silent. Intel Arc A310 (ANV) and Radeon RX 9060 XT (RADV) agree with each other and with `ffmpeg -pix_fmt nv12` on all four streams. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crate described itself as encode-only, with decoding listed as a TODO. - `lib.rs`: decoding section with a worked example, `Decode` column in the codec table, the pipelined behaviour and the zero-copy decode-order option, and the `ANV_DEBUG` note Intel Arc needs. Re-export the decoder types at the crate root next to the encoder ones. README regenerated from it. - CHANGELOG: an Unreleased section covering the decoder, the async pipeline, the validation messenger and the new `timelineSemaphore` requirement for adopted devices. - AGENTS.md: the documented `cargo readme` invocation did not reproduce the committed README (`--no-indent-headings` flattens every section to `#`), so regenerating it churned every heading. Corrected, and added how to verify a decode against ffmpeg, the `PIXELFORGE_VALIDATION` and `RUST_LOG` pairing, the warning that a missing validation layer makes silence meaningless, and the note that `testdata/test_frames.yuv` is an unfetched LFS pointer. Also log the reported decode capability flags, which is how the coincide and layered-DPB branches a device actually takes can be told apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runners have no Vulkan driver at all, and the tests treated that as a failure: one asserted the error was not an instance-creation error, precisely the error you get with no driver. They were written to avoid passing vacuously, which is the right instinct, but a missing driver is a fact about the environment rather than something the test can act on. Distinguish the three cases explicitly: a video-capable device (the `supports_decode` contract must hold), a driver without video queues (a typed `NoSuitableDevice`), and no driver at all (skip). Match on the error variant instead of substring-matching its message, and keep the strictness available through `PIXELFORGE_REQUIRE_VULKAN=1`, which turns a skip back into a failure on a machine that is supposed to have a driver. Verified both ways by pointing the loader at a nonexistent ICD: the tests skip and pass, and fail loudly with `PIXELFORGE_REQUIRE_VULKAN=1`.
…th CI `cargo doc` runs with `-D warnings` in CI, and rustdoc rejects an explicit link target whose label already resolves to it. `OutputOrder` is re-exported at the crate root, so the label alone resolves. Also point AGENTS.md at the exact README command CI compares against (`diff --brief <(cargo readme) README.md`) rather than a variant of it, so regenerating locally cannot disagree with the check.
Display-order output copied every picture into a pool image so it could outlive the DPB slot it was decoded into. Pinning the slot achieves the same thing without the copy: the codec allocates around a pinned slot, so a picture can wait its turn in display order in place, and the pin then passes to the DecodedFrame the caller receives. That makes zero-copy the only output path, so OutputOrder goes away. Decode order was the zero-copy mode; now every mode is. The frame pool survives as the fallback for the two cases a picture cannot stay put: a device with no spare DPB slots for the stream, and a driver that decodes into a distinct output image the next picture overwrites. The DPB is sized accordingly: references, the current picture, then reorder_depth slots for pictures awaiting their turn and output_depth for frames the caller holds. Reorder depth comes from the stream's VUI and is now resolved in `activate`, alongside the slot budget it feeds. Verified byte-identical to `ffmpeg -pix_fmt nv12` for base, bframes, multislice and zerolatency on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310), validation layers enabled and silent on both. Throughput, 300 frames of 1080p, interleaved A/B medians: Intel Arc gains 4%, AMD loses 18%. The AMD cost is not new, it is what the zero-copy path already cost there: on this branch's parent, decode-order output measured ~880 fps against display order's ~1080 fps on the same stream. Making zero-copy the only path means display-order callers now pay it too. CPU submit time drops (9ms against 15ms per 300 frames, no copy to record); the difference is GPU-side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A DecodedFrame is the decoder's own image, but nothing could read it in a shader: the picture images carried VIDEO_DECODE_DPB, VIDEO_DECODE_DST and TRANSFER_SRC, so the only legal thing a renderer could do with a frame was copy from it. Zero-copy output that still forces a copy at the far end is only half the point. Picture images now ask for SAMPLED as well. It is a request, not an assumption: the format query runs with SAMPLED first and falls back to the plain usage if the device reports no sampleable picture format, and DecodedFrame::sampleable says which happened. Both RADV and ANV report every 4:2:0 format as sampleable, so the fallback is untested in practice. Pool images, being ordinary images with no video profile, are sampleable on any driver. Sampling from a renderer also means a third queue family touches the image, which is undefined unless it was shared with that family. DecodeConfig::with_consumer_queue_family names it and adds it to each picture's sharing set. Left unset, sharing is unchanged. SAMPLED on a multi-planar image makes vkCreateImageView demand a VkSamplerYcbcrConversion, which a video picture resource must not have. The decoder's own views now declare the narrower usage they actually need via VkImageViewUsageCreateInfo, which satisfies both rules at once. Validation caught this; without the messenger added earlier in this branch it would have been silent. Verified byte-identical to `ffmpeg -pix_fmt nv12` for base, bframes, multislice and zerolatency on AMD (RADV) and Intel (ANV), validation enabled and silent on both. Throughput on AMD is unchanged (1080p B-frames, interleaved medians: 872 fps against 878 without SAMPLED). What this does not yet include is an example that actually samples a frame through a ycbcr conversion; the capability is declared and the images are created, but the render path is exercised no further than that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`stage_slices` copies a picture's slices into the current pipeline slot's
staging buffer, and `ensure_bitstream_capacity` may destroy and recreate
that buffer outright. Both ran before `begin_decode_commands`, which is
what waits for the slot's previous submission to complete. So a picture
could overwrite coded data the GPU was still reading, and could free the
buffer under it.
`ensure_bitstream_capacity` already documented the invariant it needed
("the slot must already be free, the caller waits before recording"); the
caller simply waited afterwards instead.
The race needs the CPU to run a full pipeline depth ahead, so feeding one
coded frame per `decode` call mostly loses it: every stream this branch
was verified against decoded byte-identically on three vendors. Feeding
several frames per call loses it every time, and produced pictures with
no resemblance to the source. `Decoder::decode` has always documented
that it accepts any number of complete coded frames, so this was
reachable through the public API as it stands.
Staging after `begin_command_buffer` is safe: it is a host write to mapped
memory, ordered only against the submission that reads it.
Verified with the whole of tests/data/base.264 fed in a single `decode`
call, which garbles every frame before this change and is byte-identical
to `ffmpeg -pix_fmt nv12` after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reorder copy reads a picture out of its DPB slot on the transfer queue. A slot holding a non-reference picture is released the moment that picture is finished, so the very next decode can be handed the slot a copy is still reading, and nothing ordered the two. The copy then returned a picture with a later frame's contents. Copies already waited on the decode that produced their source; this adds the other direction, so a decode waits on the previous copy as well. That is enough, because copies are chained to each other, so waiting on the last one waits on all of them. Reachable only when a picture's slot is reused while its copy is still in flight, which needs a non-reference picture and a CPU running ahead. In practice that means B-frames or multi-slice pictures with several coded frames fed per `decode` call: bframes.264 and multislice.264 both returned roughly one wrong frame in six, while base.264 and zerolatency.264 were unaffected. The wait costs nothing when no copy has been submitted, which is the common case now that pictures are pinned in place rather than copied: a timeline wait on a value already signalled returns immediately. Verified byte-identical to `ffmpeg -pix_fmt nv12` for all four test streams on AMD (RADV) and Intel (ANV). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Decoder::split` handed back one slice per coded frame, which meant the caller had to hold the whole stream in memory before decoding any of it. That rules out the case the decoder exists for: bytes arriving from a network, where the end of a read is not the end of a frame. Framing moves inside. `Framing` says which kind of input this is, since it is a property of where the bytes came from and cannot be detected: - `FrameAligned` (default) takes whole coded frames per call, which is what a container or transport hands over already framed. Nothing is buffered, so the frame is decoded by the call that delivers it. - `ByteStream` takes a slice that may cut anywhere, including mid-NAL. The decoder holds back a trailing partial frame until later bytes complete it, and `flush` ends the last one. This costs one frame of latency, unavoidably: an H.264 picture is only known to have ended once the next one starts. `DEFAULT_OUTPUT_DEPTH` goes from 2 to 4. Every frame a `decode` call emits is outstanding at once, so a call that emits several needs more slots than one that emits one, and the pipeline holds frames in flight of its own. Exceeding the budget is not an error: pictures past it are copied out so decoding continues, so it is a throughput setting rather than a limit. The pin budget now counts every live pin rather than only the reorder buffer's, which is what makes that graceful: previously a call emitting more frames than there were slots deadlocked, waiting on a frame only the caller could release and the caller was inside the call. The examples feed 64 KB chunks, which is both the realistic shape and the one that exercises the framing. decode_h264 raises `output_depth` to 8 to suit it, with the reasoning written out. Byte-stream framing is covered by a host-side test that feeds each stream in chunks of 1, 2, 3, 7, 64, 1024 and 100000 bytes and asserts the picture boundaries and the bytes match having the whole buffer at once. Verified byte-identical to `ffmpeg -pix_fmt nv12` on AMD (RADV) and Intel (ANV): all four test streams plus 300 frames of 1080p with B-pyramid, validation enabled and silent on both. AMD throughput is up 3% against the previous commit (1080p, interleaved medians, 895 fps against 869). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pinned frame is a live DPB slot, and that picture may still be a reference for later decodes. Reading it means transitioning it out of VIDEO_DECODE_DPB_KHR and back, and doing that while the decode queue is reading it as a reference is undefined. So zero-copy output was only ever safe if the consumer never touched the layout, which meant it could not be sampled at all, and the readback path papered over it by draining the whole pipeline first. VK_KHR_unified_image_layouts removes the constraint rather than working around it. With `unifiedImageLayouts` and `unifiedImageLayoutsVideo` both enabled, VK_IMAGE_LAYOUT_GENERAL is valid for every use including video picture resources, so pictures stay in GENERAL and nothing ever transitions them. The decoder's reference reads and a consumer's reads are then both reads of an image in a layout neither has to change, which do not conflict. The drain is gone with the hazard. Where the extension is missing, pictures are not pinned at all: they are copied into private images on the way out, which are ordinary images a consumer can transition freely. That is the whole fallback, and it also shrinks the DPB, since slots are only reserved for pinning. Adopted devices are the awkward case. Vulkan cannot be asked which features a device was created with, so `declare_unified_image_layouts` lets the caller say. It is checked against what the physical device supports, so a declaration that cannot hold fails cleanly; it cannot be checked against what was actually enabled, which validation layers catch. `DeviceRequirements::unified_image_layouts` tells a caller whether it is worth doing. This matters because the renderer-sharing case is exactly where zero-copy pays off, and it is always an adopted device. `without_unified_image_layouts` forces the copying path so it can be tested on hardware that would never otherwise take it; decode_h264 wires it to PIXELFORGE_NO_UNIFIED_LAYOUTS. Support is narrower than "modern GPUs": RADV has it, ANV does not yet (only llvmpipe advertises it on that machine), and NVIDIA has it from RTX 20-series on recent drivers. So AMD gets the zero-copy path today and Intel Arc takes the fallback. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310), both paths forced on AMD: base, bframes, multislice and zerolatency plus 300 frames of 1080p B-pyramid all byte-identical to `ffmpeg -pix_fmt nv12`, validation enabled and silent throughout. AMD throughput, 1080p, interleaved medians: 874 fps against the fallback's 701 for decode alone, and 78 against 70 with host readback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Decoder::decode` returned a future for the frames that call produced, which tied output to input: the caller had to hold a future per call and resolve them in order, and a producer feeding a socket could not simply feed. A packet can yield any number of frames, including none, so the count belongs to the stream rather than to the call. Bytes now go in through a `DecodeSink` and frames come out of a `DecodeSource`, connected by a channel the completion thread writes as each picture's GPU work finishes. `Decoder` holds both halves, so one thread can still drive everything; `Decoder::split` separates them for a producer and a consumer on their own threads, which is the shape a renderer wants. `DecodeSink::finish` ends the stream: it decodes what framing still holds, emits what reordering still holds, and closes the source so `next_frame` reports the end. `try_next_frame` covers the single-threaded case: feed a chunk, take whatever is ready, repeat. Without it a lone thread would have to block on frames that may not be coming yet, or buffer the entire stream. The frame channel is unbounded deliberately. Blocking the completion thread would stop it freeing pipeline slots and stall the very `decode` call producing the frames, and one call can emit more frames than any fixed bound. Back-pressure stays in the DPB slot budget: past it, pictures are copied out rather than pinned. `download` and `copy_frame_to_planes` are gone, along with `DecodedFrameData` and the reorder-pool readback machinery. A frame is a GPU image the consumer owns until they drop it, and once sink and source are separate the consumer cannot reach the decoder's transfer queue anyway. What is left of transfer.rs is the copy that the fallback path needs. `examples/common` carries the readback the examples still want, which is also an honest demonstration of what a consumer has to write. `DecodedFrame` gains `bit_depth`, since a consumer interpreting pixels needs it and can no longer ask the sink. decode_adopted now enables `VK_KHR_unified_image_layouts` on its own device and declares it, which is the case that matters: adopting a renderer's device is exactly where zero-copy pays off. verify_planes is deleted; it existed to exercise `copy_frame_to_planes`. A direct-sampling example replaces it next. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310): four test streams on both paths plus 300 frames of 1080p B-pyramid, all byte-identical to `ffmpeg -pix_fmt nv12`, and the adopted-device and round-trip examples, validation enabled and silent throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the path the rest of the branch was built for, and until now nothing exercised it. `sample_frame` takes the decoder's own image, reads it in a compute shader through a `VkSamplerYcbcrConversion`, and writes RGBA. The hardware does chroma reconstruction and the YUV to RGB matrix on read, so nothing is copied and nothing is transitioned, while the decoder carries on using that picture as a reference. Two things it demonstrates that are easy to get wrong, and that cost time to find here: A combined image sampler for a multi-planar format does not necessarily cost one descriptor. The implementation reports how many through `combinedImageSamplerDescriptorCount`, and RADV asks for more than one where ANV asks for one, so a pool sized for a single descriptor allocates fine on Intel and fails with ERROR_OUT_OF_POOL_MEMORY on AMD. The layout still declares a count of one; only the pool has to know. The colour model has to come from somewhere. A real player reads the stream's VUI; with nothing signalled the example guesses BT.601 below standard definition and BT.709 above, which is what ffmpeg does. Getting this wrong is not subtle: BT.709 on BT.601 content moves every colour. Do not judge the output by PSNR. The sampler and ffmpeg reconstruct subsampled chroma differently, so they agree almost everywhere and disagree hard on pixels sitting on a colour edge. On the synthetic bar patterns in tests/data that pulls PSNR down to ~26 dB while the images are indistinguishable by eye. The share of agreeing pixels says it better: 87.9% within 2 and 33.2% exact, identical on AMD and Intel. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310), validation enabled and silent. On AMD the zero-copy and copying paths produce byte-identical output to each other, which is the check that the two storage paths are interchangeable from a consumer's point of view. verify_planes, which this replaces, tested `copy_frame_to_planes` instead: the decoder copying into consumer-owned plane images, to avoid needing a ycbcr conversion at all. That is a real cost this example takes on, and the reason the old API existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`decode` returned `Err(NeedsKeyframe)` for data that referenced parameter sets or reference pictures it had not seen. That is not a fault: it is what joining a live stream partway through looks like, and what packet loss looks like, and every caller has to catch it and carry on. Putting it in the error type made routine control flow look like a failure and forced it through error handling that exists for real problems. `DecodeSink::decode` now returns a `DecodeStatus`: - `Decoded`: pictures went to the GPU, frames will follow on the source. - `Buffered`: nothing complete yet, which is the normal state of a byte-stream chunk that ends mid-picture or carries only parameter sets. - `NeedsKeyframe`: pictures were present but cannot be decoded until a keyframe arrives. Ask the sender for one and keep feeding. `Buffered` is the variant that did not exist before: the old signature could not tell "nothing to do yet" from "decoded something", so a byte-stream caller could not see the difference. `PixelForgeError::NeedsKeyframe` is gone. It was also used internally as the signal for a slice whose parameter sets are missing, which is now a `Grouped` value returned from `group_slices` rather than an error travelling up through `?` and being caught again. The reason string survives as a debug log rather than as a payload, so the enum stays cheap and `Copy` while the diagnostics remain. Covered by a host-side test that feeds a real stream's slices with no parameter sets and asserts every one asks for a keyframe rather than forming a picture. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310): four streams on both storage paths plus 300 frames of 1080p B-pyramid byte-identical to `ffmpeg -pix_fmt nv12`, and the sampling, adopted-device and round-trip examples, validation enabled and silent throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`DecoderCommon` still carried a readback buffer field, set to None at construction and only ever freed in `Drop`. Nothing assigned it once `download` was removed, and because `Drop` reads it, dead-code analysis had nothing to complain about. Also corrects the comments that still describe a readback path the decoder no longer has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Just the rebase against new upstream and a small extra variable that helps with tracking changes to decoding state (i.e. resolution changes in decoded input, without re-creating decoder), working on that now so won't take too long 🙂 After those are commited, feel free to merge when free 👍 E: Found a possible issue for downstream use, dropping decoded frame too early or holding onto it too long could get problematic so I'll defer the cleanup when no longer referenced, at worst it will add ~200 MB of memory usage on 4K decode if the consumer can't keep up, but it's better to be safe rather than have an use-after-free or similar issue later. |
CI runs `cargo clippy --all-features -- -D warnings` on the stable toolchain, which moved to 1.98 and brought new lints with it. I had been checking `--all-targets` on 1.97: neither the command CI runs nor the version it runs it with, so a red job was the first I heard of it. Upstream fixed the library's five (0a5c9bf), and this now carries only what the same clippy finds outside it, which CI does not check but which is no reason to leave: the SPIR-V loader in `examples/sample_frame`, an `is_some`-then-`unwrap` in `examples/roundtrip_h264`, and a test module sitting above later items in `encoder/resources.rs`. The bit-reader test literals keep their codeword-aligned grouping behind an `allow`, since regrouping them by nibble would destroy the thing they document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sampling a multi-planar image needs a VkSamplerYcbcrConversion, which needs a combined image sampler with an immutable sampler, which is a thing some shader toolchains simply cannot express: naga has no combined-image-sampler type, so wgpu has no ycbcr sampler either. A renderer built on those had no way to read a DecodedFrame at all, and had to copy every picture out. Picture images now ask for MUTABLE_FORMAT, so a consumer can create two ordinary views over the same image, PLANE_0 as R8_UNORM and PLANE_1 as R8G8_UNORM, and read luma and chroma as two plain textures with the YUV to RGB matrix left to their shader. No conversion object, no immutable sampler, no copy. It is a request, not an assumption. The driver enumerates which creation flags it accepts per profile, format and usage in VkVideoFormatPropertiesKHR::imageCreateFlags, and the answer genuinely varies: both RADV and ANV allow MUTABLE_FORMAT for the usage pixelforge creates pictures with, and both report *no* flags at all for a reference-only DPB. DecodedFrame::plane_views says what happened. VkImageFormatListCreateInfo names the picture format and its plane formats alongside the flag, so a driver need not assume the image might be reinterpreted as anything and can keep compression it would otherwise give up. Measured at 1080p on both GPUs, interleaved medians, three ways (baseline, MUTABLE_FORMAT alone, MUTABLE_FORMAT with the format list): every difference is inside the run-to-run spread. RADV 883 / 889 / 887 fps, ANV 596 / 584 / 583. So the flag is set wherever the driver allows it rather than hidden behind a config knob, since it costs nothing to the consumers who will not use it. The format list did not measurably matter on either driver either, but it is free and it is what the spec offers for exactly this, so it stays. Pool images, the copying fallback, get it unconditionally: they carry no video profile, so they are ordinary images and no driver permission is needed. Without that, plane_views would be false on precisely the hardware that already lost the zero-copy path, and a consumer would need two code paths for no reason. query_capabilities now prints decode picture imageCreateFlags for both usages, since the difference between them is the trap. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310): all four test streams byte-identical to `ffmpeg -pix_fmt nv12` in all three configurations, validation enabled and silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The counterpart to sample_frame, and what makes `plane_views` worth having. Same picture, read as two ordinary single-plane textures bound as plain `texture2D` descriptors: no ycbcr conversion, no immutable sampler, no combined image sampler, no sampler at all. That is the shape a toolchain without a combined-image-sampler type can produce, which is the situation naga and therefore wgpu are in. `texelFetch` needs no sampler and does no filtering, so the shader writes the plane samples through unchanged and the output is plain NV12. That makes this the tightest test in the suite: byte-identical to `ffmpeg -pix_fmt nv12`, not "close enough" like the RGBA comparison in sample_frame, which has chroma reconstruction and a colour matrix in the way. Written as its own example rather than a mode of sample_frame. The plan was to share, on the assumption the two differed by a couple of lines; they do not. Different descriptor types, different targets, different output format, different verification. The shared part turned out to be the memory helper, which moved to examples/common. Testing this on both GPUs earned its keep. A view inherits its image's usage, and a decoded picture's usage includes VIDEO_DECODE_DST_KHR, which R8_UNORM cannot satisfy: no VIDEO_DECODE_OUTPUT format feature. The view needs VkImageViewUsageCreateInfo narrowing it to SAMPLED. Intel never saw it, because without unified image layouts its frames are pool copies with no video usage at all; AMD reported it ten times a run. Documented on `DecodedFrame::plane_views`, and AGENTS.md now says to run this one on AMD for exactly that reason. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310): all four test streams byte-identical, on both the zero-copy path (where the frame is a pinned DPB image the decoder is still using as a reference) and the copying fallback, validation enabled and silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The decode verification notes told the reader to "run that one on AMD too, not just locally", which is advice about the machines I happened to have rather than about pixelforge. Nobody else's setup is described by it, and plenty of contributors will have exactly one GPU. Restated as the property that actually matters: a plane-view mistake is silent on a device without VK_KHR_unified_image_layouts, because there the frames are pool copies carrying no video usage, and is an immediate validation error on a device that has it, because there the frame is the decoder's own DPB image. If such a device is available, use it; the debug log says which case a run was. Also stopped attributing the sampling agreement figures to two named vendors, since the useful claim is that they have been identical everywhere tested and a change in them is a regression. The remaining vendor names in the source are a different thing and stay: they explain why code is shaped the way it is (RADV's decode queue not advertising TRANSFER_BIT, ANV mis-decoding a 4-byte start code) or record where a result was measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…under A chunk can carry a parameter set *change*: an SPS resent under an id it has already used, which is how H.264 signals a new resolution. Parameter sets were resolved by id in `decode_frames`, after the whole chunk had been scanned and every SPS in it absorbed, so the last one won and applied retroactively to the pictures before it. Concretely: a 320x240 clip followed by a 640x480 one, fed as one byte stream, created a single 640x480 session and decoded all sixty frames against it. No error, no validation complaint, just sixty wrong frames. A picture now carries the SPS and PPS that were in effect when its slices were grouped, which is the only moment they are unambiguous, and `decode_frames` uses those. Session reuse was comparing ids and dimensions, which misses the same class of change from the other side: an SPS resent with different content under the same id at the same resolution would have kept the old session. It now compares the parameter sets themselves. Reachable through the public API before byte-stream framing existed, since `decode` has always accepted several coded frames per call, but framing moved inside the decoder in 5dcbf18 and chunks became the normal input, so this went from a corner to the default path for any stream that changes resolution. Covered by a host-side test that concatenates two fixtures which reuse SPS id 0 with different content, and asserts each half kept its own. The test asserts the two differ first, so it cannot pass vacuously. Verified with a real 320x240-then-640x480 stream: byte-identical to ffmpeg decoding each segment separately, on Intel (ANV, Arc A310). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A consumer that caches per-image state has no correct key today. The decoder rebuilds its session, and every picture image with it, when the stream's parameter sets change; frames from before keep their DPB slot but their images are destroyed. Drivers reuse `VkImage` handles freely, so a handle from a dead image comes back attached to a live one and a cache keyed on the handle hits and hands back views of dead memory. That is not hypothetical: it is what a downstream renderer hit as green frames after a resolution change, green being the signature of a chroma plane reading zeros. `DecodedFrame::generation` is a counter bumped per session creation. Keying on `(generation, image, array_layer)` is then correct, and a frame whose generation is behind the newest seen can be discarded; dropping one is always safe, since only its slot release runs and that touches no Vulkan object. The teardown notice drops from warn to debug, and says which generation died. With the counter available this is a routine event that a correct consumer handles, and a warning on every resolution change under correct usage only teaches people to filter the log. Deliberately not doing the other half that was discussed: deferring the images' destruction until the last pin drops. Tolerating the revocation and discarding stale frames is correct whether or not pins happen to be outstanding, and it frees the old session immediately rather than holding two sets across a changeover, which at 4K is real memory. The residual hazard is narrower than it looks but not zero: `destroy_session` waits for the device to idle before freeing, which covers work already submitted, not work a consumer thread submits concurrently with the rebuild. Closing that needs the deferred-destruction design, and it is also what would remove the `vkDeviceWaitIdle` call whose host-synchronisation requirement the adopted -device design cannot honour. Both are worth doing; neither is what causes the green frames. decode_h264 reports generation changes, so the field appears in an example rather than only in prose, and AGENTS.md carries a recipe for building a resolution-changing stream, which nothing in tests/data covers. Verified on Intel (ANV, Arc A310): the four test streams byte-identical as before, and a 320x240-then-640x480 stream byte-identical to ffmpeg decoding each segment separately, with the generation flipping at frame 30 exactly. Not yet run on AMD, where a rebuild happens with pins genuinely live rather than on the copying path, because that machine needs its Tailscale session reauthenticated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The examples segfaulted on a mid-stream resolution change, on AMD only. `vkCmdCopyImageToBuffer2` on a destroyed VkImage: a session rebuild frees its picture images, and frames decoded before it are still queued for delivery, so a consumer reads them afterwards and touches freed memory. Intel never showed it, because without unified image layouts its frames are pool copies, which a rebuild does not destroy. `DecodeSink::generation` reports the generation being produced now, which a consumer needs and `DecodedFrame::generation` alone cannot supply. Frames arrive in decode order, so every stale frame is delivered *before* the first frame of the new generation: comparing against the newest generation seen on a frame always decides too late. The examples now compare against the sink and drop what the rebuild invalidated, and key their view caches on `(generation, image, array_layer)` since handles are reused across generations. That is only race-free when one thread drives both halves, as it does here and as the accessor documents. A consumer with its own render thread can still be handed a frame that goes stale immediately after the check, and nothing on the consumer's side can close that. It is also blunter than it should be: pool-backed frames are never destroyed while alive, but a generation comparison cannot tell them from pinned ones, so they are dropped too. On the 60-frame test stream that costs 30 frames on both vendors where the real loss is at most the pinned ones. Fixing that properly means the decoder deferring destruction until the last frame referencing a session is dropped, which was considered and set aside; this commit is the safe floor, not the end state. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310): the four test streams byte-identical on both storage paths, and the resolution-changing stream now exits cleanly with validation silent on both, where before it dumped core on AMD. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A DecodedFrame promised its image stayed valid while the frame lived, with an exception for a session rebuild, which a resolution or parameter set change causes. That exception was not actionable: nothing told a consumer a rebuild had happened in time, because frames arrive in decode order and the invalidated ones are delivered *before* the first frame of the new generation. It crashed the examples on AMD, where frames really are DPB images, and could not on Intel, where they are pool copies a rebuild does not touch. DPB images are now reference counted, one refcount per image rather than per session, and a frame holds the image it was decoded into. A rebuild frees every image nothing is holding and leaves the rest to the last frame to let go. So the promise is now unconditional, which is what `drop(frame) -> storage returned` in the original API sketch always implied. Per image, not per session, so the memory held across a changeover is the frames actually outstanding rather than the whole old DPB: at 4K roughly 12 MB per live frame instead of 190 MB flat. That was the objection to doing this, and it does not apply to this shape. What this deletes is the point of it. `DecodeSink::generation` goes, along with the discard logic it existed for, and with that the frames the discard was throwing away: the resolution-change stream now delivers all sixty frames rather than thirty. `SlotPins::clear` goes too; it existed to forget pins at teardown, which was the unsafe step itself. `DecodedFrame::generation` stays, now purely a cache key. A consumer's view cache outlives their frames, and handles are reused once a generation is finally released, so `(generation, image, array_layer)` is still the only correct key. Getting it wrong now costs a wrong-looking frame instead of a use-after-free. Two further things this turned up, both real: Slot reservations are now per session. They were per decoder, so a frame outliving a rebuild released a slot number that the *current* session may have reserved for someone else, and the decoder could then decode over a picture a consumer was holding. Covered by a test. Pool images named only their plane formats in VkImageFormatListCreateInfo, omitting their own, so viewing one with the picture format was invalid. Only reachable on a driver that pins, since it needs a pool frame and a full-format view in the same run; AMD reported it ten times a run and Intel never. Documentation says what to do instead of holding frames: copy the picture into an image of your own. And it now says not to drop a frame while your own GPU work on it is still running, since the drop is what returns the storage. Verified on AMD (RADV, RX 9060 XT) and Intel (ANV, Arc A310): four test streams byte-identical on both storage paths for decode_h264 and sample_planes, and a mid-stream 320x240-to-640x480 change delivering all sixty frames byte-identical to ffmpeg per segment through all three examples, validation enabled and silent throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9a4058d to
22aadad
Compare
|
That should be all 🎉 |
|
Awesome 👍 |

PR changes
Adds decoding support, scoped to H.264 for now so there's not too much to review at once.. though I feel it already is 😅
LLM's summary:
Comment
Whew.. this has been atleast a few weeks worth of work, I ended up getting a claude subscription since I was losing my mind over H.264 codec parsing and dealing with all the reference frame management, it's just not fun and goes over my brain's capacity to handle 😓
This is very much a draft, meaning WIP, RFC and all. API could be better IMO, and I'd like some feedback and guidance on what you wish for @hgaiser !
Currently codec parsing and actual decode are done separately for API user:
incase someone wants to do their own parsing or do some magic by intercepting parsed data. When adding AV1 decoding in future, the parsing API needs changing to be more codec-agnostic though.
I've verified it works though in a real use-case, here's a picture showing pixelforge decoding integrated into a native app for playing Nestri streams, H.264 is decoded and shown perfectly fine

It's a lot to unpack, despite trying to limit the scope to H.264 decoding for now, there's a lot of boilerplate and logic needed for even just that, I apologize for the size of this PR 😅
Certain code parts are somewhat oriented towards H.26X codecs, so when adding AV1 decoding in future, it will need adjusting to be more clean and sane.
Aside from decoder changes, the encoder
pub fn new..was poked a bit to change the "b-frames unsupported" assertion into anErrreturn instead, allowing to gracefully handle that. Though I can revert that one since it's overreaching a bit here.