feat(streamer): add ffmpeg encoder and mcap recording

This commit is contained in:
2026-03-10 22:12:22 +08:00
parent 769d36f86f
commit 6af97ee5d3
86 changed files with 30551 additions and 1482 deletions
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -18,15 +18,7 @@ namespace {
[[nodiscard]]
std::string resolve_client_target(const RuntimeConfig &config) {
if (config.input.shm_name.starts_with("cvmmap://")) {
return config.input.shm_name;
}
if (!config.input.shm_name.empty() && config.input.shm_name.front() == '/') {
return config.input.shm_name.substr(1);
}
return config.input.shm_name;
return config.input.uri;
}
[[nodiscard]]
+10 -18
View File
@@ -42,28 +42,20 @@ namespace {
[[nodiscard]]
std::expected<ResolvedInputEndpoints, std::string> resolve_input_endpoints(const RuntimeConfig &config) {
ResolvedInputEndpoints resolved{
.shm_name = config.input.shm_name,
.zmq_endpoint = config.input.zmq_endpoint,
};
if (!config.input.shm_name.starts_with("cvmmap://")) {
return resolved;
}
try {
auto target = cvmmap::resolve_cvmmap_target_or_throw(config.input.shm_name);
resolved.shm_name = target.shm_name;
resolved.zmq_endpoint = target.zmq_addr;
auto target = cvmmap::resolve_cvmmap_target_or_throw(config.input.uri);
spdlog::info(
"ingest real-input URI resolved: shm_name='{}' zmq_endpoint='{}'",
resolved.shm_name,
resolved.zmq_endpoint);
"ingest input URI resolved: uri='{}' shm_name='{}' zmq_endpoint='{}'",
config.input.uri,
target.shm_name,
target.zmq_addr);
return ResolvedInputEndpoints{
.shm_name = target.shm_name,
.zmq_endpoint = target.zmq_addr,
};
} catch (const std::exception &e) {
return std::unexpected(std::string("invalid cvmmap uri in --shm-name: ") + e.what());
return std::unexpected(std::string("invalid cvmmap uri in input.uri: ") + e.what());
}
return resolved;
}
struct SharedMemoryView {
+36
View File
@@ -0,0 +1,36 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
#include <memory>
#include <string>
namespace cvmmap_streamer::encode {
std::unique_ptr<EncoderBackend> make_ffmpeg_backend();
std::unique_ptr<EncoderBackend> make_gstreamer_legacy_backend();
std::expected<std::unique_ptr<EncoderBackend>, std::string> make_encoder_backend(const RuntimeConfig &config) {
switch (config.encoder.backend) {
case EncoderBackendType::FFmpeg:
return make_ffmpeg_backend();
case EncoderBackendType::GStreamerLegacy: {
auto backend = make_gstreamer_legacy_backend();
if (!backend) {
return std::unexpected("legacy GStreamer backend is not compiled in this build");
}
return backend;
}
case EncoderBackendType::Auto:
if (config.outputs.rtmp.enabled) {
auto backend = make_gstreamer_legacy_backend();
if (!backend) {
return std::unexpected("RTMP requires the legacy GStreamer backend, but it is not compiled");
}
return backend;
}
return make_ffmpeg_backend();
}
return std::unexpected("unknown encoder backend");
}
}
+433
View File
@@ -0,0 +1,433 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavcodec/bsf.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libavutil/opt.h>
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}
#include <cstdint>
#include <cstring>
#include <expected>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <spdlog/spdlog.h>
namespace cvmmap_streamer::encode {
namespace {
class FfmpegEncoderBackend final : public EncoderBackend {
public:
~FfmpegEncoderBackend() override {
shutdown();
}
[[nodiscard]]
std::string_view backend_name() const override {
return "ffmpeg";
}
[[nodiscard]]
bool using_hardware() const override {
return using_hardware_;
}
[[nodiscard]]
std::expected<void, std::string> init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info) override {
shutdown();
config_ = &config;
frame_info_ = frame_info;
codec_ = config.encoder.codec;
encoder_pix_fmt_ = pick_encoder_pixel_format(config.encoder.device);
auto input_pixel_format = to_av_pixel_format(frame_info.pixel_format);
if (!input_pixel_format) {
return std::unexpected(input_pixel_format.error());
}
input_pix_fmt_ = *input_pixel_format;
auto encoder_name = pick_encoder_name(config);
if (!encoder_name) {
return std::unexpected(encoder_name.error());
}
using_hardware_ = encoder_name->find("nvenc") != std::string::npos;
const auto *encoder = avcodec_find_encoder_by_name(encoder_name->c_str());
if (encoder == nullptr) {
return std::unexpected("FFmpeg encoder '" + *encoder_name + "' is unavailable");
}
context_ = avcodec_alloc_context3(encoder);
if (context_ == nullptr) {
return std::unexpected("failed to allocate FFmpeg encoder context");
}
context_->codec_type = AVMEDIA_TYPE_VIDEO;
context_->codec_id = encoder->id;
context_->width = static_cast<int>(frame_info.width);
context_->height = static_cast<int>(frame_info.height);
context_->pix_fmt = encoder_pix_fmt_;
context_->time_base = AVRational{1, 1000000000};
context_->framerate = AVRational{30, 1};
context_->gop_size = static_cast<int>(config.encoder.gop);
context_->max_b_frames = static_cast<int>(config.encoder.b_frames);
context_->thread_count = 1;
auto codec_setup = configure_codec(*encoder_name, config);
if (!codec_setup) {
return std::unexpected(codec_setup.error());
}
const auto open_result = avcodec_open2(context_, encoder, nullptr);
if (open_result < 0) {
return std::unexpected("failed to open FFmpeg encoder '" + *encoder_name + "': " + av_error_string(open_result));
}
scaler_ = sws_getCachedContext(
nullptr,
static_cast<int>(frame_info.width),
static_cast<int>(frame_info.height),
input_pix_fmt_,
static_cast<int>(frame_info.width),
static_cast<int>(frame_info.height),
encoder_pix_fmt_,
SWS_BILINEAR,
nullptr,
nullptr,
nullptr);
if (scaler_ == nullptr) {
return std::unexpected("failed to create swscale conversion context");
}
frame_ = av_frame_alloc();
if (frame_ == nullptr) {
return std::unexpected("failed to allocate FFmpeg frame");
}
frame_->format = encoder_pix_fmt_;
frame_->width = context_->width;
frame_->height = context_->height;
const auto frame_buffer = av_frame_get_buffer(frame_, 32);
if (frame_buffer < 0) {
return std::unexpected("failed to allocate FFmpeg frame buffer: " + av_error_string(frame_buffer));
}
packet_ = av_packet_alloc();
if (packet_ == nullptr) {
return std::unexpected("failed to allocate FFmpeg packet");
}
filtered_packet_ = av_packet_alloc();
if (filtered_packet_ == nullptr) {
return std::unexpected("failed to allocate FFmpeg filtered packet");
}
auto bitstream_filter = create_bitstream_filter();
if (!bitstream_filter) {
return std::unexpected(bitstream_filter.error());
}
spdlog::info(
"FFMPEG_ENCODER_PATH codec={} device={} encoder={} pix_fmt={}",
cvmmap_streamer::to_string(codec_),
device_to_string(config.encoder.device),
*encoder_name,
av_get_pix_fmt_name(encoder_pix_fmt_));
return {};
}
[[nodiscard]]
std::expected<void, std::string> poll() override {
return {};
}
[[nodiscard]]
std::expected<void, std::string> push_frame(const RawVideoFrame &frame) override {
if (context_ == nullptr || frame_ == nullptr || scaler_ == nullptr) {
return std::unexpected("FFmpeg backend not initialized");
}
if (frame.bytes.empty()) {
return {};
}
const auto make_writable = av_frame_make_writable(frame_);
if (make_writable < 0) {
return std::unexpected("failed to make FFmpeg frame writable: " + av_error_string(make_writable));
}
AVFrame input_frame{};
input_frame.format = input_pix_fmt_;
input_frame.width = static_cast<int>(frame_info_.width);
input_frame.height = static_cast<int>(frame_info_.height);
if (av_image_fill_arrays(
input_frame.data,
input_frame.linesize,
const_cast<std::uint8_t *>(frame.bytes.data()),
input_pix_fmt_,
input_frame.width,
input_frame.height,
1) < 0) {
return std::unexpected("failed to map input frame into FFmpeg image arrays");
}
sws_scale(
scaler_,
input_frame.data,
input_frame.linesize,
0,
input_frame.height,
frame_->data,
frame_->linesize);
if (!first_source_timestamp_ns_) {
first_source_timestamp_ns_ = frame.source_timestamp_ns;
}
frame_->pts = static_cast<std::int64_t>(frame.source_timestamp_ns - *first_source_timestamp_ns_);
const auto send_result = avcodec_send_frame(context_, frame_);
if (send_result < 0) {
return std::unexpected("failed to send frame to FFmpeg encoder: " + av_error_string(send_result));
}
return {};
}
[[nodiscard]]
std::expected<std::vector<EncodedAccessUnit>, std::string> drain() override {
return drain_packets();
}
[[nodiscard]]
std::expected<std::vector<EncodedAccessUnit>, std::string> flush() override {
if (context_ == nullptr) {
return std::vector<EncodedAccessUnit>{};
}
const auto flush_result = avcodec_send_frame(context_, nullptr);
if (flush_result < 0 && flush_result != AVERROR_EOF) {
return std::unexpected("failed to flush FFmpeg encoder: " + av_error_string(flush_result));
}
return drain_packets();
}
void shutdown() override {
if (bsf_context_ != nullptr) {
av_bsf_free(&bsf_context_);
}
if (filtered_packet_ != nullptr) {
av_packet_free(&filtered_packet_);
}
if (packet_ != nullptr) {
av_packet_free(&packet_);
}
if (frame_ != nullptr) {
av_frame_free(&frame_);
}
if (context_ != nullptr) {
avcodec_free_context(&context_);
}
if (scaler_ != nullptr) {
sws_freeContext(scaler_);
scaler_ = nullptr;
}
first_source_timestamp_ns_.reset();
using_hardware_ = false;
}
private:
[[nodiscard]]
static std::string av_error_string(int error_code) {
char buffer[AV_ERROR_MAX_STRING_SIZE]{};
av_strerror(error_code, buffer, sizeof(buffer));
return std::string(buffer);
}
[[nodiscard]]
static std::expected<AVPixelFormat, std::string> to_av_pixel_format(ipc::PixelFormat format) {
switch (format) {
case ipc::PixelFormat::BGR:
return AV_PIX_FMT_BGR24;
case ipc::PixelFormat::RGB:
return AV_PIX_FMT_RGB24;
case ipc::PixelFormat::BGRA:
return AV_PIX_FMT_BGRA;
case ipc::PixelFormat::RGBA:
return AV_PIX_FMT_RGBA;
case ipc::PixelFormat::GRAY:
return AV_PIX_FMT_GRAY8;
default:
return std::unexpected("unsupported raw pixel format for FFmpeg backend (supported: BGR/RGB/BGRA/RGBA/GRAY)");
}
}
[[nodiscard]]
static AVPixelFormat pick_encoder_pixel_format(EncoderDeviceType device) {
if (device == EncoderDeviceType::Software) {
return AV_PIX_FMT_YUV420P;
}
return AV_PIX_FMT_NV12;
}
[[nodiscard]]
static std::string_view device_to_string(EncoderDeviceType device) {
switch (device) {
case EncoderDeviceType::Auto:
return "auto";
case EncoderDeviceType::Nvidia:
return "nvidia";
case EncoderDeviceType::Software:
return "software";
}
return "unknown";
}
[[nodiscard]]
std::expected<std::string, std::string> pick_encoder_name(const RuntimeConfig &config) const {
const bool prefer_hardware = config.encoder.device != EncoderDeviceType::Software;
const bool prefer_software = config.encoder.device == EncoderDeviceType::Software;
if (codec_ == CodecType::H265) {
if (prefer_hardware && avcodec_find_encoder_by_name("hevc_nvenc") != nullptr) {
return std::string("hevc_nvenc");
}
if (!prefer_hardware || config.encoder.device == EncoderDeviceType::Auto) {
if (avcodec_find_encoder_by_name("libx265") != nullptr) {
return std::string("libx265");
}
}
if (!prefer_software && avcodec_find_encoder_by_name("hevc_nvenc") != nullptr) {
return std::string("hevc_nvenc");
}
return std::unexpected("no usable FFmpeg encoder found for h265 (looked for hevc_nvenc, libx265)");
}
if (prefer_hardware && avcodec_find_encoder_by_name("h264_nvenc") != nullptr) {
return std::string("h264_nvenc");
}
if (!prefer_hardware || config.encoder.device == EncoderDeviceType::Auto) {
if (avcodec_find_encoder_by_name("libx264") != nullptr) {
return std::string("libx264");
}
}
if (!prefer_software && avcodec_find_encoder_by_name("h264_nvenc") != nullptr) {
return std::string("h264_nvenc");
}
return std::unexpected("no usable FFmpeg encoder found for h264 (looked for h264_nvenc, libx264)");
}
[[nodiscard]]
std::expected<void, std::string> configure_codec(std::string_view encoder_name, const RuntimeConfig &config) {
av_opt_set(context_->priv_data, "preset", encoder_name.find("nvenc") != std::string_view::npos ? "llhq" : "veryfast", 0);
if (encoder_name.find("nvenc") != std::string_view::npos) {
av_opt_set(context_->priv_data, "tune", "ull", 0);
av_opt_set(context_->priv_data, "zerolatency", "1", 0);
av_opt_set(context_->priv_data, "rc-lookahead", "0", 0);
} else {
av_opt_set(context_->priv_data, "tune", "zerolatency", 0);
if (encoder_name == "libx265") {
av_opt_set(context_->priv_data, "x265-params", "repeat-headers=1:scenecut=0", 0);
}
}
av_opt_set_int(context_->priv_data, "forced-idr", config.latency.force_idr_on_reset ? 1 : 0, 0);
return {};
}
[[nodiscard]]
std::expected<void, std::string> create_bitstream_filter() {
const char *filter_name = codec_ == CodecType::H265 ? "hevc_mp4toannexb" : "h264_mp4toannexb";
const auto *filter = av_bsf_get_by_name(filter_name);
if (filter == nullptr) {
return std::unexpected(std::string("required FFmpeg bitstream filter '") + filter_name + "' is unavailable");
}
const auto alloc_result = av_bsf_alloc(filter, &bsf_context_);
if (alloc_result < 0) {
return std::unexpected("failed to allocate FFmpeg bitstream filter: " + av_error_string(alloc_result));
}
const auto copy_result = avcodec_parameters_from_context(bsf_context_->par_in, context_);
if (copy_result < 0) {
return std::unexpected("failed to copy codec parameters into bitstream filter: " + av_error_string(copy_result));
}
bsf_context_->time_base_in = context_->time_base;
const auto init_result = av_bsf_init(bsf_context_);
if (init_result < 0) {
return std::unexpected("failed to initialize FFmpeg bitstream filter: " + av_error_string(init_result));
}
return {};
}
[[nodiscard]]
std::expected<std::vector<EncodedAccessUnit>, std::string> drain_packets() {
std::vector<EncodedAccessUnit> access_units{};
while (true) {
const auto receive_result = avcodec_receive_packet(context_, packet_);
if (receive_result == AVERROR(EAGAIN) || receive_result == AVERROR_EOF) {
break;
}
if (receive_result < 0) {
return std::unexpected("failed to receive FFmpeg packet: " + av_error_string(receive_result));
}
const auto bsf_send_result = av_bsf_send_packet(bsf_context_, packet_);
if (bsf_send_result < 0) {
av_packet_unref(packet_);
return std::unexpected("failed to send packet to bitstream filter: " + av_error_string(bsf_send_result));
}
av_packet_unref(packet_);
while (true) {
const auto bsf_receive_result = av_bsf_receive_packet(bsf_context_, filtered_packet_);
if (bsf_receive_result == AVERROR(EAGAIN) || bsf_receive_result == AVERROR_EOF) {
break;
}
if (bsf_receive_result < 0) {
return std::unexpected("failed to receive filtered packet: " + av_error_string(bsf_receive_result));
}
EncodedAccessUnit access_unit{};
access_unit.codec = codec_;
access_unit.stream_pts_ns = filtered_packet_->pts == AV_NOPTS_VALUE ? 0ull : static_cast<std::uint64_t>(filtered_packet_->pts);
access_unit.source_timestamp_ns = first_source_timestamp_ns_.value_or(0ull) + access_unit.stream_pts_ns;
access_unit.keyframe = (filtered_packet_->flags & AV_PKT_FLAG_KEY) != 0;
access_unit.annexb_bytes.assign(filtered_packet_->data, filtered_packet_->data + filtered_packet_->size);
access_units.push_back(std::move(access_unit));
av_packet_unref(filtered_packet_);
}
}
return access_units;
}
const RuntimeConfig *config_{nullptr};
ipc::FrameInfo frame_info_{};
CodecType codec_{CodecType::H264};
AVCodecContext *context_{nullptr};
AVPacket *packet_{nullptr};
AVPacket *filtered_packet_{nullptr};
AVFrame *frame_{nullptr};
SwsContext *scaler_{nullptr};
AVBSFContext *bsf_context_{nullptr};
AVPixelFormat input_pix_fmt_{AV_PIX_FMT_NONE};
AVPixelFormat encoder_pix_fmt_{AV_PIX_FMT_NONE};
std::optional<std::uint64_t> first_source_timestamp_ns_{};
bool using_hardware_{false};
};
}
std::unique_ptr<EncoderBackend> make_ffmpeg_backend() {
return std::make_unique<FfmpegEncoderBackend>();
}
}
+437
View File
@@ -0,0 +1,437 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
#include <array>
#include <cstring>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <spdlog/spdlog.h>
#if __has_include(<gst/app/gstappsink.h>) && __has_include(<gst/app/gstappsrc.h>) && __has_include(<gst/gst.h>)
#define CVMMAP_STREAMER_HAS_GSTREAMER 1
#include <gst/app/gstappsink.h>
#include <gst/app/gstappsrc.h>
#include <gst/gst.h>
#else
#define CVMMAP_STREAMER_HAS_GSTREAMER 0
#endif
namespace cvmmap_streamer::encode {
namespace {
#if CVMMAP_STREAMER_HAS_GSTREAMER
[[nodiscard]]
std::expected<const char *, std::string> pixel_format_to_caps(ipc::PixelFormat format) {
switch (format) {
case ipc::PixelFormat::BGR:
return "BGR";
case ipc::PixelFormat::RGB:
return "RGB";
case ipc::PixelFormat::BGRA:
return "BGRA";
case ipc::PixelFormat::RGBA:
return "RGBA";
case ipc::PixelFormat::GRAY:
return "GRAY8";
default:
return std::unexpected("unsupported raw pixel format for legacy GStreamer backend");
}
}
void ensure_gst_initialized() {
static std::once_flag gst_init_flag;
std::call_once(gst_init_flag, []() {
gst_init(nullptr, nullptr);
spdlog::info("GStreamer initialized: {}", gst_version_string());
});
}
[[nodiscard]]
std::string selected_parser_name(CodecType codec) {
return codec == CodecType::H265 ? "h265parse" : "h264parse";
}
struct EncoderChoice {
std::string encoder_name;
std::string parser_name;
bool is_nvenc{false};
};
[[nodiscard]]
std::vector<std::string_view> encoder_candidates(CodecType codec, bool prefer_nvenc) {
if (codec == CodecType::H265) {
if (prefer_nvenc) {
return {"nvh265enc", "x265enc", "avenc_libx265"};
}
return {"x265enc", "avenc_libx265", "nvh265enc"};
}
if (prefer_nvenc) {
return {"nvh264enc", "x264enc", "openh264enc", "avenc_h264"};
}
return {"x264enc", "openh264enc", "avenc_h264", "nvh264enc"};
}
[[nodiscard]]
std::expected<EncoderChoice, std::string> pick_encoder_choice(CodecType codec, bool prefer_nvenc) {
const std::string parser_name = selected_parser_name(codec);
if (gst_element_factory_find(parser_name.c_str()) == nullptr) {
return std::unexpected("required GStreamer parser element '" + parser_name + "' is unavailable");
}
for (const auto candidate : encoder_candidates(codec, prefer_nvenc)) {
if (gst_element_factory_find(candidate.data()) == nullptr) {
continue;
}
EncoderChoice choice{};
choice.encoder_name = std::string(candidate);
choice.parser_name = parser_name;
choice.is_nvenc = choice.encoder_name.starts_with("nvh");
return choice;
}
return std::unexpected("no usable GStreamer encoder available");
}
[[nodiscard]]
std::string encoder_input_format(const std::string &encoder_name) {
if (encoder_name == "x265enc" || encoder_name == "openh264enc") {
return "I420";
}
return "NV12";
}
[[nodiscard]]
bool has_property(GObject *object, const char *name) {
if (object == nullptr || name == nullptr) {
return false;
}
return g_object_class_find_property(G_OBJECT_GET_CLASS(object), name) != nullptr;
}
[[nodiscard]]
bool set_property_arg_if_exists(GObject *object, const char *name, const std::string &value) {
if (!has_property(object, name)) {
return false;
}
gst_util_set_object_arg(object, name, value.c_str());
return true;
}
class GstreamerLegacyBackend final : public EncoderBackend {
public:
GstreamerLegacyBackend() = default;
~GstreamerLegacyBackend() override {
shutdown();
}
[[nodiscard]]
std::string_view backend_name() const override {
return "gstreamer_legacy";
}
[[nodiscard]]
bool using_hardware() const override {
return using_hardware_;
}
[[nodiscard]]
std::expected<void, std::string> init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info) override {
shutdown();
config_ = &config;
ensure_gst_initialized();
bool prefer_nvenc = config.encoder.device != EncoderDeviceType::Software;
auto encoder_choice = pick_encoder_choice(config.encoder.codec, prefer_nvenc);
if (!encoder_choice && prefer_nvenc && config.encoder.device == EncoderDeviceType::Auto) {
encoder_choice = pick_encoder_choice(config.encoder.codec, false);
}
if (!encoder_choice) {
return std::unexpected(encoder_choice.error());
}
using_hardware_ = encoder_choice->is_nvenc;
active_encoder_name_ = encoder_choice->encoder_name;
active_parser_name_ = encoder_choice->parser_name;
auto pixel_format = pixel_format_to_caps(frame_info.pixel_format);
if (!pixel_format) {
return std::unexpected(pixel_format.error());
}
const std::string codec_caps =
config.encoder.codec == CodecType::H265
? "video/x-h265,stream-format=byte-stream,alignment=au"
: "video/x-h264,stream-format=byte-stream,alignment=au";
const std::string pipeline_desc =
std::string("appsrc name=ingest_src is-live=true format=time do-timestamp=true block=false ") +
"! queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 " +
"! videoconvert " +
"! video/x-raw,format=" + encoder_input_format(active_encoder_name_) + " " +
"! " + active_encoder_name_ + " name=encoder " +
"! " + active_parser_name_ + " name=parser config-interval=-1 disable-passthrough=true " +
"! " + codec_caps + " " +
"! appsink name=encoded_sink emit-signals=false sync=false drop=true max-buffers=1";
GError *error = nullptr;
pipeline_ = gst_parse_launch(pipeline_desc.c_str(), &error);
if (error != nullptr) {
const std::string message = "failed to create GStreamer pipeline: " + std::string(error->message);
g_error_free(error);
return std::unexpected(message);
}
if (pipeline_ == nullptr) {
return std::unexpected("failed to create GStreamer pipeline");
}
appsrc_ = gst_bin_get_by_name(GST_BIN(pipeline_), "ingest_src");
appsink_ = gst_bin_get_by_name(GST_BIN(pipeline_), "encoded_sink");
encoder_ = gst_bin_get_by_name(GST_BIN(pipeline_), "encoder");
if (appsrc_ == nullptr || appsink_ == nullptr || encoder_ == nullptr) {
return std::unexpected("failed to locate GStreamer pipeline elements");
}
const auto caps_string =
"video/x-raw,format=(string)" +
std::string(*pixel_format) +
",width=(int)" +
std::to_string(frame_info.width) +
",height=(int)" +
std::to_string(frame_info.height) +
",framerate=(fraction)30/1";
GstCaps *caps = gst_caps_from_string(caps_string.c_str());
if (caps == nullptr) {
return std::unexpected("failed to create GStreamer caps: " + caps_string);
}
gst_app_src_set_caps(GST_APP_SRC(appsrc_), caps);
gst_caps_unref(caps);
gst_app_src_set_stream_type(GST_APP_SRC(appsrc_), GST_APP_STREAM_TYPE_STREAM);
gst_app_src_set_max_buffers(GST_APP_SRC(appsrc_), 1);
(void)set_property_arg_if_exists(G_OBJECT(appsrc_), "leaky-type", "downstream");
(void)set_property_arg_if_exists(G_OBJECT(appsrc_), "block", "false");
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "bframes", std::to_string(config.encoder.b_frames));
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "rc-lookahead", "0");
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "lookahead", "0");
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "zerolatency", "true");
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "gop-size", std::to_string(config.encoder.gop));
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "iframeinterval", std::to_string(config.encoder.gop));
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "preset", "llhq");
(void)set_property_arg_if_exists(G_OBJECT(encoder_), "tune", "zerolatency");
bus_ = gst_element_get_bus(pipeline_);
if (gst_element_set_state(pipeline_, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
return std::unexpected("failed to set GStreamer pipeline to PLAYING");
}
spdlog::info(
"ENCODER_PATH codec={} mode={} encoder={} backend=gstreamer_legacy",
to_string(config.encoder.codec),
using_hardware_ ? "hardware" : "software",
active_encoder_name_);
return {};
}
[[nodiscard]]
std::expected<void, std::string> poll() override {
if (bus_ == nullptr) {
return {};
}
while (auto *message = gst_bus_pop_filtered(
bus_,
static_cast<GstMessageType>(GST_MESSAGE_ERROR | GST_MESSAGE_EOS | GST_MESSAGE_WARNING))) {
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_WARNING) {
GError *warning = nullptr;
gchar *debug = nullptr;
gst_message_parse_warning(message, &warning, &debug);
spdlog::warn(
"legacy backend warning: {} ({})",
warning != nullptr ? warning->message : "unknown",
debug != nullptr ? debug : "no-debug");
if (warning != nullptr) {
g_error_free(warning);
}
if (debug != nullptr) {
g_free(debug);
}
gst_message_unref(message);
continue;
}
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS) {
gst_message_unref(message);
return std::unexpected("legacy backend reached EOS");
}
GError *error = nullptr;
gchar *debug = nullptr;
gst_message_parse_error(message, &error, &debug);
const std::string message_text =
"legacy backend error: " +
std::string(error != nullptr ? error->message : "unknown") +
" (" +
std::string(debug != nullptr ? debug : "no-debug") +
")";
if (error != nullptr) {
g_error_free(error);
}
if (debug != nullptr) {
g_free(debug);
}
gst_message_unref(message);
return std::unexpected(message_text);
}
return {};
}
[[nodiscard]]
std::expected<void, std::string> push_frame(const RawVideoFrame &frame) override {
if (appsrc_ == nullptr) {
return std::unexpected("legacy backend appsrc is null");
}
auto *buffer = gst_buffer_new_allocate(nullptr, frame.bytes.size(), nullptr);
if (buffer == nullptr) {
return std::unexpected("failed to allocate GStreamer buffer");
}
GstMapInfo map{};
if (!gst_buffer_map(buffer, &map, GST_MAP_WRITE)) {
gst_buffer_unref(buffer);
return std::unexpected("failed to map GStreamer buffer");
}
std::memcpy(map.data, frame.bytes.data(), frame.bytes.size());
gst_buffer_unmap(buffer, &map);
if (!first_source_timestamp_ns_) {
first_source_timestamp_ns_ = frame.source_timestamp_ns;
}
const auto pts_ns =
frame.source_timestamp_ns >= *first_source_timestamp_ns_
? frame.source_timestamp_ns - *first_source_timestamp_ns_
: 0ull;
GST_BUFFER_PTS(buffer) = static_cast<GstClockTime>(pts_ns);
GST_BUFFER_DTS(buffer) = static_cast<GstClockTime>(pts_ns);
const auto flow = gst_app_src_push_buffer(GST_APP_SRC(appsrc_), buffer);
if (flow != GST_FLOW_OK) {
return std::unexpected("legacy backend push failed with flow=" + std::to_string(static_cast<int>(flow)));
}
return {};
}
[[nodiscard]]
std::expected<std::vector<EncodedAccessUnit>, std::string> drain() override {
return pull_samples();
}
[[nodiscard]]
std::expected<std::vector<EncodedAccessUnit>, std::string> flush() override {
if (appsrc_ != nullptr) {
(void)gst_app_src_end_of_stream(GST_APP_SRC(appsrc_));
}
return pull_samples();
}
void shutdown() override {
if (pipeline_ != nullptr) {
gst_element_set_state(pipeline_, GST_STATE_NULL);
}
if (bus_ != nullptr) {
gst_object_unref(bus_);
bus_ = nullptr;
}
if (appsrc_ != nullptr) {
gst_object_unref(appsrc_);
appsrc_ = nullptr;
}
if (appsink_ != nullptr) {
gst_object_unref(appsink_);
appsink_ = nullptr;
}
if (encoder_ != nullptr) {
gst_object_unref(encoder_);
encoder_ = nullptr;
}
if (pipeline_ != nullptr) {
gst_object_unref(pipeline_);
pipeline_ = nullptr;
}
active_encoder_name_.clear();
active_parser_name_.clear();
first_source_timestamp_ns_.reset();
using_hardware_ = false;
}
private:
[[nodiscard]]
std::expected<std::vector<EncodedAccessUnit>, std::string> pull_samples() {
std::vector<EncodedAccessUnit> access_units{};
if (appsink_ == nullptr || config_ == nullptr) {
return access_units;
}
while (auto *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(appsink_), 0)) {
auto *buffer = gst_sample_get_buffer(sample);
if (buffer == nullptr) {
gst_sample_unref(sample);
continue;
}
GstMapInfo map{};
if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) {
gst_sample_unref(sample);
return std::unexpected("failed to map legacy encoded buffer");
}
EncodedAccessUnit access_unit{};
access_unit.codec = config_->encoder.codec;
const auto pts = GST_BUFFER_PTS(buffer);
if (pts != GST_CLOCK_TIME_NONE) {
access_unit.stream_pts_ns = static_cast<std::uint64_t>(pts);
}
access_unit.source_timestamp_ns =
first_source_timestamp_ns_.value_or(0) + access_unit.stream_pts_ns;
access_unit.keyframe = !GST_BUFFER_FLAG_IS_SET(buffer, GST_BUFFER_FLAG_DELTA_UNIT);
access_unit.annexb_bytes.assign(map.data, map.data + map.size);
access_units.push_back(std::move(access_unit));
gst_buffer_unmap(buffer, &map);
gst_sample_unref(sample);
}
return access_units;
}
const RuntimeConfig *config_{nullptr};
GstElement *pipeline_{nullptr};
GstElement *appsrc_{nullptr};
GstElement *appsink_{nullptr};
GstElement *encoder_{nullptr};
GstBus *bus_{nullptr};
std::optional<std::uint64_t> first_source_timestamp_ns_{};
bool using_hardware_{false};
std::string active_encoder_name_{};
std::string active_parser_name_{};
};
#endif
}
std::unique_ptr<EncoderBackend> make_gstreamer_legacy_backend() {
#if CVMMAP_STREAMER_HAS_GSTREAMER
return std::make_unique<GstreamerLegacyBackend>();
#else
return {};
#endif
}
}
+31 -11
View File
@@ -9,17 +9,37 @@ namespace cvmmap_streamer {
namespace {
constexpr std::array<std::string_view, 10> kHelpLines{
"Usage:",
" --help, -h\tshow this message",
"",
"Options:",
" --version\tprint version information",
"",
"Examples:",
" cvmmap_streamer --help",
" cvmmap_streamer --run-mode pipeline --shm-name cvmmap://default --help",
" rtp_receiver_tester --help"};
constexpr std::array<std::string_view, 30> kHelpLines{
"Usage:",
" --help, -h\tshow this message",
"",
"Options:",
" --version\tprint version information",
" --config <path>\tload runtime config from TOML",
" --input-uri <uri>\tcvmmap source URI (example: cvmmap://default)",
" --run-mode <mode>\tpipeline|ingest",
" --codec <codec>\th264|h265",
" --encoder-backend <backend>\tauto|ffmpeg|gstreamer_legacy",
" --encoder-device <device>\tauto|nvidia|software",
" --gop <frames>\tencoder GOP length",
" --b-frames <count>\tencoder B-frame count",
" --rtp\t\tenable RTP output",
" --rtp-endpoint <host:port>\tRTP destination",
" --rtp-payload-type <pt>\tRTP payload type (96-127)",
" --rtp-sdp <path>\twrite SDP sidecar",
" --rtmp\t\tenable RTMP output",
" --rtmp-url <url>\tadd RTMP destination (repeatable)",
" --rtmp-mode <mode>\tenhanced|domestic",
" --mcap\t\tenable MCAP recording",
" --mcap-path <path>\tMCAP output file",
" --mcap-topic <topic>\tMCAP topic name",
" --mcap-frame-id <id>\tFoxglove CompressedVideo frame_id",
" --mcap-compression <mode>\tnone|lz4|zstd",
"",
"Examples:",
" cvmmap_streamer --help",
" cvmmap_streamer --run-mode pipeline --input-uri cvmmap://default --help",
" rtp_receiver_tester --help"};
}
+2 -2
View File
@@ -5,7 +5,7 @@
namespace cvmmap_streamer::core {
int run_ingest_loop(const RuntimeConfig &config);
int run_nvenc_pipeline(const RuntimeConfig &config);
int run_pipeline(const RuntimeConfig &config);
}
@@ -32,7 +32,7 @@ int main(int argc, char **argv) {
switch (config->run_mode) {
case cvmmap_streamer::RunMode::Pipeline:
return cvmmap_streamer::core::run_nvenc_pipeline(*config);
return cvmmap_streamer::core::run_pipeline(*config);
case cvmmap_streamer::RunMode::Ingest:
return cvmmap_streamer::core::run_ingest_loop(*config);
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -744,19 +744,19 @@ std::expected<RtmpPublisher, std::string> RtmpPublisher::create(const RuntimeCon
return std::unexpected("invalid RTMP publisher init: no RTMP URL configured");
}
if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.codec != CodecType::H265) {
if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.encoder.codec != CodecType::H265) {
return std::unexpected(
"invalid mode matrix: --rtmp-mode domestic requires --codec h265 (h264+domestic is unsupported)");
}
spdlog::info(
"RTMP_MODE_SELECTED codec={} mode={} urls={}",
to_string(config.codec),
to_string(config.encoder.codec),
to_string(config.outputs.rtmp.mode),
config.outputs.rtmp.urls.size());
RtmpPublisher publisher{};
publisher.codec_ = config.codec;
publisher.codec_ = config.encoder.codec;
publisher.mode_ = config.outputs.rtmp.mode;
publisher.sessions_.reserve(config.outputs.rtmp.urls.size());
+2 -2
View File
@@ -191,7 +191,7 @@ std::expected<UdpRtpPublisher, std::string> UdpRtpPublisher::create(const Runtim
publisher.destination_host_ = *config.outputs.rtp.host;
publisher.destination_port_ = *config.outputs.rtp.port;
publisher.payload_type_ = config.outputs.rtp.payload_type;
publisher.codec_ = config.codec;
publisher.codec_ = config.encoder.codec;
publisher.sequence_ = compute_initial_sequence();
publisher.ssrc_ = compute_ssrc(
publisher.destination_host_,
@@ -250,7 +250,7 @@ std::expected<UdpRtpPublisher, std::string> UdpRtpPublisher::create(const Runtim
return std::unexpected("RTP socket non-blocking setup failed: " + std::string(std::strerror(errno)));
}
const std::string codec_name = config.codec == CodecType::H265 ? "h265" : "h264";
const std::string codec_name = config.encoder.codec == CodecType::H265 ? "h265" : "h264";
if (config.outputs.rtp.sdp_path && !config.outputs.rtp.sdp_path->empty()) {
publisher.sdp_path_ = *config.outputs.rtp.sdp_path;
} else {
+160
View File
@@ -0,0 +1,160 @@
#define MCAP_IMPLEMENTATION
#include <mcap/writer.hpp>
#include "cvmmap_streamer/record/mcap_record_sink.hpp"
#include "protobuf_descriptor.hpp"
#include "foxglove/CompressedVideo.pb.h"
#include <google/protobuf/timestamp.pb.h>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <memory>
#include <string>
#include <string_view>
#include <utility>
namespace cvmmap_streamer::record {
namespace {
[[nodiscard]]
std::string codec_format(CodecType codec) {
return codec == CodecType::H265 ? "h265" : "h264";
}
[[nodiscard]]
mcap::Compression to_mcap_compression(McapCompression compression) {
switch (compression) {
case McapCompression::Lz4:
return mcap::Compression::Lz4;
case McapCompression::None:
return mcap::Compression::None;
case McapCompression::Zstd:
default:
return mcap::Compression::Zstd;
}
}
[[nodiscard]]
google::protobuf::Timestamp to_proto_timestamp(std::uint64_t timestamp_ns) {
google::protobuf::Timestamp timestamp{};
timestamp.set_seconds(static_cast<std::int64_t>(timestamp_ns / 1000000000ull));
timestamp.set_nanos(static_cast<std::int32_t>(timestamp_ns % 1000000000ull));
return timestamp;
}
}
struct McapRecordSink::State {
mcap::McapWriter writer{};
std::string path{};
std::string frame_id{};
mcap::ChannelId channel_id{0};
std::uint32_t sequence{0};
};
McapRecordSink::~McapRecordSink() {
close();
}
McapRecordSink::McapRecordSink(McapRecordSink &&other) noexcept
: state_(other.state_) {
other.state_ = nullptr;
}
McapRecordSink &McapRecordSink::operator=(McapRecordSink &&other) noexcept {
if (this != &other) {
close();
state_ = other.state_;
other.state_ = nullptr;
}
return *this;
}
std::expected<McapRecordSink, std::string> McapRecordSink::create(const RuntimeConfig &config) {
McapRecordSink sink{};
auto state = std::make_unique<State>();
state->path = config.record.mcap.path;
state->frame_id = config.record.mcap.frame_id;
mcap::McapWriterOptions options("");
options.compression = to_mcap_compression(config.record.mcap.compression);
const auto open_status = state->writer.open(state->path, options);
if (!open_status.ok()) {
return std::unexpected("failed to open MCAP writer at '" + state->path + "': " + open_status.message);
}
const auto descriptor_set = build_file_descriptor_set(foxglove::CompressedVideo::descriptor());
std::string schema_bytes{};
if (!descriptor_set.SerializeToString(&schema_bytes)) {
return std::unexpected("failed to serialize foxglove.CompressedVideo descriptor set");
}
mcap::Schema schema("foxglove.CompressedVideo", "protobuf", schema_bytes);
state->writer.addSchema(schema);
mcap::Channel channel(config.record.mcap.topic, "protobuf", schema.id);
state->writer.addChannel(channel);
state->channel_id = channel.id;
sink.state_ = state.release();
return sink;
}
std::expected<void, std::string> McapRecordSink::write_access_unit(const encode::EncodedAccessUnit &access_unit) {
if (state_ == nullptr) {
return std::unexpected("MCAP sink is not open");
}
foxglove::CompressedVideo message{};
*message.mutable_timestamp() = to_proto_timestamp(access_unit.source_timestamp_ns);
message.set_frame_id(state_->frame_id);
message.set_format(codec_format(access_unit.codec));
message.set_data(
reinterpret_cast<const char *>(access_unit.annexb_bytes.data()),
static_cast<int>(access_unit.annexb_bytes.size()));
std::string serialized{};
if (!message.SerializeToString(&serialized)) {
return std::unexpected("failed to serialize foxglove.CompressedVideo");
}
mcap::Message record{};
record.channelId = state_->channel_id;
record.sequence = state_->sequence++;
record.logTime = access_unit.source_timestamp_ns;
record.publishTime = access_unit.source_timestamp_ns;
record.data = reinterpret_cast<const std::byte *>(serialized.data());
record.dataSize = serialized.size();
const auto write_status = state_->writer.write(record);
if (!write_status.ok()) {
return std::unexpected("failed to write MCAP message: " + write_status.message);
}
return {};
}
bool McapRecordSink::is_open() const {
return state_ != nullptr;
}
std::string_view McapRecordSink::path() const {
if (state_ == nullptr) {
return {};
}
return state_->path;
}
void McapRecordSink::close() {
if (state_ == nullptr) {
return;
}
state_->writer.close();
delete state_;
state_ = nullptr;
}
}
+38
View File
@@ -0,0 +1,38 @@
#include "protobuf_descriptor.hpp"
#include <queue>
#include <string>
#include <unordered_set>
namespace cvmmap_streamer::record {
google::protobuf::FileDescriptorSet build_file_descriptor_set(const google::protobuf::Descriptor *descriptor) {
google::protobuf::FileDescriptorSet descriptor_set;
if (descriptor == nullptr) {
return descriptor_set;
}
std::queue<const google::protobuf::FileDescriptor *> pending{};
std::unordered_set<std::string> seen{};
pending.push(descriptor->file());
seen.insert(std::string(descriptor->file()->name()));
while (!pending.empty()) {
const auto *next = pending.front();
pending.pop();
next->CopyTo(descriptor_set.add_file());
for (int index = 0; index < next->dependency_count(); ++index) {
const auto *dependency = next->dependency(index);
if (dependency == nullptr) {
continue;
}
if (seen.insert(std::string(dependency->name())).second) {
pending.push(dependency);
}
}
}
return descriptor_set;
}
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <google/protobuf/descriptor.h>
#include <google/protobuf/descriptor.pb.h>
namespace cvmmap_streamer::record {
google::protobuf::FileDescriptorSet build_file_descriptor_set(const google::protobuf::Descriptor *descriptor);
}
+118
View File
@@ -0,0 +1,118 @@
#define MCAP_IMPLEMENTATION
#include <mcap/reader.hpp>
#include <foxglove/CompressedVideo.pb.h>
#include <CLI/CLI.hpp>
#include <cstdint>
#include <expected>
#include <iostream>
#include <optional>
#include <string>
#include <spdlog/spdlog.h>
namespace {
struct Config {
std::string input_path{};
std::optional<std::string> expected_topic{};
std::optional<std::string> expected_format{};
std::uint32_t min_messages{1};
};
[[nodiscard]]
std::expected<Config, int> parse_args(int argc, char **argv) {
Config config{};
CLI::App app{"mcap_reader_tester - validate foxglove.CompressedVideo MCAP output"};
app.add_option("input", config.input_path, "Input MCAP path")->required();
app.add_option("--expect-topic", config.expected_topic, "Expected MCAP topic");
app.add_option("--expect-format", config.expected_format, "Expected CompressedVideo format");
app.add_option("--min-messages", config.min_messages, "Minimum expected message count")->check(CLI::PositiveNumber);
try {
app.parse(argc, argv);
} catch (const CLI::ParseError &e) {
return std::unexpected(app.exit(e));
}
return config;
}
}
int main(int argc, char **argv) {
auto config = parse_args(argc, argv);
if (!config) {
return config.error();
}
mcap::McapReader reader{};
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;
}
std::uint64_t message_count{0};
std::uint64_t previous_log_time{0};
bool saw_log_time{false};
auto message_view = reader.readMessages();
for (auto it = message_view.begin(); it != message_view.end(); ++it) {
if (it->schema == nullptr || it->channel == nullptr) {
spdlog::error("MCAP message missing schema or channel metadata");
reader.close();
return 3;
}
if (it->schema->encoding != "protobuf" || it->schema->name != "foxglove.CompressedVideo") {
continue;
}
if (it->channel->messageEncoding != "protobuf") {
spdlog::error("unexpected MCAP message encoding: {}", it->channel->messageEncoding);
reader.close();
return 3;
}
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;
}
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;
}
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;
}
if (config->expected_format && message.format() != *config->expected_format) {
spdlog::error("unexpected format: expected '{}' got '{}'", *config->expected_format, message.format());
reader.close();
return 7;
}
if (message.data().empty()) {
spdlog::error("compressed video payload is empty");
reader.close();
return 8;
}
previous_log_time = it->message.logTime;
saw_log_time = true;
message_count += 1;
}
reader.close();
if (message_count < config->min_messages) {
spdlog::error("message threshold not met: {} < {}", message_count, config->min_messages);
return 9;
}
spdlog::info("validated {} foxglove.CompressedVideo MCAP messages", message_count);
return 0;
}