10870bc6c9
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.
167 lines
6.0 KiB
C++
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;
|
|
}
|