feat(downstream): add cvmmap downstream runtime implementation
This commit introduces the full downstream runtime implementation needed to ingest, transform, and publish streams. It preserves the original upstream request boundary by packaging the entire cvmmap-streamer module (build config, public API, protocol and IPC glue, and simulator/tester entrypoints) in one coherent core unit. Keeping this group isolated enables reviewers to validate runtime behavior and correctness without mixing test evidence or process documentation changes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+110
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string_view>
|
||||
|
||||
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();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> urls{};
|
||||
RtmpMode mode{RtmpMode::Enhanced};
|
||||
};
|
||||
|
||||
struct RtpOutputConfig {
|
||||
bool enabled{false};
|
||||
std::optional<std::string> endpoint{std::nullopt};
|
||||
std::optional<std::string> host{std::nullopt};
|
||||
std::optional<std::uint16_t> port{std::nullopt};
|
||||
std::uint8_t payload_type{96};
|
||||
std::optional<std::string> 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<RuntimeConfig, std::string> parse_runtime_config(int argc, char **argv);
|
||||
std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &config);
|
||||
std::string summarize_runtime_config(const RuntimeConfig &config);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
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<std::uint8_t, 8> 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<std::uint8_t, kFrameMetadataMagic.size()> 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<std::uint8_t, kLabelLenMax> label_bytes;
|
||||
|
||||
[[nodiscard]]
|
||||
std::string_view label() const {
|
||||
const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast<std::uint8_t>(0));
|
||||
return std::string_view{
|
||||
reinterpret_cast<const char *>(label_bytes.data()),
|
||||
static_cast<std::size_t>(std::distance(label_bytes.begin(), end))};
|
||||
}
|
||||
};
|
||||
|
||||
struct ModuleStatusMessage {
|
||||
std::uint8_t versions_major;
|
||||
std::uint8_t versions_minor;
|
||||
ModuleStatus module_status;
|
||||
std::array<std::uint8_t, kLabelLenMax> label_bytes;
|
||||
|
||||
[[nodiscard]]
|
||||
std::string_view label() const {
|
||||
const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast<std::uint8_t>(0));
|
||||
return std::string_view{
|
||||
reinterpret_cast<const char *>(label_bytes.data()),
|
||||
static_cast<std::size_t>(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<std::uint8_t, kLabelLenMax> label_bytes;
|
||||
std::span<const std::uint8_t> request_payload;
|
||||
|
||||
[[nodiscard]]
|
||||
std::string_view label() const {
|
||||
const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast<std::uint8_t>(0));
|
||||
return std::string_view{
|
||||
reinterpret_cast<const char *>(label_bytes.data()),
|
||||
static_cast<std::size_t>(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<std::uint8_t, kLabelLenMax> label_bytes;
|
||||
std::span<const std::uint8_t> response_payload;
|
||||
|
||||
[[nodiscard]]
|
||||
std::string_view label() const {
|
||||
const auto end = std::find(label_bytes.begin(), label_bytes.end(), static_cast<std::uint8_t>(0));
|
||||
return std::string_view{
|
||||
reinterpret_cast<const char *>(label_bytes.data()),
|
||||
static_cast<std::size_t>(std::distance(label_bytes.begin(), end))};
|
||||
}
|
||||
};
|
||||
|
||||
struct ValidatedShmView {
|
||||
FrameMetadata metadata;
|
||||
std::span<const std::uint8_t> payload;
|
||||
};
|
||||
|
||||
struct CoherentSnapshot {
|
||||
FrameMetadata metadata;
|
||||
std::size_t bytes_copied;
|
||||
};
|
||||
|
||||
using SnapshotReadHook = std::function<void()>;
|
||||
|
||||
std::expected<FrameMetadata, ParseError> parse_frame_metadata(std::span<const std::uint8_t> bytes);
|
||||
std::expected<SyncMessage, ParseError> parse_sync_message(std::span<const std::uint8_t> bytes);
|
||||
std::expected<ModuleStatusMessage, ParseError> parse_module_status_message(std::span<const std::uint8_t> bytes);
|
||||
std::expected<ControlRequestMessage, ParseError> parse_control_request_message(std::span<const std::uint8_t> bytes);
|
||||
std::expected<ControlResponseMessage, ParseError> parse_control_response_message(std::span<const std::uint8_t> bytes);
|
||||
|
||||
std::expected<ValidatedShmView, ParseError> validate_shm_region(std::span<const std::uint8_t> shm_region);
|
||||
|
||||
std::expected<CoherentSnapshot, SnapshotError> read_coherent_snapshot(
|
||||
std::span<const std::uint8_t> shm_region,
|
||||
std::span<std::uint8_t> destination,
|
||||
const SnapshotReadHook &before_second_metadata_read = {});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint64_t> ingest_queue_ns_{};
|
||||
std::vector<std::uint64_t> samples_us_{};
|
||||
std::uint64_t emit_stall_events_{0};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include "cvmmap_streamer/config/runtime_config.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<RtmpPublisher, std::string> create(const RuntimeConfig &config);
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<void, std::string> publish_access_unit(std::span<const std::uint8_t> 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<void, std::string> 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<Session> sessions_{};
|
||||
RtmpPublisherStats stats_{};
|
||||
bool had_successful_video_message_{false};
|
||||
bool warned_all_sessions_closed_{false};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "cvmmap_streamer/config/runtime_config.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <sys/socket.h>
|
||||
|
||||
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<UdpRtpPublisher, std::string> create(const RuntimeConfig &config);
|
||||
|
||||
void publish_access_unit(std::span<const std::uint8_t> 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};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#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<std::uint32_t> emit_reset_at{};
|
||||
std::optional<std::uint32_t> emit_reset_every{};
|
||||
std::optional<std::uint32_t> switch_format_at{};
|
||||
std::optional<std::uint16_t> switch_width{};
|
||||
std::optional<std::uint16_t> 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<RuntimeConfig, std::string> parse_runtime_config(int argc, char **argv);
|
||||
void print_help();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
#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<std::uint8_t> metadata,
|
||||
const ipc::FrameInfo &info,
|
||||
std::uint32_t frame_count,
|
||||
std::uint64_t timestamp_ns);
|
||||
|
||||
void write_sync_message(
|
||||
std::span<std::uint8_t> out,
|
||||
std::string_view label,
|
||||
std::uint32_t frame_count,
|
||||
std::uint64_t timestamp_ns);
|
||||
|
||||
void write_module_status_message(
|
||||
std::span<std::uint8_t> out,
|
||||
std::string_view label,
|
||||
ipc::ModuleStatus status);
|
||||
|
||||
void write_deterministic_payload(
|
||||
std::span<std::uint8_t> out,
|
||||
std::uint32_t frame_count,
|
||||
std::uint16_t width,
|
||||
std::uint16_t height,
|
||||
std::uint8_t channels);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
#include "cvmmap_streamer/config/runtime_config.hpp"
|
||||
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace cvmmap_streamer {
|
||||
|
||||
namespace {
|
||||
|
||||
std::expected<std::string_view, std::string>
|
||||
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<std::uint32_t, std::string> 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<std::uint16_t, std::string> 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<std::size_t, std::string> 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<unsigned long long>(std::numeric_limits<std::size_t>::max())) {
|
||||
return std::unexpected("value out of range for " + std::string(flag_name) + ": '" + std::string(raw) + "'");
|
||||
}
|
||||
return static_cast<std::size_t>(parsed);
|
||||
}
|
||||
|
||||
std::expected<bool, std::string> 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<CodecType, std::string> 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<RunMode, std::string> 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<RtmpMode, std::string> 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::pair<std::string, std::uint16_t>, 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 '<host>:<port>' 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<RuntimeConfig, std::string> 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<std::uint8_t>::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<std::uint8_t>(*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<void, std::string> 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 : "<unset>");
|
||||
ss << ", rtp.payload_type=" << static_cast<unsigned>(config.outputs.rtp.payload_type);
|
||||
ss << ", rtp.sdp=" << (config.outputs.rtp.sdp_path ? *config.outputs.rtp.sdp_path : "<auto>");
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
#include "cvmmap_streamer/config/runtime_config.hpp"
|
||||
#include "cvmmap_streamer/ipc/contracts.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <zmq.hpp>
|
||||
|
||||
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<const std::uint8_t> region() const {
|
||||
return std::span<const std::uint8_t>(ptr, bytes);
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
static std::expected<SharedMemoryView, std::string> 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<std::size_t>(statbuf.st_size);
|
||||
auto *mapped = static_cast<std::uint8_t *>(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<std::uint64_t> 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<std::size_t>(1, config.latency.queue_size);
|
||||
std::vector<std::uint8_t> snapshot_buffer(shm->bytes - ipc::kShmPayloadOffset, static_cast<std::uint8_t>(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<IngestFrame> queue;
|
||||
std::size_t queue_depth_peak{0};
|
||||
std::optional<ipc::FrameInfo> last_frame_info{};
|
||||
std::atomic_bool stop_requested{false};
|
||||
std::atomic_bool producer_offline{false};
|
||||
|
||||
IngestStats stats{};
|
||||
|
||||
std::thread consumer([&]() {
|
||||
while (true) {
|
||||
std::optional<IngestFrame> 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<const std::uint8_t>(
|
||||
static_cast<const std::uint8_t *>(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<unsigned>(last_frame_info->channels),
|
||||
snapshot->metadata.info.width,
|
||||
snapshot->metadata.info.height,
|
||||
static_cast<unsigned>(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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
#include "cvmmap_streamer/ipc/contracts.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
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<const std::uint8_t> bytes, std::size_t offset) {
|
||||
return static_cast<std::uint16_t>(bytes[offset]) |
|
||||
(static_cast<std::uint16_t>(bytes[offset + 1]) << 8);
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::uint32_t read_u32_le(std::span<const std::uint8_t> bytes, std::size_t offset) {
|
||||
return static_cast<std::uint32_t>(bytes[offset]) |
|
||||
(static_cast<std::uint32_t>(bytes[offset + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[offset + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[offset + 3]) << 24);
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::int32_t read_i32_le(std::span<const std::uint8_t> bytes, std::size_t offset) {
|
||||
return static_cast<std::int32_t>(read_u32_le(bytes, offset));
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::uint64_t read_u64_le(std::span<const std::uint8_t> bytes, std::size_t offset) {
|
||||
return static_cast<std::uint64_t>(bytes[offset]) |
|
||||
(static_cast<std::uint64_t>(bytes[offset + 1]) << 8) |
|
||||
(static_cast<std::uint64_t>(bytes[offset + 2]) << 16) |
|
||||
(static_cast<std::uint64_t>(bytes[offset + 3]) << 24) |
|
||||
(static_cast<std::uint64_t>(bytes[offset + 4]) << 32) |
|
||||
(static_cast<std::uint64_t>(bytes[offset + 5]) << 40) |
|
||||
(static_cast<std::uint64_t>(bytes[offset + 6]) << 48) |
|
||||
(static_cast<std::uint64_t>(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<Depth, ParseError> validate_depth(std::uint8_t depth_raw) {
|
||||
if (depth_raw > static_cast<std::uint8_t>(Depth::F16)) {
|
||||
return std::unexpected(ParseError::InvalidDepth);
|
||||
}
|
||||
return static_cast<Depth>(depth_raw);
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<PixelFormat, ParseError> validate_pixel_format(std::uint8_t pixel_format_raw) {
|
||||
if (pixel_format_raw > static_cast<std::uint8_t>(PixelFormat::YUYV)) {
|
||||
return std::unexpected(ParseError::InvalidPixelFormat);
|
||||
}
|
||||
return static_cast<PixelFormat>(pixel_format_raw);
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<ModuleStatus, ParseError> validate_module_status(std::int32_t status_raw) {
|
||||
switch (status_raw) {
|
||||
case static_cast<std::int32_t>(ModuleStatus::Online):
|
||||
return ModuleStatus::Online;
|
||||
case static_cast<std::int32_t>(ModuleStatus::Offline):
|
||||
return ModuleStatus::Offline;
|
||||
case static_cast<std::int32_t>(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<FrameMetadata, ParseError> parse_frame_metadata(std::span<const std::uint8_t> 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<SyncMessage, ParseError> parse_sync_message(std::span<const std::uint8_t> 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<ModuleStatusMessage, ParseError> parse_module_status_message(std::span<const std::uint8_t> 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<ControlRequestMessage, ParseError> parse_control_request_message(std::span<const std::uint8_t> 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<std::size_t>(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<ControlResponseMessage, ParseError> parse_control_response_message(std::span<const std::uint8_t> 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<std::size_t>(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<ValidatedShmView, ParseError> validate_shm_region(std::span<const std::uint8_t> 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<std::size_t>(metadata_result->info.buffer_size);
|
||||
if (payload_size > std::numeric_limits<std::size_t>::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<CoherentSnapshot, SnapshotError> read_coherent_snapshot(
|
||||
std::span<const std::uint8_t> shm_region,
|
||||
std::span<std::uint8_t> 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()};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#include <array>
|
||||
#include <string_view>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "cvmmap_streamer/common.h"
|
||||
|
||||
namespace cvmmap_streamer {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::array<std::string_view, 10> kHelpLines{
|
||||
"Usage:",
|
||||
" --help, -h\tshow this message",
|
||||
"",
|
||||
"Options:",
|
||||
" --version\tprint version information",
|
||||
"",
|
||||
"Examples:",
|
||||
" cvmmap_streamer --help",
|
||||
" cvmmap_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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#include <cstddef>
|
||||
|
||||
#include "cvmmap_streamer/ipc/contracts.hpp"
|
||||
|
||||
namespace cvmmap_streamer {
|
||||
|
||||
std::size_t sample_ipc_payload_size() {
|
||||
return ipc::kShmPayloadOffset;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <zmq.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
namespace ipc = cvmmap_streamer::ipc;
|
||||
|
||||
class SharedMemoryRegion {
|
||||
public:
|
||||
static std::expected<SharedMemoryRegion, std::string> 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<off_t>(bytes)) != 0) {
|
||||
close(fd);
|
||||
shm_unlink(shm_name.c_str());
|
||||
return std::unexpected("ftruncate failed");
|
||||
}
|
||||
|
||||
auto *mapped = static_cast<std::uint8_t *>(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<std::uint8_t> metadata() {
|
||||
return std::span<std::uint8_t>(ptr_, ipc::kShmPayloadOffset);
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::span<std::uint8_t> payload(std::size_t payload_bytes) {
|
||||
return std::span<std::uint8_t>(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<std::size_t>(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<std::size_t>(switched_width) *
|
||||
static_cast<std::size_t>(switched_height) *
|
||||
static_cast<std::size_t>(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<zmq::socket_t> 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<std::uint8_t, cvmmap_streamer::sim::kSyncMessageBytes> sync_msg{};
|
||||
std::array<std::uint8_t, cvmmap_streamer::sim::kModuleStatusMessageBytes> 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<std::int32_t>(status), frame_count);
|
||||
};
|
||||
|
||||
std::uint64_t timestamp_ns = 1'000'000'000ull;
|
||||
const std::uint64_t tick_ns =
|
||||
(config->fps == 0)
|
||||
? 0ull
|
||||
: std::max<std::uint64_t>(1ull, 1'000'000'000ull / static_cast<std::uint64_t>(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<std::uint32_t>(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<unsigned>(frame_info.channels),
|
||||
frame_info.buffer_size);
|
||||
}
|
||||
|
||||
const auto active_payload_bytes = static_cast<std::size_t>(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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "cvmmap_streamer/common.h"
|
||||
#include "cvmmap_streamer/config/runtime_config.hpp"
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "cvmmap_streamer/metrics/latency_tracker.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
|
||||
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::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::nanoseconds>(now).count());
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::uint64_t percentile_from_sorted(const std::vector<std::uint64_t> &sorted, std::uint32_t p) {
|
||||
if (sorted.empty()) {
|
||||
return 0;
|
||||
}
|
||||
const auto n = sorted.size();
|
||||
const auto idx = ((n - 1) * static_cast<std::size_t>(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<std::uint64_t>(ingest_queue_ns_.size());
|
||||
}
|
||||
|
||||
LatencySummary IngestEmitLatencyTracker::summarize() const {
|
||||
LatencySummary summary{};
|
||||
summary.samples = static_cast<std::uint64_t>(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<std::uint64_t>(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;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
namespace cvmmap_streamer {
|
||||
|
||||
void protocol_step() {}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,502 @@
|
||||
#include "cvmmap_streamer/protocol/rtp_publisher.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
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<std::uint32_t>(b);
|
||||
hash *= 16777619u;
|
||||
};
|
||||
for (const auto ch : host) {
|
||||
mix_byte(static_cast<std::uint8_t>(ch));
|
||||
}
|
||||
mix_byte(static_cast<std::uint8_t>(port >> 8));
|
||||
mix_byte(static_cast<std::uint8_t>(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::uint16_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(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<std::uint32_t>(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<std::pair<std::size_t, std::size_t>> next_start_code(std::span<const std::uint8_t> 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<std::size_t>(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<std::size_t>(4)};
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
std::vector<std::span<const std::uint8_t>> split_annexb_nalus(std::span<const std::uint8_t> access_unit) {
|
||||
std::vector<std::span<const std::uint8_t>> 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, std::string> 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<socklen_t>(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<unsigned>(publisher.payload_type_) << "\n";
|
||||
sdp << "a=rtpmap:" << static_cast<unsigned>(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<unsigned>(publisher.payload_type_),
|
||||
endpoint_ip,
|
||||
publisher.destination_port_,
|
||||
publisher.sdp_path_);
|
||||
|
||||
return publisher;
|
||||
}
|
||||
|
||||
void UdpRtpPublisher::publish_access_unit(std::span<const std::uint8_t> access_unit, std::uint64_t pts_ns) {
|
||||
stats_.access_units += 1;
|
||||
stats_.access_unit_bytes += access_unit.size();
|
||||
|
||||
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<const std::uint8_t> payload, bool marker) {
|
||||
std::vector<std::uint8_t> packet{};
|
||||
packet.resize(kRtpHeaderBytes + payload.size());
|
||||
|
||||
packet[0] = 0x80;
|
||||
packet[1] = static_cast<std::uint8_t>((marker ? 0x80u : 0x00u) | (payload_type_ & 0x7fu));
|
||||
|
||||
const auto seq = sequence_++;
|
||||
packet[2] = static_cast<std::uint8_t>((seq >> 8) & 0xffu);
|
||||
packet[3] = static_cast<std::uint8_t>(seq & 0xffu);
|
||||
|
||||
const auto timestamp = to_rtp_timestamp(pts_ns);
|
||||
packet[4] = static_cast<std::uint8_t>((timestamp >> 24) & 0xffu);
|
||||
packet[5] = static_cast<std::uint8_t>((timestamp >> 16) & 0xffu);
|
||||
packet[6] = static_cast<std::uint8_t>((timestamp >> 8) & 0xffu);
|
||||
packet[7] = static_cast<std::uint8_t>(timestamp & 0xffu);
|
||||
|
||||
packet[8] = static_cast<std::uint8_t>((ssrc_ >> 24) & 0xffu);
|
||||
packet[9] = static_cast<std::uint8_t>((ssrc_ >> 16) & 0xffu);
|
||||
packet[10] = static_cast<std::uint8_t>((ssrc_ >> 8) & 0xffu);
|
||||
packet[11] = static_cast<std::uint8_t>(ssrc_ & 0xffu);
|
||||
|
||||
if (!payload.empty()) {
|
||||
std::memcpy(packet.data() + kRtpHeaderBytes, payload.data(), payload.size());
|
||||
}
|
||||
|
||||
const auto sent = sendto(
|
||||
socket_fd_,
|
||||
reinterpret_cast<const void *>(packet.data()),
|
||||
packet.size(),
|
||||
MSG_DONTWAIT,
|
||||
reinterpret_cast<const sockaddr *>(&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<unsigned>(payload_type_),
|
||||
destination(),
|
||||
errno,
|
||||
std::strerror(errno),
|
||||
stats_.send_errors);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (static_cast<std::size_t>(sent) != packet.size()) {
|
||||
stats_.packets_dropped += 1;
|
||||
stats_.send_errors += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
stats_.packets_sent += 1;
|
||||
stats_.bytes_sent += static_cast<std::uint64_t>(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<std::uint8_t>((nal_hdr & 0xe0u) | 28u);
|
||||
const std::uint8_t nal_type = static_cast<std::uint8_t>(nal_hdr & 0x1fu);
|
||||
|
||||
auto remaining = nal.subspan(1);
|
||||
bool first = true;
|
||||
while (!remaining.empty()) {
|
||||
const auto chunk_size = std::min<std::size_t>(remaining.size(), kRtpPayloadBytesMax - 2);
|
||||
const bool last_chunk = chunk_size == remaining.size();
|
||||
|
||||
std::vector<std::uint8_t> 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<std::uint8_t>(fu_header | 0x80u);
|
||||
}
|
||||
if (last_chunk) {
|
||||
fu_header = static_cast<std::uint8_t>(fu_header | 0x40u);
|
||||
}
|
||||
fu_payload.push_back(fu_header);
|
||||
fu_payload.insert(fu_payload.end(), remaining.begin(), remaining.begin() + static_cast<std::ptrdiff_t>(chunk_size));
|
||||
|
||||
const bool marker = is_last_nal && last_chunk;
|
||||
(void)send_packet(std::span<const std::uint8_t>(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<std::uint8_t>((hdr0 >> 1) & 0x3fu);
|
||||
|
||||
const std::uint8_t fu_indicator0 = static_cast<std::uint8_t>((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<std::size_t>(remaining.size(), kRtpPayloadBytesMax - 3);
|
||||
const bool last_chunk = chunk_size == remaining.size();
|
||||
|
||||
std::vector<std::uint8_t> 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<std::uint8_t>(fu_header | 0x80u);
|
||||
}
|
||||
if (last_chunk) {
|
||||
fu_header = static_cast<std::uint8_t>(fu_header | 0x40u);
|
||||
}
|
||||
fu_payload.push_back(fu_header);
|
||||
fu_payload.insert(fu_payload.end(), remaining.begin(), remaining.begin() + static_cast<std::ptrdiff_t>(chunk_size));
|
||||
|
||||
const bool marker = is_last_nal && last_chunk;
|
||||
(void)send_packet(std::span<const std::uint8_t>(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<unsigned>(payload_type_),
|
||||
destination(),
|
||||
sdp_path_,
|
||||
stats_.access_units,
|
||||
stats_.access_unit_bytes,
|
||||
stats_.packets_sent,
|
||||
stats_.packets_dropped,
|
||||
stats_.bytes_sent,
|
||||
stats_.send_errors);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#include "cvmmap_streamer/sim/options.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <limits>
|
||||
#include <string_view>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
namespace cvmmap_streamer::sim {
|
||||
|
||||
namespace {
|
||||
|
||||
std::expected<std::string_view, std::string> 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<std::uint32_t, std::string> 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<std::uint16_t, std::string> 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<std::uint64_t>(width);
|
||||
const auto height64 = static_cast<std::uint64_t>(height);
|
||||
const auto channels64 = static_cast<std::uint64_t>(channels);
|
||||
return static_cast<std::uint32_t>(width64 * height64 * channels64);
|
||||
}
|
||||
|
||||
void print_help() {
|
||||
constexpr std::array<std::string_view, 17> kHelpLines{
|
||||
"cvmmap_sim",
|
||||
"Usage:",
|
||||
" cvmmap_sim [options]",
|
||||
"",
|
||||
"Required simulation controls:",
|
||||
" --frames <n> total frames to emit (default: 360)",
|
||||
" --fps <n> deterministic frame pacing, 0 disables sleep (default: 60)",
|
||||
" --width <px> frame width (default: 64)",
|
||||
" --height <px> frame height (default: 48)",
|
||||
" --emit-reset-at <n> emit MODULE_STATUS_STREAM_RESET at frame_count n",
|
||||
" --emit-reset-every <n> emit MODULE_STATUS_STREAM_RESET each n frames",
|
||||
" --switch-format-at <n> switch metadata frame format at frame_count n",
|
||||
" --switch-width <px> width after format switch (default: --width)",
|
||||
" --switch-height <px> height after format switch (default: --height)",
|
||||
"",
|
||||
"Optional:",
|
||||
" --label <name> --shm-name <name> --zmq-endpoint <endpoint>"};
|
||||
|
||||
for (const auto &line : kHelpLines) {
|
||||
spdlog::info("{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
std::expected<RuntimeConfig, std::string> 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<std::uint64_t>(config.width) *
|
||||
static_cast<std::uint64_t>(config.height) *
|
||||
static_cast<std::uint64_t>(config.channels);
|
||||
if (payload_size > static_cast<std::uint64_t>(std::numeric_limits<std::uint32_t>::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<std::uint64_t>(switched_width) *
|
||||
static_cast<std::uint64_t>(switched_height) *
|
||||
static_cast<std::uint64_t>(config.channels);
|
||||
if (switched_payload_size > static_cast<std::uint64_t>(std::numeric_limits<std::uint32_t>::max())) {
|
||||
return std::unexpected("computed switched payload size exceeds cv-mmap frame_info.buffer_size range");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "cvmmap_streamer/sim/wire.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
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<std::uint8_t> bytes, std::size_t offset, std::uint16_t value) {
|
||||
bytes[offset] = static_cast<std::uint8_t>(value & 0xffu);
|
||||
bytes[offset + 1] = static_cast<std::uint8_t>((value >> 8) & 0xffu);
|
||||
}
|
||||
|
||||
void write_u32_le(std::span<std::uint8_t> bytes, std::size_t offset, std::uint32_t value) {
|
||||
bytes[offset] = static_cast<std::uint8_t>(value & 0xffu);
|
||||
bytes[offset + 1] = static_cast<std::uint8_t>((value >> 8) & 0xffu);
|
||||
bytes[offset + 2] = static_cast<std::uint8_t>((value >> 16) & 0xffu);
|
||||
bytes[offset + 3] = static_cast<std::uint8_t>((value >> 24) & 0xffu);
|
||||
}
|
||||
|
||||
void write_i32_le(std::span<std::uint8_t> bytes, std::size_t offset, std::int32_t value) {
|
||||
write_u32_le(bytes, offset, static_cast<std::uint32_t>(value));
|
||||
}
|
||||
|
||||
void write_u64_le(std::span<std::uint8_t> 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<std::uint8_t>((value >> (i * 8)) & 0xffu);
|
||||
}
|
||||
}
|
||||
|
||||
void write_label_bytes(std::span<std::uint8_t> out, std::size_t offset, std::string_view label) {
|
||||
const auto bounded = std::min<std::size_t>(label.size(), ipc::kLabelLenMax);
|
||||
std::fill_n(out.begin() + offset, ipc::kLabelLenMax, static_cast<std::uint8_t>(0));
|
||||
for (std::size_t i = 0; i < bounded; ++i) {
|
||||
out[offset + i] = static_cast<std::uint8_t>(label[i]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void write_frame_metadata(
|
||||
std::span<std::uint8_t> metadata,
|
||||
const ipc::FrameInfo &info,
|
||||
std::uint32_t frame_count,
|
||||
std::uint64_t timestamp_ns) {
|
||||
std::fill(metadata.begin(), metadata.end(), static_cast<std::uint8_t>(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<std::uint8_t>(info.depth);
|
||||
frame_info[kFrameInfoPixelFmtOffset] = static_cast<std::uint8_t>(info.pixel_format);
|
||||
write_u32_le(frame_info, kFrameInfoBufferSizeOffset, info.buffer_size);
|
||||
}
|
||||
|
||||
void write_sync_message(
|
||||
std::span<std::uint8_t> out,
|
||||
std::string_view label,
|
||||
std::uint32_t frame_count,
|
||||
std::uint64_t timestamp_ns) {
|
||||
std::fill(out.begin(), out.end(), static_cast<std::uint8_t>(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<std::uint8_t> out,
|
||||
std::string_view label,
|
||||
ipc::ModuleStatus status) {
|
||||
std::fill(out.begin(), out.end(), static_cast<std::uint8_t>(0));
|
||||
out[kModuleStatusMagicOffset] = ipc::kModuleStatusMagic;
|
||||
out[kModuleStatusVersionMajor] = ipc::kVersionMajor;
|
||||
out[kModuleStatusVersionMinor] = ipc::kVersionMinor;
|
||||
write_i32_le(out, kModuleStatusCodeOffset, static_cast<std::int32_t>(status));
|
||||
write_label_bytes(out, kModuleStatusLabelOffset, label);
|
||||
}
|
||||
|
||||
void write_deterministic_payload(
|
||||
std::span<std::uint8_t> out,
|
||||
std::uint32_t frame_count,
|
||||
std::uint16_t width,
|
||||
std::uint16_t height,
|
||||
std::uint8_t channels) {
|
||||
const auto row_stride = static_cast<std::size_t>(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<std::uint8_t>((frame_count + (row * 7u) + (col * 13u) + (ch * 17u) + row_stride + height) & 0xffu);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#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<std::uint8_t> buffer, std::size_t offset, std::uint16_t value) {
|
||||
buffer[offset] = static_cast<std::uint8_t>(value & 0xffu);
|
||||
buffer[offset + 1] = static_cast<std::uint8_t>((value >> 8) & 0xffu);
|
||||
}
|
||||
|
||||
void write_u32_le(std::span<std::uint8_t> buffer, std::size_t offset, std::uint32_t value) {
|
||||
buffer[offset] = static_cast<std::uint8_t>(value & 0xffu);
|
||||
buffer[offset + 1] = static_cast<std::uint8_t>((value >> 8) & 0xffu);
|
||||
buffer[offset + 2] = static_cast<std::uint8_t>((value >> 16) & 0xffu);
|
||||
buffer[offset + 3] = static_cast<std::uint8_t>((value >> 24) & 0xffu);
|
||||
}
|
||||
|
||||
void write_u64_le(std::span<std::uint8_t> 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<std::uint8_t>((value >> (i * 8)) & 0xffu);
|
||||
}
|
||||
}
|
||||
|
||||
void write_metadata(
|
||||
std::span<std::uint8_t> 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<std::uint8_t>(cvmmap_streamer::ipc::Depth::U8);
|
||||
buffer[kInfoPixelFormatOffset] = static_cast<std::uint8_t>(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<std::uint8_t, cvmmap_streamer::ipc::kShmPayloadOffset + 32> shm{};
|
||||
auto shm_view = std::span<std::uint8_t>(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<std::uint8_t>(i + 1);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 32> 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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,505 @@
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <expected>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#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<std::uint8_t> expectedPt;
|
||||
std::optional<std::string> sdpFile;
|
||||
std::optional<std::string> 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<std::uint8_t> ptMismatchError;
|
||||
};
|
||||
|
||||
// Parse RTP header from buffer
|
||||
std::optional<RtpHeader> parseRtpHeader(std::span<const std::uint8_t> 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<std::uint16_t>(data[2]) << 8 | data[3];
|
||||
header.timestamp = static_cast<std::uint32_t>(data[4]) << 24 |
|
||||
static_cast<std::uint32_t>(data[5]) << 16 |
|
||||
static_cast<std::uint32_t>(data[6]) << 8 | data[7];
|
||||
header.ssrc = static_cast<std::uint32_t>(data[8]) << 24 |
|
||||
static_cast<std::uint32_t>(data[9]) << 16 |
|
||||
static_cast<std::uint32_t>(data[10]) << 8 | data[11];
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
// Parse SDP file for media information
|
||||
std::optional<SdpMediaInfo> 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=<media> <port> <proto> <pt>
|
||||
// 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<std::uint8_t>(pt);
|
||||
}
|
||||
}
|
||||
} else if (inMediaSection && line.starts_with("a=rtpmap:")) {
|
||||
// Parse rtpmap: a=rtpmap:<pt> <encoding>/<clockrate>
|
||||
// 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<int, std::string> 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<sockaddr *>(&addr), sizeof(addr)) < 0) {
|
||||
close(sock);
|
||||
return std::unexpected(std::format("bind failed: {}", std::strerror(errno)));
|
||||
}
|
||||
|
||||
return sock;
|
||||
}
|
||||
|
||||
// Parse command-line arguments
|
||||
std::expected<Config, std::string> 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::uint16_t>(std::stoul(argv[++i]));
|
||||
} else if (arg == "--expect-pt" && i + 1 < argc) {
|
||||
config.expectedPt = static_cast<std::uint8_t>(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 <num> UDP port to listen on (default: 5004)");
|
||||
spdlog::info(" --expect-pt <num> Expected payload type (0-127)");
|
||||
spdlog::info(" --sdp <path> SDP file to validate against");
|
||||
spdlog::info(" --decode-hook <cmd> Optional command to validate payload");
|
||||
spdlog::info(" --packet-threshold <n> Minimum packets to consider success (default: 10)");
|
||||
spdlog::info(" --timeout-ms <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<const std::uint8_t> 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<SdpMediaInfo> 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<std::uint8_t> 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<std::chrono::milliseconds>(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<sockaddr *>(&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<std::uint16_t>(buffer[headerSize + 2]) << 8 |
|
||||
buffer[headerSize + 3];
|
||||
headerSize += 4 + (extLength * 4);
|
||||
}
|
||||
}
|
||||
if (received > static_cast<ssize_t>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user