diff --git a/.gitmodules b/.gitmodules index 41352019857..61313fd2963 100644 --- a/.gitmodules +++ b/.gitmodules @@ -37,6 +37,10 @@ path = third-party/plasma-wayland-protocols url = https://github.com/KDE/plasma-wayland-protocols.git branch = master +[submodule "third-party/pyrowave"] + path = third-party/pyrowave + url = https://github.com/Themaister/pyrowave.git + branch = master [submodule "third-party/Simple-Web-Server"] path = third-party/Simple-Web-Server url = https://github.com/LizardByte-infrastructure/Simple-Web-Server.git diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index e3292b9a7ff..5e17789cc1a 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -106,6 +106,7 @@ if(WIN32) # add_nvenc_sdk_implementation: args = `target_name`, `sdk_version`, `sdk_include_dir` function(add_nvenc_sdk_implementation target_name sdk_version sdk_include_dir) add_library(${target_name} OBJECT ${NVENC_IMPLEMENTATION_SOURCES}) + target_link_libraries(${target_name} PRIVATE sunshine_pyrowave) target_include_directories(${target_name} BEFORE PRIVATE "${sdk_include_dir}") target_compile_definitions(${target_name} PRIVATE NVENC_FACTORY_SUFFIX=${sdk_version} diff --git a/cmake/dependencies/common.cmake b/cmake/dependencies/common.cmake index 9c050969f49..5d833355f18 100644 --- a/cmake/dependencies/common.cmake +++ b/cmake/dependencies/common.cmake @@ -98,6 +98,7 @@ include_directories(SYSTEM ${MINIUPNP_INCLUDE_DIRS}) # ffmpeg pre-compiled binaries include("${CMAKE_MODULE_PATH}/dependencies/ffmpeg.cmake") +include("${CMAKE_MODULE_PATH}/dependencies/pyrowave.cmake") # Opus if(TARGET PkgConfig::SUNSHINE_BUNDLED_OPUS) diff --git a/cmake/dependencies/pyrowave.cmake b/cmake/dependencies/pyrowave.cmake new file mode 100644 index 00000000000..9e8744d030c --- /dev/null +++ b/cmake/dependencies/pyrowave.cmake @@ -0,0 +1,60 @@ +# Build the pinned PyroWave C API into Sunshine. +include_guard(GLOBAL) + +# Build the codec and C API without exposing upstream options to Sunshine. +function(sunshine_add_pyrowave) + set(pyrowave_source_dir "${CMAKE_SOURCE_DIR}/third-party/pyrowave") + # Defer configuring Granite until the options below are set, including local source overrides. + CPMAddPackage(NAME pyrowave_granite DOWNLOAD_ONLY YES) + + # Scope upstream options to the dependency build and use its precompiled shaders. + # cmake-lint: disable=C0103 + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + set(GRANITE_SHARED OFF) + set(GRANITE_TOOLS OFF) + set(GRANITE_RENDERER OFF) + set(GRANITE_PLATFORM "null") + set(GRANITE_INSTALL_TARGETS OFF) + set(GRANITE_HIDDEN ON) + set(GRANITE_POSITION_INDEPENDENT ON) + set(GRANITE_SHIPPING ON) + set(GRANITE_FFMPEG OFF) + set(GRANITE_FFMPEG_VULKAN OFF) + set(GRANITE_SHADER_COMPILER_OPTIMIZE OFF) + set(GRANITE_VULKAN_SHADER_MANAGER_RUNTIME_COMPILER OFF) + set(GRANITE_VULKAN_SPIRV_CROSS OFF) + set(GRANITE_VULKAN_FOSSILIZE OFF) + set(GRANITE_VULKAN_SYSTEM_HANDLES OFF) + set(GRANITE_VULKAN_DXGI_INTEROP OFF) + set(GRANITE_RENDERDOC_CAPTURE OFF) + add_subdirectory("${pyrowave_granite_SOURCE_DIR}" "${pyrowave_granite_BINARY_DIR}" EXCLUDE_FROM_ALL) + + # Keep Volk's function pointers distinct from the Vulkan loader used by FFmpeg. + target_compile_definitions(granite-volk-headers INTERFACE VOLK_NAMESPACE) + set_source_files_properties("${pyrowave_granite_SOURCE_DIR}/third_party/volk/volk.c" + TARGET_DIRECTORY granite-volk PROPERTIES LANGUAGE CXX) + + set(PYROWAVE_DEVEL OFF) + set(PYROWAVE_UTILS OFF) + add_subdirectory("${pyrowave_source_dir}" "${CMAKE_BINARY_DIR}/third-party/pyrowave" EXCLUDE_FROM_ALL) + + # Upstream exposes the C API only for standalone builds; add its wrapper here. + add_library(sunshine_pyrowave STATIC + "${pyrowave_source_dir}/pyrowave_c.cpp" + "${pyrowave_granite_SOURCE_DIR}/video/scaler.cpp") + target_include_directories(sunshine_pyrowave PRIVATE + "${pyrowave_source_dir}/shaders" "${pyrowave_granite_SOURCE_DIR}/video") + target_include_directories(sunshine_pyrowave SYSTEM INTERFACE + "${pyrowave_source_dir}" + "${pyrowave_granite_SOURCE_DIR}/third_party/khronos/vulkan-headers/include") + target_link_libraries(sunshine_pyrowave PRIVATE pyrowave granite-vulkan granite-math Threads::Threads) + + # Clients use the vendored revision to diagnose bitstream compatibility. + execute_process(COMMAND git -C "${pyrowave_source_dir}" rev-parse --short=8 HEAD + OUTPUT_VARIABLE pyrowave_bitstream_id OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + target_compile_definitions(sunshine_pyrowave INTERFACE + SUNSHINE_PYROWAVE_BITSTREAM_ID="${pyrowave_bitstream_id}") +endfunction() + +sunshine_add_pyrowave() +list(APPEND SUNSHINE_EXTERNAL_LIBRARIES sunshine_pyrowave) diff --git a/docs/configuration.md b/docs/configuration.md index 2d81d8c0dd0..5c6bb1c6bec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2096,6 +2096,7 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin Percentage of error correcting packets per data packet in each video frame. Set to 0 to disable forward error correction. + PyroWave protects only critical data when the client supports record framing. @warning{Higher values can correct for more network packet loss, but at the cost of increasing bandwidth usage.} @@ -2252,6 +2253,33 @@ consent prompt for directory ACLs, so the Web UI shows setup steps for correctin +### pyrowave + + + + + + + + + + + + + + +
Description + Allows compatible clients to request PyroWave 8-bit or 10-bit 4:2:0 or 4:4:4 video streams. + Support is advertised based on encoder capabilities. HDR requires a supported capture backend + and HDR enabled on the host display and client. + Transport framing is negotiated with the client. + @warning{PyroWave consumes a significant amount of bandwidth. Not recommended unless on LAN and utilizing a hardwired connection.} +
Default@code{} + enabled + @endcode
Example@code{} + pyrowave = disabled + @endcode
+ ### capture diff --git a/package-lock.cmake b/package-lock.cmake index 5231caa5a9a..94224707685 100644 --- a/package-lock.cmake +++ b/package-lock.cmake @@ -46,3 +46,14 @@ CPMDeclarePackage(Boost URL_HASH SHA256=${BOOST_SHA256} DOWNLOAD_ONLY YES ) + +# Granite/PyroWave (no official releases/versioning, so the commit hash is tracked) +set(PYROWAVE_GRANITE_TAG fb178c8080d163419e8d20f10715c61c53c1ec9b) +CPMDeclarePackage(pyrowave_granite + NAME pyrowave_granite + GITHUB_REPOSITORY Themaister/Granite + GIT_TAG ${PYROWAVE_GRANITE_TAG} + GIT_SUBMODULES third_party/volk third_party/khronos/vulkan-headers + GIT_SUBMODULES_RECURSE OFF + DOWNLOAD_ONLY YES +) diff --git a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml index 7e03d69bff5..f266b0016b7 100644 --- a/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml +++ b/packaging/linux/flatpak/dev.lizardbyte.app.Sunshine.yml @@ -80,6 +80,7 @@ modules: - -DBUILD_WERROR=ON - -DCMAKE_BUILD_TYPE=Release - -DCMAKE_CUDA_COMPILER=/app/cuda/bin/nvcc + - -DCPM_pyrowave_granite_SOURCE=/run/build/sunshine/third-party/pyrowave-granite - -DFFMPEG_PREPARED_BINARIES=/app/ffmpeg - -DGLAD_SKIP_PIP_INSTALL=ON - -DSUNSHINE_ASSETS_DIR=share/sunshine @@ -107,5 +108,20 @@ modules: - type: git url: "@GITHUB_CLONE_URL@" commit: "@GITHUB_COMMIT@" + - type: git + url: https://github.com/Themaister/Granite.git + commit: "@PYROWAVE_GRANITE_TAG@" + disable-submodules: true + dest: third-party/pyrowave-granite + - type: git + url: https://github.com/zeux/volk.git + commit: 47cddf7ed97b94118a08aacb548a411188e016cc + disable-submodules: true + dest: third-party/pyrowave-granite/third_party/volk + - type: git + url: https://github.com/KhronosGroup/Vulkan-Headers.git + commit: 6802bb4733b63ed5efd3adb308a6c885ef180ea1 + disable-submodules: true + dest: third-party/pyrowave-granite/third_party/khronos/vulkan-headers - type: file path: package-lock.json diff --git a/src/config.cpp b/src/config.cpp index de28fe97c6f..6b616991859 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -711,6 +711,7 @@ namespace config { 0, // hevc_mode 0, // av1_mode + true, // pyrowave 2, // min_threads { @@ -1600,6 +1601,7 @@ namespace config { int_f(vars, "qp", video.qp); int_between_f(vars, "hevc_mode", video.hevc_mode, {0, 3}); int_between_f(vars, "av1_mode", video.av1_mode, {0, 3}); + bool_f(vars, "pyrowave", video.pyrowave); int_f(vars, "min_threads", video.min_threads); string_f(vars, "sw_preset", video.sw.sw_preset); if (!video.sw.sw_preset.empty()) { diff --git a/src/config.h b/src/config.h index ead9e9812db..e997fabb04d 100644 --- a/src/config.h +++ b/src/config.h @@ -75,6 +75,7 @@ namespace config { int hevc_mode; ///< HEVC support mode advertised to clients. int av1_mode; ///< AV1 support mode advertised to clients. + bool pyrowave; ///< Advertise successfully probed PyroWave profiles unless explicitly disabled. int min_threads; ///< Minimum number of threads or slices for CPU encoding. diff --git a/src/nvhttp.cpp b/src/nvhttp.cpp index bf1bf6a6c80..edbe00d2719 100644 --- a/src/nvhttp.cpp +++ b/src/nvhttp.cpp @@ -1230,6 +1230,7 @@ namespace nvhttp { if ((video::active_av1_mode == 4 || video::active_av1_mode == 5) && video::last_encoder_probe_supported_dynamic_range_yuv444_for_codec[2]) { codec_mode_flags |= SCM_AV1_HIGH10_444; } + codec_mode_flags |= video::active_pyrowave_profiles & video::pyrowave::scm_mask; return codec_mode_flags; } diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp index 917c5c390ad..a79cc6bff16 100644 --- a/src/platform/windows/display_vram.cpp +++ b/src/platform/windows/display_vram.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include // platform includes @@ -2203,3 +2204,198 @@ namespace platf::dxgi { return 0; } } // namespace platf::dxgi + +namespace video::pyrowave { + /** + * @brief Share converted D3D11 pixels and a timeline fence with Vulkan on the same adapter. + */ + class d3d11_input_t final: public gpu_input_t { + public: + using gpu_input_t::gpu_input_t; + + /** + * @brief Release imports before destroying the Vulkan device and their D3D11 resources. + */ + ~d3d11_input_t() override { + if (image) { + api.destroy_image(image); + } + if (sync) { + api.destroy_sync(sync); + } + if (device) { + api.destroy_device(device); + } + } + + /** + * @brief Import reusable shared resources and let PyroWave describe their sampled image views. + * @param frames D3D11 conversion pool. + * @return True when texture and fence imports succeed. + */ + bool init(AVHWFramesContext *frames) { + if (!frames->device_ctx || frames->device_ctx->type != AV_HWDEVICE_TYPE_D3D11VA) { + return false; + } + const auto format = frames->sw_format; + VkFormat image_format; + switch (format) { + case AV_PIX_FMT_NV12: + dxgi_format = DXGI_FORMAT_NV12; + image_format = VK_FORMAT_G8_B8R8_2PLANE_420_UNORM; + break; + case AV_PIX_FMT_P010: + dxgi_format = DXGI_FORMAT_P010; + image_format = VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16; + break; + case AV_PIX_FMT_VUYX: + dxgi_format = DXGI_FORMAT_AYUV; + image_format = VK_FORMAT_R8G8B8A8_UNORM; + break; + case AV_PIX_FMT_XV30: + dxgi_format = DXGI_FORMAT_Y410; + image_format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; + break; + default: + return false; + } + auto source = static_cast(frames->device_ctx->hwctx)->device; + capture_device = source; + platf::dxgi::dxgi_t dxgi; + util::safe_ptr> adapter; + platf::dxgi::device_ctx_t immediate; + DXGI_ADAPTER_DESC description {}; + if (FAILED(source->QueryInterface(IID_ID3D11Device5, (void **) &d3d_device)) || FAILED(source->QueryInterface(IID_IDXGIDevice, (void **) &dxgi)) || FAILED(dxgi->GetAdapter(&adapter)) || FAILED(adapter->GetDesc(&description))) { + return false; + } + source->GetImmediateContext(&immediate); + if (FAILED(immediate->QueryInterface(IID_ID3D11DeviceContext4, (void **) &context))) { + return false; + } + pyrowave_luid luid {}; + static_assert(sizeof(luid) == sizeof(description.AdapterLuid)); + std::memcpy(&luid, &description.AdapterLuid, sizeof(luid)); + if (api.match_device(0, 0, nullptr, nullptr, &luid, VK_QUEUE_GLOBAL_PRIORITY_MEDIUM, &device) != PYROWAVE_SUCCESS) { + return false; + } + width = frames->width; + height = frames->height; + D3D11_TEXTURE2D_DESC texture_info {}; + texture_info.Width = width; + texture_info.Height = height; + texture_info.MipLevels = 1; + texture_info.ArraySize = 1; + texture_info.Format = dxgi_format; + texture_info.SampleDesc.Count = 1; + texture_info.Usage = D3D11_USAGE_DEFAULT; + texture_info.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + texture_info.MiscFlags = D3D11_RESOURCE_MISC_SHARED | D3D11_RESOURCE_MISC_SHARED_NTHANDLE; + platf::dxgi::resource1_t resource; + util::safe_ptr_v2 texture_handle; + util::safe_ptr_v2 fence_handle; + if (FAILED(d3d_device->CreateTexture2D(&texture_info, nullptr, &texture)) || FAILED(texture->QueryInterface(IID_IDXGIResource1, (void **) &resource)) || FAILED(resource->CreateSharedHandle(nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, nullptr, &texture_handle)) || FAILED(d3d_device->CreateFence(0, D3D11_FENCE_FLAG_SHARED, IID_ID3D11Fence, (void **) &fence)) || FAILED(fence->CreateSharedHandle(nullptr, GENERIC_ALL, nullptr, &fence_handle))) { + return false; + } + VkImageCreateInfo image_info {VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + image_info.flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT; + image_info.imageType = VK_IMAGE_TYPE_2D; + image_info.format = image_format; + image_info.extent = {uint32_t(width), uint32_t(height), 1}; + image_info.mipLevels = 1; + image_info.arrayLayers = 1; + image_info.samples = VK_SAMPLE_COUNT_1_BIT; + image_info.tiling = VK_IMAGE_TILING_OPTIMAL; + image_info.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + image_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + pyrowave_image_create_info import {}; + import.device = device; + import.external_handle = reinterpret_cast(texture_handle.get()); + import.handle_type = VK_EXTERNAL_MEMORY_HANDLE_TYPE_D3D11_TEXTURE_BIT; + import.image_create_info = &image_info; + if (api.import_image(&import, &image) != PYROWAVE_SUCCESS) { + return false; + } + texture_handle.release(); // Successful imports take ownership of the NT handle. + pyrowave_sync_object_create_info sync_info {}; + sync_info.device = device; + sync_info.external_handle = reinterpret_cast(fence_handle.get()); + sync_info.handle_type = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_D3D12_FENCE_BIT; + sync_info.semaphore_type = VK_SEMAPHORE_TYPE_TIMELINE; + if (api.import_sync(&sync_info, &sync) != PYROWAVE_SUCCESS) { + return false; + } + fence_handle.release(); + if (format == AV_PIX_FMT_VUYX || format == AV_PIX_FMT_XV30) { + pyrowave_image_view view {}; + return api.image_view(image, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_USAGE_SAMPLED_BIT, &view) == PYROWAVE_SUCCESS && + map_gpu_frame(format, std::span(&view, 1), buffers, scaling_factor); + } + for (int component = 0; component < 3; ++component) { + if (api.image_view(image, VkImageAspectFlagBits(VK_IMAGE_ASPECT_PLANE_0_BIT << component), VK_IMAGE_USAGE_SAMPLED_BIT, &buffers.planes[component]) != PYROWAVE_SUCCESS) { + return false; + } + } + return true; + } + + /** @copydoc gpu_input_t::prepare */ + bool prepare(AVFrame *frame) override { + if (frame->format != AV_PIX_FMT_D3D11 || !frame->data[0] || frame->width != width || frame->height != height) { + return false; + } + auto source = reinterpret_cast(frame->data[0]); + platf::dxgi::device_t source_device; + source->GetDevice(&source_device); + D3D11_TEXTURE2D_DESC description {}; + source->GetDesc(&description); + const auto layer = reinterpret_cast(frame->data[1]); + if (source_device.get() != capture_device || description.Format != dxgi_format || description.SampleDesc.Count != 1 || description.MipLevels != 1 || layer >= description.ArraySize || description.Width < uint32_t(width) || description.Height < uint32_t(height)) { + return false; + } + if (timeline && FAILED(context->Wait(fence.get(), timeline))) { + return false; + } + const D3D11_BOX region {0, 0, 0, uint32_t(width), uint32_t(height), 1}; + context->CopySubresourceRegion(texture.get(), 0, 0, 0, 0, source, UINT(layer), ®ion); + if (FAILED(context->Signal(fence.get(), ++timeline))) { + return false; + } + context->Flush(); + return true; + } + + /** @copydoc gpu_input_t::encode */ + pyrowave_result encode(pyrowave_encoder encoder, const pyrowave_rate_control &rate_control) override { + const pyrowave_gpu_external_reference reference {image, VK_QUEUE_FAMILY_EXTERNAL}; + const auto semaphore = api.sync_semaphore(sync); + const pyrowave_gpu_sync_operation acquire {&reference, 1, {semaphore, timeline}}; + const pyrowave_gpu_sync_operation release {&reference, 1, {semaphore, timeline + 1}}; + api.set_scaling_factor(encoder, scaling_factor); + const auto result = api.encode_gpu(encoder, &acquire, &release, &buffers, &rate_control); + if (result == PYROWAVE_SUCCESS) { + ++timeline; + } + return result; + } + + private: + util::safe_ptr> d3d_device; ///< Capture adapter's device. + ID3D11Device *capture_device = nullptr; ///< Base interface retained through d3d_device, used to validate source textures. + util::safe_ptr> context; ///< Conversion and copy command stream. + util::safe_ptr> fence; ///< Shared timeline protecting the texture. + platf::dxgi::texture2d_t texture; ///< Reusable shared copy of the converter's output. + pyrowave_image image {}; ///< Vulkan import of texture. + pyrowave_sync_object sync {}; ///< Vulkan import of fence. + pyrowave_gpu_buffers buffers {}; ///< Component views of the imported texture. + float scaling_factor = 1.0f; ///< Normalization of the imported pixel format. + uint64_t timeline = 0; ///< Last value queued by D3D11 or Vulkan. + DXGI_FORMAT dxgi_format = DXGI_FORMAT_UNKNOWN; ///< Native texture layout. + int width = 0; ///< Conversion output width. + int height = 0; ///< Conversion output height. + }; + + std::unique_ptr make_d3d11_input(AVHWFramesContext *frames, const api_t &api) { + auto input = std::make_unique(api); + return input->init(frames) ? std::move(input) : nullptr; + } +} // namespace video::pyrowave diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 4532c2b09c5..674ec54c3ce 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -13,6 +13,7 @@ extern "C" { // standard includes #include #include +#include #include #include #include @@ -910,6 +911,13 @@ namespace rtsp_stream { ss << "a=rtpmap:98 AV1/90000"sv << std::endl; } + if (video::active_pyrowave_profiles != 0) { + ss << "a=rtpmap:99 PYROWAVE/90000"sv << std::endl; + if (std::string_view {SUNSHINE_PYROWAVE_BITSTREAM_ID}.size()) { + ss << "a=x-ss-pyrowave.bitstream:"sv << SUNSHINE_PYROWAVE_BITSTREAM_ID << std::endl; + } + } + if (!session.surround_params.empty()) { // If we have our own surround parameters, advertise them twice first ss << "a=fmtp:97 surround-params="sv << session.surround_params << std::endl; @@ -1013,6 +1021,39 @@ namespace rtsp_stream { respond(sock, session, &seqn, 200, "OK", req->sequenceNumber, {}); } + /** + * @brief Select PyroWave framing from client capabilities and reject unsupported transports. + * + * @param config Stream configuration containing the final negotiated packet size. + * @param args Client ANNOUNCE attributes. + * @return True when the requested transport is supported. + */ + bool configure_pyrowave(stream::config_t &config, const std::unordered_map &args) { + if (config.monitor.videoFormat != video::pyrowave::video_format) { + return true; + } + uint32_t features = 0; + for (auto name : {"x-ss-video[0].pyrowaveFeatures"sv, "x-ss-video[0].pyrowaveCompression"sv, "x-ss-video[0].pyrowaveHybrid"sv}) { + auto value = args.find(name); + if (value == args.end()) { + continue; + } + uint32_t parsed = 0; + auto [end, error] = std::from_chars(value->second.data(), value->second.data() + value->second.size(), parsed); + if (error != std::errc {} || end != value->second.data() + value->second.size()) { + return false; + } + if (name == "x-ss-video[0].pyrowaveFeatures"sv) { + features = parsed; + } else if (parsed != 0) { + return false; + } + } + config.monitor.pyrowaveRecordFraming = args.contains("x-ss-video[0].pyrowaveAdaptiveFec"sv) || (features & 1); + config.monitor.packetSize = config.packetsize; + return config.packetsize >= config::PACKETSIZE_MIN && config.packetsize <= config::PACKETSIZE_MAX && config.minRequiredFecPackets >= 0; + } + /** * @brief Handle RTSP ANNOUNCE requests from the client. * @@ -1066,7 +1107,7 @@ namespace rtsp_stream { auto name = line.substr(2, pos - 2); auto val = line.substr(pos + 1); - if (val[val.size() - 1] == ' ') { + if (!val.empty() && val.back() == ' ') { val = val.substr(0, val.size() - 1); } args.try_emplace(name, val); @@ -1161,6 +1202,12 @@ namespace rtsp_stream { return; } + if (!configure_pyrowave(config, args)) { + BOOST_LOG(warning) << "Client requested an unsupported PyroWave transport"; + respond(sock, session, &option, 400, "BAD REQUEST", req->sequenceNumber, {}); + return; + } + // When using stereo audio, the audio quality is (strangely) indicated by whether the Host field // in the RTSP message matches a local interface's IP address. Fortunately, Moonlight always sends // 0.0.0.0 when it wants low quality, so it is easy to check without enumerating interfaces. @@ -1226,6 +1273,12 @@ namespace rtsp_stream { config.monitor.bitrate = (int) configuredBitrateKbps; } + if (config.monitor.videoFormat < 0 || config.monitor.videoFormat > video::pyrowave::video_format || (config.monitor.videoFormat == video::pyrowave::video_format && !video::pyrowave::supported(config.monitor, video::active_pyrowave_profiles))) { + BOOST_LOG(warning) << "Client requested an unavailable video format or PyroWave profile"; + respond(sock, session, &option, 400, "BAD REQUEST", req->sequenceNumber, {}); + return; + } + if (config.monitor.videoFormat == 1 && video::active_hevc_mode == 1) { BOOST_LOG(warning) << "HEVC is disabled, yet the client requested HEVC"sv; diff --git a/src/stream.cpp b/src/stream.cpp index a222b4045e2..92038ffb6f1 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -123,11 +123,11 @@ namespace stream { // zero padding, such as AV1 (Sunshine extension). boost::endian::little_uint16_at lastPayloadLen; ///< Last payload len. - std::array unknown; ///< Reserved bytes with no known client-visible meaning. + boost::endian::little_uint16_at pyrowave_critical_packets; ///< Critical prefix length in data packets; zero for other codecs. }; static_assert( - sizeof(video_short_frame_header_t) == 8, + sizeof(video_short_frame_header_t) == video::pyrowave::frame_header_size, "Short frame header must be 8 bytes" ); @@ -150,6 +150,8 @@ namespace stream { NV_VIDEO_PACKET packet; ///< GameStream video packet header. }; + static_assert(sizeof(video_packet_raw_t) - MAX_RTP_HEADER_SIZE == video::pyrowave::shard_overhead); + /** * @brief AES-GCM prefix written before encrypted video packet payloads. */ @@ -949,6 +951,27 @@ namespace stream { } } // namespace fec + std::vector pyrowave_fec_blocks(size_t data_shards, size_t critical_shards, size_t percentage, size_t minimum_parity) { + // The wire header allows four blocks and ten bits for each block's data count. + constexpr size_t max_data_shards = 1023; + if (!data_shards || data_shards > 4 * max_data_shards || critical_shards > data_shards || percentage > 100) { + return {}; + } + std::vector blocks; + const auto parity = std::max((critical_shards * percentage + 99) / 100, std::max(size_t(2), minimum_parity)); + // FEC has a 255-shard limit; its percentage also occupies only eight bits. + if (critical_shards && percentage && critical_shards <= RS8_DATA_SHARDS_MAX && parity <= RS8_DATA_SHARDS_MAX - critical_shards && parity * 100 / critical_shards <= 255 && data_shards - critical_shards <= 3 * max_data_shards) { + blocks.push_back({critical_shards, percentage}); + data_shards -= critical_shards; + } + while (data_shards) { + const auto count = std::min(data_shards, max_data_shards); + blocks.push_back({count, 0}); + data_shards -= count; + } + return blocks; + } + /** * @brief Combines two buffers and inserts new buffers at each slice boundary of the result. * @param insert_size The number of bytes to insert. @@ -1682,7 +1705,9 @@ namespace stream { frame_header.frame_processing_latency = 0; } - auto fecPercentage = config::stream.fec_percentage; + const bool pyrowave = session->config.monitor.videoFormat == video::pyrowave::video_format; + frame_header.pyrowave_critical_packets = packet->pyrowave_critical_packets; + auto fecPercentage = pyrowave ? 0 : config::stream.fec_percentage; // Insert space for packet headers auto blocksize = session->config.packetsize + MAX_RTP_HEADER_SIZE; @@ -1694,46 +1719,63 @@ namespace stream { // There are 2 bits for FEC block count for a maximum of 4 FEC blocks constexpr auto MAX_FEC_BLOCKS = 4; - auto max_data_shards_per_fec_block = fec::max_data_shards(fecPercentage); - - // Compute the number of FEC blocks needed for this frame using the block size and max shards - auto max_data_per_fec_block = max_data_shards_per_fec_block * blocksize; - auto fec_blocks_needed = (payload.size() + (max_data_per_fec_block - 1)) / max_data_per_fec_block; - - // If the number of FEC blocks needed exceeds the protocol limit, turn off FEC for this frame. - // For normal FEC percentages, this should only happen for enormous frames (over 800 packets at 20%). - if (fec_blocks_needed > MAX_FEC_BLOCKS) { - BOOST_LOG(warning) << "Skipping FEC for abnormally large encoded frame (needed "sv << fec_blocks_needed << " FEC blocks)"sv; - fecPercentage = 0; - fec_blocks_needed = MAX_FEC_BLOCKS; - } - std::array fec_blocks; - auto fec_blocks_begin = std::begin(fec_blocks); - auto fec_blocks_end = std::begin(fec_blocks) + fec_blocks_needed; - - BOOST_LOG(verbose) << "Generating "sv << fec_blocks_needed << " FEC blocks"sv; + std::array fec_percentages {}; + size_t fec_blocks_needed; + const auto minimum_parity = pyrowave ? std::max(2, session->config.minRequiredFecPackets) : session->config.minRequiredFecPackets; + if (pyrowave) { + const auto plan = pyrowave_fec_blocks((payload.size() + blocksize - 1) / blocksize, packet->pyrowave_critical_packets, config::stream.fec_percentage, minimum_parity); + if (plan.empty()) { + BOOST_LOG(warning) << "PyroWave frame exceeds the video packet limit"; + continue; + } + fec_blocks_needed = plan.size(); + size_t offset = 0; + for (size_t block = 0; block < plan.size(); ++block) { + fec_blocks[block] = payload.substr(offset, plan[block].data_shards * blocksize); + fec_percentages[block] = plan[block].percentage; + offset += fec_blocks[block].size(); + } + } else { + auto max_data_shards_per_fec_block = fec::max_data_shards(fecPercentage); + + // Compute the number of FEC blocks needed for this frame using the block size and max shards + auto max_data_per_fec_block = max_data_shards_per_fec_block * blocksize; + fec_blocks_needed = (payload.size() + (max_data_per_fec_block - 1)) / max_data_per_fec_block; + + // If the number of FEC blocks needed exceeds the protocol limit, turn off FEC for this frame. + // For normal FEC percentages, this should only happen for enormous frames (over 800 packets at 20%). + if (fec_blocks_needed > MAX_FEC_BLOCKS) { + BOOST_LOG(warning) << "Skipping FEC for abnormally large encoded frame (needed "sv << fec_blocks_needed << " FEC blocks)"sv; + fecPercentage = 0; + fec_blocks_needed = MAX_FEC_BLOCKS; + } - // Align individual FEC blocks to blocksize - auto unaligned_size = payload.size() / fec_blocks_needed; - auto aligned_size = ((unaligned_size + (blocksize - 1)) / blocksize) * blocksize; + // Align individual FEC blocks to blocksize + auto unaligned_size = payload.size() / fec_blocks_needed; + auto aligned_size = ((unaligned_size + (blocksize - 1)) / blocksize) * blocksize; - // If we exceed the 10-bit FEC packet index (which means our frame exceeded 4096 packets), - // the frame will be unrecoverable. Log an error for this case. - if (aligned_size / blocksize >= 1024) { - BOOST_LOG(error) << "Encoder produced a frame too large to send! Is the encoder broken? (needed "sv << (aligned_size / blocksize) << " packets)"sv; - } + // If we exceed the 10-bit FEC packet index (which means our frame exceeded 4096 packets), + // the frame will be unrecoverable. Log an error for this case. + if (aligned_size / blocksize >= 1024) { + BOOST_LOG(error) << "Encoder produced a frame too large to send! Is the encoder broken? (needed "sv << (aligned_size / blocksize) << " packets)"sv; + } - // Split the data into aligned FEC blocks - for (int x = 0; x < fec_blocks_needed; ++x) { - if (x == fec_blocks_needed - 1) { - // The last block must extend to the end of the payload - fec_blocks[x] = payload.substr(x * aligned_size); - } else { - // Earlier blocks just extend to the next block offset - fec_blocks[x] = payload.substr(x * aligned_size, aligned_size); + // Split the data into aligned FEC blocks + for (int x = 0; x < fec_blocks_needed; ++x) { + if (x == fec_blocks_needed - 1) { + // The last block must extend to the end of the payload + fec_blocks[x] = payload.substr(x * aligned_size); + } else { + // Earlier blocks just extend to the next block offset + fec_blocks[x] = payload.substr(x * aligned_size, aligned_size); + } } + fec_percentages.fill(fecPercentage); } + auto fec_blocks_begin = std::begin(fec_blocks); + auto fec_blocks_end = fec_blocks_begin + fec_blocks_needed; + BOOST_LOG(verbose) << "Generating "sv << fec_blocks_needed << " FEC blocks"sv; try { // Use around 80% of 1Gbps 1Gbps percent ms packet byte @@ -1756,6 +1798,7 @@ namespace stream { size_t ratecontrol_group_packets_sent = 0; auto blockIndex = 0; + size_t data_shards_sent = 0; std::for_each(fec_blocks_begin, fec_blocks_end, [&](std::string_view ¤t_payload) { auto payload_packets = (current_payload.size() + (blocksize - 1)) / blocksize; @@ -1769,6 +1812,11 @@ namespace stream { inspect->packet.multiFecFlags = 0x10; inspect->packet.multiFecBlocks = (blockIndex << 4) | ((fec_blocks_needed - 1) << 6); + const auto data_index = data_shards_sent + x; + // Record clients can resume decoding at these boundaries after packet loss. + if (data_index < packet->pyrowave_record_starts.size() && packet->pyrowave_record_starts[data_index]) { + inspect->packet.extraFlags |= 0x80; + } inspect->packet.flags = FLAG_CONTAINS_PIC_DATA; if (x == 0) { inspect->packet.flags |= FLAG_SOF; @@ -1780,7 +1828,7 @@ namespace stream { frame_fec_latency_logger.first_point_now(); // If video encryption is enabled, we allocate space for the encryption header before each shard - auto shards = fec::encode(current_payload, blocksize, fecPercentage, session->config.minRequiredFecPackets, session->video.cipher ? sizeof(video_packet_enc_prefix_t) : 0); + auto shards = fec::encode(current_payload, blocksize, fec_percentages[blockIndex], minimum_parity, session->video.cipher ? sizeof(video_packet_enc_prefix_t) : 0); frame_fec_latency_logger.second_point_now_and_log(); auto peer_address = session->video.peer.address(); @@ -1908,6 +1956,7 @@ namespace stream { << (packet->is_idr() ? " Key" : "") << (packet->after_ref_frame_invalidation ? " RFI" : ""); + data_shards_sent += payload_packets; ++blockIndex; lowseq += shards.size(); }); diff --git a/src/stream.h b/src/stream.h index c0b0f962378..bfab91231b3 100644 --- a/src/stream.h +++ b/src/stream.h @@ -6,6 +6,7 @@ // standard includes #include +#include // lib includes #include @@ -22,6 +23,25 @@ namespace stream { struct session_t; + /** + * @brief Data shard count and parity percentage for one PyroWave FEC block. + */ + struct video_fec_block_t { + size_t data_shards; ///< Consecutive RTP data packets in this block. + size_t percentage; ///< Recovery percentage, zero for unprotected detail packets. + }; + + /** + * @brief Protect the critical PyroWave prefix while fitting the existing four-block transport. + * + * @param data_shards Total RTP data packets in the frame. + * @param critical_shards Leading packets required for decoding. + * @param percentage Host recovery percentage for the critical prefix. + * @param minimum_parity Client's minimum recovery packet count. + * @return At most four blocks, or an empty plan when the frame cannot be represented. + */ + std::vector pyrowave_fec_blocks(size_t data_shards, size_t critical_shards, size_t percentage, size_t minimum_parity); + /** * @brief Stream configuration shared by capture and network senders. */ diff --git a/src/video.cpp b/src/video.cpp index 995e8a73a1e..8bfc16123c4 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -16,7 +17,9 @@ #include extern "C" { +#include #include +#include #include #include #include @@ -43,6 +46,12 @@ extern "C" { } #endif +#ifdef SUNSHINE_BUILD_VULKAN +extern "C" { + #include +} +#endif + using namespace std::literals; namespace video { @@ -167,6 +176,10 @@ namespace video { * @return Effective stream configuration, downgraded to SDR when HDR is unsupported. */ config_t resolve_dynamic_range(const encoder_t &encoder, config_t config) { + // PyroWave profiles are independently probed and validated during ANNOUNCE. + if (config.videoFormat == pyrowave::video_format) { + return config; + } if (!config.dynamicRange) { return config; } @@ -613,6 +626,967 @@ namespace video { bool force_idr = false; }; + namespace pyrowave { + size_t frame_budget(const config_t &config) { + if (config.bitrate <= 0 || config.framerate <= 0 || config.framerateX100 < 0 || config.packetSize <= int(shard_overhead) || config.packetSize > config::PACKETSIZE_MAX) { + return 0; + } + const auto rate = config.framerateX100 ? uint64_t(config.framerateX100) : uint64_t(config.framerate) * 100; + const auto bitrate = config::video.max_bitrate > 0 ? std::min(config.bitrate, config::video.max_bitrate) : config.bitrate; + const auto budget = uint64_t(bitrate) * 1000 * 100 / (8 * rate); + if (!budget || budget > max_frame_size) { + return 0; + } + + const auto payload_size = size_t(config.packetSize) - shard_overhead; + auto capacity = std::min(max_frame_size, max_frame_packets * payload_size - frame_header_size); + // The codec budget already includes the eight-byte sequence and block headers. + if (!config.pyrowaveRecordFraming) { + // Each native packet is at least eight bytes and needs a four-byte length, plus the packet count. + capacity = (capacity - 4) * 2 / 3; + } else if (payload_size >= 24 && payload_size % 4 == 0) { + // Padding costs at most one record's size plus four bytes per record (minimum eight bytes). + // Reserve another payload for the critical/detail boundary: framed size <= 5/2 * budget + payload. + capacity = (capacity - payload_size) * 2 / 5; + } + return std::min(size_t(budget), capacity); + } + + bool valid_config(const config_t &config) { + return config.videoFormat == video_format && + config.width > 0 && config.width <= 16384 && + config.height > 0 && config.height <= 16384 && + config.packetSize > int(shard_overhead) && config.packetSize <= config::PACKETSIZE_MAX && + (config.dynamicRange == 0 || config.dynamicRange == 1) && + (config.chromaSamplingType == 1 || (config.chromaSamplingType == 0 && config.width % 2 == 0 && config.height % 2 == 0)) && + frame_budget(config) != 0; + } + + packet_t frame_packets(std::span packets, std::span bitstream, const config_t &config, size_t critical_blocks, int64_t frame_index) { + if (packets.empty() || packets.size() > std::numeric_limits::max() || config.packetSize <= int(shard_overhead) || config.packetSize > config::PACKETSIZE_MAX) { + return nullptr; + } + std::vector output; + std::vector> records; + if (!config.pyrowaveRecordFraming) { + output.resize(4); + AV_WL32(output.data(), packets.size()); + } + size_t native_size = 0; + for (const auto &packet : packets) { + if (!packet.size || packet.size > record_packet_boundary || packet.size % 4 || packet.offset > bitstream.size() || packet.size > bitstream.size() - packet.offset || packet.size > max_frame_size - native_size) { + return nullptr; + } + native_size += packet.size; + auto bytes = bitstream.subspan(packet.offset, packet.size); + if (!config.pyrowaveRecordFraming) { + if (output.size() > max_frame_size - 4 - bytes.size()) { + return nullptr; + } + const auto offset = output.size(); + output.resize(offset + 4); + AV_WL32(output.data() + offset, bytes.size()); + output.insert(output.end(), bytes.begin(), bytes.end()); + continue; + } + while (!bytes.empty()) { + if (bytes.size() < 8) { + return nullptr; + } + const auto word = AV_RL32(bytes.data()); + // Native sequence and block records both have eight-byte headers. + const bool sequence = word & 0x80000000u; + const size_t size = sequence ? 8 : ((word >> 16) & 0xfffu) * 4; + if (sequence != records.empty() || size < 8 || size > bytes.size()) { + return nullptr; + } + records.push_back(bytes.first(size)); + bytes = bytes.subspan(size); + } + } + if (!config.pyrowaveRecordFraming) { + return std::make_unique(std::move(output), frame_index, true); + } + + const auto payload_size = size_t(config.packetSize) - shard_overhead; + const bool aligned = payload_size >= 24 && payload_size % 4 == 0; + output.reserve(native_size); + std::vector record_starts; + auto append = [&](std::span bytes) { + if (bytes.size() > max_frame_size - output.size()) { + return false; + } + const auto position = output.size() + frame_header_size; + if (aligned) { + record_starts.resize((position + bytes.size() + payload_size - 1) / payload_size); + if (output.empty() || position % payload_size == 0) { + record_starts[position / payload_size] = true; + } + } + output.insert(output.end(), bytes.begin(), bytes.end()); + return true; + }; + auto pad = [&](size_t size) { + std::array padding; + AV_WL32(padding.data(), 0xffffffffu); + AV_WL32(padding.data() + 4, (size - 8) / 4); + if (size > max_frame_size - output.size()) { + return false; + } + append(padding); + output.resize(output.size() + size - padding.size()); + return true; + }; + auto remaining = [&]() { + return payload_size - (output.size() + frame_header_size) % payload_size; + }; + + size_t critical_packets = 0; + auto detail = records.end(); + if (aligned) { + detail = std::stable_partition(records.begin() + 1, records.end(), [&](auto record) { + return (AV_RL32(record.data() + 4) >> 8) < critical_blocks; + }); + // Put records that must span shards first, preserving recovery for small records. + auto oversized = [&](auto record) { + return record.size() > payload_size - 8; + }; + std::stable_partition(records.begin() + 1, detail, oversized); + std::stable_partition(detail, records.end(), oversized); + } + for (auto record = records.begin(); record != records.end(); ++record) { + if (aligned) { + if (record == detail) { + if (remaining() != payload_size && !pad(remaining())) { + return nullptr; + } + critical_packets = (output.size() + frame_header_size) / payload_size; + } + if (record->size() <= payload_size - 8) { + if ((record->size() > remaining() || remaining() - record->size() == 4) && !pad(remaining())) { + return nullptr; + } + } else if ((output.size() + frame_header_size + record->size()) % payload_size == payload_size - 4 && !pad(8)) { + return nullptr; + } + } + if (!append(*record)) { + return nullptr; + } + } + if (aligned && detail == records.end()) { + critical_packets = record_starts.size(); + } + if (record_starts.size() > max_frame_packets) { + return nullptr; + } + auto result = std::make_unique(std::move(output), frame_index, true); + result->pyrowave_critical_packets = uint16_t(critical_packets); + result->pyrowave_record_starts = std::move(record_starts); + return result; + } + + /** + * @brief Identify the wire capability required by a validated PyroWave configuration. + * @param config Validated stream configuration. + * @return ServerCodecModeSupport bit for this profile. + */ + uint32_t profile_flag(const config_t &config) { + return config.chromaSamplingType ? (config.dynamicRange ? scm_hdr_444 : scm_sdr_444) : + (config.dynamicRange ? scm_hdr : scm_sdr); + } + + bool supported(const config_t &config, uint32_t profiles) { + return valid_config(config) && (profiles & profile_flag(config)) != 0; + } + + uint32_t probe_profiles(const std::function &validate) { + config_t probe {1920, 1080, 60, 6000, 200000, 1, 0, 3, video_format, 0, 0, 0}; + uint32_t profiles = 0; + for (int chroma : {0, 1}) { + for (int hdr : {0, 1}) { + probe.chromaSamplingType = chroma; + probe.dynamicRange = hdr; + if (validate(probe)) { + profiles |= profile_flag(probe); + BOOST_LOG(info) << "Found PyroWave encoder (" << (hdr ? "HDR" : "SDR") << ", " << (chroma ? "4:4:4" : "4:2:0") << ')'; + } + } + } + return profiles; + } + + AVPixelFormat capture_format(const encoder_platform_formats_t &formats, const config_t &config) { + // The shared D3D11 converter supports packed 4:4:4 independently of the hardware video encoder. + if (formats.dev_type == platf::mem_type_e::dxgi) { + return config.chromaSamplingType ? (config.dynamicRange ? AV_PIX_FMT_XV30 : AV_PIX_FMT_VUYX) : + (config.dynamicRange ? AV_PIX_FMT_P010 : AV_PIX_FMT_NV12); + } + const auto avcodec_formats = dynamic_cast(&formats); + if (!avcodec_formats) { + return AV_PIX_FMT_NONE; + } + return config.chromaSamplingType ? (config.dynamicRange ? avcodec_formats->avcodec_pix_fmt_yuv444_10bit : avcodec_formats->avcodec_pix_fmt_yuv444_8bit) : + (config.dynamicRange ? avcodec_formats->avcodec_pix_fmt_10bit : avcodec_formats->avcodec_pix_fmt_8bit); + } + + static_assert(PYROWAVE_API_VERSION_MAJOR == 1, "Sunshine requires PyroWave C API 1.x"); + + bool map_gpu_frame(AVPixelFormat format, std::span images, pyrowave_gpu_buffers &buffers, float &scaling_factor) { + buffers = {}; + scaling_factor = 1.0f; + if (images.empty() || std::any_of(images.begin(), images.end(), [](const auto &image) { + return !image.image || !image.width || !image.height; + })) { + return false; + } + + if (format == AV_PIX_FMT_VUYX || format == AV_PIX_FMT_XV30) { + std::array swizzles; + if (images.size() != 1) { + return false; + } + if (format == AV_PIX_FMT_VUYX && images[0].image_format == VK_FORMAT_R8G8B8A8_UNORM) { + swizzles = {VK_COMPONENT_SWIZZLE_B, VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_R}; + } else if (format == AV_PIX_FMT_XV30 && images[0].image_format == VK_FORMAT_A2B10G10R10_UNORM_PACK32) { + swizzles = {VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_R, VK_COMPONENT_SWIZZLE_B}; + } else if (format == AV_PIX_FMT_XV30 && images[0].image_format == VK_FORMAT_A2R10G10B10_UNORM_PACK32) { + swizzles = {VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_B, VK_COMPONENT_SWIZZLE_R}; + } else { + return false; + } + for (int component = 0; component < 3; ++component) { + buffers.planes[component] = images[0]; + buffers.planes[component].view_format = images[0].image_format; + buffers.planes[component].swizzle = swizzles[component]; + } + return true; + } + + VkFormat combined; + std::array plane_formats {VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM}; + const bool semiplanar = format == AV_PIX_FMT_NV12 || format == AV_PIX_FMT_P010; + switch (format) { + case AV_PIX_FMT_NV12: + combined = VK_FORMAT_G8_B8R8_2PLANE_420_UNORM; + plane_formats[1] = VK_FORMAT_R8G8_UNORM; + break; + case AV_PIX_FMT_P010: + combined = VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16; + plane_formats = {VK_FORMAT_R16_UNORM, VK_FORMAT_R16G16_UNORM, VK_FORMAT_UNDEFINED}; + scaling_factor = 65535.0f / (1023.0f * 64.0f); + break; + case AV_PIX_FMT_YUV420P: + combined = VK_FORMAT_G8_B8_R8_3PLANE_420_UNORM; + break; + case AV_PIX_FMT_YUV444P: + combined = VK_FORMAT_G8_B8_R8_3PLANE_444_UNORM; + break; + case AV_PIX_FMT_YUV420P10: + case AV_PIX_FMT_YUV444P10: + case AV_PIX_FMT_YUV444P16: + combined = format == AV_PIX_FMT_YUV420P10 ? VK_FORMAT_G16_B16_R16_3PLANE_420_UNORM : VK_FORMAT_G16_B16_R16_3PLANE_444_UNORM; + plane_formats.fill(VK_FORMAT_R16_UNORM); + scaling_factor = format == AV_PIX_FMT_YUV444P16 ? 1.0f : 65535.0f / 1023.0f; + break; + default: + return false; + } + + const bool multiplanar = images.size() == 1; + if (multiplanar ? images[0].image_format != combined : images.size() != (semiplanar ? 2 : 3)) { + return false; + } + // Native P010 plane views normalize the ten significant bits without a shader scale. + if (multiplanar && format == AV_PIX_FMT_P010) { + plane_formats = {VK_FORMAT_R10X6_UNORM_PACK16, VK_FORMAT_R10X6G10X6_UNORM_2PACK16, VK_FORMAT_UNDEFINED}; + scaling_factor = 1.0f; + } + const auto desc = av_pix_fmt_desc_get(format); + constexpr VkImageAspectFlagBits aspects[] {VK_IMAGE_ASPECT_PLANE_0_BIT, VK_IMAGE_ASPECT_PLANE_1_BIT, VK_IMAGE_ASPECT_PLANE_2_BIT}; + for (int component = 0; component < 3; ++component) { + const int plane = semiplanar && component ? 1 : component; + const auto &image = images[multiplanar ? 0 : plane]; + if (!multiplanar && (image.image_format != plane_formats[plane] || image.width != (images[0].width >> (plane ? desc->log2_chroma_w : 0)) || image.height != (images[0].height >> (plane ? desc->log2_chroma_h : 0)))) { + return false; + } + auto &view = buffers.planes[component]; + view = image; + view.view_format = plane_formats[plane]; + view.aspect = multiplanar ? aspects[plane] : VK_IMAGE_ASPECT_COLOR_BIT; + view.swizzle = semiplanar && component == 2 ? VK_COMPONENT_SWIZZLE_G : VK_COMPONENT_SWIZZLE_R; + } + return true; + } + +#ifdef SUNSHINE_BUILD_VULKAN + /** + * @brief Encode FFmpeg Vulkan frames using their device, layouts, and timeline semaphores. + */ + class vulkan_input_t final: public gpu_input_t { + public: + using gpu_input_t::gpu_input_t; + + /** + * @brief Finish submitted work before releasing command resources and the borrowed device wrapper. + */ + ~vulkan_input_t() override { + if (pending) { + vkWaitForFences(vk->act_dev, 1, &fence, VK_TRUE, UINT64_MAX); + } + if (command_pool) { + vkDestroyCommandPool(vk->act_dev, command_pool, nullptr); + } + if (fence) { + vkDestroyFence(vk->act_dev, fence, nullptr); + } + if (device) { + api.destroy_device(device); + } + } + + /** + * @brief Use FFmpeg to retain or derive a Vulkan device for hardware-only frame transfers. + * @param source_frames Capture conversion pool. + * @return True when the shared device and submission resources are usable. + */ + bool init(AVHWFramesContext *source_frames) { + if (!api.share_device || !source_frames->device_ctx) { + return false; + } + const auto type = source_frames->device_ctx->type; + external_frames = type == AV_HWDEVICE_TYPE_VAAPI; + if (type != AV_HWDEVICE_TYPE_VULKAN && type != AV_HWDEVICE_TYPE_CUDA && type != AV_HWDEVICE_TYPE_VAAPI) { + return false; + } + if (av_hwdevice_ctx_create_derived(&context, AV_HWDEVICE_TYPE_VULKAN, source_frames->device_ref, 0) < 0) { + return false; + } + if (type != AV_HWDEVICE_TYPE_VULKAN) { + transfer_frames.reset(av_hwframe_ctx_alloc(context.get())); + if (!transfer_frames) { + return false; + } + auto pool = reinterpret_cast(transfer_frames->data); + pool->format = AV_PIX_FMT_VULKAN; + pool->sw_format = source_frames->sw_format; + pool->width = source_frames->width; + pool->height = source_frames->height; + auto vk_frames = static_cast(pool->hwctx); + // CUDA interop requires separate images. VAAPI exports separate DMA-BUF layers. + vk_frames->flags = AVVkFrameFlags(AV_VK_FRAME_FLAG_NONE | AV_VK_FRAME_FLAG_DISABLE_MULTIPLANE); + if (av_hwframe_ctx_init(transfer_frames.get()) < 0) { + return false; + } + mapped.reset(av_frame_alloc()); + if (!mapped) { + return false; + } + } + auto ctx = reinterpret_cast(context->data); + vk = static_cast(ctx->hwctx); + if (!vk->get_proc_addr || vk->alloc) { + return false; + } + #if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(60, 32, 100) + synchronized_queues = vk->queue_flags & VK_DEVICE_QUEUE_CREATE_INTERNALLY_SYNCHRONIZED_BIT_KHR; + #endif + #if !FF_API_VULKAN_SYNC_QUEUES + if (!synchronized_queues) { + return false; + } + #endif + for (auto capability : {VK_QUEUE_GRAPHICS_BIT, VK_QUEUE_COMPUTE_BIT}) { + const auto family = std::find_if(vk->qf, vk->qf + vk->nb_qf, [&](const auto &qf) { + return qf.num > 0 && (qf.flags & capability); + }); + if (family == vk->qf + vk->nb_qf) { + return false; + } + if (capability == VK_QUEUE_COMPUTE_BIT) { + compute_family = family->idx; + } + if (queue_count && queues[0].familyIndex == uint32_t(family->idx)) { + continue; + } + auto &queue = queues[queue_count]; + auto &info = queue_infos[queue_count++]; + info = {VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO}; + info.queueFamilyIndex = family->idx; + info.queueCount = 1; + info.pQueuePriorities = &priority; + #if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(60, 32, 100) + info.flags = vk->queue_flags; + #endif + VkDeviceQueueInfo2 get_info {VK_STRUCTURE_TYPE_DEVICE_QUEUE_INFO_2}; + get_info.flags = info.flags; + get_info.queueFamilyIndex = family->idx; + queue.familyIndex = family->idx; + vkGetDeviceQueue2(vk->act_dev, &get_info, &queue.queue); + if (!queue.queue) { + return false; + } + } + for (uint32_t i = 0; i < queue_count; ++i) { + if (queues[i].familyIndex == compute_family) { + compute_queue = queues[i].queue; + } + } + application.apiVersion = VK_API_VERSION_1_3; + instance_info.pApplicationInfo = &application; + instance_info.enabledExtensionCount = vk->nb_enabled_inst_extensions; + instance_info.ppEnabledExtensionNames = vk->enabled_inst_extensions; + device_info.pNext = &vk->device_features; + device_info.enabledExtensionCount = vk->nb_enabled_dev_extensions; + device_info.ppEnabledExtensionNames = vk->enabled_dev_extensions; + device_info.queueCreateInfoCount = queue_count; + device_info.pQueueCreateInfos = queue_infos.data(); + pyrowave_device_create_info info {}; + info.GetInstanceProcAddr = vk->get_proc_addr; + info.instance = vk->inst; + info.physical_device = vk->phys_dev; + info.device = vk->act_dev; + info.instance_create_info = &instance_info; + info.device_create_info = &device_info; + info.queue_info = queues.data(); + info.queue_info_count = queue_count; + info.userdata = this; + info.queue_lock_callback = [](void *self) { + static_cast(self)->lock_queues(true); + }; + info.queue_unlock_callback = [](void *self) { + static_cast(self)->lock_queues(false); + }; + if (api.share_device(&info, &device) != PYROWAVE_SUCCESS || api.set_queue_type(device, VK_QUEUE_COMPUTE_BIT) != PYROWAVE_SUCCESS) { + return false; + } + VkCommandPoolCreateInfo pool_info {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; + pool_info.queueFamilyIndex = compute_family; + VkCommandBufferAllocateInfo allocation {VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + allocation.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocation.commandBufferCount = 1; + VkFenceCreateInfo fence_info {VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + if (vkCreateCommandPool(vk->act_dev, &pool_info, nullptr, &command_pool) != VK_SUCCESS) { + return false; + } + allocation.commandPool = command_pool; + return vkAllocateCommandBuffers(vk->act_dev, &allocation, &command_buffer) == VK_SUCCESS && + vkCreateFence(vk->act_dev, &fence_info, nullptr, &fence) == VK_SUCCESS; + } + + /** @copydoc gpu_input_t::prepare */ + bool prepare(AVFrame *source) override { + if (!source->hw_frames_ctx) { + return false; + } + if (source->format == AV_PIX_FMT_CUDA && transfer_frames) { + if ((!mapped->buf[0] && av_hwframe_get_buffer(transfer_frames.get(), mapped.get(), 0) < 0) || av_hwframe_transfer_data(mapped.get(), source, 0) < 0) { + return false; + } + source = mapped.get(); + } else if (source->format == AV_PIX_FMT_VAAPI && transfer_frames) { + av_frame_unref(mapped.get()); + mapped->format = AV_PIX_FMT_VULKAN; + mapped->hw_frames_ctx = av_buffer_ref(transfer_frames.get()); + if (!mapped->hw_frames_ctx || av_hwframe_map(mapped.get(), source, AV_HWFRAME_MAP_READ | AV_HWFRAME_MAP_DIRECT) < 0) { + return false; + } + source = mapped.get(); + } + if (source->format != AV_PIX_FMT_VULKAN || !source->data[0]) { + return false; + } + frame = source; + auto pool = reinterpret_cast(source->hw_frames_ctx->data); + if (pool->device_ctx->hwctx != vk) { + return false; + } + auto vk_frames = static_cast(pool->hwctx); + auto pixels = reinterpret_cast(source->data[0]); + const auto desc = av_pix_fmt_desc_get(pool->sw_format); + if (!desc || !(vk_frames->usage & VK_IMAGE_USAGE_SAMPLED_BIT)) { + return false; + } + std::array images {}; + size_t count = 0; + // FFmpeg uses concurrent sharing when more than one distinct queue family is enabled. + const bool concurrent = std::any_of(vk->qf, vk->qf + vk->nb_qf, [&](const auto &qf) { + return qf.idx != int(compute_family); + }); + image_family = concurrent ? VK_QUEUE_FAMILY_IGNORED : compute_family; + for (; count < images.size() && pixels->img[count]; ++count) { + if (pixels->queue_family[count] == VK_QUEUE_FAMILY_EXTERNAL || pixels->queue_family[count] == VK_QUEUE_FAMILY_FOREIGN_EXT || (!concurrent && pixels->queue_family[count] != compute_family && pixels->queue_family[count] != VK_QUEUE_FAMILY_IGNORED)) { + return false; + } + auto &image = images[count]; + image.image = pixels->img[count]; + image.image_format = vk_frames->format[count]; + image.width = source->width >> (count ? desc->log2_chroma_w : 0); + image.height = source->height >> (count ? desc->log2_chroma_h : 0); + image.aspect = VK_IMAGE_ASPECT_COLOR_BIT; + image.layout = VK_IMAGE_LAYOUT_GENERAL; + } + return map_gpu_frame(pool->sw_format, std::span(images).first(count), buffers, scaling_factor); + } + + /** @copydoc gpu_input_t::encode */ + pyrowave_result encode(pyrowave_encoder encoder, const pyrowave_rate_control &rate_control) override { + auto pool = reinterpret_cast(frame->hw_frames_ctx->data); + auto vk_frames = static_cast(pool->hwctx); + auto pixels = reinterpret_cast(frame->data[0]); + vk_frames->lock_frame(pool, pixels); + auto unlock = util::fail_guard([&]() { + vk_frames->unlock_frame(pool, pixels); + }); + VkCommandBufferBeginInfo begin {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + if (vkResetCommandPool(vk->act_dev, command_pool, 0) != VK_SUCCESS || vkResetFences(vk->act_dev, 1, &fence) != VK_SUCCESS || vkBeginCommandBuffer(command_buffer, &begin) != VK_SUCCESS) { + return PYROWAVE_ERROR_GENERIC; + } + std::array barriers {}; + std::array waits {}, signals {}; + uint32_t count = 0; + for (; count < barriers.size() && pixels->img[count]; ++count) { + auto &barrier = barriers[count]; + barrier = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2}; + barrier.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + barrier.srcAccessMask = pixels->access[count]; + barrier.dstStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + barrier.dstAccessMask = VK_ACCESS_2_SHADER_SAMPLED_READ_BIT; + barrier.oldLayout = pixels->layout[count]; + barrier.newLayout = VK_IMAGE_LAYOUT_GENERAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + if (external_frames && pixels->queue_family[count] == VK_QUEUE_FAMILY_EXTERNAL) { + barrier.srcStageMask = VK_PIPELINE_STAGE_2_NONE; + barrier.srcAccessMask = 0; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL; + barrier.dstQueueFamilyIndex = image_family; + } + barrier.image = pixels->img[count]; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + if (!pixels->sem[count]) { + return PYROWAVE_ERROR_INVALID_ARGUMENT; + } + waits[count] = {VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO}; + waits[count].semaphore = pixels->sem[count]; + waits[count].value = pixels->sem_value[count]; + waits[count].stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + signals[count] = waits[count]; + ++signals[count].value; + } + VkDependencyInfo dependency {VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dependency.imageMemoryBarrierCount = count; + dependency.pImageMemoryBarriers = barriers.data(); + vkCmdPipelineBarrier2(command_buffer, &dependency); + api.set_scaling_factor(encoder, scaling_factor); + api.set_command_buffer(device, command_buffer); + const auto result = api.encode_gpu(encoder, nullptr, nullptr, &buffers, &rate_control); + api.set_command_buffer(device, VK_NULL_HANDLE); + if (result != PYROWAVE_SUCCESS) { + return result; + } + if (external_frames) { + // VAAPI/EGL may overwrite the surface on the next capture conversion. + for (uint32_t i = 0; i < count; ++i) { + auto &barrier = barriers[i]; + barrier.srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + barrier.srcAccessMask = VK_ACCESS_2_SHADER_SAMPLED_READ_BIT; + barrier.dstStageMask = VK_PIPELINE_STAGE_2_NONE; + barrier.dstAccessMask = 0; + barrier.oldLayout = VK_IMAGE_LAYOUT_GENERAL; + barrier.srcQueueFamilyIndex = image_family; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL; + } + vkCmdPipelineBarrier2(command_buffer, &dependency); + } + if (vkEndCommandBuffer(command_buffer) != VK_SUCCESS) { + return PYROWAVE_ERROR_GENERIC; + } + VkCommandBufferSubmitInfo command {VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO}; + command.commandBuffer = command_buffer; + VkSubmitInfo2 submission {VK_STRUCTURE_TYPE_SUBMIT_INFO_2}; + submission.waitSemaphoreInfoCount = count; + submission.pWaitSemaphoreInfos = waits.data(); + submission.signalSemaphoreInfoCount = count; + submission.pSignalSemaphoreInfos = signals.data(); + submission.commandBufferInfoCount = 1; + submission.pCommandBufferInfos = &command; + lock_queues(true); + const auto submitted = vkQueueSubmit2(compute_queue, 1, &submission, fence); + lock_queues(false); + if (submitted != VK_SUCCESS) { + return PYROWAVE_ERROR_GENERIC; + } + pending = true; + for (uint32_t i = 0; i < count; ++i) { + pixels->layout[i] = VK_IMAGE_LAYOUT_GENERAL; + pixels->access[i] = external_frames ? 0 : VK_ACCESS_2_SHADER_SAMPLED_READ_BIT; + pixels->queue_family[i] = external_frames ? VK_QUEUE_FAMILY_EXTERNAL : image_family; + pixels->sem_value[i] = signals[i].value; + } + vk_frames->unlock_frame(pool, pixels); + unlock.disable(); + // Packetization reads encoded bytes on the CPU, so this submission must finish first. + if (vkWaitForFences(vk->act_dev, 1, &fence, VK_TRUE, UINT64_MAX) != VK_SUCCESS) { + return PYROWAVE_ERROR_GENERIC; + } + pending = false; + return PYROWAVE_SUCCESS; + } + + private: + /** + * @brief Synchronize queue submissions with FFmpeg on devices without internally synchronized queues. + * @param lock Whether to acquire or release the queue locks. + */ + void lock_queues(bool lock) { + #if FF_API_VULKAN_SYNC_QUEUES + if (synchronized_queues) { + return; + } + // Older FFmpeg devices still require the queue locks deprecated by the new queue extension. + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wdeprecated-declarations" + auto ctx = reinterpret_cast(context->data); + for (uint32_t i = 0; i < queue_count; ++i) { + const auto &queue = queues[lock ? i : queue_count - i - 1]; + if (lock) { + vk->lock_queue(ctx, queue.familyIndex, queue.index); + } else { + vk->unlock_queue(ctx, queue.familyIndex, queue.index); + } + } + #pragma GCC diagnostic pop + #endif + } + + avcodec_buffer_t context; ///< Retained FFmpeg Vulkan device and feature chain. + avcodec_buffer_t transfer_frames; ///< Optional pool for hardware-to-hardware transfers. + avcodec_frame_t mapped; ///< Imported VAAPI frame or reusable CUDA transfer target. + AVVulkanDeviceContext *vk = nullptr; ///< Borrowed context owned by context. + AVFrame *frame = nullptr; ///< Prepared frame owned by the converter or mapped. + VkApplicationInfo application {VK_STRUCTURE_TYPE_APPLICATION_INFO}; ///< Borrowed instance API version. + VkInstanceCreateInfo instance_info {VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; ///< Enabled instance extensions. + VkDeviceCreateInfo device_info {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO}; ///< Enabled device features and queues. + std::array queue_infos {}; ///< Graphics and compute queue descriptions. + std::array queues {}; ///< Concrete borrowed queue handles. + uint32_t queue_count = 0; ///< Number of distinct borrowed queue families. + float priority = 1.0f; ///< Queue priority storage retained with the create infos. + uint32_t compute_family = 0; ///< Queue family used for the encoding command buffer. + VkQueue compute_queue {}; ///< Borrowed compute queue. + VkCommandPool command_pool {}; ///< Reusable command buffer allocation pool. + VkCommandBuffer command_buffer {}; ///< Current encoding command buffer. + VkFence fence {}; ///< Completion of the current encoding submission. + bool pending = false; ///< Whether the command pool still has submitted work. + bool synchronized_queues = false; ///< Whether the driver synchronizes shared queue submissions. + bool external_frames = false; ///< Whether imported VAAPI images must be released back to their producer. + uint32_t image_family = VK_QUEUE_FAMILY_IGNORED; ///< Ownership while Vulkan samples the images. + pyrowave_gpu_buffers buffers {}; ///< Sampled YUV component views. + float scaling_factor = 1.0f; ///< Normalization of the GPU sample storage. + }; +#endif + + /** + * @brief Own conversion, GPU codec state, and reusable host transfer buffers. + */ + class session_impl_t final: public session_t { + public: + /** + * @brief Store the converter and byte budget before initializing the codec. + * + * @param device Initialized frame conversion device. + * @param budget Maximum native bitstream bytes per frame, including record headers. + * @param api C API implementation to call. + * @param frames Hardware frame pool retained until after converter destruction. + */ + session_impl_t(std::unique_ptr device, size_t budget, const api_t &api, avcodec_buffer_t frames): + frames {std::move(frames)}, + converter {std::move(device)}, + api {api}, + rate_control {budget} { + } + + /** + * @brief Destroy codec objects in dependency order, including partial initialization. + */ + ~session_impl_t() override { + if (encoder) { + api.destroy_encoder(encoder); + } + if (device && !gpu_input) { + api.destroy_device(device); + } + } + + /** + * @brief Initialize GPU sharing when available and prepare the CPU fallback for the negotiated profile. + * + * @param config Negotiated stream configuration. + * @return True when the Vulkan device and encoder are usable. + */ + bool init(const config_t &config) { + framing = config; + uint32_t major, minor, patch; + api.get_api_version(&major, &minor, &patch); + if (major != PYROWAVE_API_VERSION_MAJOR || std::pair {major, minor} < std::pair {uint32_t(PYROWAVE_API_VERSION_MAJOR), uint32_t(PYROWAVE_API_VERSION_MINOR)}) { + BOOST_LOG(error) << "Incompatible PyroWave C API: " << major << '.' << minor << '.' << patch; + return false; + } +#if defined(SUNSHINE_BUILD_VULKAN) || defined(_WIN32) + const auto pool_ref = converter->frame->hw_frames_ctx ? converter->frame->hw_frames_ctx : frames.get(); + if (pool_ref) { + auto pool = reinterpret_cast(pool_ref->data); + #ifdef SUNSHINE_BUILD_VULKAN + auto input = std::make_unique(api); + if (input->init(pool)) { + gpu_input = std::move(input); + } + #endif + #ifdef _WIN32 + if (pool->format == AV_PIX_FMT_D3D11 && api.match_device) { + gpu_input = make_d3d11_input(pool, api); + } + #endif + } +#endif + const auto create_encoder = [&]() { + const pyrowave_encoder_create_info info {device, config.width, config.height, config.chromaSamplingType ? PYROWAVE_CHROMA_SUBSAMPLING_444 : PYROWAVE_CHROMA_SUBSAMPLING_420}; + return api.create_encoder(&info, &encoder) == PYROWAVE_SUCCESS; + }; + if (gpu_input) { + device = gpu_input->device; + if (!create_encoder()) { + if (encoder) { + api.destroy_encoder(encoder); + encoder = nullptr; + } + gpu_input.reset(); + device = nullptr; + } + } + if (!gpu_input) { + if (api.create_device(&device) != PYROWAVE_SUCCESS) { + BOOST_LOG(error) << "Could not create a PyroWave Vulkan device"; + return false; + } + if (!create_encoder()) { + BOOST_LOG(error) << "Could not create a PyroWave encoder"; + return false; + } + } + gpu_active = gpu_input != nullptr; + + converted.reset(av_frame_alloc()); + readback.reset(av_frame_alloc()); + input_frame.reset(av_frame_alloc()); + if (!converted || !readback || !input_frame) { + return false; + } + auto source_format = converter->frame->format; + if (const auto pool = converter->frame->hw_frames_ctx ? converter->frame->hw_frames_ctx : frames.get()) { + source_format = reinterpret_cast(pool->data)->sw_format; + } + // PyroWave caches upload images, so keep the NV12 or planar layout fixed for this session. + converted->format = config.chromaSamplingType ? (config.dynamicRange ? AV_PIX_FMT_YUV444P10 : AV_PIX_FMT_YUV444P) : + (config.dynamicRange ? AV_PIX_FMT_YUV420P10 : (source_format == AV_PIX_FMT_NV12 ? AV_PIX_FMT_NV12 : AV_PIX_FMT_YUV420P)); + converted->width = config.width; + converted->height = config.height; + return av_frame_copy_props(converted.get(), converter->frame) >= 0; + } + + /** + * @brief Retain compatible frames directly, converting other layouts while preserving HDR precision. + * + * @param img Captured image. + * @return Zero on success, or a negative conversion/transfer error. + */ + int convert(platf::img_t &img) override { + ready = false; + av_frame_unref(input_frame.get()); + if (converter->convert(img)) { + return -1; + } + auto source = converter->frame; + if (gpu_active) { + const auto pool = source->hw_frames_ctx ? reinterpret_cast(source->hw_frames_ctx->data) : nullptr; + if (pool && source->width == converted->width && source->height == converted->height && matches_profile(pool->sw_format) && av_frame_ref(input_frame.get(), source) >= 0 && gpu_input->prepare(input_frame.get())) { + ready = true; + return 0; + } + av_frame_unref(input_frame.get()); + gpu_active = false; + BOOST_LOG(warning) << "PyroWave GPU input unavailable; using CPU frame transfer"; + } + if (source->hw_frames_ctx) { + av_frame_unref(readback.get()); + if (av_hwframe_transfer_data(readback.get(), source, 0) < 0) { + BOOST_LOG(error) << "Could not download the PyroWave input frame"; + return -1; + } + source = readback.get(); + } + + // Only change plane layout/bit storage here. The capture converter has + // already applied the negotiated matrix, range, and HDR transfer function. + if (source->format != AV_PIX_FMT_NV12 && source->format != AV_PIX_FMT_P010 && source->format != AV_PIX_FMT_YUV420P && source->format != AV_PIX_FMT_YUV420P10 && source->format != AV_PIX_FMT_YUV444P && source->format != AV_PIX_FMT_YUV444P10 && source->format != AV_PIX_FMT_YUV444P16 && source->format != AV_PIX_FMT_VUYX && source->format != AV_PIX_FMT_XV30) { + BOOST_LOG(error) << "Unsupported PyroWave input pixel format: " << source->format; + return -1; + } + if (!matches_profile(AVPixelFormat(source->format))) { + BOOST_LOG(error) << "PyroWave input color depth or chroma sampling does not match the negotiated profile"; + return -1; + } + // Matching layouts and dimensions can bypass sws_scale(). + // PyroWave requires positive row strides, so reversed rows still need conversion. + const bool direct = source->format == converted->format && source->width == converted->width && source->height == converted->height && + std::all_of(source->linesize, source->linesize + av_pix_fmt_count_planes(AVPixelFormat(source->format)), [](int stride) { + return stride > 0; + }); + if (!direct) { + auto context = sws_getCachedContext(scaler.release(), source->width, source->height, AVPixelFormat(source->format), converted->width, converted->height, AVPixelFormat(converted->format), SWS_POINT, nullptr, nullptr, nullptr); + scaler.reset(context); + if (!scaler || (!converted->buf[0] && av_frame_get_buffer(converted.get(), 0) < 0) || av_frame_make_writable(converted.get()) < 0 || sws_scale(scaler.get(), source->data, source->linesize, 0, source->height, converted->data, converted->linesize) != converted->height) { + return -1; + } + source = converted.get(); + } + // References keep repeated frames alive; av_frame_ref() copies unowned capture buffers. + if (av_frame_ref(input_frame.get(), source) < 0) { + return -1; + } + ready = true; + return 0; + } + + /** + * @brief Every PyroWave frame is independently decodable. + */ + void request_idr_frame() override {} + + /** + * @brief Continue producing independently decodable frames. + */ + void request_normal_frame() override {} + + /** + * @brief Ignore reference invalidation because the codec has no inter-frame references. + * + * @param first_frame First lost frame, unused. + * @param last_frame Last lost frame, unused. + */ + void invalidate_ref_frames(int64_t first_frame, int64_t last_frame) override {} + + /** + * @brief Encode the latest converted pixels and preserve native packet boundaries. + * + * @param frame_index GameStream frame sequence number. + * @return Independent frame container, or null on codec failure. + */ + packet_t encode(int64_t frame_index) override { + if (!ready) { + return nullptr; + } + pyrowave_result result; + if (gpu_active) { + result = gpu_input->encode(encoder, rate_control); + } else { + pyrowave_cpu_buffer input {}; + input.width = input_frame->width; + input.height = input_frame->height; + const auto desc = av_pix_fmt_desc_get(AVPixelFormat(input_frame->format)); + input.format = input_frame->format == AV_PIX_FMT_NV12 ? PYROWAVE_CPU_BUFFER_FORMAT_NV12 : + desc->log2_chroma_w == 0 ? (desc->comp[0].depth == 10 ? PYROWAVE_CPU_BUFFER_FORMAT_YUV444P10 : PYROWAVE_CPU_BUFFER_FORMAT_YUV444P) : + (desc->comp[0].depth == 10 ? PYROWAVE_CPU_BUFFER_FORMAT_YUV420P10 : PYROWAVE_CPU_BUFFER_FORMAT_YUV420P); + for (int plane = 0; plane < av_pix_fmt_count_planes(AVPixelFormat(input_frame->format)); ++plane) { + input.data[plane] = input_frame->data[plane]; + input.row_stride_in_bytes[plane] = input_frame->linesize[plane]; + input.plane_size_in_bytes[plane] = input.row_stride_in_bytes[plane] * (input.height >> (plane ? desc->log2_chroma_h : 0)); + } + result = api.encode_cpu(encoder, &input, &rate_control); + } + size_t count = 0; + const auto boundary = framing.pyrowaveRecordFraming ? record_packet_boundary : packet_boundary; + if (result != PYROWAVE_SUCCESS || api.num_packets(encoder, boundary, &count) != PYROWAVE_SUCCESS || count == 0 || count > max_frame_size / boundary) { + BOOST_LOG(error) << "PyroWave encoding failed"; + return nullptr; + } + + packets.resize(count); + // Native records cannot be split, so a legacy packet can exceed its 1024-byte target. + // Query an allocation with a boundary large enough for any native record. + size_t storage_count = count; + if (boundary != record_packet_boundary && (api.num_packets(encoder, record_packet_boundary, &storage_count) != PYROWAVE_SUCCESS || !storage_count || storage_count > max_frame_size / record_packet_boundary)) { + return nullptr; + } + bitstream.resize(storage_count * record_packet_boundary); + if (api.packetize(encoder, packets.data(), boundary, &count, bitstream.data(), bitstream.size()) != PYROWAVE_SUCCESS || count == 0 || count > packets.size()) { + BOOST_LOG(error) << "PyroWave packetization failed"; + return nullptr; + } + + size_t critical_blocks = 0; + // The first two native bands contain the coarsest wavelet level and its detail. + if (framing.pyrowaveRecordFraming && api.active_blocks(encoder, 2, &critical_blocks) != PYROWAVE_SUCCESS) { + return nullptr; + } + return frame_packets(std::span(packets.data(), count), bitstream, framing, critical_blocks, frame_index); + } + + private: + /** + * @brief Match eight-bit versus higher precision and chroma sampling to the session. + * + * @param format Pixel layout supplied by the converter or hardware frame pool. + * @return True when the depth category and chroma subsampling match. + */ + bool matches_profile(AVPixelFormat format) const { + const auto input = av_pix_fmt_desc_get(format); + const auto output = av_pix_fmt_desc_get(AVPixelFormat(converted->format)); + return input && (input->comp[0].depth > 8) == (output->comp[0].depth > 8) && input->log2_chroma_w == output->log2_chroma_w && input->log2_chroma_h == output->log2_chroma_h; + } + + avcodec_buffer_t frames; ///< Hardware pool borrowed by the converter; destroyed last. + std::unique_ptr converter; ///< Platform color conversion and source frame owner. + api_t api; ///< Runtime C API implementation. + std::unique_ptr gpu_input; ///< Optional hardware sharing resources, retained through encoder destruction. + bool gpu_active = false; ///< Whether subsequent frames can use hardware sharing. + pyrowave_device device {}; ///< Device owned by gpu_input, or owned directly for CPU input. + pyrowave_encoder encoder {}; ///< Codec instance owned by this session. + pyrowave_rate_control rate_control; ///< Per-frame coefficient byte budget. + config_t framing {}; ///< Client framing and final negotiated packet size. + avcodec_frame_t readback; ///< Downloaded hardware frame, when needed. + avcodec_frame_t converted; ///< Stable CPU input layout, with buffers allocated only when conversion is needed. + avcodec_frame_t input_frame; ///< Retained pixels for encoding, including repeated frames. + sws_t scaler; ///< Plane-layout and bit-storage conversion preserving negotiated chroma and color depth. + std::vector packets; ///< Packet offsets returned by the C API. + std::vector bitstream; ///< Reusable encoded coefficient storage. + bool ready = false; ///< Whether conversion has produced a usable frame. + }; + + std::unique_ptr make_session(const config_t &config, std::unique_ptr device, const api_t &api, avcodec_buffer_t frames) { + if (!valid_config(config) || !device || !device->frame || device->frame->width != config.width || device->frame->height != config.height) { + return nullptr; + } + // Associate borrowed VideoToolbox buffers with their pool for readback. + if (device->frame->format == AV_PIX_FMT_VIDEOTOOLBOX && !device->frame->hw_frames_ctx) { + if (!frames) { + return nullptr; + } + device->frame->hw_frames_ctx = av_buffer_ref(frames.get()); + if (!device->frame->hw_frames_ctx) { + return nullptr; + } + } + auto session = std::make_unique(std::move(device), frame_budget(config), api, std::move(frames)); + return session->init(config) ? std::move(session) : nullptr; + } + + std::unique_ptr make_session(const config_t &config, std::unique_ptr device, avcodec_buffer_t frames) { + return make_session(config, std::move(device), api_t {}, std::move(frames)); + } + } // namespace pyrowave + /** * @brief Native VideoToolbox encode session and device state for hardware encoding. */ @@ -1511,6 +2485,7 @@ namespace video { static encoder_t *chosen_encoder; int active_hevc_mode; ///< HEVC mode selected by the most recent encoder probe. int active_av1_mode; ///< AV1 mode selected by the most recent encoder probe. + uint32_t active_pyrowave_profiles; ///< PyroWave profile bits validated on the selected capture backend. bool last_encoder_probe_supported_ref_frames_invalidation = false; ///< Whether the last probe found reference-frame invalidation support. std::array last_encoder_probe_supported_yuv444_for_codec = {}; ///< YUV444 support discovered for each probed codec. std::array last_encoder_probe_supported_dynamic_range_for_codec = {}; ///< 10-bit 4:2:0 support discovered for each probed codec. @@ -2042,6 +3017,15 @@ namespace video { return encode_nvenc(frame_nr, *nvenc_session, packets, channel_data, frame_timestamp); } else if (auto vt_session = dynamic_cast(&session)) { return encode_vt(frame_nr, *vt_session, packets, channel_data, frame_timestamp); + } else if (auto pyrowave_session = dynamic_cast(&session)) { + auto packet = pyrowave_session->encode(frame_nr); + if (!packet) { + return -1; + } + packet->channel_data = channel_data; + packet->frame_timestamp = frame_timestamp; + packets->raise(std::move(packet)); + return 0; } return -1; @@ -2476,6 +3460,108 @@ namespace video { return std::make_unique(std::move(encode_device)); } + /** + * @brief Prepare the existing capture converter for PyroWave without opening an FFmpeg encoder. + * @param encoder Encoder descriptor selecting the capture memory type. + * @param config Negotiated PyroWave configuration. + * @param width Captured image width. + * @param height Captured image height. + * @param device Platform color-conversion device. + * @param api PyroWave C API entry points. + * @return PyroWave session, or null if conversion or encoding is unavailable. + */ + std::unique_ptr make_pyrowave_encode_session(const encoder_t &encoder, const config_t &config, int width, int height, std::unique_ptr device, const pyrowave::api_t &api = {}) { + if (!pyrowave::valid_config(config) || !device || !encoder.platform_formats || width <= 0 || height <= 0) { + return nullptr; + } + const auto colorspace = device->colorspace; + // The software converter accepts 8-bit capture pixels and cannot preserve HDR. + if (config.dynamicRange && !device->hardware) { + return nullptr; + } + + auto formats = dynamic_cast(encoder.platform_formats.get()); +#ifdef _WIN32 + // Native NVENC, QSV, and AMF all use the same D3D11 capture converter. + // PyroWave needs the base D3D11 frame, without a derived QSV context. + static const encoder_platform_formats_avcodec d3d_formats { + AV_HWDEVICE_TYPE_D3D11VA, + AV_HWDEVICE_TYPE_NONE, + AV_PIX_FMT_D3D11, + AV_PIX_FMT_NV12, + AV_PIX_FMT_P010, + AV_PIX_FMT_VUYX, + AV_PIX_FMT_XV30, + dxgi_init_avcodec_hardware_input_buffer + }; + if (encoder.platform_formats->dev_type == platf::mem_type_e::dxgi) { + formats = &d3d_formats; + } +#endif + if (!formats) { + return nullptr; + } + const auto sw_format = pyrowave::capture_format(*encoder.platform_formats, config); + if (sw_format == AV_PIX_FMT_NONE) { + return nullptr; + } + avcodec_frame_t frame {av_frame_alloc()}; + if (!frame) { + return nullptr; + } + frame->width = config.width; + frame->height = config.height; + const auto metadata = avcodec_colorspace_from_sunshine_colorspace(colorspace); + frame->color_range = metadata.range; + frame->color_primaries = metadata.primaries; + frame->color_trc = metadata.transfer_function; + frame->colorspace = metadata.matrix; + + avcodec_buffer_t frames; + if (device->hardware) { + if (!formats->init_avcodec_hardware_input_buffer) { + return nullptr; + } + auto context = formats->init_avcodec_hardware_input_buffer(device.get()); + if (context.has_right()) { + return nullptr; + } + frames.reset(av_hwframe_ctx_alloc(context.left().get())); + if (!frames) { + return nullptr; + } + auto hw_frames = reinterpret_cast(frames->data); + hw_frames->format = formats->avcodec_dev_pix_fmt; + hw_frames->sw_format = sw_format; + hw_frames->width = config.width; + hw_frames->height = config.height; + device->init_hwframes(hw_frames); +#ifdef SUNSHINE_BUILD_VULKAN + if (hw_frames->format == AV_PIX_FMT_VULKAN) { + auto vk_frames = static_cast(hw_frames->hwctx); + vk_frames->usage = static_cast((vk_frames->usage & ~VK_IMAGE_USAGE_VIDEO_ENCODE_SRC_BIT_KHR) | VK_IMAGE_USAGE_TRANSFER_SRC_BIT); + } +#endif + if (av_hwframe_ctx_init(frames.get()) < 0) { + return nullptr; + } + frame->format = hw_frames->format; + } else { + frame->format = sw_format; + auto converter = std::make_unique(); + if (converter->init(width, height, frame.get(), sw_format, false)) { + return nullptr; + } + converter->colorspace = colorspace; + device = std::move(converter); + } + if (device->set_frame(frame.release(), frames.get())) { + return nullptr; + } + device->apply_colorspace(); + return pyrowave::make_session(config, std::move(device), api, std::move(frames)); + } + /** * @brief Create encode session. * @@ -2488,6 +3574,9 @@ namespace video { * @return Constructed encode session object. */ std::unique_ptr make_encode_session(platf::display_t *disp, const encoder_t &encoder, const config_t &config, int width, int height, std::unique_ptr encode_device) { + if (config.videoFormat == pyrowave::video_format) { + return make_pyrowave_encode_session(encoder, config, width, height, boost::dynamic_pointer_cast(std::move(encode_device))); + } if (dynamic_cast(encode_device.get())) { auto avcodec_encode_device = boost::dynamic_pointer_cast(std::move(encode_device)); return make_avcodec_encode_session(disp, encoder, config, width, height, std::move(avcodec_encode_device)); @@ -2693,7 +3782,12 @@ namespace video { auto colorspace = colorspace_from_client_config(config, disp.is_hdr()); platf::pix_fmt_e pix_fmt; - if (config.chromaSamplingType == 1) { + if (config.videoFormat == pyrowave::video_format) { + pix_fmt = map_pix_fmt(pyrowave::capture_format(*encoder.platform_formats, config)); + if (pix_fmt == platf::pix_fmt_e::unknown) { + return {}; + } + } else if (config.chromaSamplingType == 1) { // YUV 4:4:4 if (!(encoder.flags & YUV444_SUPPORT)) { // Encoder can't support YUV 4:4:4 regardless of hardware capabilities @@ -2710,7 +3804,7 @@ namespace video { } { - auto encoder_name = encoder.codec_from_config(config).name; + auto encoder_name = config.videoFormat == pyrowave::video_format ? "pyrowave"s : encoder.codec_from_config(config).name; BOOST_LOG(info) << "Creating encoder " << logging::bracket(encoder_name); @@ -2719,7 +3813,7 @@ namespace video { BOOST_LOG(info) << "Color range: " << (colorspace.full_range ? "JPEG" : "MPEG"); } - if (dynamic_cast(encoder.platform_formats.get())) { + if (config.videoFormat == pyrowave::video_format || dynamic_cast(encoder.platform_formats.get())) { result = disp.make_avcodec_encode_device(pix_fmt); } else if (dynamic_cast(encoder.platform_formats.get())) { result = disp.make_nvenc_encode_device(pix_fmt); @@ -3402,6 +4496,26 @@ namespace video { encoder.av1[encoder_t::DYNAMIC_RANGE_YUV444]; } + /** + * @brief Probe each PyroWave color depth and chroma profile using the selected capture backend. + * @param encoder Encoder descriptor selecting the capture backend. + */ + void probe_pyrowave(const encoder_t &encoder) { + active_pyrowave_profiles = 0; + if (!config::video.pyrowave) { + return; + } + const auto output_name = display_device::map_output_name(config::video.output_name); + std::shared_ptr display; + active_pyrowave_profiles = pyrowave::probe_profiles([&](const config_t &probe) { + reset_display(display, encoder.platform_formats->dev_type, output_name, probe); + return display && validate_config(display, encoder, probe) >= 0; + }); + if (!active_pyrowave_profiles) { + BOOST_LOG(warning) << "PyroWave is unavailable on the selected capture backend"; + } + } + int probe_encoders() { if (!allow_encoder_probing(display_device::enumerate_devices())) { // Error already logged @@ -3412,6 +4526,7 @@ namespace video { // If we already have a good encoder, check to see if another probe is required if (chosen_encoder && !(chosen_encoder->flags & ALWAYS_REPROBE) && !platf::needs_encoder_reenumeration()) { + probe_pyrowave(*chosen_encoder); return 0; } @@ -3420,6 +4535,7 @@ namespace video { chosen_encoder = nullptr; active_hevc_mode = config::video.hevc_mode; active_av1_mode = config::video.av1_mode; + active_pyrowave_profiles = 0; reset_encoder_probe_results(); auto adjust_encoder_constraints_hevc = [&](encoder_t *encoder) { @@ -3570,6 +4686,8 @@ namespace video { auto &encoder = *chosen_encoder; + probe_pyrowave(encoder); + record_encoder_probe_results(encoder); BOOST_LOG(debug) << "------ h264 ------"sv; diff --git a/src/video.h b/src/video.h index 8b2158b6137..1cbf7f35135 100644 --- a/src/video.h +++ b/src/video.h @@ -8,10 +8,17 @@ #include #include #include +#include #include #include #include +// lib includes +#include + +// PyroWave requires Vulkan declarations before its C API header. +#include + // local includes #include "input.h" #include "platform/common.h" @@ -47,10 +54,12 @@ namespace video { int slicesPerFrame; ///< Number of slices per frame. int numRefFrames; ///< Maximum number of reference frames. int encoderCscMode; ///< Requested color range and SDR colorspace; HDR always uses BT.2020 and ST2084. - int videoFormat; ///< Video codec format: 0 = H.264, 1 = HEVC, 2 = AV1. + int videoFormat; ///< Video codec format: 0 = H.264, 1 = HEVC, 2 = AV1, 3 = PyroWave. int dynamicRange; ///< Encoding color depth: 0 = 8-bit, 1 = 10-bit. int chromaSamplingType; ///< Chroma sampling type: 0 = 4:2:0, 1 = 4:4:4. int enableIntraRefresh; ///< Intra refresh setting: 0 = disabled, 1 = enabled. + bool pyrowaveRecordFraming = false; ///< Client accepts native PyroWave records and padding. + int packetSize = 1392; ///< Negotiated video packet size, including the NV video header. }; /** @@ -618,6 +627,8 @@ namespace video { stream::session_t *channel_data = nullptr; ///< Stream session that receives this packet. bool after_ref_frame_invalidation = false; ///< Whether the frame follows reference-frame invalidation. std::optional frame_timestamp; ///< Capture timestamp associated with the frame. + uint16_t pyrowave_critical_packets = 0; ///< Leading RTP data packets needed to decode the coarsest wavelet level. + std::vector pyrowave_record_starts; ///< RTP data packets that start with a native or padding record. }; /** @@ -734,6 +745,196 @@ namespace video { */ using packet_t = std::unique_ptr; + namespace pyrowave { + inline constexpr int video_format = 3; ///< Experimental GameStream bitStreamFormat value. + inline constexpr uint32_t scm_sdr = 0x00800000; ///< ServerCodecModeSupport bit for 8-bit 4:2:0. + inline constexpr uint32_t scm_sdr_444 = 0x01000000; ///< ServerCodecModeSupport bit for 8-bit 4:4:4. + inline constexpr uint32_t scm_hdr = 0x02000000; ///< ServerCodecModeSupport bit for 10-bit 4:2:0. + inline constexpr uint32_t scm_hdr_444 = 0x04000000; ///< ServerCodecModeSupport bit for 10-bit 4:4:4. + inline constexpr uint32_t scm_mask = scm_sdr | scm_sdr_444 | scm_hdr | scm_hdr_444; ///< All PyroWave profile bits. + inline constexpr size_t packet_boundary = 1024; ///< Native packet boundary used by legacy PyroWave clients. + inline constexpr size_t record_packet_boundary = 16384; ///< Packet allocation large enough for any native block record. + inline constexpr size_t frame_header_size = 8; ///< Sunshine short frame header preceding the codec payload. + inline constexpr size_t shard_overhead = 16; ///< NV video header bytes included in the negotiated packet size. + inline constexpr size_t max_frame_packets = 4 * 1023; ///< Four FEC blocks with ten-bit data packet counts. + inline constexpr size_t max_frame_size = 16 * 1024 * 1024; ///< Bound on codec output allocation. + + /** + * @brief Validate dimensions, color format, framerate, and bitrate for PyroWave. + * + * @param config Negotiated stream configuration. + * @return True for supported 8-bit or 10-bit 4:2:0 or 4:4:4 configurations. + */ + bool valid_config(const config_t &config); + + /** + * @brief Calculate the codec byte budget within host bitrate, packet capacity, and allocation limits. + * + * @param config Negotiated stream configuration. + * @return Maximum native bitstream bytes per frame, including headers, or zero for invalid input. + */ + size_t frame_budget(const config_t &config); + + /** + * @brief Check a requested profile against the last successful encoder probe. + * + * @param config Negotiated stream configuration. + * @param profiles Successfully probed ServerCodecModeSupport profile bits. + * @return Whether the requested profile may be accepted. + */ + bool supported(const config_t &config, uint32_t profiles); + + /** + * @brief Probe each color depth and chroma profile independently. + * + * @param validate Callback that tests capture, conversion, and encoding for a profile. + * @return ServerCodecModeSupport bits for successful profiles. + */ + uint32_t probe_profiles(const std::function &validate); + + /** + * @brief Select the capture converter's pixel layout for a PyroWave profile. + * + * @param formats Capture backend's pixel formats. + * @param config Negotiated stream configuration. + * @return Pixel layout, or AV_PIX_FMT_NONE when unavailable. + */ + AVPixelFormat capture_format(const encoder_platform_formats_t &formats, const config_t &config); + + /** + * @brief Frame native codec packets for the client's negotiated transport. + * + * @param packets Native packet byte ranges returned by the encoder. + * @param bitstream Storage containing the native packets. + * @param config Negotiated framing and packet size. + * @param critical_blocks Native block indices below this value belong to the coarsest level. + * @param frame_index Monotonic frame index. + * @return Framed packet and recovery metadata, or null for malformed or oversized output. + */ + packet_t frame_packets(std::span packets, std::span bitstream, const config_t &config, size_t critical_blocks, int64_t frame_index); + + /** + * @brief Describe YUV components as GPU image views without copying pixel data. + * + * @param format Pixel layout produced by the capture converter. + * @param images Images containing the planes or packed components. + * @param buffers Y, Cb, and Cr views returned for the encoder. + * @param scaling_factor Normalization for ten-bit samples stored in sixteen-bit images. + * @return True when the image formats match the pixel layout. + */ + bool map_gpu_frame(AVPixelFormat format, std::span images, pyrowave_gpu_buffers &buffers, float &scaling_factor); + + /** + * @brief PyroWave session interface used by the common capture pipeline. + */ + class session_t: public encode_session_t { + public: + /** + * @brief Encode the converted frame using the client's negotiated framing. + * + * @param frame_index Monotonic GameStream frame index. + * @return Encoded frame, or null on failure. + */ + virtual packet_t encode(int64_t frame_index) = 0; + }; + + /** + * @brief Create a session with the bundled PyroWave C API and an initialized frame conversion device. + * + * @param config Negotiated stream configuration. + * @param device Converter owning its input frame and hardware context, if any. + * @param frames Hardware pool retained for the converter and attached to borrowed VideoToolbox frames. + * @return Session, or null when the configuration is invalid or initialization fails. + */ + std::unique_ptr make_session(const config_t &config, std::unique_ptr device, avcodec_buffer_t frames = {}); + + /** + * @brief C API entry points, injectable for deterministic encoder failure tests. + */ + struct api_t { + decltype(&pyrowave_get_api_version) get_api_version = pyrowave_get_api_version; ///< Query the runtime ABI. + decltype(&pyrowave_create_default_device) create_device = pyrowave_create_default_device; ///< Create the Vulkan device. + decltype(&pyrowave_create_device) share_device = pyrowave_create_device; ///< Borrow the converter's Vulkan device. + decltype(&pyrowave_create_device_by_compat) match_device = pyrowave_create_device_by_compat; ///< Select the capture adapter for external textures. + decltype(&pyrowave_device_destroy) destroy_device = pyrowave_device_destroy; ///< Destroy the Vulkan device. + decltype(&pyrowave_device_set_command_buffer) set_command_buffer = pyrowave_device_set_command_buffer; ///< Record encoding into the caller's command buffer. + decltype(&pyrowave_device_set_queue_type) set_queue_type = pyrowave_device_set_queue_type; ///< Select the queue used for encoding. + decltype(&pyrowave_encoder_create) create_encoder = pyrowave_encoder_create; ///< Create an encoder. + decltype(&pyrowave_encoder_destroy) destroy_encoder = pyrowave_encoder_destroy; ///< Destroy an encoder before its device. + decltype(&pyrowave_encoder_encode_cpu) encode_cpu = pyrowave_encoder_encode_cpu; ///< Upload and encode planar YUV. + decltype(&pyrowave_encoder_encode_gpu) encode_gpu = pyrowave_encoder_encode_gpu; ///< Encode GPU-resident YUV images. + decltype(&pyrowave_encoder_set_ycbcr_scaling_factor) set_scaling_factor = pyrowave_encoder_set_ycbcr_scaling_factor; ///< Normalize stored YUV samples. + decltype(&pyrowave_image_create) import_image = pyrowave_image_create; ///< Import shared texture memory. + decltype(&pyrowave_image_get_image_view) image_view = pyrowave_image_get_image_view; ///< Describe sampled components of an imported image. + decltype(&pyrowave_image_destroy) destroy_image = pyrowave_image_destroy; ///< Release an imported image. + decltype(&pyrowave_sync_object_create) import_sync = pyrowave_sync_object_create; ///< Import a shared GPU fence. + decltype(&pyrowave_sync_object_get_semaphore) sync_semaphore = pyrowave_sync_object_get_semaphore; ///< Obtain the imported semaphore. + decltype(&pyrowave_sync_object_destroy) destroy_sync = pyrowave_sync_object_destroy; ///< Release an imported fence. + decltype(&pyrowave_encoder_compute_num_packets) num_packets = pyrowave_encoder_compute_num_packets; ///< Query the packet allocation. + decltype(&pyrowave_encoder_packetize) packetize = pyrowave_encoder_packetize; ///< Read encoded packets. + decltype(&pyrowave_encoder_get_num_active_blocks) active_blocks = pyrowave_encoder_get_num_active_blocks; ///< Query native block indices for the critical wavelet bands. + }; + + /** + * @brief Platform GPU input resources retained until after encoder destruction. + */ + class gpu_input_t { + public: + /** + * @brief Retain the codec entry points used by this input path. + * @param api Codec entry points. + */ + explicit gpu_input_t(const api_t &api): + api {api} {} + + /** + * @brief Allow platform implementations to release their shared resources. + */ + virtual ~gpu_input_t() = default; + + /** + * @brief Prepare a converted hardware frame without transferring pixels to the CPU. + * @param frame Converted frame retained by the session. + * @return False when the session should use its CPU fallback. + */ + virtual bool prepare(AVFrame *frame) = 0; + + /** + * @brief Submit encoding and synchronize access to the prepared images. + * @param encoder Codec instance using this input's device. + * @param rate_control Encoded frame byte budget. + * @return Encoding status. + */ + virtual pyrowave_result encode(pyrowave_encoder encoder, const pyrowave_rate_control &rate_control) = 0; + + pyrowave_device device {}; ///< Device owned by the implementation; destroyed after its image resources. + + protected: + api_t api; ///< Codec entry points shared with the owning session. + }; + +#ifdef _WIN32 + /** + * @brief Create a D3D11 texture and fence bridge to the capture adapter's Vulkan device. + * @param frames Initialized D3D11 conversion frame pool. + * @param api Codec entry points. + * @return GPU input resources, or null when sharing is unsupported. + */ + std::unique_ptr make_d3d11_input(AVHWFramesContext *frames, const api_t &api); +#endif + + /** + * @brief Create a session with explicitly supplied PyroWave entry points. + * + * @param config Negotiated stream configuration. + * @param device Initialized frame conversion device. + * @param api C API implementation, copied into the session. + * @param frames Hardware pool retained for the converter and attached to borrowed VideoToolbox frames. + * @return Initialized session, or null on failure. + */ + std::unique_ptr make_session(const config_t &config, std::unique_ptr device, const api_t &api, avcodec_buffer_t frames = {}); + } // namespace pyrowave + /** * @brief Raw HDR metadata fields parsed from the display backend. */ @@ -767,6 +968,7 @@ namespace video { extern int active_hevc_mode; extern int active_av1_mode; + extern uint32_t active_pyrowave_profiles; ///< Independently probed PyroWave ServerCodecModeSupport profile bits. extern bool last_encoder_probe_supported_ref_frames_invalidation; extern std::array last_encoder_probe_supported_yuv444_for_codec; // 0 - H.264, 1 - HEVC, 2 - AV1 extern std::array last_encoder_probe_supported_dynamic_range_for_codec; // 0 - H.264, 1 - HEVC, 2 - AV1 diff --git a/src_assets/common/assets/web/configs/config_tabs.json b/src_assets/common/assets/web/configs/config_tabs.json index b1940b33d82..4ab7ba087c3 100644 --- a/src_assets/common/assets/web/configs/config_tabs.json +++ b/src_assets/common/assets/web/configs/config_tabs.json @@ -102,6 +102,7 @@ "min_threads": 2, "hevc_mode": 0, "av1_mode": 0, + "pyrowave": "enabled", "capture": "", "fp16_sdr_gamma_encoded": "disabled", "encoder": "" diff --git a/src_assets/common/assets/web/configs/tabs/Advanced.vue b/src_assets/common/assets/web/configs/tabs/Advanced.vue index 17a2b8ead24..94f884720d9 100644 --- a/src_assets/common/assets/web/configs/tabs/Advanced.vue +++ b/src_assets/common/assets/web/configs/tabs/Advanced.vue @@ -59,6 +59,14 @@ const config = ref(props.config)
{{ $t('config.av1_mode_desc') }}
+ + +
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 23f5baa943c..a5250a3e933 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -395,6 +395,8 @@ "port_udp": "UDP", "port_warning": "Exposing the Web UI to the internet is a security risk! Proceed at your own risk!", "port_web_ui": "Web UI", + "pyrowave": "PyroWave Support", + "pyrowave_desc": "Allows compatible clients to request PyroWave 8-bit or 10-bit (HDR) video streams with 4:2:0 or 4:4:4 chroma sampling. Sunshine advertises support based on encoder capabilities.", "qp": "Quantization Parameter", "qp_desc": "Some devices may not support Constant Bit Rate. For those devices, QP is used instead. Higher value means more compression, but less quality.", "qsv_coder": "QuickSync Coder (H264)", diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index 5cff436cf65..420f3730f5e 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -67,6 +67,13 @@ TEST(ConfigDefaultsTest, TreatsFp16SdrCapturesAsLinear) { EXPECT_FALSE(config::video.fp16_sdr_gamma_encoded); } +/** + * @brief PyroWave profile probing is enabled without a configuration override. + */ +TEST(ConfigDefaultsTest, EnablesPyrowaveCapabilityDetection) { + EXPECT_TRUE(config::video.pyrowave); +} + TEST(ConfigDefaultsTest, PreservesAmdEncoderNumericDefaults) { EXPECT_EQ(config::video.amd.amd_usage_h264, 1); EXPECT_EQ(config::video.amd.amd_usage_hevc, 1); @@ -263,6 +270,25 @@ TEST_F(ConfigPersistenceTest, ParsesBooleanOptionsCaseInsensitively) { } } +/** + * @brief Loading defaults keeps PyroWave enabled and explicit overrides remain effective. + */ +TEST_F(ConfigPersistenceTest, PreservesPyrowaveDefaultAndParsesOverrides) { + const auto original_video = config::video; + const auto restore = util::fail_guard([original_video]() { + config::video = original_video; + }); + const auto apps_file = config_file().parent_path() / "sunshine_test_config_apps.json"; + ASSERT_EQ(file_handler::write_file(apps_file.string().c_str(), "{}"), 0); + config::stream.file_apps = apps_file.string(); + config::apply_config_for_test(""); + EXPECT_TRUE(config::video.pyrowave); + config::apply_config_for_test("pyrowave = disabled\n"); + EXPECT_FALSE(config::video.pyrowave); + config::apply_config_for_test("pyrowave = enabled\n"); + EXPECT_TRUE(config::video.pyrowave); +} + TEST_F(ConfigPersistenceTest, ParsesHexIntegersWiderThanAnInt) { const auto original_video = config::video; const auto restore_video = util::fail_guard([original_video]() { diff --git a/tests/unit/test_rtsp.cpp b/tests/unit/test_rtsp.cpp index fa610b4a4c1..f9553f204e9 100644 --- a/tests/unit/test_rtsp.cpp +++ b/tests/unit/test_rtsp.cpp @@ -13,6 +13,7 @@ // standard includes #include #include +#include #include #include @@ -24,7 +25,10 @@ extern "C" { // local includes #include +#include #include +#include +#include /** * @brief The Moonlight compatibility header can be included first before using the C RTSP parser. @@ -61,6 +65,15 @@ TEST(RtspHeaderTest, ConstAccessorsPreserveTheSerializedHeader) { namespace rtsp_stream { class rtsp_server_t; + /** + * @brief Select the client's requested PyroWave wire format. + * + * @param config Negotiated stream configuration. + * @param args ANNOUNCE attributes. + * @return Whether the transport is supported. + */ + bool configure_pyrowave(stream::config_t &config, const std::unordered_map &args); + /** * @brief Release a parsed RTSP message and its allocation. * @param message Message to release. @@ -95,8 +108,160 @@ namespace rtsp_stream { * @param req Request to handle. */ void cmd_play(rtsp_server_t *, boost::asio::ip::tcp::socket &sock, launch_session_t &session, msg_t &&req); + + /** + * @brief Validate ANNOUNCE before starting a stream. + * @param sock Connected client socket. + * @param session Launch session. + * @param req Request to handle. + */ + void cmd_announce(rtsp_server_t *, boost::asio::ip::tcp::socket &sock, launch_session_t &session, msg_t &&req); } // namespace rtsp_stream +namespace { + /** + * @brief Exercise codec negotiation on a local RTSP connection without starting a stream. + * @param describe Whether to request DESCRIBE instead of an invalid ANNOUNCE. + * @param payload SDP body for ANNOUNCE. + * @return Complete RTSP response. + */ + std::string pyrowave_rtsp_response(bool describe, std::string payload = {}) { + using boost::asio::ip::tcp; + boost::asio::io_context io; + tcp::acceptor acceptor {io, {boost::asio::ip::address_v4::loopback(), 0}}; + tcp::socket client {io}; + client.connect(acceptor.local_endpoint()); + auto server_socket = acceptor.accept(); + rtsp_stream::launch_session_t session {}; + rtsp_stream::msg_t request {new RTSP_MESSAGE {}}; + request->sequenceNumber = 17; + request->payload = payload.data(); + request->payloadLength = int(payload.size()); + if (describe) { + rtsp_stream::cmd_describe(nullptr, server_socket, session, std::move(request)); + } else { + rtsp_stream::cmd_announce(nullptr, server_socket, session, std::move(request)); + } + server_socket.shutdown(tcp::socket::shutdown_send); + std::string response; + boost::system::error_code error; + boost::asio::read(client, boost::asio::dynamic_buffer(response), error); + EXPECT_EQ(error, boost::asio::error::eof); + return response; + } +} // namespace + +TEST(RtspPyrowaveTest, AdvertisesOnlyProbedProfiles) { + const auto previous = video::active_pyrowave_profiles; + const auto restore = util::fail_guard([previous]() { + video::active_pyrowave_profiles = previous; + }); + for (uint32_t profiles = 0; profiles < 16; ++profiles) { + video::active_pyrowave_profiles = profiles << 23; + auto response = pyrowave_rtsp_response(true); + EXPECT_EQ(response.find("a=rtpmap:99 PYROWAVE/90000") != std::string::npos, profiles != 0); + EXPECT_EQ(response.find("a=x-ss-pyrowave.bitstream:" SUNSHINE_PYROWAVE_BITSTREAM_ID) != std::string::npos, profiles != 0 && std::string_view {SUNSHINE_PYROWAVE_BITSTREAM_ID}.size() != 0); + EXPECT_EQ(response.find("PYRW/1"), std::string::npos); + EXPECT_NE(response.find("CSeq: 17"), std::string::npos); + } +} + +/** + * @brief Negotiate from advertised features without relying on a client name or platform. + */ +TEST(RtspPyrowaveTest, SelectsFramingFromCapabilitiesAndRejectsUnsupportedTransports) { + stream::config_t config {}; + config.monitor.videoFormat = video::pyrowave::video_format; + config.packetsize = 1000; + for (auto features : {"0", "1", "2", "3", "4", "8", "4294967295"}) { + for (bool adaptive : {false, true}) { + std::unordered_map args {{"x-ss-video[0].pyrowaveFeatures", features}}; + if (adaptive) { + args.emplace("x-ss-video[0].pyrowaveAdaptiveFec", "0"); + } + ASSERT_TRUE(rtsp_stream::configure_pyrowave(config, args)); + EXPECT_EQ(config.monitor.pyrowaveRecordFraming, adaptive || features == "1"sv || features == "3"sv || features == "4294967295"sv); + EXPECT_EQ(config.monitor.packetSize, 1000); + } + } + ASSERT_TRUE(rtsp_stream::configure_pyrowave(config, {})); + EXPECT_FALSE(config.monitor.pyrowaveRecordFraming); + for (auto name : {"x-ss-video[0].pyrowaveFeatures", "x-ss-video[0].pyrowaveCompression", "x-ss-video[0].pyrowaveHybrid"}) { + for (auto value : {"", "-1", "4294967296", "invalid", "1x"}) { + EXPECT_FALSE(rtsp_stream::configure_pyrowave(config, {{name, value}})) << name << ':' << value; + } + EXPECT_TRUE(rtsp_stream::configure_pyrowave(config, {{name, "0"}})); + if (name != "x-ss-video[0].pyrowaveFeatures"sv) { + EXPECT_FALSE(rtsp_stream::configure_pyrowave(config, {{name, "1"}})); + } + } + for (int size : {0, config::PACKETSIZE_MIN - 1, config::PACKETSIZE_MAX + 1}) { + config.packetsize = size; + EXPECT_FALSE(rtsp_stream::configure_pyrowave(config, {})); + } + config.packetsize = 1392; + config.minRequiredFecPackets = -1; + EXPECT_FALSE(rtsp_stream::configure_pyrowave(config, {})); + for (int format : {0, 1, 2}) { + config.monitor.videoFormat = format; + EXPECT_TRUE(rtsp_stream::configure_pyrowave(config, {{"x-ss-video[0].pyrowaveCompression", "1"}})); + } +} + +/** + * @brief Validate every profile independently, stopping accepted requests at the encryption gate. + */ +TEST(RtspPyrowaveTest, NegotiatesProbedProfilesAndRejectsUnknownFormatsBeforeStartingCapture) { + const auto previous = video::active_pyrowave_profiles; + const auto previous_lan = config::stream.lan_encryption_mode; + const auto previous_wan = config::stream.wan_encryption_mode; + const auto restore = util::fail_guard([=]() { + video::active_pyrowave_profiles = previous; + config::stream.lan_encryption_mode = previous_lan; + config::stream.wan_encryption_mode = previous_wan; + }); + config::stream.lan_encryption_mode = config::ENCRYPTION_MODE_MANDATORY; + config::stream.wan_encryption_mode = config::ENCRYPTION_MODE_MANDATORY; + const std::string base = + "s=Moonlight\r\n" + "a=x-nv-audio.surround.numChannels:2\r\n" + "a=x-nv-audio.surround.channelMask:3\r\n" + "a=x-nv-audio.surround.AudioQuality:1\r\n" + "a=x-nv-video[0].packetSize:1392\r\n" + "a=x-nv-video[0].clientViewportHt:1080\r\n" + "a=x-nv-video[0].clientViewportWd:1920\r\n" + "a=x-nv-video[0].maxFPS:60\r\n" + "a=x-nv-vqos[0].bw.maximumBitrateKbps:200000\r\n" + "a=x-nv-video[0].videoEncoderSlicesPerFrame:1\r\n" + "a=x-nv-video[0].maxNumReferenceFrames:0\r\n"; + for (uint32_t profiles = 0; profiles < 16; ++profiles) { + video::active_pyrowave_profiles = profiles << 23; + for (const auto &[format, hdr, chroma] : std::array { + std::array {3, 0, 0}, + std::array {3, 0, 1}, + std::array {3, 1, 0}, + std::array {3, 1, 1}, + std::array {3, 2, 0}, + std::array {3, 0, 2}, + std::array {-1, 0, 0}, + std::array {4, 0, 0} + }) { + auto payload = base + "a=x-nv-vqos[0].bitStreamFormat:" + std::to_string(format) + "\r\n" + + "a=x-nv-video[0].dynamicRangeMode:" + std::to_string(hdr) + "\r\n" + + "a=x-ss-video[0].chromaSamplingType:" + std::to_string(chroma) + "\r\n"; + const bool supported = format == 3 && hdr < 2 && chroma < 2 && (profiles & (1 << (hdr * 2 + chroma))); + EXPECT_TRUE(pyrowave_rtsp_response(false, payload).starts_with(supported ? "RTSP/1.0 403 Forbidden\r\n" : "RTSP/1.0 400 BAD REQUEST\r\n")) + << "profiles " << profiles << ", format " << format << ", HDR " << hdr << ", chroma " << chroma; + } + } + video::active_pyrowave_profiles = video::pyrowave::scm_sdr; + for (auto attribute : {"pyrowaveCompression:1", "pyrowaveHybrid:1", "pyrowaveFeatures:invalid"}) { + const auto payload = base + "a=x-nv-vqos[0].bitStreamFormat:3\r\na=x-ss-video[0]." + attribute + "\r\n"; + EXPECT_TRUE(pyrowave_rtsp_response(false, payload).starts_with("RTSP/1.0 400 BAD REQUEST\r\n")); + } + EXPECT_TRUE(pyrowave_rtsp_response(false, base + "a=x-nv-vqos[0].bitStreamFormat:3\r\na=x-ss-video[0].pyrowaveAdaptiveFec:\r\n").starts_with("RTSP/1.0 403 Forbidden\r\n")); +} + /** * @brief All three handlers respond with the request's sequence even without a server instance. */ diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index 12701e7cb81..61999ed9223 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -21,6 +22,8 @@ // local includes #include +#include +#include namespace stream { std::vector concat_and_insert(uint64_t insert_size, uint64_t slice_size, const std::string_view &data1, const std::string_view &data2); @@ -101,6 +104,37 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) { ASSERT_EQ(res, expected); } +/** + * @brief Protect only the critical prefix and respect Reed-Solomon and wire-header limits. + */ +TEST(PyrowaveFecTest, ProtectsCriticalPacketsWithoutAddingParityToDetail) { + for (const auto &[total, critical, percentage, minimum, expected] : std::vector>>> { + {100, 10, 20, 0, {{10, 20}, {90, 0}}}, + {1, 1, 20, 0, {{1, 20}}}, + {10, 10, 20, 10, {{10, 20}}}, + {10, 1, 20, 3, {{10, 0}}}, + {100, 10, 0, 0, {{100, 0}}}, + {100, 0, 20, 0, {{100, 0}}}, + {300, 213, 20, 0, {{300, 0}}}, + {300, 212, 20, 0, {{212, 20}, {88, 0}}}, + {300, 256, 20, 0, {{300, 0}}}, + {300, 10, 20, 256, {{300, 0}}}, + {3000, 10, 20, 0, {{10, 20}, {1023, 0}, {1023, 0}, {944, 0}}}, + {4092, 10, 20, 0, {{1023, 0}, {1023, 0}, {1023, 0}, {1023, 0}}}, + {0, 0, 20, 0, {}}, + {4093, 0, 20, 0, {}}, + {10, 11, 20, 0, {}}, + {10, 1, 101, 0, {}} + }) { + auto plan = stream::pyrowave_fec_blocks(total, critical, percentage, minimum); + std::vector> actual; + for (const auto &block : plan) { + actual.emplace_back(block.data_shards, block.percentage); + } + EXPECT_EQ(actual, expected) << "packets " << total << ", critical " << critical << ", FEC " << percentage << ", minimum " << minimum; + } +} + TEST(ControlPacketTests, RejectsZeroLengthPacket) { net::packet_t packet {enet_packet_create(nullptr, 0, 0)}; diff --git a/tests/unit/test_video_pyrowave.cpp b/tests/unit/test_video_pyrowave.cpp new file mode 100644 index 00000000000..d5fa6092709 --- /dev/null +++ b/tests/unit/test_video_pyrowave.cpp @@ -0,0 +1,1780 @@ +/** + * @file tests/unit/test_video_pyrowave.cpp + * @brief Regression coverage for PyroWave profiles, chroma and HDR precision, framing, and failures. + */ + +// test includes +#include "../tests_common.h" + +// standard includes +#include +#include +#include + +// lib includes +#ifdef __APPLE__ + #include +#endif + +extern "C" { +#include +#include +#include +#include +#ifdef SUNSHINE_BUILD_VULKAN + #include +#endif +} + +// local includes +#include +#include + +namespace video { + /** + * @brief Dispatch a converted frame through the common packet queue. + * @param frame_nr Sequence number. + * @param session Active encoder. + * @param packets Destination queue. + * @param channel_data Owning stream. + * @param frame_timestamp Capture timestamp. + * @return Zero on success. + */ + int encode(int64_t frame_nr, encode_session_t &session, safe::mail_raw_t::queue_t &packets, stream::session_t *channel_data, std::optional frame_timestamp); + + /** + * @brief Create an encoder session through the capture pipeline. + * @param disp Capture display. + * @param encoder Capture backend descriptor. + * @param config Negotiated configuration. + * @param width Captured width. + * @param height Captured height. + * @param encode_device Capture conversion device. + * @return Initialized session, or null on failure. + */ + std::unique_ptr make_encode_session(platf::display_t *disp, const encoder_t &encoder, const config_t &config, int width, int height, std::unique_ptr encode_device); + + /** + * @brief Create a PyroWave capture session with injectable codec entry points. + * @param encoder Capture backend descriptor. + * @param config Negotiated configuration. + * @param width Captured width. + * @param height Captured height. + * @param device Capture conversion device. + * @param api Codec entry points. + * @return Initialized session, or null on failure. + */ + std::unique_ptr make_pyrowave_encode_session(const encoder_t &encoder, const config_t &config, int width, int height, std::unique_ptr device, const pyrowave::api_t &api); + + /** + * @brief Request a capture converter for the negotiated format. + * @param disp Capture display. + * @param encoder Capture backend descriptor. + * @param config Negotiated configuration. + * @return Capture device, or null when unsupported. + */ + std::unique_ptr make_encode_device(platf::display_t &disp, const encoder_t &encoder, const config_t &config); + + /** + * @brief Refresh available PyroWave profiles. + * @param encoder Capture backend descriptor. + */ + void probe_pyrowave(const encoder_t &encoder); +} // namespace video + +namespace nvhttp { + /** + * @brief Obtain the same capability mask used in server-info responses. + * @return Probed codec support bits. + */ + uint32_t get_codec_mode_flags(); +} // namespace nvhttp + +TEST(PyrowaveConfigTest, ServerInfoReportsEachProfileIndependently) { + const auto previous = video::active_pyrowave_profiles; + const auto restore = util::fail_guard([previous]() { + video::active_pyrowave_profiles = previous; + }); + video::active_pyrowave_profiles = 0; + const auto baseline = nvhttp::get_codec_mode_flags(); + EXPECT_EQ(baseline & video::pyrowave::scm_mask, 0); + for (uint32_t profiles = 0; profiles < 16; ++profiles) { + video::active_pyrowave_profiles = profiles << 23; + EXPECT_EQ(nvhttp::get_codec_mode_flags(), baseline | (profiles << 23)); + } + video::active_pyrowave_profiles = ~video::pyrowave::scm_mask; + EXPECT_EQ(nvhttp::get_codec_mode_flags(), baseline); +} + +namespace { + /** + * @brief Return a small, valid PyroWave configuration for deterministic tests. + * @return 64x48 SDR 4:2:0 at 60 FPS. + */ + video::config_t pyrowave_config() { + return {64, 48, 60, 6000, 200000, 1, 0, 3, video::pyrowave::video_format, 0, 0, 0}; + } + + /** + * @brief Build native v1 records with known block indices and payload lengths. + * + * @param blocks Block index and total byte length for each record. + * @return Sequence header followed by the requested block records. + */ + std::vector pyrowave_records(std::initializer_list> blocks) { + std::vector data(8); + AV_WL32(data.data(), 0x80000000u | 63 | (47 << 14)); + AV_WL32(data.data() + 4, blocks.size()); + for (auto [index, size] : blocks) { + const auto offset = data.size(); + data.resize(offset + size, uint8_t(index)); + AV_WL32(data.data() + offset, (size / 4) << 16); + AV_WL32(data.data() + offset + 4, index << 8); + } + return data; + } + + /** + * @brief Record converter requests without requiring a display or video encoder. + */ + class pyrowave_test_display_t: public platf::display_t { + public: + /** + * @copydoc platf::display_t::capture + */ + platf::capture_e capture(const push_captured_image_cb_t &, const pull_free_image_cb_t &, bool *) override { + return platf::capture_e::ok; + } + + /** + * @copydoc platf::display_t::alloc_img + */ + std::shared_ptr alloc_img() override { + return {}; + } + + /** + * @copydoc platf::display_t::dummy_img + */ + int dummy_img(platf::img_t *) override { + return -1; + } + + /** + * @copydoc platf::display_t::make_avcodec_encode_device + */ + std::unique_ptr make_avcodec_encode_device(platf::pix_fmt_e format) override { + requested = format; + return std::make_unique(); + } + + platf::pix_fmt_e requested = platf::pix_fmt_e::unknown; ///< Last requested conversion format. + }; +} // namespace + +/** + * @brief Match legacy clients and record clients, including short and unaligned RTP payloads. + */ +TEST(PyrowaveFramingTest, MatchesLegacyAndRecordWireFormats) { + const auto data = pyrowave_records({{0, 12}, {12, 12}}); + const std::array packets {pyrowave_packet {0, data.size()}}; + auto config = pyrowave_config(); + auto legacy = video::pyrowave::frame_packets(packets, data, config, 12, 42); + ASSERT_NE(legacy, nullptr); + std::vector expected {1, 0, 0, 0, 32, 0, 0, 0}; + expected.insert(expected.end(), data.begin(), data.end()); + EXPECT_EQ(std::vector(legacy->data(), legacy->data() + legacy->data_size()), expected); + EXPECT_TRUE(legacy->is_idr()); + EXPECT_EQ(legacy->frame_index(), 42); + EXPECT_EQ(legacy->pyrowave_critical_packets, 0); + EXPECT_TRUE(legacy->pyrowave_record_starts.empty()); + + const auto large = pyrowave_records({{0, 2048}}); + const std::array large_packets {pyrowave_packet {0, 8}, pyrowave_packet {8, 2048}}; + auto oversized = video::pyrowave::frame_packets(large_packets, large, config, 12, 42); + ASSERT_NE(oversized, nullptr); + EXPECT_EQ(AV_RL32(oversized->data()), 2); + EXPECT_EQ(AV_RL32(oversized->data() + 16), 2048); + EXPECT_EQ(oversized->data_size(), large.size() + 12); + + config.pyrowaveRecordFraming = true; + config.packetSize = 40; // 24 data bytes per RTP packet; the first includes an eight-byte frame header. + auto records = video::pyrowave::frame_packets(packets, data, config, 12, 43); + ASSERT_NE(records, nullptr); + ASSERT_EQ(records->data_size(), 52); + EXPECT_EQ(records->pyrowave_critical_packets, 2); + EXPECT_EQ(records->pyrowave_record_starts, (std::vector {true, true, true})); + EXPECT_EQ(AV_RL32(records->data()), AV_RL32(data.data())); + EXPECT_EQ(AV_RL32(records->data() + 8), 0xffffffffu); + EXPECT_EQ(AV_RL32(records->data() + 12), 0); + EXPECT_EQ(AV_RL32(records->data() + 28), 0xffffffffu); + EXPECT_EQ(AV_RL32(records->data() + 32), 1); + EXPECT_EQ(AV_RL32(records->data() + 44) >> 8, 12); + + for (int packet_size : {36, 42}) { + config.packetSize = packet_size; + auto unaligned = video::pyrowave::frame_packets(packets, data, config, 12, 44); + ASSERT_NE(unaligned, nullptr); + EXPECT_EQ(std::vector(unaligned->data(), unaligned->data() + unaligned->data_size()), data); + EXPECT_EQ(unaligned->pyrowave_critical_packets, 0); + EXPECT_TRUE(unaligned->pyrowave_record_starts.empty()); + } +} + +/** + * @brief Preserve native records while aligning recoverable detail and critical prefix boundaries. + */ +TEST(PyrowaveFramingTest, AlignsOversizedRecordsAndMarksRecoveryBoundaries) { + auto config = pyrowave_config(); + config.pyrowaveRecordFraming = true; + config.packetSize = 64; + for (const auto &data : { + pyrowave_records({{0, 12}, {1, 84}, {12, 12}, {13, 88}}), + pyrowave_records({{0, 76}, {12, 12}}), + pyrowave_records({{0, 12}}), + pyrowave_records({}) + }) { + const std::array packets {pyrowave_packet {0, data.size()}}; + auto frame = video::pyrowave::frame_packets(packets, data, config, 12, 1); + ASSERT_NE(frame, nullptr); + std::vector starts((frame->data_size() + 8 + 47) / 48); + std::vector indices; + size_t critical_end = 8; + for (size_t offset = 0; offset < frame->data_size();) { + ASSERT_GE(frame->data_size() - offset, 8); + const auto word = AV_RL32(frame->data() + offset); + const auto info = AV_RL32(frame->data() + offset + 4); + if (offset == 0 || (offset + 8) % 48 == 0) { + starts[(offset + 8) / 48] = true; + } + const auto size = word == 0xffffffffu ? 8 + size_t(info) * 4 : word & 0x80000000u ? 8 : + size_t((word >> 16) & 0xfff) * 4; + ASSERT_GE(size, 8); + ASSERT_LE(size, frame->data_size() - offset); + if (!(word & 0x80000000u)) { + const auto index = info >> 8; + indices.push_back(index); + if (index < 12) { + critical_end = offset + size; + } + if (size <= 40) { + EXPECT_EQ((offset + 8) / 48, (offset + 8 + size - 1) / 48); + } + } + offset += size; + } + EXPECT_EQ(frame->pyrowave_record_starts, starts); + EXPECT_EQ(frame->pyrowave_critical_packets, (critical_end + 8 + 47) / 48); + if (indices.size() == 4) { + EXPECT_EQ(indices, (std::vector {1, 0, 13, 12})); + } + } +} + +/** + * @brief Reject malformed native packets and frames that exceed transport or allocation limits. + */ +TEST(PyrowaveFramingTest, RejectsInvalidPacketRangesRecordsAndFrameSizes) { + auto config = pyrowave_config(); + for (bool records : {false, true}) { + config.pyrowaveRecordFraming = records; + auto data = pyrowave_records({{0, 12}}); + for (auto packet : {pyrowave_packet {0, 0}, {0, 3}, {0, 20000}, {data.size() + 1, 8}, {data.size() - 4, 8}}) { + EXPECT_EQ(video::pyrowave::frame_packets(std::span(&packet, 1), data, config, 12, 1), nullptr); + } + EXPECT_EQ(video::pyrowave::frame_packets({}, data, config, 12, 1), nullptr); + for (int size : {-1, 0, 16, 65536}) { + config.packetSize = size; + const std::array packets {pyrowave_packet {0, data.size()}}; + EXPECT_EQ(video::pyrowave::frame_packets(packets, data, config, 12, 1), nullptr); + } + config.packetSize = 1392; + } + for (int failure = 0; failure < 5; ++failure) { + auto data = pyrowave_records({{0, 12}}); + if (failure == 0) { + data.resize(4); + } else if (failure == 1) { + AV_WL32(data.data(), 2 << 16); + } else if (failure == 2) { + AV_WL32(data.data() + 8, 0x80000000u); + } else { + AV_WL32(data.data() + 8, (failure == 3 ? 1 : 4) << 16); + } + const std::array packets {pyrowave_packet {0, data.size()}}; + EXPECT_EQ(video::pyrowave::frame_packets(packets, data, config, 12, 1), nullptr) << failure; + } + const auto data = pyrowave_records({{12, 12}}); + std::vector packets(9000, {8, 12}); + packets.front() = {0, 8}; + config.packetSize = 40; + EXPECT_EQ(video::pyrowave::frame_packets(packets, data, config, 12, 1), nullptr); + const auto large_record = pyrowave_records({{0, 16380}}); + config.packetSize = 65532; + for (size_t count : {1001, 1101}) { + packets.assign(count, {8, 16380}); + packets.front() = {0, 8}; + EXPECT_EQ(video::pyrowave::frame_packets(packets, large_record, config, 12, 1), nullptr); + } + config.pyrowaveRecordFraming = false; + std::vector large_packet(video::pyrowave::packet_boundary); + packets.assign(video::pyrowave::max_frame_size / large_packet.size(), {0, large_packet.size()}); + EXPECT_EQ(video::pyrowave::frame_packets(packets, large_packet, config, 0, 1), nullptr); +} + +TEST(PyrowaveConfigTest, NegotiatesOnlySuccessfullyProbedProfiles) { + auto config = pyrowave_config(); + const uint32_t flags[] {video::pyrowave::scm_sdr, video::pyrowave::scm_sdr_444, video::pyrowave::scm_hdr, video::pyrowave::scm_hdr_444}; + for (uint32_t profiles = 0; profiles < 16; ++profiles) { + std::array calls {}; + const auto probed = video::pyrowave::probe_profiles([&](const video::config_t &probe) { + EXPECT_TRUE(video::pyrowave::valid_config(probe)); + const auto profile = probe.dynamicRange * 2 + probe.chromaSamplingType; + ++calls.at(profile); + return (profiles & (1 << profile)) != 0; + }); + EXPECT_EQ(probed, profiles << 23); + EXPECT_EQ(calls, (std::array {1, 1, 1, 1})); + for (int profile = 0; profile < 4; ++profile) { + config.chromaSamplingType = profile % 2; + config.dynamicRange = profile / 2; + EXPECT_EQ(video::pyrowave::supported(config, probed), (profiles & (1 << profile)) != 0); + EXPECT_TRUE(video::pyrowave::supported(config, flags[profile])); + } + } +} + +TEST(PyrowaveConfigTest, RejectsMalformedOrUnsupportedRequests) { + const auto valid = pyrowave_config(); + EXPECT_TRUE(video::pyrowave::valid_config(valid)); + const std::vector> invalid { + {&video::config_t::videoFormat, -1}, + {&video::config_t::videoFormat, 2}, + {&video::config_t::width, 0}, + {&video::config_t::width, -2}, + {&video::config_t::width, 63}, + {&video::config_t::width, 16386}, + {&video::config_t::height, 0}, + {&video::config_t::height, -2}, + {&video::config_t::height, 47}, + {&video::config_t::height, 16386}, + {&video::config_t::packetSize, 0}, + {&video::config_t::packetSize, -1}, + {&video::config_t::packetSize, 65536}, + {&video::config_t::dynamicRange, -1}, + {&video::config_t::dynamicRange, 2}, + {&video::config_t::chromaSamplingType, -1}, + {&video::config_t::chromaSamplingType, 2}, + {&video::config_t::bitrate, 0}, + {&video::config_t::bitrate, -1}, + {&video::config_t::bitrate, std::numeric_limits::max()}, + {&video::config_t::framerate, 0}, + {&video::config_t::framerate, -1}, + {&video::config_t::framerateX100, -1}, + }; + for (const auto &[member, value] : invalid) { + auto config = valid; + config.*member = value; + EXPECT_FALSE(video::pyrowave::valid_config(config)) << value; + EXPECT_FALSE(video::pyrowave::supported(config, video::pyrowave::scm_mask)) << value; + } + auto config = valid; + config.chromaSamplingType = 1; + config.width = 63; + config.height = 47; + EXPECT_TRUE(video::pyrowave::valid_config(config)); +} + +/** + * @brief Select capture layouts independently of the hardware video encoder's codec support. + */ +TEST(PyrowaveConfigTest, SelectsCaptureFormatsForEachProfile) { + video::encoder_t native { + .platform_formats = std::make_unique( + platf::mem_type_e::dxgi, + platf::pix_fmt_e::nv12, + platf::pix_fmt_e::p010, + platf::pix_fmt_e::ayuv, + platf::pix_fmt_e::yuv444p16 + ) + }; + video::encoder_platform_formats_avcodec cuda { + AV_HWDEVICE_TYPE_CUDA, + AV_HWDEVICE_TYPE_NONE, + AV_PIX_FMT_CUDA, + AV_PIX_FMT_NV12, + AV_PIX_FMT_P010, + AV_PIX_FMT_YUV444P, + AV_PIX_FMT_YUV444P16, + {} + }; + const AVPixelFormat software_formats[] {AV_PIX_FMT_YUV420P, AV_PIX_FMT_YUV444P, AV_PIX_FMT_YUV420P10, AV_PIX_FMT_YUV444P10}; + const AVPixelFormat dxgi_formats[] {AV_PIX_FMT_NV12, AV_PIX_FMT_VUYX, AV_PIX_FMT_P010, AV_PIX_FMT_XV30}; + const AVPixelFormat cuda_formats[] {AV_PIX_FMT_NV12, AV_PIX_FMT_YUV444P, AV_PIX_FMT_P010, AV_PIX_FMT_YUV444P16}; + pyrowave_test_display_t display; + auto config = pyrowave_config(); + for (int profile = 0; profile < 4; ++profile) { + config.chromaSamplingType = profile % 2; + config.dynamicRange = profile / 2; + EXPECT_EQ(video::pyrowave::capture_format(*video::software.platform_formats, config), software_formats[profile]); + EXPECT_EQ(video::pyrowave::capture_format(*native.platform_formats, config), dxgi_formats[profile]); + EXPECT_EQ(video::pyrowave::capture_format(cuda, config), cuda_formats[profile]); + ASSERT_NE(video::make_encode_device(display, native, config), nullptr); + EXPECT_EQ(display.requested, video::map_pix_fmt(dxgi_formats[profile])); + } + video::encoder_platform_formats_t unknown {}; + unknown.dev_type = platf::mem_type_e::system; + EXPECT_EQ(video::pyrowave::capture_format(unknown, config), AV_PIX_FMT_NONE); + cuda.avcodec_pix_fmt_yuv444_8bit = AV_PIX_FMT_NONE; + cuda.avcodec_pix_fmt_yuv444_10bit = AV_PIX_FMT_NONE; + EXPECT_EQ(video::pyrowave::capture_format(cuda, config), AV_PIX_FMT_NONE); + video::encoder_t unavailable { + .platform_formats = std::make_unique(cuda) + }; + display.requested = platf::pix_fmt_e::unknown; + EXPECT_EQ(video::make_encode_device(display, unavailable, config), nullptr); + EXPECT_EQ(display.requested, platf::pix_fmt_e::unknown); + config.dynamicRange = 0; + EXPECT_EQ(video::make_encode_session(nullptr, unavailable, config, 64, 48, std::make_unique()), nullptr); +} + +/** + * @brief Map planar, interleaved, and packed GPU storage while preserving chroma order and HDR range. + */ +TEST(PyrowaveConfigTest, MapsGpuImagesWithoutRepackingPixels) { + struct layout_t { + AVPixelFormat format; ///< Capture pixel layout. + VkFormat combined; ///< Multiplanar Vulkan image format. + std::array planes; ///< Separate Vulkan plane formats. + float scale; ///< Normalization for separate images. + }; + + const layout_t layouts[] { + {AV_PIX_FMT_NV12, VK_FORMAT_G8_B8R8_2PLANE_420_UNORM, {VK_FORMAT_R8_UNORM, VK_FORMAT_R8G8_UNORM}, 1.0f}, + {AV_PIX_FMT_P010, VK_FORMAT_G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, {VK_FORMAT_R16_UNORM, VK_FORMAT_R16G16_UNORM}, 65535.0f / 65472.0f}, + {AV_PIX_FMT_YUV420P, VK_FORMAT_G8_B8_R8_3PLANE_420_UNORM, {VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM}, 1.0f}, + {AV_PIX_FMT_YUV444P, VK_FORMAT_G8_B8_R8_3PLANE_444_UNORM, {VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM, VK_FORMAT_R8_UNORM}, 1.0f}, + {AV_PIX_FMT_YUV420P10, VK_FORMAT_G16_B16_R16_3PLANE_420_UNORM, {VK_FORMAT_R16_UNORM, VK_FORMAT_R16_UNORM, VK_FORMAT_R16_UNORM}, 65535.0f / 1023.0f}, + {AV_PIX_FMT_YUV444P10, VK_FORMAT_G16_B16_R16_3PLANE_444_UNORM, {VK_FORMAT_R16_UNORM, VK_FORMAT_R16_UNORM, VK_FORMAT_R16_UNORM}, 65535.0f / 1023.0f}, + {AV_PIX_FMT_YUV444P16, VK_FORMAT_G16_B16_R16_3PLANE_444_UNORM, {VK_FORMAT_R16_UNORM, VK_FORMAT_R16_UNORM, VK_FORMAT_R16_UNORM}, 1.0f}, + }; + pyrowave_gpu_buffers buffers {}; + float scale; + for (const auto &layout : layouts) { + SCOPED_TRACE(av_get_pix_fmt_name(layout.format)); + const auto desc = av_pix_fmt_desc_get(layout.format); + const auto planes = av_pix_fmt_count_planes(layout.format); + for (bool combined : {false, true}) { + std::vector images(combined ? 1 : planes); + for (size_t i = 0; i < images.size(); ++i) { + images[i].image = reinterpret_cast(i + 1); + images[i].image_format = combined ? layout.combined : layout.planes[i]; + images[i].width = 64 >> (i ? desc->log2_chroma_w : 0); + images[i].height = 48 >> (i ? desc->log2_chroma_h : 0); + images[i].layout = VK_IMAGE_LAYOUT_GENERAL; + } + ASSERT_TRUE(video::pyrowave::map_gpu_frame(layout.format, images, buffers, scale)); + EXPECT_FLOAT_EQ(scale, combined && layout.format == AV_PIX_FMT_P010 ? 1.0f : layout.scale); + for (int component = 0; component < 3; ++component) { + const auto plane = planes == 2 && component ? 1 : component; + const auto &view = buffers.planes[component]; + EXPECT_EQ(view.image, images[combined ? 0 : plane].image); + EXPECT_EQ(view.aspect, combined ? VkImageAspectFlagBits(VK_IMAGE_ASPECT_PLANE_0_BIT << plane) : VK_IMAGE_ASPECT_COLOR_BIT); + EXPECT_EQ(view.swizzle, planes == 2 && component == 2 ? VK_COMPONENT_SWIZZLE_G : VK_COMPONENT_SWIZZLE_R); + EXPECT_EQ(view.width, images[combined ? 0 : plane].width); + EXPECT_EQ(view.height, images[combined ? 0 : plane].height); + EXPECT_EQ(view.layout, VK_IMAGE_LAYOUT_GENERAL); + EXPECT_EQ(view.view_format, combined && layout.format == AV_PIX_FMT_P010 ? (plane ? VK_FORMAT_R10X6G10X6_UNORM_2PACK16 : VK_FORMAT_R10X6_UNORM_PACK16) : layout.planes[plane]); + } + if (!combined) { + --images[1].width; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(layout.format, images, buffers, scale)); + ++images[1].width; + --images[1].height; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(layout.format, images, buffers, scale)); + ++images[1].height; + auto extra = images; + extra.push_back(images[0]); + EXPECT_FALSE(video::pyrowave::map_gpu_frame(layout.format, extra, buffers, scale)); + } + images[0].width = 0; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(layout.format, images, buffers, scale)); + images[0].width = 64; + images[0].height = 0; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(layout.format, images, buffers, scale)); + images[0].height = 48; + images[0].image_format = VK_FORMAT_UNDEFINED; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(layout.format, images, buffers, scale)); + } + } + const std::pair> packed[] { + {VK_FORMAT_R8G8B8A8_UNORM, {VK_COMPONENT_SWIZZLE_B, VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_R}}, + {VK_FORMAT_A2B10G10R10_UNORM_PACK32, {VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_R, VK_COMPONENT_SWIZZLE_B}}, + {VK_FORMAT_A2R10G10B10_UNORM_PACK32, {VK_COMPONENT_SWIZZLE_G, VK_COMPONENT_SWIZZLE_B, VK_COMPONENT_SWIZZLE_R}}, + }; + for (const auto &[format, swizzles] : packed) { + pyrowave_image_view image {}; + image.image = reinterpret_cast(1); + image.width = 64; + image.height = 48; + image.aspect = VK_IMAGE_ASPECT_COLOR_BIT; + image.image_format = format; + const auto pixel_format = format == VK_FORMAT_R8G8B8A8_UNORM ? AV_PIX_FMT_VUYX : AV_PIX_FMT_XV30; + ASSERT_TRUE(video::pyrowave::map_gpu_frame(pixel_format, std::span(&image, 1), buffers, scale)); + EXPECT_FLOAT_EQ(scale, 1.0f); + for (int component = 0; component < 3; ++component) { + EXPECT_EQ(buffers.planes[component].swizzle, swizzles[component]); + EXPECT_EQ(buffers.planes[component].view_format, format); + } + const std::array duplicate {image, image}; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(pixel_format, duplicate, buffers, scale)); + image.image_format = VK_FORMAT_UNDEFINED; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(pixel_format, std::span(&image, 1), buffers, scale)); + EXPECT_FALSE(video::pyrowave::map_gpu_frame(AV_PIX_FMT_RGB24, std::span(&image, 1), buffers, scale)); + image.image = VK_NULL_HANDLE; + EXPECT_FALSE(video::pyrowave::map_gpu_frame(pixel_format, std::span(&image, 1), buffers, scale)); + } + EXPECT_FALSE(video::pyrowave::map_gpu_frame(AV_PIX_FMT_NV12, {}, buffers, scale)); +} + +/** + * @brief Respect host bitrate limits and fractional rates without integer overflow. + */ +TEST(PyrowaveConfigTest, BudgetsCappedAndFractionalRatesWithoutIntegerOverflow) { + const auto previous = config::video.max_bitrate; + const auto restore = util::fail_guard([previous]() { + config::video.max_bitrate = previous; + }); + config::video.max_bitrate = 0; + auto config = pyrowave_config(); + EXPECT_EQ(video::pyrowave::frame_budget(config), 416666); + config.framerateX100 = 5994; + EXPECT_EQ(video::pyrowave::frame_budget(config), 417083); + config.framerateX100 = 0; + EXPECT_EQ(video::pyrowave::frame_budget(config), 416666); + config.bitrate = 1; + config.framerate = 1000; + EXPECT_EQ(video::pyrowave::frame_budget(config), 0); + config.bitrate = std::numeric_limits::max(); + config.framerate = std::numeric_limits::max(); + EXPECT_EQ(video::pyrowave::frame_budget(config), 125); + + config = pyrowave_config(); + config::video.max_bitrate = 5000; + EXPECT_EQ(video::pyrowave::frame_budget(config), 10416); + config.framerateX100 = 5994; + EXPECT_EQ(video::pyrowave::frame_budget(config), 10427); + config.framerateX100 = 0; + EXPECT_EQ(video::pyrowave::frame_budget(config), 10416); + config.bitrate = 2000; + EXPECT_EQ(video::pyrowave::frame_budget(config), 4166); + config.bitrate = 5000; + EXPECT_EQ(video::pyrowave::frame_budget(config), 10416); + config = pyrowave_config(); + config::video.max_bitrate = -1; + EXPECT_EQ(video::pyrowave::frame_budget(config), 416666); + for (int packet_size : {-1, 0, 16, 65536}) { + config.packetSize = packet_size; + EXPECT_EQ(video::pyrowave::frame_budget(config), 0); + } +} + +/** + * @brief Fit below-budget native frames within the negotiated transport, including record padding. + */ +TEST(PyrowaveConfigTest, BoundsAcceptedBudgetsByPacketCapacity) { + const auto previous = config::video.max_bitrate; + const auto restore = util::fail_guard([previous]() { + config::video.max_bitrate = previous; + }); + config::video.max_bitrate = 0; + auto config = pyrowave_config(); + config.width = 3840; + config.height = 2160; + for (bool records : {false, true}) { + config.pyrowaveRecordFraming = records; + for (int packet_size : {config::PACKETSIZE_MIN, 201, 1392, 36, 65532}) { + SCOPED_TRACE(testing::Message() << "records " << records << ", packet size " << packet_size); + config.packetSize = packet_size; + config.bitrate = packet_size == 65532 ? 8000000 : 1000000; + ASSERT_TRUE(video::pyrowave::valid_config(config)); + const auto budget = video::pyrowave::frame_budget(config); + const auto capacity = 4092 * (size_t(packet_size) - 16) - 8; + if (packet_size == config::PACKETSIZE_MIN) { + EXPECT_EQ(capacity, 752920); + // The reported 1,024,008-byte frame must no longer be below the accepted budget. + EXPECT_LT(budget, 1024008); + } else if (packet_size == 1392) { + EXPECT_EQ(budget, 2083333); + } + // Cover tiny records, padding on either side of a shard boundary, and records spanning shards. + for (size_t record_size : {8, 88, 164, 176, 180, 184, 1024, 16380}) { + SCOPED_TRACE(record_size); + std::vector native(8); + std::vector packets {{0, 8}}; + for (uint32_t index = 0; native.size() + record_size <= budget; ++index) { + const auto offset = native.size(); + native.resize(offset + record_size); + AV_WL32(native.data() + offset, (record_size / 4) << 16); + AV_WL32(native.data() + offset + 4, index << 8); + packets.push_back({offset, record_size}); + } + AV_WL32(native.data(), 0x80000000u | (config.width - 1) | ((config.height - 1) << 14)); + AV_WL32(native.data() + 4, packets.size() - 1); + ASSERT_LE(native.size(), budget); + const auto frame = video::pyrowave::frame_packets(packets, native, config, 12, 1); + ASSERT_NE(frame, nullptr); + EXPECT_LE(frame->data_size(), capacity); + EXPECT_LE(frame->data_size(), video::pyrowave::max_frame_size); + EXPECT_LE(frame->pyrowave_record_starts.size(), 4092); + } + } + } +} + +TEST(PyrowaveConfigTest, PreservesHdrWithoutConsultingUnrelatedCodecCapabilities) { + auto config = pyrowave_config(); + config.dynamicRange = 1; + video::encoder_t encoder {}; + for (int chroma : {0, 1}) { + config.chromaSamplingType = chroma; + EXPECT_EQ(video::resolve_dynamic_range(encoder, config).dynamicRange, 1); + } +} + +TEST(PyrowaveConfigTest, DisablingTheCodecClearsPreviouslyProbedProfiles) { + const auto previous_profiles = video::active_pyrowave_profiles; + const auto previous_enabled = config::video.pyrowave; + const auto restore = util::fail_guard([&]() { + video::active_pyrowave_profiles = previous_profiles; + config::video.pyrowave = previous_enabled; + }); + video::active_pyrowave_profiles = video::pyrowave::scm_mask; + config::video.pyrowave = false; + video::probe_pyrowave({}); + EXPECT_EQ(video::active_pyrowave_profiles, 0); +} + +TEST(PyrowaveConfigTest, RejectsInvalidCaptureDevicesAndHdrFromRam) { + auto config = pyrowave_config(); + EXPECT_EQ(video::make_encode_session(nullptr, video::software, config, 64, 48, nullptr), nullptr); + EXPECT_EQ(video::make_encode_session(nullptr, {}, config, 64, 48, std::make_unique()), nullptr); + EXPECT_EQ(video::make_encode_session(nullptr, video::software, config, 0, 48, std::make_unique()), nullptr); + EXPECT_EQ(video::make_encode_session(nullptr, video::software, config, 64, 0, std::make_unique()), nullptr); + config.width = 63; + EXPECT_EQ(video::make_encode_session(nullptr, video::software, config, 64, 48, std::make_unique()), nullptr); + config.width = 64; + config.dynamicRange = 1; + EXPECT_EQ(video::make_encode_session(nullptr, video::software, config, 64, 48, std::make_unique()), nullptr); +} + +namespace { + /** + * @brief Converter supplying padded planar or packed YUV frames without display hardware. + */ + class pyrowave_test_converter_t: public platf::avcodec_encode_device_t { + public: + /** + * @brief Fill alternating luma and checkerboard chroma with full-range endpoint values. + * @param format Pixel storage layout supplied by the capture converter. + * @param negative_stride Store rows in reverse order within each plane. + */ + explicit pyrowave_test_converter_t(AVPixelFormat format, bool negative_stride = false) { + owned.reset(av_frame_alloc()); + frame = owned.get(); + frame->format = format; + frame->width = 64; + frame->height = 48; + colorspace = {video::colorspace_e::bt2020, true, 10}; + frame->colorspace = AVCOL_SPC_BT2020_NCL; + frame->color_primaries = AVCOL_PRI_BT2020; + frame->color_trc = AVCOL_TRC_SMPTE2084; + frame->color_range = AVCOL_RANGE_JPEG; + if (av_frame_get_buffer(frame, 128) < 0) { + throw std::bad_alloc(); + } + for (auto buffer : frame->buf) { + if (buffer) { + std::memset(buffer->data, 0, buffer->size); + } + } + const auto desc = av_pix_fmt_desc_get(format); + if (negative_stride) { + for (int plane = 0; plane < av_pix_fmt_count_planes(format); ++plane) { + const auto height = 48 >> (plane ? desc->log2_chroma_h : 0); + frame->data[plane] += (height - 1) * frame->linesize[plane]; + frame->linesize[plane] *= -1; + } + } + const auto maximum = desc->comp[0].depth > 8 ? 1023 : 255; + for (int component = 0; component < 3; ++component) { + const auto width = 64 >> (component ? desc->log2_chroma_w : 0); + const auto height = 48 >> (component ? desc->log2_chroma_h : 0); + std::array row {}; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const auto value = (x + (component ? y : 0) + (component == 2)) % 2 ? maximum : 1; + row[x] = uint16_t(desc->comp[0].depth == 16 ? value * 65535 / 1023 : value); + } + av_write_image_line(row.data(), frame->data, frame->linesize, desc, 0, y, component, width); + } + } + } + + /** + * @brief Observe destruction before releasing the converter's frame. + */ + ~pyrowave_test_converter_t() override { + if (on_destroy) { + on_destroy(); + } + } + + /** + * @brief Supply the prepared pixels or simulate a conversion error. + * @param img Unused captured image. + * @return Configured conversion status. + */ + int convert(platf::img_t &img) override { + return status; + } + + int status = 0; ///< Simulated conversion result. + video::avcodec_frame_t owned; ///< Owner of the input frame. + std::function on_destroy; ///< Optional lifetime assertion. + }; + + /** + * @brief Mock codec state with explicit control over each failure boundary. + */ + struct pyrowave_api_state_t { + uint32_t major = 1; ///< Reported ABI major version. + pyrowave_result device_result = PYROWAVE_SUCCESS; ///< Device creation status. + pyrowave_result encoder_result = PYROWAVE_SUCCESS; ///< Encoder creation status. + pyrowave_result encode_result = PYROWAVE_SUCCESS; ///< Encode status. + pyrowave_result count_result = PYROWAVE_SUCCESS; ///< Packet count status. + pyrowave_result storage_result = PYROWAVE_SUCCESS; ///< Native storage allocation query status. + pyrowave_result packet_result = PYROWAVE_SUCCESS; ///< Packetization status. + pyrowave_result blocks_result = PYROWAVE_SUCCESS; ///< Critical band query status. + size_t count = 2; ///< Packet allocation requested from the caller. + size_t storage_count = 1; ///< Large native packets required for safe backing storage. + size_t output_count = 2; ///< Packet count actually written. + size_t packet_offset = 4; ///< First packet offset in the backing buffer. + size_t packet_size = 20; ///< First packet length. + size_t boundary = video::pyrowave::packet_boundary; ///< Expected native packetization boundary. + int encoded = 0; ///< Number of codec calls, including repeated frames. + int gpu_encoded = 0; ///< Number of GPU encoding calls. + pyrowave_result share_result = PYROWAVE_SUCCESS; ///< Shared device initialization result. + pyrowave_device_create_info shared_info {}; ///< Borrowed Vulkan device metadata. + VkCommandBuffer command_buffer {}; ///< Command buffer currently receiving encoding commands. + pyrowave_gpu_buffers gpu_input {}; ///< Last GPU component views. + float scaling_factor = 0.0f; ///< Last GPU sample normalization. + size_t budget = 0; ///< Last requested bitrate budget. + pyrowave_cpu_buffer input {}; ///< Last codec input layout and borrowed plane addresses. + pyrowave_chroma_subsampling chroma {}; ///< Chroma sampling used to create the encoder. + std::optional> solid_color; ///< Expected uniform YUV values instead of the checkerboard. + std::array samples {}; ///< First two luma samples and first Cb/Cr samples. + std::vector destroyed; ///< Resource destruction order. + }; + + thread_local pyrowave_api_state_t *api_state; ///< State for the current synchronous fake API test. + + /** + * @brief Build deterministic C API callbacks without a Vulkan driver. + * @return Injectable C API table. + */ + video::pyrowave::api_t fake_pyrowave_api() { + video::pyrowave::api_t api; + api.share_device = nullptr; + api.match_device = nullptr; + api.get_api_version = [](uint32_t *major, uint32_t *minor, uint32_t *patch) { + *major = api_state->major; + *minor = PYROWAVE_API_VERSION_MINOR; + *patch = PYROWAVE_API_VERSION_PATCH; + }; + api.create_device = [](pyrowave_device *device) { + if (api_state->device_result == PYROWAVE_SUCCESS) { + *device = reinterpret_cast(api_state); + } + return api_state->device_result; + }; + api.destroy_device = [](pyrowave_device) { + api_state->destroyed.emplace_back("device"); + }; + api.create_encoder = [](const pyrowave_encoder_create_info *info, pyrowave_encoder *encoder) { + EXPECT_EQ(info->width, 64); + EXPECT_EQ(info->height, 48); + api_state->chroma = info->chroma; + if (api_state->encoder_result == PYROWAVE_SUCCESS) { + *encoder = reinterpret_cast(api_state); + } + return api_state->encoder_result; + }; + api.destroy_encoder = [](pyrowave_encoder) { + api_state->destroyed.emplace_back("encoder"); + }; + api.encode_cpu = [](pyrowave_encoder, const pyrowave_cpu_buffer *input, const pyrowave_rate_control *rate) { + ++api_state->encoded; + api_state->budget = rate->maximum_bitstream_size; + api_state->input = *input; + EXPECT_EQ(input->width, 64); + EXPECT_EQ(input->height, 48); + const bool hdr = input->format == PYROWAVE_CPU_BUFFER_FORMAT_YUV420P10 || input->format == PYROWAVE_CPU_BUFFER_FORMAT_YUV444P10; + const bool nv12 = input->format == PYROWAVE_CPU_BUFFER_FORMAT_NV12; + const bool subsampled = api_state->chroma == PYROWAVE_CHROMA_SUBSAMPLING_420; + for (int component = 0; component < 3; ++component) { + const auto plane = nv12 && component ? 1 : component; + const auto width = component && subsampled ? 32 : 64; + const auto height = component && subsampled ? 24 : 48; + EXPECT_GE(input->row_stride_in_bytes[plane], width * (hdr || (nv12 && component) ? 2 : 1)); + EXPECT_EQ(input->plane_size_in_bytes[plane], input->row_stride_in_bytes[plane] * height); + for (int y = 0; y < height; ++y) { + const auto row = static_cast(input->data[plane]) + y * input->row_stride_in_bytes[plane]; + for (int x = 0; x < width; ++x) { + const auto value = hdr ? reinterpret_cast(row)[x] : row[nv12 && component ? 2 * x + (component == 2) : x]; + const auto expected = api_state->solid_color ? (*api_state->solid_color)[component] : + ((x + (component ? y : 0) + (component == 2)) % 2 ? (hdr ? 1023 : 255) : 1); + EXPECT_EQ(value, expected) << "plane " << plane << ", x " << x << ", y " << y; + } + } + } + if (nv12) { + EXPECT_EQ(input->data[2], nullptr); + EXPECT_EQ(input->row_stride_in_bytes[2], 0); + EXPECT_EQ(input->plane_size_in_bytes[2], 0); + api_state->samples = {static_cast(input->data[0])[0], static_cast(input->data[0])[1], static_cast(input->data[1])[0], static_cast(input->data[1])[1]}; + } else if (hdr) { + api_state->samples = {static_cast(input->data[0])[0], static_cast(input->data[0])[1], static_cast(input->data[1])[0], static_cast(input->data[2])[0]}; + } else { + api_state->samples = {static_cast(input->data[0])[0], static_cast(input->data[0])[1], static_cast(input->data[1])[0], static_cast(input->data[2])[0]}; + } + return api_state->encode_result; + }; + api.num_packets = [](pyrowave_encoder, size_t boundary, size_t *count) { + if (api_state->boundary == video::pyrowave::packet_boundary && boundary == video::pyrowave::record_packet_boundary) { + *count = api_state->storage_count; + return api_state->storage_result; + } + EXPECT_EQ(boundary, api_state->boundary); + *count = api_state->count; + return api_state->count_result; + }; + api.packetize = [](pyrowave_encoder, pyrowave_packet *packets, size_t, size_t *count, void *data, size_t size) { + EXPECT_GE(size, video::pyrowave::record_packet_boundary); + packets[0] = {api_state->packet_offset, api_state->packet_size}; + if (api_state->count > 1) { + packets[1] = {32, 12}; + } + auto bytes = static_cast(data); + AV_WL32(bytes + 4, 0x80000000u | 63 | (47 << 14)); + AV_WL32(bytes + 8, 2 | (api_state->chroma == PYROWAVE_CHROMA_SUBSAMPLING_444 ? 1 << 26 : 0)); + AV_WL32(bytes + 12, 3 << 16); + AV_WL32(bytes + 16, 0); + AV_WL32(bytes + 20, 0x11223344); + AV_WL32(bytes + 32, 3 << 16); + AV_WL32(bytes + 36, 12 << 8); + AV_WL32(bytes + 40, 0x55667788); + *count = api_state->output_count; + return api_state->packet_result; + }; + api.active_blocks = [](pyrowave_encoder, int bands, size_t *blocks) { + EXPECT_EQ(bands, 2); + *blocks = 12; + return api_state->blocks_result; + }; + return api; + } + +#ifdef SUNSHINE_BUILD_VULKAN + /** + * @brief Replace only codec commands while exercising real Vulkan frame submission and synchronization. + * @return Codec entry points supporting shared Vulkan inputs. + */ + video::pyrowave::api_t fake_gpu_pyrowave_api() { + auto api = fake_pyrowave_api(); + api.share_device = [](const pyrowave_device_create_info *info, pyrowave_device *device) { + api_state->shared_info = *info; + if (api_state->share_result == PYROWAVE_SUCCESS) { + *device = reinterpret_cast(api_state); + } + return api_state->share_result; + }; + api.set_queue_type = [](pyrowave_device, VkQueueFlagBits queue) { + EXPECT_EQ(queue, VK_QUEUE_COMPUTE_BIT); + return PYROWAVE_SUCCESS; + }; + api.set_command_buffer = [](pyrowave_device, VkCommandBuffer command) { + api_state->command_buffer = command; + }; + api.set_scaling_factor = [](pyrowave_encoder, float factor) { + api_state->scaling_factor = factor; + }; + api.encode_gpu = [](pyrowave_encoder, const pyrowave_gpu_sync_operation *acquire, const pyrowave_gpu_sync_operation *release, const pyrowave_gpu_buffers *buffers, const pyrowave_rate_control *rate) { + EXPECT_EQ(acquire, nullptr); + EXPECT_EQ(release, nullptr); + EXPECT_NE(api_state->command_buffer, VK_NULL_HANDLE); + api_state->gpu_input = *buffers; + api_state->budget = rate->maximum_bitstream_size; + ++api_state->gpu_encoded; + return api_state->encode_result; + }; + return api; + } +#endif +} // namespace + +/** + * @brief Reset fake C API state between independent encoder tests. + */ +class PyrowaveSessionTest: public testing::Test { +protected: + /** + * @brief Bind this test's state to the fake C callbacks. + */ + void SetUp() override { + api_state = &state; + } + + /** + * @brief Clear the callback state after each test. + */ + void TearDown() override { + api_state = nullptr; + } + + pyrowave_api_state_t state; ///< Per-test codec status and observations. +}; + +TEST_F(PyrowaveSessionTest, PreservesFrameTimingAndStreamOwnershipInThePacketQueue) { + auto session = video::pyrowave::make_session(pyrowave_config(), std::make_unique(AV_PIX_FMT_NV12), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + auto mailbox = std::make_shared(); + auto packets = mailbox->queue("pyrowave-test"); + auto stream = reinterpret_cast(&state); + const auto timestamp = std::chrono::steady_clock::now(); + EXPECT_EQ(video::encode(1, *session, packets, stream, timestamp), -1); + EXPECT_FALSE(packets->peek()); + platf::img_t image {}; + ASSERT_EQ(session->convert(image), 0); + ASSERT_EQ(video::encode(42, *session, packets, stream, timestamp), 0); + ASSERT_TRUE(packets->peek()); + auto packet = packets->pop(); + EXPECT_EQ(packet->frame_index(), 42); + EXPECT_EQ(packet->channel_data, stream); + EXPECT_EQ(packet->frame_timestamp, timestamp); + ASSERT_EQ(video::encode(43, *session, packets, nullptr, {}), 0); + ASSERT_TRUE(packets->peek()); + packet = packets->pop(); + EXPECT_FALSE(packet->frame_timestamp.has_value()); + EXPECT_EQ(packet->channel_data, nullptr); +} + +/** + * @brief Attach missing VideoToolbox pool references while preserving existing references and lifetimes. + */ +TEST_F(PyrowaveSessionTest, RetainsTheBorrowedHardwarePoolUntilAfterConverterDestruction) { + const std::pair cases[] { + {AV_PIX_FMT_NV12, false}, + {AV_PIX_FMT_VIDEOTOOLBOX, false}, + {AV_PIX_FMT_VIDEOTOOLBOX, true}, + }; + for (const auto &[format, has_pool] : cases) { + bool released = false; + AVHWFramesContext pool {}; + pool.sw_format = AV_PIX_FMT_NV12; + video::avcodec_buffer_t frames {av_buffer_create(reinterpret_cast(&pool), sizeof(pool), [](void *opaque, uint8_t *) { + *static_cast(opaque) = true; + }, + &released, + 0)}; + ASSERT_NE(frames, nullptr); + auto converter = std::make_unique(AV_PIX_FMT_NV12); + const auto frame = converter->frame; + frame->format = format; + if (has_pool) { + frame->hw_frames_ctx = av_buffer_ref(frames.get()); + ASSERT_NE(frame->hw_frames_ctx, nullptr); + } + const auto original_pool = frame->hw_frames_ctx; + const auto pool_buffer = frames->buffer; + converter->on_destroy = [&]() { + EXPECT_FALSE(released); + }; + auto session = video::pyrowave::make_session(pyrowave_config(), std::move(converter), fake_pyrowave_api(), std::move(frames)); + ASSERT_NE(session, nullptr); + EXPECT_FALSE(released); + if (format == AV_PIX_FMT_VIDEOTOOLBOX) { + ASSERT_NE(frame->hw_frames_ctx, nullptr); + EXPECT_EQ(frame->hw_frames_ctx->buffer, pool_buffer); + EXPECT_EQ(av_buffer_get_ref_count(frame->hw_frames_ctx), 2); + if (has_pool) { + EXPECT_EQ(frame->hw_frames_ctx, original_pool); + } + } else { + EXPECT_EQ(frame->hw_frames_ctx, nullptr); + platf::img_t image {}; + EXPECT_EQ(session->convert(image), 0); + } + EXPECT_FALSE(released); + session.reset(); + EXPECT_TRUE(released); + } +} + +/** + * @brief Reuse compatible planes, retain repeated frames, and convert other layouts without losing samples. + */ +TEST_F(PyrowaveSessionTest, EncodesSdrAndHdrWithExactPlaneValuesAndFraming) { + const AVPixelFormat formats[] {AV_PIX_FMT_NV12, AV_PIX_FMT_P010, AV_PIX_FMT_YUV420P, AV_PIX_FMT_YUV420P10, AV_PIX_FMT_YUV444P, AV_PIX_FMT_YUV444P10, AV_PIX_FMT_YUV444P16, AV_PIX_FMT_VUYX, AV_PIX_FMT_XV30}; + for (auto format : formats) { + for (int storage = 0; storage < 3; ++storage) { + SCOPED_TRACE(av_get_pix_fmt_name(format)); + SCOPED_TRACE(storage); + const auto desc = av_pix_fmt_desc_get(format); + const bool hdr = desc->comp[0].depth > 8; + const bool yuv444 = desc->log2_chroma_w == 0; + auto config = pyrowave_config(); + config.dynamicRange = hdr; + config.chromaSamplingType = yuv444; + auto converter = std::make_unique(format, storage == 1); + const auto raw = converter.get(); + video::avcodec_frame_t owner; + if (storage == 2) { + owner.reset(av_frame_clone(raw->frame)); + ASSERT_NE(owner, nullptr); + for (auto &buffer : raw->frame->buf) { + av_buffer_unref(&buffer); + } + } + auto session = video::pyrowave::make_session(config, std::move(converter), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + EXPECT_EQ(session->encode(1), nullptr); + platf::img_t image {}; + ASSERT_EQ(session->convert(image), 0); + session->request_idr_frame(); + session->request_normal_frame(); + session->invalidate_ref_frames(1, 2); + auto packet = session->encode(42); + ASSERT_NE(packet, nullptr); + EXPECT_TRUE(packet->is_idr()); + EXPECT_EQ(packet->frame_index(), 42); + ASSERT_EQ(packet->data_size(), 44); + EXPECT_EQ(AV_RL32(packet->data()), 2); + EXPECT_EQ(AV_RL32(packet->data() + 4), 20); + EXPECT_EQ(AV_RL32(packet->data() + 8), 0x80000000u | 63 | (47 << 14)); + EXPECT_EQ(AV_RL32(packet->data() + 24), 0x11223344); + EXPECT_EQ(AV_RL32(packet->data() + 28), 12); + EXPECT_EQ(AV_RL32(packet->data() + 40), 0x55667788); + EXPECT_EQ(packet->pyrowave_critical_packets, 0); + EXPECT_TRUE(packet->pyrowave_record_starts.empty()); + EXPECT_EQ(state.budget, 416666); + EXPECT_EQ(state.chroma, yuv444 ? PYROWAVE_CHROMA_SUBSAMPLING_444 : PYROWAVE_CHROMA_SUBSAMPLING_420); + EXPECT_EQ(state.input.format, format == AV_PIX_FMT_NV12 ? PYROWAVE_CPU_BUFFER_FORMAT_NV12 : yuv444 ? (hdr ? PYROWAVE_CPU_BUFFER_FORMAT_YUV444P10 : PYROWAVE_CPU_BUFFER_FORMAT_YUV444P) : + (hdr ? PYROWAVE_CPU_BUFFER_FORMAT_YUV420P10 : PYROWAVE_CPU_BUFFER_FORMAT_YUV420P)); + const bool direct = storage == 0 && (format == AV_PIX_FMT_NV12 || format == AV_PIX_FMT_YUV420P || format == AV_PIX_FMT_YUV420P10 || format == AV_PIX_FMT_YUV444P || format == AV_PIX_FMT_YUV444P10); + if (direct) { + for (int plane = 0; plane < av_pix_fmt_count_planes(format); ++plane) { + EXPECT_EQ(state.input.data[plane], raw->frame->data[plane]); + EXPECT_EQ(state.input.row_stride_in_bytes[plane], raw->frame->linesize[plane]); + } + EXPECT_EQ(av_buffer_get_ref_count(raw->frame->buf[0]), 2); + } else { + EXPECT_NE(state.input.data[0], raw->frame->data[0]); + } + EXPECT_EQ(state.samples, (std::array {1, uint16_t(hdr ? 1023 : 255), 1, uint16_t(hdr ? 1023 : 255)})); + // Repeated capture content must be encoded again so the codec sequence advances. + ASSERT_EQ(session->convert(image), 0); + raw->owned.reset(); + owner.reset(); + EXPECT_NE(session->encode(43), nullptr); + session.reset(); + EXPECT_EQ(state.destroyed, (std::vector {"encoder", "device"})); + state.destroyed.clear(); + } + } + EXPECT_EQ(state.encoded, 6 * std::size(formats)); +} + +/** + * @brief Keep the codec's cached upload layout stable when a frame needs scaling. + */ +TEST_F(PyrowaveSessionTest, KeepsTheInputLayoutWhenScalingFrames) { + state.solid_color = std::array {128, 128, 128}; + for (auto format : {AV_PIX_FMT_NV12, AV_PIX_FMT_YUV420P, AV_PIX_FMT_YUV444P}) { + SCOPED_TRACE(av_get_pix_fmt_name(format)); + auto converter = std::make_unique(format); + const auto frame = converter->frame; + std::memset(frame->buf[0]->data, 128, frame->buf[0]->size); + auto config = pyrowave_config(); + config.chromaSamplingType = format == AV_PIX_FMT_YUV444P; + auto session = video::pyrowave::make_session(config, std::move(converter), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + platf::img_t image {}; + for (int scale : {1, 2, 1}) { + frame->width = 64 / scale; + frame->height = 48 / scale; + ASSERT_EQ(session->convert(image), 0); + ASSERT_NE(session->encode(scale), nullptr); + EXPECT_EQ(state.input.format, format == AV_PIX_FMT_NV12 ? PYROWAVE_CPU_BUFFER_FORMAT_NV12 : format == AV_PIX_FMT_YUV420P ? PYROWAVE_CPU_BUFFER_FORMAT_YUV420P : + PYROWAVE_CPU_BUFFER_FORMAT_YUV444P); + EXPECT_EQ(state.input.data[0] == frame->data[0], scale == 1); + } + } +} + +/** + * @brief Preserve SDR/HDR samples when reading Vulkan frames or borrowed macOS capture buffers. + */ +TEST_F(PyrowaveSessionTest, DownloadsHardwareFramesWithoutLosingTenBitPrecision) { +#ifdef __APPLE__ + constexpr auto device_type = AV_HWDEVICE_TYPE_VIDEOTOOLBOX; + constexpr auto hardware_format = AV_PIX_FMT_VIDEOTOOLBOX; + const AVPixelFormat formats[] {AV_PIX_FMT_NV12, AV_PIX_FMT_P010}; +#else + constexpr auto device_type = AV_HWDEVICE_TYPE_VULKAN; + constexpr auto hardware_format = AV_PIX_FMT_VULKAN; + const AVPixelFormat formats[] {AV_PIX_FMT_NV12, AV_PIX_FMT_P010, AV_PIX_FMT_YUV444P, AV_PIX_FMT_YUV444P10, AV_PIX_FMT_YUV444P16}; +#endif + AVBufferRef *raw_device = nullptr; + if (av_hwdevice_ctx_create(&raw_device, device_type, nullptr, nullptr, 0) < 0) { + GTEST_SKIP() << "An FFmpeg-compatible " << av_hwdevice_get_type_name(device_type) << " device is unavailable"; + } + video::avcodec_buffer_t device {raw_device}; + for (auto format : formats) { + SCOPED_TRACE(av_get_pix_fmt_name(format)); + const auto desc = av_pix_fmt_desc_get(format); + const bool hdr = desc->comp[0].depth > 8; + auto config = pyrowave_config(); + config.dynamicRange = hdr; + config.chromaSamplingType = desc->log2_chroma_w == 0; + auto converter = std::make_unique(format); + auto host = std::move(converter->owned); + converter->owned.reset(av_frame_alloc()); + converter->frame = converter->owned.get(); + ASSERT_NE(converter->frame, nullptr); + video::avcodec_buffer_t frames {av_hwframe_ctx_alloc(device.get())}; + ASSERT_NE(frames, nullptr); + auto pool = reinterpret_cast(frames->data); + pool->format = hardware_format; + pool->sw_format = format; + pool->width = 64; + pool->height = 48; + ASSERT_EQ(av_hwframe_ctx_init(frames.get()), 0); +#ifdef __APPLE__ + CVPixelBufferRef pixel_buffer = nullptr; + ASSERT_EQ(CVPixelBufferCreate(kCFAllocatorDefault, 64, 48, hdr ? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange : kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, nullptr, &pixel_buffer), kCVReturnSuccess); + auto release_buffer = util::fail_guard([&]() { + CVPixelBufferRelease(pixel_buffer); + }); + converter->frame->buf[0] = av_buffer_create(reinterpret_cast(pixel_buffer), 0, [](void *, uint8_t *data) { + CVPixelBufferRelease(reinterpret_cast(data)); + }, + nullptr, + 0); + ASSERT_NE(converter->frame->buf[0], nullptr); + release_buffer.disable(); + converter->frame->data[3] = reinterpret_cast(pixel_buffer); + converter->frame->format = hardware_format; + converter->frame->width = 64; + converter->frame->height = 48; + converter->frame->hw_frames_ctx = av_buffer_ref(frames.get()); + ASSERT_NE(converter->frame->hw_frames_ctx, nullptr); +#else + ASSERT_EQ(av_hwframe_get_buffer(frames.get(), converter->frame, 0), 0); +#endif + ASSERT_EQ(av_hwframe_transfer_data(converter->frame, host.get(), 0), 0); + ASSERT_EQ(av_frame_copy_props(converter->frame, host.get()), 0); +#ifdef __APPLE__ + // Captured CVPixelBuffers arrive without an FFmpeg frame-pool reference. + av_buffer_unref(&converter->frame->hw_frames_ctx); +#endif + auto session = video::pyrowave::make_session(config, std::move(converter), fake_pyrowave_api(), std::move(frames)); + ASSERT_NE(session, nullptr); + platf::img_t image {}; + for (int frame = 1; frame <= 2; ++frame) { + ASSERT_EQ(session->convert(image), 0); + ASSERT_NE(session->encode(frame), nullptr); + EXPECT_EQ(state.samples, (std::array {1, uint16_t(hdr ? 1023 : 255), 1, uint16_t(hdr ? 1023 : 255)})); + } + pool->sw_format = AV_PIX_FMT_NONE; + EXPECT_EQ(session->convert(image), -1); + EXPECT_EQ(session->encode(3), nullptr); + pool->sw_format = format; + } +} + +TEST_F(PyrowaveSessionTest, ClearsReadinessAfterConversionFailureAndRejectsWrongDepthOrChroma) { + auto converter = std::make_unique(AV_PIX_FMT_NV12); + auto raw = converter.get(); + auto session = video::pyrowave::make_session(pyrowave_config(), std::move(converter), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + platf::img_t image {}; + ASSERT_EQ(session->convert(image), 0); + raw->status = -1; + EXPECT_EQ(session->convert(image), -1); + EXPECT_EQ(session->encode(1), nullptr); + raw->status = 0; + for (auto format : {AV_PIX_FMT_RGB24, AV_PIX_FMT_P010, AV_PIX_FMT_YUV444P}) { + raw->frame->format = format; + EXPECT_EQ(session->convert(image), -1); + EXPECT_EQ(session->encode(1), nullptr); + } + raw->frame->format = AV_PIX_FMT_NV12; + raw->frame->width = 0; + EXPECT_EQ(session->convert(image), -1); + EXPECT_EQ(session->encode(1), nullptr); + raw->frame->width = 64; + const auto restore_allocations = util::fail_guard([]() { + av_max_alloc(std::numeric_limits::max()); + }); + av_max_alloc(0); + EXPECT_EQ(session->convert(image), -1); + EXPECT_EQ(session->encode(1), nullptr); + EXPECT_EQ(state.encoded, 0); +} + +/** + * @brief Reject invalid frames and missing or unretainable VideoToolbox pools before creating a codec. + */ +TEST_F(PyrowaveSessionTest, RejectsInvalidInputsBeforeCallingTheCodec) { + EXPECT_EQ(video::pyrowave::make_session(pyrowave_config(), nullptr, fake_pyrowave_api()), nullptr); + EXPECT_EQ(video::pyrowave::make_session(pyrowave_config(), std::make_unique(), fake_pyrowave_api()), nullptr); + for (int dimension : {32, 96}) { + auto converter = std::make_unique(AV_PIX_FMT_NV12); + converter->frame->width = dimension; + EXPECT_EQ(video::pyrowave::make_session(pyrowave_config(), std::move(converter), fake_pyrowave_api()), nullptr); + } + auto config = pyrowave_config(); + config.chromaSamplingType = 2; + EXPECT_EQ(video::pyrowave::make_session(config, std::make_unique(AV_PIX_FMT_NV12), fake_pyrowave_api()), nullptr); + + auto converter = std::make_unique(AV_PIX_FMT_NV12); + converter->frame->format = AV_PIX_FMT_VIDEOTOOLBOX; + EXPECT_EQ(video::pyrowave::make_session(pyrowave_config(), std::move(converter), fake_pyrowave_api()), nullptr); + + converter = std::make_unique(AV_PIX_FMT_NV12); + converter->frame->format = AV_PIX_FMT_VIDEOTOOLBOX; + video::avcodec_buffer_t frames {av_buffer_alloc(1)}; + ASSERT_NE(frames, nullptr); + const auto restore_allocations = util::fail_guard([]() { + av_max_alloc(std::numeric_limits::max()); + }); + av_max_alloc(0); + EXPECT_EQ(video::pyrowave::make_session(pyrowave_config(), std::move(converter), fake_pyrowave_api(), std::move(frames)), nullptr); + EXPECT_TRUE(state.destroyed.empty()); +} + +TEST_F(PyrowaveSessionTest, CleansUpPartialInitializationAndRejectsAbiMismatch) { + for (int failure = 0; failure < 3; ++failure) { + state = {}; + if (failure == 0) { + state.major = 99; + } else if (failure == 1) { + state.device_result = PYROWAVE_ERROR_NO_VULKAN; + } else { + state.encoder_result = PYROWAVE_ERROR_GENERIC; + } + EXPECT_EQ(video::pyrowave::make_session(pyrowave_config(), std::make_unique(AV_PIX_FMT_NV12), fake_pyrowave_api()), nullptr); + EXPECT_EQ(state.destroyed, failure == 2 ? std::vector {"device"} : std::vector {}); + } +} + +TEST_F(PyrowaveSessionTest, FailsClosedOnEncodeAndPacketizationErrors) { + auto session = video::pyrowave::make_session(pyrowave_config(), std::make_unique(AV_PIX_FMT_NV12), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + platf::img_t image {}; + ASSERT_EQ(session->convert(image), 0); + for (int failure = 0; failure < 15; ++failure) { + state = {}; + switch (failure) { + case 0: + state.encode_result = PYROWAVE_ERROR_GENERIC; + break; + case 1: + state.count_result = PYROWAVE_ERROR_GENERIC; + break; + case 2: + state.count = 0; + break; + case 3: + state.count = 65536; + break; + case 4: + state.count = video::pyrowave::max_frame_size / video::pyrowave::packet_boundary + 1; + break; + case 5: + state.packet_result = PYROWAVE_ERROR_GENERIC; + break; + case 6: + state.output_count = 0; + break; + case 7: + state.output_count = 3; + break; + case 8: + state.packet_size = 0; + break; + case 9: + state.packet_size = video::pyrowave::record_packet_boundary + 4; + break; + case 10: + state.packet_offset = std::numeric_limits::max(); + break; + case 11: + state.packet_offset = video::pyrowave::record_packet_boundary - 4; + break; + case 12: + state.storage_result = PYROWAVE_ERROR_GENERIC; + break; + case 13: + state.storage_count = 0; + break; + case 14: + state.storage_count = video::pyrowave::max_frame_size / video::pyrowave::record_packet_boundary + 1; + break; + } + EXPECT_EQ(session->encode(failure), nullptr) << failure; + } + auto config = pyrowave_config(); + config.pyrowaveRecordFraming = true; + state = {}; + state.boundary = video::pyrowave::record_packet_boundary; + session = video::pyrowave::make_session(config, std::make_unique(AV_PIX_FMT_NV12), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + ASSERT_EQ(session->convert(image), 0); + state.blocks_result = PYROWAVE_ERROR_GENERIC; + EXPECT_EQ(session->encode(42), nullptr); + state.blocks_result = PYROWAVE_SUCCESS; + auto records = session->encode(43); + ASSERT_NE(records, nullptr); + EXPECT_EQ(AV_RL32(records->data()), 0x80000000u | 63 | (47 << 14)); + EXPECT_EQ(records->pyrowave_critical_packets, 1); + EXPECT_EQ(records->pyrowave_record_starts, (std::vector {true, true})); +} + +/** + * @brief Run integration tests only when the real device supports PyroWave's encoder shaders. + */ +class PyrowaveRoundTripTest: public testing::TestWithParam> { +protected: + /** + * @brief Check shader capabilities, which are stricter than Vulkan device creation. + */ + void SetUp() override { + if (pyrowave_create_default_device(&device) != PYROWAVE_SUCCESS) { + GTEST_SKIP() << "A PyroWave-compatible Vulkan device is unavailable"; + } + pyrowave_encoder encoder {}; + const pyrowave_encoder_create_info info {device, 64, 48, PYROWAVE_CHROMA_SUBSAMPLING_420}; + if (pyrowave_encoder_create(&info, &encoder) != PYROWAVE_SUCCESS) { + GTEST_SKIP() << "The Vulkan device does not support PyroWave's encoder shaders"; + } + pyrowave_encoder_destroy(encoder); + } + + /** + * @brief Release the codec device after the test or a skipped capability probe. + */ + void TearDown() override { + if (device) { + pyrowave_device_destroy(device); + } + } + + pyrowave_device device {}; ///< Real Vulkan codec device retained for decoder tests. +}; + +TEST_F(PyrowaveSessionTest, ConvertsCapturedRgbThroughTheSoftwareBackend) { + state.solid_color = std::array {127, 128, 128}; + std::vector pixels(128 * 96 * 4, 127); + platf::img_t image {}; + image.data = pixels.data(); + image.width = 128; + image.height = 96; + image.pixel_pitch = 4; + image.row_pitch = 128 * 4; + for (int chroma : {0, 1}) { + auto config = pyrowave_config(); + config.chromaSamplingType = chroma; + auto converter = std::make_unique(); + converter->colorspace = video::colorspace_from_client_config(config, false); + auto session = video::make_pyrowave_encode_session(video::software, config, 128, 96, std::move(converter), fake_pyrowave_api()); + ASSERT_NE(session, nullptr); + ASSERT_EQ(session->convert(image), 0); + auto pyrowave_session = dynamic_cast(session.get()); + ASSERT_NE(pyrowave_session, nullptr); + EXPECT_NE(pyrowave_session->encode(1), nullptr); + EXPECT_EQ(state.chroma, chroma ? PYROWAVE_CHROMA_SUBSAMPLING_444 : PYROWAVE_CHROMA_SUBSAMPLING_420); + } +} + +#if defined(SUNSHINE_BUILD_VULKAN) || defined(_WIN32) +namespace { + /** + * @brief Upload prepared pixels through a borrowed hardware frame pool, like platform converters. + */ + class pyrowave_test_upload_t: public pyrowave_test_converter_t { + public: + /** + * @brief Prepare SDR or HDR host pixels for upload. + * @param format Input pixel layout. + */ + explicit pyrowave_test_upload_t(AVPixelFormat format): + pyrowave_test_converter_t(format) { + hardware = true; + } + + /** + * @brief Request the same image usage as the Vulkan video converter. + * @param frames Hardware frame configuration. + */ + void init_hwframes(AVHWFramesContext *frames) override { + #ifdef SUNSHINE_BUILD_VULKAN + if (frames->format == AV_PIX_FMT_VULKAN) { + auto vk = static_cast(frames->hwctx); + vk->usage = static_cast(VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_VIDEO_ENCODE_SRC_BIT_KHR); + } + #endif + } + + /** + * @brief Take ownership of the converted frame and borrow its pool. + * @param new_frame Empty encoder input frame. + * @param hw_frames_ctx Retained by the session. + * @return Zero on success. + */ + int set_frame(AVFrame *new_frame, AVBufferRef *hw_frames_ctx) override { + input = std::move(owned); + owned.reset(new_frame); + frame = owned.get(); + frames = hw_frames_ctx; + EXPECT_EQ(frame->color_trc, AVCOL_TRC_SMPTE2084); + #ifdef SUNSHINE_BUILD_VULKAN + const auto pool = reinterpret_cast(frames->data); + if (pool->format == AV_PIX_FMT_VULKAN) { + const auto vk = static_cast(pool->hwctx); + EXPECT_TRUE(vk->usage & VK_IMAGE_USAGE_TRANSFER_SRC_BIT); + EXPECT_FALSE(vk->usage & VK_IMAGE_USAGE_VIDEO_ENCODE_SRC_BIT_KHR); + } + #endif + return 0; + } + + /** + * @brief Allocate lazily from the borrowed pool and upload the prepared image. + * @param img Unused captured image. + * @return FFmpeg transfer status. + */ + int convert(platf::img_t &img) override { + if (!frame->buf[0] && av_hwframe_get_buffer(frames, frame, 0) < 0) { + return -1; + } + return av_hwframe_transfer_data(frame, input.get(), 0); + } + + private: + video::avcodec_frame_t input; ///< Prepared host pixels. + AVBufferRef *frames = nullptr; ///< Borrowed pool used after session creation returns. + }; +} // namespace +#endif + +#ifdef SUNSHINE_BUILD_VULKAN +TEST_F(PyrowaveSessionTest, CreatesSessionsWithoutHardwareVideoEncodeUsage) { + AVBufferRef *raw_device = nullptr; + if (av_hwdevice_ctx_create(&raw_device, AV_HWDEVICE_TYPE_VULKAN, nullptr, nullptr, 0) < 0) { + GTEST_SKIP() << "An FFmpeg-compatible Vulkan device is unavailable"; + } + video::avcodec_buffer_t device {raw_device}; + video::encoder_t encoder { + .platform_formats = std::make_unique( + AV_HWDEVICE_TYPE_VULKAN, + AV_HWDEVICE_TYPE_NONE, + AV_PIX_FMT_VULKAN, + AV_PIX_FMT_NV12, + AV_PIX_FMT_P010, + AV_PIX_FMT_YUV444P, + AV_PIX_FMT_YUV444P16, + [&](platf::avcodec_encode_device_t *) -> util::Either { + return video::avcodec_buffer_t {av_buffer_ref(device.get())}; + } + ) + }; + for (auto format : {AV_PIX_FMT_NV12, AV_PIX_FMT_P010, AV_PIX_FMT_YUV444P, AV_PIX_FMT_YUV444P16}) { + SCOPED_TRACE(av_get_pix_fmt_name(format)); + const auto desc = av_pix_fmt_desc_get(format); + auto config = pyrowave_config(); + config.dynamicRange = desc->comp[0].depth > 8; + config.chromaSamplingType = desc->log2_chroma_w == 0; + for (const std::string_view mode : {"cpu", "gpu", "shared Vulkan device", "device failure", "encoder failure", "frame fallback"}) { + SCOPED_TRACE(mode); + state = {}; + auto api = mode == "cpu" ? fake_pyrowave_api() : fake_gpu_pyrowave_api(); + const bool gpu = mode == "gpu" || mode == "shared Vulkan device"; + if (mode == "shared Vulkan device") { + api.share_device = [](const pyrowave_device_create_info *info, pyrowave_device *device) { + api_state->shared_info = *info; + return pyrowave_create_device(info, device); + }; + api.create_device = [](pyrowave_device *) { + return PYROWAVE_ERROR_GENERIC; + }; + api.destroy_device = [](pyrowave_device device) { + pyrowave_device_destroy(device); + api_state->destroyed.emplace_back("device"); + }; + api.set_queue_type = pyrowave_device_set_queue_type; + } else if (mode == "device failure") { + state.share_result = PYROWAVE_ERROR_NO_VULKAN; + } else if (mode == "encoder failure") { + state.encoder_result = PYROWAVE_ERROR_GENERIC; + api.create_encoder = [](const pyrowave_encoder_create_info *info, pyrowave_encoder *encoder) { + const auto result = fake_pyrowave_api().create_encoder(info, encoder); + *encoder = reinterpret_cast(api_state); + api_state->encoder_result = PYROWAVE_SUCCESS; + return result; + }; + } + auto converter = std::make_unique(format); + const auto upload = converter.get(); + auto session = video::make_pyrowave_encode_session(encoder, config, 64, 48, std::move(converter), api); + ASSERT_NE(session, nullptr); + if (gpu) { + const auto context = reinterpret_cast(device->data); + EXPECT_EQ(state.shared_info.device, static_cast(context->hwctx)->act_dev); + } + platf::img_t image {}; + if (mode == "frame fallback") { + ASSERT_EQ(upload->convert(image), 0); + auto pool = reinterpret_cast(upload->frame->hw_frames_ctx->data); + auto vk_frames = static_cast(pool->hwctx); + vk_frames->usage = VkImageUsageFlagBits(vk_frames->usage & ~VK_IMAGE_USAGE_SAMPLED_BIT); + } + ASSERT_EQ(session->convert(image), 0); + auto pyrowave_session = dynamic_cast(session.get()); + ASSERT_NE(pyrowave_session, nullptr); + auto pixels = reinterpret_cast(upload->frame->data[0]); + const auto initial_timeline = pixels->sem_value[0]; + for (int i = 1; i <= 2; ++i) { + EXPECT_NE(pyrowave_session->encode(i), nullptr); + if (gpu) { + EXPECT_EQ(state.encoded, 0); + EXPECT_EQ(state.gpu_encoded, i); + EXPECT_EQ(state.command_buffer, VK_NULL_HANDLE); + EXPECT_EQ(state.gpu_input.planes[0].image, pixels->img[0]); + EXPECT_EQ(pixels->sem_value[0], initial_timeline + i); + for (int plane = 0; plane < AV_NUM_DATA_POINTERS && pixels->img[plane]; ++plane) { + EXPECT_EQ(pixels->layout[plane], VK_IMAGE_LAYOUT_GENERAL); + uint64_t completed = 0; + EXPECT_EQ(vkGetSemaphoreCounterValue(state.shared_info.device, pixels->sem[plane], &completed), VK_SUCCESS); + EXPECT_EQ(completed, pixels->sem_value[plane]); + } + } else { + EXPECT_EQ(state.encoded, i); + EXPECT_EQ(state.gpu_encoded, 0); + } + } + EXPECT_EQ(state.chroma, config.chromaSamplingType ? PYROWAVE_CHROMA_SUBSAMPLING_444 : PYROWAVE_CHROMA_SUBSAMPLING_420); + if (gpu) { + const auto timeline = pixels->sem_value[0]; + const auto semaphore = pixels->sem[0]; + const auto calls = state.gpu_encoded; + pixels->sem[0] = VK_NULL_HANDLE; + EXPECT_EQ(pyrowave_session->encode(3), nullptr); + EXPECT_EQ(state.gpu_encoded, calls); + pixels->sem[0] = semaphore; + state.encode_result = PYROWAVE_ERROR_GENERIC; + EXPECT_EQ(pyrowave_session->encode(3), nullptr); + EXPECT_EQ(state.command_buffer, VK_NULL_HANDLE); + EXPECT_EQ(pixels->sem_value[0], timeline); + state.encode_result = PYROWAVE_SUCCESS; + ASSERT_EQ(session->convert(image), 0); + EXPECT_NE(pyrowave_session->encode(4), nullptr); + EXPECT_GT(pixels->sem_value[0], timeline); + } + session.reset(); + ASSERT_GE(state.destroyed.size(), 2); + EXPECT_EQ(state.destroyed[state.destroyed.size() - 2], "encoder"); + EXPECT_EQ(state.destroyed.back(), "device"); + } + } +} +#endif + +/** + * @brief Exercise real encoding, imported image views, and decoding when a suitable device is present. + */ +TEST_P(PyrowaveRoundTripTest, DecodesSdrAndHdrFrameContainersWithTheUpstreamLibrary) { + bool decode_pixels = true; + video::avcodec_buffer_t hardware_device; + const auto [type, hardware_format] = GetParam(); + if (type != AV_HWDEVICE_TYPE_NONE) { + AVBufferRef *raw_device = nullptr; + if (av_hwdevice_ctx_create(&raw_device, type, nullptr, nullptr, 0) < 0) { + GTEST_SKIP() << "A hardware frame-sharing device is unavailable"; + } + hardware_device.reset(raw_device); + } +#ifdef SUNSHINE_BUILD_VULKAN + VkPhysicalDevice physical_device; + pyrowave_device_get_vk_device_handles(device, nullptr, &physical_device, nullptr); + VkPhysicalDeviceProperties properties {}; + vkGetPhysicalDeviceProperties(physical_device, &properties); + // Lavapipe's decoder shader crash also reproduces in PyroWave's own tests. + // Still validate native frame containers on software Vulkan devices. + decode_pixels = properties.deviceType != VK_PHYSICAL_DEVICE_TYPE_CPU; +#endif + for (int profile = 0; profile < 8; ++profile) { + const bool hdr = (profile % 4) / 2; + const bool yuv444 = profile % 2; + if (type == AV_HWDEVICE_TYPE_VAAPI && yuv444) { + continue; // The VAAPI capture converter only exposes 4:2:0. + } + pyrowave_decoder decoder {}; + const pyrowave_decoder_create_info info {device, 64, 48, yuv444 ? PYROWAVE_CHROMA_SUBSAMPLING_444 : PYROWAVE_CHROMA_SUBSAMPLING_420, false}; + ASSERT_EQ(pyrowave_decoder_create(&info, &decoder), PYROWAVE_SUCCESS); + const auto destroy_decoder = util::fail_guard([&]() { + pyrowave_decoder_destroy(decoder); + }); + auto config = pyrowave_config(); + config.pyrowaveRecordFraming = profile >= 4; + config.dynamicRange = hdr; + config.chromaSamplingType = yuv444; + auto format = yuv444 ? (hdr ? AV_PIX_FMT_YUV444P10 : AV_PIX_FMT_YUV444P) : (hdr ? AV_PIX_FMT_P010 : AV_PIX_FMT_NV12); + std::unique_ptr session; + static thread_local int gpu_calls; ///< GPU submissions observed by the real codec wrapper. + gpu_calls = 0; +#ifdef _WIN32 + static thread_local int rejected_views; ///< Imported views rejected to verify bridge initialization failure. + rejected_views = 0; +#endif +#if defined(SUNSHINE_BUILD_VULKAN) || defined(_WIN32) + if (hardware_device) { + if (hardware_format == AV_PIX_FMT_D3D11 && yuv444) { + format = hdr ? AV_PIX_FMT_XV30 : AV_PIX_FMT_VUYX; + } else if (hardware_format == AV_PIX_FMT_CUDA && yuv444 && hdr) { + format = AV_PIX_FMT_YUV444P16; + } + video::avcodec_buffer_t frames {av_hwframe_ctx_alloc(hardware_device.get())}; + ASSERT_NE(frames, nullptr); + auto pool = reinterpret_cast(frames->data); + pool->format = hardware_format; + pool->sw_format = format; + pool->width = 64; + pool->height = 48; + if (av_hwframe_ctx_init(frames.get()) < 0) { + GTEST_SKIP() << "The hardware frame pool does not support " << av_get_pix_fmt_name(format); + } + auto converter = std::make_unique(format); + auto frame = av_frame_alloc(); + ASSERT_NE(frame, nullptr); + ASSERT_GE(av_frame_copy_props(frame, converter->frame), 0); + frame->format = hardware_format; + frame->width = 64; + frame->height = 48; + ASSERT_EQ(converter->set_frame(frame, frames.get()), 0); + video::pyrowave::api_t api; + api.encode_gpu = [](pyrowave_encoder encoder, const pyrowave_gpu_sync_operation *acquire, const pyrowave_gpu_sync_operation *release, const pyrowave_gpu_buffers *buffers, const pyrowave_rate_control *rate) { + ++gpu_calls; + return pyrowave_encoder_encode_gpu(encoder, acquire, release, buffers, rate); + }; + #ifdef _WIN32 + if (hardware_format == AV_PIX_FMT_D3D11) { + auto unavailable_views = api; + unavailable_views.image_view = [](pyrowave_image, VkImageAspectFlagBits, VkImageUsageFlagBits, pyrowave_image_view *) { + ++rejected_views; + return PYROWAVE_ERROR_INVALID_ARGUMENT; + }; + EXPECT_EQ(video::pyrowave::make_d3d11_input(pool, unavailable_views), nullptr); + } + #endif + session = video::pyrowave::make_session(config, std::move(converter), api, std::move(frames)); + } else +#endif + { + session = video::pyrowave::make_session(config, std::make_unique(format)); + } + ASSERT_NE(session, nullptr); + platf::img_t image {}; + ASSERT_EQ(session->convert(image), 0); + auto packet = session->encode(1); + ASSERT_NE(packet, nullptr); + if (hardware_device && !gpu_calls) { + GTEST_SKIP() << "The device cannot share this frame layout with PyroWave: " << av_get_pix_fmt_name(format); + } +#ifdef _WIN32 + if (hardware_format == AV_PIX_FMT_D3D11) { + EXPECT_EQ(rejected_views, 1); + } +#endif + if (hardware_device) { + EXPECT_NE(session->encode(2), nullptr); + ASSERT_EQ(session->convert(image), 0); + EXPECT_NE(session->encode(3), nullptr); + EXPECT_EQ(gpu_calls, 3); + } + const auto data = packet->data(); + ASSERT_GE(packet->data_size(), 4); + const auto count = config.pyrowaveRecordFraming ? std::numeric_limits::max() : AV_RL32(data); + size_t offset = config.pyrowaveRecordFraming ? 0 : 4; + for (uint32_t i = 0; i < count && offset < packet->data_size(); ++i) { + ASSERT_LE(offset + 4, packet->data_size()); + const auto word = AV_RL32(data + offset); + if (config.pyrowaveRecordFraming && word == 0xffffffffu) { + ASSERT_LE(offset + 8, packet->data_size()); + offset += 8 + size_t(AV_RL32(data + offset + 4)) * 4; + continue; + } + const auto size = config.pyrowaveRecordFraming ? (word & 0x80000000u ? 8 : ((word >> 16) & 0xfff) * 4) : word; + if (!config.pyrowaveRecordFraming) { + offset += 4; + } + ASSERT_LE(size, packet->data_size() - offset); + ASSERT_EQ(pyrowave_decoder_push_packet(decoder, data + offset, size), PYROWAVE_SUCCESS); + offset += size; + } + EXPECT_EQ(offset, packet->data_size()); + ASSERT_TRUE(pyrowave_decoder_decode_is_ready(decoder, false)); + if (!decode_pixels) { + continue; + } + std::array, 3> output; + pyrowave_cpu_buffer decoded {}; + decoded.width = 64; + decoded.height = 48; + decoded.format = yuv444 ? PYROWAVE_CPU_BUFFER_FORMAT_YUV444P10 : PYROWAVE_CPU_BUFFER_FORMAT_YUV420P10; + for (int plane = 0; plane < 3; ++plane) { + output[plane].resize(plane && !yuv444 ? 32 * 24 : 64 * 48); + decoded.data[plane] = output[plane].data(); + decoded.row_stride_in_bytes[plane] = (plane && !yuv444 ? 32 : 64) * sizeof(uint16_t); + decoded.plane_size_in_bytes[plane] = output[plane].size() * sizeof(uint16_t); + } + ASSERT_EQ(pyrowave_decoder_decode_cpu_buffer_synchronous(decoder, &decoded), PYROWAVE_SUCCESS); + EXPECT_NEAR(output[0][0], hdr ? 1 : 4, 8); + EXPECT_NEAR(output[0][1], 1023, 8); + EXPECT_NEAR(output[1][0], hdr ? 1 : 4, 8); + EXPECT_NEAR(output[2][0], 1023, 8); + EXPECT_NEAR(output[1][1], 1023, 8); + EXPECT_NEAR(output[2][1], hdr ? 1 : 4, 8); + } + if (!decode_pixels) { + GTEST_SKIP() << "SDR/HDR packet validation passed; pixel decoding requires a hardware Vulkan device"; + } +} + +/** + * @brief Exercise CPU input and each hardware sharing API supported by this build. + */ +constexpr std::pair pyrowave_round_trip_inputs[] { + {AV_HWDEVICE_TYPE_NONE, AV_PIX_FMT_NONE}, +#ifdef _WIN32 + {AV_HWDEVICE_TYPE_D3D11VA, AV_PIX_FMT_D3D11}, +#endif +#ifdef SUNSHINE_BUILD_VULKAN + {AV_HWDEVICE_TYPE_VULKAN, AV_PIX_FMT_VULKAN}, + {AV_HWDEVICE_TYPE_CUDA, AV_PIX_FMT_CUDA}, + {AV_HWDEVICE_TYPE_VAAPI, AV_PIX_FMT_VAAPI}, +#endif +}; + +INSTANTIATE_TEST_SUITE_P(CpuAndGpu, PyrowaveRoundTripTest, testing::ValuesIn(pyrowave_round_trip_inputs)); diff --git a/tests/web/Configuration.test.js b/tests/web/Configuration.test.js index 04f7a2aa127..0db358f3384 100644 --- a/tests/web/Configuration.test.js +++ b/tests/web/Configuration.test.js @@ -6,6 +6,8 @@ import Advanced from '../../src_assets/common/assets/web/configs/tabs/Advanced.v import General from '../../src_assets/common/assets/web/configs/tabs/General.vue' import Inputs from '../../src_assets/common/assets/web/configs/tabs/Inputs.vue' import Network from '../../src_assets/common/assets/web/configs/tabs/Network.vue' +import Checkbox from '../../src_assets/common/assets/web/Checkbox.vue' +import configTabs from '../../src_assets/common/assets/web/configs/config_tabs.json' const globalOptions = { mocks: { @@ -26,6 +28,25 @@ const globalOptions = { }, } +describe('PyroWave configuration', () => { + it.each(['windows', 'linux', 'macos', 'freebsd'])('enables capability detection by default and supports disabling it on %s', async platform => { + const config = { ...configTabs.find(tab => tab.id === 'advanced').options } + const wrapper = mount(Advanced, { + props: { platform, config }, + global: { ...globalOptions, stubs: { ...globalOptions.stubs, Checkbox: false } }, + }) + const checkbox = wrapper.findAllComponents(Checkbox).find(component => component.props('id') === 'pyrowave') + const input = checkbox.get('input[type="checkbox"]') + expect(input.element.checked).toBe(true) + expect(checkbox.text()).toContain('_common.enabled_def_cbox') + expect(config.pyrowave).toBe('enabled') + await input.setValue(false) + expect(config.pyrowave).toBe('disabled') + await input.setValue(true) + expect(config.pyrowave).toBe('enabled') + }) +}) + describe('configuration accessibility', () => { it('gives every global prep command input an accessible label', () => { const wrapper = mount(General, { @@ -135,7 +156,7 @@ describe('network configuration', () => { describe('FEC configuration', () => { it.each([0, 1, 20, 99, 100])('accepts %i percent and updates the configuration', async percentage => { - const config = { fec_percentage: 20 } + const config = { ...configTabs.find(tab => tab.id === 'advanced').options, fec_percentage: 20 } const wrapper = mount(Advanced, { props: { platform: 'macos', config }, global: globalOptions, @@ -149,8 +170,9 @@ describe('FEC configuration', () => { }) it.each([-1, 101, 255, 0.5])('marks %s percent as invalid', async percentage => { + const config = { ...configTabs.find(tab => tab.id === 'advanced').options, fec_percentage: 20 } const wrapper = mount(Advanced, { - props: { platform: 'macos', config: { fec_percentage: 20 } }, + props: { platform: 'macos', config }, global: globalOptions, }) const input = wrapper.get('#fec_percentage') diff --git a/third-party/pyrowave b/third-party/pyrowave new file mode 160000 index 00000000000..a0d2e71e98b --- /dev/null +++ b/third-party/pyrowave @@ -0,0 +1 @@ +Subproject commit a0d2e71e98b85f6a23f62407df21a39e9745b875