56e874ab6d
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>
1030 lines
29 KiB
C++
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);
|
|
}
|
|
|
|
}
|