From 56e874ab6d1ee333e11e91ab823ee30642d69629 Mon Sep 17 00:00:00 2001 From: crosstyan Date: Thu, 5 Mar 2026 20:31:58 +0800 Subject: [PATCH] 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 --- CMakeLists.txt | 110 + README.md | 319 +++ include/cvmmap_streamer/common.h | 14 + .../cvmmap_streamer/config/runtime_config.hpp | 84 + include/cvmmap_streamer/ipc/contracts.hpp | 181 ++ .../metrics/latency_tracker.hpp | 40 + .../protocol/rtmp_publisher.hpp | 84 + .../protocol/rtp_publisher.hpp | 67 + include/cvmmap_streamer/sim/options.hpp | 36 + include/cvmmap_streamer/sim/wire.hpp | 38 + src/config/runtime_config.cpp | 524 +++++ src/core/ingest_runtime.cpp | 388 ++++ src/ipc/contracts.cpp | 356 +++ src/ipc/help.cpp | 43 + src/ipc/ipc_stub.cpp | 11 + src/main_sim.cpp | 280 +++ src/main_streamer.cpp | 44 + src/metrics/latency_tracker.cpp | 83 + src/pipeline/pipeline_stub.cpp | 1141 +++++++++ src/protocol/protocol_stub.cpp | 5 + src/protocol/rtmp_publisher.cpp | 1029 ++++++++ src/protocol/rtp_publisher.cpp | 502 ++++ src/sim/options.cpp | 291 +++ src/sim/wire.cpp | 135 ++ src/testers/ipc_snapshot_tester.cpp | 111 + src/testers/rtmp_stub_tester.cpp | 2062 +++++++++++++++++ src/testers/rtp_receiver_tester.cpp | 505 ++++ 27 files changed, 8483 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 include/cvmmap_streamer/common.h create mode 100644 include/cvmmap_streamer/config/runtime_config.hpp create mode 100644 include/cvmmap_streamer/ipc/contracts.hpp create mode 100644 include/cvmmap_streamer/metrics/latency_tracker.hpp create mode 100644 include/cvmmap_streamer/protocol/rtmp_publisher.hpp create mode 100644 include/cvmmap_streamer/protocol/rtp_publisher.hpp create mode 100644 include/cvmmap_streamer/sim/options.hpp create mode 100644 include/cvmmap_streamer/sim/wire.hpp create mode 100644 src/config/runtime_config.cpp create mode 100644 src/core/ingest_runtime.cpp create mode 100644 src/ipc/contracts.cpp create mode 100644 src/ipc/help.cpp create mode 100644 src/ipc/ipc_stub.cpp create mode 100644 src/main_sim.cpp create mode 100644 src/main_streamer.cpp create mode 100644 src/metrics/latency_tracker.cpp create mode 100644 src/pipeline/pipeline_stub.cpp create mode 100644 src/protocol/protocol_stub.cpp create mode 100644 src/protocol/rtmp_publisher.cpp create mode 100644 src/protocol/rtp_publisher.cpp create mode 100644 src/sim/options.cpp create mode 100644 src/sim/wire.cpp create mode 100644 src/testers/ipc_snapshot_tester.cpp create mode 100644 src/testers/rtmp_stub_tester.cpp create mode 100644 src/testers/rtp_receiver_tester.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bcb0fbf --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,110 @@ +cmake_minimum_required(VERSION 3.20) + +project(cvmmap-streamer LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +find_package(Threads REQUIRED) + +find_package(cppzmq QUIET) +find_package(ZeroMQ QUIET) +find_package(spdlog QUIET) +find_package(PkgConfig REQUIRED) + +pkg_check_modules(GSTREAMER + IMPORTED_TARGET + gstreamer-1.0>=1.14 + gstreamer-video-1.0>=1.14 + gstreamer-app-1.0>=1.14) + +if (NOT GSTREAMER_FOUND) + message(FATAL_ERROR + "GStreamer development packages are required for cvmmap-streamer. " + "Install pkg-config modules: gstreamer-1.0>=1.14, gstreamer-video-1.0>=1.14, " + "and gstreamer-app-1.0>=1.14.") +endif() + +if (NOT TARGET spdlog::spdlog AND NOT TARGET spdlog) + if (EXISTS "${CMAKE_CURRENT_LIST_DIR}/../app/lib/spdlog/CMakeLists.txt") + set(SPDLOG_BUILD_EXAMPLE OFF) + set(SPDLOG_BUILD_TESTS OFF) + set(SPDLOG_INSTALL OFF) + add_subdirectory( + "${CMAKE_CURRENT_LIST_DIR}/../app/lib/spdlog" + "${CMAKE_CURRENT_BINARY_DIR}/vendor/spdlog") + endif() +endif() +add_library(cvmmap_streamer_common STATIC + src/ipc/help.cpp + src/config/runtime_config.cpp + src/core/ingest_runtime.cpp + src/ipc/contracts.cpp + src/ipc/ipc_stub.cpp + src/metrics/latency_tracker.cpp + src/pipeline/pipeline_stub.cpp + src/protocol/rtmp_publisher.cpp + src/protocol/rtp_publisher.cpp) + +target_include_directories(cvmmap_streamer_common + PUBLIC + "${CMAKE_CURRENT_LIST_DIR}/include") +set(CVMAP_STREAMER_LINK_DEPS Threads::Threads) + +if (TARGET cppzmq::cppzmq) + list(APPEND CVMAP_STREAMER_LINK_DEPS cppzmq::cppzmq) +elseif (TARGET cppzmq) + list(APPEND CVMAP_STREAMER_LINK_DEPS cppzmq) +endif() + +if (TARGET ZeroMQ::libzmq) + list(APPEND CVMAP_STREAMER_LINK_DEPS ZeroMQ::libzmq) +elseif (TARGET ZeroMQ::ZeroMQ) + list(APPEND CVMAP_STREAMER_LINK_DEPS ZeroMQ::ZeroMQ) +endif() + +if (TARGET ZeroMQ::cppzmq) + list(APPEND CVMAP_STREAMER_LINK_DEPS ZeroMQ::cppzmq) +elseif (TARGET cppzmq::cppzmq) + list(APPEND CVMAP_STREAMER_LINK_DEPS cppzmq::cppzmq) +endif() + + +if (NOT TARGET PkgConfig::GSTREAMER) + message(FATAL_ERROR + "GStreamer packages were detected but PkgConfig::GSTREAMER target is unavailable. " + "Please ensure GStreamer development toolchain is correctly installed.") +endif() + +list(APPEND CVMAP_STREAMER_LINK_DEPS PkgConfig::GSTREAMER) + + +if (TARGET spdlog::spdlog) + list(APPEND CVMAP_STREAMER_LINK_DEPS spdlog::spdlog) +elseif (TARGET spdlog) + list(APPEND CVMAP_STREAMER_LINK_DEPS spdlog) +endif() + +target_link_libraries(cvmmap_streamer_common PUBLIC ${CVMAP_STREAMER_LINK_DEPS}) + +function(add_cvmmap_binary target source) + add_executable(${target} ${source} ${ARGN}) + target_include_directories(${target} + PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include") + target_link_libraries(${target} + PRIVATE + cvmmap_streamer_common) + set_target_properties(${target} PROPERTIES OUTPUT_NAME "${target}") +endfunction() + +add_cvmmap_binary(cvmmap_streamer src/main_streamer.cpp) +add_cvmmap_binary( + cvmmap_sim + src/main_sim.cpp + src/sim/options.cpp + src/sim/wire.cpp) +add_cvmmap_binary(rtp_receiver_tester src/testers/rtp_receiver_tester.cpp) +add_cvmmap_binary(rtmp_stub_tester src/testers/rtmp_stub_tester.cpp) +add_cvmmap_binary(ipc_snapshot_tester src/testers/ipc_snapshot_tester.cpp) diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae0b779 --- /dev/null +++ b/README.md @@ -0,0 +1,319 @@ +# cv-mmap Streamer + +A standalone C++ downstream project that reads frames from cv-mmap IPC, encodes with NVIDIA NVENC (with software fallback), and publishes RTMP + RTP streams with low-latency tuning on localhost. + +## Overview + +This project consumes video frames from the cv-mmap shared memory interface and publishes them as encoded streams. It operates as a downstream consumer only, never writing to the cv-mmap shared memory. + +**Key Features:** +- Reads cv-mmap IPC frames via POSIX shared memory + ZeroMQ synchronization +- NVENC H.264/H.265 encoding with deterministic software fallback +- RTP UDP-unicast publisher with automatic SDP generation +- RTMP publisher with dual H.265 modes (Enhanced-RTMP + domestic extension) +- Embedded standalone testers for server-independent validation +- Low-latency bounded queues with latest-frame semantics + +## Quickstart + +### Prerequisites + +- C++23 compatible compiler (GCC 13+, Clang 16+) +- CMake 3.20+ +- GStreamer 1.20+ with development headers +- ZeroMQ (cppzmq) with development headers +- spdlog +- NVIDIA GPU with NVENC support (optional, falls back to software encoding) + +**Arch Linux:** +```bash +sudo pacman -S cmake gstreamer gst-plugins-base gst-plugins-good \ + gst-plugins-bad gst-plugins-ugly gst-libav cppzmq spdlog +``` + +### Build + +```bash +cmake -B downstream/cvmmap-streamer/build -S downstream/cvmmap-streamer +cmake --build downstream/cvmmap-streamer/build +``` + +**Verify binaries exist:** +```bash +ls -la downstream/cvmmap-streamer/build/{cvmmap_sim,cvmmap_streamer,rtp_receiver_tester,rtmp_stub_tester} +``` + +### Mandatory Acceptance (Standalone) + +Run the full mandatory acceptance suite. This executes the complete protocol/codec matrix without requiring external servers. + +```bash +cd downstream/cvmmap-streamer +./scripts/acceptance_standalone.sh +``` + +**Expected result:** Exit code 0 with summary showing `total=5 pass=5 fail=0 skip=0` + +**Individual matrix rows verified:** +1. RTP + H.264 +2. RTP + H.265 +3. RTMP + H.264 (enhanced mode) +4. RTMP + H.265 enhanced mode +5. RTMP + H.265 domestic mode + +### Fault Suite Baseline + +Run the fault injection and latency validation suite. + +```bash +cd downstream/cvmmap-streamer +./scripts/fault_suite.sh +``` + +**Expected result:** Exit code 0 with all scenarios passing. + +**Scenarios tested:** +- Torn read handling (coherent snapshot validation) +- Sink stall resilience (backpressure containment) +- Reset storm recovery (stream reset handling) + +### Manual Component Testing + +**1. Start the simulator:** +```bash +./build/cvmmap_sim \ + --shm-name test_stream \ + --zmq-endpoint "ipc:///tmp/test_sync.ipc" \ + --label teststream \ + --frames 300 \ + --fps 30 \ + --width 640 \ + --height 360 +``` + +**2. Test RTP output:** +```bash +# Terminal 1: Start receiver tester +./build/rtp_receiver_tester \ + --port 5004 \ + --expect-pt 96 \ + --packet-threshold 1 \ + --timeout-ms 10000 + +# Terminal 2: Start streamer +./build/cvmmap_streamer \ + --run-mode pipeline \ + --codec h264 \ + --shm-name test_stream \ + --zmq-endpoint "ipc:///tmp/test_sync.ipc" \ + --rtp \ + --rtp-endpoint "127.0.0.1:5004" \ + --rtp-payload-type 96 \ + --rtp-sdp /tmp/test.sdp +``` + +**3. Test RTMP output (enhanced mode):** +```bash +# Terminal 1: Start RTMP stub tester +./build/rtmp_stub_tester \ + --mode h264 \ + --listen-host 127.0.0.1 \ + --listen-port 1935 \ + --video-threshold 1 \ + --timeout-ms 10000 + +# Terminal 2: Start streamer +./build/cvmmap_streamer \ + --run-mode pipeline \ + --codec h264 \ + --shm-name test_stream \ + --zmq-endpoint "ipc:///tmp/test_sync.ipc" \ + --rtmp \ + --rtmp-url "rtmp://127.0.0.1:1935/live/test" \ + --rtmp-mode enhanced +``` + +## Compatibility Matrix + +| Protocol | Codec | RTMP Mode | Status | Notes | +|----------|-------|-----------|--------|-------| +| RTP | H.264 | N/A | MANDATORY | Full support | +| RTP | H.265 | N/A | MANDATORY | Full support | +| RTMP | H.264 | enhanced | MANDATORY | Legacy codec-id 7 | +| RTMP | H.265 | enhanced | MANDATORY | FourCC `hvc1`, [Enhanced-RTMP spec](https://github.com/veovera/enhanced-rtmp) | +| RTMP | H.265 | domestic | MANDATORY | FLV codec-id 12, legacy CDN compatibility | +| RTMP | H.264 | domestic | INVALID | Rejected at startup with clear error | + +**Legend:** +- **MANDATORY**: Must pass for release acceptance +- **INVALID**: Explicitly rejected, exits non-zero + +## Runtime Configuration + +### Input Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--shm-name NAME` | POSIX shared memory segment name | required | +| `--zmq-endpoint URI` | ZeroMQ PUB endpoint for sync/status | required | +| `--queue-size N` | Ingest queue capacity (1 = latest-frame) | 1 | + +### Codec Options + +| Flag | Description | +|------|-------------| +| `--codec h264\|h265` | Video codec selection (required) | + +### Output Options + +| Flag | Description | +|------|-------------| +| `--rtp` | Enable RTP output | +| `--rtp-endpoint HOST:PORT` | RTP destination (required if --rtp) | +| `--rtp-payload-type PT` | Dynamic payload type [96,127] | 96 | +| `--rtp-sdp PATH` | SDP output path | +| `--rtmp` | Enable RTMP output | +| `--rtmp-url URL` | RTMP publish URL (required if --rtmp) | +| `--rtmp-mode enhanced\|domestic` | H.265 packaging mode (required for H.265) | + +### Latency Knobs + +| Flag | Description | Default | +|------|-------------|---------| +| `--gop N` | GOP size (keyframe interval) | 30 | +| `--b-frames N` | B-frame count (0 = lowest latency) | 0 | +| `--queue-size N` | Ingest queue depth | 1 | + +### Operational Limits + +| Flag | Description | Default | +|------|-------------|---------| +| `--ingest-max-frames N` | Process at most N frames then exit | 0 (unlimited) | +| `--ingest-idle-timeout-ms MS` | Exit if idle for MS milliseconds | 0 (disabled) | + +## Architecture + +### Data Flow + +``` +cv-mmap producer ──> SHM + ZMQ sync ──> Ingest Runtime + │ + v + ┌───────────────┐ + │ Bounded Queue │ + │ (size=1) │ + └───────┬───────┘ + │ + v + NVENC Pipeline + (NVENC -> fallback) + │ + ┌───────┴───────┐ + v v + RTP Publisher RTMP Publisher + (UDP unicast) (TCP + FLV) +``` + +### Key Design Decisions + +**Latest-Frame Semantics:** The ingest queue has size 1 by default. When a new frame arrives while the previous is still queued, the old frame is dropped. This prevents latency accumulation under backpressure. + +**Coherent Snapshot:** Frame metadata is read twice around the payload copy. If `frame_count` or `timestamp_ns` changed, the frame is rejected as torn. This prevents consuming partially-updated frames. + +**NVENC with Fallback:** The pipeline attempts NVENC first for hardware acceleration. If NVENC produces zero encoded access units after 60 frames, it falls back to software encoding (`x264enc` or `x265enc`). + +**Dual-Mode H.265:** H.265 RTMP supports two packaging modes: +- **Enhanced-RTMP**: Uses FourCC `hvc1`, modern standard, supported by FFmpeg 6.0+, SRS 6.0+, ZLMediaKit +- **Domestic extension**: Uses FLV codec-id 12, legacy Chinese CDN compatibility + +The mode must be explicitly selected via `--rtmp-mode` and cannot be mixed within a session. + +## Environment Caveats + +### Simulator Label Length +Simulator labels (`--label`) have a hard maximum of 24 bytes. Exceeding this causes immediate exit with code 2. Use compact deterministic labels like `acc_1_rtp_h264` instead of descriptive names. + +### Deterministic Simulator Sizing +For reliable RTMP validation, use simulator frame sizes of at least 640x360. Smaller frames may trigger GStreamer caps negotiation failures before the first encoded access unit on some hosts. + +### Build Path +Always use `downstream/cvmmap-streamer/build` for the build directory. Using the root `build/` folder causes cache collision with the main cv-mmap project. + +### Fresh Configure +If you encounter configure errors referencing sibling repo paths, run: +```bash +cmake --fresh -B downstream/cvmmap-streamer/build -S downstream/cvmmap-streamer +``` + +## Optional Server Smoke Tests + +Interoperability tests with SRS and ZLMediaKit are provided for reference but are **NOT** mandatory for acceptance. See: + +- [SRS Smoke Test Profile](docs/smoke/srs.md) +- [ZLMediaKit Smoke Test Profile](docs/smoke/zlm.md) + +If the server environment is unavailable, these tests should be skipped without failing the mandatory acceptance criteria. + +## Project Structure + +``` +downstream/cvmmap-streamer/ +├── CMakeLists.txt # Build configuration +├── README.md # This file +├── docs/ +│ ├── smoke/ +│ │ ├── srs.md # SRS interoperability guide +│ │ └── zlm.md # ZLMediaKit interoperability guide +│ ├── compat_matrix.md # Detailed compatibility matrix +│ └── caveats.md # Environment and operational caveats +├── include/cvmmap_streamer/# Public headers +│ ├── config/ +│ │ └── runtime_config.hpp +│ ├── ipc/ +│ │ └── cvmmap_contract.hpp +│ └── pipeline/ +│ └── pipeline_types.hpp +├── scripts/ +│ ├── acceptance_standalone.sh # Mandatory acceptance runner +│ ├── fault_suite.sh # Fault injection suite +│ └── *_helper.py # Summary generators +└── src/ + ├── config/ # Runtime configuration + ├── core/ # Ingest runtime and supervision + ├── ipc/ # cv-mmap contract parsing + ├── pipeline/ # NVENC encoding + ├── protocol/ # RTP and RTMP publishers + └── testers/ # Simulator and test stubs +``` + +## Evidence Artifacts + +All test runs produce machine-readable evidence in `.sisyphus/evidence/`: + +- `task-14-acceptance.txt` - Latest acceptance run metadata +- `task-14-acceptance-summary.json` - JSON summary of acceptance results +- `task-15-fault-suite.txt` - Latest fault suite run metadata +- `task-15-fault-suite-summary.json` - JSON summary of fault suite results + +Each run creates timestamped subdirectories with full logs for every matrix row or fault scenario. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Invalid arguments | +| 2 | Invalid arguments or configuration | +| 3 | RTP payload type mismatch | +| 4 | Packet/frame threshold not met | +| 5 | Pipeline initialization error (missing encoder) | +| 6 | RTMP mode mismatch (tester validation) | +| 7 | Protocol validation error | +| 124 | Timeout | + +## References + +- [Enhanced RTMP Specification](https://github.com/veovera/enhanced-rtmp) +- [cv-mmap IPC Contract](../../docs/cvmmap.ksy) +- SRS Documentation: https://ossrs.io/lts/en-us/docs/v7/doc/rtmp +- ZLMediaKit: https://github.com/ZLMediaKit/ZLMediaKit diff --git a/include/cvmmap_streamer/common.h b/include/cvmmap_streamer/common.h new file mode 100644 index 0000000..d13e602 --- /dev/null +++ b/include/cvmmap_streamer/common.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace cvmmap_streamer { + +void print_help(std::string_view executable); +bool has_help_flag(int argc, char **argv); +std::size_t sample_ipc_payload_size(); +void pipeline_tick(); +void protocol_step(); + +} diff --git a/include/cvmmap_streamer/config/runtime_config.hpp b/include/cvmmap_streamer/config/runtime_config.hpp new file mode 100644 index 0000000..7bf1370 --- /dev/null +++ b/include/cvmmap_streamer/config/runtime_config.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace cvmmap_streamer { + +enum class CodecType { + H264, + H265, +}; + +enum class RunMode { + Pipeline, + Ingest, +}; + +enum class RtmpMode { + Enhanced, + Domestic, +}; + +struct InputConfig { + std::string shm_name{"cvmmap_default"}; + std::string zmq_endpoint{"ipc:///tmp/cvmmap_default"}; +}; + +struct RtmpOutputConfig { + bool enabled{false}; + std::vector urls{}; + RtmpMode mode{RtmpMode::Enhanced}; +}; + +struct RtpOutputConfig { + bool enabled{false}; + std::optional endpoint{std::nullopt}; + std::optional host{std::nullopt}; + std::optional port{std::nullopt}; + std::uint8_t payload_type{96}; + std::optional sdp_path{std::nullopt}; +}; + +struct OutputsConfig { + RtmpOutputConfig rtmp{}; + RtpOutputConfig rtp{}; +}; + +struct LatencyConfig { + std::size_t queue_size{1}; + std::uint32_t gop{30}; + std::uint32_t b_frames{0}; + bool realtime_sync{true}; + bool force_idr_on_reset{true}; + std::uint32_t ingest_max_frames{0}; + std::uint32_t ingest_idle_timeout_ms{1000}; + std::uint32_t ingest_consumer_delay_ms{0}; + std::uint32_t snapshot_copy_delay_us{0}; + std::uint32_t emit_stall_ms{0}; +}; + +struct RuntimeConfig { + InputConfig input{}; + RunMode run_mode{RunMode::Pipeline}; + CodecType codec{CodecType::H264}; + OutputsConfig outputs{}; + LatencyConfig latency{}; + + static RuntimeConfig defaults(); +}; + +std::string_view to_string(CodecType codec); +std::string_view to_string(RunMode mode); +std::string_view to_string(RtmpMode mode); + +std::expected parse_runtime_config(int argc, char **argv); +std::expected validate_runtime_config(const RuntimeConfig &config); +std::string summarize_runtime_config(const RuntimeConfig &config); + +} diff --git a/include/cvmmap_streamer/ipc/contracts.hpp b/include/cvmmap_streamer/ipc/contracts.hpp new file mode 100644 index 0000000..1096ffc --- /dev/null +++ b/include/cvmmap_streamer/ipc/contracts.hpp @@ -0,0 +1,181 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvmmap_streamer::ipc { + +constexpr std::size_t kLabelLenMax = 24; +constexpr std::size_t kShmPayloadOffset = 256; + +constexpr std::uint8_t kFrameTopicMagic = 0x7d; +constexpr std::uint8_t kModuleStatusMagic = 0x5a; +constexpr std::uint8_t kControlRequestMagic = 0x3c; +constexpr std::uint8_t kControlResponseMagic = 0x3d; +constexpr std::uint8_t kVersionMajor = 1; +constexpr std::uint8_t kVersionMinor = 0; + +constexpr std::array kFrameMetadataMagic{ + 'C', 'V', '-', 'M', 'M', 'A', 'P', '\0'}; + +enum class Depth : std::uint8_t { + U8 = 0, + S8 = 1, + U16 = 2, + S16 = 3, + S32 = 4, + F32 = 5, + F64 = 6, + F16 = 7, +}; + +enum class PixelFormat : std::uint8_t { + RGB = 0, + BGR, + RGBA, + BGRA, + GRAY, + YUV, + YUYV, +}; + +enum class ModuleStatus : std::int32_t { + Online = 0xa1, + Offline = 0xa0, + StreamReset = 0xb0, +}; + +enum class ParseError { + BufferTooSmall, + InvalidSize, + InvalidMagic, + UnsupportedVersion, + InvalidDepth, + InvalidPixelFormat, + InvalidModuleStatus, + PayloadLengthMismatch, +}; + +enum class SnapshotError { + InvalidShmLayout, + DestinationTooSmall, + TornRead, +}; + +std::string_view to_string(ParseError error); +std::string_view to_string(SnapshotError error); + +struct FrameInfo { + std::uint16_t width; + std::uint16_t height; + std::uint8_t channels; + Depth depth; + PixelFormat pixel_format; + std::uint32_t buffer_size; +}; + +struct FrameMetadata { + std::array magic; + std::uint8_t versions_major; + std::uint8_t versions_minor; + std::uint32_t frame_count; + std::uint64_t timestamp_ns; + FrameInfo info; +}; + +struct SyncMessage { + std::uint8_t versions_major; + std::uint8_t versions_minor; + std::uint32_t frame_count; + std::uint64_t timestamp_ns; + std::array label_bytes; + + [[nodiscard]] + std::string_view label() const { + const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast(0)); + return std::string_view{ + reinterpret_cast(label_bytes.data()), + static_cast(std::distance(label_bytes.begin(), end))}; + } +}; + +struct ModuleStatusMessage { + std::uint8_t versions_major; + std::uint8_t versions_minor; + ModuleStatus module_status; + std::array label_bytes; + + [[nodiscard]] + std::string_view label() const { + const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast(0)); + return std::string_view{ + reinterpret_cast(label_bytes.data()), + static_cast(std::distance(label_bytes.begin(), end))}; + } +}; + +struct ControlRequestMessage { + std::uint8_t versions_major; + std::uint8_t versions_minor; + std::int32_t command_id; + std::array label_bytes; + std::span request_payload; + + [[nodiscard]] + std::string_view label() const { + const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast(0)); + return std::string_view{ + reinterpret_cast(label_bytes.data()), + static_cast(std::distance(label_bytes.begin(), end))}; + } +}; + +struct ControlResponseMessage { + std::uint8_t versions_major; + std::uint8_t versions_minor; + std::int32_t command_id; + std::int32_t response_code; + std::array label_bytes; + std::span response_payload; + + [[nodiscard]] + std::string_view label() const { + const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast(0)); + return std::string_view{ + reinterpret_cast(label_bytes.data()), + static_cast(std::distance(label_bytes.begin(), end))}; + } +}; + +struct ValidatedShmView { + FrameMetadata metadata; + std::span payload; +}; + +struct CoherentSnapshot { + FrameMetadata metadata; + std::size_t bytes_copied; +}; + +using SnapshotReadHook = std::function; + +std::expected parse_frame_metadata(std::span bytes); +std::expected parse_sync_message(std::span bytes); +std::expected parse_module_status_message(std::span bytes); +std::expected parse_control_request_message(std::span bytes); +std::expected parse_control_response_message(std::span bytes); + +std::expected validate_shm_region(std::span shm_region); + +std::expected read_coherent_snapshot( + std::span shm_region, + std::span destination, + const SnapshotReadHook &before_second_metadata_read = {}); + +} diff --git a/include/cvmmap_streamer/metrics/latency_tracker.hpp b/include/cvmmap_streamer/metrics/latency_tracker.hpp new file mode 100644 index 0000000..cf9b3f2 --- /dev/null +++ b/include/cvmmap_streamer/metrics/latency_tracker.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +namespace cvmmap_streamer::metrics { + +struct LatencySummary { + std::uint64_t samples{0}; + std::uint64_t min_us{0}; + std::uint64_t max_us{0}; + std::uint64_t avg_us{0}; + std::uint64_t p50_us{0}; + std::uint64_t p95_us{0}; + std::uint64_t p99_us{0}; +}; + +class IngestEmitLatencyTracker { +public: + void note_ingest(); + void note_emit(); + void note_emit_stall(); + + [[nodiscard]] + std::uint64_t emit_stall_events() const; + + [[nodiscard]] + std::uint64_t pending_frames() const; + + [[nodiscard]] + LatencySummary summarize() const; + +private: + std::deque ingest_queue_ns_{}; + std::vector samples_us_{}; + std::uint64_t emit_stall_events_{0}; +}; + +} diff --git a/include/cvmmap_streamer/protocol/rtmp_publisher.hpp b/include/cvmmap_streamer/protocol/rtmp_publisher.hpp new file mode 100644 index 0000000..1045583 --- /dev/null +++ b/include/cvmmap_streamer/protocol/rtmp_publisher.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "cvmmap_streamer/config/runtime_config.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace cvmmap_streamer::protocol { + +struct RtmpPublisherStats { + std::uint64_t access_units{0}; + std::uint64_t access_unit_bytes{0}; + std::uint64_t video_messages{0}; + std::uint64_t bytes_sent{0}; + std::uint64_t send_errors{0}; + std::uint64_t publish_failures{0}; + std::uint64_t reconnect_attempts{0}; + std::uint64_t reconnect_successes{0}; + std::uint64_t reconnect_failures{0}; +}; + +class RtmpPublisher { +public: + RtmpPublisher() = default; + ~RtmpPublisher(); + + RtmpPublisher(const RtmpPublisher &) = delete; + RtmpPublisher &operator=(const RtmpPublisher &) = delete; + + RtmpPublisher(RtmpPublisher &&other) noexcept; + RtmpPublisher &operator=(RtmpPublisher &&other) noexcept; + + [[nodiscard]] + static std::expected create(const RuntimeConfig &config); + + [[nodiscard]] + std::expected publish_access_unit(std::span access_unit, std::uint64_t pts_ns); + + [[nodiscard]] + const RtmpPublisherStats &stats() const; + + void on_stream_reset(); + + void log_metrics() const; + +private: + struct Session { + std::string original_url{}; + std::string host{}; + std::uint16_t port{1935}; + std::string app{}; + std::string stream{}; + std::string tc_url{}; + int socket_fd{-1}; + std::uint32_t out_chunk_size{128}; + std::uint32_t stream_id{1}; + bool sequence_header_sent{false}; + std::uint32_t reconnect_backoff_ms{250}; + std::uint32_t consecutive_reconnect_failures{0}; + std::chrono::steady_clock::time_point reconnect_due_at{}; + bool in_cooldown{false}; + }; + + [[nodiscard]] + std::expected connect_session(Session &session); + + void schedule_reconnect(Session &session, std::string_view reason, bool startup_path); + + void close_session(Session &session); + + CodecType codec_{CodecType::H264}; + RtmpMode mode_{RtmpMode::Enhanced}; + std::vector sessions_{}; + RtmpPublisherStats stats_{}; + bool had_successful_video_message_{false}; + bool warned_all_sessions_closed_{false}; +}; + +} diff --git a/include/cvmmap_streamer/protocol/rtp_publisher.hpp b/include/cvmmap_streamer/protocol/rtp_publisher.hpp new file mode 100644 index 0000000..afe893f --- /dev/null +++ b/include/cvmmap_streamer/protocol/rtp_publisher.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "cvmmap_streamer/config/runtime_config.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace cvmmap_streamer::protocol { + +struct RtpPublisherStats { + std::uint64_t access_units{0}; + std::uint64_t access_unit_bytes{0}; + std::uint64_t packets_sent{0}; + std::uint64_t packets_dropped{0}; + std::uint64_t bytes_sent{0}; + std::uint64_t send_errors{0}; +}; + +class UdpRtpPublisher { +public: + UdpRtpPublisher() = default; + ~UdpRtpPublisher(); + + UdpRtpPublisher(const UdpRtpPublisher &) = delete; + UdpRtpPublisher &operator=(const UdpRtpPublisher &) = delete; + + UdpRtpPublisher(UdpRtpPublisher &&other) noexcept; + UdpRtpPublisher &operator=(UdpRtpPublisher &&other) noexcept; + + [[nodiscard]] + static std::expected create(const RuntimeConfig &config); + + void publish_access_unit(std::span access_unit, std::uint64_t pts_ns); + + [[nodiscard]] + const RtpPublisherStats &stats() const; + + [[nodiscard]] + std::string_view sdp_path() const; + + [[nodiscard]] + std::string_view destination() const; + + void log_metrics() const; + +private: + int socket_fd_{-1}; + std::string destination_host_{}; + std::string destination_ip_{}; + std::uint16_t destination_port_{0}; + std::uint8_t payload_type_{96}; + CodecType codec_{CodecType::H264}; + std::uint16_t sequence_{0}; + std::uint32_t ssrc_{0}; + std::string sdp_path_{}; + RtpPublisherStats stats_{}; + + sockaddr_storage endpoint_addr_{}; + socklen_t endpoint_addr_len_{0}; +}; + +} diff --git a/include/cvmmap_streamer/sim/options.hpp b/include/cvmmap_streamer/sim/options.hpp new file mode 100644 index 0000000..6e7f409 --- /dev/null +++ b/include/cvmmap_streamer/sim/options.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +#include "cvmmap_streamer/ipc/contracts.hpp" + +namespace cvmmap_streamer::sim { + +struct RuntimeConfig { + std::uint32_t frames{360}; + std::uint32_t fps{60}; + std::uint16_t width{64}; + std::uint16_t height{48}; + std::optional emit_reset_at{}; + std::optional emit_reset_every{}; + std::optional switch_format_at{}; + std::optional switch_width{}; + std::optional switch_height{}; + std::string label{"sim"}; + std::string shm_name{"cvmmap_sim"}; + std::string zmq_endpoint{"ipc:///tmp/cvmmap_sim"}; + std::uint8_t channels{3}; + ipc::Depth depth{ipc::Depth::U8}; + ipc::PixelFormat pixel_format{ipc::PixelFormat::BGR}; + + [[nodiscard]] + std::uint32_t payload_size_bytes() const; +}; + +std::expected parse_runtime_config(int argc, char **argv); +void print_help(); + +} diff --git a/include/cvmmap_streamer/sim/wire.hpp b/include/cvmmap_streamer/sim/wire.hpp new file mode 100644 index 0000000..2050ed0 --- /dev/null +++ b/include/cvmmap_streamer/sim/wire.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +#include "cvmmap_streamer/ipc/contracts.hpp" + +namespace cvmmap_streamer::sim { + +constexpr std::size_t kSyncMessageBytes = 48; +constexpr std::size_t kModuleStatusMessageBytes = 32; + +void write_frame_metadata( + std::span metadata, + const ipc::FrameInfo &info, + std::uint32_t frame_count, + std::uint64_t timestamp_ns); + +void write_sync_message( + std::span out, + std::string_view label, + std::uint32_t frame_count, + std::uint64_t timestamp_ns); + +void write_module_status_message( + std::span out, + std::string_view label, + ipc::ModuleStatus status); + +void write_deterministic_payload( + std::span out, + std::uint32_t frame_count, + std::uint16_t width, + std::uint16_t height, + std::uint8_t channels); + +} diff --git a/src/config/runtime_config.cpp b/src/config/runtime_config.cpp new file mode 100644 index 0000000..04ff30f --- /dev/null +++ b/src/config/runtime_config.cpp @@ -0,0 +1,524 @@ +#include "cvmmap_streamer/config/runtime_config.hpp" + +#include +#include +#include +#include +#include +#include + +namespace cvmmap_streamer { + +namespace { + + std::expected + next_value(int argc, char **argv, int &index, std::string_view flag_name) { + if (index + 1 >= argc) { + return std::unexpected("missing value for " + std::string(flag_name)); + } + ++index; + return std::string_view{argv[index]}; + } + + std::expected parse_u32(std::string_view raw, std::string_view flag_name) { + std::uint32_t value{0}; + const auto *begin = raw.data(); + const auto *end = raw.data() + raw.size(); + const auto result = std::from_chars(begin, end, value, 10); + if (result.ec != std::errc{} || result.ptr != end) { + return std::unexpected("invalid value for " + std::string(flag_name) + ": '" + std::string(raw) + "'"); + } + return value; + } + + std::expected parse_u16(std::string_view raw, std::string_view flag_name) { + std::uint16_t value{0}; + const auto *begin = raw.data(); + const auto *end = raw.data() + raw.size(); + const auto result = std::from_chars(begin, end, value, 10); + if (result.ec != std::errc{} || result.ptr != end) { + return std::unexpected("invalid value for " + std::string(flag_name) + ": '" + std::string(raw) + "'"); + } + return value; + } + + std::expected parse_size(std::string_view raw, std::string_view flag_name) { + unsigned long long parsed{0}; + const auto *begin = raw.data(); + const auto *end = raw.data() + raw.size(); + const auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc{} || result.ptr != end) { + return std::unexpected("invalid value for " + std::string(flag_name) + ": '" + std::string(raw) + "'"); + } + if (parsed > static_cast(std::numeric_limits::max())) { + return std::unexpected("value out of range for " + std::string(flag_name) + ": '" + std::string(raw) + "'"); + } + return static_cast(parsed); + } + + std::expected parse_bool(std::string_view raw, std::string_view flag_name) { + if (raw == "true" || raw == "1") { + return true; + } + if (raw == "false" || raw == "0") { + return false; + } + return std::unexpected( + "invalid value for " + std::string(flag_name) + ": '" + std::string(raw) + "' (expected: true|false|1|0)"); + } + + std::expected parse_codec(std::string_view raw) { + if (raw == "h264") { + return CodecType::H264; + } + if (raw == "h265") { + return CodecType::H265; + } + return std::unexpected("invalid codec: '" + std::string(raw) + "' (expected: h264|h265)"); + } + + std::expected parse_run_mode(std::string_view raw) { + if (raw == "pipeline") { + return RunMode::Pipeline; + } + if (raw == "ingest") { + return RunMode::Ingest; + } + return std::unexpected("invalid run mode: '" + std::string(raw) + "' (expected: pipeline|ingest)"); + } + + std::expected parse_rtmp_mode(std::string_view raw) { + if (raw == "enhanced") { + return RtmpMode::Enhanced; + } + if (raw == "domestic") { + return RtmpMode::Domestic; + } + return std::unexpected("invalid rtmp mode: '" + std::string(raw) + "' (expected: enhanced|domestic)"); + } + + std::expected, std::string> parse_rtp_endpoint(std::string_view endpoint) { + if (endpoint.empty()) { + return std::unexpected("invalid RTP config: --rtp-endpoint must not be empty"); + } + + const auto colon = endpoint.rfind(':'); + if (colon == std::string_view::npos || colon == 0 || colon + 1 >= endpoint.size()) { + return std::unexpected("invalid RTP config: --rtp-endpoint must be in ':' format"); + } + + const auto host = endpoint.substr(0, colon); + const auto port = endpoint.substr(colon + 1); + if (host.empty()) { + return std::unexpected("invalid RTP config: --rtp-endpoint host must not be empty"); + } + + auto parsed_port = parse_u16(port, "--rtp-endpoint"); + if (!parsed_port) { + return std::unexpected(parsed_port.error()); + } + if (*parsed_port == 0) { + return std::unexpected("invalid RTP config: --rtp-endpoint port must be in range [1,65535]"); + } + + return std::pair{std::string(host), *parsed_port}; + } + +} + +RuntimeConfig RuntimeConfig::defaults() { + return RuntimeConfig{}; +} + +std::string_view to_string(CodecType codec) { + switch (codec) { + case CodecType::H264: + return "h264"; + case CodecType::H265: + return "h265"; + default: + return "unknown"; + } +} + +std::string_view to_string(RunMode mode) { + switch (mode) { + case RunMode::Pipeline: + return "pipeline"; + case RunMode::Ingest: + return "ingest"; + default: + return "unknown"; + } +} + +std::string_view to_string(RtmpMode mode) { + switch (mode) { + case RtmpMode::Enhanced: + return "enhanced"; + case RtmpMode::Domestic: + return "domestic"; + default: + return "unknown"; + } +} + +std::expected parse_runtime_config(int argc, char **argv) { + RuntimeConfig config = RuntimeConfig::defaults(); + + for (int i = 1; i < argc; ++i) { + const std::string_view arg{argv[i]}; + + if (arg == "--codec") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto codec = parse_codec(*raw); + if (!codec) { + return std::unexpected(codec.error()); + } + config.codec = *codec; + continue; + } + + if (arg == "--run-mode") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto mode = parse_run_mode(*raw); + if (!mode) { + return std::unexpected(mode.error()); + } + config.run_mode = *mode; + continue; + } + + if (arg == "--shm-name") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + config.input.shm_name = std::string(*value); + continue; + } + + if (arg == "--zmq-endpoint") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + config.input.zmq_endpoint = std::string(*value); + continue; + } + + if (arg == "--rtmp") { + config.outputs.rtmp.enabled = true; + continue; + } + + if (arg == "--rtmp-url") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + config.outputs.rtmp.enabled = true; + config.outputs.rtmp.urls.emplace_back(*value); + continue; + } + + if (arg == "--rtmp-mode") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto mode = parse_rtmp_mode(*raw); + if (!mode) { + return std::unexpected(mode.error()); + } + config.outputs.rtmp.mode = *mode; + continue; + } + + if (arg == "--rtp") { + config.outputs.rtp.enabled = true; + continue; + } + + if (arg == "--rtp-endpoint") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto endpoint = parse_rtp_endpoint(*value); + if (!endpoint) { + return std::unexpected(endpoint.error()); + } + config.outputs.rtp.enabled = true; + config.outputs.rtp.endpoint = std::string(*value); + config.outputs.rtp.host = endpoint->first; + config.outputs.rtp.port = endpoint->second; + continue; + } + + if (arg == "--rtp-payload-type") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto value = parse_u32(*raw, arg); + if (!value) { + return std::unexpected(value.error()); + } + if (*value > std::numeric_limits::max()) { + return std::unexpected("value out of range for --rtp-payload-type: '" + std::string(*raw) + "'"); + } + config.outputs.rtp.enabled = true; + config.outputs.rtp.payload_type = static_cast(*value); + continue; + } + + if (arg == "--rtp-sdp" || arg == "--sdp") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + if (value->empty()) { + return std::unexpected("invalid RTP config: " + std::string(arg) + " must not be empty"); + } + config.outputs.rtp.enabled = true; + config.outputs.rtp.sdp_path = std::string(*value); + continue; + } + + if (arg == "--queue-size") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_size(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.queue_size = *parsed; + continue; + } + + if (arg == "--gop") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.gop = *parsed; + continue; + } + + if (arg == "--b-frames") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.b_frames = *parsed; + continue; + } + + if (arg == "--realtime-sync") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_bool(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.realtime_sync = *parsed; + continue; + } + + if (arg == "--force-idr-on-reset") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_bool(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.force_idr_on_reset = *parsed; + continue; + } + + if (arg == "--ingest-max-frames") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.ingest_max_frames = *parsed; + continue; + } + + if (arg == "--ingest-idle-timeout-ms") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.ingest_idle_timeout_ms = *parsed; + continue; + } + + if (arg == "--ingest-consumer-delay-ms") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.ingest_consumer_delay_ms = *parsed; + continue; + } + + if (arg == "--snapshot-copy-delay-us") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.snapshot_copy_delay_us = *parsed; + continue; + } + + if (arg == "--emit-stall-ms") { + auto raw = next_value(argc, argv, i, arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto parsed = parse_u32(*raw, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.latency.emit_stall_ms = *parsed; + continue; + } + + if (arg == "--help" || arg == "-h" || arg == "--version") { + continue; + } + + return std::unexpected("unknown argument: " + std::string(arg)); + } + + return config; +} + +std::expected validate_runtime_config(const RuntimeConfig &config) { + if (config.input.shm_name.empty()) { + return std::unexpected("invalid input config: --shm-name must not be empty"); + } + + if (config.input.zmq_endpoint.empty()) { + return std::unexpected("invalid input config: --zmq-endpoint must not be empty"); + } + + if (config.outputs.rtmp.enabled && config.outputs.rtmp.urls.empty()) { + return std::unexpected("invalid RTMP config: --rtmp requires at least one --rtmp-url"); + } + + for (const auto &url : config.outputs.rtmp.urls) { + if (url.empty()) { + return std::unexpected("invalid RTMP config: --rtmp-url must not be empty"); + } + } + + 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)"); + } + + if (config.outputs.rtp.enabled) { + if (!config.outputs.rtp.endpoint || config.outputs.rtp.endpoint->empty()) { + return std::unexpected("invalid RTP config: --rtp requires --rtp-endpoint"); + } + + auto endpoint_validation = parse_rtp_endpoint(*config.outputs.rtp.endpoint); + if (!endpoint_validation) { + return std::unexpected(endpoint_validation.error()); + } + + if (config.outputs.rtp.payload_type < 96 || config.outputs.rtp.payload_type > 127) { + return std::unexpected( + "invalid RTP config: --rtp-payload-type must be in dynamic range [96,127]"); + } + + if (config.outputs.rtp.sdp_path && config.outputs.rtp.sdp_path->empty()) { + return std::unexpected("invalid RTP config: --rtp-sdp/--sdp must not be empty"); + } + } + + if (config.latency.queue_size == 0) { + return std::unexpected("invalid latency config: --queue-size must be >= 1"); + } + + if (config.latency.gop == 0) { + return std::unexpected("invalid latency config: --gop must be >= 1"); + } + + if (config.latency.b_frames > config.latency.gop) { + return std::unexpected("invalid latency config: --b-frames must be <= --gop"); + } + + if (config.latency.ingest_idle_timeout_ms == 0) { + return std::unexpected("invalid ingest config: --ingest-idle-timeout-ms must be >= 1"); + } + + return {}; +} + +std::string summarize_runtime_config(const RuntimeConfig &config) { + std::ostringstream ss; + ss << "input.shm=" << config.input.shm_name; + ss << ", input.zmq=" << config.input.zmq_endpoint; + ss << ", run_mode=" << to_string(config.run_mode); + ss << ", codec=" << to_string(config.codec); + ss << ", rtmp.enabled=" << (config.outputs.rtmp.enabled ? "true" : "false"); + ss << ", rtmp.mode=" << to_string(config.outputs.rtmp.mode); + ss << ", rtmp.urls=" << config.outputs.rtmp.urls.size(); + ss << ", rtp.enabled=" << (config.outputs.rtp.enabled ? "true" : "false"); + ss << ", rtp.endpoint=" << (config.outputs.rtp.endpoint ? *config.outputs.rtp.endpoint : ""); + ss << ", rtp.payload_type=" << static_cast(config.outputs.rtp.payload_type); + ss << ", rtp.sdp=" << (config.outputs.rtp.sdp_path ? *config.outputs.rtp.sdp_path : ""); + ss << ", latency.queue_size=" << config.latency.queue_size; + ss << ", latency.gop=" << config.latency.gop; + ss << ", latency.b_frames=" << config.latency.b_frames; + ss << ", latency.realtime_sync=" << (config.latency.realtime_sync ? "true" : "false"); + ss << ", latency.force_idr_on_reset=" << (config.latency.force_idr_on_reset ? "true" : "false"); + ss << ", latency.ingest_max_frames=" << config.latency.ingest_max_frames; + ss << ", latency.ingest_idle_timeout_ms=" << config.latency.ingest_idle_timeout_ms; + ss << ", latency.ingest_consumer_delay_ms=" << config.latency.ingest_consumer_delay_ms; + ss << ", latency.snapshot_copy_delay_us=" << config.latency.snapshot_copy_delay_us; + ss << ", latency.emit_stall_ms=" << config.latency.emit_stall_ms; + return ss.str(); +} + +} diff --git a/src/core/ingest_runtime.cpp b/src/core/ingest_runtime.cpp new file mode 100644 index 0000000..4ae62ce --- /dev/null +++ b/src/core/ingest_runtime.cpp @@ -0,0 +1,388 @@ +#include "cvmmap_streamer/config/runtime_config.hpp" +#include "cvmmap_streamer/ipc/contracts.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace cvmmap_streamer::core { + +namespace { + + using namespace std::chrono_literals; + namespace ipc = cvmmap_streamer::ipc; + + struct SharedMemoryView { + SharedMemoryView() = default; + + int fd{-1}; + std::uint8_t *ptr{nullptr}; + std::size_t bytes{0}; + + ~SharedMemoryView() { + if (ptr != nullptr && ptr != MAP_FAILED && bytes > 0) { + munmap(ptr, bytes); + } + if (fd >= 0) { + close(fd); + } + } + + SharedMemoryView(const SharedMemoryView &) = delete; + SharedMemoryView &operator=(const SharedMemoryView &) = delete; + + SharedMemoryView(SharedMemoryView &&other) noexcept { + fd = std::exchange(other.fd, -1); + ptr = std::exchange(other.ptr, nullptr); + bytes = std::exchange(other.bytes, 0); + } + + SharedMemoryView &operator=(SharedMemoryView &&other) noexcept { + if (this == &other) { + return *this; + } + if (ptr != nullptr && ptr != MAP_FAILED && bytes > 0) { + munmap(ptr, bytes); + } + if (fd >= 0) { + close(fd); + } + fd = std::exchange(other.fd, -1); + ptr = std::exchange(other.ptr, nullptr); + bytes = std::exchange(other.bytes, 0); + return *this; + } + + [[nodiscard]] + std::span region() const { + return std::span(ptr, bytes); + } + + [[nodiscard]] + static std::expected open_readonly(const std::string &raw_name) { + const auto shm_name = raw_name.starts_with('/') ? raw_name : "/" + raw_name; + const int fd = shm_open(shm_name.c_str(), O_RDONLY, 0); + if (fd < 0) { + return std::unexpected("shm_open failed for '" + shm_name + "'"); + } + + struct stat statbuf { + }; + if (fstat(fd, &statbuf) != 0) { + close(fd); + return std::unexpected("fstat failed for '" + shm_name + "'"); + } + if (statbuf.st_size <= 0) { + close(fd); + return std::unexpected("shared memory size is zero for '" + shm_name + "'"); + } + + const auto bytes = static_cast(statbuf.st_size); + auto *mapped = static_cast(mmap(nullptr, bytes, PROT_READ, MAP_SHARED, fd, 0)); + if (mapped == MAP_FAILED) { + close(fd); + return std::unexpected("mmap failed for '" + shm_name + "'"); + } + + SharedMemoryView view; + view.fd = fd; + view.ptr = mapped; + view.bytes = bytes; + return view; + } + }; + + struct IngestFrame { + std::uint32_t frame_count; + std::uint64_t timestamp_ns; + std::size_t payload_bytes; + }; + + struct IngestStats { + std::uint64_t dropped_frames{0}; + std::uint64_t torn_frames{0}; + std::uint64_t resets{0}; + std::uint64_t decode_reconfigs{0}; + std::uint64_t sync_messages{0}; + std::uint64_t status_messages{0}; + std::atomic consumed_frames{0}; + }; + + [[nodiscard]] + bool frame_info_equal(const ipc::FrameInfo &lhs, const ipc::FrameInfo &rhs) { + return lhs.width == rhs.width && + lhs.height == rhs.height && + lhs.channels == rhs.channels && + lhs.depth == rhs.depth && + lhs.pixel_format == rhs.pixel_format && + lhs.buffer_size == rhs.buffer_size; + } + + [[nodiscard]] + std::string status_to_string(ipc::ModuleStatus status) { + switch (status) { + case ipc::ModuleStatus::Online: + return "online"; + case ipc::ModuleStatus::Offline: + return "offline"; + case ipc::ModuleStatus::StreamReset: + return "stream_reset"; + } + return "unknown"; + } + +} + +int run_ingest_loop(const RuntimeConfig &config) { + auto shm = SharedMemoryView::open_readonly(config.input.shm_name); + if (!shm) { + spdlog::error("ingest open shared memory failed: {}", shm.error()); + return 3; + } + + if (shm->bytes <= ipc::kShmPayloadOffset) { + spdlog::error("ingest invalid shared memory size: {}", shm->bytes); + return 3; + } + + const std::size_t queue_capacity = std::max(1, config.latency.queue_size); + std::vector snapshot_buffer(shm->bytes - ipc::kShmPayloadOffset, static_cast(0)); + + zmq::context_t zmq_ctx{1}; + zmq::socket_t subscriber(zmq_ctx, zmq::socket_type::sub); + try { + subscriber.set(zmq::sockopt::subscribe, ""); + subscriber.set(zmq::sockopt::rcvtimeo, 20); + subscriber.connect(config.input.zmq_endpoint); + } catch (const zmq::error_t &e) { + spdlog::error("ingest subscribe failed on '{}': {}", config.input.zmq_endpoint, e.what()); + return 4; + } + + std::mutex queue_mutex; + std::condition_variable queue_cv; + std::deque queue; + std::size_t queue_depth_peak{0}; + std::optional last_frame_info{}; + std::atomic_bool stop_requested{false}; + std::atomic_bool producer_offline{false}; + + IngestStats stats{}; + + std::thread consumer([&]() { + while (true) { + std::optional next{}; + { + std::unique_lock lock(queue_mutex); + queue_cv.wait_for(lock, 25ms, [&]() { + return stop_requested.load(std::memory_order_relaxed) || !queue.empty(); + }); + if (stop_requested.load(std::memory_order_relaxed) && queue.empty()) { + break; + } + if (!queue.empty()) { + next.emplace(std::move(queue.front())); + queue.pop_front(); + } + } + + if (!next) { + continue; + } + + if (config.latency.ingest_consumer_delay_ms > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(config.latency.ingest_consumer_delay_ms)); + } + + const auto consumed = stats.consumed_frames.fetch_add(1, std::memory_order_relaxed) + 1; + spdlog::debug( + "consume frame_count={} timestamp_ns={} payload_bytes={} consumed_frames={}", + next->frame_count, + next->timestamp_ns, + next->payload_bytes, + consumed); + + if (config.latency.ingest_max_frames > 0 && consumed >= config.latency.ingest_max_frames) { + stop_requested.store(true, std::memory_order_relaxed); + queue_cv.notify_all(); + } + } + }); + + const auto idle_timeout = std::chrono::milliseconds(config.latency.ingest_idle_timeout_ms); + auto last_event = std::chrono::steady_clock::now(); + + while (!stop_requested.load(std::memory_order_relaxed)) { + zmq::message_t message; + const auto recv_result = subscriber.recv(message, zmq::recv_flags::none); + if (!recv_result) { + const auto now = std::chrono::steady_clock::now(); + if (now - last_event >= idle_timeout) { + spdlog::info( + "ingest idle timeout reached ({} ms), stopping", + config.latency.ingest_idle_timeout_ms); + break; + } + + if (producer_offline.load(std::memory_order_relaxed)) { + std::lock_guard lock(queue_mutex); + if (queue.empty()) { + spdlog::info("producer offline and queue drained, stopping ingest loop"); + break; + } + } + continue; + } + + last_event = std::chrono::steady_clock::now(); + const auto bytes = std::span( + static_cast(message.data()), + message.size()); + if (bytes.empty()) { + continue; + } + + if (bytes[0] == ipc::kFrameTopicMagic) { + stats.sync_messages += 1; + auto sync = ipc::parse_sync_message(bytes); + if (!sync) { + spdlog::warn("sync parse error: {}", ipc::to_string(sync.error())); + continue; + } + + auto snapshot = ipc::read_coherent_snapshot(shm->region(), snapshot_buffer); + if (!snapshot) { + if (snapshot.error() == ipc::SnapshotError::TornRead) { + stats.torn_frames += 1; + } + spdlog::warn("snapshot rejected: {}", ipc::to_string(snapshot.error())); + continue; + } + + if (last_frame_info && !frame_info_equal(*last_frame_info, snapshot->metadata.info)) { + stats.decode_reconfigs += 1; + spdlog::info( + "decode reconfig detected old={}x{}x{} new={}x{}x{}", + last_frame_info->width, + last_frame_info->height, + static_cast(last_frame_info->channels), + snapshot->metadata.info.width, + snapshot->metadata.info.height, + static_cast(snapshot->metadata.info.channels)); + } + last_frame_info = snapshot->metadata.info; + + IngestFrame frame{ + .frame_count = snapshot->metadata.frame_count, + .timestamp_ns = snapshot->metadata.timestamp_ns, + .payload_bytes = snapshot->bytes_copied}; + + std::size_t depth_after_push{0}; + { + std::lock_guard lock(queue_mutex); + while (queue.size() >= queue_capacity) { + queue.pop_front(); + stats.dropped_frames += 1; + } + queue.push_back(std::move(frame)); + depth_after_push = queue.size(); + queue_depth_peak = std::max(queue_depth_peak, depth_after_push); + } + queue_cv.notify_one(); + + spdlog::debug( + "ingest sync={} snapshot={} queue_depth={} dropped_frames={}", + sync->frame_count, + snapshot->metadata.frame_count, + depth_after_push, + stats.dropped_frames); + continue; + } + + if (bytes[0] == ipc::kModuleStatusMagic) { + stats.status_messages += 1; + auto status = ipc::parse_module_status_message(bytes); + if (!status) { + spdlog::warn("status parse error: {}", ipc::to_string(status.error())); + continue; + } + + spdlog::info( + "status event label='{}' status={}", + status->label(), + status_to_string(status->module_status)); + + if (status->module_status == ipc::ModuleStatus::Online) { + producer_offline.store(false, std::memory_order_relaxed); + } + + if (status->module_status == ipc::ModuleStatus::Offline) { + producer_offline.store(true, std::memory_order_relaxed); + } + + if (status->module_status == ipc::ModuleStatus::StreamReset) { + stats.resets += 1; + std::size_t cleared{0}; + { + std::lock_guard lock(queue_mutex); + cleared = queue.size(); + queue.clear(); + } + last_frame_info.reset(); + spdlog::info( + "ingest state reset applied queue_cleared={} resets={}", + cleared, + stats.resets); + } + continue; + } + + spdlog::warn("unknown message type: magic=0x{:02x} size={}", bytes[0], bytes.size()); + } + + stop_requested.store(true, std::memory_order_relaxed); + queue_cv.notify_all(); + consumer.join(); + + std::size_t final_depth{0}; + { + std::lock_guard lock(queue_mutex); + final_depth = queue.size(); + } + + spdlog::info( + "INGEST_METRICS queue_capacity={} queue_depth={} queue_depth_peak={} dropped_frames={} torn_frames={} resets={} decode_reconfigs={} consumed_frames={} sync_messages={} status_messages={}", + queue_capacity, + final_depth, + queue_depth_peak, + stats.dropped_frames, + stats.torn_frames, + stats.resets, + stats.decode_reconfigs, + stats.consumed_frames.load(std::memory_order_relaxed), + stats.sync_messages, + stats.status_messages); + + return 0; +} + +} diff --git a/src/ipc/contracts.cpp b/src/ipc/contracts.cpp new file mode 100644 index 0000000..a5148e4 --- /dev/null +++ b/src/ipc/contracts.cpp @@ -0,0 +1,356 @@ +#include "cvmmap_streamer/ipc/contracts.hpp" + +#include +#include + +namespace cvmmap_streamer::ipc { + +namespace { + + constexpr std::size_t kSyncMessageSize = 48; + constexpr std::size_t kModuleStatusMessageSize = 32; + constexpr std::size_t kControlRequestBaseSize = 36; + constexpr std::size_t kControlResponseBaseSize = 40; + constexpr std::size_t kFrameMetadataRequiredBytes = 36; + + constexpr std::size_t kSyncFrameCountOffset = 4; + constexpr std::size_t kSyncTimestampOffset = 16; + constexpr std::size_t kSyncLabelOffset = 24; + + constexpr std::size_t kModuleStatusCodeOffset = 4; + constexpr std::size_t kModuleStatusLabelOffset = 8; + + constexpr std::size_t kControlCommandOffset = 4; + constexpr std::size_t kControlReqLabelOffset = 8; + constexpr std::size_t kControlReqLengthOffset = 32; + constexpr std::size_t kControlReqPayloadOffset = 34; + + constexpr std::size_t kControlRespCodeOffset = 8; + constexpr std::size_t kControlRespLabelOffset = 12; + constexpr std::size_t kControlRespLengthOffset = 36; + constexpr std::size_t kControlRespPayloadOffset = 38; + + constexpr std::size_t kMetaVersionMajorOffset = 8; + constexpr std::size_t kMetaVersionMinorOffset = 9; + constexpr std::size_t kMetaFrameCountOffset = 12; + constexpr std::size_t kMetaTimestampOffset = 16; + constexpr std::size_t kMetaFrameInfoOffset = 24; + + constexpr std::size_t kFrameInfoWidthOffset = 0; + constexpr std::size_t kFrameInfoHeightOffset = 2; + constexpr std::size_t kFrameInfoChannelsOffset = 4; + constexpr std::size_t kFrameInfoDepthOffset = 5; + constexpr std::size_t kFrameInfoPixelFmtOffset = 6; + constexpr std::size_t kFrameInfoBufferSizeOffset = 8; + + [[nodiscard]] + std::uint16_t read_u16_le(std::span bytes, std::size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1]) << 8); + } + + [[nodiscard]] + std::uint32_t read_u32_le(std::span bytes, std::size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1]) << 8) | + (static_cast(bytes[offset + 2]) << 16) | + (static_cast(bytes[offset + 3]) << 24); + } + + [[nodiscard]] + std::int32_t read_i32_le(std::span bytes, std::size_t offset) { + return static_cast(read_u32_le(bytes, offset)); + } + + [[nodiscard]] + std::uint64_t read_u64_le(std::span bytes, std::size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1]) << 8) | + (static_cast(bytes[offset + 2]) << 16) | + (static_cast(bytes[offset + 3]) << 24) | + (static_cast(bytes[offset + 4]) << 32) | + (static_cast(bytes[offset + 5]) << 40) | + (static_cast(bytes[offset + 6]) << 48) | + (static_cast(bytes[offset + 7]) << 56); + } + + [[nodiscard]] + bool is_supported_version(std::uint8_t major, std::uint8_t minor) { + return major == kVersionMajor && minor == kVersionMinor; + } + + [[nodiscard]] + std::expected validate_depth(std::uint8_t depth_raw) { + if (depth_raw > static_cast(Depth::F16)) { + return std::unexpected(ParseError::InvalidDepth); + } + return static_cast(depth_raw); + } + + [[nodiscard]] + std::expected validate_pixel_format(std::uint8_t pixel_format_raw) { + if (pixel_format_raw > static_cast(PixelFormat::YUYV)) { + return std::unexpected(ParseError::InvalidPixelFormat); + } + return static_cast(pixel_format_raw); + } + + [[nodiscard]] + std::expected validate_module_status(std::int32_t status_raw) { + switch (status_raw) { + case static_cast(ModuleStatus::Online): + return ModuleStatus::Online; + case static_cast(ModuleStatus::Offline): + return ModuleStatus::Offline; + case static_cast(ModuleStatus::StreamReset): + return ModuleStatus::StreamReset; + default: + return std::unexpected(ParseError::InvalidModuleStatus); + } + } + +} + +std::string_view to_string(ParseError error) { + switch (error) { + case ParseError::BufferTooSmall: + return "buffer too small"; + case ParseError::InvalidSize: + return "invalid message size"; + case ParseError::InvalidMagic: + return "invalid magic"; + case ParseError::UnsupportedVersion: + return "unsupported version"; + case ParseError::InvalidDepth: + return "invalid depth"; + case ParseError::InvalidPixelFormat: + return "invalid pixel format"; + case ParseError::InvalidModuleStatus: + return "invalid module status"; + case ParseError::PayloadLengthMismatch: + return "payload length mismatch"; + } + return "unknown parse error"; +} + +std::string_view to_string(SnapshotError error) { + switch (error) { + case SnapshotError::InvalidShmLayout: + return "invalid shared memory layout"; + case SnapshotError::DestinationTooSmall: + return "destination buffer too small"; + case SnapshotError::TornRead: + return "torn read"; + } + return "unknown snapshot error"; +} + +std::expected parse_frame_metadata(std::span bytes) { + if (bytes.size() < kFrameMetadataRequiredBytes) { + return std::unexpected(ParseError::BufferTooSmall); + } + + FrameMetadata metadata{}; + std::copy_n(bytes.begin(), kFrameMetadataMagic.size(), metadata.magic.begin()); + if (metadata.magic != kFrameMetadataMagic) { + return std::unexpected(ParseError::InvalidMagic); + } + + metadata.versions_major = bytes[kMetaVersionMajorOffset]; + metadata.versions_minor = bytes[kMetaVersionMinorOffset]; + if (!is_supported_version(metadata.versions_major, metadata.versions_minor)) { + return std::unexpected(ParseError::UnsupportedVersion); + } + + metadata.frame_count = read_u32_le(bytes, kMetaFrameCountOffset); + metadata.timestamp_ns = read_u64_le(bytes, kMetaTimestampOffset); + + auto frame_info_bytes = bytes.subspan(kMetaFrameInfoOffset); + auto depth = validate_depth(frame_info_bytes[kFrameInfoDepthOffset]); + if (!depth) { + return std::unexpected(depth.error()); + } + auto pixel_format = validate_pixel_format(frame_info_bytes[kFrameInfoPixelFmtOffset]); + if (!pixel_format) { + return std::unexpected(pixel_format.error()); + } + + metadata.info.width = read_u16_le(frame_info_bytes, kFrameInfoWidthOffset); + metadata.info.height = read_u16_le(frame_info_bytes, kFrameInfoHeightOffset); + metadata.info.channels = frame_info_bytes[kFrameInfoChannelsOffset]; + metadata.info.depth = *depth; + metadata.info.pixel_format = *pixel_format; + metadata.info.buffer_size = read_u32_le(frame_info_bytes, kFrameInfoBufferSizeOffset); + + return metadata; +} + +std::expected parse_sync_message(std::span bytes) { + if (bytes.size() < kSyncMessageSize) { + return std::unexpected(ParseError::BufferTooSmall); + } + if (bytes.size() != kSyncMessageSize) { + return std::unexpected(ParseError::InvalidSize); + } + if (bytes[0] != kFrameTopicMagic) { + return std::unexpected(ParseError::InvalidMagic); + } + if (!is_supported_version(bytes[2], bytes[3])) { + return std::unexpected(ParseError::UnsupportedVersion); + } + + SyncMessage message{}; + message.versions_major = bytes[2]; + message.versions_minor = bytes[3]; + message.frame_count = read_u32_le(bytes, kSyncFrameCountOffset); + message.timestamp_ns = read_u64_le(bytes, kSyncTimestampOffset); + std::copy_n(bytes.begin() + kSyncLabelOffset, kLabelLenMax, message.label_bytes.begin()); + + return message; +} + +std::expected parse_module_status_message(std::span bytes) { + if (bytes.size() < kModuleStatusMessageSize) { + return std::unexpected(ParseError::BufferTooSmall); + } + if (bytes.size() != kModuleStatusMessageSize) { + return std::unexpected(ParseError::InvalidSize); + } + if (bytes[0] != kModuleStatusMagic) { + return std::unexpected(ParseError::InvalidMagic); + } + if (!is_supported_version(bytes[2], bytes[3])) { + return std::unexpected(ParseError::UnsupportedVersion); + } + + auto status = validate_module_status(read_i32_le(bytes, kModuleStatusCodeOffset)); + if (!status) { + return std::unexpected(status.error()); + } + + ModuleStatusMessage message{}; + message.versions_major = bytes[2]; + message.versions_minor = bytes[3]; + message.module_status = *status; + std::copy_n(bytes.begin() + kModuleStatusLabelOffset, kLabelLenMax, message.label_bytes.begin()); + + return message; +} + +std::expected parse_control_request_message(std::span bytes) { + if (bytes.size() < kControlRequestBaseSize) { + return std::unexpected(ParseError::BufferTooSmall); + } + if (bytes[0] != kControlRequestMagic) { + return std::unexpected(ParseError::InvalidMagic); + } + if (!is_supported_version(bytes[2], bytes[3])) { + return std::unexpected(ParseError::UnsupportedVersion); + } + + const auto payload_size = static_cast(read_u16_le(bytes, kControlReqLengthOffset)); + if (payload_size > bytes.size() - kControlReqPayloadOffset) { + return std::unexpected(ParseError::PayloadLengthMismatch); + } + if (bytes.size() != kControlRequestBaseSize + payload_size) { + return std::unexpected(ParseError::InvalidSize); + } + + ControlRequestMessage message{}; + message.versions_major = bytes[2]; + message.versions_minor = bytes[3]; + message.command_id = read_i32_le(bytes, kControlCommandOffset); + std::copy_n(bytes.begin() + kControlReqLabelOffset, kLabelLenMax, message.label_bytes.begin()); + message.request_payload = bytes.subspan(kControlReqPayloadOffset, payload_size); + + return message; +} + +std::expected parse_control_response_message(std::span bytes) { + if (bytes.size() < kControlResponseBaseSize) { + return std::unexpected(ParseError::BufferTooSmall); + } + if (bytes[0] != kControlResponseMagic) { + return std::unexpected(ParseError::InvalidMagic); + } + if (!is_supported_version(bytes[2], bytes[3])) { + return std::unexpected(ParseError::UnsupportedVersion); + } + + const auto payload_size = static_cast(read_u16_le(bytes, kControlRespLengthOffset)); + if (payload_size > bytes.size() - kControlRespPayloadOffset) { + return std::unexpected(ParseError::PayloadLengthMismatch); + } + if (bytes.size() != kControlResponseBaseSize + payload_size) { + return std::unexpected(ParseError::InvalidSize); + } + + ControlResponseMessage message{}; + message.versions_major = bytes[2]; + message.versions_minor = bytes[3]; + message.command_id = read_i32_le(bytes, kControlCommandOffset); + message.response_code = read_i32_le(bytes, kControlRespCodeOffset); + std::copy_n(bytes.begin() + kControlRespLabelOffset, kLabelLenMax, message.label_bytes.begin()); + message.response_payload = bytes.subspan(kControlRespPayloadOffset, payload_size); + + return message; +} + +std::expected validate_shm_region(std::span shm_region) { + if (shm_region.size() < kShmPayloadOffset) { + return std::unexpected(ParseError::BufferTooSmall); + } + + auto metadata_result = parse_frame_metadata(shm_region); + if (!metadata_result) { + return std::unexpected(metadata_result.error()); + } + + const auto payload_size = static_cast(metadata_result->info.buffer_size); + if (payload_size > std::numeric_limits::max() - kShmPayloadOffset) { + return std::unexpected(ParseError::InvalidSize); + } + if (payload_size > shm_region.size() - kShmPayloadOffset) { + return std::unexpected(ParseError::InvalidSize); + } + + return ValidatedShmView{ + .metadata = *metadata_result, + .payload = shm_region.subspan(kShmPayloadOffset, payload_size)}; +} + +std::expected read_coherent_snapshot( + std::span shm_region, + std::span destination, + const SnapshotReadHook &before_second_metadata_read) { + auto first = validate_shm_region(shm_region); + if (!first) { + return std::unexpected(SnapshotError::InvalidShmLayout); + } + + if (destination.size() < first->payload.size()) { + return std::unexpected(SnapshotError::DestinationTooSmall); + } + + std::copy(first->payload.begin(), first->payload.end(), destination.begin()); + + if (before_second_metadata_read) { + before_second_metadata_read(); + } + + auto second = validate_shm_region(shm_region); + if (!second) { + return std::unexpected(SnapshotError::InvalidShmLayout); + } + + if (first->metadata.frame_count != second->metadata.frame_count || + first->metadata.timestamp_ns != second->metadata.timestamp_ns) { + return std::unexpected(SnapshotError::TornRead); + } + + return CoherentSnapshot{ + .metadata = first->metadata, + .bytes_copied = first->payload.size()}; +} + +} diff --git a/src/ipc/help.cpp b/src/ipc/help.cpp new file mode 100644 index 0000000..9f622c2 --- /dev/null +++ b/src/ipc/help.cpp @@ -0,0 +1,43 @@ +#include +#include + +#include + +#include "cvmmap_streamer/common.h" + +namespace cvmmap_streamer { + +namespace { + + constexpr std::array kHelpLines{ + "Usage:", + " --help, -h\tshow this message", + "", + "Options:", + " --version\tprint version information", + "", + "Examples:", + " cvmmap_streamer --help", + " cvmmap_sim --help", + " rtp_receiver_tester --help"}; + +} + +void print_help(std::string_view executable) { + spdlog::info("{}", executable); + for (const auto &item : kHelpLines) { + spdlog::info("{}", item); + } +} + +bool has_help_flag(int argc, char **argv) { + for (int i = 1; i < argc; ++i) { + std::string_view arg{argv[i]}; + if (arg == "--help" || arg == "-h") { + return true; + } + } + return false; +} + +} diff --git a/src/ipc/ipc_stub.cpp b/src/ipc/ipc_stub.cpp new file mode 100644 index 0000000..4d9d6ed --- /dev/null +++ b/src/ipc/ipc_stub.cpp @@ -0,0 +1,11 @@ +#include + +#include "cvmmap_streamer/ipc/contracts.hpp" + +namespace cvmmap_streamer { + +std::size_t sample_ipc_payload_size() { + return ipc::kShmPayloadOffset; +} + +} diff --git a/src/main_sim.cpp b/src/main_sim.cpp new file mode 100644 index 0000000..648c25d --- /dev/null +++ b/src/main_sim.cpp @@ -0,0 +1,280 @@ +#include "cvmmap_streamer/common.h" +#include "cvmmap_streamer/ipc/contracts.hpp" +#include "cvmmap_streamer/sim/options.hpp" +#include "cvmmap_streamer/sim/wire.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace ipc = cvmmap_streamer::ipc; + +class SharedMemoryRegion { +public: + static std::expected create(const std::string &name, std::size_t bytes) { + const std::string shm_name = "/" + name; + const int fd = shm_open(shm_name.c_str(), O_CREAT | O_RDWR, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH); + if (fd < 0) { + return std::unexpected("shm_open failed"); + } + + if (ftruncate(fd, static_cast(bytes)) != 0) { + close(fd); + shm_unlink(shm_name.c_str()); + return std::unexpected("ftruncate failed"); + } + + auto *mapped = static_cast(mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)); + if (mapped == MAP_FAILED) { + close(fd); + shm_unlink(shm_name.c_str()); + return std::unexpected("mmap failed"); + } + + std::memset(mapped, 0, bytes); + return SharedMemoryRegion(shm_name, fd, mapped, bytes); + } + + SharedMemoryRegion(const SharedMemoryRegion &) = delete; + SharedMemoryRegion &operator=(const SharedMemoryRegion &) = delete; + + SharedMemoryRegion(SharedMemoryRegion &&other) noexcept + : name_(std::move(other.name_)), + fd_(other.fd_), + ptr_(other.ptr_), + bytes_(other.bytes_) { + other.fd_ = -1; + other.ptr_ = nullptr; + other.bytes_ = 0; + } + + SharedMemoryRegion &operator=(SharedMemoryRegion &&other) noexcept { + if (this == &other) { + return *this; + } + cleanup(); + name_ = std::move(other.name_); + fd_ = other.fd_; + ptr_ = other.ptr_; + bytes_ = other.bytes_; + other.fd_ = -1; + other.ptr_ = nullptr; + other.bytes_ = 0; + return *this; + } + + ~SharedMemoryRegion() { + cleanup(); + } + + [[nodiscard]] + std::span metadata() { + return std::span(ptr_, ipc::kShmPayloadOffset); + } + + [[nodiscard]] + std::span payload(std::size_t payload_bytes) { + return std::span(ptr_ + ipc::kShmPayloadOffset, payload_bytes); + } + +private: + SharedMemoryRegion(std::string name, int fd, std::uint8_t *ptr, std::size_t bytes) + : name_(std::move(name)), fd_(fd), ptr_(ptr), bytes_(bytes) {} + + void cleanup() { + if (ptr_ != nullptr && bytes_ > 0) { + munmap(ptr_, bytes_); + ptr_ = nullptr; + } + if (fd_ >= 0) { + close(fd_); + fd_ = -1; + } + if (!name_.empty()) { + shm_unlink(name_.c_str()); + } + } + + std::string name_; + int fd_{-1}; + std::uint8_t *ptr_{nullptr}; + std::size_t bytes_{0}; +}; + +void cleanup_zmq_ipc_path(std::string_view endpoint) { + constexpr std::string_view kPrefix{"ipc://"}; + if (!endpoint.starts_with(kPrefix)) { + return; + } + const auto path = endpoint.substr(kPrefix.size()); + if (!path.empty()) { + unlink(std::string(path).c_str()); + } +} + +} + +int main(int argc, char **argv) { + if (cvmmap_streamer::has_help_flag(argc, argv)) { + cvmmap_streamer::sim::print_help(); + return 0; + } + + auto config = cvmmap_streamer::sim::parse_runtime_config(argc, argv); + if (!config) { + spdlog::error("{}", config.error()); + cvmmap_streamer::sim::print_help(); + return 2; + } + + const auto payload_bytes = static_cast(config->payload_size_bytes()); + const auto switched_width = config->switch_width.value_or(config->width); + const auto switched_height = config->switch_height.value_or(config->height); + const auto switched_payload_bytes = + static_cast(switched_width) * + static_cast(switched_height) * + static_cast(config->channels); + const auto payload_bytes_max = std::max(payload_bytes, switched_payload_bytes); + const auto shm_bytes = ipc::kShmPayloadOffset + payload_bytes_max; + + auto shm = SharedMemoryRegion::create(config->shm_name, shm_bytes); + if (!shm) { + spdlog::error("failed to create shared memory '{}': {}", config->shm_name, shm.error()); + return 3; + } + + cleanup_zmq_ipc_path(config->zmq_endpoint); + std::optional publisher; + try { + static zmq::context_t context{1}; + publisher.emplace(context, zmq::socket_type::pub); + publisher->bind(config->zmq_endpoint); + } catch (const zmq::error_t &e) { + spdlog::error("failed to bind zmq endpoint '{}': {}", config->zmq_endpoint, e.what()); + return 4; + } + + ipc::FrameInfo frame_info{ + .width = config->width, + .height = config->height, + .channels = config->channels, + .depth = config->depth, + .pixel_format = config->pixel_format, + .buffer_size = config->payload_size_bytes()}; + + std::array sync_msg{}; + std::array status_msg{}; + + const auto send_status = [&](ipc::ModuleStatus status, std::uint32_t frame_count) { + cvmmap_streamer::sim::write_module_status_message(status_msg, config->label, status); + publisher->send(zmq::buffer(status_msg), zmq::send_flags::none); + spdlog::info("status={} frame_count={}", static_cast(status), frame_count); + }; + + std::uint64_t timestamp_ns = 1'000'000'000ull; + const std::uint64_t tick_ns = + (config->fps == 0) + ? 0ull + : std::max(1ull, 1'000'000'000ull / static_cast(config->fps)); + + spdlog::info( + "sim start shm='{}' zmq='{}' label='{}' frames={} fps={} {}x{} payload={}", + config->shm_name, + config->zmq_endpoint, + config->label, + config->frames, + config->fps, + config->width, + config->height, + payload_bytes); + + send_status(ipc::ModuleStatus::Online, 0); + + bool reset_sent{false}; + std::uint32_t reset_every_count{0}; + bool format_switched{false}; + for (std::uint32_t frame_count = 1; frame_count <= config->frames; ++frame_count) { + if (!format_switched && config->switch_format_at && *config->switch_format_at == frame_count) { + frame_info.width = switched_width; + frame_info.height = switched_height; + frame_info.buffer_size = static_cast(switched_payload_bytes); + format_switched = true; + spdlog::info( + "sim format switch at frame={} new={}x{} channels={} payload={}", + frame_count, + frame_info.width, + frame_info.height, + static_cast(frame_info.channels), + frame_info.buffer_size); + } + + const auto active_payload_bytes = static_cast(frame_info.buffer_size); + auto payload = shm->payload(active_payload_bytes); + cvmmap_streamer::sim::write_deterministic_payload( + payload, + frame_count, + frame_info.width, + frame_info.height, + config->channels); + + cvmmap_streamer::sim::write_frame_metadata( + shm->metadata(), + frame_info, + frame_count, + timestamp_ns); + + cvmmap_streamer::sim::write_sync_message( + sync_msg, + config->label, + frame_count, + timestamp_ns); + publisher->send(zmq::buffer(sync_msg), zmq::send_flags::none); + + spdlog::info("sync frame_count={} timestamp_ns={}", frame_count, timestamp_ns); + + if (!reset_sent && config->emit_reset_at && *config->emit_reset_at == frame_count) { + send_status(ipc::ModuleStatus::StreamReset, frame_count); + reset_sent = true; + } + + if (config->emit_reset_every && *config->emit_reset_every > 0 && (frame_count % *config->emit_reset_every) == 0) { + send_status(ipc::ModuleStatus::StreamReset, frame_count); + reset_sent = true; + reset_every_count += 1; + } + + timestamp_ns += tick_ns; + if (config->fps > 0) { + std::this_thread::sleep_for(std::chrono::nanoseconds(tick_ns)); + } + } + + send_status(ipc::ModuleStatus::Offline, config->frames); + cleanup_zmq_ipc_path(config->zmq_endpoint); + spdlog::info( + "sim complete frames={} reset_sent={} periodic_resets={} format_switched={}", + config->frames, + reset_sent ? "true" : "false", + reset_every_count, + format_switched ? "true" : "false"); + return 0; +} diff --git a/src/main_streamer.cpp b/src/main_streamer.cpp new file mode 100644 index 0000000..d904b8f --- /dev/null +++ b/src/main_streamer.cpp @@ -0,0 +1,44 @@ +#include "cvmmap_streamer/common.h" +#include "cvmmap_streamer/config/runtime_config.hpp" + +#include + +namespace cvmmap_streamer::core { + +int run_ingest_loop(const RuntimeConfig &config); +int run_nvenc_pipeline(const RuntimeConfig &config); + +} + +int main(int argc, char **argv) { + if (argc <= 1 || cvmmap_streamer::has_help_flag(argc, argv)) { + cvmmap_streamer::print_help("cvmmap_streamer"); + return 0; + } + + auto config = cvmmap_streamer::parse_runtime_config(argc, argv); + if (!config) { + spdlog::error("{}", config.error()); + cvmmap_streamer::print_help("cvmmap_streamer"); + return 2; + } + + auto validation = cvmmap_streamer::validate_runtime_config(*config); + if (!validation) { + spdlog::error("{}", validation.error()); + cvmmap_streamer::print_help("cvmmap_streamer"); + return 2; + } + + spdlog::info("runtime config: {}", cvmmap_streamer::summarize_runtime_config(*config)); + + switch (config->run_mode) { + case cvmmap_streamer::RunMode::Pipeline: + return cvmmap_streamer::core::run_nvenc_pipeline(*config); + case cvmmap_streamer::RunMode::Ingest: + return cvmmap_streamer::core::run_ingest_loop(*config); + } + + spdlog::error("unknown run mode"); + return 2; +} diff --git a/src/metrics/latency_tracker.cpp b/src/metrics/latency_tracker.cpp new file mode 100644 index 0000000..4e0e692 --- /dev/null +++ b/src/metrics/latency_tracker.cpp @@ -0,0 +1,83 @@ +#include "cvmmap_streamer/metrics/latency_tracker.hpp" + +#include +#include + +namespace cvmmap_streamer::metrics { + +namespace { + + [[nodiscard]] + std::uint64_t now_ns() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); + } + + [[nodiscard]] + std::uint64_t percentile_from_sorted(const std::vector &sorted, std::uint32_t p) { + if (sorted.empty()) { + return 0; + } + const auto n = sorted.size(); + const auto idx = ((n - 1) * static_cast(p)) / 100; + return sorted[idx]; + } + +} + +void IngestEmitLatencyTracker::note_ingest() { + ingest_queue_ns_.push_back(now_ns()); +} + +void IngestEmitLatencyTracker::note_emit() { + if (ingest_queue_ns_.empty()) { + return; + } + + const auto end_ns = now_ns(); + const auto begin_ns = ingest_queue_ns_.front(); + ingest_queue_ns_.pop_front(); + + const auto elapsed_ns = end_ns >= begin_ns ? end_ns - begin_ns : 0ull; + samples_us_.push_back(elapsed_ns / 1'000ull); +} + +void IngestEmitLatencyTracker::note_emit_stall() { + emit_stall_events_ += 1; +} + +std::uint64_t IngestEmitLatencyTracker::emit_stall_events() const { + return emit_stall_events_; +} + +std::uint64_t IngestEmitLatencyTracker::pending_frames() const { + return static_cast(ingest_queue_ns_.size()); +} + +LatencySummary IngestEmitLatencyTracker::summarize() const { + LatencySummary summary{}; + summary.samples = static_cast(samples_us_.size()); + if (samples_us_.empty()) { + return summary; + } + + auto sorted = samples_us_; + std::sort(sorted.begin(), sorted.end()); + + summary.min_us = sorted.front(); + summary.max_us = sorted.back(); + + std::uint64_t total{0}; + for (const auto item : sorted) { + total += item; + } + summary.avg_us = total / static_cast(sorted.size()); + + summary.p50_us = percentile_from_sorted(sorted, 50); + summary.p95_us = percentile_from_sorted(sorted, 95); + summary.p99_us = percentile_from_sorted(sorted, 99); + return summary; +} + +} diff --git a/src/pipeline/pipeline_stub.cpp b/src/pipeline/pipeline_stub.cpp new file mode 100644 index 0000000..14e5e15 --- /dev/null +++ b/src/pipeline/pipeline_stub.cpp @@ -0,0 +1,1141 @@ +#include "cvmmap_streamer/config/runtime_config.hpp" +#include "cvmmap_streamer/ipc/contracts.hpp" +#include "cvmmap_streamer/metrics/latency_tracker.hpp" +#include "cvmmap_streamer/protocol/rtmp_publisher.hpp" +#include "cvmmap_streamer/protocol/rtp_publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#if __has_include() && __has_include() && __has_include() +#define CVMMAP_STREAMER_HAS_GSTREAMER 1 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#else +#define CVMMAP_STREAMER_HAS_GSTREAMER 0 +#endif + +namespace cvmmap_streamer { + +void pipeline_tick() {} + +} + +namespace cvmmap_streamer::core { + +namespace { + + namespace ipc = cvmmap_streamer::ipc; + + struct SharedMemoryView { + SharedMemoryView() = default; + + int fd{-1}; + std::uint8_t *ptr{nullptr}; + std::size_t bytes{0}; + + ~SharedMemoryView() { + if (ptr != nullptr && ptr != MAP_FAILED && bytes > 0) { + munmap(ptr, bytes); + } + if (fd >= 0) { + close(fd); + } + } + + SharedMemoryView(const SharedMemoryView &) = delete; + SharedMemoryView &operator=(const SharedMemoryView &) = delete; + + SharedMemoryView(SharedMemoryView &&other) noexcept { + fd = std::exchange(other.fd, -1); + ptr = std::exchange(other.ptr, nullptr); + bytes = std::exchange(other.bytes, 0); + } + + SharedMemoryView &operator=(SharedMemoryView &&other) noexcept { + if (this == &other) { + return *this; + } + if (ptr != nullptr && ptr != MAP_FAILED && bytes > 0) { + munmap(ptr, bytes); + } + if (fd >= 0) { + close(fd); + } + fd = std::exchange(other.fd, -1); + ptr = std::exchange(other.ptr, nullptr); + bytes = std::exchange(other.bytes, 0); + return *this; + } + + [[nodiscard]] + std::span region() const { + return std::span(ptr, bytes); + } + + [[nodiscard]] + static std::expected open_readonly(const std::string &raw_name) { + const auto shm_name = raw_name.starts_with('/') ? raw_name : "/" + raw_name; + const int fd = shm_open(shm_name.c_str(), O_RDONLY, 0); + if (fd < 0) { + return std::unexpected("shm_open failed for '" + shm_name + "'"); + } + + struct stat statbuf{}; + if (fstat(fd, &statbuf) != 0) { + close(fd); + return std::unexpected("fstat failed for '" + shm_name + "'"); + } + if (statbuf.st_size <= 0) { + close(fd); + return std::unexpected("shared memory size is zero for '" + shm_name + "'"); + } + + const auto bytes = static_cast(statbuf.st_size); + auto *mapped = static_cast(mmap(nullptr, bytes, PROT_READ, MAP_SHARED, fd, 0)); + if (mapped == MAP_FAILED) { + close(fd); + return std::unexpected("mmap failed for '" + shm_name + "'"); + } + + SharedMemoryView view; + view.fd = fd; + view.ptr = mapped; + view.bytes = bytes; + return view; + } + }; + + struct PipelineStats { + std::uint64_t sync_messages{0}; + std::uint64_t status_messages{0}; + std::uint64_t torn_frames{0}; + std::uint64_t pushed_frames{0}; + std::uint64_t encoded_access_units{0}; + std::uint64_t resets{0}; + std::uint64_t format_rebuilds{0}; + std::uint64_t supervised_restarts{0}; + }; + + [[nodiscard]] + std::string_view status_to_string(ipc::ModuleStatus status) { + switch (status) { + case ipc::ModuleStatus::Online: + return "online"; + case ipc::ModuleStatus::Offline: + return "offline"; + case ipc::ModuleStatus::StreamReset: + return "stream_reset"; + } + return "unknown"; + } + + [[nodiscard]] + bool frame_info_equal(const ipc::FrameInfo &lhs, const ipc::FrameInfo &rhs) { + return lhs.width == rhs.width && + lhs.height == rhs.height && + lhs.channels == rhs.channels && + lhs.depth == rhs.depth && + lhs.pixel_format == rhs.pixel_format && + lhs.buffer_size == rhs.buffer_size; + } + + [[nodiscard]] + std::expected 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 NVENC appsrc path (supported: BGR/RGB/BGRA/RGBA/GRAY)"); + } + } + +#if CVMMAP_STREAMER_HAS_GSTREAMER + + using namespace std::chrono_literals; + + 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 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 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. " + "Install gst-plugins-bad and verify with: gst-inspect-1.0 " + + parser_name); + } + + 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 for codec='" + std::string(to_string(codec)) + + "'; looked for NVENC and software fallbacks"); + } + + [[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, std::vector &applied) { + if (!has_property(object, name)) { + return false; + } + gst_util_set_object_arg(object, name, value.c_str()); + applied.emplace_back(std::string(name) + "=" + value); + return true; + } + + [[nodiscard]] + bool set_enum_if_available(GObject *object, + const char *name, + const std::array &preferred_values, + std::vector &applied) { + auto *property = g_object_class_find_property(G_OBJECT_GET_CLASS(object), name); + if (property == nullptr) { + return false; + } + if (!G_IS_PARAM_SPEC_ENUM(property)) { + return false; + } + + auto *enum_class = G_ENUM_CLASS(g_type_class_ref(property->value_type)); + if (enum_class == nullptr) { + return false; + } + + bool set = false; + for (const auto candidate : preferred_values) { + if (candidate.empty()) { + continue; + } + for (unsigned int i = 0; i < enum_class->n_values; ++i) { + const auto &entry = enum_class->values[i]; + if (entry.value_nick != nullptr && candidate == entry.value_nick) { + gst_util_set_object_arg(object, name, entry.value_nick); + applied.emplace_back(std::string(name) + "=" + std::string(entry.value_nick)); + set = true; + break; + } + } + if (set) { + break; + } + } + + g_type_class_unref(enum_class); + return set; + } + + struct GStreamerPipeline { + GStreamerPipeline() = default; + + GstElement *pipeline{nullptr}; + GstElement *appsrc{nullptr}; + GstElement *appsink{nullptr}; + GstElement *encoder{nullptr}; + GstBus *bus{nullptr}; + std::optional first_timestamp_ns{}; + bool using_nvenc{true}; + std::string active_encoder_name{}; + std::string active_parser_name{}; + + ~GStreamerPipeline() { + shutdown(); + } + + GStreamerPipeline(const GStreamerPipeline &) = delete; + GStreamerPipeline &operator=(const GStreamerPipeline &) = delete; + + void shutdown() { + 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; + } + first_timestamp_ns.reset(); + active_encoder_name.clear(); + active_parser_name.clear(); + } + + [[nodiscard]] + std::expected poll_bus() { + if (bus == nullptr) { + return {}; + } + + while (auto *message = gst_bus_pop_filtered( + bus, + static_cast(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( + "pipeline 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("pipeline reached EOS"); + } + + GError *error = nullptr; + gchar *debug = nullptr; + gst_message_parse_error(message, &error, &debug); + const std::string msg = + "pipeline error: " + + std::string(error != nullptr ? error->message : "unknown") + + " (" + + std::string(debug != nullptr ? debug : "no-debug") + + ")"; + std::string hinted_msg = msg; + if (hinted_msg.find("nvh264enc") != std::string::npos || + hinted_msg.find("nvh265enc") != std::string::npos || + hinted_msg.find("nvcodec") != std::string::npos || + hinted_msg.find("CUDA") != std::string::npos || + hinted_msg.find("cuda") != std::string::npos) { + hinted_msg += + " | NVENC guidance: verify NVIDIA driver/GPU runtime and plugin availability " + "with gst-inspect-1.0 nvh264enc / gst-inspect-1.0 nvh265enc"; + } + if (error != nullptr) { + g_error_free(error); + } + if (debug != nullptr) { + g_free(debug); + } + gst_message_unref(message); + return std::unexpected(hinted_msg); + } + + return {}; + } + + [[nodiscard]] + std::expected configure_low_latency_encoder(const RuntimeConfig &config) { + if (encoder == nullptr) { + return std::unexpected("internal error: encoder element is null"); + } + + std::vector applied{}; + + (void)set_property_arg_if_exists(G_OBJECT(encoder), "bframes", "0", applied); + (void)set_property_arg_if_exists(G_OBJECT(encoder), "rc-lookahead", "0", applied); + (void)set_property_arg_if_exists(G_OBJECT(encoder), "lookahead", "0", applied); + (void)set_property_arg_if_exists(G_OBJECT(encoder), "zerolatency", "true", applied); + (void)set_property_arg_if_exists(G_OBJECT(encoder), "gop-size", std::to_string(config.latency.gop), applied); + (void)set_property_arg_if_exists(G_OBJECT(encoder), "iframeinterval", std::to_string(config.latency.gop), applied); + + if (!set_enum_if_available( + G_OBJECT(encoder), + "preset", + {"low-latency-hq", "llhq", "low-latency-hp", "llhp", "p1"}, + applied)) { + (void)set_property_arg_if_exists(G_OBJECT(encoder), "preset", "llhq", applied); + } + + if (!set_enum_if_available( + G_OBJECT(encoder), + "tune", + {"ultra-low-latency", "low-latency", "zerolatency", "ull", "ll"}, + applied)) { + (void)set_property_arg_if_exists(G_OBJECT(encoder), "tune", "zerolatency", applied); + } + + if (applied.empty()) { + spdlog::warn("encoder low-latency knobs: no configurable properties discovered on selected encoder element"); + } else { + for (const auto &item : applied) { + spdlog::info("encoder low-latency knob applied: {}", item); + } + } + + return {}; + } + + [[nodiscard]] + std::expected init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info, bool prefer_nvenc) { + ensure_gst_initialized(); + + auto encoder_choice = pick_encoder_choice(config.codec, prefer_nvenc); + if (!encoder_choice) { + return std::unexpected(encoder_choice.error()); + } + + const std::string encoder_name = encoder_choice->encoder_name; + const std::string parser_name = encoder_choice->parser_name; + using_nvenc = encoder_choice->is_nvenc; + active_encoder_name = encoder_name; + active_parser_name = 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 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(encoder_name) + " " + + "! " + encoder_name + " name=nvenc " + + "! " + parser_name + " " + + "! appsink name=encoded_sink emit-signals=false sync=false drop=true max-buffers=1"; + + spdlog::info( + "ENCODER_PATH codec={} mode={} encoder={} parser={}", + to_string(config.codec), + using_nvenc ? "nvenc" : "fallback", + encoder_name, + parser_name); + spdlog::info("pipeline graph: {}", pipeline_desc); + + GError *error = nullptr; + pipeline = gst_parse_launch(pipeline_desc.c_str(), &error); + if (error != nullptr) { + const std::string message = "failed to create pipeline: " + std::string(error->message); + g_error_free(error); + return std::unexpected(message); + } + if (pipeline == nullptr) { + return std::unexpected("failed to create pipeline: parse launch returned null"); + } + + appsrc = gst_bin_get_by_name(GST_BIN(pipeline), "ingest_src"); + if (appsrc == nullptr) { + return std::unexpected("failed to locate appsrc element 'ingest_src'"); + } + + appsink = gst_bin_get_by_name(GST_BIN(pipeline), "encoded_sink"); + if (appsink == nullptr) { + return std::unexpected("failed to locate appsink element 'encoded_sink'"); + } + + encoder = gst_bin_get_by_name(GST_BIN(pipeline), "nvenc"); + if (encoder == nullptr) { + return std::unexpected("failed to locate encoder element 'nvenc'"); + } + + 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 build appsrc 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); + std::vector appsrc_applied{}; + (void)set_property_arg_if_exists(G_OBJECT(appsrc), "leaky-type", "downstream", appsrc_applied); + (void)set_property_arg_if_exists(G_OBJECT(appsrc), "block", "false", appsrc_applied); + for (const auto &item : appsrc_applied) { + spdlog::info("appsrc low-latency knob applied: {}", item); + } + + auto encoder_setup = configure_low_latency_encoder(config); + if (!encoder_setup) { + return std::unexpected(encoder_setup.error()); + } + + bus = gst_element_get_bus(pipeline); + + const auto state_result = gst_element_set_state(pipeline, GST_STATE_PLAYING); + if (state_result == GST_STATE_CHANGE_FAILURE) { + return std::unexpected("failed to set pipeline to PLAYING state"); + } + + return {}; + } + + [[nodiscard]] + std::expected push_frame(const ipc::CoherentSnapshot &snapshot, std::span payload) { + if (appsrc == nullptr) { + return std::unexpected("internal error: appsrc is null"); + } + if (payload.size() < snapshot.bytes_copied) { + return std::unexpected("internal error: payload buffer smaller than snapshot size"); + } + + auto *buffer = gst_buffer_new_allocate(nullptr, snapshot.bytes_copied, nullptr); + if (buffer == nullptr) { + return std::unexpected("failed to allocate gst buffer"); + } + + GstMapInfo map; + if (!gst_buffer_map(buffer, &map, GST_MAP_WRITE)) { + gst_buffer_unref(buffer); + return std::unexpected("failed to map gst buffer for write"); + } + + std::memcpy(map.data, payload.data(), snapshot.bytes_copied); + gst_buffer_unmap(buffer, &map); + + const auto current_ts = snapshot.metadata.timestamp_ns; + if (!first_timestamp_ns) { + first_timestamp_ns = current_ts; + } + const auto pts = current_ts >= *first_timestamp_ns ? current_ts - *first_timestamp_ns : 0ull; + GST_BUFFER_PTS(buffer) = static_cast(pts); + GST_BUFFER_DTS(buffer) = static_cast(pts); + + const auto flow = gst_app_src_push_buffer(GST_APP_SRC(appsrc), buffer); + if (flow != GST_FLOW_OK) { + return std::unexpected("appsrc push failed with flow=" + std::to_string(static_cast(flow))); + } + + return {}; + } + + [[nodiscard]] + std::expected + drain_encoded(PipelineStats &stats, + protocol::UdpRtpPublisher *rtp_publisher, + protocol::RtmpPublisher *rtmp_publisher, + metrics::IngestEmitLatencyTracker &latency_tracker, + std::uint32_t emit_stall_ms) { + if (appsink == nullptr) { + return {}; + } + + while (auto *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(appsink), 0)) { + auto *buffer = gst_sample_get_buffer(sample); + std::size_t bytes{0}; + std::uint64_t pts_ns{0}; + if (buffer != nullptr) { + bytes = static_cast(gst_buffer_get_size(buffer)); + const auto pts = GST_BUFFER_PTS(buffer); + if (pts != GST_CLOCK_TIME_NONE) { + pts_ns = static_cast(pts); + } + if (bytes > 0 && emit_stall_ms > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(emit_stall_ms)); + latency_tracker.note_emit_stall(); + } + if (rtp_publisher != nullptr && bytes > 0) { + GstMapInfo map; + if (gst_buffer_map(buffer, &map, GST_MAP_READ)) { + rtp_publisher->publish_access_unit( + std::span(map.data, map.size), + pts_ns); + if (rtmp_publisher != nullptr) { + auto rtmp_publish = rtmp_publisher->publish_access_unit( + std::span(map.data, map.size), + pts_ns); + if (!rtmp_publish) { + gst_buffer_unmap(buffer, &map); + gst_sample_unref(sample); + return std::unexpected(rtmp_publish.error()); + } + } + gst_buffer_unmap(buffer, &map); + } + } else if (rtmp_publisher != nullptr && bytes > 0) { + GstMapInfo map; + if (gst_buffer_map(buffer, &map, GST_MAP_READ)) { + auto rtmp_publish = rtmp_publisher->publish_access_unit( + std::span(map.data, map.size), + pts_ns); + if (!rtmp_publish) { + gst_buffer_unmap(buffer, &map); + gst_sample_unref(sample); + return std::unexpected(rtmp_publish.error()); + } + gst_buffer_unmap(buffer, &map); + } + } + } + if (bytes > 0) { + latency_tracker.note_emit(); + } + stats.encoded_access_units += 1; + if (stats.encoded_access_units <= 10 || (stats.encoded_access_units % 30) == 0) { + spdlog::info( + "ENCODED_AU codec={} count={} bytes={}", + to_string(configured_codec), + stats.encoded_access_units, + bytes); + } + gst_sample_unref(sample); + } + + return {}; + } + + CodecType configured_codec{CodecType::H264}; + }; + +#endif + +} + +int run_nvenc_pipeline(const RuntimeConfig &config) { +#if !CVMMAP_STREAMER_HAS_GSTREAMER + spdlog::error( + "GStreamer development/runtime libraries are unavailable; NVENC pipeline core requires gstreamer-1.0, gstreamer-app-1.0, and gstreamer-video-1.0"); + return 5; +#else + GStreamerPipeline pipeline; + pipeline.configured_codec = config.codec; + ensure_gst_initialized(); + + auto shm = SharedMemoryView::open_readonly(config.input.shm_name); + if (!shm) { + spdlog::error("pipeline open shared memory failed: {}", shm.error()); + return 3; + } + + if (shm->bytes <= ipc::kShmPayloadOffset) { + spdlog::error("pipeline invalid shared memory size: {}", shm->bytes); + return 3; + } + + std::vector snapshot_buffer( + shm->bytes - ipc::kShmPayloadOffset, + static_cast(0)); + + zmq::context_t zmq_ctx{1}; + zmq::socket_t subscriber(zmq_ctx, zmq::socket_type::sub); + try { + subscriber.set(zmq::sockopt::subscribe, ""); + subscriber.set(zmq::sockopt::rcvtimeo, 20); + subscriber.connect(config.input.zmq_endpoint); + } catch (const zmq::error_t &e) { + spdlog::error("pipeline subscribe failed on '{}': {}", config.input.zmq_endpoint, e.what()); + return 4; + } + + PipelineStats stats{}; + metrics::IngestEmitLatencyTracker latency_tracker{}; + bool producer_offline{false}; + bool started{false}; + bool prefer_nvenc{true}; + bool fallback_attempted{false}; + std::optional active_info{}; + std::optional rtp_publisher{}; + std::optional rtmp_publisher{}; + + if (config.outputs.rtp.enabled) { + auto created_publisher = protocol::UdpRtpPublisher::create(config); + if (!created_publisher) { + spdlog::error("pipeline RTP publisher init failed: {}", created_publisher.error()); + return 5; + } + rtp_publisher.emplace(std::move(*created_publisher)); + spdlog::info( + "pipeline RTP publisher enabled destination={} payload_type={} sdp={}", + rtp_publisher->destination(), + static_cast(config.outputs.rtp.payload_type), + rtp_publisher->sdp_path()); + } + + if (config.outputs.rtmp.enabled) { + auto created_publisher = protocol::RtmpPublisher::create(config); + if (!created_publisher) { + spdlog::error("pipeline RTMP publisher init failed: {}", created_publisher.error()); + return 5; + } + rtmp_publisher.emplace(std::move(*created_publisher)); + spdlog::info( + "pipeline RTMP publisher enabled urls={} mode={}", + config.outputs.rtmp.urls.size(), + to_string(config.outputs.rtmp.mode)); + } + + constexpr std::uint32_t kSupervisorRestartInitialMs = 200; + constexpr std::uint32_t kSupervisorRestartMaxMs = 4'000; + constexpr std::uint32_t kSupervisorRetryBudget = 8; + + bool restart_pending{false}; + std::string restart_reason{"startup"}; + std::optional restart_target_info{}; + auto restart_due_at = std::chrono::steady_clock::now(); + std::uint32_t restart_backoff_ms{kSupervisorRestartInitialMs}; + std::uint32_t consecutive_restart_failures{0}; + + const auto schedule_restart = [&](std::string reason, bool immediate, std::optional target_info) { + restart_pending = true; + restart_reason = std::move(reason); + restart_target_info = target_info; + restart_due_at = std::chrono::steady_clock::now() + std::chrono::milliseconds(immediate ? 0 : restart_backoff_ms); + + if (rtmp_publisher) { + rtmp_publisher->on_stream_reset(); + } + + spdlog::warn( + "PIPELINE_STATE_TRANSITION from={} to=restart_pending reason='{}' cooldown_ms={} failures={}", + started ? "running" : "stopped", + restart_reason, + immediate ? 0u : restart_backoff_ms, + consecutive_restart_failures); + + pipeline.shutdown(); + started = false; + + if (!immediate) { + restart_backoff_ms = std::min( + kSupervisorRestartMaxMs, + std::max(kSupervisorRestartInitialMs, restart_backoff_ms * 2)); + } + }; + + const auto attempt_pipeline_start = [&](const ipc::FrameInfo &target_info, std::string_view trigger) -> std::expected { + const auto now = std::chrono::steady_clock::now(); + if (restart_pending && now < restart_due_at) { + return false; + } + + if (restart_pending) { + spdlog::info( + "PIPELINE_RESTART_ATTEMPT reason='{}' trigger={} failures={} backoff_ms={}", + restart_reason, + trigger, + consecutive_restart_failures, + restart_backoff_ms); + } + + auto init = pipeline.init(config, target_info, prefer_nvenc); + if (!init && prefer_nvenc && !fallback_attempted) { + spdlog::warn( + "ENCODER_FALLBACK trigger=startup_init_failure codec={} reason='{}' from=nvenc to=fallback", + to_string(config.codec), + init.error()); + fallback_attempted = true; + prefer_nvenc = false; + pipeline.shutdown(); + init = pipeline.init(config, target_info, prefer_nvenc); + } + + if (!init) { + consecutive_restart_failures += 1; + if (consecutive_restart_failures > kSupervisorRetryBudget) { + return std::unexpected( + "pipeline supervisor retry budget exhausted after " + + std::to_string(consecutive_restart_failures) + + " consecutive failures; last error: " + + init.error()); + } + + schedule_restart( + "init_failed trigger=" + std::string(trigger) + " detail='" + init.error() + "'", + false, + target_info); + return false; + } + + if (restart_pending) { + stats.supervised_restarts += 1; + spdlog::info( + "PIPELINE_STATE_TRANSITION from=restart_pending to=running reason='{}' restarts={}", + restart_reason, + stats.supervised_restarts); + } + + started = true; + active_info = target_info; + restart_pending = false; + restart_reason.clear(); + restart_target_info.reset(); + consecutive_restart_failures = 0; + restart_backoff_ms = kSupervisorRestartInitialMs; + return true; + }; + + const auto idle_timeout = std::chrono::milliseconds(config.latency.ingest_idle_timeout_ms); + auto last_event = std::chrono::steady_clock::now(); + + while (true) { + auto poll = pipeline.poll_bus(); + if (!poll) { + schedule_restart("bus_error detail='" + poll.error() + "'", false, active_info); + continue; + } + + zmq::message_t message; + const auto recv_result = subscriber.recv(message, zmq::recv_flags::none); + if (!recv_result) { + const auto now = std::chrono::steady_clock::now(); + + if (!started && restart_pending && restart_target_info) { + auto restarted = attempt_pipeline_start(*restart_target_info, "scheduled"); + if (!restarted) { + if (!restarted.has_value()) { + spdlog::error("pipeline restart failed: {}", restarted.error()); + return 6; + } + } + } + + if (now - last_event >= idle_timeout) { + spdlog::info("pipeline idle timeout reached ({} ms), stopping", config.latency.ingest_idle_timeout_ms); + break; + } + if (producer_offline) { + continue; + } + if (!started) { + continue; + } + auto drain = pipeline.drain_encoded( + stats, + rtp_publisher ? &*rtp_publisher : nullptr, + rtmp_publisher ? &*rtmp_publisher : nullptr, + latency_tracker, + config.latency.emit_stall_ms); + if (!drain) { + schedule_restart("publish_failed detail='" + drain.error() + "'", false, active_info); + continue; + } + continue; + } + + last_event = std::chrono::steady_clock::now(); + auto bytes = std::span( + static_cast(message.data()), + message.size()); + if (bytes.empty()) { + continue; + } + + if (bytes[0] == ipc::kFrameTopicMagic) { + stats.sync_messages += 1; + auto sync = ipc::parse_sync_message(bytes); + if (!sync) { + spdlog::warn("pipeline sync parse error: {}", ipc::to_string(sync.error())); + continue; + } + + auto snapshot = ipc::read_coherent_snapshot( + shm->region(), + snapshot_buffer, + [&]() { + if (config.latency.snapshot_copy_delay_us > 0) { + std::this_thread::sleep_for(std::chrono::microseconds(config.latency.snapshot_copy_delay_us)); + } + }); + if (!snapshot) { + if (snapshot.error() == ipc::SnapshotError::TornRead) { + stats.torn_frames += 1; + } + spdlog::warn("pipeline snapshot rejected: {}", ipc::to_string(snapshot.error())); + continue; + } + + if (active_info && started && !frame_info_equal(*active_info, snapshot->metadata.info)) { + stats.format_rebuilds += 1; + spdlog::warn( + "PIPELINE_RECONFIG_TRIGGER reason='frame_info_change' old={}x{}x{} new={}x{}x{} rebuilds={}", + active_info->width, + active_info->height, + static_cast(active_info->channels), + snapshot->metadata.info.width, + snapshot->metadata.info.height, + static_cast(snapshot->metadata.info.channels), + stats.format_rebuilds); + schedule_restart("frame_info_change", true, snapshot->metadata.info); + } + + if (!started || restart_pending) { + const auto target_info = restart_target_info.value_or(snapshot->metadata.info); + auto restarted = attempt_pipeline_start(target_info, started ? "reconfigure" : "startup"); + if (!restarted) { + if (!restarted.has_value()) { + spdlog::error("pipeline init/restart failed: {}", restarted.error()); + return 5; + } + continue; + } + } + + if (active_info && !frame_info_equal(*active_info, snapshot->metadata.info)) { + schedule_restart("frame_info_change_during_restart", true, snapshot->metadata.info); + continue; + } + + latency_tracker.note_ingest(); + + auto push = pipeline.push_frame( + *snapshot, + std::span(snapshot_buffer.data(), snapshot->bytes_copied)); + if (!push) { + schedule_restart("push_failed detail='" + push.error() + "'", false, active_info); + continue; + } + + stats.pushed_frames += 1; + auto drain = pipeline.drain_encoded( + stats, + rtp_publisher ? &*rtp_publisher : nullptr, + rtmp_publisher ? &*rtmp_publisher : nullptr, + latency_tracker, + config.latency.emit_stall_ms); + if (!drain) { + schedule_restart("publish_failed detail='" + drain.error() + "'", false, active_info); + continue; + } + + if (pipeline.using_nvenc && !fallback_attempted && stats.pushed_frames >= 60 && stats.encoded_access_units == 0) { + spdlog::warn( + "ENCODER_FALLBACK trigger=zero_encoded_output codec={} pushed_frames={} from=nvenc to=fallback", + to_string(config.codec), + stats.pushed_frames); + fallback_attempted = true; + prefer_nvenc = false; + schedule_restart("encoder_zero_output_fallback", true, snapshot->metadata.info); + continue; + } + + if (config.latency.ingest_max_frames > 0 && stats.pushed_frames >= config.latency.ingest_max_frames) { + spdlog::info("pipeline reached ingest_max_frames={}, stopping", config.latency.ingest_max_frames); + break; + } + continue; + } + + if (bytes[0] == ipc::kModuleStatusMagic) { + stats.status_messages += 1; + auto status = ipc::parse_module_status_message(bytes); + if (!status) { + spdlog::warn("pipeline status parse error: {}", ipc::to_string(status.error())); + continue; + } + + spdlog::info("pipeline status event label='{}' status={}", status->label(), status_to_string(status->module_status)); + + if (status->module_status == ipc::ModuleStatus::Online) { + producer_offline = false; + } + + if (status->module_status == ipc::ModuleStatus::Offline) { + producer_offline = true; + } + + if (status->module_status == ipc::ModuleStatus::StreamReset) { + stats.resets += 1; + restart_pending = false; + restart_reason.clear(); + restart_target_info.reset(); + restart_backoff_ms = kSupervisorRestartInitialMs; + consecutive_restart_failures = 0; + pipeline.shutdown(); + started = false; + active_info.reset(); + if (rtmp_publisher) { + rtmp_publisher->on_stream_reset(); + } + spdlog::info("PIPELINE_STATE_TRANSITION from=running to=reset_wait resets={}", stats.resets); + } + continue; + } + + spdlog::warn("pipeline unknown message type: magic=0x{:02x} size={}", bytes[0], bytes.size()); + } + + if (started) { + const auto eos_result = gst_app_src_end_of_stream(GST_APP_SRC(pipeline.appsrc)); + if (eos_result != GST_FLOW_OK) { + spdlog::warn("pipeline end-of-stream push returned flow={}", static_cast(eos_result)); + } + for (int i = 0; i < 20; ++i) { + auto drain = pipeline.drain_encoded( + stats, + rtp_publisher ? &*rtp_publisher : nullptr, + rtmp_publisher ? &*rtmp_publisher : nullptr, + latency_tracker, + config.latency.emit_stall_ms); + if (!drain) { + spdlog::error("pipeline publish failed during EOS drain: {}", drain.error()); + return 6; + } + auto poll = pipeline.poll_bus(); + if (!poll) { + break; + } + std::this_thread::sleep_for(10ms); + } + } + + spdlog::info( + "PIPELINE_METRICS codec={} sync_messages={} status_messages={} torn_frames={} pushed_frames={} encoded_access_units={} resets={} format_rebuilds={} supervised_restarts={}", + to_string(config.codec), + stats.sync_messages, + stats.status_messages, + stats.torn_frames, + stats.pushed_frames, + stats.encoded_access_units, + stats.resets, + stats.format_rebuilds, + stats.supervised_restarts); + + const auto latency_summary = latency_tracker.summarize(); + const auto pending_frames = latency_tracker.pending_frames(); + const auto ingest_drop_frames = + stats.sync_messages >= stats.pushed_frames + ? stats.sync_messages - stats.pushed_frames + : 0ull; + const auto total_drop_frames = ingest_drop_frames + stats.torn_frames + pending_frames; + const auto drop_ratio_ppm = + stats.sync_messages > 0 + ? (total_drop_frames * 1'000'000ull) / stats.sync_messages + : 0ull; + + spdlog::info( + "LATENCY_METRICS ingest_to_emit_samples={} p50_us={} p95_us={} p99_us={} min_us={} max_us={} avg_us={} pending_frames={} ingest_drop_frames={} total_drop_frames={} drop_ratio_ppm={} sink_stall_events={}", + latency_summary.samples, + latency_summary.p50_us, + latency_summary.p95_us, + latency_summary.p99_us, + latency_summary.min_us, + latency_summary.max_us, + latency_summary.avg_us, + pending_frames, + ingest_drop_frames, + total_drop_frames, + drop_ratio_ppm, + latency_tracker.emit_stall_events()); + + spdlog::info( + "FAULT_COUNTERS torn_read_events={} sink_stall_events={} reset_events={}", + stats.torn_frames, + latency_tracker.emit_stall_events(), + stats.resets); + + if (started && stats.encoded_access_units == 0) { + spdlog::error("pipeline produced zero encoded access units; check NVENC runtime availability and raw input format"); + return 6; + } + + if (rtp_publisher) { + rtp_publisher->log_metrics(); + } + + if (rtmp_publisher) { + rtmp_publisher->log_metrics(); + } + + return 0; +#endif +} + +} diff --git a/src/protocol/protocol_stub.cpp b/src/protocol/protocol_stub.cpp new file mode 100644 index 0000000..cf5d23f --- /dev/null +++ b/src/protocol/protocol_stub.cpp @@ -0,0 +1,5 @@ +namespace cvmmap_streamer { + +void protocol_step() {} + +} diff --git a/src/protocol/rtmp_publisher.cpp b/src/protocol/rtmp_publisher.cpp new file mode 100644 index 0000000..6308065 --- /dev/null +++ b/src/protocol/rtmp_publisher.cpp @@ -0,0 +1,1029 @@ +#include "cvmmap_streamer/protocol/rtmp_publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 send_all(int fd, std::span data) { + std::size_t written = 0; + while (written < data.size()) { + const auto n = send( + fd, + reinterpret_cast(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(n); + } + return {}; + } + + [[nodiscard]] + std::expected recv_exact(int fd, std::span data) { + std::size_t read = 0; + while (read < data.size()) { + const auto n = recv( + fd, + reinterpret_cast(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(n); + } + return {}; + } + + void append_be24(std::vector &out, std::uint32_t v) { + out.push_back(static_cast((v >> 16) & 0xffu)); + out.push_back(static_cast((v >> 8) & 0xffu)); + out.push_back(static_cast(v & 0xffu)); + } + + void append_be32(std::vector &out, std::uint32_t v) { + out.push_back(static_cast((v >> 24) & 0xffu)); + out.push_back(static_cast((v >> 16) & 0xffu)); + out.push_back(static_cast((v >> 8) & 0xffu)); + out.push_back(static_cast(v & 0xffu)); + } + + void append_le32(std::vector &out, std::uint32_t v) { + out.push_back(static_cast(v & 0xffu)); + out.push_back(static_cast((v >> 8) & 0xffu)); + out.push_back(static_cast((v >> 16) & 0xffu)); + out.push_back(static_cast((v >> 24) & 0xffu)); + } + + void append_basic_header(std::vector &out, std::uint8_t fmt, std::uint32_t csid) { + if (csid <= 63) { + out.push_back(static_cast((fmt << 6) | csid)); + return; + } + + if (csid <= 319) { + out.push_back(static_cast((fmt << 6) | 0)); + out.push_back(static_cast(csid - 64)); + return; + } + + out.push_back(static_cast((fmt << 6) | 1)); + const std::uint32_t normalized = csid - 64; + out.push_back(static_cast(normalized & 0xff)); + out.push_back(static_cast((normalized >> 8) & 0xff)); + } + + [[nodiscard]] + std::expected 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 payload, + std::uint32_t chunk_size) { + if (chunk_size == 0) { + return std::unexpected("invalid RTMP chunk size 0"); + } + + std::vector 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(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(chunk_size, payload.size()); + packet.insert(packet.end(), payload.begin(), payload.begin() + static_cast(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(chunk_size, payload.size() - offset); + packet.insert( + packet.end(), + payload.begin() + static_cast(offset), + payload.begin() + static_cast(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 &out, std::string_view value) { + out.push_back(0x02); + out.push_back(static_cast((value.size() >> 8) & 0xff)); + out.push_back(static_cast(value.size() & 0xff)); + out.insert(out.end(), value.begin(), value.end()); + } + + void append_amf0_number(std::vector &out, double value) { + out.push_back(0x00); + std::uint64_t bits{0}; + std::memcpy(&bits, &value, sizeof(bits)); + out.push_back(static_cast((bits >> 56) & 0xff)); + out.push_back(static_cast((bits >> 48) & 0xff)); + out.push_back(static_cast((bits >> 40) & 0xff)); + out.push_back(static_cast((bits >> 32) & 0xff)); + out.push_back(static_cast((bits >> 24) & 0xff)); + out.push_back(static_cast((bits >> 16) & 0xff)); + out.push_back(static_cast((bits >> 8) & 0xff)); + out.push_back(static_cast(bits & 0xff)); + } + + void append_amf0_null(std::vector &out) { + out.push_back(0x05); + } + + void append_amf0_object_start(std::vector &out) { + out.push_back(0x03); + } + + void append_amf0_object_end(std::vector &out) { + out.push_back(0x00); + out.push_back(0x00); + out.push_back(0x09); + } + + void append_amf0_object_key(std::vector &out, std::string_view key) { + out.push_back(static_cast((key.size() >> 8) & 0xff)); + out.push_back(static_cast(key.size() & 0xff)); + out.insert(out.end(), key.begin(), key.end()); + } + + void append_amf0_object_string_property( + std::vector &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 &out, std::string_view key, double value) { + append_amf0_object_key(out, key); + append_amf0_number(out, value); + } + + [[nodiscard]] + std::expected 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://[:port]//", + 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 //", + 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 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 send_connect_message(int fd, std::uint32_t chunk_size, const ParsedRtmpUrl &url) { + std::vector 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 send_create_stream_message(int fd, std::uint32_t chunk_size) { + std::vector 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 send_publish_message( + int fd, + std::uint32_t chunk_size, + std::uint32_t stream_id, + std::string_view stream_name) { + std::vector 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 run_handshake(int fd) { + std::array c0c1{}; + c0c1[0] = kRtmpVersion; + for (std::size_t i = 0; i < kRtmpHandshakePartLength; ++i) { + c0c1[1 + i] = static_cast((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 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(s0s1s2[0]), + static_cast(kRtmpVersion))); + } + + std::array 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 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 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 make_h264_video_payload(std::span access_unit) { + const bool is_idr = h264_idr_access_unit(access_unit); + std::vector 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 make_h265_enhanced_sequence_header() { + return { + 0x90, + 'h', + 'v', + 'c', + '1', + 0x01, + 0x01, + 0x60, + }; + } + + [[nodiscard]] + std::vector make_h265_domestic_sequence_header() { + return { + 0x1c, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x01, + 0x60, + }; + } + + [[nodiscard]] + std::vector make_h265_enhanced_video_payload(std::span access_unit) { + std::vector 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 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((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((access_unit[0] >> 1) & 0x3fu); + return nal_type == 19u || nal_type == 20u || nal_type == 21u; + } + + [[nodiscard]] + std::vector make_h265_domestic_video_payload(std::span access_unit) { + const bool is_idr = h265_idr_access_unit(access_unit); + std::vector 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((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 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::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 +RtmpPublisher::publish_access_unit(std::span 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 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 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); +} + +} diff --git a/src/protocol/rtp_publisher.cpp b/src/protocol/rtp_publisher.cpp new file mode 100644 index 0000000..a066dd9 --- /dev/null +++ b/src/protocol/rtp_publisher.cpp @@ -0,0 +1,502 @@ +#include "cvmmap_streamer/protocol/rtp_publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace cvmmap_streamer { +} + +namespace cvmmap_streamer::protocol { + +namespace { + + constexpr std::size_t kRtpHeaderBytes = 12; + constexpr std::size_t kRtpPayloadBytesMax = 1200; + constexpr std::uint32_t kRtpVideoClockRate = 90'000; + constexpr std::uint64_t kNanosPerSecond = 1'000'000'000ull; + constexpr std::uint64_t kErrorLogFirstPackets = 8; + constexpr std::uint64_t kErrorLogEveryNPackets = 120; + + [[nodiscard]] + std::uint32_t compute_ssrc(std::string_view host, std::uint16_t port, std::uint8_t payload_type, CodecType codec) { + std::uint32_t hash = 2166136261u; + const auto mix_byte = [&](std::uint8_t b) { + hash ^= static_cast(b); + hash *= 16777619u; + }; + for (const auto ch : host) { + mix_byte(static_cast(ch)); + } + mix_byte(static_cast(port >> 8)); + mix_byte(static_cast(port & 0xffu)); + mix_byte(payload_type); + mix_byte(codec == CodecType::H265 ? 0x65u : 0x64u); + if (hash == 0u) { + hash = 1u; + } + return hash; + } + + [[nodiscard]] + std::uint16_t compute_initial_sequence() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast(std::chrono::duration_cast(now).count() & 0xffffu); + } + + [[nodiscard]] + std::uint32_t to_rtp_timestamp(std::uint64_t pts_ns) { + const auto ticks = (pts_ns * kRtpVideoClockRate) / kNanosPerSecond; + return static_cast(ticks & 0xffffffffu); + } + + [[nodiscard]] + std::string rtp_encoding_name(CodecType codec) { + return codec == CodecType::H265 ? "H265" : "H264"; + } + + [[nodiscard]] + std::string rtp_fmtp_line(CodecType codec, std::uint8_t payload_type) { + if (codec == CodecType::H265) { + return "a=fmtp:" + std::to_string(payload_type) + " sprop-max-don-diff=0"; + } + return "a=fmtp:" + std::to_string(payload_type) + " packetization-mode=1;profile-level-id=42e01f"; + } + + [[nodiscard]] + std::optional> next_start_code(std::span bytes, std::size_t offset) { + for (std::size_t i = offset; i + 3 <= bytes.size(); ++i) { + if (bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 1) { + return std::pair{i, static_cast(3)}; + } + if (i + 4 <= bytes.size() && bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 0 && bytes[i + 3] == 1) { + return std::pair{i, static_cast(4)}; + } + } + return std::nullopt; + } + + [[nodiscard]] + std::vector> split_annexb_nalus(std::span access_unit) { + std::vector> nalus{}; + auto first_sc = next_start_code(access_unit, 0); + if (!first_sc) { + if (!access_unit.empty()) { + nalus.push_back(access_unit); + } + return nalus; + } + + std::size_t cursor = first_sc->first; + while (true) { + auto current_sc = next_start_code(access_unit, cursor); + if (!current_sc) { + break; + } + const std::size_t payload_begin = current_sc->first + current_sc->second; + auto next_sc = next_start_code(access_unit, payload_begin); + const std::size_t payload_end = next_sc ? next_sc->first : access_unit.size(); + + if (payload_begin < payload_end) { + nalus.push_back(access_unit.subspan(payload_begin, payload_end - payload_begin)); + } + + if (!next_sc) { + break; + } + cursor = next_sc->first; + } + + if (nalus.empty() && !access_unit.empty()) { + nalus.push_back(access_unit); + } + + return nalus; + } + +} + +UdpRtpPublisher::~UdpRtpPublisher() { + if (socket_fd_ >= 0) { + close(socket_fd_); + socket_fd_ = -1; + } +} + +UdpRtpPublisher::UdpRtpPublisher(UdpRtpPublisher &&other) noexcept { + socket_fd_ = std::exchange(other.socket_fd_, -1); + destination_host_ = std::move(other.destination_host_); + destination_ip_ = std::move(other.destination_ip_); + destination_port_ = std::exchange(other.destination_port_, 0); + payload_type_ = std::exchange(other.payload_type_, 96); + codec_ = std::exchange(other.codec_, CodecType::H264); + sequence_ = std::exchange(other.sequence_, 0); + ssrc_ = std::exchange(other.ssrc_, 0); + sdp_path_ = std::move(other.sdp_path_); + stats_ = other.stats_; + endpoint_addr_ = other.endpoint_addr_; + endpoint_addr_len_ = std::exchange(other.endpoint_addr_len_, 0); +} + +UdpRtpPublisher &UdpRtpPublisher::operator=(UdpRtpPublisher &&other) noexcept { + if (this == &other) { + return *this; + } + if (socket_fd_ >= 0) { + close(socket_fd_); + } + socket_fd_ = std::exchange(other.socket_fd_, -1); + destination_host_ = std::move(other.destination_host_); + destination_ip_ = std::move(other.destination_ip_); + destination_port_ = std::exchange(other.destination_port_, 0); + payload_type_ = std::exchange(other.payload_type_, 96); + codec_ = std::exchange(other.codec_, CodecType::H264); + sequence_ = std::exchange(other.sequence_, 0); + ssrc_ = std::exchange(other.ssrc_, 0); + sdp_path_ = std::move(other.sdp_path_); + stats_ = other.stats_; + endpoint_addr_ = other.endpoint_addr_; + endpoint_addr_len_ = std::exchange(other.endpoint_addr_len_, 0); + return *this; +} + +std::expected UdpRtpPublisher::create(const RuntimeConfig &config) { + if (!config.outputs.rtp.enabled) { + return std::unexpected("invalid RTP publisher init: RTP output disabled"); + } + if (!config.outputs.rtp.host || !config.outputs.rtp.port) { + return std::unexpected("invalid RTP publisher init: host/port not configured"); + } + + UdpRtpPublisher publisher{}; + 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.sequence_ = compute_initial_sequence(); + publisher.ssrc_ = compute_ssrc( + publisher.destination_host_, + publisher.destination_port_, + publisher.payload_type_, + publisher.codec_); + + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_DGRAM; + + addrinfo *result = nullptr; + const auto port = std::to_string(publisher.destination_port_); + const int gai = getaddrinfo( + publisher.destination_host_.c_str(), + port.c_str(), + &hints, + &result); + if (gai != 0) { + return std::unexpected("RTP getaddrinfo failed for host '" + publisher.destination_host_ + "': " + std::string(gai_strerror(gai))); + } + + for (auto *it = result; it != nullptr; it = it->ai_next) { + if (it->ai_addrlen > sizeof(sockaddr_storage)) { + continue; + } + std::memcpy(&publisher.endpoint_addr_, it->ai_addr, it->ai_addrlen); + publisher.endpoint_addr_len_ = static_cast(it->ai_addrlen); + char ip_text[INET6_ADDRSTRLEN]{}; + if (getnameinfo( + it->ai_addr, + it->ai_addrlen, + ip_text, + sizeof(ip_text), + nullptr, + 0, + NI_NUMERICHOST) == 0) { + publisher.destination_ip_ = ip_text; + } + break; + } + + freeaddrinfo(result); + + if (publisher.endpoint_addr_len_ == 0) { + return std::unexpected("RTP endpoint resolution failed for host '" + publisher.destination_host_ + "'"); + } + + publisher.socket_fd_ = socket(AF_INET, SOCK_DGRAM, 0); + if (publisher.socket_fd_ < 0) { + return std::unexpected("RTP socket create failed: " + std::string(std::strerror(errno))); + } + + const int current_flags = fcntl(publisher.socket_fd_, F_GETFL, 0); + if (current_flags < 0 || fcntl(publisher.socket_fd_, F_SETFL, current_flags | O_NONBLOCK) < 0) { + 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"; + if (config.outputs.rtp.sdp_path && !config.outputs.rtp.sdp_path->empty()) { + publisher.sdp_path_ = *config.outputs.rtp.sdp_path; + } else { + publisher.sdp_path_ = + "/tmp/cvmmap_streamer_" + + codec_name + + "_" + + std::to_string(publisher.destination_port_) + + ".sdp"; + } + + std::filesystem::path sdp_path{publisher.sdp_path_}; + if (sdp_path.has_parent_path() && !sdp_path.parent_path().empty()) { + std::error_code ec; + std::filesystem::create_directories(sdp_path.parent_path(), ec); + if (ec) { + return std::unexpected("RTP SDP directory create failed: " + ec.message()); + } + } + + std::ofstream sdp(publisher.sdp_path_, std::ios::trunc); + if (!sdp.is_open()) { + return std::unexpected("RTP SDP open failed: " + publisher.sdp_path_); + } + + const auto endpoint_ip = publisher.destination_ip_.empty() ? publisher.destination_host_ : publisher.destination_ip_; + sdp << "v=0\n"; + sdp << "o=- 0 0 IN IP4 " << endpoint_ip << "\n"; + sdp << "s=cvmmap-streamer\n"; + sdp << "c=IN IP4 " << endpoint_ip << "\n"; + sdp << "t=0 0\n"; + sdp << "m=video " << publisher.destination_port_ << " RTP/AVP " << static_cast(publisher.payload_type_) << "\n"; + sdp << "a=rtpmap:" << static_cast(publisher.payload_type_) << " " << rtp_encoding_name(publisher.codec_) << "/" << kRtpVideoClockRate << "\n"; + sdp << rtp_fmtp_line(publisher.codec_, publisher.payload_type_) << "\n"; + sdp << "a=sendonly\n"; + sdp << "a=control:streamid=0\n"; + + if (!sdp.good()) { + return std::unexpected("RTP SDP write failed: " + publisher.sdp_path_); + } + + spdlog::info( + "RTP_SDP_WRITTEN codec={} payload_type={} destination={}:{} path={}", + to_string(publisher.codec_), + static_cast(publisher.payload_type_), + endpoint_ip, + publisher.destination_port_, + publisher.sdp_path_); + + return publisher; +} + +void UdpRtpPublisher::publish_access_unit(std::span access_unit, std::uint64_t pts_ns) { + stats_.access_units += 1; + stats_.access_unit_bytes += access_unit.size(); + + if (socket_fd_ < 0 || endpoint_addr_len_ == 0 || access_unit.empty()) { + if (!access_unit.empty()) { + stats_.packets_dropped += 1; + } + return; + } + + const auto send_packet = [&](std::span payload, bool marker) { + std::vector packet{}; + packet.resize(kRtpHeaderBytes + payload.size()); + + packet[0] = 0x80; + packet[1] = static_cast((marker ? 0x80u : 0x00u) | (payload_type_ & 0x7fu)); + + const auto seq = sequence_++; + packet[2] = static_cast((seq >> 8) & 0xffu); + packet[3] = static_cast(seq & 0xffu); + + const auto timestamp = to_rtp_timestamp(pts_ns); + packet[4] = static_cast((timestamp >> 24) & 0xffu); + packet[5] = static_cast((timestamp >> 16) & 0xffu); + packet[6] = static_cast((timestamp >> 8) & 0xffu); + packet[7] = static_cast(timestamp & 0xffu); + + packet[8] = static_cast((ssrc_ >> 24) & 0xffu); + packet[9] = static_cast((ssrc_ >> 16) & 0xffu); + packet[10] = static_cast((ssrc_ >> 8) & 0xffu); + packet[11] = static_cast(ssrc_ & 0xffu); + + if (!payload.empty()) { + std::memcpy(packet.data() + kRtpHeaderBytes, payload.data(), payload.size()); + } + + const auto sent = sendto( + socket_fd_, + reinterpret_cast(packet.data()), + packet.size(), + MSG_DONTWAIT, + reinterpret_cast(&endpoint_addr_), + endpoint_addr_len_); + + if (sent < 0) { + stats_.send_errors += 1; + stats_.packets_dropped += 1; + if (stats_.send_errors <= kErrorLogFirstPackets || (stats_.send_errors % kErrorLogEveryNPackets) == 0) { + spdlog::warn( + "RTP_SEND_ERROR codec={} payload_type={} destination={} errno={} detail='{}' send_errors={}", + to_string(codec_), + static_cast(payload_type_), + destination(), + errno, + std::strerror(errno), + stats_.send_errors); + } + return false; + } + + if (static_cast(sent) != packet.size()) { + stats_.packets_dropped += 1; + stats_.send_errors += 1; + return false; + } + + stats_.packets_sent += 1; + stats_.bytes_sent += static_cast(sent); + return true; + }; + + auto nalus = split_annexb_nalus(access_unit); + if (nalus.empty()) { + nalus.push_back(access_unit); + } + + for (std::size_t nal_index = 0; nal_index < nalus.size(); ++nal_index) { + const auto nal = nalus[nal_index]; + const bool is_last_nal = (nal_index + 1) == nalus.size(); + const bool use_single_ru = nal.size() <= kRtpPayloadBytesMax; + + if (use_single_ru) { + (void)send_packet(nal, is_last_nal); + continue; + } + + if (codec_ == CodecType::H264) { + if (nal.size() < 2) { + stats_.packets_dropped += 1; + continue; + } + + const std::uint8_t nal_hdr = nal[0]; + const std::uint8_t fu_indicator = static_cast((nal_hdr & 0xe0u) | 28u); + const std::uint8_t nal_type = static_cast(nal_hdr & 0x1fu); + + auto remaining = nal.subspan(1); + bool first = true; + while (!remaining.empty()) { + const auto chunk_size = std::min(remaining.size(), kRtpPayloadBytesMax - 2); + const bool last_chunk = chunk_size == remaining.size(); + + std::vector fu_payload{}; + fu_payload.reserve(2 + chunk_size); + fu_payload.push_back(fu_indicator); + std::uint8_t fu_header = nal_type; + if (first) { + fu_header = static_cast(fu_header | 0x80u); + } + if (last_chunk) { + fu_header = static_cast(fu_header | 0x40u); + } + fu_payload.push_back(fu_header); + fu_payload.insert(fu_payload.end(), remaining.begin(), remaining.begin() + static_cast(chunk_size)); + + const bool marker = is_last_nal && last_chunk; + (void)send_packet(std::span(fu_payload.data(), fu_payload.size()), marker); + + remaining = remaining.subspan(chunk_size); + first = false; + } + continue; + } + + if (nal.size() < 3) { + stats_.packets_dropped += 1; + continue; + } + + const std::uint8_t hdr0 = nal[0]; + const std::uint8_t hdr1 = nal[1]; + const std::uint8_t nal_type = static_cast((hdr0 >> 1) & 0x3fu); + + const std::uint8_t fu_indicator0 = static_cast((hdr0 & 0x81u) | (49u << 1)); + const std::uint8_t fu_indicator1 = hdr1; + + auto remaining = nal.subspan(2); + bool first = true; + while (!remaining.empty()) { + const auto chunk_size = std::min(remaining.size(), kRtpPayloadBytesMax - 3); + const bool last_chunk = chunk_size == remaining.size(); + + std::vector fu_payload{}; + fu_payload.reserve(3 + chunk_size); + fu_payload.push_back(fu_indicator0); + fu_payload.push_back(fu_indicator1); + std::uint8_t fu_header = nal_type; + if (first) { + fu_header = static_cast(fu_header | 0x80u); + } + if (last_chunk) { + fu_header = static_cast(fu_header | 0x40u); + } + fu_payload.push_back(fu_header); + fu_payload.insert(fu_payload.end(), remaining.begin(), remaining.begin() + static_cast(chunk_size)); + + const bool marker = is_last_nal && last_chunk; + (void)send_packet(std::span(fu_payload.data(), fu_payload.size()), marker); + + remaining = remaining.subspan(chunk_size); + first = false; + } + } +} + +const RtpPublisherStats &UdpRtpPublisher::stats() const { + return stats_; +} + +std::string_view UdpRtpPublisher::sdp_path() const { + return sdp_path_; +} + +std::string_view UdpRtpPublisher::destination() const { + static thread_local std::string destination_text{}; + const auto host_or_ip = destination_ip_.empty() ? destination_host_ : destination_ip_; + destination_text = host_or_ip + ":" + std::to_string(destination_port_); + return destination_text; +} + +void UdpRtpPublisher::log_metrics() const { + spdlog::info( + "RTP_METRICS codec={} payload_type={} destination={} sdp={} access_units={} access_unit_bytes={} packets_sent={} packets_dropped={} bytes_sent={} send_errors={}", + to_string(codec_), + static_cast(payload_type_), + destination(), + sdp_path_, + stats_.access_units, + stats_.access_unit_bytes, + stats_.packets_sent, + stats_.packets_dropped, + stats_.bytes_sent, + stats_.send_errors); +} + +} diff --git a/src/sim/options.cpp b/src/sim/options.cpp new file mode 100644 index 0000000..4c504ff --- /dev/null +++ b/src/sim/options.cpp @@ -0,0 +1,291 @@ +#include "cvmmap_streamer/sim/options.hpp" + +#include +#include +#include +#include + +#include + +namespace cvmmap_streamer::sim { + +namespace { + + std::expected next_value(int argc, char **argv, int &index, std::string_view flag_name) { + if (index + 1 >= argc) { + return std::unexpected("missing value for " + std::string(flag_name)); + } + ++index; + return std::string_view{argv[index]}; + } + + std::expected parse_u32(std::string_view raw, std::string_view flag_name) { + std::uint32_t value{0}; + const auto result = std::from_chars(raw.data(), raw.data() + raw.size(), value, 10); + if (result.ec != std::errc{} || result.ptr != raw.data() + raw.size()) { + return std::unexpected("invalid value for " + std::string(flag_name) + ": '" + std::string(raw) + "'"); + } + return value; + } + + std::expected parse_u16(std::string_view raw, std::string_view flag_name) { + std::uint16_t value{0}; + const auto result = std::from_chars(raw.data(), raw.data() + raw.size(), value, 10); + if (result.ec != std::errc{} || result.ptr != raw.data() + raw.size()) { + return std::unexpected("invalid value for " + std::string(flag_name) + ": '" + std::string(raw) + "'"); + } + return value; + } + +} + +std::uint32_t RuntimeConfig::payload_size_bytes() const { + const auto width64 = static_cast(width); + const auto height64 = static_cast(height); + const auto channels64 = static_cast(channels); + return static_cast(width64 * height64 * channels64); +} + +void print_help() { + constexpr std::array kHelpLines{ + "cvmmap_sim", + "Usage:", + " cvmmap_sim [options]", + "", + "Required simulation controls:", + " --frames total frames to emit (default: 360)", + " --fps deterministic frame pacing, 0 disables sleep (default: 60)", + " --width frame width (default: 64)", + " --height frame height (default: 48)", + " --emit-reset-at emit MODULE_STATUS_STREAM_RESET at frame_count n", + " --emit-reset-every emit MODULE_STATUS_STREAM_RESET each n frames", + " --switch-format-at switch metadata frame format at frame_count n", + " --switch-width width after format switch (default: --width)", + " --switch-height height after format switch (default: --height)", + "", + "Optional:", + " --label --shm-name --zmq-endpoint "}; + + for (const auto &line : kHelpLines) { + spdlog::info("{}", line); + } +} + +std::expected parse_runtime_config(int argc, char **argv) { + RuntimeConfig config{}; + + for (int i = 1; i < argc; ++i) { + const std::string_view arg{argv[i]}; + + if (arg == "--frames") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u32(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.frames = *parsed; + continue; + } + + if (arg == "--fps") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u32(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.fps = *parsed; + continue; + } + + if (arg == "--width") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u16(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.width = *parsed; + continue; + } + + if (arg == "--height") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u16(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.height = *parsed; + continue; + } + + if (arg == "--emit-reset-at") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u32(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.emit_reset_at = *parsed; + continue; + } + + if (arg == "--emit-reset-every") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u32(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.emit_reset_every = *parsed; + continue; + } + + if (arg == "--switch-format-at") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u32(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.switch_format_at = *parsed; + continue; + } + + if (arg == "--switch-width") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u16(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.switch_width = *parsed; + continue; + } + + if (arg == "--switch-height") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + auto parsed = parse_u16(*value, arg); + if (!parsed) { + return std::unexpected(parsed.error()); + } + config.switch_height = *parsed; + continue; + } + + if (arg == "--label") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + config.label = std::string(*value); + continue; + } + + if (arg == "--shm-name") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + config.shm_name = std::string(*value); + continue; + } + + if (arg == "--zmq-endpoint") { + auto value = next_value(argc, argv, i, arg); + if (!value) { + return std::unexpected(value.error()); + } + config.zmq_endpoint = std::string(*value); + continue; + } + + if (arg == "--help" || arg == "-h") { + continue; + } + + return std::unexpected("unknown argument: '" + std::string(arg) + "'"); + } + + if (config.frames == 0) { + return std::unexpected("--frames must be > 0"); + } + if (config.width == 0 || config.height == 0) { + return std::unexpected("--width and --height must be > 0"); + } + if (config.label.empty()) { + return std::unexpected("--label must not be empty"); + } + if (config.label.size() > ipc::kLabelLenMax) { + return std::unexpected("--label exceeds 24 bytes"); + } + if (config.shm_name.empty()) { + return std::unexpected("--shm-name must not be empty"); + } + if (config.zmq_endpoint.empty()) { + return std::unexpected("--zmq-endpoint must not be empty"); + } + if (config.emit_reset_at && *config.emit_reset_at == 0) { + return std::unexpected("--emit-reset-at must be in [1, --frames]"); + } + if (config.emit_reset_at && *config.emit_reset_at > config.frames) { + return std::unexpected("--emit-reset-at must be in [1, --frames]"); + } + if (config.emit_reset_every && *config.emit_reset_every == 0) { + return std::unexpected("--emit-reset-every must be > 0"); + } + if (config.switch_format_at && *config.switch_format_at == 0) { + return std::unexpected("--switch-format-at must be in [1, --frames]"); + } + if (config.switch_format_at && *config.switch_format_at > config.frames) { + return std::unexpected("--switch-format-at must be in [1, --frames]"); + } + if (config.switch_width && *config.switch_width == 0) { + return std::unexpected("--switch-width must be > 0"); + } + if (config.switch_height && *config.switch_height == 0) { + return std::unexpected("--switch-height must be > 0"); + } + + const auto payload_size = static_cast(config.width) * + static_cast(config.height) * + static_cast(config.channels); + if (payload_size > static_cast(std::numeric_limits::max())) { + return std::unexpected("computed payload size exceeds cv-mmap frame_info.buffer_size range"); + } + + const auto switched_width = config.switch_width.value_or(config.width); + const auto switched_height = config.switch_height.value_or(config.height); + const auto switched_payload_size = + static_cast(switched_width) * + static_cast(switched_height) * + static_cast(config.channels); + if (switched_payload_size > static_cast(std::numeric_limits::max())) { + return std::unexpected("computed switched payload size exceeds cv-mmap frame_info.buffer_size range"); + } + + return config; +} + +} diff --git a/src/sim/wire.cpp b/src/sim/wire.cpp new file mode 100644 index 0000000..20ea6dc --- /dev/null +++ b/src/sim/wire.cpp @@ -0,0 +1,135 @@ +#include "cvmmap_streamer/sim/wire.hpp" + +#include +#include + +namespace cvmmap_streamer::sim { + +namespace { + + constexpr std::size_t kMetaVersionMajorOffset = 8; + constexpr std::size_t kMetaVersionMinorOffset = 9; + constexpr std::size_t kMetaFrameCountOffset = 12; + constexpr std::size_t kMetaTimestampOffset = 16; + constexpr std::size_t kMetaFrameInfoOffset = 24; + + constexpr std::size_t kFrameInfoWidthOffset = 0; + constexpr std::size_t kFrameInfoHeightOffset = 2; + constexpr std::size_t kFrameInfoChannelsOffset = 4; + constexpr std::size_t kFrameInfoDepthOffset = 5; + constexpr std::size_t kFrameInfoPixelFmtOffset = 6; + constexpr std::size_t kFrameInfoBufferSizeOffset = 8; + + constexpr std::size_t kSyncMagicOffset = 0; + constexpr std::size_t kSyncVersionMajor = 2; + constexpr std::size_t kSyncVersionMinor = 3; + constexpr std::size_t kSyncFrameCountOffset = 4; + constexpr std::size_t kSyncTimestampOffset = 16; + constexpr std::size_t kSyncLabelOffset = 24; + + constexpr std::size_t kModuleStatusMagicOffset = 0; + constexpr std::size_t kModuleStatusVersionMajor = 2; + constexpr std::size_t kModuleStatusVersionMinor = 3; + constexpr std::size_t kModuleStatusCodeOffset = 4; + constexpr std::size_t kModuleStatusLabelOffset = 8; + + void write_u16_le(std::span bytes, std::size_t offset, std::uint16_t value) { + bytes[offset] = static_cast(value & 0xffu); + bytes[offset + 1] = static_cast((value >> 8) & 0xffu); + } + + void write_u32_le(std::span bytes, std::size_t offset, std::uint32_t value) { + bytes[offset] = static_cast(value & 0xffu); + bytes[offset + 1] = static_cast((value >> 8) & 0xffu); + bytes[offset + 2] = static_cast((value >> 16) & 0xffu); + bytes[offset + 3] = static_cast((value >> 24) & 0xffu); + } + + void write_i32_le(std::span bytes, std::size_t offset, std::int32_t value) { + write_u32_le(bytes, offset, static_cast(value)); + } + + void write_u64_le(std::span bytes, std::size_t offset, std::uint64_t value) { + for (std::size_t i = 0; i < sizeof(std::uint64_t); ++i) { + bytes[offset + i] = static_cast((value >> (i * 8)) & 0xffu); + } + } + + void write_label_bytes(std::span out, std::size_t offset, std::string_view label) { + const auto bounded = std::min(label.size(), ipc::kLabelLenMax); + std::fill_n(out.begin() + offset, ipc::kLabelLenMax, static_cast(0)); + for (std::size_t i = 0; i < bounded; ++i) { + out[offset + i] = static_cast(label[i]); + } + } + +} + +void write_frame_metadata( + std::span metadata, + const ipc::FrameInfo &info, + std::uint32_t frame_count, + std::uint64_t timestamp_ns) { + std::fill(metadata.begin(), metadata.end(), static_cast(0)); + std::copy( + ipc::kFrameMetadataMagic.begin(), + ipc::kFrameMetadataMagic.end(), + metadata.begin()); + + metadata[kMetaVersionMajorOffset] = ipc::kVersionMajor; + metadata[kMetaVersionMinorOffset] = ipc::kVersionMinor; + write_u32_le(metadata, kMetaFrameCountOffset, frame_count); + write_u64_le(metadata, kMetaTimestampOffset, timestamp_ns); + + auto frame_info = metadata.subspan(kMetaFrameInfoOffset); + write_u16_le(frame_info, kFrameInfoWidthOffset, info.width); + write_u16_le(frame_info, kFrameInfoHeightOffset, info.height); + frame_info[kFrameInfoChannelsOffset] = info.channels; + frame_info[kFrameInfoDepthOffset] = static_cast(info.depth); + frame_info[kFrameInfoPixelFmtOffset] = static_cast(info.pixel_format); + write_u32_le(frame_info, kFrameInfoBufferSizeOffset, info.buffer_size); +} + +void write_sync_message( + std::span out, + std::string_view label, + std::uint32_t frame_count, + std::uint64_t timestamp_ns) { + std::fill(out.begin(), out.end(), static_cast(0)); + out[kSyncMagicOffset] = ipc::kFrameTopicMagic; + out[kSyncVersionMajor] = ipc::kVersionMajor; + out[kSyncVersionMinor] = ipc::kVersionMinor; + write_u32_le(out, kSyncFrameCountOffset, frame_count); + write_u64_le(out, kSyncTimestampOffset, timestamp_ns); + write_label_bytes(out, kSyncLabelOffset, label); +} + +void write_module_status_message( + std::span out, + std::string_view label, + ipc::ModuleStatus status) { + std::fill(out.begin(), out.end(), static_cast(0)); + out[kModuleStatusMagicOffset] = ipc::kModuleStatusMagic; + out[kModuleStatusVersionMajor] = ipc::kVersionMajor; + out[kModuleStatusVersionMinor] = ipc::kVersionMinor; + write_i32_le(out, kModuleStatusCodeOffset, static_cast(status)); + write_label_bytes(out, kModuleStatusLabelOffset, label); +} + +void write_deterministic_payload( + std::span out, + std::uint32_t frame_count, + std::uint16_t width, + std::uint16_t height, + std::uint8_t channels) { + const auto row_stride = static_cast(width) * channels; + for (std::size_t idx = 0; idx < out.size(); ++idx) { + const auto pixel = idx / channels; + const auto row = pixel / width; + const auto col = pixel % width; + const auto ch = idx % channels; + out[idx] = static_cast((frame_count + (row * 7u) + (col * 13u) + (ch * 17u) + row_stride + height) & 0xffu); + } +} + +} diff --git a/src/testers/ipc_snapshot_tester.cpp b/src/testers/ipc_snapshot_tester.cpp new file mode 100644 index 0000000..cd8f4d2 --- /dev/null +++ b/src/testers/ipc_snapshot_tester.cpp @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include + +#include + +#include "cvmmap_streamer/common.h" +#include "cvmmap_streamer/ipc/contracts.hpp" + +namespace { + +constexpr std::size_t kMagicOffset = 0; +constexpr std::size_t kVersionMajorOffset = 8; +constexpr std::size_t kVersionMinorOffset = 9; +constexpr std::size_t kFrameCountOffset = 12; +constexpr std::size_t kTimestampOffset = 16; +constexpr std::size_t kInfoWidthOffset = 24; +constexpr std::size_t kInfoHeightOffset = 26; +constexpr std::size_t kInfoChannelsOffset = 28; +constexpr std::size_t kInfoDepthOffset = 29; +constexpr std::size_t kInfoPixelFormatOffset = 30; +constexpr std::size_t kInfoBufferSizeOffset = 32; + +void write_u16_le(std::span buffer, std::size_t offset, std::uint16_t value) { + buffer[offset] = static_cast(value & 0xffu); + buffer[offset + 1] = static_cast((value >> 8) & 0xffu); +} + +void write_u32_le(std::span buffer, std::size_t offset, std::uint32_t value) { + buffer[offset] = static_cast(value & 0xffu); + buffer[offset + 1] = static_cast((value >> 8) & 0xffu); + buffer[offset + 2] = static_cast((value >> 16) & 0xffu); + buffer[offset + 3] = static_cast((value >> 24) & 0xffu); +} + +void write_u64_le(std::span buffer, std::size_t offset, std::uint64_t value) { + for (std::size_t i = 0; i < sizeof(std::uint64_t); ++i) { + buffer[offset + i] = static_cast((value >> (i * 8)) & 0xffu); + } +} + +void write_metadata( + std::span buffer, + std::uint32_t frame_count, + std::uint64_t timestamp_ns, + std::uint32_t payload_size) { + std::copy( + cvmmap_streamer::ipc::kFrameMetadataMagic.begin(), + cvmmap_streamer::ipc::kFrameMetadataMagic.end(), + buffer.begin() + kMagicOffset); + buffer[kVersionMajorOffset] = cvmmap_streamer::ipc::kVersionMajor; + buffer[kVersionMinorOffset] = cvmmap_streamer::ipc::kVersionMinor; + write_u32_le(buffer, kFrameCountOffset, frame_count); + write_u64_le(buffer, kTimestampOffset, timestamp_ns); + write_u16_le(buffer, kInfoWidthOffset, 2); + write_u16_le(buffer, kInfoHeightOffset, 2); + buffer[kInfoChannelsOffset] = 1; + buffer[kInfoDepthOffset] = static_cast(cvmmap_streamer::ipc::Depth::U8); + buffer[kInfoPixelFormatOffset] = static_cast(cvmmap_streamer::ipc::PixelFormat::GRAY); + write_u32_le(buffer, kInfoBufferSizeOffset, payload_size); +} + +} + +int main(int argc, char **argv) { + if (argc <= 1 || cvmmap_streamer::has_help_flag(argc, argv)) { + cvmmap_streamer::print_help("ipc_snapshot_tester"); + return 0; + } + + std::array shm{}; + auto shm_view = std::span(shm); + + write_metadata(shm_view, 7, 2222, 32); + for (std::size_t i = 0; i < 32; ++i) { + shm[cvmmap_streamer::ipc::kShmPayloadOffset + i] = static_cast(i + 1); + } + + std::array destination{}; + auto valid = cvmmap_streamer::ipc::read_coherent_snapshot(shm_view, destination); + if (!valid) { + spdlog::error("coherent snapshot should succeed: {}", cvmmap_streamer::ipc::to_string(valid.error())); + return 2; + } + + if (valid->bytes_copied != 32 || valid->metadata.frame_count != 7 || valid->metadata.timestamp_ns != 2222) { + spdlog::error("valid snapshot verification failed"); + return 3; + } + + const auto torn = cvmmap_streamer::ipc::read_coherent_snapshot( + shm_view, + destination, + [&shm_view]() { + write_u32_le(shm_view, kFrameCountOffset, 8); + }); + + if (torn) { + spdlog::error("torn read should be rejected"); + return 4; + } + if (torn.error() != cvmmap_streamer::ipc::SnapshotError::TornRead) { + spdlog::error("unexpected torn read error: {}", cvmmap_streamer::ipc::to_string(torn.error())); + return 5; + } + + spdlog::info("snapshot path valid and torn-read rejection verified"); + return 0; +} diff --git a/src/testers/rtmp_stub_tester.cpp b/src/testers/rtmp_stub_tester.cpp new file mode 100644 index 0000000..f57946d --- /dev/null +++ b/src/testers/rtmp_stub_tester.cpp @@ -0,0 +1,2062 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr std::uint8_t kRtmpVersion = 3; +constexpr std::size_t kRtmpHandshakePartLength = 1536; +constexpr std::uint32_t kDefaultChunkSize = 128; +constexpr std::uint32_t kServerChunkSize = 4096; +constexpr std::uint32_t kWindowAckSize = 2'500'000; +constexpr std::size_t kMaxMessageSize = 8 * 1024 * 1024; + +constexpr std::uint8_t kMsgSetChunkSize = 1; +constexpr std::uint8_t kMsgWindowAckSize = 5; +constexpr std::uint8_t kMsgSetPeerBandwidth = 6; +constexpr std::uint8_t kMsgAudio = 8; +constexpr std::uint8_t kMsgVideo = 9; +constexpr std::uint8_t kMsgAmf3Command = 17; +constexpr std::uint8_t kMsgAmf0Data = 18; +constexpr std::uint8_t kMsgAmf0Command = 20; + +constexpr std::uint32_t kChunkStreamControl = 2; +constexpr std::uint32_t kChunkStreamCommand = 3; +constexpr std::uint32_t kChunkStreamVideo = 6; + +enum class ExitCode : int { + Success = 0, + InvalidArgs = 1, + SocketError = 2, + HandshakeFailed = 3, + PipelineFailed = 4, + ThresholdNotMet = 5, + ModeMismatch = 6, + ProtocolParseFail = 7, + SelfTestFailed = 8, +}; + +enum class ExpectMode { + H264, + H265Enhanced, + H265Domestic, +}; + +enum class VideoSignal { + Unknown, + H264, + H265Enhanced, + H265Domestic, +}; + +struct Config { + std::string listen_host{"127.0.0.1"}; + std::uint16_t listen_port{19350}; + ExpectMode expect_mode{ExpectMode::H264}; + std::uint32_t video_threshold{1}; + std::uint32_t timeout_ms{5000}; + bool verbose{false}; + bool self_test{false}; + std::optional self_test_send_mode{}; +}; + +struct Stats { + bool handshake_ok{false}; + std::uint32_t connect_count{0}; + std::uint32_t create_stream_count{0}; + std::uint32_t publish_count{0}; + + std::uint32_t set_chunk_size_messages{0}; + std::uint32_t total_messages{0}; + std::uint32_t total_audio_messages{0}; + std::uint32_t total_video_messages{0}; + std::uint32_t total_data_messages{0}; + std::uint64_t total_payload_bytes{0}; + + std::uint32_t h264_video_messages{0}; + std::uint32_t h265_enhanced_video_messages{0}; + std::uint32_t h265_domestic_video_messages{0}; + std::uint32_t unknown_video_messages{0}; + + bool mode_mismatch{false}; + std::string mismatch_reason{}; +}; + +struct SessionState { + bool connected{false}; + bool stream_created{false}; + bool publishing{false}; + std::uint32_t publish_stream_id{1}; +}; + +struct ScopedFd { + int fd{-1}; + + ScopedFd() = default; + explicit ScopedFd(int value) : fd(value) { + } + + ~ScopedFd() { + if (fd >= 0) { + close(fd); + } + } + + ScopedFd(const ScopedFd &) = delete; + ScopedFd &operator=(const ScopedFd &) = delete; + + ScopedFd(ScopedFd &&other) noexcept : fd(other.fd) { + other.fd = -1; + } + + ScopedFd &operator=(ScopedFd &&other) noexcept { + if (this != &other) { + if (fd >= 0) { + close(fd); + } + fd = other.fd; + other.fd = -1; + } + return *this; + } + + [[nodiscard]] + bool valid() const { + return fd >= 0; + } +}; + +struct RtmpMessage { + std::uint32_t chunk_stream_id{0}; + std::uint32_t timestamp{0}; + std::uint32_t message_length{0}; + std::uint8_t type_id{0}; + std::uint32_t message_stream_id{0}; + std::vector payload{}; +}; + +struct ParsedCommand { + std::string name{}; + double transaction_id{0.0}; +}; + +struct ChunkStreamState { + bool has_prev_header{false}; + bool has_in_progress_message{false}; + bool extended_timestamp_active{false}; + + std::uint32_t timestamp{0}; + std::uint32_t timestamp_delta{0}; + std::uint32_t message_length{0}; + std::uint8_t type_id{0}; + std::uint32_t message_stream_id{0}; + + std::vector buffer{}; + std::size_t received{0}; +}; + +[[nodiscard]] +std::string_view to_string(ExpectMode mode) { + switch (mode) { + case ExpectMode::H264: + return "h264"; + case ExpectMode::H265Enhanced: + return "h265-enhanced"; + case ExpectMode::H265Domestic: + return "h265-domestic"; + default: + return "unknown"; + } +} + +[[nodiscard]] +std::string_view to_string(VideoSignal signal) { + switch (signal) { + case VideoSignal::H264: + return "h264"; + case VideoSignal::H265Enhanced: + return "h265-enhanced"; + case VideoSignal::H265Domestic: + return "h265-domestic"; + case VideoSignal::Unknown: + default: + return "unknown"; + } +} + +[[nodiscard]] +std::expected +parse_u32_arg(std::string_view raw, std::string_view name) { + std::uint32_t value{0}; + const auto *begin = raw.data(); + const auto *end = raw.data() + raw.size(); + const auto parsed = std::from_chars(begin, end, value, 10); + const bool success = parsed.ec == std::errc{} && parsed.ptr == end; + if (!success) { + return std::unexpected(std::format("invalid value for {}: '{}'", name, raw)); + } + return value; +} + +[[nodiscard]] +std::expected +parse_u16_arg(std::string_view raw, std::string_view name) { + auto parsed = parse_u32_arg(raw, name); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (*parsed == 0 || *parsed > 65535) { + return std::unexpected(std::format("{} must be in range [1, 65535]", name)); + } + return static_cast(*parsed); +} + +[[nodiscard]] +std::expected parse_mode(std::string_view raw) { + if (raw == "h264") { + return ExpectMode::H264; + } + if (raw == "h265-enhanced" || raw == "enhanced") { + return ExpectMode::H265Enhanced; + } + if (raw == "h265-domestic" || raw == "domestic") { + return ExpectMode::H265Domestic; + } + return std::unexpected(std::format( + "invalid mode '{}'; expected: h264 | h265-enhanced | h265-domestic", + raw)); +} + +void print_usage() { + spdlog::info("rtmp_stub_tester - standalone RTMP ingest validator"); + spdlog::info(""); + spdlog::info("Usage:"); + spdlog::info(" rtmp_stub_tester --mode [options]"); + spdlog::info(""); + spdlog::info("Options:"); + spdlog::info(" --mode Expected publish signaling mode (required)"); + spdlog::info(" --listen-host Listen address (default: 127.0.0.1)"); + spdlog::info(" --listen-port <1-65535> Listen port (default: 19350)"); + spdlog::info(" --video-threshold Required matching video tag count (default: 1)"); + spdlog::info(" --timeout-ms Session timeout milliseconds (default: 5000)"); + spdlog::info(" --self-test Spawn built-in local RTMP publisher"); + spdlog::info(" --self-test-send-mode Self-test publish mode (default: same as --mode)"); + spdlog::info(" --verbose, -v Enable debug logging"); + spdlog::info(" --help, -h Show this message"); + spdlog::info(""); + spdlog::info("Exit codes:"); + spdlog::info(" 0 PASS"); + spdlog::info(" 1 Invalid arguments"); + spdlog::info(" 2 Socket/listen/accept failure"); + spdlog::info(" 3 RTMP handshake failure"); + spdlog::info(" 4 Missing connect/createStream/publish pipeline"); + spdlog::info(" 5 Matching video threshold not met"); + spdlog::info(" 6 Mode mismatch detected"); + spdlog::info(" 7 RTMP chunk/protocol parse failure"); + spdlog::info(" 8 Self-test client failure"); + spdlog::info(""); + spdlog::info("Examples:"); + spdlog::info(" rtmp_stub_tester --mode h264 --self-test"); + spdlog::info(" rtmp_stub_tester --mode h265-enhanced --self-test"); + spdlog::info(" rtmp_stub_tester --mode h265-domestic --self-test"); + spdlog::info(" rtmp_stub_tester --mode h265-enhanced --self-test --self-test-send-mode h265-domestic"); +} + +[[nodiscard]] +std::expected parse_args(int argc, char **argv) { + Config config; + bool mode_set = false; + + for (int i = 1; i < argc; ++i) { + std::string_view arg{argv[i]}; + + const auto need_value = [&](std::string_view flag) -> std::expected { + if (i + 1 >= argc) { + return std::unexpected(std::format("missing value for {}", flag)); + } + ++i; + return std::string_view{argv[i]}; + }; + + if (arg == "--help" || arg == "-h") { + return std::unexpected("help"); + } + if (arg == "--mode") { + auto raw = need_value(arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto mode = parse_mode(*raw); + if (!mode) { + return std::unexpected(mode.error()); + } + config.expect_mode = *mode; + mode_set = true; + continue; + } + if (arg == "--listen-host") { + auto raw = need_value(arg); + if (!raw) { + return std::unexpected(raw.error()); + } + config.listen_host = std::string(*raw); + continue; + } + if (arg == "--listen-port") { + auto raw = need_value(arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto port = parse_u16_arg(*raw, "--listen-port"); + if (!port) { + return std::unexpected(port.error()); + } + config.listen_port = *port; + continue; + } + if (arg == "--video-threshold") { + auto raw = need_value(arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto value = parse_u32_arg(*raw, "--video-threshold"); + if (!value) { + return std::unexpected(value.error()); + } + if (*value == 0) { + return std::unexpected("--video-threshold must be >= 1"); + } + config.video_threshold = *value; + continue; + } + if (arg == "--timeout-ms") { + auto raw = need_value(arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto value = parse_u32_arg(*raw, "--timeout-ms"); + if (!value) { + return std::unexpected(value.error()); + } + if (*value < 100) { + return std::unexpected("--timeout-ms must be >= 100"); + } + config.timeout_ms = *value; + continue; + } + if (arg == "--self-test") { + config.self_test = true; + continue; + } + if (arg == "--self-test-send-mode") { + auto raw = need_value(arg); + if (!raw) { + return std::unexpected(raw.error()); + } + auto mode = parse_mode(*raw); + if (!mode) { + return std::unexpected(mode.error()); + } + config.self_test_send_mode = *mode; + continue; + } + if (arg == "--verbose" || arg == "-v") { + config.verbose = true; + continue; + } + + return std::unexpected(std::format("unknown argument: {}", arg)); + } + + if (!mode_set) { + return std::unexpected("--mode is required"); + } + + if (config.listen_host.empty()) { + return std::unexpected("--listen-host must not be empty"); + } + + if (config.self_test_send_mode && !config.self_test) { + return std::unexpected("--self-test-send-mode requires --self-test"); + } + + return config; +} + +[[nodiscard]] +std::expected send_all(int fd, std::span data) { + std::size_t sent = 0; + while (sent < data.size()) { + const ssize_t n = send(fd, data.data() + sent, data.size() - sent, 0); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return std::unexpected(std::format("send failed: {}", std::strerror(errno))); + } + if (n == 0) { + return std::unexpected("send failed: peer closed socket"); + } + sent += static_cast(n); + } + return {}; +} + +[[nodiscard]] +std::expected read_exact( + int fd, + std::span output, + std::chrono::steady_clock::time_point deadline) { + std::size_t received = 0; + while (received < output.size()) { + auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::unexpected("read timeout"); + } + + auto remaining_ms = + std::chrono::duration_cast(deadline - now).count(); + if (remaining_ms <= 0) { + return std::unexpected("read timeout"); + } + + pollfd pfd{}; + pfd.fd = fd; + pfd.events = POLLIN; + const int poll_result = poll(&pfd, 1, static_cast(remaining_ms)); + if (poll_result < 0) { + if (errno == EINTR) { + continue; + } + return std::unexpected(std::format("poll failed: {}", std::strerror(errno))); + } + if (poll_result == 0) { + return std::unexpected("read timeout"); + } + + const ssize_t n = recv(fd, output.data() + received, output.size() - received, 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 socket"); + } + + received += static_cast(n); + } + return {}; +} + +[[nodiscard]] +std::uint32_t read_be24(const std::uint8_t *data) { + return (static_cast(data[0]) << 16) | + (static_cast(data[1]) << 8) | + static_cast(data[2]); +} + +[[nodiscard]] +std::uint32_t read_be32(const std::uint8_t *data) { + return (static_cast(data[0]) << 24) | + (static_cast(data[1]) << 16) | + (static_cast(data[2]) << 8) | + static_cast(data[3]); +} + +[[nodiscard]] +std::uint32_t read_le32(const std::uint8_t *data) { + return (static_cast(data[3]) << 24) | + (static_cast(data[2]) << 16) | + (static_cast(data[1]) << 8) | + static_cast(data[0]); +} + +void append_be24(std::vector &out, std::uint32_t value) { + out.push_back(static_cast((value >> 16) & 0xff)); + out.push_back(static_cast((value >> 8) & 0xff)); + out.push_back(static_cast(value & 0xff)); +} + +void append_be32(std::vector &out, std::uint32_t value) { + out.push_back(static_cast((value >> 24) & 0xff)); + out.push_back(static_cast((value >> 16) & 0xff)); + out.push_back(static_cast((value >> 8) & 0xff)); + out.push_back(static_cast(value & 0xff)); +} + +void append_le32(std::vector &out, std::uint32_t value) { + out.push_back(static_cast(value & 0xff)); + out.push_back(static_cast((value >> 8) & 0xff)); + out.push_back(static_cast((value >> 16) & 0xff)); + out.push_back(static_cast((value >> 24) & 0xff)); +} + +void append_basic_header(std::vector &out, std::uint8_t fmt, std::uint32_t csid) { + if (csid >= 2 && csid <= 63) { + out.push_back(static_cast((fmt << 6) | csid)); + return; + } + + if (csid <= 319) { + out.push_back(static_cast((fmt << 6) | 0)); + out.push_back(static_cast(csid - 64)); + return; + } + + out.push_back(static_cast((fmt << 6) | 1)); + const std::uint32_t normalized = csid - 64; + out.push_back(static_cast(normalized & 0xff)); + out.push_back(static_cast((normalized >> 8) & 0xff)); +} + +[[nodiscard]] +std::expected 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 payload, + std::uint32_t chunk_size) { + if (chunk_size == 0) { + return std::unexpected("invalid chunk size 0"); + } + + std::vector 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(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(chunk_size, payload.size()); + packet.insert(packet.end(), payload.begin(), payload.begin() + 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(chunk_size, payload.size() - offset); + packet.insert(packet.end(), payload.begin() + offset, payload.begin() + offset + part); + offset += part; + } + + return send_all(fd, packet); +} + +void append_amf0_string(std::vector &out, std::string_view value) { + out.push_back(0x02); + out.push_back(static_cast((value.size() >> 8) & 0xff)); + out.push_back(static_cast(value.size() & 0xff)); + out.insert(out.end(), value.begin(), value.end()); +} + +void append_amf0_number(std::vector &out, double value) { + out.push_back(0x00); + std::uint64_t bits{0}; + std::memcpy(&bits, &value, sizeof(bits)); + out.push_back(static_cast((bits >> 56) & 0xff)); + out.push_back(static_cast((bits >> 48) & 0xff)); + out.push_back(static_cast((bits >> 40) & 0xff)); + out.push_back(static_cast((bits >> 32) & 0xff)); + out.push_back(static_cast((bits >> 24) & 0xff)); + out.push_back(static_cast((bits >> 16) & 0xff)); + out.push_back(static_cast((bits >> 8) & 0xff)); + out.push_back(static_cast(bits & 0xff)); +} + +void append_amf0_null(std::vector &out) { + out.push_back(0x05); +} + +void append_amf0_object_start(std::vector &out) { + out.push_back(0x03); +} + +void append_amf0_object_end(std::vector &out) { + out.push_back(0x00); + out.push_back(0x00); + out.push_back(0x09); +} + +void append_amf0_object_key(std::vector &out, std::string_view key) { + out.push_back(static_cast((key.size() >> 8) & 0xff)); + out.push_back(static_cast(key.size() & 0xff)); + out.insert(out.end(), key.begin(), key.end()); +} + +void append_amf0_object_string_property( + std::vector &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 &out, std::string_view key, double value) { + append_amf0_object_key(out, key); + append_amf0_number(out, value); +} + +void append_amf0_object_bool_property(std::vector &out, std::string_view key, bool value) { + append_amf0_object_key(out, key); + out.push_back(0x01); + out.push_back(value ? 1 : 0); +} + +class Amf0Reader { +public: + explicit Amf0Reader(std::span data) : data_(data) { + } + + [[nodiscard]] + std::size_t remaining() const { + return data_.size() - offset_; + } + + [[nodiscard]] + std::expected read_u8() { + if (remaining() < 1) { + return std::unexpected("amf0 unexpected end of data"); + } + return data_[offset_++]; + } + + [[nodiscard]] + std::expected read_u16be() { + if (remaining() < 2) { + return std::unexpected("amf0 unexpected end of data"); + } + const std::uint16_t value = + (static_cast(data_[offset_]) << 8) | + static_cast(data_[offset_ + 1]); + offset_ += 2; + return value; + } + + [[nodiscard]] + std::expected read_string_payload(std::size_t length) { + if (remaining() < length) { + return std::unexpected("amf0 string exceeds payload"); + } + std::string value(reinterpret_cast(data_.data() + offset_), length); + offset_ += length; + return value; + } + + [[nodiscard]] + std::expected read_typed_string() { + auto marker = read_u8(); + if (!marker) { + return std::unexpected(marker.error()); + } + if (*marker != 0x02) { + return std::unexpected(std::format("amf0 expected string marker 0x02, got 0x{:02x}", *marker)); + } + auto len = read_u16be(); + if (!len) { + return std::unexpected(len.error()); + } + return read_string_payload(*len); + } + + [[nodiscard]] + std::expected read_typed_number() { + auto marker = read_u8(); + if (!marker) { + return std::unexpected(marker.error()); + } + if (*marker != 0x00) { + return std::unexpected(std::format("amf0 expected number marker 0x00, got 0x{:02x}", *marker)); + } + if (remaining() < 8) { + return std::unexpected("amf0 number truncated"); + } + + std::uint64_t bits = + (static_cast(data_[offset_]) << 56) | + (static_cast(data_[offset_ + 1]) << 48) | + (static_cast(data_[offset_ + 2]) << 40) | + (static_cast(data_[offset_ + 3]) << 32) | + (static_cast(data_[offset_ + 4]) << 24) | + (static_cast(data_[offset_ + 5]) << 16) | + (static_cast(data_[offset_ + 6]) << 8) | + static_cast(data_[offset_ + 7]); + offset_ += 8; + + double value = 0.0; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + [[nodiscard]] + std::expected skip_typed_value() { + auto marker = read_u8(); + if (!marker) { + return std::unexpected(marker.error()); + } + + switch (*marker) { + case 0x00: { + if (remaining() < 8) { + return std::unexpected("amf0 number truncated"); + } + offset_ += 8; + return {}; + } + case 0x01: { + if (remaining() < 1) { + return std::unexpected("amf0 bool truncated"); + } + offset_ += 1; + return {}; + } + case 0x02: { + auto len = read_u16be(); + if (!len) { + return std::unexpected(len.error()); + } + if (remaining() < *len) { + return std::unexpected("amf0 string truncated"); + } + offset_ += *len; + return {}; + } + case 0x03: { + while (true) { + if (remaining() < 2) { + return std::unexpected("amf0 object truncated"); + } + auto key_len = read_u16be(); + if (!key_len) { + return std::unexpected(key_len.error()); + } + if (*key_len == 0) { + auto end_marker = read_u8(); + if (!end_marker) { + return std::unexpected(end_marker.error()); + } + if (*end_marker == 0x09) { + break; + } + return std::unexpected("amf0 invalid object end marker"); + } + if (remaining() < *key_len) { + return std::unexpected("amf0 object key truncated"); + } + offset_ += *key_len; + auto skipped = skip_typed_value(); + if (!skipped) { + return std::unexpected(skipped.error()); + } + } + return {}; + } + case 0x05: + case 0x06: + return {}; + case 0x08: { + if (remaining() < 4) { + return std::unexpected("amf0 ecma array truncated"); + } + offset_ += 4; + while (true) { + if (remaining() < 2) { + return std::unexpected("amf0 ecma array truncated"); + } + auto key_len = read_u16be(); + if (!key_len) { + return std::unexpected(key_len.error()); + } + if (*key_len == 0) { + auto end_marker = read_u8(); + if (!end_marker) { + return std::unexpected(end_marker.error()); + } + if (*end_marker == 0x09) { + break; + } + return std::unexpected("amf0 invalid ecma array end marker"); + } + if (remaining() < *key_len) { + return std::unexpected("amf0 ecma key truncated"); + } + offset_ += *key_len; + auto skipped = skip_typed_value(); + if (!skipped) { + return std::unexpected(skipped.error()); + } + } + return {}; + } + case 0x0a: { + if (remaining() < 4) { + return std::unexpected("amf0 strict array truncated"); + } + const std::uint32_t count = + (static_cast(data_[offset_]) << 24) | + (static_cast(data_[offset_ + 1]) << 16) | + (static_cast(data_[offset_ + 2]) << 8) | + static_cast(data_[offset_ + 3]); + offset_ += 4; + for (std::uint32_t i = 0; i < count; ++i) { + auto skipped = skip_typed_value(); + if (!skipped) { + return std::unexpected(skipped.error()); + } + } + return {}; + } + case 0x0b: { + if (remaining() < 10) { + return std::unexpected("amf0 date truncated"); + } + offset_ += 10; + return {}; + } + case 0x0c: { + if (remaining() < 4) { + return std::unexpected("amf0 long string truncated"); + } + const std::uint32_t length = + (static_cast(data_[offset_]) << 24) | + (static_cast(data_[offset_ + 1]) << 16) | + (static_cast(data_[offset_ + 2]) << 8) | + static_cast(data_[offset_ + 3]); + offset_ += 4; + if (remaining() < length) { + return std::unexpected("amf0 long string exceeds payload"); + } + offset_ += length; + return {}; + } + default: + return std::unexpected(std::format("unsupported amf0 marker 0x{:02x}", *marker)); + } + } + +private: + std::span data_; + std::size_t offset_{0}; +}; + +[[nodiscard]] +std::expected +parse_command_message(std::span payload, bool amf3_wrapped) { + if (amf3_wrapped) { + if (payload.empty()) { + return std::unexpected("empty amf3 command payload"); + } + if (payload[0] != 0x00) { + return std::unexpected(std::format("unsupported amf3 command marker 0x{:02x}", payload[0])); + } + payload = payload.subspan(1); + } + + Amf0Reader reader(payload); + auto command = reader.read_typed_string(); + if (!command) { + return std::unexpected(std::format("command parse failed: {}", command.error())); + } + auto transaction = reader.read_typed_number(); + if (!transaction) { + return std::unexpected(std::format("transaction parse failed: {}", transaction.error())); + } + + return ParsedCommand{.name = *command, .transaction_id = *transaction}; +} + +class RtmpChunkReader { +public: + explicit RtmpChunkReader(int fd) : fd_(fd) { + } + + void set_chunk_size(std::uint32_t size) { + if (size > 0) { + in_chunk_size_ = size; + } + } + + [[nodiscard]] + std::uint32_t chunk_size() const { + return in_chunk_size_; + } + + [[nodiscard]] + std::expected + next_message(std::chrono::steady_clock::time_point deadline) { + while (true) { + std::array basic_header_extra{}; + std::array message_header{}; + + auto b0 = read_one(deadline); + if (!b0) { + return std::unexpected(b0.error()); + } + + const std::uint8_t fmt = static_cast((*b0 >> 6) & 0x03); + std::uint32_t csid = static_cast(*b0 & 0x3f); + + if (csid == 0) { + auto ext = read_exact(fd_, std::span(basic_header_extra).first(1), deadline); + if (!ext) { + return std::unexpected(ext.error()); + } + csid = 64 + basic_header_extra[0]; + } else if (csid == 1) { + auto ext = read_exact(fd_, std::span(basic_header_extra).first(2), deadline); + if (!ext) { + return std::unexpected(ext.error()); + } + csid = 64 + basic_header_extra[0] + (static_cast(basic_header_extra[1]) << 8); + } + + auto &state = streams_[csid]; + bool start_new_message{false}; + bool need_ext_timestamp{false}; + + switch (fmt) { + case 0: { + auto header_read = read_exact(fd_, std::span(message_header).first(11), deadline); + if (!header_read) { + return std::unexpected(header_read.error()); + } + + if (state.has_in_progress_message) { + return std::unexpected("protocol error: new type-0 chunk while previous message in progress"); + } + + std::uint32_t timestamp = read_be24(message_header.data()); + state.message_length = read_be24(message_header.data() + 3); + state.type_id = message_header[6]; + state.message_stream_id = read_le32(message_header.data() + 7); + state.timestamp_delta = 0; + state.extended_timestamp_active = timestamp == 0x00ffffffu; + need_ext_timestamp = state.extended_timestamp_active; + if (!need_ext_timestamp) { + state.timestamp = timestamp; + } + start_new_message = true; + break; + } + case 1: { + auto header_read = read_exact(fd_, std::span(message_header).first(7), deadline); + if (!header_read) { + return std::unexpected(header_read.error()); + } + if (!state.has_prev_header) { + return std::unexpected("protocol error: type-1 chunk without previous header"); + } + if (state.has_in_progress_message) { + return std::unexpected("protocol error: type-1 chunk while previous message in progress"); + } + + std::uint32_t delta = read_be24(message_header.data()); + state.message_length = read_be24(message_header.data() + 3); + state.type_id = message_header[6]; + state.extended_timestamp_active = delta == 0x00ffffffu; + need_ext_timestamp = state.extended_timestamp_active; + if (!need_ext_timestamp) { + state.timestamp_delta = delta; + state.timestamp += state.timestamp_delta; + } + start_new_message = true; + break; + } + case 2: { + auto header_read = read_exact(fd_, std::span(message_header).first(3), deadline); + if (!header_read) { + return std::unexpected(header_read.error()); + } + if (!state.has_prev_header) { + return std::unexpected("protocol error: type-2 chunk without previous header"); + } + if (state.has_in_progress_message) { + return std::unexpected("protocol error: type-2 chunk while previous message in progress"); + } + + std::uint32_t delta = read_be24(message_header.data()); + state.extended_timestamp_active = delta == 0x00ffffffu; + need_ext_timestamp = state.extended_timestamp_active; + if (!need_ext_timestamp) { + state.timestamp_delta = delta; + state.timestamp += state.timestamp_delta; + } + start_new_message = true; + break; + } + case 3: { + if (!state.has_prev_header) { + return std::unexpected("protocol error: type-3 chunk without previous header"); + } + if (!state.has_in_progress_message) { + state.timestamp += state.timestamp_delta; + start_new_message = true; + } + need_ext_timestamp = state.extended_timestamp_active; + break; + } + default: + return std::unexpected("protocol error: invalid fmt"); + } + + if (need_ext_timestamp) { + std::array ext{}; + auto ext_read = read_exact(fd_, ext, deadline); + if (!ext_read) { + return std::unexpected(ext_read.error()); + } + const std::uint32_t ext_ts = read_be32(ext.data()); + if (fmt == 0) { + state.timestamp = ext_ts; + } else if (fmt == 1 || fmt == 2) { + state.timestamp_delta = ext_ts; + state.timestamp += state.timestamp_delta; + } + } + + if (state.message_length > kMaxMessageSize) { + return std::unexpected(std::format( + "message too large ({} bytes > {} bytes limit)", + state.message_length, + kMaxMessageSize)); + } + + if (start_new_message) { + state.buffer.clear(); + state.buffer.resize(state.message_length); + state.received = 0; + state.has_in_progress_message = true; + state.has_prev_header = true; + } + + if (!state.has_in_progress_message) { + return std::unexpected("protocol error: continuation chunk without active message"); + } + + const std::size_t remaining = state.message_length - state.received; + const std::size_t to_read = std::min(in_chunk_size_, remaining); + if (to_read > 0) { + auto body_read = read_exact( + fd_, + std::span(state.buffer.data() + state.received, to_read), + deadline); + if (!body_read) { + return std::unexpected(body_read.error()); + } + state.received += to_read; + } + + if (state.received < state.message_length) { + continue; + } + + RtmpMessage message{}; + message.chunk_stream_id = csid; + message.timestamp = state.timestamp; + message.message_length = state.message_length; + message.type_id = state.type_id; + message.message_stream_id = state.message_stream_id; + message.payload = std::move(state.buffer); + + state.buffer.clear(); + state.received = 0; + state.has_in_progress_message = false; + return message; + } + } + +private: + [[nodiscard]] + std::expected + read_one(std::chrono::steady_clock::time_point deadline) { + std::array byte{}; + auto result = read_exact(fd_, byte, deadline); + if (!result) { + return std::unexpected(result.error()); + } + return byte[0]; + } + + int fd_{-1}; + std::uint32_t in_chunk_size_{kDefaultChunkSize}; + std::unordered_map streams_{}; +}; + +[[nodiscard]] +std::expected send_set_chunk_size(int fd, std::uint32_t size, std::uint32_t chunk_size) { + std::array payload{ + static_cast((size >> 24) & 0xff), + static_cast((size >> 16) & 0xff), + static_cast((size >> 8) & 0xff), + static_cast(size & 0xff), + }; + return send_rtmp_message(fd, kChunkStreamControl, 0, kMsgSetChunkSize, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_window_ack_size(int fd, std::uint32_t window_size, std::uint32_t chunk_size) { + std::array payload{ + static_cast((window_size >> 24) & 0xff), + static_cast((window_size >> 16) & 0xff), + static_cast((window_size >> 8) & 0xff), + static_cast(window_size & 0xff), + }; + return send_rtmp_message(fd, kChunkStreamControl, 0, kMsgWindowAckSize, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_set_peer_bandwidth(int fd, std::uint32_t window_size, std::uint8_t limit_type, std::uint32_t chunk_size) { + std::array payload{ + static_cast((window_size >> 24) & 0xff), + static_cast((window_size >> 16) & 0xff), + static_cast((window_size >> 8) & 0xff), + static_cast(window_size & 0xff), + limit_type, + }; + return send_rtmp_message(fd, kChunkStreamControl, 0, kMsgSetPeerBandwidth, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_connect_result(int fd, double transaction_id, std::uint32_t chunk_size) { + std::vector payload; + payload.reserve(256); + + append_amf0_string(payload, "_result"); + append_amf0_number(payload, transaction_id); + + append_amf0_object_start(payload); + append_amf0_object_string_property(payload, "fmsVer", "FMS/3,5,7,7009"); + append_amf0_object_number_property(payload, "capabilities", 31.0); + append_amf0_object_end(payload); + + append_amf0_object_start(payload); + append_amf0_object_string_property(payload, "level", "status"); + append_amf0_object_string_property(payload, "code", "NetConnection.Connect.Success"); + append_amf0_object_string_property(payload, "description", "Connection succeeded."); + append_amf0_object_number_property(payload, "objectEncoding", 0.0); + append_amf0_object_end(payload); + + return send_rtmp_message(fd, kChunkStreamCommand, 0, kMsgAmf0Command, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_simple_result(int fd, double transaction_id, std::uint32_t message_stream_id, std::uint32_t chunk_size) { + std::vector payload; + payload.reserve(64); + append_amf0_string(payload, "_result"); + append_amf0_number(payload, transaction_id); + append_amf0_null(payload); + append_amf0_null(payload); + return send_rtmp_message( + fd, + kChunkStreamCommand, + 0, + kMsgAmf0Command, + message_stream_id, + payload, + chunk_size); +} + +[[nodiscard]] +std::expected +send_create_stream_result(int fd, double transaction_id, double stream_id, std::uint32_t chunk_size) { + std::vector payload; + payload.reserve(64); + append_amf0_string(payload, "_result"); + append_amf0_number(payload, transaction_id); + append_amf0_null(payload); + append_amf0_number(payload, stream_id); + return send_rtmp_message(fd, kChunkStreamCommand, 0, kMsgAmf0Command, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_publish_start(int fd, std::uint32_t stream_id, std::uint32_t chunk_size) { + std::vector payload; + payload.reserve(256); + append_amf0_string(payload, "onStatus"); + append_amf0_number(payload, 0.0); + append_amf0_null(payload); + + append_amf0_object_start(payload); + append_amf0_object_string_property(payload, "level", "status"); + append_amf0_object_string_property(payload, "code", "NetStream.Publish.Start"); + append_amf0_object_string_property(payload, "description", "Start publishing"); + append_amf0_object_bool_property(payload, "isRecorded", false); + append_amf0_object_end(payload); + + return send_rtmp_message( + fd, + kChunkStreamCommand, + 0, + kMsgAmf0Command, + stream_id, + payload, + chunk_size); +} + +[[nodiscard]] +std::expected do_server_handshake( + int fd, + std::chrono::steady_clock::time_point deadline, + Stats &stats) { + std::array c0c1{}; + auto c0c1_read = read_exact(fd, c0c1, deadline); + if (!c0c1_read) { + return std::unexpected(std::format("failed to read C0+C1: {}", c0c1_read.error())); + } + if (c0c1[0] != kRtmpVersion) { + return std::unexpected(std::format( + "unexpected RTMP version in C0: {} (expected {})", + static_cast(c0c1[0]), + static_cast(kRtmpVersion))); + } + + std::array s0s1s2{}; + s0s1s2[0] = kRtmpVersion; + + for (std::size_t i = 0; i < kRtmpHandshakePartLength; ++i) { + s0s1s2[1 + i] = static_cast((i * 37) & 0xff); + } + + std::copy_n(c0c1.begin() + 1, kRtmpHandshakePartLength, s0s1s2.begin() + 1 + kRtmpHandshakePartLength); + auto s_write = send_all(fd, s0s1s2); + if (!s_write) { + return std::unexpected(std::format("failed to write S0+S1+S2: {}", s_write.error())); + } + + std::array c2{}; + auto c2_read = read_exact(fd, c2, deadline); + if (!c2_read) { + return std::unexpected(std::format("failed to read C2: {}", c2_read.error())); + } + + stats.handshake_ok = true; + return {}; +} + +[[nodiscard]] +VideoSignal classify_video_packet(std::span payload) { + if (payload.empty()) { + return VideoSignal::Unknown; + } + + const std::uint8_t first = payload[0]; + const std::uint8_t codec_id = first & 0x0f; + + if (codec_id == 7) { + return VideoSignal::H264; + } + if (codec_id == 12) { + return VideoSignal::H265Domestic; + } + + if ((first & 0x80) != 0 && payload.size() >= 5) { + const std::array hvc1{'h', 'v', 'c', '1'}; + const std::array hev1{'h', 'e', 'v', '1'}; + const std::span fourcc(payload.data() + 1, 4); + if (std::equal(fourcc.begin(), fourcc.end(), hvc1.begin()) || + std::equal(fourcc.begin(), fourcc.end(), hev1.begin())) { + return VideoSignal::H265Enhanced; + } + } + + return VideoSignal::Unknown; +} + +void update_mode_stats( + Stats &stats, + ExpectMode expected, + VideoSignal actual, + std::span payload, + std::uint32_t timestamp) { + switch (actual) { + case VideoSignal::H264: + stats.h264_video_messages++; + break; + case VideoSignal::H265Enhanced: + stats.h265_enhanced_video_messages++; + break; + case VideoSignal::H265Domestic: + stats.h265_domestic_video_messages++; + break; + case VideoSignal::Unknown: + default: + stats.unknown_video_messages++; + break; + } + + if (actual == VideoSignal::Unknown || stats.mode_mismatch) { + return; + } + + bool mismatch = false; + if (expected == ExpectMode::H264 && actual != VideoSignal::H264) { + mismatch = true; + } + if (expected == ExpectMode::H265Enhanced && actual != VideoSignal::H265Enhanced) { + mismatch = true; + } + if (expected == ExpectMode::H265Domestic && actual != VideoSignal::H265Domestic) { + mismatch = true; + } + + if (!mismatch) { + return; + } + + stats.mode_mismatch = true; + stats.mismatch_reason = std::format( + "mode mismatch at video tag #{} (timestamp={}): expected {}, got {} (payload-size={}, first-byte=0x{:02x})", + stats.total_video_messages, + timestamp, + to_string(expected), + to_string(actual), + payload.size(), + payload.empty() ? 0 : payload[0]); +} + +[[nodiscard]] +std::uint32_t matching_count(const Stats &stats, ExpectMode mode) { + switch (mode) { + case ExpectMode::H264: + return stats.h264_video_messages; + case ExpectMode::H265Enhanced: + return stats.h265_enhanced_video_messages; + case ExpectMode::H265Domestic: + return stats.h265_domestic_video_messages; + default: + return 0; + } +} + +[[nodiscard]] +std::expected create_listen_socket(std::string_view host, std::uint16_t port, ScopedFd &out) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return std::unexpected(std::format("socket failed: {}", std::strerror(errno))); + } + + int reuse = 1; + if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) { + close(fd); + return std::unexpected(std::format("setsockopt(SO_REUSEADDR) failed: {}", std::strerror(errno))); + } + + std::string host_ip(host); + if (host_ip == "localhost") { + host_ip = "127.0.0.1"; + } + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (inet_pton(AF_INET, host_ip.c_str(), &addr.sin_addr) != 1) { + close(fd); + return std::unexpected(std::format("invalid --listen-host IPv4 address: {}", host)); + } + + if (bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + close(fd); + return std::unexpected(std::format("bind {}:{} failed: {}", host_ip, port, std::strerror(errno))); + } + + if (listen(fd, 1) < 0) { + close(fd); + return std::unexpected(std::format("listen failed: {}", std::strerror(errno))); + } + + out = ScopedFd(fd); + return {}; +} + +[[nodiscard]] +std::expected +accept_with_deadline(int listen_fd, std::chrono::steady_clock::time_point deadline) { + while (true) { + auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::unexpected("accept timeout"); + } + + auto remaining_ms = + std::chrono::duration_cast(deadline - now).count(); + pollfd pfd{}; + pfd.fd = listen_fd; + pfd.events = POLLIN; + + const int poll_result = poll(&pfd, 1, static_cast(remaining_ms)); + if (poll_result < 0) { + if (errno == EINTR) { + continue; + } + return std::unexpected(std::format("poll(accept) failed: {}", std::strerror(errno))); + } + if (poll_result == 0) { + return std::unexpected("accept timeout"); + } + + int client_fd = accept(listen_fd, nullptr, nullptr); + if (client_fd < 0) { + if (errno == EINTR) { + continue; + } + return std::unexpected(std::format("accept failed: {}", std::strerror(errno))); + } + + return ScopedFd(client_fd); + } +} + +[[nodiscard]] +std::expected +handle_command( + int client_fd, + const RtmpMessage &message, + const ParsedCommand &command, + SessionState &session, + Stats &stats, + std::uint32_t &out_chunk_size) { + if (command.name == "connect") { + stats.connect_count++; + session.connected = true; + + auto wa = send_window_ack_size(client_fd, kWindowAckSize, out_chunk_size); + if (!wa) { + return std::unexpected(std::format("failed to send WindowAcknowledgementSize: {}", wa.error())); + } + auto pb = send_set_peer_bandwidth(client_fd, kWindowAckSize, 2, out_chunk_size); + if (!pb) { + return std::unexpected(std::format("failed to send SetPeerBandwidth: {}", pb.error())); + } + auto scs = send_set_chunk_size(client_fd, kServerChunkSize, out_chunk_size); + if (!scs) { + return std::unexpected(std::format("failed to send SetChunkSize: {}", scs.error())); + } + out_chunk_size = kServerChunkSize; + + auto result = send_connect_result(client_fd, command.transaction_id, out_chunk_size); + if (!result) { + return std::unexpected(std::format("failed to send connect _result: {}", result.error())); + } + return {}; + } + + if (command.name == "releaseStream" || command.name == "FCPublish" || command.name == "FCUnpublish") { + auto result = send_simple_result(client_fd, command.transaction_id, 0, out_chunk_size); + if (!result) { + return std::unexpected(std::format("failed to send _result for {}: {}", command.name, result.error())); + } + return {}; + } + + if (command.name == "createStream") { + stats.create_stream_count++; + session.stream_created = true; + session.publish_stream_id = 1; + + auto result = send_create_stream_result(client_fd, command.transaction_id, 1.0, out_chunk_size); + if (!result) { + return std::unexpected(std::format("failed to send createStream _result: {}", result.error())); + } + return {}; + } + + if (command.name == "publish") { + stats.publish_count++; + if (!session.connected || !session.stream_created) { + return std::unexpected( + "publish received before connect/createStream pipeline completed"); + } + + session.publishing = true; + session.publish_stream_id = message.message_stream_id; + + auto status = send_publish_start(client_fd, session.publish_stream_id, out_chunk_size); + if (!status) { + return std::unexpected(std::format("failed to send publish onStatus: {}", status.error())); + } + return {}; + } + + if (command.name == "deleteStream" || command.name == "closeStream") { + return {}; + } + + spdlog::debug( + "Ignoring RTMP command '{}' (tx={})", + command.name, + command.transaction_id); + return {}; +} + +[[nodiscard]] +std::expected +run_server_session(int client_fd, const Config &config, Stats &stats) { + RtmpChunkReader reader(client_fd); + SessionState session; + std::uint32_t out_chunk_size = kDefaultChunkSize; + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(config.timeout_ms); + + auto handshake = do_server_handshake(client_fd, deadline, stats); + if (!handshake) { + return std::unexpected(handshake.error()); + } + + while (std::chrono::steady_clock::now() < deadline) { + auto message = reader.next_message(deadline); + if (!message) { + return std::unexpected(std::format("chunk parse/read failed: {}", message.error())); + } + + stats.total_messages++; + stats.total_payload_bytes += message->payload.size(); + + if (message->type_id == kMsgSetChunkSize) { + if (message->payload.size() >= 4) { + const std::uint32_t new_size = read_be32(message->payload.data()) & 0x7fffffff; + if (new_size > 0) { + reader.set_chunk_size(new_size); + stats.set_chunk_size_messages++; + spdlog::debug("peer set chunk-size={}", new_size); + } + } + continue; + } + + if (message->type_id == kMsgAmf0Data) { + stats.total_data_messages++; + continue; + } + + if (message->type_id == kMsgAudio) { + stats.total_audio_messages++; + continue; + } + + if (message->type_id == kMsgAmf0Command || message->type_id == kMsgAmf3Command) { + const bool amf3_wrapped = message->type_id == kMsgAmf3Command; + auto command = parse_command_message(message->payload, amf3_wrapped); + if (!command) { + return std::unexpected(command.error()); + } + + auto handled = + handle_command(client_fd, *message, *command, session, stats, out_chunk_size); + if (!handled) { + return std::unexpected(handled.error()); + } + continue; + } + + if (message->type_id == kMsgVideo) { + stats.total_video_messages++; + const auto signal = classify_video_packet(message->payload); + update_mode_stats(stats, config.expect_mode, signal, message->payload, message->timestamp); + + if (config.verbose) { + spdlog::debug( + "video tag #{} timestamp={} stream-id={} signal={} payload-size={}", + stats.total_video_messages, + message->timestamp, + message->message_stream_id, + to_string(signal), + message->payload.size()); + } + + if (stats.mode_mismatch) { + return std::unexpected(stats.mismatch_reason); + } + + if (session.connected && session.stream_created && session.publishing && + matching_count(stats, config.expect_mode) >= config.video_threshold) { + return {}; + } + continue; + } + + spdlog::debug( + "ignoring message: type={} csid={} size={} stream-id={}", + static_cast(message->type_id), + message->chunk_stream_id, + message->payload.size(), + message->message_stream_id); + } + + return std::unexpected("session timeout"); +} + +[[nodiscard]] +std::expected do_client_handshake( + int fd, + std::chrono::steady_clock::time_point deadline) { + std::array c0c1{}; + c0c1[0] = kRtmpVersion; + for (std::size_t i = 0; i < kRtmpHandshakePartLength; ++i) { + c0c1[1 + i] = static_cast((i * 19) & 0xff); + } + auto c_write = send_all(fd, c0c1); + if (!c_write) { + return std::unexpected(std::format("self-test write C0+C1 failed: {}", c_write.error())); + } + + std::array s0s1s2{}; + auto s_read = read_exact(fd, s0s1s2, deadline); + if (!s_read) { + return std::unexpected(std::format("self-test read S0+S1+S2 failed: {}", s_read.error())); + } + if (s0s1s2[0] != kRtmpVersion) { + return std::unexpected(std::format( + "self-test unexpected S0 version: {}", + static_cast(s0s1s2[0]))); + } + + std::array c2{}; + std::copy_n(s0s1s2.begin() + 1, kRtmpHandshakePartLength, c2.begin()); + auto c2_write = send_all(fd, c2); + if (!c2_write) { + return std::unexpected(std::format("self-test write C2 failed: {}", c2_write.error())); + } + + return {}; +} + +[[nodiscard]] +std::expected +send_client_connect(int fd, std::uint32_t chunk_size, std::string_view host, std::uint16_t port) { + std::vector 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", "live"); + append_amf0_object_string_property( + payload, + "tcUrl", + std::format("rtmp://{}:{}/live", host == "localhost" ? "127.0.0.1" : std::string(host), port)); + append_amf0_object_number_property(payload, "objectEncoding", 0.0); + append_amf0_object_end(payload); + + return send_rtmp_message(fd, kChunkStreamCommand, 0, kMsgAmf0Command, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected send_client_create_stream(int fd, std::uint32_t chunk_size) { + std::vector payload; + append_amf0_string(payload, "createStream"); + append_amf0_number(payload, 2.0); + append_amf0_null(payload); + return send_rtmp_message(fd, kChunkStreamCommand, 0, kMsgAmf0Command, 0, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_client_publish(int fd, std::uint32_t chunk_size, std::uint32_t stream_id) { + std::vector payload; + append_amf0_string(payload, "publish"); + append_amf0_number(payload, 3.0); + append_amf0_null(payload); + append_amf0_string(payload, "livestream"); + append_amf0_string(payload, "live"); + return send_rtmp_message(fd, kChunkStreamCommand, 0, kMsgAmf0Command, stream_id, payload, chunk_size); +} + +[[nodiscard]] +std::expected +send_client_video_mode_packets(int fd, std::uint32_t chunk_size, std::uint32_t stream_id, ExpectMode mode) { + std::vector config_payload; + std::vector frame_payload; + + switch (mode) { + case ExpectMode::H264: { + config_payload = { + 0x17, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x64, + 0x00, + 0x1f, + 0xff, + 0xe1, + 0x00, + 0x04, + 0x67, + 0x64, + 0x00, + 0x1f, + }; + frame_payload = { + 0x27, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x09, + 0x10, + }; + break; + } + case ExpectMode::H265Enhanced: { + config_payload = { + 0x90, + 'h', + 'v', + 'c', + '1', + 0x01, + 0x01, + 0x60, + }; + frame_payload = { + 0x91, + 'h', + 'v', + 'c', + '1', + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x26, + }; + break; + } + case ExpectMode::H265Domestic: { + config_payload = { + 0x1c, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x01, + 0x60, + }; + frame_payload = { + 0x2c, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x26, + }; + break; + } + default: + return std::unexpected("unsupported self-test mode"); + } + + auto config_send = + send_rtmp_message(fd, kChunkStreamVideo, 0, kMsgVideo, stream_id, config_payload, chunk_size); + if (!config_send) { + return std::unexpected(std::format("self-test send config video tag failed: {}", config_send.error())); + } + + auto frame_send = + send_rtmp_message(fd, kChunkStreamVideo, 40, kMsgVideo, stream_id, frame_payload, chunk_size); + if (!frame_send) { + return std::unexpected(std::format("self-test send frame video tag failed: {}", frame_send.error())); + } + + return {}; +} + +[[nodiscard]] +std::expected +run_self_test_client(std::string host, std::uint16_t port, ExpectMode send_mode, std::uint32_t timeout_ms) { + if (host == "0.0.0.0" || host == "localhost") { + host = "127.0.0.1"; + } + + ScopedFd fd(socket(AF_INET, SOCK_STREAM, 0)); + if (!fd.valid()) { + return std::unexpected(std::format("self-test socket failed: {}", std::strerror(errno))); + } + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { + return std::unexpected(std::format("self-test invalid host: {}", host)); + } + + if (connect(fd.fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + return std::unexpected(std::format("self-test connect failed: {}", std::strerror(errno))); + } + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + + auto handshake = do_client_handshake(fd.fd, deadline); + if (!handshake) { + return std::unexpected(handshake.error()); + } + + auto connect_send = send_client_connect(fd.fd, kDefaultChunkSize, host, port); + if (!connect_send) { + return std::unexpected(connect_send.error()); + } + + auto create_send = send_client_create_stream(fd.fd, kDefaultChunkSize); + if (!create_send) { + return std::unexpected(create_send.error()); + } + + constexpr std::uint32_t stream_id = 1; + auto publish_send = send_client_publish(fd.fd, kDefaultChunkSize, stream_id); + if (!publish_send) { + return std::unexpected(publish_send.error()); + } + + auto video_send = send_client_video_mode_packets(fd.fd, kDefaultChunkSize, stream_id, send_mode); + if (!video_send) { + return std::unexpected(video_send.error()); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + return {}; +} + +void print_summary(const Config &config, const Stats &stats) { + spdlog::info(""); + spdlog::info("=== RTMP Stub Summary ==="); + spdlog::info("Expected mode: {}", to_string(config.expect_mode)); + spdlog::info("Handshake ok: {}", stats.handshake_ok ? "yes" : "no"); + spdlog::info("Commands: connect={}, createStream={}, publish={}", + stats.connect_count, + stats.create_stream_count, + stats.publish_count); + spdlog::info("Messages: total={}, audio={}, video={}, data={}, chunk-size-updates={}", + stats.total_messages, + stats.total_audio_messages, + stats.total_video_messages, + stats.total_data_messages, + stats.set_chunk_size_messages); + spdlog::info("Video signaling counts: h264={}, h265-enhanced={}, h265-domestic={}, unknown={}", + stats.h264_video_messages, + stats.h265_enhanced_video_messages, + stats.h265_domestic_video_messages, + stats.unknown_video_messages); + spdlog::info("Matching count for expected mode: {} (threshold={})", + matching_count(stats, config.expect_mode), + config.video_threshold); + spdlog::info("Payload bytes processed: {}", stats.total_payload_bytes); + if (stats.mode_mismatch) { + spdlog::error("Mode mismatch detail: {}", stats.mismatch_reason); + } +} + +} + +int main(int argc, char **argv) { + if (argc <= 1) { + print_usage(); + return static_cast(ExitCode::InvalidArgs); + } + + auto config_or_error = parse_args(argc, argv); + if (!config_or_error) { + if (config_or_error.error() == "help") { + print_usage(); + return static_cast(ExitCode::Success); + } + spdlog::error("{}", config_or_error.error()); + print_usage(); + return static_cast(ExitCode::InvalidArgs); + } + + const Config config = *config_or_error; + if (config.verbose) { + spdlog::set_level(spdlog::level::debug); + } + + spdlog::info( + "Starting RTMP stub tester: mode={}, listen={}:{}, threshold={}, timeout={}ms, self-test={}", + to_string(config.expect_mode), + config.listen_host, + config.listen_port, + config.video_threshold, + config.timeout_ms, + config.self_test ? "on" : "off"); + + ScopedFd listen_fd; + auto listen_result = create_listen_socket(config.listen_host, config.listen_port, listen_fd); + if (!listen_result) { + spdlog::error("{}", listen_result.error()); + return static_cast(ExitCode::SocketError); + } + + std::optional>> self_test_future; + std::optional self_test_thread; + + if (config.self_test) { + const ExpectMode send_mode = config.self_test_send_mode.value_or(config.expect_mode); + std::promise> promise; + self_test_future = promise.get_future(); + + self_test_thread.emplace([promise = std::move(promise), config, send_mode]() mutable { + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + promise.set_value(run_self_test_client( + config.listen_host, + config.listen_port, + send_mode, + config.timeout_ms)); + }); + + spdlog::info("Self-test client armed: send-mode={}", to_string(send_mode)); + } + + const auto accept_deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(config.timeout_ms); + auto client_fd_result = accept_with_deadline(listen_fd.fd, accept_deadline); + if (!client_fd_result) { + spdlog::error("{}", client_fd_result.error()); + if (self_test_thread && self_test_thread->joinable()) { + self_test_thread->join(); + } + return static_cast(ExitCode::SocketError); + } + + spdlog::info("Client connected"); + + Stats stats; + auto session = run_server_session(client_fd_result->fd, config, stats); + + if (self_test_thread && self_test_thread->joinable()) { + self_test_thread->join(); + } + + if (self_test_future) { + auto self_result = self_test_future->get(); + if (!self_result) { + spdlog::error("Self-test client failure: {}", self_result.error()); + print_summary(config, stats); + return static_cast(ExitCode::SelfTestFailed); + } + } + + if (!session) { + print_summary(config, stats); + if (stats.mode_mismatch) { + spdlog::error("FAIL: {}", stats.mismatch_reason); + return static_cast(ExitCode::ModeMismatch); + } + + if (!stats.handshake_ok) { + spdlog::error("FAIL: handshake did not complete ({})", session.error()); + return static_cast(ExitCode::HandshakeFailed); + } + + if (stats.connect_count == 0 || stats.create_stream_count == 0 || stats.publish_count == 0) { + spdlog::error( + "FAIL: missing command pipeline (connect={}, createStream={}, publish={}) [{}]", + stats.connect_count, + stats.create_stream_count, + stats.publish_count, + session.error()); + return static_cast(ExitCode::PipelineFailed); + } + + if (matching_count(stats, config.expect_mode) < config.video_threshold) { + spdlog::error( + "FAIL: matching video threshold not met for mode {} (have {}, need {}) [{}]", + to_string(config.expect_mode), + matching_count(stats, config.expect_mode), + config.video_threshold, + session.error()); + return static_cast(ExitCode::ThresholdNotMet); + } + + spdlog::error("FAIL: protocol/session error: {}", session.error()); + return static_cast(ExitCode::ProtocolParseFail); + } + + print_summary(config, stats); + + if (!stats.handshake_ok) { + spdlog::error("FAIL: handshake did not complete"); + return static_cast(ExitCode::HandshakeFailed); + } + + if (stats.connect_count == 0 || stats.create_stream_count == 0 || stats.publish_count == 0) { + spdlog::error( + "FAIL: missing command pipeline (connect={}, createStream={}, publish={})", + stats.connect_count, + stats.create_stream_count, + stats.publish_count); + return static_cast(ExitCode::PipelineFailed); + } + + const std::uint32_t matched = matching_count(stats, config.expect_mode); + if (matched < config.video_threshold) { + spdlog::error( + "FAIL: matching video threshold not met for mode {} (have {}, need {})", + to_string(config.expect_mode), + matched, + config.video_threshold); + return static_cast(ExitCode::ThresholdNotMet); + } + + if (stats.mode_mismatch) { + spdlog::error("FAIL: {}", stats.mismatch_reason); + return static_cast(ExitCode::ModeMismatch); + } + + spdlog::info("PASS: handshake + connect/createStream/publish pipeline verified for mode {}", to_string(config.expect_mode)); + return static_cast(ExitCode::Success); +} diff --git a/src/testers/rtp_receiver_tester.cpp b/src/testers/rtp_receiver_tester.cpp new file mode 100644 index 0000000..35d082e --- /dev/null +++ b/src/testers/rtp_receiver_tester.cpp @@ -0,0 +1,505 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include "cvmmap_streamer/common.h" + +namespace { + +// RFC3550 RTP header constants +constexpr std::size_t kRtpHeaderMinSize = 12; +constexpr std::uint8_t kRtpVersion = 2; +constexpr std::uint8_t kRtpVersionMask = 0xC0; +constexpr std::uint8_t kRtpVersionShift = 6; +constexpr std::uint8_t kRtpPaddingMask = 0x20; +constexpr std::uint8_t kRtpExtensionMask = 0x10; +constexpr std::uint8_t kRtpCsrcCountMask = 0x0F; +constexpr std::uint8_t kRtpMarkerMask = 0x80; +constexpr std::uint8_t kRtpPayloadTypeMask = 0x7F; + +// RTP header structure (RFC3550) +struct RtpHeader { + std::uint8_t version; // 2 bits + bool padding; // 1 bit + bool extension; // 1 bit + std::uint8_t csrcCount; // 4 bits + bool marker; // 1 bit + std::uint8_t payloadType; // 7 bits + std::uint16_t sequence; // 16 bits + std::uint32_t timestamp; // 32 bits + std::uint32_t ssrc; // 32 bits +}; + +// Parsed SDP media info +struct SdpMediaInfo { + std::string encodingName; + std::uint32_t clockRate = 0; + std::uint8_t payloadType = 0; + bool hasH264 = false; + bool hasH265 = false; +}; + +// Test configuration +struct Config { + std::uint16_t port = 5004; + std::optional expectedPt; + std::optional sdpFile; + std::optional decodeHook; + std::uint32_t packetThreshold = 10; + std::uint32_t timeoutMs = 5000; + bool verbose = false; +}; + +// Test statistics +struct Stats { + std::uint32_t packetsReceived = 0; + std::uint32_t sequenceGaps = 0; + std::uint32_t invalidPackets = 0; + std::uint16_t lastSequence = 0; + std::uint8_t detectedPt = 0; + bool hasSeenPacket = false; + std::optional ptMismatchError; +}; + +// Parse RTP header from buffer +std::optional parseRtpHeader(std::span data) { + if (data.size() < kRtpHeaderMinSize) { + return std::nullopt; + } + + RtpHeader header{}; + header.version = (data[0] & kRtpVersionMask) >> kRtpVersionShift; + header.padding = (data[0] & kRtpPaddingMask) != 0; + header.extension = (data[0] & kRtpExtensionMask) != 0; + header.csrcCount = data[0] & kRtpCsrcCountMask; + header.marker = (data[1] & kRtpMarkerMask) != 0; + header.payloadType = data[1] & kRtpPayloadTypeMask; + header.sequence = static_cast(data[2]) << 8 | data[3]; + header.timestamp = static_cast(data[4]) << 24 | + static_cast(data[5]) << 16 | + static_cast(data[6]) << 8 | data[7]; + header.ssrc = static_cast(data[8]) << 24 | + static_cast(data[9]) << 16 | + static_cast(data[10]) << 8 | data[11]; + + return header; +} + +// Parse SDP file for media information +std::optional parseSdpFile(std::string_view path) { + std::string pathStr(path); + std::ifstream file(pathStr); + if (!file.is_open()) { + spdlog::error("Failed to open SDP file: {}", path); + return std::nullopt; + } + + SdpMediaInfo info; + std::string line; + bool inMediaSection = false; + + while (std::getline(file, line)) { + // Remove trailing \r if present + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + + if (line.starts_with("m=")) { + inMediaSection = true; + // Parse media line: m= + // e.g., m=video 5004 RTP/AVP 96 + std::istringstream iss(line); + std::string mediaType, port, proto; + int pt = 0; + // Skip "m=" prefix + if (line.size() > 2 && iss.seekg(2) && std::getline(iss, mediaType, ' ')) { + if (iss >> port >> proto >> pt) { + info.payloadType = static_cast(pt); + } + } + } else if (inMediaSection && line.starts_with("a=rtpmap:")) { + // Parse rtpmap: a=rtpmap: / + // e.g., a=rtpmap:96 H264/90000 + size_t ptEnd = line.find(' ', 9); + if (ptEnd != std::string::npos) { + size_t slashPos = line.find('/', ptEnd + 1); + if (slashPos != std::string::npos) { + info.encodingName = line.substr(ptEnd + 1, slashPos - ptEnd - 1); + info.clockRate = std::stoul(line.substr(slashPos + 1)); + } + } + if (info.encodingName == "H264") { + info.hasH264 = true; + } else if (info.encodingName == "H265" || info.encodingName == "HEVC") { + info.hasH265 = true; + } + } + } + + return info; +} + +// Create UDP socket and bind to port +std::expected createUdpSocket(std::uint16_t port) { + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) { + return std::unexpected(std::format("socket failed: {}", std::strerror(errno))); + } + + int reuse = 1; + if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) { + close(sock); + return std::unexpected(std::format("setsockopt failed: {}", std::strerror(errno))); + } + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = INADDR_ANY; + + if (bind(sock, reinterpret_cast(&addr), sizeof(addr)) < 0) { + close(sock); + return std::unexpected(std::format("bind failed: {}", std::strerror(errno))); + } + + return sock; +} + +// Parse command-line arguments +std::expected parseArgs(int argc, char **argv) { + Config config; + + for (int i = 1; i < argc; ++i) { + std::string_view arg(argv[i]); + + if (arg == "--help" || arg == "-h") { + return std::unexpected("help"); + } else if (arg == "--port" && i + 1 < argc) { + config.port = static_cast(std::stoul(argv[++i])); + } else if (arg == "--expect-pt" && i + 1 < argc) { + config.expectedPt = static_cast(std::stoul(argv[++i])); + } else if (arg == "--sdp" && i + 1 < argc) { + config.sdpFile = argv[++i]; + } else if (arg == "--decode-hook" && i + 1 < argc) { + config.decodeHook = argv[++i]; + } else if (arg == "--packet-threshold" && i + 1 < argc) { + config.packetThreshold = std::stoul(argv[++i]); + } else if (arg == "--timeout-ms" && i + 1 < argc) { + config.timeoutMs = std::stoul(argv[++i]); + } else if (arg == "--verbose" || arg == "-v") { + config.verbose = true; + } + } + + return config; +} + +// Print usage +void printRtpReceiverUsage() { + spdlog::info("rtp_receiver_tester - UDP RTP packet receiver and validator"); + spdlog::info(""); + spdlog::info("Usage:"); + spdlog::info(" rtp_receiver_tester [options]"); + spdlog::info(""); + spdlog::info("Options:"); + spdlog::info(" --port UDP port to listen on (default: 5004)"); + spdlog::info(" --expect-pt Expected payload type (0-127)"); + spdlog::info(" --sdp SDP file to validate against"); + spdlog::info(" --decode-hook Optional command to validate payload"); + spdlog::info(" --packet-threshold Minimum packets to consider success (default: 10)"); + spdlog::info(" --timeout-ms Max time to wait for packets (default: 5000)"); + spdlog::info(" --verbose, -v Enable verbose logging"); + spdlog::info(" --help, -h Show this message"); + spdlog::info(""); + spdlog::info("Examples:"); + spdlog::info(" rtp_receiver_tester --port 5004 --expect-pt 96"); + spdlog::info(" rtp_receiver_tester --port 5004 --sdp /tmp/stream.sdp"); + spdlog::info(""); + spdlog::info("Exit codes:"); + spdlog::info(" 0 Success (packets received, PT matches)"); + spdlog::info(" 1 Invalid arguments"); + spdlog::info(" 2 Socket/bind error"); + spdlog::info(" 3 Payload type mismatch"); + spdlog::info(" 4 Packet threshold not met"); + spdlog::info(" 5 SDP validation failed"); + spdlog::info(" 6 Decode hook failed"); +} + +// Run optional decode hook +bool runDecodeHook(std::string_view hookCmd, std::span payload) { + if (hookCmd.empty()) { + return true; + } + + pid_t pid = fork(); + if (pid < 0) { + spdlog::error("fork failed for decode hook"); + return false; + } + + if (pid == 0) { + // Child process - execute hook + // Write payload to stdin of hook command + // For simplicity, use a temp file approach or pipe + // Here we use execlp with the command + execlp("sh", "sh", "-c", std::string(hookCmd).c_str(), nullptr); + _exit(127); + } + + // Parent - wait for child with timeout + int status = 0; + int waitResult = waitpid(pid, &status, WNOHANG); + + // Simple non-blocking check - if not ready, we continue + // The hook is optional/validation only + if (waitResult == 0) { + // Still running, don't block + spdlog::debug("Decode hook still running (non-blocking)"); + return true; + } + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + return true; + } + + spdlog::warn("Decode hook exited with error"); + return false; +} + +} // namespace + +int main(int argc, char **argv) { + if (argc <= 1 || cvmmap_streamer::has_help_flag(argc, argv)) { + printRtpReceiverUsage(); + return (argc <= 1) ? 1 : 0; + } + + auto configResult = parseArgs(argc, argv); + if (!configResult) { + if (configResult.error() == "help") { + printRtpReceiverUsage(); + return 0; + } + spdlog::error("Argument error: {}", configResult.error()); + printRtpReceiverUsage(); + return 1; + } + + const auto &config = *configResult; + + if (config.verbose) { + spdlog::set_level(spdlog::level::debug); + } + + // Parse SDP file if provided + std::optional sdpInfo; + if (config.sdpFile) { + sdpInfo = parseSdpFile(*config.sdpFile); + if (!sdpInfo) { + spdlog::error("Failed to parse SDP file: {}", *config.sdpFile); + return 5; + } + spdlog::info("SDP parsed: encoding={}, clock-rate={}, PT={}", + sdpInfo->encodingName, + sdpInfo->clockRate, + sdpInfo->payloadType); + + // Cross-validate expected PT with SDP + if (config.expectedPt && *config.expectedPt != sdpInfo->payloadType) { + spdlog::error("Expected PT({}) does not match SDP PT({})", + *config.expectedPt, + sdpInfo->payloadType); + return 5; + } + } + + // Create UDP socket + auto sockResult = createUdpSocket(config.port); + if (!sockResult) { + spdlog::error("Socket error: {}", sockResult.error()); + return 2; + } + + int sock = *sockResult; + spdlog::info("Listening on UDP port {} for RTP packets...", config.port); + + Stats stats; + auto startTime = std::chrono::steady_clock::now(); + + std::vector buffer(65535); + sockaddr_in clientAddr{}; + socklen_t addrLen = sizeof(clientAddr); + + while (true) { + // Check timeout + auto elapsed = std::chrono::steady_clock::now() - startTime; + if (std::chrono::duration_cast(elapsed).count() > config.timeoutMs) { + spdlog::info("Timeout reached after {} ms", config.timeoutMs); + break; + } + + // Non-blocking receive with short timeout using select + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(sock, &readfds); + + timeval tv{}; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + + int selectResult = select(sock + 1, &readfds, nullptr, nullptr, &tv); + if (selectResult < 0) { + spdlog::error("select error: {}", std::strerror(errno)); + break; + } + if (selectResult == 0) { + continue; // Timeout, check overall timeout + } + + ssize_t received = recvfrom(sock, + buffer.data(), + buffer.size(), + 0, + reinterpret_cast(&clientAddr), + &addrLen); + + if (received < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + continue; + } + spdlog::error("recvfrom error: {}", std::strerror(errno)); + break; + } + + // Parse RTP header + auto headerOpt = parseRtpHeader(std::span(buffer.data(), received)); + if (!headerOpt) { + spdlog::warn("Received invalid packet (too small)"); + stats.invalidPackets++; + continue; + } + + const auto &header = *headerOpt; + + // Validate RTP version + if (header.version != kRtpVersion) { + spdlog::warn("Invalid RTP version: {} (expected {})", header.version, kRtpVersion); + stats.invalidPackets++; + continue; + } + + stats.packetsReceived++; + + // Track sequence gaps + if (stats.hasSeenPacket) { + std::uint16_t expectedSeq = stats.lastSequence + 1; + if (header.sequence != expectedSeq) { + std::uint16_t gap = header.sequence - expectedSeq; + stats.sequenceGaps += gap; + if (config.verbose) { + spdlog::debug("Sequence gap detected: expected {}, got {} (gap size: {})", + expectedSeq, + header.sequence, + gap); + } + } + } + stats.lastSequence = header.sequence; + stats.hasSeenPacket = true; + stats.detectedPt = header.payloadType; + + // Validate payload type if expected + if (config.expectedPt && header.payloadType != *config.expectedPt) { + spdlog::error("Payload type mismatch: expected {}, got {}", + *config.expectedPt, + header.payloadType); + stats.ptMismatchError = header.payloadType; + } + + // Log packet info + if (config.verbose) { + spdlog::debug("Packet {}: PT={}, seq={}, ts={}, ssrc=0x{:08X}, marker={}", + stats.packetsReceived, + header.payloadType, + header.sequence, + header.timestamp, + header.ssrc, + header.marker); + } + + // Run optional decode hook + if (config.decodeHook && !stats.ptMismatchError) { + std::size_t headerSize = kRtpHeaderMinSize + (header.csrcCount * 4); + if (header.extension) { + // Skip extension header if present + if (received >= headerSize + 4) { + std::uint16_t extLength = static_cast(buffer[headerSize + 2]) << 8 | + buffer[headerSize + 3]; + headerSize += 4 + (extLength * 4); + } + } + if (received > static_cast(headerSize)) { + auto payload = std::span(buffer.data() + headerSize, received - headerSize); + if (!runDecodeHook(*config.decodeHook, payload)) { + spdlog::warn("Decode hook validation failed"); + } + } + } + + // Check if we've received enough packets + if (stats.packetsReceived >= config.packetThreshold) { + spdlog::info("Packet threshold reached ({} packets)", config.packetThreshold); + break; + } + } + + close(sock); + + // Print summary + spdlog::info(""); + spdlog::info("=== RTP Receiver Statistics ==="); + spdlog::info("Packets received: {}", stats.packetsReceived); + spdlog::info("Sequence gaps: {}", stats.sequenceGaps); + spdlog::info("Invalid packets: {}", stats.invalidPackets); + if (stats.hasSeenPacket) { + spdlog::info("Detected payload type: {}", stats.detectedPt); + } + + // Determine exit code + if (stats.ptMismatchError) { + spdlog::error("FAIL: Payload type mismatch detected (expected {}, got {})", + config.expectedPt.value(), + *stats.ptMismatchError); + return 3; + } + + if (stats.packetsReceived < config.packetThreshold) { + spdlog::error("FAIL: Packet threshold not met (received {}, required {})", + stats.packetsReceived, + config.packetThreshold); + return 4; + } + + spdlog::info("PASS: All validations successful"); + return 0; +}