ref:6ad5f9735d36f2fa1d5b167298751479551f5986

fix(linux/pipewire): restore dummy buffer for software encoders and fix buffer ownership (#5495)

Co-authored-by: luanweslley77 <luanweslley77@users.noreply.github.com>
SHA: 6ad5f9735d36f2fa1d5b167298751479551f5986
Author: luanweslley77 <luanweslley77@gmail.com>
Date: 2026-08-18 15:59
Parents: c8763b6
4 files changed +322 -162
Type
src/platform/linux/pipewire.cpp +28 −4
@@ -128,11 +128,17 @@
*/
struct img_descriptor_t: public egl::img_descriptor_t {
~img_descriptor_t() override {
if (data) {
// Only free buffers this image actually owns. The memory-buffer capture
// path points img->data at the PipeWire staging vector (front_buffer),
// which is owned by pipewire_t -- deleting it here corrupts the heap.
if (data && data_owned) {
delete[] data;
data = nullptr;
}
data = nullptr;
data_owned = false;
}
bool data_owned = false; ///< Whether img->data is owned by this image and must be freed.
};
/**
@@ -430,12 +436,16 @@
struct spa_buffer *buf = stream_data.current_buffer->buffer;
if (buf->datas[0].chunk->size != 0) {
auto *img_descriptor = static_cast<egl::img_descriptor_t *>(img);
auto *img_descriptor = static_cast<img_descriptor_t *>(img);
fill_img_metadata(img_descriptor, buf);
if (buf->datas[0].type == SPA_DATA_DmaBuf) {
fill_img_dmabuf(img_descriptor, buf, stream_data);
} else {
img->data = stream_data.front_buffer->data();
img_descriptor->data_owned = false;
img->row_pitch = stream_data.local_stride;
// NV12 is the only 1-byte-per-pixel format delivered on the memory
// path; every other negotiated format is packed 4 bytes per pixel.
img->pixel_pitch = (stream_data.format.info.raw.format == SPA_VIDEO_FORMAT_NV12) ? 1 : 4;
}
}
@@ -937,6 +947,7 @@
img->sequence = 0;
img->serial = std::numeric_limits<decltype(img->serial)>::max();
img->data = nullptr;
img->data_owned = false;
std::fill_n(img->sd.fds, 4, -1);
return img;
@@ -1061,6 +1072,19 @@
* @return Capture status reported to the streaming pipeline.
*/
int dummy_img(platf::img_t *img) override {
// Empty images are recognized as dummies by the zero sequence number
// Software encoders convert the dummy image immediately; provide a valid
// (black) buffer instead of leaving img->data null, which makes sws fail
// with EINVAL. The buffer is new[]-allocated and marked as owned so the
// destructor releases it.
if (img->data == nullptr) {
const auto w = img->width;
const auto h = img->height;
if (w > 0 && h > 0) {
img->data = new uint8_t[static_cast<size_t>(w) * h * 4](); // NOSONAR(cpp:S5025) - buffer is owned by the image and freed by img_descriptor_t's destructor
static_cast<img_descriptor_t *>(img)->data_owned = true;
img->row_pitch = w * 4;
img->pixel_pitch = 4;
}
}
return 0;
}
src/video.cpp +150 −158
@@ -3,6 +3,7 @@
* @brief Definitions for video.
*/
// standard includes
#include <algorithm>
#include <array>
#include <atomic>
#include <bitset>
@@ -194,198 +195,189 @@
*/
util::Either<avcodec_buffer_t, int> vulkan_init_avcodec_hardware_input_buffer(platf::avcodec_encode_device_t *);
/**
* @brief FFmpeg software encode device used when no hardware frames are required.
*/
class avcodec_software_encode_device_t: public platf::avcodec_encode_device_t {
public:
/**
* @brief Accept a software frame without additional hardware conversion.
*
* @param img Image or frame object to read from or populate.
* @return Conversion status.
*/
int convert(platf::img_t &img) override {
// If we need to add aspect ratio padding, we need to scale into an intermediate output buffer
bool requires_padding = (sw_frame->width != sws_output_frame->width || sw_frame->height != sws_output_frame->height);
int avcodec_software_encode_device_t::convert(platf::img_t &img) {
// If we need to add aspect ratio padding, we need to scale into an intermediate output buffer
bool requires_padding = (sw_frame->width != sws_output_frame->width || sw_frame->height != sws_output_frame->height);
// Detect the actual capture pixel format. PipeWire-based captures (KWin
// screencast / XDG portal) deliver NV12 with 1 byte per pixel, while
// Setup the input frame using the caller's img_t
sws_input_frame->data[0] = img.data;
sws_input_frame->linesize[0] = img.row_pitch;
// KMS/DMABUF captures deliver BGR0 (4 bytes per pixel). The capture
// backend reports bytes per pixel in img.pixel_pitch; fall back to the row
// pitch heuristic when it is unavailable.
const auto pixel_pitch = img.pixel_pitch > 0 ? img.pixel_pitch : (img.row_pitch / std::max(img.width, 1));
const auto input_fmt = (pixel_pitch == 1) ? AV_PIX_FMT_NV12 : AV_PIX_FMT_BGR0;
// The sws context is created with the default BGR0 source format;
// recreate it once if the capture is actually NV12.
// Perform color conversion and scaling to the final size
auto status = sws_scale_frame(sws.get(), requires_padding ? sws_output_frame.get() : sw_frame.get(), sws_input_frame.get());
if (input_fmt != sws_src_format) {
sws_src_format = input_fmt;
if (reinit_sws(input_fmt) < 0) {
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Couldn't scale frame: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
}
// The colorspace details were applied to the previous sws context.
apply_colorspace();
}
// Setup the input frame using the caller's img_t
sws_input_frame->data[0] = img.data;
sws_input_frame->linesize[0] = img.row_pitch;
if (input_fmt == AV_PIX_FMT_NV12) {
sws_input_frame->data[1] = img.data + static_cast<std::size_t>(img.row_pitch) * img.height;
sws_input_frame->linesize[1] = img.row_pitch;
} else {
sws_input_frame->data[1] = nullptr;
// If we require aspect ratio padding, copy the output frame into the final padded frame
if (requires_padding) {
auto fmt_desc = av_pix_fmt_desc_get(static_cast<AVPixelFormat>(sws_output_frame->format));
auto planes = av_pix_fmt_count_planes(static_cast<AVPixelFormat>(sws_output_frame->format));
for (int plane = 0; plane < planes; plane++) {
auto shift_h = plane == 0 ? 0 : fmt_desc->log2_chroma_h;
auto shift_w = plane == 0 ? 0 : fmt_desc->log2_chroma_w;
auto offset = ((offsetW >> shift_w) * fmt_desc->comp[plane].step) + (offsetH >> shift_h) * sw_frame->linesize[plane];
sws_input_frame->linesize[1] = 0;
}
sws_input_frame->data[2] = nullptr;
sws_input_frame->linesize[2] = 0;
sws_input_frame->data[3] = nullptr;
sws_input_frame->linesize[3] = 0;
// Perform color conversion and scaling to the final size
auto status = sws_scale_frame(sws.get(), requires_padding ? sws_output_frame.get() : sw_frame.get(), sws_input_frame.get());
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Couldn't scale frame: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
// Copy line-by-line to preserve leading padding for each row
for (int line = 0; line < sws_output_frame->height >> shift_h; line++) {
memcpy(sw_frame->data[plane] + offset + (line * sw_frame->linesize[plane]), sws_output_frame->data[plane] + (line * sws_output_frame->linesize[plane]), static_cast<std::size_t>(sws_output_frame->width >> shift_w) * fmt_desc->comp[plane].step);
return -1;
}
}
}
}
// If frame is not a software frame, it means we still need to transfer from main memory
// to vram memory
if (frame->hw_frames_ctx) {
auto status = av_hwframe_transfer_data(frame, sw_frame.get(), 0);
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Failed to transfer image data to hardware frame: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
// If we require aspect ratio padding, copy the output frame into the final padded frame
if (requires_padding) {
auto fmt_desc = av_pix_fmt_desc_get(static_cast<AVPixelFormat>(sws_output_frame->format));
auto planes = av_pix_fmt_count_planes(static_cast<AVPixelFormat>(sws_output_frame->format));
for (int plane = 0; plane < planes; plane++) {
auto shift_h = plane == 0 ? 0 : fmt_desc->log2_chroma_h;
auto shift_w = plane == 0 ? 0 : fmt_desc->log2_chroma_w;
auto offset = ((offsetW >> shift_w) * fmt_desc->comp[plane].step) + (offsetH >> shift_h) * sw_frame->linesize[plane];
// Copy line-by-line to preserve leading padding for each row
for (int line = 0; line < sws_output_frame->height >> shift_h; line++) {
memcpy(sw_frame->data[plane] + offset + (line * sw_frame->linesize[plane]), sws_output_frame->data[plane] + (line * sws_output_frame->linesize[plane]), static_cast<std::size_t>(sws_output_frame->width >> shift_w) * fmt_desc->comp[plane].step);
}
}
}
return 0;
// If frame is not a software frame, it means we still need to transfer from main memory
// to vram memory
if (frame->hw_frames_ctx) {
auto status = av_hwframe_transfer_data(frame, sw_frame.get(), 0);
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Failed to transfer image data to hardware frame: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
}
}
/**
* @brief Attach frame resources used by the next conversion or encode operation.
*
* @param frame Video or graphics frame being processed.
return 0;
}
int avcodec_software_encode_device_t::set_frame(AVFrame *in_frame, AVBufferRef *hw_frames_ctx) {
this->frame = in_frame;
* @param hw_frames_ctx FFmpeg hardware frames context associated with the frame.
* @return Status from updating frame.
*/
int set_frame(AVFrame *frame, AVBufferRef *hw_frames_ctx) override {
this->frame = frame;
// If it's a hwframe, allocate buffers for hardware
if (hw_frames_ctx) {
// If it's a hwframe, allocate buffers for hardware
if (hw_frames_ctx) {
hw_frame.reset(in_frame);
hw_frame.reset(frame);
if (av_hwframe_get_buffer(hw_frames_ctx, in_frame, 0)) {
return -1;
if (av_hwframe_get_buffer(hw_frames_ctx, frame, 0)) {
return -1;
}
} else {
sw_frame.reset(frame);
}
return 0;
} else {
sw_frame.reset(in_frame);
}
/**
* @brief Apply the configured colorspace metadata to the active frame.
*/
void apply_colorspace() override {
auto avcodec_colorspace = avcodec_colorspace_from_sunshine_colorspace(colorspace);
return 0;
}
sws_setColorspaceDetails(sws.get(), sws_getCoefficients(SWS_CS_DEFAULT), 0, sws_getCoefficients(avcodec_colorspace.software_format), avcodec_colorspace.range - 1, 0, 1 << 16, 1 << 16);
}
/**
* When preserving aspect ratio, ensure that padding is black
*/
void prefill() {
auto frame = sw_frame ? sw_frame.get() : this->frame;
av_frame_get_buffer(frame, 0);
av_frame_make_writable(frame);
ptrdiff_t linesize[4] = {frame->linesize[0], frame->linesize[1], frame->linesize[2], frame->linesize[3]};
av_image_fill_black(frame->data, linesize, static_cast<AVPixelFormat>(frame->format), frame->color_range, frame->width, frame->height);
}
void avcodec_software_encode_device_t::apply_colorspace() {
auto avcodec_colorspace = avcodec_colorspace_from_sunshine_colorspace(colorspace);
sws_setColorspaceDetails(sws.get(), sws_getCoefficients(SWS_CS_DEFAULT), 0, sws_getCoefficients(avcodec_colorspace.software_format), avcodec_colorspace.range - 1, 0, 1 << 16, 1 << 16);
}
/**
* @brief Initialize FFmpeg software encoding for the requested codec.
*
* @param in_width In width.
* @param in_height In height.
* @param frame Video or graphics frame being processed.
void avcodec_software_encode_device_t::prefill() {
auto active_frame = sw_frame ? sw_frame.get() : this->frame;
av_frame_get_buffer(active_frame, 0);
av_frame_make_writable(active_frame);
std::array<ptrdiff_t, 4> linesize = {active_frame->linesize[0], active_frame->linesize[1], active_frame->linesize[2], active_frame->linesize[3]};
av_image_fill_black(active_frame->data, linesize.data(), static_cast<AVPixelFormat>(active_frame->format), active_frame->color_range, active_frame->width, active_frame->height);
}
* @param format Pixel, audio, or protocol format being converted.
* @param hardware Whether the frame is backed by hardware resources.
* @return 0 on success; nonzero or negative platform status on failure.
*/
int init(int in_width, int in_height, AVFrame *frame, AVPixelFormat format, bool hardware) {
// If the device used is hardware, yet the image resides on main memory
if (hardware) {
sw_frame.reset(av_frame_alloc());
int avcodec_software_encode_device_t::init(int in_width, int in_height, AVFrame *in_frame, AVPixelFormat format, bool hardware) {
// If the device used is hardware, yet the image resides on main memory
sw_frame->width = frame->width;
sw_frame->height = frame->height;
sw_frame->format = format;
if (hardware) {
sw_frame.reset(av_frame_alloc());
} else {
this->frame = frame;
}
sw_frame->width = in_frame->width;
// Fill aspect ratio padding in the destination frame
prefill();
sw_frame->height = in_frame->height;
sw_frame->format = format;
} else {
this->frame = in_frame;
}
// Fill aspect ratio padding in the destination frame
auto out_width = frame->width;
prefill();
auto out_height = frame->height;
auto out_width = in_frame->width;
auto out_height = in_frame->height;
// Ensure aspect ratio is maintained
auto scalar = std::fminf(static_cast<float>(out_width) / in_width, static_cast<float>(out_height) / in_height);
out_width = in_width * scalar;
out_height = in_height * scalar;
sws_input_frame.reset(av_frame_alloc());
sws_input_frame->width = in_width;
sws_input_frame->height = in_height;
sws_input_frame->format = AV_PIX_FMT_BGR0;
// Ensure aspect ratio is maintained
auto scalar = std::fminf(static_cast<float>(out_width) / in_width, static_cast<float>(out_height) / in_height);
out_width = in_width * scalar;
out_height = in_height * scalar;
sws_input_frame.reset(av_frame_alloc());
sws_input_frame->width = in_width;
sws_input_frame->height = in_height;
sws_input_frame->format = AV_PIX_FMT_BGR0;
sws_output_frame.reset(av_frame_alloc());
sws_output_frame->width = out_width;
sws_output_frame->height = out_height;
sws_output_frame->format = format;
sws_output_frame.reset(av_frame_alloc());
// Result is always positive
offsetW = (frame->width - out_width) / 2;
offsetH = (frame->height - out_height) / 2;
sws_output_frame->width = out_width;
sws_output_frame->height = out_height;
sws_output_frame->format = format;
sws.reset(sws_alloc_context());
if (!sws) {
return -1;
// Result is always positive
offsetW = (in_frame->width - out_width) / 2;
offsetH = (in_frame->height - out_height) / 2;
}
AVDictionary *options {nullptr};
av_dict_set_int(&options, "srcw", sws_input_frame->width, 0);
av_dict_set_int(&options, "srch", sws_input_frame->height, 0);
av_dict_set_int(&options, "src_format", sws_input_frame->format, 0);
av_dict_set_int(&options, "dstw", sws_output_frame->width, 0);
av_dict_set_int(&options, "dsth", sws_output_frame->height, 0);
av_dict_set_int(&options, "dst_format", sws_output_frame->format, 0);
av_dict_set_int(&options, "sws_flags", SWS_LANCZOS | SWS_ACCURATE_RND, 0);
sws_src_format = AV_PIX_FMT_BGR0;
av_dict_set_int(&options, "threads", config::video.min_threads, 0);
return reinit_sws(sws_src_format);
auto status = av_opt_set_dict(sws.get(), &options);
av_dict_free(&options);
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Failed to set SWS options: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
}
}
int avcodec_software_encode_device_t::reinit_sws(AVPixelFormat src_format) {
sws_input_frame->format = src_format;
status = sws_init_context(sws.get(), nullptr, nullptr);
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Failed to initialize SWS: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
}
sws.reset(sws_alloc_context());
if (!sws) {
return -1;
return 0;
}
AVDictionary *options {nullptr};
av_dict_set_int(&options, "srcw", sws_input_frame->width, 0);
av_dict_set_int(&options, "srch", sws_input_frame->height, 0);
av_dict_set_int(&options, "src_format", src_format, 0);
av_dict_set_int(&options, "dstw", sws_output_frame->width, 0);
av_dict_set_int(&options, "dsth", sws_output_frame->height, 0);
av_dict_set_int(&options, "dst_format", sws_output_frame->format, 0);
av_dict_set_int(&options, "sws_flags", SWS_LANCZOS | SWS_ACCURATE_RND, 0);
av_dict_set_int(&options, "threads", config::video.min_threads, 0);
// Store ownership when frame is hw_frame
avcodec_frame_t hw_frame; ///< Hw frame.
auto status = av_opt_set_dict(sws.get(), &options);
av_dict_free(&options);
if (status < 0) {
avcodec_frame_t sw_frame; ///< Sw frame.
avcodec_frame_t sws_input_frame; ///< Sws input frame.
avcodec_frame_t sws_output_frame; ///< Sws output frame.
sws_t sws; ///< Software scaler used when frames need CPU-side pixel conversion.
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Failed to set SWS options: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
}
status = sws_init_context(sws.get(), nullptr, nullptr);
if (status < 0) {
char string[AV_ERROR_MAX_STRING_SIZE];
BOOST_LOG(error) << "Failed to initialize SWS: "sv << av_make_error_string(string, AV_ERROR_MAX_STRING_SIZE, status);
return -1;
}
// Offset of input image to output frame in pixels
int offsetW; ///< Offset w.
return 0;
}
int offsetH; ///< Offset h.
};
/**
* @brief Enumerates supported flag options.
@@ -1399,7 +1391,7 @@
};
#endif
static const std::vector<encoder_t *> encoders {
static const std::vector encoders {
#ifndef __APPLE__
&nvenc,
#endif
src/video.h +68 −0
@@ -120,6 +120,74 @@
using img_event_t = std::shared_ptr<safe::event_t<std::shared_ptr<platf::img_t>>>;
/**
* @brief FFmpeg software encode device used when no hardware frames are required.
*/
class avcodec_software_encode_device_t: public platf::avcodec_encode_device_t {
public:
/**
* @brief Convert a captured image into the encoder input representation.
*
* @param img Image or frame object to read from or populate.
* @return Conversion status.
*/
int convert(platf::img_t &img) override;
/**
* @brief Attach frame resources used by the next conversion or encode operation.
* @note Takes ownership of 'in_frame'.
*
* @param in_frame Video or graphics frame being processed.
* @param hw_frames_ctx FFmpeg hardware frames context associated with the frame.
* @return Status from updating frame.
*/
int set_frame(AVFrame *in_frame, AVBufferRef *hw_frames_ctx) override;
/**
* @brief Apply the configured colorspace metadata to the active frame.
*/
void apply_colorspace() override;
/**
* @brief Initialize FFmpeg software encoding for the requested codec.
*
* @param in_width In width.
* @param in_height In height.
* @param in_frame Video or graphics frame being processed.
* @param format Pixel, audio, or protocol format being converted.
* @param hardware Whether the frame is backed by hardware resources.
* @return 0 on success; nonzero or negative platform status on failure.
*/
int init(int in_width, int in_height, AVFrame *in_frame, AVPixelFormat format, bool hardware);
private:
/**
* @brief When preserving aspect ratio, ensure that padding is black.
*/
void prefill();
/**
* @brief (Re)create the software scaler for the given source format.
*
* @param src_format Pixel format of the captured frames.
* @return 0 on success; nonzero on failure.
*/
int reinit_sws(AVPixelFormat src_format);
// Store ownership when frame is hw_frame
avcodec_frame_t hw_frame; ///< Hw frame.
avcodec_frame_t sw_frame; ///< Sw frame.
avcodec_frame_t sws_input_frame; ///< Sws input frame.
avcodec_frame_t sws_output_frame; ///< Sws output frame.
sws_t sws; ///< Software scaler used when frames need CPU-side pixel conversion.
AVPixelFormat sws_src_format {AV_PIX_FMT_BGR0}; ///< Source format the sws context was created with.
// Offset of input image to output frame in pixels
int offsetW; ///< Offset w.
int offsetH; ///< Offset h.
};
/**
* @brief Pixel formats supported by one encoder backend.
*/
struct encoder_platform_formats_t {
tests/unit/test_video.cpp +76 −0
@@ -9,7 +9,14 @@
#include <algorithm>
#include <tuple>
#include <utility>
#include <vector>
// ffmpeg includes
extern "C" {
#include <libavutil/frame.h>
#include <libavutil/pixfmt.h>
}
// local includes
#include <src/config.h>
#include <src/video.h>
@@ -167,3 +174,72 @@
std::make_tuple(120, 11988, std::chrono::nanoseconds {8341666}) // 1e9 * 1001 / 120000
)
);
/**
* @brief Software encoder converts BGR0 and NV12 frames, including padded strides and
* backends that don't report the pixel pitch.
*/
TEST(SoftwareEncoderConversion, Bgr0AndNv12) {
constexpr int w = 320;
constexpr int h = 240;
AVFrame *frame = av_frame_alloc();
ASSERT_NE(frame, nullptr);
frame->width = w;
frame->height = h;
frame->format = AV_PIX_FMT_YUV420P;
video::avcodec_software_encode_device_t device;
ASSERT_EQ(device.init(w, h, frame, AV_PIX_FMT_YUV420P, false), 0);
// set_frame() takes ownership of the frame; the device frees it on destruction.
ASSERT_EQ(device.set_frame(frame, nullptr), 0);
// BGR0 frame (4 bytes per pixel) -- the classic KMS/DMABUF capture layout.
std::vector<uint8_t> bgr0_buffer(static_cast<size_t>(w) * h * 4);
platf::img_t bgr0_img {};
bgr0_img.data = bgr0_buffer.data();
bgr0_img.width = w;
bgr0_img.height = h;
bgr0_img.row_pitch = w * 4;
bgr0_img.pixel_pitch = 4;
EXPECT_EQ(device.convert(bgr0_img), 0);
// NV12 frame (1 byte per pixel row pitch, Y plane + interleaved UV) -- the
// layout delivered by PipeWire-based captures (KWin screencast / portal).
std::vector<uint8_t> nv12_buffer(static_cast<size_t>(w) * h + static_cast<size_t>(w) * h / 2);
platf::img_t nv12_img {};
nv12_img.data = nv12_buffer.data();
nv12_img.width = w;
nv12_img.height = h;
nv12_img.row_pitch = w;
nv12_img.pixel_pitch = 1;
EXPECT_EQ(device.convert(nv12_img), 0);
// Padded-stride NV12 (alignment padding) -- the case the old row_pitch ==
// width heuristic misdetected as BGR0, causing out-of-bounds reads.
constexpr int padded_stride = w + 32;
std::vector<uint8_t> padded_nv12_buffer(static_cast<size_t>(padded_stride) * h + static_cast<size_t>(padded_stride) * h / 2);
platf::img_t padded_nv12_img {};
padded_nv12_img.data = padded_nv12_buffer.data();
padded_nv12_img.width = w;
padded_nv12_img.height = h;
padded_nv12_img.row_pitch = padded_stride;
padded_nv12_img.pixel_pitch = 1;
EXPECT_EQ(device.convert(padded_nv12_img), 0);
// Capture backends that don't report pixel_pitch fall back to deriving it
// from the row pitch (1 byte per pixel = NV12, 4 = BGR0).
platf::img_t fallback_bgr0_img {};
fallback_bgr0_img.data = bgr0_buffer.data();
fallback_bgr0_img.width = w;
fallback_bgr0_img.height = h;
fallback_bgr0_img.row_pitch = w * 4;
EXPECT_EQ(device.convert(fallback_bgr0_img), 0);
platf::img_t fallback_nv12_img {};
fallback_nv12_img.data = nv12_buffer.data();
fallback_nv12_img.width = w;
fallback_nv12_img.height = h;
fallback_nv12_img.row_pitch = w;
EXPECT_EQ(device.convert(fallback_nv12_img), 0);
}