refactor(streamer): adopt proxy backends and typed statuses
This commit is contained in:
@@ -11,6 +11,19 @@
|
||||
|
||||
namespace {
|
||||
|
||||
enum class TesterExitCode : int {
|
||||
Success = 0,
|
||||
ReadError = 2,
|
||||
VerificationError = 3,
|
||||
TornReadAccepted = 4,
|
||||
TornReadWrongError = 5,
|
||||
};
|
||||
|
||||
[[nodiscard]]
|
||||
constexpr int exit_code(TesterExitCode code) {
|
||||
return static_cast<int>(code);
|
||||
}
|
||||
|
||||
constexpr std::size_t kMagicOffset = 0;
|
||||
constexpr std::size_t kVersionMajorOffset = 8;
|
||||
constexpr std::size_t kVersionMinorOffset = 9;
|
||||
@@ -67,7 +80,7 @@ void write_metadata(
|
||||
int main(int argc, char **argv) {
|
||||
if (argc <= 1 || cvmmap_streamer::has_help_flag(argc, argv)) {
|
||||
cvmmap_streamer::print_help("ipc_snapshot_tester");
|
||||
return 0;
|
||||
return exit_code(TesterExitCode::Success);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, cvmmap_streamer::ipc::kShmPayloadOffset + 32> shm{};
|
||||
@@ -82,12 +95,12 @@ int main(int argc, char **argv) {
|
||||
auto valid = cvmmap_streamer::ipc::read_coherent_snapshot(shm_view, destination);
|
||||
if (!valid) {
|
||||
spdlog::error("coherent snapshot should succeed: {}", cvmmap_streamer::ipc::to_string(valid.error()));
|
||||
return 2;
|
||||
return exit_code(TesterExitCode::ReadError);
|
||||
}
|
||||
|
||||
if (valid->bytes_copied != 32 || valid->metadata.frame_count != 7 || valid->metadata.timestamp_ns != 2222) {
|
||||
spdlog::error("valid snapshot verification failed");
|
||||
return 3;
|
||||
return exit_code(TesterExitCode::VerificationError);
|
||||
}
|
||||
|
||||
const auto torn = cvmmap_streamer::ipc::read_coherent_snapshot(
|
||||
@@ -99,13 +112,13 @@ int main(int argc, char **argv) {
|
||||
|
||||
if (torn) {
|
||||
spdlog::error("torn read should be rejected");
|
||||
return 4;
|
||||
return exit_code(TesterExitCode::TornReadAccepted);
|
||||
}
|
||||
if (torn.error() != cvmmap_streamer::ipc::SnapshotError::TornRead) {
|
||||
spdlog::error("unexpected torn read error: {}", cvmmap_streamer::ipc::to_string(torn.error()));
|
||||
return 5;
|
||||
return exit_code(TesterExitCode::TornReadWrongError);
|
||||
}
|
||||
|
||||
spdlog::info("snapshot path valid and torn-read rejection verified");
|
||||
return 0;
|
||||
return exit_code(TesterExitCode::Success);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
|
||||
namespace {
|
||||
|
||||
enum class TesterExitCode : int {
|
||||
Success = 0,
|
||||
OpenError = 2,
|
||||
SchemaError = 3,
|
||||
TopicMismatch = 4,
|
||||
TimestampError = 5,
|
||||
ParseError = 6,
|
||||
FormatMismatch = 7,
|
||||
EmptyPayload = 8,
|
||||
ThresholdError = 9,
|
||||
};
|
||||
|
||||
[[nodiscard]]
|
||||
constexpr int exit_code(TesterExitCode code) {
|
||||
return static_cast<int>(code);
|
||||
}
|
||||
|
||||
struct Config {
|
||||
std::string input_path{};
|
||||
std::optional<std::string> expected_topic{};
|
||||
@@ -51,7 +68,7 @@ int main(int argc, char **argv) {
|
||||
const auto open_status = reader.open(config->input_path);
|
||||
if (!open_status.ok()) {
|
||||
spdlog::error("failed to open MCAP file '{}': {}", config->input_path, open_status.message);
|
||||
return 2;
|
||||
return exit_code(TesterExitCode::OpenError);
|
||||
}
|
||||
|
||||
std::uint64_t message_count{0};
|
||||
@@ -63,7 +80,7 @@ int main(int argc, char **argv) {
|
||||
if (it->schema == nullptr || it->channel == nullptr) {
|
||||
spdlog::error("MCAP message missing schema or channel metadata");
|
||||
reader.close();
|
||||
return 3;
|
||||
return exit_code(TesterExitCode::SchemaError);
|
||||
}
|
||||
if (it->schema->encoding != "protobuf" || it->schema->name != "foxglove.CompressedVideo") {
|
||||
continue;
|
||||
@@ -71,34 +88,34 @@ int main(int argc, char **argv) {
|
||||
if (it->channel->messageEncoding != "protobuf") {
|
||||
spdlog::error("unexpected MCAP message encoding: {}", it->channel->messageEncoding);
|
||||
reader.close();
|
||||
return 3;
|
||||
return exit_code(TesterExitCode::SchemaError);
|
||||
}
|
||||
if (config->expected_topic && it->channel->topic != *config->expected_topic) {
|
||||
spdlog::error("unexpected topic: expected '{}' got '{}'", *config->expected_topic, it->channel->topic);
|
||||
reader.close();
|
||||
return 4;
|
||||
return exit_code(TesterExitCode::TopicMismatch);
|
||||
}
|
||||
if (saw_log_time && it->message.logTime < previous_log_time) {
|
||||
spdlog::error("non-monotonic logTime detected: {} < {}", it->message.logTime, previous_log_time);
|
||||
reader.close();
|
||||
return 5;
|
||||
return exit_code(TesterExitCode::TimestampError);
|
||||
}
|
||||
|
||||
foxglove::CompressedVideo message{};
|
||||
if (!message.ParseFromArray(it->message.data, static_cast<int>(it->message.dataSize))) {
|
||||
spdlog::error("failed to parse foxglove.CompressedVideo payload");
|
||||
reader.close();
|
||||
return 6;
|
||||
return exit_code(TesterExitCode::ParseError);
|
||||
}
|
||||
if (config->expected_format && message.format() != *config->expected_format) {
|
||||
spdlog::error("unexpected format: expected '{}' got '{}'", *config->expected_format, message.format());
|
||||
reader.close();
|
||||
return 7;
|
||||
return exit_code(TesterExitCode::FormatMismatch);
|
||||
}
|
||||
if (message.data().empty()) {
|
||||
spdlog::error("compressed video payload is empty");
|
||||
reader.close();
|
||||
return 8;
|
||||
return exit_code(TesterExitCode::EmptyPayload);
|
||||
}
|
||||
|
||||
previous_log_time = it->message.logTime;
|
||||
@@ -110,9 +127,9 @@ int main(int argc, char **argv) {
|
||||
|
||||
if (message_count < config->min_messages) {
|
||||
spdlog::error("message threshold not met: {} < {}", message_count, config->min_messages);
|
||||
return 9;
|
||||
return exit_code(TesterExitCode::ThresholdError);
|
||||
}
|
||||
|
||||
spdlog::info("validated {} foxglove.CompressedVideo MCAP messages", message_count);
|
||||
return 0;
|
||||
return exit_code(TesterExitCode::Success);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#include "cvmmap_streamer/config/runtime_config.hpp"
|
||||
#include "cvmmap_streamer/encode/encoder_backend.hpp"
|
||||
#include "cvmmap_streamer/ipc/contracts.hpp"
|
||||
#include "cvmmap_streamer/protocol/rtmp_output.hpp"
|
||||
|
||||
#include <CLI/CLI.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace {
|
||||
|
||||
enum class TesterExitCode : int {
|
||||
Success = 0,
|
||||
InvalidArgument = 2,
|
||||
BackendSelectionError = 3,
|
||||
BackendInitError = 4,
|
||||
StreamInfoError = 5,
|
||||
OutputInitError = 6,
|
||||
PushError = 7,
|
||||
DrainError = 8,
|
||||
PublishError = 9,
|
||||
FlushError = 10,
|
||||
FlushPublishError = 11,
|
||||
};
|
||||
|
||||
[[nodiscard]]
|
||||
constexpr int exit_code(TesterExitCode code) {
|
||||
return static_cast<int>(code);
|
||||
}
|
||||
|
||||
struct Config {
|
||||
std::string rtmp_url{"rtmp://127.0.0.1/live/cvmmap_streamer_test"};
|
||||
std::string transport{"libavformat"};
|
||||
std::string codec{"h264"};
|
||||
std::string ffmpeg_path{"ffmpeg"};
|
||||
std::uint32_t frames{48};
|
||||
std::uint32_t width{320};
|
||||
std::uint32_t height{240};
|
||||
std::uint32_t frame_interval_ms{33};
|
||||
std::uint32_t linger_ms{3000};
|
||||
};
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<Config, int> parse_args(int argc, char **argv) {
|
||||
Config config{};
|
||||
CLI::App app{"rtmp_output_tester - publish synthetic encoded video to RTMP using the configured sink"};
|
||||
app.add_option("--rtmp-url", config.rtmp_url, "RTMP destination URL")->required();
|
||||
app.add_option("--transport", config.transport, "RTMP transport backend (libavformat|ffmpeg_process)")
|
||||
->check(CLI::IsMember({"libavformat", "ffmpeg_process", "legacy_custom"}));
|
||||
app.add_option("--codec", config.codec, "Video codec (h264|h265)")
|
||||
->check(CLI::IsMember({"h264", "h265"}));
|
||||
app.add_option("--ffmpeg-path", config.ffmpeg_path, "ffmpeg binary path for ffmpeg_process transport");
|
||||
app.add_option("--frames", config.frames, "Number of frames to publish")->check(CLI::PositiveNumber);
|
||||
app.add_option("--width", config.width, "Frame width")->check(CLI::PositiveNumber);
|
||||
app.add_option("--height", config.height, "Frame height")->check(CLI::PositiveNumber);
|
||||
app.add_option("--frame-interval-ms", config.frame_interval_ms, "Frame interval in milliseconds")->check(CLI::PositiveNumber);
|
||||
app.add_option("--linger-ms", config.linger_ms, "How long to keep the RTMP output open after flush")->check(CLI::NonNegativeNumber);
|
||||
|
||||
try {
|
||||
app.parse(argc, argv);
|
||||
} catch (const CLI::ParseError &e) {
|
||||
return std::unexpected(app.exit(e));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<cvmmap_streamer::CodecType, std::string> parse_codec(std::string_view raw) {
|
||||
if (raw == "h264") {
|
||||
return cvmmap_streamer::CodecType::H264;
|
||||
}
|
||||
if (raw == "h265") {
|
||||
return cvmmap_streamer::CodecType::H265;
|
||||
}
|
||||
return std::unexpected("unsupported codec");
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<cvmmap_streamer::RtmpTransportType, std::string> parse_transport(std::string_view raw) {
|
||||
if (raw == "libavformat") {
|
||||
return cvmmap_streamer::RtmpTransportType::Libavformat;
|
||||
}
|
||||
if (raw == "ffmpeg_process") {
|
||||
return cvmmap_streamer::RtmpTransportType::FfmpegProcess;
|
||||
}
|
||||
if (raw == "legacy_custom") {
|
||||
return cvmmap_streamer::RtmpTransportType::LegacyCustom;
|
||||
}
|
||||
return std::unexpected("unsupported transport");
|
||||
}
|
||||
|
||||
void fill_pattern(std::vector<std::uint8_t> &buffer, std::uint32_t width, std::uint32_t height, std::uint32_t frame_index) {
|
||||
for (std::uint32_t y = 0; y < height; ++y) {
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
const std::size_t pixel = static_cast<std::size_t>(y) * width * 3 + static_cast<std::size_t>(x) * 3;
|
||||
buffer[pixel + 0] = static_cast<std::uint8_t>((x + frame_index * 3) & 0xffu);
|
||||
buffer[pixel + 1] = static_cast<std::uint8_t>((y * 2 + frame_index * 5) & 0xffu);
|
||||
buffer[pixel + 2] = static_cast<std::uint8_t>(((x + y) / 2 + frame_index * 7) & 0xffu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
auto args = parse_args(argc, argv);
|
||||
if (!args) {
|
||||
return args.error();
|
||||
}
|
||||
|
||||
auto codec = parse_codec(args->codec);
|
||||
if (!codec) {
|
||||
spdlog::error("{}", codec.error());
|
||||
return exit_code(TesterExitCode::InvalidArgument);
|
||||
}
|
||||
|
||||
auto transport = parse_transport(args->transport);
|
||||
if (!transport) {
|
||||
spdlog::error("{}", transport.error());
|
||||
return exit_code(TesterExitCode::InvalidArgument);
|
||||
}
|
||||
|
||||
cvmmap_streamer::RuntimeConfig config = cvmmap_streamer::RuntimeConfig::defaults();
|
||||
config.encoder.backend = cvmmap_streamer::EncoderBackendType::FFmpeg;
|
||||
config.encoder.device = cvmmap_streamer::EncoderDeviceType::Software;
|
||||
config.encoder.codec = *codec;
|
||||
config.encoder.gop = 15;
|
||||
config.encoder.b_frames = 0;
|
||||
config.outputs.rtmp.enabled = true;
|
||||
config.outputs.rtmp.urls = {args->rtmp_url};
|
||||
config.outputs.rtmp.transport = *transport;
|
||||
config.outputs.rtmp.ffmpeg_path = args->ffmpeg_path;
|
||||
|
||||
if (config.outputs.rtmp.transport == cvmmap_streamer::RtmpTransportType::LegacyCustom) {
|
||||
config.encoder.backend = cvmmap_streamer::EncoderBackendType::GStreamerLegacy;
|
||||
}
|
||||
|
||||
cvmmap_streamer::ipc::FrameInfo frame_info{
|
||||
.width = static_cast<std::uint16_t>(args->width),
|
||||
.height = static_cast<std::uint16_t>(args->height),
|
||||
.channels = 3,
|
||||
.depth = cvmmap_streamer::ipc::Depth::U8,
|
||||
.pixel_format = cvmmap_streamer::ipc::PixelFormat::BGR,
|
||||
.buffer_size = args->width * args->height * 3,
|
||||
};
|
||||
|
||||
auto backend = cvmmap_streamer::encode::make_encoder_backend(config);
|
||||
if (!backend) {
|
||||
spdlog::error("failed to select encoder backend: {}", cvmmap_streamer::format_error(backend.error()));
|
||||
return exit_code(TesterExitCode::BackendSelectionError);
|
||||
}
|
||||
|
||||
auto init = (*backend)->init(config, frame_info);
|
||||
if (!init) {
|
||||
spdlog::error("failed to initialize encoder backend: {}", cvmmap_streamer::format_error(init.error()));
|
||||
return exit_code(TesterExitCode::BackendInitError);
|
||||
}
|
||||
|
||||
auto stream_info = (*backend)->stream_info();
|
||||
if (!stream_info) {
|
||||
spdlog::error("failed to get encoder stream info: {}", cvmmap_streamer::format_error(stream_info.error()));
|
||||
return exit_code(TesterExitCode::StreamInfoError);
|
||||
}
|
||||
|
||||
auto output = cvmmap_streamer::protocol::make_rtmp_output(config, *stream_info);
|
||||
if (!output) {
|
||||
spdlog::error("failed to initialize RTMP output: {}", cvmmap_streamer::format_error(output.error()));
|
||||
return exit_code(TesterExitCode::OutputInitError);
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> frame_bytes(frame_info.buffer_size, 0);
|
||||
const auto frame_interval = std::chrono::milliseconds(args->frame_interval_ms);
|
||||
std::uint64_t timestamp_ns{0};
|
||||
|
||||
for (std::uint32_t frame_index = 0; frame_index < args->frames; ++frame_index) {
|
||||
fill_pattern(frame_bytes, args->width, args->height, frame_index);
|
||||
|
||||
auto push = (*backend)->push_frame(cvmmap_streamer::encode::RawVideoFrame{
|
||||
.info = frame_info,
|
||||
.source_timestamp_ns = timestamp_ns,
|
||||
.bytes = std::span<const std::uint8_t>(frame_bytes.data(), frame_bytes.size()),
|
||||
});
|
||||
if (!push) {
|
||||
spdlog::error("encoder push failed at frame {}: {}", frame_index, cvmmap_streamer::format_error(push.error()));
|
||||
return exit_code(TesterExitCode::PushError);
|
||||
}
|
||||
|
||||
auto drained = (*backend)->drain();
|
||||
if (!drained) {
|
||||
spdlog::error("encoder drain failed at frame {}: {}", frame_index, cvmmap_streamer::format_error(drained.error()));
|
||||
return exit_code(TesterExitCode::DrainError);
|
||||
}
|
||||
for (const auto &access_unit : *drained) {
|
||||
auto publish = (*output)->publish_access_unit(access_unit);
|
||||
if (!publish) {
|
||||
spdlog::error("RTMP publish failed at frame {}: {}", frame_index, cvmmap_streamer::format_error(publish.error()));
|
||||
return exit_code(TesterExitCode::PublishError);
|
||||
}
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(frame_interval);
|
||||
timestamp_ns += static_cast<std::uint64_t>(args->frame_interval_ms) * 1'000'000ull;
|
||||
}
|
||||
|
||||
auto flushed = (*backend)->flush();
|
||||
if (!flushed) {
|
||||
spdlog::error("encoder flush failed: {}", cvmmap_streamer::format_error(flushed.error()));
|
||||
return exit_code(TesterExitCode::FlushError);
|
||||
}
|
||||
for (const auto &access_unit : *flushed) {
|
||||
auto publish = (*output)->publish_access_unit(access_unit);
|
||||
if (!publish) {
|
||||
spdlog::error("RTMP publish failed during flush: {}", cvmmap_streamer::format_error(publish.error()));
|
||||
return exit_code(TesterExitCode::FlushPublishError);
|
||||
}
|
||||
}
|
||||
|
||||
spdlog::info(
|
||||
"rtmp_output_tester completed publish: transport={} codec={} frames={} linger_ms={}",
|
||||
args->transport,
|
||||
args->codec,
|
||||
args->frames,
|
||||
args->linger_ms);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(args->linger_ms));
|
||||
|
||||
(*output)->log_metrics();
|
||||
(*backend)->shutdown();
|
||||
return exit_code(TesterExitCode::Success);
|
||||
}
|
||||
@@ -26,6 +26,19 @@
|
||||
|
||||
namespace {
|
||||
|
||||
enum class TesterExitCode : int {
|
||||
Success = 0,
|
||||
SocketError = 2,
|
||||
PayloadTypeMismatch = 3,
|
||||
PacketThresholdError = 4,
|
||||
SdpValidationError = 5,
|
||||
};
|
||||
|
||||
[[nodiscard]]
|
||||
constexpr int exit_code(TesterExitCode code) {
|
||||
return static_cast<int>(code);
|
||||
}
|
||||
|
||||
// RFC3550 RTP header constants
|
||||
constexpr std::size_t kRtpHeaderMinSize = 12;
|
||||
constexpr std::uint8_t kRtpVersion = 2;
|
||||
@@ -300,7 +313,7 @@ int main(int argc, char **argv) {
|
||||
sdpInfo = parseSdpFile(*config.sdpFile);
|
||||
if (!sdpInfo) {
|
||||
spdlog::error("Failed to parse SDP file: {}", *config.sdpFile);
|
||||
return 5;
|
||||
return exit_code(TesterExitCode::SdpValidationError);
|
||||
}
|
||||
spdlog::info("SDP parsed: encoding={}, clock-rate={}, PT={}",
|
||||
sdpInfo->encodingName,
|
||||
@@ -312,7 +325,7 @@ int main(int argc, char **argv) {
|
||||
spdlog::error("Expected PT({}) does not match SDP PT({})",
|
||||
*config.expectedPt,
|
||||
sdpInfo->payloadType);
|
||||
return 5;
|
||||
return exit_code(TesterExitCode::SdpValidationError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +333,7 @@ int main(int argc, char **argv) {
|
||||
auto sockResult = createUdpSocket(config.port);
|
||||
if (!sockResult) {
|
||||
spdlog::error("Socket error: {}", sockResult.error());
|
||||
return 2;
|
||||
return exit_code(TesterExitCode::SocketError);
|
||||
}
|
||||
|
||||
int sock = *sockResult;
|
||||
@@ -473,16 +486,16 @@ int main(int argc, char **argv) {
|
||||
spdlog::error("FAIL: Payload type mismatch detected (expected {}, got {})",
|
||||
config.expectedPt.value(),
|
||||
*stats.ptMismatchError);
|
||||
return 3;
|
||||
return exit_code(TesterExitCode::PayloadTypeMismatch);
|
||||
}
|
||||
|
||||
if (stats.packetsReceived < config.packetThreshold) {
|
||||
spdlog::error("FAIL: Packet threshold not met (received {}, required {})",
|
||||
stats.packetsReceived,
|
||||
config.packetThreshold);
|
||||
return 4;
|
||||
return exit_code(TesterExitCode::PacketThresholdError);
|
||||
}
|
||||
|
||||
spdlog::info("PASS: All validations successful");
|
||||
return 0;
|
||||
return exit_code(TesterExitCode::Success);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user