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:
@@ -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;
|
||||
}
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user