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
+167
View File
@@ -0,0 +1,167 @@
// Phase 3 smoke test: capture a few real desktop frames through the portal,
// encode them to H.264, and write an Annex-B elementary stream to disk.
//
// This tool is intentionally manual: it needs a running desktop session and
// the user must confirm the source picker dialog, so it is not registered
// with `meson test`. Validation steps are documented in docs/RUNBOOK.md.
#include "screencast/capture/capture.h"
#include "screencast/codec/encoder.h"
#include <charconv>
#include <cstdint>
#include <format>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace {
int positive_int_arg(std::string_view text) {
int value = 0;
const auto [ptr, ec] = std::from_chars(text.data(), text.data() + text.size(), value);
if (ec != std::errc{} || ptr != text.data() + text.size() || value <= 0) {
return -1;
}
return value;
}
std::string_view pixel_format_name(sc::PixelFormat format) {
switch (format) {
case sc::PixelFormat::Rgba:
return "rgba";
case sc::PixelFormat::Bgrx:
return "bgrx";
case sc::PixelFormat::Yuv420p:
return "yuv420p";
}
return "unknown";
}
bool write_bytes(std::ofstream& output, const std::vector<std::byte>& bytes) {
output.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return output.good();
}
} // namespace
int main(int argc, char** argv) {
const int max_frames = argc > 1 ? positive_int_arg(argv[1]) : 10;
if (max_frames < 0) {
std::cerr << "usage: capture_smoke [frames] [output.h264]\n";
return 1;
}
const std::string output_path = argc > 2 ? argv[2] : "screencast_smoke.h264";
std::cout << "creating capture session (choose a source in the portal dialog)...\n";
auto capture_result = sc::CaptureFactory::create(sc::CaptureTargetWholeScreen{});
if (sc::is_capture_error(capture_result)) {
std::cerr << std::format("capture failed: {}\n", sc::capture_error(capture_result).message);
return 1;
}
auto capture = std::move(sc::capture_value(capture_result));
std::ofstream output(output_path, std::ios::binary | std::ios::trunc);
if (!output.is_open()) {
std::cerr << std::format("failed to open {} for writing\n", output_path);
return 1;
}
std::unique_ptr<sc::Encoder> encoder;
std::uint64_t total_bytes = 0;
std::uint64_t encoded_frames = 0;
std::uint64_t keyframes = 0;
for (int captured = 0; captured < max_frames; ++captured) {
auto frame = capture->next_frame();
if (!frame.has_value()) {
std::cout << std::format("capture session ended after {} frames\n", captured);
break;
}
if (encoder == nullptr) {
sc::EncoderConfig config;
config.width = frame->width;
config.height = frame->height;
config.frame_rate_num = 25;
config.frame_rate_den = 1;
config.bitrate_kbps = 8000;
auto encoder_result = sc::EncoderFactory::create(config);
if (sc::is_codec_error(encoder_result)) {
std::cerr << std::format("encoder creation failed: {}\n", sc::codec_error(encoder_result).message);
return 1;
}
encoder = std::move(sc::codec_value(encoder_result));
// The encoder is configured with AV_CODEC_FLAG_GLOBAL_HEADER, so
// parameter sets live in extradata; prepend them so the output
// file is a self-contained Annex-B stream.
const auto extradata = encoder->get_extradata();
if (!extradata.empty()) {
if (!write_bytes(output, extradata)) {
std::cerr << std::format("failed to write parameter sets to {}\n", output_path);
return 1;
}
total_bytes += extradata.size();
}
std::cout << std::format("capturing {}x{} ({}, stride {})\n",
frame->width,
frame->height,
pixel_format_name(frame->pixel_format),
frame->stride);
}
auto encoded_result = encoder->encode(*frame);
if (sc::is_codec_error(encoded_result)) {
std::cerr << std::format("encode failed: {}\n", sc::codec_error(encoded_result).message);
return 1;
}
for (const auto& encoded : sc::codec_value(encoded_result)) {
if (!write_bytes(output, encoded.data)) {
std::cerr << std::format("failed to write encoded data to {}\n", output_path);
return 1;
}
total_bytes += encoded.data.size();
++encoded_frames;
keyframes += encoded.is_keyframe ? 1 : 0;
}
}
if (encoder != nullptr) {
auto flushed = encoder->flush();
if (sc::is_codec_error(flushed)) {
std::cerr << std::format("flush failed: {}\n", sc::codec_error(flushed).message);
return 1;
}
for (const auto& encoded : sc::codec_value(flushed)) {
if (!write_bytes(output, encoded.data)) {
std::cerr << std::format("failed to write flushed data to {}\n", output_path);
return 1;
}
total_bytes += encoded.data.size();
++encoded_frames;
}
}
capture->stop();
output.close();
if (total_bytes == 0 || encoded_frames == 0) {
std::cerr << "no frames were captured\n";
return 1;
}
std::cout << std::format("wrote {} bytes to {} ({} encoded frames, {} keyframes)\n",
total_bytes,
output_path,
encoded_frames,
keyframes);
std::cout << std::format("validate with: ffprobe -v error -show_entries stream=codec_name,width,height {}\n",
output_path);
return 0;
}
+6
View File
@@ -0,0 +1,6 @@
# Manual smoke tools. These are not registered with `meson test` because they
# need an interactive desktop session (portal consent dialog).
executable('capture_smoke',
'capture_smoke.cpp',
dependencies : [sc_capture_dep, sc_codec_dep])