Files
screen_cast/tools/capture_smoke.cpp
T
fegger 10870bc6c9 feat(app): implement Phase 5 local UDP sender->receiver loopback
Wire the first end-to-end pipeline: capture -> encode -> packetize ->
UDP -> depacketize -> decode -> render.

- UdpRtpTransport: raw POSIX UDP sockets (IPv4 via getaddrinfo), a
  receive jthread woken by socket close on stop; port 0 skips binding
  so the sender uses an OS-assigned source port. ASIO stays deferred
  to the signaling phase per ARCHITECTURE.md.
- SdlRenderer: SDL3 window/renderer with RGBA texture upload; the
  texture is recreated on resolution change. RendererFactory now
  returns RendererResult so SDL init failures carry a message,
  mirroring the codec/capture error patterns.
- screencast binary: parse_cli plus SenderPipeline/ReceiverPipeline
  per the app scaffolds; the sender creates its encoder once capture
  reports real dimensions, the receiver keeps a bounded 3-frame queue
  to hold latency down and renders on its own thread until the window
  closes. cli argv signature fixed to 'const char* const*' so main's
  argv converts implicitly.
- Encoder: drop AV_CODEC_FLAG_GLOBAL_HEADER so libx264 repeats SPS/PPS
  in-band at each keyframe -- the receiver decodes from the bitstream
  alone, which also makes mid-stream joins and later PLI recovery
  work without out-of-band parameter negotiation. The round-trip test
  now exercises exactly that path.
- tests: new udp-loopback integration test pushes synthetic frames
  through a real localhost socket and decodes 10/10 frames with the
  right dimensions; valgrind clean (loopback + codec). meson test 4/4.

Manual validation on the desktop (receiver window shows the captured
desktop) is documented in docs/RUNBOOK.md.
2026-09-07 11:02:40 +02:00

167 lines
6.0 KiB
C++

// 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 repeats SPS/PPS in-band at each keyframe, so the
// file is a self-contained Annex-B stream without a prefix.
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;
}