feat(streamer): add ffmpeg encoder and mcap recording
This commit is contained in:
+799
-488
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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"};
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+303
-898
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user