928 lines
30 KiB
C++
928 lines
30 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(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.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;
|
|
}
|
|
|
|
}
|
|
|
|
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_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-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_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.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.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();
|
|
}
|
|
|
|
}
|