feat(capture): implement Phase 3 PipeWire/portal desktop capture

Implement the xdg-desktop-portal ScreenCast backend via libportal: a
blocking portal handshake (interactive source picker), a PipeWire stream
on the portal's node enumerating BGRx/BGRA/RGBx/RGBA, and a latest-frame
slot handing frames to next_frame(). stop() is thread-safe; teardown
follows the order PipeWire requires. All proxy operations run under the
thread-loop lock to satisfy the protocol extension context checks
('impl_ext_end_proxy called from wrong context' otherwise).

The encoder now accepts padded strides for packed RGB inputs (real
PipeWire row pitches) and maps the new PixelFormat::Bgrx to
AV_PIX_FMT_BGRA.

Add tools/capture_smoke: a manual smoke tool (interactive, not in
meson test) that captures N frames, encodes them, and writes a
self-contained Annex-B elementary stream with prepended SPS/PPS.

Validated manually on Wayland/Hyprland: 2256x1504 H.264 elementary
stream, ffprobe clean. Phase 3 marked complete in docs/PHASES.md.
This commit is contained in:
2026-09-07 10:37:14 +02:00
parent ac9dc02b51
commit ce52f64e52
11 changed files with 883 additions and 55 deletions
+12 -6
View File
@@ -1,11 +1,17 @@
# Phase 3 capture placeholder. libpipewire-0.3 will be added when the portal
# implementation is wired in.
# Phase 3 capture backend: PipeWire via the xdg-desktop-portal ScreenCast
# interface, with libportal handling the portal D-Bus handshake.
dep_pipewire = dependency('libpipewire-0.3')
dep_portal = dependency('libportal')
sc_capture_sources = files('pipewire_capture.cpp')
# Built even though nothing links it yet, so the stub stays compile-clean.
static_library('sc_capture',
sc_capture = static_library('sc_capture',
sc_capture_sources,
include_directories : sc_core_inc)
include_directories : sc_core_inc,
dependencies : [dep_pipewire, dep_portal])
# sc_capture_dep will be declared once the app/pipeline code links against it.
sc_capture_dep = declare_dependency(
link_with : sc_capture,
include_directories : sc_core_inc,
dependencies : [dep_pipewire, dep_portal])
+573 -4
View File
@@ -1,13 +1,582 @@
#include "screencast/capture/capture.h"
#include <glib.h>
#include <libportal/portal-helpers.h>
#include <libportal/portal.h>
#include <libportal/remote.h>
#include <libportal/session.h>
#include <pipewire/pipewire.h>
#include <pipewire/thread-loop.h>
#include <spa/param/param.h>
#include <spa/param/video/format-utils.h>
#include <spa/pod/builder.h>
#include <spa/utils/result.h>
#include <unistd.h>
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <format>
#include <iostream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
namespace sc {
namespace {
CaptureResult<std::unique_ptr<CaptureSession>> CaptureFactory::create(CaptureTarget /*target*/) {
// Phase 3 placeholder. The PipeWire / xdg-desktop-portal capture backend
// will be implemented here once the portal integration lands.
return CaptureError{"PipeWire capture is not implemented yet"};
struct GObjectDeleter {
void operator()(gpointer object) const noexcept {
if (object != nullptr) {
g_object_unref(object);
}
}
};
using PortalPtr = std::unique_ptr<XdpPortal, GObjectDeleter>;
using XdpSessionPtr = std::unique_ptr<XdpSession, GObjectDeleter>;
class ScopedMainContext {
public:
ScopedMainContext() : context_(g_main_context_new()) {
if (context_ != nullptr) {
g_main_context_push_thread_default(context_);
}
}
~ScopedMainContext() {
if (context_ != nullptr) {
g_main_context_pop_thread_default(context_);
g_main_context_unref(context_);
}
}
ScopedMainContext(const ScopedMainContext&) = delete;
ScopedMainContext& operator=(const ScopedMainContext&) = delete;
GMainContext* get() const noexcept {
return context_;
}
private:
GMainContext* context_;
};
class ScopedMainLoop {
public:
explicit ScopedMainLoop(GMainContext* context) : loop_(g_main_loop_new(context, FALSE)) {}
~ScopedMainLoop() {
if (loop_ != nullptr) {
g_main_loop_unref(loop_);
}
}
ScopedMainLoop(const ScopedMainLoop&) = delete;
ScopedMainLoop& operator=(const ScopedMainLoop&) = delete;
void run() {
g_main_loop_run(loop_);
}
void quit() {
g_main_loop_quit(loop_);
}
private:
GMainLoop* loop_;
};
// Owns a raw fd and closes it unless release()d.
class OwnedFd {
public:
explicit OwnedFd(int fd) noexcept : fd_(fd) {}
~OwnedFd() {
if (fd_ >= 0) {
close(fd_);
}
}
OwnedFd(const OwnedFd&) = delete;
OwnedFd& operator=(const OwnedFd&) = delete;
OwnedFd(OwnedFd&& other) noexcept : fd_(other.fd_) {
other.fd_ = -1;
}
OwnedFd& operator=(OwnedFd&& other) noexcept {
if (this != &other) {
if (fd_ >= 0) {
close(fd_);
}
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
int get() const noexcept {
return fd_;
}
int release() noexcept {
const int fd = fd_;
fd_ = -1;
return fd;
}
private:
int fd_;
};
std::string gerror_message(const GError* error) {
if (error == nullptr || error->message == nullptr) {
return "unknown error";
}
return std::string{error->message};
}
uint64_t monotonic_now_ns() {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(now).count());
}
std::optional<PixelFormat> pixel_format_from_spa(uint32_t spa_format) noexcept {
switch (spa_format) {
case SPA_VIDEO_FORMAT_BGRx:
case SPA_VIDEO_FORMAT_BGRA:
return PixelFormat::Bgrx;
case SPA_VIDEO_FORMAT_RGBx:
case SPA_VIDEO_FORMAT_RGBA:
return PixelFormat::Rgba;
default:
return std::nullopt;
}
}
// The portal Start response exposes one entry per stream as (u a{sv}); the
// first child of the first stream holds the PipeWire node id.
std::optional<uint32_t> first_stream_node_id(XdpSession* session) {
GVariant* streams = xdp_session_get_streams(session); // transfer full
if (streams == nullptr) {
return std::nullopt;
}
std::optional<uint32_t> node_id;
if (g_variant_n_children(streams) > 0) {
GVariant* stream = g_variant_get_child_value(streams, 0);
if (stream != nullptr) {
if (g_variant_is_of_type(stream, G_VARIANT_TYPE("(ua{sv})"))) {
guint32 id = 0;
GVariant* properties = nullptr;
g_variant_get(stream, "(u@a{sv})", &id, &properties);
if (properties != nullptr) {
g_variant_unref(properties);
}
node_id = static_cast<uint32_t>(id);
}
g_variant_unref(stream);
}
}
g_variant_unref(streams);
return node_id;
}
struct SessionCreatedOp {
ScopedMainLoop* loop = nullptr;
XdpSession* session = nullptr; // owned, transferred by the caller
GError* error = nullptr; // owned, cleared by the caller
};
void on_screencast_session_created(GObject* source, GAsyncResult* result, gpointer user_data) {
auto* op = static_cast<SessionCreatedOp*>(user_data);
op->session = xdp_portal_create_screencast_session_finish(XDP_PORTAL(source), result, &op->error);
op->loop->quit();
}
struct SessionStartedOp {
ScopedMainLoop* loop = nullptr;
bool started = false;
GError* error = nullptr; // owned, cleared by the caller
};
void on_screencast_session_started(GObject* source, GAsyncResult* result, gpointer user_data) {
auto* op = static_cast<SessionStartedOp*>(user_data);
op->started = xdp_session_start_finish(XDP_SESSION(source), result, &op->error) == TRUE;
op->loop->quit();
}
} // namespace
// PipeWire screencast session driven by the xdg-desktop-portal ScreenCast
// interface (via libportal). The portal handshake runs synchronously on the
// thread that calls CaptureFactory::create(); frames are produced on a
// PipeWire thread and handed to next_frame() through a latest-frame slot.
class PipeWireCaptureSession final : public CaptureSession {
public:
PipeWireCaptureSession(uint32_t node_id, XdpSessionPtr portal_session, PortalPtr portal)
: node_id_(node_id), portal_session_(std::move(portal_session)), portal_(std::move(portal)) {
stream_events_.version = PW_VERSION_STREAM_EVENTS;
stream_events_.state_changed = &PipeWireCaptureSession::on_state_changed;
stream_events_.param_changed = &PipeWireCaptureSession::on_param_changed;
stream_events_.process = &PipeWireCaptureSession::on_process;
}
~PipeWireCaptureSession() override {
stop();
teardown_pipewire();
}
PipeWireCaptureSession(const PipeWireCaptureSession&) = delete;
PipeWireCaptureSession& operator=(const PipeWireCaptureSession&) = delete;
std::optional<CapturedFrame> next_frame() override {
std::unique_lock lock(frame_mutex_);
frame_cv_.wait(lock, [this] { return stopped_ || has_new_frame_; });
if (!has_new_frame_) {
return std::nullopt; // stopped (or the stream ended with an error)
}
has_new_frame_ = false;
return std::move(latest_frame_);
}
void stop() override {
{
std::lock_guard lock(frame_mutex_);
stopped_ = true;
}
frame_cv_.notify_all();
pw_thread_loop* loop = loop_;
if (loop != nullptr) {
pw_thread_loop_lock(loop);
if (stream_ != nullptr) {
pw_stream_disconnect(stream_);
}
pw_thread_loop_unlock(loop);
}
}
// Connects the capture stream to the portal's PipeWire node. On success
// the fd has been handed to PipeWire; on failure it is closed here.
//
// Every PipeWire operation that marshals a proxy message must run under
// the thread-loop lock; the protocol extension rejects calls from other
// contexts ("impl_ext_end_proxy called from wrong context").
std::optional<std::string> connect_to_pipewire(OwnedFd fd) {
loop_ = pw_thread_loop_new("screencast-capture", nullptr);
if (loop_ == nullptr) {
return std::string{"failed to create the PipeWire thread loop"};
}
if (pw_thread_loop_start(loop_) < 0) {
return std::string{"failed to start the PipeWire thread loop"};
}
pw_thread_loop_lock(loop_);
context_ = pw_context_new(pw_thread_loop_get_loop(loop_), nullptr, 0);
if (context_ == nullptr) {
pw_thread_loop_unlock(loop_);
return std::string{"failed to create the PipeWire context"};
}
// pw_context_connect_fd takes ownership of the socket (see core.h).
core_ = pw_context_connect_fd(context_, fd.release(), nullptr, 0);
if (core_ == nullptr) {
pw_thread_loop_unlock(loop_);
return std::string{"failed to connect to the PipeWire remote"};
}
pw_properties* props = pw_properties_new(PW_KEY_MEDIA_TYPE, "Video", PW_KEY_MEDIA_CATEGORY, "Capture", nullptr);
stream_ = pw_stream_new(core_, "screencast", props);
if (stream_ == nullptr) {
pw_thread_loop_unlock(loop_);
return std::string{"failed to create the capture stream"};
}
pw_stream_add_listener(stream_, &stream_listener_, &stream_events_, this);
// Enumerate the formats the portal stream can provide. Properties that
// are omitted (size, framerate) mean "any".
std::array<uint8_t, 1024> pod_buffer{};
struct spa_pod_builder pod_builder = SPA_POD_BUILDER_INIT(pod_buffer.data(), pod_buffer.size());
const struct spa_pod* connect_params[1] = {nullptr};
{
struct spa_pod_frame outer[1];
struct spa_pod_frame choice[1];
spa_pod_builder_push_object(&pod_builder, &outer[0], SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat);
spa_pod_builder_add(&pod_builder,
SPA_FORMAT_mediaType,
SPA_POD_Id(SPA_MEDIA_TYPE_video),
SPA_FORMAT_mediaSubtype,
SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw),
0);
spa_pod_builder_prop(&pod_builder, SPA_FORMAT_VIDEO_format, 0);
spa_pod_builder_push_choice(&pod_builder, &choice[0], SPA_CHOICE_Enum, 0);
spa_pod_builder_id(&pod_builder, SPA_VIDEO_FORMAT_BGRx); // default
spa_pod_builder_id(&pod_builder, SPA_VIDEO_FORMAT_BGRA);
spa_pod_builder_id(&pod_builder, SPA_VIDEO_FORMAT_RGBx);
spa_pod_builder_id(&pod_builder, SPA_VIDEO_FORMAT_RGBA);
spa_pod_builder_pop(&pod_builder, &choice[0]);
connect_params[0] = static_cast<const struct spa_pod*>(spa_pod_builder_pop(&pod_builder, &outer[0]));
}
const int connect_result = pw_stream_connect(stream_,
PW_DIRECTION_INPUT,
node_id_,
static_cast<enum pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT |
PW_STREAM_FLAG_MAP_BUFFERS |
PW_STREAM_FLAG_DONT_RECONNECT),
connect_params,
1);
pw_thread_loop_unlock(loop_);
if (connect_result < 0) {
return std::string{"failed to connect the capture stream: "} + spa_strerror(connect_result);
}
return std::nullopt;
}
private:
static void on_state_changed(void* data, enum pw_stream_state, enum pw_stream_state state, const char* error) {
if (state != PW_STREAM_STATE_ERROR) {
return;
}
auto* self = static_cast<PipeWireCaptureSession*>(data);
std::cerr << std::format("screencast: capture stream error: {}\n", error != nullptr ? error : "unknown error");
self->mark_stopped();
}
static void on_param_changed(void* data, uint32_t id, const struct spa_pod* param) {
if (param == nullptr || id != SPA_PARAM_Format) {
return;
}
auto* self = static_cast<PipeWireCaptureSession*>(data);
struct spa_video_info info = {};
if (spa_format_parse(param, &info.media_type, &info.media_subtype) < 0) {
return;
}
if (info.media_type != SPA_MEDIA_TYPE_video || info.media_subtype != SPA_MEDIA_SUBTYPE_raw) {
return;
}
if (spa_format_video_raw_parse(param, &info.info.raw) < 0) {
return;
}
self->video_width_.store(info.info.raw.size.width, std::memory_order_relaxed);
self->video_height_.store(info.info.raw.size.height, std::memory_order_relaxed);
self->video_spa_format_.store(info.info.raw.format, std::memory_order_relaxed);
}
static void on_process(void* data) {
auto* self = static_cast<PipeWireCaptureSession*>(data);
struct pw_buffer* buffer = nullptr;
while ((buffer = pw_stream_dequeue_buffer(self->stream_)) != nullptr) {
self->store_frame(*buffer);
pw_stream_queue_buffer(self->stream_, buffer);
}
}
void store_frame(const struct pw_buffer& pw_buffer) {
const struct spa_buffer* buffer = pw_buffer.buffer;
if (buffer == nullptr || buffer->n_datas < 1) {
return;
}
const struct spa_data& data = buffer->datas[0];
if (data.type == SPA_DATA_DmaBuf && !dma_buf_logged_.exchange(true)) {
std::cerr << "screencast: the portal offers DMA-BUF buffers only; software capture "
"requires mapped memory (deferred to the hardware-acceleration phase)\n";
}
if (data.data == nullptr || data.chunk == nullptr) {
return; // unmapped (e.g. DMA-BUF) or empty buffer
}
const int width = video_width_.load(std::memory_order_relaxed);
const int height = video_height_.load(std::memory_order_relaxed);
const uint32_t spa_format = video_spa_format_.load(std::memory_order_relaxed);
if (width <= 0 || height <= 0) {
return;
}
const auto pixel_format = pixel_format_from_spa(spa_format);
if (!pixel_format.has_value()) {
if (!unsupported_format_logged_.exchange(true)) {
std::cerr << std::format("screencast: unsupported negotiated pixel format {}\n", spa_format);
}
return;
}
const int stride = data.chunk->stride;
const int row_bytes = width * 4; // every enumerated format is 4 bytes/pixel
if (stride < row_bytes) {
return;
}
const std::size_t required_size = static_cast<std::size_t>(stride) * (static_cast<std::size_t>(height) - 1) +
static_cast<std::size_t>(row_bytes);
if (data.chunk->size < required_size && data.maxsize < required_size) {
return; // buffer smaller than the negotiated frame
}
CapturedFrame frame;
frame.width = width;
frame.height = height;
frame.pixel_format = *pixel_format;
frame.stride = stride;
frame.timestamp_ns = monotonic_now_ns();
frame.pixels.resize(required_size);
std::memcpy(frame.pixels.data(), data.data, required_size);
{
std::lock_guard lock(frame_mutex_);
latest_frame_ = std::move(frame);
has_new_frame_ = true;
}
frame_cv_.notify_all();
}
void mark_stopped() {
{
std::lock_guard lock(frame_mutex_);
stopped_ = true;
}
frame_cv_.notify_all();
}
// PipeWire teardown in the order required by the API: destroy the stream
// and disconnect the core under the thread-loop lock, then drop the
// context, then stop the loop.
void teardown_pipewire() noexcept {
if (loop_ != nullptr) {
pw_thread_loop_lock(loop_);
if (stream_ != nullptr) {
spa_hook_remove(&stream_listener_);
pw_stream_destroy(stream_);
stream_ = nullptr;
}
if (core_ != nullptr) {
pw_core_disconnect(core_);
core_ = nullptr;
}
pw_thread_loop_unlock(loop_);
}
if (context_ != nullptr) {
pw_context_destroy(context_);
context_ = nullptr;
}
if (loop_ != nullptr) {
pw_thread_loop_stop(loop_);
pw_thread_loop_destroy(loop_);
loop_ = nullptr;
}
pw_deinit();
}
uint32_t node_id_ = 0;
XdpSessionPtr portal_session_;
PortalPtr portal_;
pw_thread_loop* loop_ = nullptr;
pw_context* context_ = nullptr;
pw_core* core_ = nullptr;
pw_stream* stream_ = nullptr;
struct spa_hook stream_listener_ = {};
struct pw_stream_events stream_events_ = {};
std::atomic<int> video_width_{0};
std::atomic<int> video_height_{0};
std::atomic<uint32_t> video_spa_format_{SPA_VIDEO_FORMAT_UNKNOWN};
std::atomic<bool> dma_buf_logged_{false};
std::atomic<bool> unsupported_format_logged_{false};
std::mutex frame_mutex_;
std::condition_variable frame_cv_;
std::optional<CapturedFrame> latest_frame_;
bool has_new_frame_ = false;
bool stopped_ = false;
};
CaptureResult<std::unique_ptr<CaptureSession>> CaptureFactory::create(CaptureTarget target) {
XdpOutputType outputs = XDP_OUTPUT_NONE;
if (std::holds_alternative<CaptureTargetWholeScreen>(target)) {
outputs = XDP_OUTPUT_MONITOR;
} else if (std::holds_alternative<CaptureTargetWindow>(target)) {
outputs = XDP_OUTPUT_WINDOW;
} else {
return CaptureError{"region capture is not supported by the desktop portal"};
}
pw_init(nullptr, nullptr);
ScopedMainContext main_context;
if (main_context.get() == nullptr) {
return CaptureError{"failed to create a GLib main context"};
}
ScopedMainLoop main_loop(main_context.get());
PortalPtr portal(xdp_portal_new());
if (portal == nullptr) {
return CaptureError{"failed to connect to the desktop portal"};
}
// Step 1: create the screencast session. The user picks a source in the
// portal dialog while this call blocks.
SessionCreatedOp created{&main_loop};
xdp_portal_create_screencast_session(portal.get(),
outputs,
XDP_SCREENCAST_FLAG_NONE,
XDP_CURSOR_MODE_EMBEDDED,
XDP_PERSIST_MODE_NONE,
nullptr,
nullptr,
&on_screencast_session_created,
&created);
main_loop.run();
XdpSessionPtr portal_session(created.session);
if (portal_session == nullptr) {
const std::string message = gerror_message(created.error);
g_clear_error(&created.error);
return CaptureError{"failed to create the screencast session: " + message};
}
g_clear_error(&created.error);
// Step 2: start the session.
SessionStartedOp started{&main_loop};
xdp_session_start(portal_session.get(), nullptr, nullptr, &on_screencast_session_started, &started);
main_loop.run();
if (!started.started) {
const std::string message = gerror_message(started.error);
g_clear_error(&started.error);
return CaptureError{"failed to start the screencast session: " + message};
}
g_clear_error(&started.error);
// Step 3: find the PipeWire node to capture.
const std::optional<uint32_t> node_id = first_stream_node_id(portal_session.get());
if (!node_id.has_value()) {
return CaptureError{"the screencast session exposes no streams"};
}
OwnedFd remote_fd(xdp_session_open_pipewire_remote(portal_session.get()));
if (remote_fd.get() < 0) {
return CaptureError{"failed to open the PipeWire remote of the screencast session"};
}
auto session = std::make_unique<PipeWireCaptureSession>(*node_id, std::move(portal_session), std::move(portal));
if (auto error = session->connect_to_pipewire(std::move(remote_fd))) {
return CaptureError{std::move(*error)};
}
return std::unique_ptr<CaptureSession>(std::move(session));
}
} // namespace sc
+56 -14
View File
@@ -3,6 +3,7 @@
#include "ffmpeg_raii.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
@@ -18,15 +19,24 @@ namespace {
using namespace sc::detail;
AVPixelFormat to_ffmpeg_format(PixelFormat format) noexcept {
return format == PixelFormat::Rgba ? AV_PIX_FMT_RGBA : AV_PIX_FMT_YUV420P;
switch (format) {
case PixelFormat::Rgba:
return AV_PIX_FMT_RGBA;
case PixelFormat::Bgrx:
// BGRx is BGR with an unused fourth byte; FFmpeg models that layout as BGRA.
return AV_PIX_FMT_BGRA;
case PixelFormat::Yuv420p:
return AV_PIX_FMT_YUV420P;
}
return AV_PIX_FMT_NONE;
}
int expected_first_plane_stride(PixelFormat format, int width) noexcept {
return format == PixelFormat::Rgba ? width * 4 : width;
bool is_packed_rgb(PixelFormat format) noexcept {
return format == PixelFormat::Rgba || format == PixelFormat::Bgrx;
}
std::size_t expected_buffer_size(PixelFormat format, int width, int height) {
return static_cast<std::size_t>(av_image_get_buffer_size(to_ffmpeg_format(format), width, height, 1));
int minimum_row_bytes(PixelFormat format, int width) noexcept {
return is_packed_rgb(format) ? width * 4 : width;
}
enum class ReceiveStatus {
@@ -244,12 +254,29 @@ class FfmpegEncoder final : public Encoder {
}
const AVPixelFormat input_format = to_ffmpeg_format(frame.pixel_format);
const int expected_stride = expected_first_plane_stride(frame.pixel_format, frame.width);
if (frame.stride != 0 && frame.stride != expected_stride) {
return CodecError{"unsupported input stride"};
if (input_format == AV_PIX_FMT_NONE) {
return CodecError{"unsupported pixel format"};
}
const std::size_t required_size = expected_buffer_size(frame.pixel_format, frame.width, frame.height);
const int row_bytes = minimum_row_bytes(frame.pixel_format, frame.width);
std::size_t stride = static_cast<std::size_t>(row_bytes);
if (frame.stride != 0) {
if (frame.stride < row_bytes) {
return CodecError{"input stride is smaller than one pixel row"};
}
if (!is_packed_rgb(frame.pixel_format) && frame.stride != row_bytes) {
return CodecError{"unsupported input stride for planar format"};
}
stride = static_cast<std::size_t>(frame.stride);
}
std::size_t required_size = 0;
if (is_packed_rgb(frame.pixel_format)) {
required_size = stride * (static_cast<std::size_t>(frame.height) - 1) + static_cast<std::size_t>(row_bytes);
} else {
required_size =
static_cast<std::size_t>(av_image_get_buffer_size(input_format, frame.width, frame.height, 1));
}
if (frame.pixels.size() < required_size) {
return CodecError{"input pixel buffer is too small"};
}
@@ -274,12 +301,27 @@ class FfmpegEncoder final : public Encoder {
return CodecError{"failed to create swscale context"};
}
std::array<uint8_t*, 4> src{nullptr, nullptr, nullptr, nullptr};
std::array<const uint8_t*, 4> src{nullptr, nullptr, nullptr, nullptr};
std::array<int, 4> src_lines{0, 0, 0, 0};
if (av_image_fill_arrays(
src.data(), src_lines.data(), as_u8(frame.pixels.data()), input_format, frame.width, frame.height, 1) <
0) {
return CodecError{"failed to fill input pixel arrays"};
if (is_packed_rgb(frame.pixel_format)) {
// Packed RGB is a single plane whose row pitch may be padded beyond
// the packed row size, so build the source arrays by hand.
src[0] = as_u8(frame.pixels.data());
src_lines[0] = static_cast<int>(stride);
} else {
std::array<uint8_t*, 4> planar_src{nullptr, nullptr, nullptr, nullptr};
std::array<int, 4> planar_lines{0, 0, 0, 0};
if (av_image_fill_arrays(planar_src.data(),
planar_lines.data(),
as_u8(frame.pixels.data()),
input_format,
frame.width,
frame.height,
1) < 0) {
return CodecError{"failed to fill input pixel arrays"};
}
std::copy(planar_src.begin(), planar_src.end(), src.begin());
std::copy(planar_lines.begin(), planar_lines.end(), src_lines.begin());
}
if (sws_scale(scaler_.get(), src.data(), src_lines.data(), 0, frame.height, output->data, output->linesize) <=