From 76369478ba888feff5fc63d84bd814803263c516 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Fri, 16 Oct 2026 16:07:38 +0200 Subject: [PATCH 1/2] feat: decode VP9 spatial layers with VA-API Use WebRTC's layer sizes to split spatial frames and retain references across resolutions. Covers L2T2, L3T3 and L3T3_KEY decoding. --- .../media/video/codec/linux/VaapiVp9Decoder.h | 12 +- .../video/codec/linux/Vp9FrameHeaderParser.h | 5 + .../video/codec/linux/VaapiVp9Decoder.cpp | 132 ++++++++++++++---- .../codec/linux/Vp9FrameHeaderParser.cpp | 56 ++++++++ .../HardwareVideoDecoderIntegrationTest.java | 24 +++- 5 files changed, 195 insertions(+), 34 deletions(-) diff --git a/webrtc-jni/src/main/cpp/include/media/video/codec/linux/VaapiVp9Decoder.h b/webrtc-jni/src/main/cpp/include/media/video/codec/linux/VaapiVp9Decoder.h index e106aca9..245c30e3 100644 --- a/webrtc-jni/src/main/cpp/include/media/video/codec/linux/VaapiVp9Decoder.h +++ b/webrtc-jni/src/main/cpp/include/media/video/codec/linux/VaapiVp9Decoder.h @@ -30,6 +30,7 @@ #include #include #include +#include #include namespace jni @@ -71,8 +72,8 @@ namespace jni bool DecodeFrame(const Vp9FrameHeader & header, std::span frame, int surface); // Hands the frame in the surface to WebRTC. bool Output(int surface, const webrtc::EncodedImage & image, int64_t renderTimeMs); - // A surface that no reference slot holds. - int FreeSurface() const; + // A surface that no reference slot holds, other than the given one. + int FreeSurface(int keep) const; bool Check(VAStatus status, const char * operation) const; @@ -88,8 +89,12 @@ namespace jni VAConfigID config; VAContextID context; std::vector surfaces; + // The size of the surfaces, the largest frame they take. uint32_t width; uint32_t height; + // The size of the frame decoded into each surface, which may be + // smaller: the lower spatial layers, or a stream that scaled down. + std::vector> frameSizes; // The image a decoded surface is copied into, where the driver // copies surfaces at all. @@ -102,6 +107,9 @@ namespace jni // then failed for good. bool started; bool failed; + // Whether the stream has spatial layers, whose surfaces stay large + // enough for all of them. + bool layered; }; } diff --git a/webrtc-jni/src/main/cpp/include/media/video/codec/linux/Vp9FrameHeaderParser.h b/webrtc-jni/src/main/cpp/include/media/video/codec/linux/Vp9FrameHeaderParser.h index cfcb0926..af518268 100644 --- a/webrtc-jni/src/main/cpp/include/media/video/codec/linux/Vp9FrameHeaderParser.h +++ b/webrtc-jni/src/main/cpp/include/media/video/codec/linux/Vp9FrameHeaderParser.h @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace jni @@ -123,6 +124,10 @@ namespace jni // the data. static std::vector> SplitSuperframe(std::span data); + // The size a frame codes itself, without the state of the stream; + // none for an inter frame that takes the size of a reference. + static std::optional> FrameSize(std::span frame); + private: // What the specification calls setup_past_independence. void SetupPastIndependence(); diff --git a/webrtc-jni/src/main/cpp/src/media/video/codec/linux/VaapiVp9Decoder.cpp b/webrtc-jni/src/main/cpp/src/media/video/codec/linux/VaapiVp9Decoder.cpp index 2f4db942..9f2a3e56 100644 --- a/webrtc-jni/src/main/cpp/src/media/video/codec/linux/VaapiVp9Decoder.cpp +++ b/webrtc-jni/src/main/cpp/src/media/video/codec/linux/VaapiVp9Decoder.cpp @@ -55,7 +55,8 @@ namespace jni height(0), downloadImage(), started(false), - failed(false) + failed(false), + layered(false) { downloadImage.image_id = VA_INVALID_ID; slots.fill(-1); @@ -103,19 +104,54 @@ namespace jni return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; } - // The layers of a VP9 frame with spatial layers reach a decoder back to - // back, without the superframe index that tells where one ends. - if (image.SpatialIndex().value_or(0) > 0 || image.SpatialLayerFrameSize(1).has_value()) { - return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; - } - // Decoding starts with a key frame. Until one has come, WebRTC is // asked for it. if (!started && image.FrameType() != webrtc::VideoFrameType::kVideoFrameKey) { return WEBRTC_VIDEO_CODEC_ERROR; } - const auto frames = Vp9FrameHeaderParser::SplitSuperframe(std::span(image.data(), image.size())); + const std::span data(image.data(), image.size()); + const int topLayer = image.SpatialIndex().value_or(0); + std::vector> frames; + + // The spatial layers of a frame come back to back, without the + // superframe index that would tell where one ends; WebRTC gives the + // size of each instead, and none for a layer the encoder left out. + if (topLayer > 0) { + layered = true; + + size_t offset = 0; + + for (int layer = 0; layer <= topLayer; layer++) { + const std::optional size = image.SpatialLayerFrameSize(layer); + + if (!size || *size == 0) { + continue; + } + if (*size > data.size() - offset) { + RTC_LOG(LS_WARNING) << implementationName << " VP9: spatial layers larger than the frame"; + failed = true; + + return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; + } + + for (std::span frame : Vp9FrameHeaderParser::SplitSuperframe(data.subspan(offset, *size))) { + frames.push_back(frame); + } + + offset += *size; + } + + if (offset != data.size()) { + RTC_LOG(LS_WARNING) << implementationName << " VP9: spatial layers that do not add up to the frame"; + failed = true; + + return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; + } + } + else { + frames = Vp9FrameHeaderParser::SplitSuperframe(data); + } if (frames.empty()) { RTC_LOG(LS_WARNING) << implementationName << " VP9: a superframe with a broken index"; @@ -124,6 +160,10 @@ namespace jni return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; } + // Of the frames shown, the last is the picture: the highest spatial + // layer, as libvpx shows it. + int shown = -1; + for (std::span frame : frames) { const std::optional header = parser.Parse(frame); @@ -136,34 +176,60 @@ namespace jni } if (header->showExistingFrame) { - if (slots[header->frameToShow] < 0 || !Output(slots[header->frameToShow], image, renderTimeMs)) { + if (slots[header->frameToShow] < 0) { failed = true; return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; } + shown = slots[header->frameToShow]; continue; } - const bool sizeChanged = header->width != width || header->height != height; + if (header->keyFrame) { + // The surfaces take the largest frame of the picture, so that + // the spatial layers above a key frame fit. + uint32_t largestWidth = header->width; + uint32_t largestHeight = header->height; - if (header->keyFrame && (context == VA_INVALID_ID || sizeChanged)) { - if (!CreateSession(header->width, header->height)) { - failed = true; + for (std::span other : frames) { + std::optional> size = Vp9FrameHeaderParser::FrameSize(other); - return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; + if (size) { + largestWidth = std::max(largestWidth, size->first); + largestHeight = std::max(largestHeight, size->second); + } + } + + // With spatial layers, surfaces that take the picture stay, so + // that a layer WebRTC adds back later fits without a key frame. + const bool fits = layered && largestWidth <= width && largestHeight <= height; + + if (context == VA_INVALID_ID || (!fits && (largestWidth != width || largestHeight != height))) { + if (!CreateSession(largestWidth, largestHeight)) { + failed = true; + + return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; + } } } - else if (context == VA_INVALID_ID || sizeChanged) { - // Inter frames that scale their references are left to libvpx. - RTC_LOG(LS_INFO) << implementationName << " VP9: an inter frame changes the size to " - << header->width << "x" << header->height; + else if (context == VA_INVALID_ID) { failed = true; return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; } + else if (header->width > width || header->height > height) { + // A spatial layer that WebRTC adds without a key frame, larger + // than the surfaces. The key frame WebRTC is asked for carries + // all layers, and the surfaces are made to take them. + RTC_LOG(LS_INFO) << implementationName << " VP9: a spatial layer of " << header->width << "x" + << header->height << " does not fit, asking for a key frame"; + + return WEBRTC_VIDEO_CODEC_ERROR; + } + // The hardware scales references of another size. - const int surface = FreeSurface(); + const int surface = FreeSurface(shown); if (surface < 0 || !DecodeFrame(*header, frame, surface)) { failed = true; @@ -171,6 +237,8 @@ namespace jni return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; } + frameSizes[surface] = { header->width, header->height }; + for (int slot = 0; slot < 8; slot++) { if (header->refreshFrameFlags & (1 << slot)) { slots[slot] = surface; @@ -179,13 +247,17 @@ namespace jni started = true; - if (header->showFrame && !Output(surface, image, renderTimeMs)) { - failed = true; - - return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; + if (header->showFrame) { + shown = surface; } } + if (shown >= 0 && !Output(shown, image, renderTimeMs)) { + failed = true; + + return WEBRTC_VIDEO_CODEC_FALLBACK_SOFTWARE; + } + return WEBRTC_VIDEO_CODEC_OK; } @@ -203,6 +275,7 @@ namespace jni parser.Reset(); started = false; failed = false; + layered = false; return WEBRTC_VIDEO_CODEC_OK; } @@ -272,6 +345,7 @@ namespace jni width = frameWidth; height = frameHeight; + frameSizes.assign(kSurfaceCount, { frameWidth, frameHeight }); RTC_LOG(LS_INFO) << implementationName << " VP9 decoding " << width << "x" << height; @@ -301,13 +375,14 @@ namespace jni width = 0; height = 0; + frameSizes.clear(); slots.fill(-1); } - int VaapiVp9Decoder::FreeSurface() const + int VaapiVp9Decoder::FreeSurface(int keep) const { for (int surface = 0; surface < static_cast(surfaces.size()); surface++) { - if (std::find(slots.begin(), slots.end(), surface) == slots.end()) { + if (surface != keep && std::find(slots.begin(), slots.end(), surface) == slots.end()) { return surface; } } @@ -422,15 +497,16 @@ namespace jni bool VaapiVp9Decoder::Output(int surface, const webrtc::EncodedImage & image, int64_t renderTimeMs) { VADisplay display = library.Display(); - const int frameWidth = static_cast(width); - const int frameHeight = static_cast(height); + const int frameWidth = static_cast(frameSizes[surface].first); + const int frameHeight = static_cast(frameSizes[surface].second); VAImage mapped = {}; mapped.image_id = VA_INVALID_ID; bool derived = false; if (downloadImage.image_id != VA_INVALID_ID - && va.GetImage(display, surfaces[surface], 0, 0, width, height, downloadImage.image_id) + && va.GetImage(display, surfaces[surface], 0, 0, frameSizes[surface].first, + frameSizes[surface].second, downloadImage.image_id) == VA_STATUS_SUCCESS) { mapped = downloadImage; diff --git a/webrtc-jni/src/main/cpp/src/media/video/codec/linux/Vp9FrameHeaderParser.cpp b/webrtc-jni/src/main/cpp/src/media/video/codec/linux/Vp9FrameHeaderParser.cpp index 9346ea3d..5bb16b5c 100644 --- a/webrtc-jni/src/main/cpp/src/media/video/codec/linux/Vp9FrameHeaderParser.cpp +++ b/webrtc-jni/src/main/cpp/src/media/video/codec/linux/Vp9FrameHeaderParser.cpp @@ -411,6 +411,62 @@ namespace jni } } + std::optional> Vp9FrameHeaderParser::FrameSize(std::span frame) + { + webrtc::BitstreamReader reader(frame); + + // Frame marker and profile 0; a frame that shows an earlier one codes + // no size. + if (reader.ReadBits(2) != 2 || reader.ReadBits(2) != 0 || reader.Read()) { + return std::nullopt; + } + + const bool keyFrame = !reader.Read(); + const bool showFrame = reader.Read(); + const bool errorResilient = reader.Read(); + + if (keyFrame) { + if (reader.ReadBits(24) != kFrameSyncCode) { + return std::nullopt; + } + // The color space and range of profile 0. + reader.ConsumeBits(4); + } + else { + const bool intraOnly = showFrame ? false : reader.Read(); + + if (!errorResilient) { + reader.ConsumeBits(2); + } + + if (intraOnly) { + if (reader.ReadBits(24) != kFrameSyncCode) { + return std::nullopt; + } + reader.ConsumeBits(8); + } + else { + // The refresh flags, then three references with a sign bias. + reader.ConsumeBits(8 + 3 * 4); + + for (int i = 0; i < 3; i++) { + if (reader.Read()) { + return std::nullopt; + } + } + } + } + + const uint32_t width = static_cast(reader.Read()) + 1; + const uint32_t height = static_cast(reader.Read()) + 1; + + if (!reader.Ok()) { + return std::nullopt; + } + + return std::make_pair(width, height); + } + std::vector> Vp9FrameHeaderParser::SplitSuperframe(std::span data) { std::vector> frames; diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java index 50dac76a..8b0e1a4f 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java @@ -750,10 +750,26 @@ void vp9SpatialLayers() throws Exception { assumeTrue(result.scalability.startsWith("L2"), "no spatial layers were encoded: " + result.scalability); - // The frames arrive, from libvpx: the layers of a frame come without a - // superframe index, which VideoToolbox does not decode, and which a - // Media Foundation or NVDEC decoder is not known to. - assertFalse(isHardware(result.decoder), result.decoder); + if (OS.contains("linux") && result.decoder.startsWith("VA-API")) { + // VA-API uses the layer sizes WebRTC supplies to decode the layers + // in order, including references at a different resolution. + assertTrue(result.pliCount <= 1, "key frames requested: " + result.pliCount); + } + else { + assertFalse(isHardware(result.decoder), result.decoder); + } + } + + @Test + void vaapiDecodesThreeSpatialLayers() throws Exception { + assumeTrue(OS.contains("linux"), "VA-API is available on Linux only"); + for (String mode : new String[] { "L3T3", "L3T3_KEY" }) { + CallResult result = receiveVideo(hardwareFactory(), VP9, 1280, 720, mode, 90); + assertEquals(mode, result.scalability); + expectVp9Hardware(result.decoder); + assertTrue(result.decoder.startsWith("VA-API"), result.decoder); + assertTrue(result.pliCount <= 1, "key frames requested: " + result.pliCount); + } } @Test From e33fbf8b841ba82ddeb6e6381cd499eb2c3c9bb1 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Fri, 16 Oct 2026 16:15:05 +0200 Subject: [PATCH 2/2] test: cover VA-API VP9 spatial layer switching --- .../HardwareVideoDecoderIntegrationTest.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java index 8b0e1a4f..6a01da77 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/HardwareVideoDecoderIntegrationTest.java @@ -772,6 +772,95 @@ void vaapiDecodesThreeSpatialLayers() throws Exception { } } + @Test + void vaapiFollowsSpatialLayerChanges() throws Exception { + assertSpatialLayerChanges("L3T3", 1280, false); + } + + @Test + void vaapiFollowsTwoSpatialLayerChanges() throws Exception { + assertSpatialLayerChanges("L2T2", 640, false); + } + + @Test + void vaapiFollowsKeySpatialLayerChanges() throws Exception { + assertSpatialLayerChanges("L3T3_KEY", 1280, false); + } + + @Test + void vaapiAddsSpatialLayersAfterStartingAtBaseLayer() throws Exception { + assertSpatialLayerChanges("L3T3", 1280, true); + } + + /** Changes the bitrate budget without resizing the source or changing the SVC mode. */ + private void assertSpatialLayerChanges(String mode, int fullWidth, boolean startAtBase) throws Exception { + assumeTrue(OS.contains("linux"), "VA-API is available on Linux only"); + PeerConnectionFactory hardware = hardwareFactory(); + AtomicInteger expectedWidth = new AtomicInteger(startAtBase ? 320 : fullWidth); + AtomicReference frames = new AtomicReference<>(new CountDownLatch(10)); + + try (TestMediaCall call = new TestMediaCall(hardware, true, false, VP9)) { + call.setVideoSize(fullWidth, fullWidth * 9 / 16); + call.negotiate(); + RTCRtpReceiver receiver = call.getReceiver("video"); + VideoTrack track = (VideoTrack) receiver.getTrack(); + VideoTrackSink sink = frame -> { + int width = expectedWidth.get(); + if (frame.buffer.getWidth() == width && frame.buffer.getHeight() == width * 9 / 16) { + frames.get().countDown(); + } + frame.release(); + }; + track.addSink(sink); + try { + call.awaitConnected(); + RTCRtpSender sender = call.getVideoSender(); + RTCRtpSendParameters parameters = sender.getParameters(); + parameters.degradationPreference = RTCDegradationPreference.MAINTAIN_RESOLUTION; + parameters.encodings.get(0).scalabilityMode = mode; + parameters.encodings.get(0).minBitrate = startAtBase ? 30_000 : 2_000_000; + parameters.encodings.get(0).maxBitrate = startAtBase ? 100_000 : 3_000_000; + sender.setParameters(parameters); + call.startMedia(); + assertTrue(frames.get().await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "no initial spatial layer"); + String implementation = decoderImplementationOf(call); + expectVp9Hardware(implementation); + assumeTrue(implementation.startsWith("VA-API"), implementation); + assertEquals(mode, call.getOutboundVideoStats().get("scalabilityMode")); + + // Keep the source and SVC mode unchanged. The bitrate allocator + // disables upper spatial layers, then enables them again. + int[] widths = fullWidth == 1280 + ? new int[] { 640, 320, 640, 1280, 320, 1280 } + : new int[] { 320, 640, 320, 640 }; + for (int width : widths) { + expectedWidth.set(width); + CountDownLatch received = new CountDownLatch(10); + frames.set(received); + parameters = sender.getParameters(); + parameters.encodings.get(0).minBitrate = width == 320 ? 30_000 + : width == fullWidth ? 2_000_000 : 400_000; + parameters.encodings.get(0).maxBitrate = width == 320 ? 100_000 + : width == fullWidth ? 3_000_000 : 500_000; + sender.setParameters(parameters); + assertTrue(received.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), "no frames at width " + width); + assertEquals(implementation, decoderImplementationOf(call)); + } + // Starting with only the base layer may require new surfaces as + // upper layers arrive. At most one request per growth step. + long pliCount = count(call.getInboundVideoStats(), "pliCount"); + assertTrue(pliCount <= (startAtBase ? 3 : 2), "key frames requested: " + pliCount); + } + finally { + track.removeSink(sink); + receiver.dispose(); + } + } + finally { + hardware.dispose(); + } + } + @Test void av1NeedsNoKeyFrames() throws Exception { CallResult result = receiveVideo(hardwareFactory(), AV1, 320, 240, null, 60);