Files
cvmmap-streamer/src/config/runtime_config.cpp
T
crosstyan ae19b881b0 feat: add mcap recorder control and cnats providers
Register an MCAP recorder service on the streamer control subjects, reuse the shared recording request and status model, and expose the zed recording preview/conversion helper.

This also replaces the temporary cnats boolean with the explicit CVMMAP_CNATS_PROVIDER modes and documents the supported system and workspace build paths.
2026-03-18 11:53:04 +08:00

960 lines
31 KiB
C++

#include "cvmmap_streamer/config/runtime_config.hpp"
#include <CLI/CLI.hpp>
#include <toml++/toml.hpp>
#include <array>
#include <charconv>
#include <cstdint>
#include <limits>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace cvmmap_streamer {
namespace {
std::string trim_copy(std::string value) {
auto not_space = [](unsigned char c) {
return c != ' ' && c != '\t' && c != '\n' && c != '\r';
};
while (!value.empty() && !not_space(static_cast<unsigned char>(value.front()))) {
value.erase(value.begin());
}
while (!value.empty() && !not_space(static_cast<unsigned char>(value.back()))) {
value.pop_back();
}
return value;
}
std::string normalize_cli_error(std::string raw_message) {
if (
raw_message.find("The following argument was not expected:") != std::string::npos ||
raw_message.find("The following arguments were not expected:") != std::string::npos) {
const auto pos = raw_message.find(':');
if (pos != std::string::npos && pos + 1 < raw_message.size()) {
const auto argument = trim_copy(raw_message.substr(pos + 1));
if (argument.rfind("--rtmp-mode", 0) == 0) {
return "unknown argument: --rtmp-mode (removed; RTMP always uses enhanced mode)";
}
return "unknown argument: " + argument;
}
return "unknown argument";
}
return trim_copy(std::move(raw_message));
}
std::expected<std::uint32_t, std::string> parse_u32(std::string_view raw, std::string_view field_name) {
std::uint32_t value{0};
const auto *begin = raw.data();
const auto *end = raw.data() + raw.size();
const auto result = std::from_chars(begin, end, value, 10);
if (result.ec != std::errc{} || result.ptr != end) {
return std::unexpected("invalid value for " + std::string(field_name) + ": '" + std::string(raw) + "'");
}
return value;
}
std::expected<std::uint16_t, std::string> parse_u16(std::string_view raw, std::string_view field_name) {
std::uint16_t value{0};
const auto *begin = raw.data();
const auto *end = raw.data() + raw.size();
const auto result = std::from_chars(begin, end, value, 10);
if (result.ec != std::errc{} || result.ptr != end) {
return std::unexpected("invalid value for " + std::string(field_name) + ": '" + std::string(raw) + "'");
}
return value;
}
std::expected<std::size_t, std::string> parse_size(std::string_view raw, std::string_view field_name) {
unsigned long long parsed{0};
const auto *begin = raw.data();
const auto *end = raw.data() + raw.size();
const auto result = std::from_chars(begin, end, parsed, 10);
if (result.ec != std::errc{} || result.ptr != end) {
return std::unexpected("invalid value for " + std::string(field_name) + ": '" + std::string(raw) + "'");
}
if (parsed > static_cast<unsigned long long>(std::numeric_limits<std::size_t>::max())) {
return std::unexpected("value out of range for " + std::string(field_name) + ": '" + std::string(raw) + "'");
}
return static_cast<std::size_t>(parsed);
}
std::expected<bool, std::string> parse_bool(std::string_view raw, std::string_view field_name) {
if (raw == "true" || raw == "1") {
return true;
}
if (raw == "false" || raw == "0") {
return false;
}
return std::unexpected(
"invalid value for " + std::string(field_name) + ": '" + std::string(raw) + "' (expected: true|false|1|0)");
}
std::expected<CodecType, std::string> parse_codec(std::string_view raw) {
if (raw == "h264") {
return CodecType::H264;
}
if (raw == "h265") {
return CodecType::H265;
}
return std::unexpected("invalid codec: '" + std::string(raw) + "' (expected: h264|h265)");
}
std::expected<RunMode, std::string> parse_run_mode(std::string_view raw) {
if (raw == "pipeline") {
return RunMode::Pipeline;
}
if (raw == "ingest") {
return RunMode::Ingest;
}
return std::unexpected("invalid run mode: '" + std::string(raw) + "' (expected: pipeline|ingest)");
}
std::expected<RtmpTransportType, std::string> parse_rtmp_transport(std::string_view raw) {
if (raw == "libavformat") {
return RtmpTransportType::Libavformat;
}
if (raw == "ffmpeg_process" || raw == "ffmpeg-process") {
return RtmpTransportType::FfmpegProcess;
}
if (raw == "legacy_custom" || raw == "legacy-custom") {
return std::unexpected(
"invalid rtmp transport: '" + std::string(raw) + "' was removed; use libavformat or ffmpeg_process");
}
return std::unexpected("invalid rtmp transport: '" + std::string(raw) + "' (expected: libavformat|ffmpeg_process)");
}
std::expected<EncoderBackendType, std::string> parse_encoder_backend(std::string_view raw) {
if (raw == "auto") {
return EncoderBackendType::Auto;
}
if (raw == "ffmpeg") {
return EncoderBackendType::FFmpeg;
}
if (raw == "gstreamer_legacy" || raw == "gstreamer-legacy") {
return std::unexpected("invalid encoder backend: '" + std::string(raw) + "' was removed; use ffmpeg");
}
return std::unexpected("invalid encoder backend: '" + std::string(raw) + "' (expected: auto|ffmpeg)");
}
std::expected<EncoderDeviceType, std::string> parse_encoder_device(std::string_view raw) {
if (raw == "auto") {
return EncoderDeviceType::Auto;
}
if (raw == "nvidia") {
return EncoderDeviceType::Nvidia;
}
if (raw == "software") {
return EncoderDeviceType::Software;
}
return std::unexpected("invalid encoder device: '" + std::string(raw) + "' (expected: auto|nvidia|software)");
}
std::expected<McapCompression, std::string> parse_mcap_compression_impl(std::string_view raw) {
if (raw == "none") {
return McapCompression::None;
}
if (raw == "lz4") {
return McapCompression::Lz4;
}
if (raw == "zstd") {
return McapCompression::Zstd;
}
return std::unexpected("invalid mcap compression: '" + std::string(raw) + "' (expected: none|lz4|zstd)");
}
std::expected<std::pair<std::string, std::uint16_t>, std::string> parse_rtp_endpoint(std::string_view endpoint) {
if (endpoint.empty()) {
return std::unexpected("invalid RTP config: endpoint must not be empty");
}
const auto colon = endpoint.rfind(':');
if (colon == std::string_view::npos || colon == 0 || colon + 1 >= endpoint.size()) {
return std::unexpected("invalid RTP config: endpoint must be in '<host>:<port>' format");
}
const auto host = endpoint.substr(0, colon);
const auto port = endpoint.substr(colon + 1);
auto parsed_port = parse_u16(port, "outputs.rtp.endpoint");
if (!parsed_port) {
return std::unexpected(parsed_port.error());
}
if (*parsed_port == 0) {
return std::unexpected("invalid RTP config: endpoint port must be in range [1,65535]");
}
return std::pair{std::string(host), *parsed_port};
}
template <typename T>
std::optional<T> toml_value(const toml::table &table, std::string_view path) {
auto node = table.at_path(path);
if (!node) {
return std::nullopt;
}
auto value = node.template value<T>();
if (!value) {
return std::nullopt;
}
return *value;
}
std::optional<std::vector<std::string>> toml_string_array(const toml::table &table, std::string_view path) {
auto node = table.at_path(path);
if (!node) {
return std::nullopt;
}
auto *array = node.as_array();
if (array == nullptr) {
return std::nullopt;
}
std::vector<std::string> values{};
values.reserve(array->size());
for (const auto &item : *array) {
auto value = item.value<std::string>();
if (!value) {
return std::nullopt;
}
values.push_back(*value);
}
return values;
}
template <typename T>
std::expected<std::optional<T>, std::string> toml_nonnegative_integral(
const toml::table &table,
std::string_view path,
std::string_view field_name) {
auto node = table.at_path(path);
if (!node) {
return std::optional<T>{};
}
auto value = node.template value<std::int64_t>();
if (!value) {
return std::unexpected("invalid value for " + std::string(field_name) + ": expected integer");
}
if (*value < 0) {
return std::unexpected("invalid value for " + std::string(field_name) + ": must be >= 0");
}
if (static_cast<std::uint64_t>(*value) > static_cast<std::uint64_t>(std::numeric_limits<T>::max())) {
return std::unexpected("value out of range for " + std::string(field_name));
}
return std::optional<T>{static_cast<T>(*value)};
}
std::expected<void, std::string> apply_toml_file(RuntimeConfig &config, const std::string &path) {
toml::table table{};
try {
table = toml::parse_file(path);
} catch (const toml::parse_error &e) {
return std::unexpected("failed to parse config file '" + path + "': " + std::string(e.description()));
}
if (auto value = toml_value<std::string>(table, "input.uri")) {
config.input.uri = *value;
}
if (auto value = toml_value<std::string>(table, "input.nats_url")) {
config.input.nats_url = *value;
}
if (auto value = toml_value<std::string>(table, "run_mode")) {
auto parsed = parse_run_mode(*value);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.run_mode = *parsed;
}
if (auto value = toml_value<std::string>(table, "encoder.backend")) {
auto parsed = parse_encoder_backend(*value);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.backend = *parsed;
}
if (auto value = toml_value<std::string>(table, "encoder.device")) {
auto parsed = parse_encoder_device(*value);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.device = *parsed;
}
if (auto value = toml_value<std::string>(table, "encoder.codec")) {
auto parsed = parse_codec(*value);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.codec = *parsed;
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(table, "encoder.gop", "encoder.gop");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.encoder.gop = **value;
}
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(table, "encoder.b_frames", "encoder.b_frames");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.encoder.b_frames = **value;
}
}
if (auto value = toml_value<bool>(table, "outputs.rtp.enabled")) {
config.outputs.rtp.enabled = *value;
}
if (auto value = toml_value<std::string>(table, "outputs.rtp.endpoint")) {
config.outputs.rtp.enabled = true;
config.outputs.rtp.endpoint = *value;
}
{
auto value = toml_nonnegative_integral<std::uint8_t>(table, "outputs.rtp.payload_type", "outputs.rtp.payload_type");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.outputs.rtp.enabled = true;
config.outputs.rtp.payload_type = **value;
}
}
if (auto value = toml_value<std::string>(table, "outputs.rtp.sdp_path")) {
config.outputs.rtp.enabled = true;
config.outputs.rtp.sdp_path = *value;
}
if (auto value = toml_value<bool>(table, "outputs.rtmp.enabled")) {
config.outputs.rtmp.enabled = *value;
}
if (auto values = toml_string_array(table, "outputs.rtmp.urls")) {
config.outputs.rtmp.enabled = true;
config.outputs.rtmp.urls = std::move(*values);
}
if (auto value = toml_value<std::string>(table, "outputs.rtmp.transport")) {
auto parsed = parse_rtmp_transport(*value);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.outputs.rtmp.transport = *parsed;
}
if (auto value = toml_value<std::string>(table, "outputs.rtmp.ffmpeg_path")) {
config.outputs.rtmp.ffmpeg_path = *value;
}
if (auto value = toml_value<std::string>(table, "outputs.rtmp.mode")) {
return std::unexpected("invalid RTMP config: outputs.rtmp.mode was removed; RTMP always uses enhanced mode");
}
if (auto value = toml_value<bool>(table, "record.mcap.enabled")) {
config.record.mcap.enabled = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.path")) {
config.record.mcap.enabled = true;
config.record.mcap.path = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.topic")) {
config.record.mcap.enabled = true;
config.record.mcap.topic = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.depth_topic")) {
config.record.mcap.enabled = true;
config.record.mcap.depth_topic = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.calibration_topic")) {
config.record.mcap.enabled = true;
config.record.mcap.calibration_topic = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.pose_topic")) {
config.record.mcap.enabled = true;
config.record.mcap.pose_topic = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.body_topic")) {
config.record.mcap.enabled = true;
config.record.mcap.body_topic = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.frame_id")) {
config.record.mcap.enabled = true;
config.record.mcap.frame_id = *value;
}
if (auto value = toml_value<std::string>(table, "record.mcap.compression")) {
auto parsed = parse_mcap_compression(*value);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.record.mcap.enabled = true;
config.record.mcap.compression = *parsed;
}
{
auto value = toml_nonnegative_integral<std::size_t>(table, "latency.queue_size", "latency.queue_size");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.latency.queue_size = **value;
}
}
if (auto value = toml_value<bool>(table, "latency.realtime_sync")) {
config.latency.realtime_sync = *value;
}
if (auto value = toml_value<bool>(table, "latency.force_idr_on_reset")) {
config.latency.force_idr_on_reset = *value;
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(
table,
"latency.ingest_max_frames",
"latency.ingest_max_frames");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.latency.ingest_max_frames = **value;
}
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(
table,
"latency.ingest_idle_timeout_ms",
"latency.ingest_idle_timeout_ms");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.latency.ingest_idle_timeout_ms = **value;
}
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(
table,
"latency.ingest_consumer_delay_ms",
"latency.ingest_consumer_delay_ms");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.latency.ingest_consumer_delay_ms = **value;
}
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(
table,
"latency.snapshot_copy_delay_us",
"latency.snapshot_copy_delay_us");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.latency.snapshot_copy_delay_us = **value;
}
}
{
auto value = toml_nonnegative_integral<std::uint32_t>(table, "latency.emit_stall_ms", "latency.emit_stall_ms");
if (!value) {
return std::unexpected(value.error());
}
if (value->has_value()) {
config.latency.emit_stall_ms = **value;
}
}
return {};
}
void finalize_rtp_endpoint(RuntimeConfig &config) {
config.outputs.rtp.host.reset();
config.outputs.rtp.port.reset();
if (!config.outputs.rtp.endpoint || config.outputs.rtp.endpoint->empty()) {
return;
}
auto parsed = parse_rtp_endpoint(*config.outputs.rtp.endpoint);
if (!parsed) {
return;
}
config.outputs.rtp.host = parsed->first;
config.outputs.rtp.port = parsed->second;
}
}
std::expected<McapCompression, std::string> parse_mcap_compression(std::string_view raw) {
return parse_mcap_compression_impl(raw);
}
RuntimeConfig RuntimeConfig::defaults() {
return RuntimeConfig{};
}
std::string_view to_string(CodecType codec) {
switch (codec) {
case CodecType::H264:
return "h264";
case CodecType::H265:
return "h265";
}
return "unknown";
}
std::string_view to_string(RunMode mode) {
switch (mode) {
case RunMode::Pipeline:
return "pipeline";
case RunMode::Ingest:
return "ingest";
}
return "unknown";
}
std::string_view to_string(RtmpMode mode) {
switch (mode) {
case RtmpMode::Enhanced:
return "enhanced";
}
return "unknown";
}
std::string_view to_string(RtmpTransportType transport) {
switch (transport) {
case RtmpTransportType::Libavformat:
return "libavformat";
case RtmpTransportType::FfmpegProcess:
return "ffmpeg_process";
}
return "unknown";
}
std::string_view to_string(EncoderBackendType backend) {
switch (backend) {
case EncoderBackendType::Auto:
return "auto";
case EncoderBackendType::FFmpeg:
return "ffmpeg";
}
return "unknown";
}
std::string_view to_string(EncoderDeviceType device) {
switch (device) {
case EncoderDeviceType::Auto:
return "auto";
case EncoderDeviceType::Nvidia:
return "nvidia";
case EncoderDeviceType::Software:
return "software";
}
return "unknown";
}
std::string_view to_string(McapCompression compression) {
switch (compression) {
case McapCompression::None:
return "none";
case McapCompression::Lz4:
return "lz4";
case McapCompression::Zstd:
return "zstd";
}
return "unknown";
}
std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **argv) {
RuntimeConfig config = RuntimeConfig::defaults();
std::string config_path_raw{};
std::string input_uri_raw{};
std::string input_nats_url_raw{};
std::string run_mode_raw{};
std::string codec_raw{};
std::string encoder_backend_raw{};
std::string encoder_device_raw{};
std::string rtmp_transport_raw{};
std::string rtmp_ffmpeg_path_raw{};
std::vector<std::string> rtmp_urls_raw{};
std::string rtp_endpoint_raw{};
std::string rtp_payload_type_raw{};
std::string rtp_sdp_raw{};
std::string mcap_path_raw{};
std::string mcap_topic_raw{};
std::string mcap_depth_topic_raw{};
std::string mcap_calibration_topic_raw{};
std::string mcap_pose_topic_raw{};
std::string mcap_body_topic_raw{};
std::string mcap_frame_id_raw{};
std::string mcap_compression_raw{};
std::string queue_size_raw{};
std::string gop_raw{};
std::string b_frames_raw{};
std::string realtime_sync_raw{};
std::string force_idr_on_reset_raw{};
std::string ingest_max_frames_raw{};
std::string ingest_idle_timeout_raw{};
std::string ingest_consumer_delay_raw{};
std::string snapshot_copy_delay_raw{};
std::string emit_stall_raw{};
bool rtmp_enabled{false};
bool rtp_enabled{false};
bool mcap_enabled{false};
bool version_requested{false};
CLI::App app{"cvmmap-streamer runtime options"};
app.allow_extras(false);
app.set_help_flag("--help,-h", "show this message");
app.add_option("--config", config_path_raw);
app.add_option("--input-uri", input_uri_raw);
app.add_option("--nats-url", input_nats_url_raw);
app.add_option("--run-mode", run_mode_raw);
app.add_option("--codec", codec_raw);
app.add_option("--encoder-backend", encoder_backend_raw);
app.add_option("--encoder-device", encoder_device_raw);
app.add_flag("--rtmp", rtmp_enabled);
app.add_option("--rtmp-url", rtmp_urls_raw);
app.add_option("--rtmp-transport", rtmp_transport_raw);
app.add_option("--rtmp-ffmpeg", rtmp_ffmpeg_path_raw);
app.add_flag("--rtp", rtp_enabled);
app.add_option("--rtp-endpoint", rtp_endpoint_raw);
app.add_option("--rtp-payload-type", rtp_payload_type_raw);
auto *rtp_sdp = app.add_option("--rtp-sdp", rtp_sdp_raw);
app.add_option("--sdp", rtp_sdp_raw)->excludes(rtp_sdp);
app.add_flag("--mcap", mcap_enabled);
app.add_option("--mcap-path", mcap_path_raw);
app.add_option("--mcap-topic", mcap_topic_raw);
app.add_option("--mcap-depth-topic", mcap_depth_topic_raw);
app.add_option("--mcap-calibration-topic", mcap_calibration_topic_raw);
app.add_option("--mcap-pose-topic", mcap_pose_topic_raw);
app.add_option("--mcap-body-topic", mcap_body_topic_raw);
app.add_option("--mcap-frame-id", mcap_frame_id_raw);
app.add_option("--mcap-compression", mcap_compression_raw);
app.add_option("--queue-size", queue_size_raw);
app.add_option("--gop", gop_raw);
app.add_option("--b-frames", b_frames_raw);
app.add_option("--realtime-sync", realtime_sync_raw);
app.add_option("--force-idr-on-reset", force_idr_on_reset_raw);
app.add_option("--ingest-max-frames", ingest_max_frames_raw);
app.add_option("--ingest-idle-timeout-ms", ingest_idle_timeout_raw);
app.add_option("--ingest-consumer-delay-ms", ingest_consumer_delay_raw);
app.add_option("--snapshot-copy-delay-us", snapshot_copy_delay_raw);
app.add_option("--emit-stall-ms", emit_stall_raw);
app.add_flag("--version", version_requested);
try {
app.parse(argc, argv);
} catch (const CLI::ParseError &e) {
const auto exit_code = app.exit(e);
if (exit_code == 0) {
return std::unexpected("help");
}
return std::unexpected(normalize_cli_error(e.what()));
}
if (!config_path_raw.empty()) {
auto load_result = apply_toml_file(config, config_path_raw);
if (!load_result) {
return std::unexpected(load_result.error());
}
}
if (!input_uri_raw.empty()) {
config.input.uri = input_uri_raw;
}
if (!input_nats_url_raw.empty()) {
config.input.nats_url = input_nats_url_raw;
}
if (!run_mode_raw.empty()) {
auto parsed = parse_run_mode(run_mode_raw);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.run_mode = *parsed;
}
if (!codec_raw.empty()) {
auto parsed = parse_codec(codec_raw);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.codec = *parsed;
}
if (!encoder_backend_raw.empty()) {
auto parsed = parse_encoder_backend(encoder_backend_raw);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.backend = *parsed;
}
if (!encoder_device_raw.empty()) {
auto parsed = parse_encoder_device(encoder_device_raw);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.device = *parsed;
}
config.outputs.rtmp.enabled = config.outputs.rtmp.enabled || rtmp_enabled;
if (!rtmp_urls_raw.empty()) {
config.outputs.rtmp.enabled = true;
config.outputs.rtmp.urls = std::move(rtmp_urls_raw);
}
if (!rtmp_transport_raw.empty()) {
auto parsed = parse_rtmp_transport(rtmp_transport_raw);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.outputs.rtmp.transport = *parsed;
}
if (!rtmp_ffmpeg_path_raw.empty()) {
config.outputs.rtmp.ffmpeg_path = rtmp_ffmpeg_path_raw;
}
config.outputs.rtp.enabled = config.outputs.rtp.enabled || rtp_enabled;
if (!rtp_endpoint_raw.empty()) {
config.outputs.rtp.enabled = true;
config.outputs.rtp.endpoint = rtp_endpoint_raw;
}
if (!rtp_payload_type_raw.empty()) {
auto value = parse_u32(rtp_payload_type_raw, "--rtp-payload-type");
if (!value) {
return std::unexpected(value.error());
}
if (*value > std::numeric_limits<std::uint8_t>::max()) {
return std::unexpected("value out of range for --rtp-payload-type: '" + rtp_payload_type_raw + "'");
}
config.outputs.rtp.enabled = true;
config.outputs.rtp.payload_type = static_cast<std::uint8_t>(*value);
}
if (!rtp_sdp_raw.empty()) {
config.outputs.rtp.enabled = true;
config.outputs.rtp.sdp_path = rtp_sdp_raw;
}
config.record.mcap.enabled = config.record.mcap.enabled || mcap_enabled;
if (!mcap_path_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.path = mcap_path_raw;
}
if (!mcap_topic_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.topic = mcap_topic_raw;
}
if (!mcap_depth_topic_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.depth_topic = mcap_depth_topic_raw;
}
if (!mcap_calibration_topic_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.calibration_topic = mcap_calibration_topic_raw;
}
if (!mcap_pose_topic_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.pose_topic = mcap_pose_topic_raw;
}
if (!mcap_body_topic_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.body_topic = mcap_body_topic_raw;
}
if (!mcap_frame_id_raw.empty()) {
config.record.mcap.enabled = true;
config.record.mcap.frame_id = mcap_frame_id_raw;
}
if (!mcap_compression_raw.empty()) {
auto parsed = parse_mcap_compression(mcap_compression_raw);
if (!parsed) {
return std::unexpected(parsed.error());
}
config.record.mcap.enabled = true;
config.record.mcap.compression = *parsed;
}
if (!queue_size_raw.empty()) {
auto parsed = parse_size(queue_size_raw, "--queue-size");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.queue_size = *parsed;
}
if (!gop_raw.empty()) {
auto parsed = parse_u32(gop_raw, "--gop");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.gop = *parsed;
}
if (!b_frames_raw.empty()) {
auto parsed = parse_u32(b_frames_raw, "--b-frames");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.encoder.b_frames = *parsed;
}
if (!realtime_sync_raw.empty()) {
auto parsed = parse_bool(realtime_sync_raw, "--realtime-sync");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.realtime_sync = *parsed;
}
if (!force_idr_on_reset_raw.empty()) {
auto parsed = parse_bool(force_idr_on_reset_raw, "--force-idr-on-reset");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.force_idr_on_reset = *parsed;
}
if (!ingest_max_frames_raw.empty()) {
auto parsed = parse_u32(ingest_max_frames_raw, "--ingest-max-frames");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.ingest_max_frames = *parsed;
}
if (!ingest_idle_timeout_raw.empty()) {
auto parsed = parse_u32(ingest_idle_timeout_raw, "--ingest-idle-timeout-ms");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.ingest_idle_timeout_ms = *parsed;
}
if (!ingest_consumer_delay_raw.empty()) {
auto parsed = parse_u32(ingest_consumer_delay_raw, "--ingest-consumer-delay-ms");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.ingest_consumer_delay_ms = *parsed;
}
if (!snapshot_copy_delay_raw.empty()) {
auto parsed = parse_u32(snapshot_copy_delay_raw, "--snapshot-copy-delay-us");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.snapshot_copy_delay_us = *parsed;
}
if (!emit_stall_raw.empty()) {
auto parsed = parse_u32(emit_stall_raw, "--emit-stall-ms");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.latency.emit_stall_ms = *parsed;
}
finalize_rtp_endpoint(config);
return config;
}
std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &config) {
if (config.input.uri.empty()) {
return std::unexpected("invalid input config: input.uri must not be empty");
}
if (config.input.nats_url.empty()) {
return std::unexpected("invalid input config: input.nats_url must not be empty");
}
if (config.outputs.rtmp.enabled && config.outputs.rtmp.urls.empty()) {
return std::unexpected("invalid RTMP config: enabled RTMP output requires at least one URL");
}
for (const auto &url : config.outputs.rtmp.urls) {
if (url.empty()) {
return std::unexpected("invalid RTMP config: URL must not be empty");
}
}
if (config.outputs.rtmp.enabled) {
if (config.encoder.backend == EncoderBackendType::Auto) {
// auto resolves to FFmpeg; nothing else is supported.
} else if (config.encoder.backend != EncoderBackendType::FFmpeg) {
return std::unexpected("invalid backend/output matrix: RTMP requires encoder.backend=ffmpeg or auto");
}
if (config.outputs.rtmp.transport == RtmpTransportType::FfmpegProcess && config.outputs.rtmp.ffmpeg_path.empty()) {
return std::unexpected("invalid RTMP config: ffmpeg_process transport requires a non-empty ffmpeg path");
}
}
if (config.outputs.rtp.enabled) {
if (!config.outputs.rtp.endpoint || config.outputs.rtp.endpoint->empty()) {
return std::unexpected("invalid RTP config: enabled RTP output requires endpoint");
}
auto endpoint_validation = parse_rtp_endpoint(*config.outputs.rtp.endpoint);
if (!endpoint_validation) {
return std::unexpected(endpoint_validation.error());
}
if (config.outputs.rtp.payload_type < 96 || config.outputs.rtp.payload_type > 127) {
return std::unexpected("invalid RTP config: payload type must be in dynamic range [96,127]");
}
}
if (config.record.mcap.enabled && config.record.mcap.path.empty()) {
return std::unexpected("invalid MCAP config: enabled MCAP output requires path");
}
if (config.record.mcap.topic.empty()) {
return std::unexpected("invalid MCAP config: topic must not be empty");
}
if (config.record.mcap.depth_topic.empty()) {
return std::unexpected("invalid MCAP config: depth_topic must not be empty");
}
if (config.record.mcap.calibration_topic.empty()) {
return std::unexpected("invalid MCAP config: calibration_topic must not be empty");
}
if (config.record.mcap.pose_topic.empty()) {
return std::unexpected("invalid MCAP config: pose_topic must not be empty");
}
if (config.record.mcap.body_topic.empty()) {
return std::unexpected("invalid MCAP config: body_topic must not be empty");
}
if (config.record.mcap.frame_id.empty()) {
return std::unexpected("invalid MCAP config: frame_id must not be empty");
}
if (config.latency.queue_size == 0) {
return std::unexpected("invalid latency config: queue_size must be >= 1");
}
if (config.encoder.gop == 0) {
return std::unexpected("invalid encoder config: gop must be >= 1");
}
if (config.encoder.b_frames > config.encoder.gop) {
return std::unexpected("invalid encoder config: b_frames must be <= gop");
}
if (config.latency.ingest_idle_timeout_ms == 0) {
return std::unexpected("invalid ingest config: ingest_idle_timeout_ms must be >= 1");
}
return {};
}
std::string summarize_runtime_config(const RuntimeConfig &config) {
std::ostringstream ss;
ss << "input.uri=" << config.input.uri;
ss << ", input.nats_url=" << config.input.nats_url;
ss << ", run_mode=" << to_string(config.run_mode);
ss << ", encoder.backend=" << to_string(config.encoder.backend);
ss << ", encoder.device=" << to_string(config.encoder.device);
ss << ", encoder.codec=" << to_string(config.encoder.codec);
ss << ", encoder.gop=" << config.encoder.gop;
ss << ", encoder.b_frames=" << config.encoder.b_frames;
ss << ", rtmp.enabled=" << (config.outputs.rtmp.enabled ? "true" : "false");
ss << ", rtmp.transport=" << to_string(config.outputs.rtmp.transport);
ss << ", rtmp.urls=" << config.outputs.rtmp.urls.size();
ss << ", rtp.enabled=" << (config.outputs.rtp.enabled ? "true" : "false");
ss << ", rtp.endpoint=" << (config.outputs.rtp.endpoint ? *config.outputs.rtp.endpoint : "<unset>");
ss << ", rtp.payload_type=" << static_cast<unsigned>(config.outputs.rtp.payload_type);
ss << ", mcap.enabled=" << (config.record.mcap.enabled ? "true" : "false");
ss << ", mcap.path=" << config.record.mcap.path;
ss << ", mcap.topic=" << config.record.mcap.topic;
ss << ", mcap.depth_topic=" << config.record.mcap.depth_topic;
ss << ", mcap.calibration_topic=" << config.record.mcap.calibration_topic;
ss << ", mcap.pose_topic=" << config.record.mcap.pose_topic;
ss << ", mcap.body_topic=" << config.record.mcap.body_topic;
ss << ", mcap.frame_id=" << config.record.mcap.frame_id;
ss << ", mcap.compression=" << to_string(config.record.mcap.compression);
ss << ", latency.queue_size=" << config.latency.queue_size;
ss << ", latency.realtime_sync=" << (config.latency.realtime_sync ? "true" : "false");
ss << ", latency.force_idr_on_reset=" << (config.latency.force_idr_on_reset ? "true" : "false");
ss << ", latency.ingest_max_frames=" << config.latency.ingest_max_frames;
ss << ", latency.ingest_idle_timeout_ms=" << config.latency.ingest_idle_timeout_ms;
ss << ", latency.ingest_consumer_delay_ms=" << config.latency.ingest_consumer_delay_ms;
ss << ", latency.snapshot_copy_delay_us=" << config.latency.snapshot_copy_delay_us;
ss << ", latency.emit_stall_ms=" << config.latency.emit_stall_ms;
return ss.str();
}
}