Files
cvmmap-streamer/src/protocol/rtmp_publisher.cpp
T
crosstyan 56e874ab6d feat(downstream): add cvmmap downstream runtime implementation
This commit introduces the full downstream runtime implementation needed to ingest, transform, and publish streams.

It preserves the original upstream request boundary by packaging the entire cvmmap-streamer module (build config, public API, protocol and IPC glue, and simulator/tester entrypoints) in one coherent core unit.

Keeping this group isolated enables reviewers to validate runtime behavior and correctness without mixing test evidence or process documentation changes.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-05 20:31:58 +08:00

1030 lines
29 KiB
C++

#include "cvmmap_streamer/protocol/rtmp_publisher.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <format>
#include <netdb.h>
#include <span>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <utility>
#include <vector>
#include <spdlog/spdlog.h>
namespace cvmmap_streamer::protocol {
namespace {
constexpr std::uint8_t kRtmpVersion = 3;
constexpr std::size_t kRtmpHandshakePartLength = 1536;
constexpr std::uint32_t kDefaultChunkSize = 128;
constexpr std::uint32_t kReconnectBackoffInitialMs = 250;
constexpr std::uint32_t kReconnectBackoffMaxMs = 8'000;
constexpr std::uint8_t kMsgAmf0Command = 20;
constexpr std::uint8_t kMsgVideo = 9;
constexpr std::uint32_t kChunkStreamCommand = 3;
constexpr std::uint32_t kChunkStreamVideo = 6;
constexpr std::uint64_t kErrorLogFirstPackets = 8;
constexpr std::uint64_t kErrorLogEveryNPackets = 120;
struct ParsedRtmpUrl {
std::string host{};
std::uint16_t port{1935};
std::string app{};
std::string stream{};
std::string tc_url{};
};
[[nodiscard]]
std::expected<void, std::string> send_all(int fd, std::span<const std::uint8_t> data) {
std::size_t written = 0;
while (written < data.size()) {
const auto n = send(
fd,
reinterpret_cast<const void *>(data.data() + written),
data.size() - written,
MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return std::unexpected(std::format("send failed: {}", std::strerror(errno)));
}
if (n == 0) {
return std::unexpected("send returned 0 (peer closed)");
}
written += static_cast<std::size_t>(n);
}
return {};
}
[[nodiscard]]
std::expected<void, std::string> recv_exact(int fd, std::span<std::uint8_t> data) {
std::size_t read = 0;
while (read < data.size()) {
const auto n = recv(
fd,
reinterpret_cast<void *>(data.data() + read),
data.size() - read,
0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return std::unexpected(std::format("recv failed: {}", std::strerror(errno)));
}
if (n == 0) {
return std::unexpected("peer closed while receiving");
}
read += static_cast<std::size_t>(n);
}
return {};
}
void append_be24(std::vector<std::uint8_t> &out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xffu));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xffu));
out.push_back(static_cast<std::uint8_t>(v & 0xffu));
}
void append_be32(std::vector<std::uint8_t> &out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xffu));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xffu));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xffu));
out.push_back(static_cast<std::uint8_t>(v & 0xffu));
}
void append_le32(std::vector<std::uint8_t> &out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xffu));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xffu));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xffu));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xffu));
}
void append_basic_header(std::vector<std::uint8_t> &out, std::uint8_t fmt, std::uint32_t csid) {
if (csid <= 63) {
out.push_back(static_cast<std::uint8_t>((fmt << 6) | csid));
return;
}
if (csid <= 319) {
out.push_back(static_cast<std::uint8_t>((fmt << 6) | 0));
out.push_back(static_cast<std::uint8_t>(csid - 64));
return;
}
out.push_back(static_cast<std::uint8_t>((fmt << 6) | 1));
const std::uint32_t normalized = csid - 64;
out.push_back(static_cast<std::uint8_t>(normalized & 0xff));
out.push_back(static_cast<std::uint8_t>((normalized >> 8) & 0xff));
}
[[nodiscard]]
std::expected<std::size_t, std::string> send_rtmp_message(
int fd,
std::uint32_t chunk_stream_id,
std::uint32_t timestamp,
std::uint8_t type_id,
std::uint32_t message_stream_id,
std::span<const std::uint8_t> payload,
std::uint32_t chunk_size) {
if (chunk_size == 0) {
return std::unexpected("invalid RTMP chunk size 0");
}
std::vector<std::uint8_t> packet;
packet.reserve(payload.size() + 64);
const std::uint32_t timestamp_field = std::min(timestamp, 0x00ffffffu);
append_basic_header(packet, 0, chunk_stream_id);
append_be24(packet, timestamp_field);
append_be24(packet, static_cast<std::uint32_t>(payload.size()));
packet.push_back(type_id);
append_le32(packet, message_stream_id);
if (timestamp >= 0x00ffffffu) {
append_be32(packet, timestamp);
}
std::size_t offset = 0;
const std::size_t first_chunk = std::min<std::size_t>(chunk_size, payload.size());
packet.insert(packet.end(), payload.begin(), payload.begin() + static_cast<std::ptrdiff_t>(first_chunk));
offset += first_chunk;
while (offset < payload.size()) {
append_basic_header(packet, 3, chunk_stream_id);
if (timestamp >= 0x00ffffffu) {
append_be32(packet, timestamp);
}
const std::size_t part = std::min<std::size_t>(chunk_size, payload.size() - offset);
packet.insert(
packet.end(),
payload.begin() + static_cast<std::ptrdiff_t>(offset),
payload.begin() + static_cast<std::ptrdiff_t>(offset + part));
offset += part;
}
auto send_result = send_all(fd, packet);
if (!send_result) {
return std::unexpected(send_result.error());
}
return packet.size();
}
void append_amf0_string(std::vector<std::uint8_t> &out, std::string_view value) {
out.push_back(0x02);
out.push_back(static_cast<std::uint8_t>((value.size() >> 8) & 0xff));
out.push_back(static_cast<std::uint8_t>(value.size() & 0xff));
out.insert(out.end(), value.begin(), value.end());
}
void append_amf0_number(std::vector<std::uint8_t> &out, double value) {
out.push_back(0x00);
std::uint64_t bits{0};
std::memcpy(&bits, &value, sizeof(bits));
out.push_back(static_cast<std::uint8_t>((bits >> 56) & 0xff));
out.push_back(static_cast<std::uint8_t>((bits >> 48) & 0xff));
out.push_back(static_cast<std::uint8_t>((bits >> 40) & 0xff));
out.push_back(static_cast<std::uint8_t>((bits >> 32) & 0xff));
out.push_back(static_cast<std::uint8_t>((bits >> 24) & 0xff));
out.push_back(static_cast<std::uint8_t>((bits >> 16) & 0xff));
out.push_back(static_cast<std::uint8_t>((bits >> 8) & 0xff));
out.push_back(static_cast<std::uint8_t>(bits & 0xff));
}
void append_amf0_null(std::vector<std::uint8_t> &out) {
out.push_back(0x05);
}
void append_amf0_object_start(std::vector<std::uint8_t> &out) {
out.push_back(0x03);
}
void append_amf0_object_end(std::vector<std::uint8_t> &out) {
out.push_back(0x00);
out.push_back(0x00);
out.push_back(0x09);
}
void append_amf0_object_key(std::vector<std::uint8_t> &out, std::string_view key) {
out.push_back(static_cast<std::uint8_t>((key.size() >> 8) & 0xff));
out.push_back(static_cast<std::uint8_t>(key.size() & 0xff));
out.insert(out.end(), key.begin(), key.end());
}
void append_amf0_object_string_property(
std::vector<std::uint8_t> &out,
std::string_view key,
std::string_view value) {
append_amf0_object_key(out, key);
append_amf0_string(out, value);
}
void append_amf0_object_number_property(std::vector<std::uint8_t> &out, std::string_view key, double value) {
append_amf0_object_key(out, key);
append_amf0_number(out, value);
}
[[nodiscard]]
std::expected<ParsedRtmpUrl, std::string> parse_rtmp_url(std::string_view url) {
constexpr std::string_view kPrefix{"rtmp://"};
if (!url.starts_with(kPrefix)) {
return std::unexpected(std::format(
"invalid RTMP url '{}': must start with rtmp://",
url));
}
const auto raw = url.substr(kPrefix.size());
const auto slash = raw.find('/');
if (slash == std::string_view::npos || slash == 0 || slash + 1 >= raw.size()) {
return std::unexpected(std::format(
"invalid RTMP url '{}': expected rtmp://<host>[:port]/<app>/<stream>",
url));
}
ParsedRtmpUrl parsed{};
const auto host_port = raw.substr(0, slash);
const auto path = raw.substr(slash + 1);
const auto colon = host_port.rfind(':');
if (colon != std::string_view::npos && colon + 1 < host_port.size()) {
parsed.host = std::string(host_port.substr(0, colon));
std::uint16_t port{0};
const auto port_raw = host_port.substr(colon + 1);
auto [ptr, ec] = std::from_chars(
port_raw.data(),
port_raw.data() + port_raw.size(),
port,
10);
if (ec != std::errc{} || ptr != port_raw.data() + port_raw.size() || port == 0) {
return std::unexpected(std::format(
"invalid RTMP url '{}': invalid port '{}'",
url,
port_raw));
}
parsed.port = port;
} else {
parsed.host = std::string(host_port);
}
if (parsed.host.empty()) {
return std::unexpected(std::format("invalid RTMP url '{}': host must not be empty", url));
}
const auto app_sep = path.find('/');
if (app_sep == std::string_view::npos || app_sep == 0 || app_sep + 1 >= path.size()) {
return std::unexpected(std::format(
"invalid RTMP url '{}': expected /<app>/<stream>",
url));
}
parsed.app = std::string(path.substr(0, app_sep));
parsed.stream = std::string(path.substr(app_sep + 1));
if (parsed.app.empty() || parsed.stream.empty()) {
return std::unexpected(std::format("invalid RTMP url '{}': app/stream must not be empty", url));
}
parsed.tc_url = std::format(
"rtmp://{}:{}/{}",
parsed.host,
parsed.port,
parsed.app);
return parsed;
}
[[nodiscard]]
std::expected<int, std::string> open_tcp(std::string_view host, std::uint16_t port) {
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo *result = nullptr;
const auto port_text = std::to_string(port);
const int gai = getaddrinfo(
std::string(host).c_str(),
port_text.c_str(),
&hints,
&result);
if (gai != 0) {
return std::unexpected(std::format(
"getaddrinfo failed for '{}:{}': {}",
host,
port,
gai_strerror(gai)));
}
int fd{-1};
for (auto *it = result; it != nullptr; it = it->ai_next) {
fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol);
if (fd < 0) {
continue;
}
if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) {
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(result);
if (fd < 0) {
return std::unexpected(std::format(
"connect failed for '{}:{}': {}",
host,
port,
std::strerror(errno)));
}
return fd;
}
[[nodiscard]]
std::expected<void, std::string> send_connect_message(int fd, std::uint32_t chunk_size, const ParsedRtmpUrl &url) {
std::vector<std::uint8_t> payload;
payload.reserve(256);
append_amf0_string(payload, "connect");
append_amf0_number(payload, 1.0);
append_amf0_object_start(payload);
append_amf0_object_string_property(payload, "app", url.app);
append_amf0_object_string_property(payload, "tcUrl", url.tc_url);
append_amf0_object_number_property(payload, "objectEncoding", 0.0);
append_amf0_object_end(payload);
auto sent = send_rtmp_message(
fd,
kChunkStreamCommand,
0,
kMsgAmf0Command,
0,
payload,
chunk_size);
if (!sent) {
return std::unexpected(sent.error());
}
return {};
}
[[nodiscard]]
std::expected<void, std::string> send_create_stream_message(int fd, std::uint32_t chunk_size) {
std::vector<std::uint8_t> payload;
payload.reserve(64);
append_amf0_string(payload, "createStream");
append_amf0_number(payload, 2.0);
append_amf0_null(payload);
auto sent = send_rtmp_message(
fd,
kChunkStreamCommand,
0,
kMsgAmf0Command,
0,
payload,
chunk_size);
if (!sent) {
return std::unexpected(sent.error());
}
return {};
}
[[nodiscard]]
std::expected<void, std::string> send_publish_message(
int fd,
std::uint32_t chunk_size,
std::uint32_t stream_id,
std::string_view stream_name) {
std::vector<std::uint8_t> payload;
payload.reserve(128);
append_amf0_string(payload, "publish");
append_amf0_number(payload, 3.0);
append_amf0_null(payload);
append_amf0_string(payload, stream_name);
append_amf0_string(payload, "live");
auto sent = send_rtmp_message(
fd,
kChunkStreamCommand,
0,
kMsgAmf0Command,
stream_id,
payload,
chunk_size);
if (!sent) {
return std::unexpected(sent.error());
}
return {};
}
[[nodiscard]]
std::expected<void, std::string> run_handshake(int fd) {
std::array<std::uint8_t, 1 + kRtmpHandshakePartLength> c0c1{};
c0c1[0] = kRtmpVersion;
for (std::size_t i = 0; i < kRtmpHandshakePartLength; ++i) {
c0c1[1 + i] = static_cast<std::uint8_t>((i * 17) & 0xff);
}
auto c0c1_send = send_all(fd, c0c1);
if (!c0c1_send) {
return std::unexpected(std::format("write C0+C1 failed: {}", c0c1_send.error()));
}
std::array<std::uint8_t, 1 + kRtmpHandshakePartLength + kRtmpHandshakePartLength> s0s1s2{};
auto s0s1s2_read = recv_exact(fd, s0s1s2);
if (!s0s1s2_read) {
return std::unexpected(std::format("read S0+S1+S2 failed: {}", s0s1s2_read.error()));
}
if (s0s1s2[0] != kRtmpVersion) {
return std::unexpected(std::format(
"unexpected S0 RTMP version {} (expected {})",
static_cast<unsigned>(s0s1s2[0]),
static_cast<unsigned>(kRtmpVersion)));
}
std::array<std::uint8_t, kRtmpHandshakePartLength> c2{};
std::copy_n(s0s1s2.begin() + 1, kRtmpHandshakePartLength, c2.begin());
auto c2_send = send_all(fd, c2);
if (!c2_send) {
return std::unexpected(std::format("write C2 failed: {}", c2_send.error()));
}
return {};
}
[[nodiscard]]
bool h264_idr_access_unit(std::span<const std::uint8_t> access_unit) {
if (access_unit.empty()) {
return false;
}
for (std::size_t i = 0; i < access_unit.size(); ++i) {
if (i + 4 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x00 && access_unit[i + 3] == 0x01) {
const std::size_t nal = i + 4;
if (nal < access_unit.size()) {
return (access_unit[nal] & 0x1fu) == 5u;
}
}
if (i + 3 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x01) {
const std::size_t nal = i + 3;
if (nal < access_unit.size()) {
return (access_unit[nal] & 0x1fu) == 5u;
}
}
}
return (access_unit[0] & 0x1fu) == 5u;
}
[[nodiscard]]
std::vector<std::uint8_t> make_h264_sequence_header() {
return {
0x17,
0x00,
0x00,
0x00,
0x00,
0x01,
0x64,
0x00,
0x1f,
0xff,
0xe1,
0x00,
0x04,
0x67,
0x64,
0x00,
0x1f,
};
}
[[nodiscard]]
std::vector<std::uint8_t> make_h264_video_payload(std::span<const std::uint8_t> access_unit) {
const bool is_idr = h264_idr_access_unit(access_unit);
std::vector<std::uint8_t> payload;
payload.reserve(5 + access_unit.size());
payload.push_back(is_idr ? 0x17 : 0x27);
payload.push_back(0x01);
payload.push_back(0x00);
payload.push_back(0x00);
payload.push_back(0x00);
payload.insert(payload.end(), access_unit.begin(), access_unit.end());
return payload;
}
[[nodiscard]]
std::vector<std::uint8_t> make_h265_enhanced_sequence_header() {
return {
0x90,
'h',
'v',
'c',
'1',
0x01,
0x01,
0x60,
};
}
[[nodiscard]]
std::vector<std::uint8_t> make_h265_domestic_sequence_header() {
return {
0x1c,
0x00,
0x00,
0x00,
0x00,
0x01,
0x01,
0x60,
};
}
[[nodiscard]]
std::vector<std::uint8_t> make_h265_enhanced_video_payload(std::span<const std::uint8_t> access_unit) {
std::vector<std::uint8_t> payload;
payload.reserve(9 + access_unit.size());
payload.push_back(0x91);
payload.push_back('h');
payload.push_back('v');
payload.push_back('c');
payload.push_back('1');
payload.push_back(0x00);
payload.push_back(0x00);
payload.push_back(0x00);
payload.push_back(0x00);
payload.insert(payload.end(), access_unit.begin(), access_unit.end());
return payload;
}
[[nodiscard]]
bool h265_idr_access_unit(std::span<const std::uint8_t> access_unit) {
if (access_unit.size() < 2) {
return false;
}
for (std::size_t i = 0; i < access_unit.size(); ++i) {
std::size_t nal = access_unit.size();
if (i + 4 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x00 && access_unit[i + 3] == 0x01) {
nal = i + 4;
} else if (i + 3 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x01) {
nal = i + 3;
}
if (nal + 1 >= access_unit.size()) {
continue;
}
const std::uint8_t nal_type = static_cast<std::uint8_t>((access_unit[nal] >> 1) & 0x3fu);
if (nal_type == 19u || nal_type == 20u || nal_type == 21u) {
return true;
}
}
const std::uint8_t nal_type = static_cast<std::uint8_t>((access_unit[0] >> 1) & 0x3fu);
return nal_type == 19u || nal_type == 20u || nal_type == 21u;
}
[[nodiscard]]
std::vector<std::uint8_t> make_h265_domestic_video_payload(std::span<const std::uint8_t> access_unit) {
const bool is_idr = h265_idr_access_unit(access_unit);
std::vector<std::uint8_t> payload;
payload.reserve(5 + access_unit.size());
payload.push_back(is_idr ? 0x1c : 0x2c);
payload.push_back(0x01);
payload.push_back(0x00);
payload.push_back(0x00);
payload.push_back(0x00);
payload.insert(payload.end(), access_unit.begin(), access_unit.end());
return payload;
}
[[nodiscard]]
std::uint32_t to_rtmp_timestamp_ms(std::uint64_t pts_ns) {
return static_cast<std::uint32_t>((pts_ns / 1'000'000ull) & 0xffffffffu);
}
}
RtmpPublisher::~RtmpPublisher() {
for (auto &session : sessions_) {
if (session.socket_fd >= 0) {
close(session.socket_fd);
session.socket_fd = -1;
}
}
}
RtmpPublisher::RtmpPublisher(RtmpPublisher &&other) noexcept {
codec_ = std::exchange(other.codec_, CodecType::H264);
mode_ = std::exchange(other.mode_, RtmpMode::Enhanced);
sessions_ = std::move(other.sessions_);
stats_ = other.stats_;
}
RtmpPublisher &RtmpPublisher::operator=(RtmpPublisher &&other) noexcept {
if (this == &other) {
return *this;
}
for (auto &session : sessions_) {
if (session.socket_fd >= 0) {
close(session.socket_fd);
session.socket_fd = -1;
}
}
codec_ = std::exchange(other.codec_, CodecType::H264);
mode_ = std::exchange(other.mode_, RtmpMode::Enhanced);
sessions_ = std::move(other.sessions_);
stats_ = other.stats_;
return *this;
}
void RtmpPublisher::close_session(Session &session) {
if (session.socket_fd >= 0) {
close(session.socket_fd);
session.socket_fd = -1;
}
}
std::expected<void, std::string> RtmpPublisher::connect_session(Session &session) {
auto fd = open_tcp(session.host, session.port);
if (!fd) {
return std::unexpected(std::format("RTMP connect failed: {}", fd.error()));
}
auto handshake = run_handshake(*fd);
if (!handshake) {
close(*fd);
return std::unexpected(std::format("RTMP handshake failed: {}", handshake.error()));
}
ParsedRtmpUrl parsed{};
parsed.host = session.host;
parsed.port = session.port;
parsed.app = session.app;
parsed.stream = session.stream;
parsed.tc_url = session.tc_url;
auto connect_message = send_connect_message(*fd, kDefaultChunkSize, parsed);
if (!connect_message) {
close(*fd);
return std::unexpected(std::format("RTMP connect-command failed: {}", connect_message.error()));
}
auto create_stream = send_create_stream_message(*fd, kDefaultChunkSize);
if (!create_stream) {
close(*fd);
return std::unexpected(std::format("RTMP createStream failed: {}", create_stream.error()));
}
auto publish_message = send_publish_message(*fd, kDefaultChunkSize, 1, session.stream);
if (!publish_message) {
close(*fd);
return std::unexpected(std::format("RTMP publish-command failed: {}", publish_message.error()));
}
close_session(session);
session.socket_fd = *fd;
session.out_chunk_size = kDefaultChunkSize;
session.stream_id = 1;
session.sequence_header_sent = false;
session.consecutive_reconnect_failures = 0;
session.reconnect_backoff_ms = kReconnectBackoffInitialMs;
session.in_cooldown = false;
return {};
}
void RtmpPublisher::schedule_reconnect(Session &session, std::string_view reason, bool startup_path) {
close_session(session);
session.sequence_header_sent = false;
const auto now = std::chrono::steady_clock::now();
if (startup_path) {
session.reconnect_due_at = now;
session.in_cooldown = false;
} else {
session.reconnect_due_at = now + std::chrono::milliseconds(session.reconnect_backoff_ms);
session.in_cooldown = true;
}
spdlog::warn(
"RTMP_SESSION_RECONNECT_SCHEDULED codec={} mode={} url={} reason='{}' cooldown_ms={} failures={}",
to_string(codec_),
to_string(mode_),
session.original_url,
reason,
startup_path ? 0u : session.reconnect_backoff_ms,
session.consecutive_reconnect_failures);
}
std::expected<RtmpPublisher, std::string> RtmpPublisher::create(const RuntimeConfig &config) {
if (!config.outputs.rtmp.enabled) {
return std::unexpected("invalid RTMP publisher init: RTMP output disabled");
}
if (config.outputs.rtmp.urls.empty()) {
return std::unexpected("invalid RTMP publisher init: no RTMP URL configured");
}
if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.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.outputs.rtmp.mode),
config.outputs.rtmp.urls.size());
RtmpPublisher publisher{};
publisher.codec_ = config.codec;
publisher.mode_ = config.outputs.rtmp.mode;
publisher.sessions_.reserve(config.outputs.rtmp.urls.size());
for (const auto &url : config.outputs.rtmp.urls) {
auto parsed = parse_rtmp_url(url);
if (!parsed) {
return std::unexpected(parsed.error());
}
Session session{};
session.original_url = url;
session.host = parsed->host;
session.port = parsed->port;
session.app = parsed->app;
session.stream = parsed->stream;
session.tc_url = parsed->tc_url;
session.reconnect_backoff_ms = kReconnectBackoffInitialMs;
session.reconnect_due_at = std::chrono::steady_clock::now();
auto connect_result = publisher.connect_session(session);
if (!connect_result) {
return std::unexpected(std::format(
"RTMP initial session setup failed for '{}': {}",
url,
connect_result.error()));
}
publisher.sessions_.push_back(std::move(session));
spdlog::info(
"RTMP_SESSION_READY codec={} mode={} url={} app={} stream={}",
to_string(publisher.codec_),
to_string(publisher.mode_),
url,
parsed->app,
parsed->stream);
}
return publisher;
}
std::expected<void, std::string>
RtmpPublisher::publish_access_unit(std::span<const std::uint8_t> access_unit, std::uint64_t pts_ns) {
stats_.access_units += 1;
stats_.access_unit_bytes += access_unit.size();
const auto now = std::chrono::steady_clock::now();
std::size_t connected_sessions{0};
for (auto &session : sessions_) {
if (session.socket_fd >= 0) {
connected_sessions += 1;
continue;
}
if (session.in_cooldown && now < session.reconnect_due_at) {
continue;
}
stats_.reconnect_attempts += 1;
spdlog::info(
"RTMP_SESSION_RECONNECT_ATTEMPT codec={} mode={} url={} attempt={} failures={} cooldown_elapsed_ms={}",
to_string(codec_),
to_string(mode_),
session.original_url,
stats_.reconnect_attempts,
session.consecutive_reconnect_failures,
session.in_cooldown ? session.reconnect_backoff_ms : 0u);
auto reconnect = connect_session(session);
if (!reconnect) {
stats_.reconnect_failures += 1;
session.consecutive_reconnect_failures += 1;
session.reconnect_backoff_ms = std::min(
kReconnectBackoffMaxMs,
std::max(kReconnectBackoffInitialMs, session.reconnect_backoff_ms * 2));
schedule_reconnect(session, reconnect.error(), false);
continue;
}
stats_.reconnect_successes += 1;
connected_sessions += 1;
spdlog::info(
"RTMP_SESSION_RECONNECTED codec={} mode={} url={} successes={} failures={}",
to_string(codec_),
to_string(mode_),
session.original_url,
stats_.reconnect_successes,
session.consecutive_reconnect_failures);
}
if (access_unit.empty()) {
return {};
}
if (connected_sessions == 0) {
if (!warned_all_sessions_closed_) {
spdlog::warn(
"RTMP_PUBLISH_STOPPED codec={} mode={} reason='no active RTMP sessions' reconnect_attempts={} reconnect_successes={} reconnect_failures={}",
to_string(codec_),
to_string(mode_),
stats_.reconnect_attempts,
stats_.reconnect_successes,
stats_.reconnect_failures);
warned_all_sessions_closed_ = true;
}
return {};
}
const std::uint32_t timestamp_ms = to_rtmp_timestamp_ms(pts_ns);
std::size_t index{0};
while (index < sessions_.size()) {
auto &session = sessions_[index];
if (session.socket_fd < 0) {
++index;
continue;
}
if (!session.sequence_header_sent) {
std::vector<std::uint8_t> sequence_header{};
if (codec_ == CodecType::H264) {
sequence_header = make_h264_sequence_header();
} else {
if (mode_ == RtmpMode::Enhanced) {
sequence_header = make_h265_enhanced_sequence_header();
} else if (mode_ == RtmpMode::Domestic) {
sequence_header = make_h265_domestic_sequence_header();
} else {
return std::unexpected(std::format(
"unsupported RTMP mode '{}' for codec '{}'",
to_string(mode_),
to_string(codec_)));
}
}
auto config_send = send_rtmp_message(
session.socket_fd,
kChunkStreamVideo,
timestamp_ms,
kMsgVideo,
session.stream_id,
sequence_header,
session.out_chunk_size);
if (!config_send) {
stats_.send_errors += 1;
stats_.publish_failures += 1;
session.consecutive_reconnect_failures += 1;
session.reconnect_backoff_ms = std::min(
kReconnectBackoffMaxMs,
std::max(kReconnectBackoffInitialMs, session.reconnect_backoff_ms * 2));
if (stats_.send_errors <= kErrorLogFirstPackets || (stats_.send_errors % kErrorLogEveryNPackets) == 0) {
spdlog::warn(
"RTMP_SEND_ERROR codec={} mode={} url={} stage=sequence-header detail='{}' send_errors={}",
to_string(codec_),
to_string(mode_),
session.original_url,
config_send.error(),
stats_.send_errors);
}
schedule_reconnect(session, config_send.error(), false);
continue;
}
stats_.video_messages += 1;
stats_.bytes_sent += *config_send;
session.sequence_header_sent = true;
}
std::vector<std::uint8_t> frame_payload{};
if (codec_ == CodecType::H264) {
frame_payload = make_h264_video_payload(access_unit);
} else {
if (mode_ == RtmpMode::Enhanced) {
frame_payload = make_h265_enhanced_video_payload(access_unit);
} else if (mode_ == RtmpMode::Domestic) {
frame_payload = make_h265_domestic_video_payload(access_unit);
} else {
return std::unexpected(std::format(
"unsupported RTMP mode '{}' for codec '{}'",
to_string(mode_),
to_string(codec_)));
}
}
auto frame_send = send_rtmp_message(
session.socket_fd,
kChunkStreamVideo,
timestamp_ms,
kMsgVideo,
session.stream_id,
frame_payload,
session.out_chunk_size);
if (!frame_send) {
stats_.send_errors += 1;
stats_.publish_failures += 1;
session.consecutive_reconnect_failures += 1;
session.reconnect_backoff_ms = std::min(
kReconnectBackoffMaxMs,
std::max(kReconnectBackoffInitialMs, session.reconnect_backoff_ms * 2));
if (stats_.send_errors <= kErrorLogFirstPackets || (stats_.send_errors % kErrorLogEveryNPackets) == 0) {
spdlog::warn(
"RTMP_SEND_ERROR codec={} mode={} url={} stage=video-frame detail='{}' send_errors={}",
to_string(codec_),
to_string(mode_),
session.original_url,
frame_send.error(),
stats_.send_errors);
}
schedule_reconnect(session, frame_send.error(), false);
continue;
}
stats_.video_messages += 1;
stats_.bytes_sent += *frame_send;
had_successful_video_message_ = true;
session.consecutive_reconnect_failures = 0;
session.reconnect_backoff_ms = kReconnectBackoffInitialMs;
session.in_cooldown = false;
++index;
}
const bool any_connected = std::any_of(sessions_.begin(), sessions_.end(), [](const Session &session) {
return session.socket_fd >= 0;
});
if (!any_connected && !had_successful_video_message_ && !warned_all_sessions_closed_) {
spdlog::warn(
"RTMP_EARLY_DISCONNECT codec={} mode={} reason='all sessions disconnected before first video delivery; keeping publisher alive for reconnect/backoff'",
to_string(codec_),
to_string(mode_));
}
if (any_connected) {
warned_all_sessions_closed_ = false;
}
return {};
}
const RtmpPublisherStats &RtmpPublisher::stats() const {
return stats_;
}
void RtmpPublisher::on_stream_reset() {
spdlog::info("RTMP_STREAM_RESET codec={} mode={} sessions={}", to_string(codec_), to_string(mode_), sessions_.size());
for (auto &session : sessions_) {
session.sequence_header_sent = false;
if (session.socket_fd >= 0) {
spdlog::info("RTMP_STREAM_RESET_REBASE url={} action=force_sequence_header", session.original_url);
}
}
}
void RtmpPublisher::log_metrics() const {
spdlog::info(
"RTMP_METRICS codec={} mode={} sessions={} access_units={} access_unit_bytes={} video_messages={} bytes_sent={} send_errors={} publish_failures={} reconnect_attempts={} reconnect_successes={} reconnect_failures={}",
to_string(codec_),
to_string(mode_),
sessions_.size(),
stats_.access_units,
stats_.access_unit_bytes,
stats_.video_messages,
stats_.bytes_sent,
stats_.send_errors,
stats_.publish_failures,
stats_.reconnect_attempts,
stats_.reconnect_successes,
stats_.reconnect_failures);
}
}