From 782af9481ce7e752eb03cd7fe7740ed7c700c82b Mon Sep 17 00:00:00 2001 From: crosstyan Date: Wed, 11 Mar 2026 16:43:29 +0800 Subject: [PATCH] refactor(streamer): remove gstreamer and legacy rtmp paths --- CMakeLists.txt | 23 +- docs/caveats.md | 289 +---- docs/compat_matrix.md | 218 +--- docs/smoke/srs.local.conf | 2 +- docs/smoke/srs.md | 48 +- docs/smoke/zlm.md | 246 +--- .../cvmmap_streamer/config/runtime_config.hpp | 4 - .../protocol/rtmp_publisher.hpp | 84 -- scripts/acceptance_standalone.sh | 419 ++++--- scripts/acceptance_summary_helper.py | 186 +-- scripts/fault_suite.sh | 355 ++---- scripts/fault_summary_helper.py | 355 +----- scripts/live_srs_forward_smoke.sh | 7 +- src/config/runtime_config.cpp | 95 +- src/encode/encoder_backend.cpp | 18 - src/encode/gstreamer_legacy_backend.cpp | 457 -------- src/ipc/help.cpp | 9 +- src/protocol/rtmp_output.cpp | 36 - src/protocol/rtmp_publisher.cpp | 1029 ----------------- src/testers/rtmp_output_tester.cpp | 9 +- src/testers/rtmp_stub_tester.cpp | 53 +- src/testers/rtp_output_tester.cpp | 214 ++++ 22 files changed, 817 insertions(+), 3339 deletions(-) delete mode 100644 include/cvmmap_streamer/protocol/rtmp_publisher.hpp delete mode 100644 src/encode/gstreamer_legacy_backend.cpp delete mode 100644 src/protocol/rtmp_publisher.cpp create mode 100644 src/testers/rtp_output_tester.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 17b968f..63a74c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,8 +6,6 @@ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -option(ENABLE_GSTREAMER_LEGACY "Build the optional legacy GStreamer backend" ON) - find_package(Threads REQUIRED) find_package(cppzmq QUIET) if ( @@ -55,16 +53,6 @@ if (NOT EXISTS "${CVMMAP_PROXY_INCLUDE_DIR}/proxy/proxy.h") message(FATAL_ERROR "proxy headers not found at ${CVMMAP_PROXY_INCLUDE_DIR}") endif() -set(CVMMAP_STREAMER_HAS_GSTREAMER 0) -if (ENABLE_GSTREAMER_LEGACY) - pkg_check_modules(GSTREAMER IMPORTED_TARGET gstreamer-1.0>=1.14 gstreamer-video-1.0>=1.14 gstreamer-app-1.0>=1.14) - if (GSTREAMER_FOUND AND TARGET PkgConfig::GSTREAMER) - set(CVMMAP_STREAMER_HAS_GSTREAMER 1) - else() - message(WARNING "GStreamer legacy backend disabled because required GStreamer packages were not found") - endif() -endif() - add_library(cvmmap_streamer_foxglove_proto STATIC) protobuf_generate( TARGET cvmmap_streamer_foxglove_proto @@ -101,11 +89,9 @@ add_library(cvmmap_streamer_common STATIC src/metrics/latency_tracker.cpp src/pipeline/pipeline_runtime.cpp src/protocol/rtmp_output.cpp - src/protocol/rtmp_publisher.cpp src/protocol/rtp_publisher.cpp src/encode/encoder_backend.cpp src/encode/ffmpeg_encoder_backend.cpp - src/encode/gstreamer_legacy_backend.cpp src/record/protobuf_descriptor.cpp src/record/mcap_record_sink.cpp) @@ -118,10 +104,6 @@ target_include_directories(cvmmap_streamer_common "${CVMMAP_PROXY_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") -target_compile_definitions(cvmmap_streamer_common - PUBLIC - CVMMAP_STREAMER_HAS_GSTREAMER=${CVMMAP_STREAMER_HAS_GSTREAMER}) - set(CVMMAP_STREAMER_LINK_DEPS Threads::Threads cvmmap_streamer_foxglove_proto @@ -157,10 +139,6 @@ if (TARGET PkgConfig::PROTOBUF_PKG) list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::PROTOBUF_PKG) endif() -if (CVMMAP_STREAMER_HAS_GSTREAMER) - list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::GSTREAMER) -endif() - target_link_libraries(cvmmap_streamer_common PUBLIC ${CVMMAP_STREAMER_LINK_DEPS}) function(add_cvmmap_binary target source) @@ -181,6 +159,7 @@ endfunction() add_cvmmap_binary(cvmmap_streamer src/main_streamer.cpp) add_cvmmap_binary(rtp_receiver_tester src/testers/rtp_receiver_tester.cpp) +add_cvmmap_binary(rtp_output_tester src/testers/rtp_output_tester.cpp) add_cvmmap_binary(rtmp_stub_tester src/testers/rtmp_stub_tester.cpp) add_cvmmap_binary(rtmp_output_tester src/testers/rtmp_output_tester.cpp) add_cvmmap_binary(ipc_snapshot_tester src/testers/ipc_snapshot_tester.cpp) diff --git a/docs/caveats.md b/docs/caveats.md index 1360899..d5f9a97 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -1,286 +1,97 @@ # Operational Caveats -This document captures known environment constraints, behavioral edge cases, and operational considerations for the cv-mmap streamer. +This document captures the current runtime constraints for `cvmmap-streamer` after the FFmpeg-only cleanup. -## Environment Constraints +## Input Model -### Simulator Label Length Limit +### cvmmap URI Only -The cv-mmap simulator uses POSIX shared memory naming that imposes a 24-byte maximum on the `--label` parameter. - -**Constraint:** -- Maximum label length: 24 bytes -- Exceeding this causes immediate exit with code 2 - -**Error message:** -``` ---label exceeds 24 bytes -``` - -**Workaround:** -Use compact deterministic labels: -```bash -# Good (19 bytes) -./build/cvmmap_streamer --run-mode pipeline --input-mode dummy --dummy-label acc_1_rtp_h264 ... - -# Bad (28 bytes, will fail) -./build/cvmmap_streamer --run-mode pipeline --input-mode dummy --dummy-label acceptance_rtp_h264_test ... -``` - -### Deterministic Simulator Sizing - -Small frame sizes can trigger GStreamer caps negotiation failures before the first encoded access unit on certain hosts. - -**Constraint:** -- Minimum recommended frame size: 640x360 -- Smaller sizes may cause `not-negotiated` pipeline errors - -**Recommended simulator parameters for validation:** -```bash -./build/cvmmap_streamer \ - --run-mode pipeline \ - --input-mode dummy \ - --dummy-label acc_1_rtp_h264 \ - --dummy-width 640 \ - --dummy-height 360 \ - --dummy-fps 200 \ - --dummy-frames 320 -``` - -### Build Path Isolation - -The downstream project must use its own build directory. Sharing the root `build/` folder with the main cv-mmap project causes cache collision. - -**Constraint:** -- Use `downstream/cvmmap-streamer/build` -- Do not use root `build/` - -**Error symptom:** -Configure errors referencing sibling repo paths or stale cache entries. - -**Resolution:** -```bash -cmake --fresh -B downstream/cvmmap-streamer/build -S downstream/cvmmap-streamer -``` - -### GStreamer Version Requirements - -The NVENC pipeline requires GStreamer 1.20+ with full development headers. Missing elements are detected at configure time. - -**Constraint:** -- GStreamer 1.20+ required -- Development headers required for build -- `nvh264enc` and `nvh265enc` elements checked at runtime - -**Missing NVENC error:** -``` -FATAL: NVENC encoder not available (nvh264enc/nvh265enc) -Run: gst-inspect-1.0 nvh264enc nvh265enc -``` - -**Note:** The pipeline falls back to software encoding (`x264enc`, `x265enc`) if NVENC produces zero encoded access units after 60 frames. - -## Dual-Mode H.265 RTMP Caveats - -### Mode Selection is Binding - -Once selected, the RTMP mode cannot be changed without restarting the streamer process. The mode determines packetization format for the entire session. - -**Enhanced-RTMP mode:** -- Uses `0x90`/`0x91` header bytes -- FourCC signaling (`hvc1`) -- Compatible with FFmpeg 6.0+, OBS 29+, SRS 6.0+, ZLMediaKit - -**Domestic extension mode:** -- Uses `0x1c`/`0x2c` header bytes -- FLV codec-id 12 signaling -- Legacy Chinese CDN compatibility -- Not supported by all players - -### Server Configuration Requirements - -**ZLMediaKit:** -Must set `enhanced=1` in config.ini for Enhanced-RTMP mode: -```ini -[rtmp] -port=1935 -enhanced=1 # 1 = Enhanced RTMP, 0 = Domestic extension -``` - -**SRS:** -Use `hevc.flv.conf` or `hevc.ts.conf` for HEVC support: -```bash -docker run --rm -it -p 1935:1935 ossrs/srs:6 \ - ./objs/srs -c conf/hevc.flv.conf -``` - -### Player Compatibility - -| Player | H.264 RTMP | H.265 Enhanced | H.265 Domestic | -|--------|------------|----------------|----------------| -| FFmpeg 6.0+ | Yes | Yes | With patch | -| ffplay | Yes | Yes | Variable | -| VLC 3.0+ | Yes | Yes | No | -| Chrome 105+ (MSE) | Yes | HTTP-FLV/TS only | No | -| OBS 29+ | Yes | Yes | No | - -### Mode Mismatch Detection - -The `rtmp_stub_tester` validates mode expectations and fails with exit code 6 on mismatch: +The runtime ingests from a cvmmap URI, for example: ```bash -# Start stub expecting domestic mode -./build/rtmp_stub_tester --mode h265-domestic ... - -# Streamer sends enhanced mode -./build/cvmmap_streamer --codec h265 --rtmp-mode enhanced ... - -# Result: tester exits 6 (ModeMismatch) +./build/cvmmap_streamer --run-mode pipeline --input-uri 'cvmmap://zed@/tmp/cvmmap' ``` -## Low-Latency Configuration Trade-offs +Legacy flags such as `--shm-name`, `--zmq-endpoint`, `--input-mode`, and the dummy-input options are no longer supported. -### Queue Size +## Encoder Path -| Setting | Latency | Behavior | -|---------|---------|----------| -| `--queue-size 1` | Lowest | Latest-frame semantics, drops old frames | -| `--queue-size 5` | Medium | Small backlog, some frame retention | -| `--queue-size 0` | N/A | Unbounded (NOT recommended) | +### FFmpeg Is The Only Encoder Backend -### Encoder Settings +The public backend surface is: -| Setting | Low-Latency Value | Trade-off | -|---------|-------------------|-----------| -| `--b-frames 0` | Disabled | Slightly lower compression efficiency | -| `--gop 30` | 1 second at 30fps | Larger GOP = better compression, higher seek latency | -| `rc-lookahead` | 0 (NVENC) | Slightly lower quality prediction | -| `zerolatency` | true | Disables encoder buffering | +- `--encoder-backend auto` +- `--encoder-backend ffmpeg` -### Server-Side Latency (Optional) +Both resolve to the FFmpeg encoder path. The removed GStreamer backend is no longer available. -When using SRS or ZLMediaKit, additional latency can be introduced by server buffering. +### NVENC Is Optional -**SRS low-latency settings:** -``` -publish { mr off; } -play { gop_cache off; queue_length 5; tcp_nodelay on; } +When `--encoder-device nvidia` is selected, FFmpeg must expose `h264_nvenc` and `hevc_nvenc`. + +Useful local checks: + +```bash +ffmpeg -hide_banner -encoders | rg 'nvenc|libx264|libx265' ``` -**Trade-offs:** -- `gop_cache off`: Players wait for next I-frame (startup delay) -- `mr off`: Higher CPU usage -- `queue_length 5`: More susceptible to network jitter +If NVENC is unavailable, use: -## Fault Scenario Behaviors - -### Torn Frame Handling - -When the coherent snapshot detects a torn read (metadata changed during copy): - -1. Frame is dropped -2. `torn_frames` counter increments -3. Ingest loop continues -4. Next sync message triggers new snapshot attempt - -**Expected log:** -``` -SNAPSHOT_TORN frame_count=A/B timestamp=X/Y +```bash +--encoder-device software ``` -### Stream Reset Handling +### Low-Latency Defaults -When `MODULE_STATUS_STREAM_RESET` is received: +The current low-latency defaults are: -1. Ingest queue is flushed -2. Frame counters reset -3. Pipeline may rebuild if resolution/format changed -4. RTMP publishers send sequence header rebase -5. `resets` counter increments +- `gop=30` +- `b_frames=0` +- NVENC preset/tune tuned for low latency -**Expected log:** -``` -STREAM_RESET_RECEIVED -RTMP_STREAM_RESET_REBASE mode= -``` +This favors immediacy over compression efficiency. -### Backpressure Containment +## Transport Caveats -When downstream sinks are slower than ingest: +### RTMP Is Enhanced RTMP Only -1. Queue fills to capacity -2. Oldest frames evicted before push -3. `dropped_frames` counter increments -4. Latest frame always prioritized -5. Latency remains bounded +The repo now supports: -**Expected log:** -``` -QUEUE_DROP dropped_frames=N queue_depth=1 -``` +- `libavformat` RTMP output +- `ffmpeg_process` RTMP output -## Known Limitations +The removed custom RTMP packetizer and `domestic` mode are no longer available. RTMP is always Enhanced RTMP. -### No Audio Support +### RTP SDP Is Optional -Version 1.0 does not support audio capture or muxing. Video-only streams. +The RTP publisher can write an SDP sidecar when `--rtp-sdp` is set. This is only for generic receivers that need an out-of-band description. If the consumer is preconfigured, skip the SDP output. ### No Direct RTSP/WebRTC Publishing -RTSP and WebRTC are not direct publisher outputs. They require server-side conversion from RTMP or RTP. +This repo does not contain a direct RTSP publisher or a direct WebRTC/WHEP publisher. -### Single Codec Per Session +If you want browser playback or WHEP, publish RTMP to a media server such as SRS and let that server expose WHEP/WebRTC. -Runtime codec switching is not supported. To change codecs, restart the streamer process. +## Media Scope -### NVENC Requires NVIDIA GPU +### No Audio Support -NVENC hardware encoding requires an NVIDIA GPU with encode support. Falls back to software encoding on non-NVIDIA systems or when NVENC is unavailable. +This project is video-only. It publishes or records encoded video access units only. -### UDP RTP Only +If you need audio transport, muxing, or A/V sync, that is outside the current repo scope. -RTP output uses UDP unicast only. No multicast or TCP interleaving support in v1. +## Recording Caveats -## Debugging Tips +### MCAP Stores Encoded Video Frames -### Enable Verbose Logging +MCAP recording stores one `foxglove.CompressedVideo` message per encoded access unit. Replay depends on encoded keyframes carrying decoder configuration; this is handled by the current writer on keyframes. -All binaries use spdlog. Set the environment variable for debug output: +## External Server Caveats -```bash -export SPDLOG_LEVEL=debug -./build/cvmmap_streamer ... -``` +### Local SRS Defaults Can Hit `ulimit` -### Check Evidence Logs +On this machine, stock SRS configs can fail at startup if `max_connections` exceeds the local `ulimit -n`. The checked-in smoke profile uses `max_connections 64` to avoid that local failure mode. -Failed runs leave detailed logs: +### Server Latency Still Matters -```bash -# Find latest run -ls -lt .sisyphus/evidence/task-14-acceptance/ | head - -# Examine specific row logs -cat .sisyphus/evidence/task-14-acceptance/RUN_ID/1-rtp_h264/streamer.log -``` - -### Verify Binary Existence - -Before running scripts, verify all binaries are built: - -```bash -for bin in cvmmap_streamer rtp_receiver_tester rtmp_stub_tester; do - test -x "build/$bin" || echo "Missing: $bin" -done -``` - -### Test Individual Components - -```bash -# Test simulator only -./build/cvmmap_streamer --run-mode pipeline --help - -# Test streamer config validation only -./build/cvmmap_streamer --codec h264 --shm-name test --zmq-endpoint ipc:///tmp/test.ipc -# (Will fail with "No output enabled" but validates config parsing) -``` +Low encoder latency does not guarantee low end-to-end latency. SRS, ZLMediaKit, HTTP-FLV players, and browser buffering can all add delay on top of the encoder path. diff --git a/docs/compat_matrix.md b/docs/compat_matrix.md index 4366891..5089164 100644 --- a/docs/compat_matrix.md +++ b/docs/compat_matrix.md @@ -1,188 +1,74 @@ # Compatibility Matrix -This document defines the complete protocol, codec, and RTMP mode compatibility matrix for cv-mmap streamer. It explicitly separates mandatory (blocking) checks from optional (non-blocking) checks. +## Mandatory Acceptance Matrix -## Mandatory Checks (Blocking for Release) +The deterministic acceptance matrix is driven by tester binaries, not by a dummy pipeline source. -These checks must ALL pass for a release to be considered valid. They require no external servers and run entirely in standalone mode. +Run it with: -### Protocol/Codec Matrix +```bash +cmake -B build -S . +cmake --build build +./scripts/acceptance_standalone.sh +``` -| Row | Protocol | Codec | RTMP Mode | Test Command | Evidence | -|-----|----------|-------|-----------|--------------|----------| -| 1 | RTP | H.264 | N/A | `./scripts/acceptance_standalone.sh` (row 1) | task-14-acceptance.txt | -| 2 | RTP | H.265 | N/A | `./scripts/acceptance_standalone.sh` (row 2) | task-14-acceptance.txt | -| 3 | RTMP | H.264 | enhanced | `./scripts/acceptance_standalone.sh` (row 3) | task-14-acceptance.txt | -| 4 | RTMP | H.265 | enhanced | `./scripts/acceptance_standalone.sh` (row 4) | task-14-acceptance.txt | -| 5 | RTMP | H.265 | domestic | `./scripts/acceptance_standalone.sh` (row 5) | task-14-acceptance.txt | +Covered rows: -**Pass Criteria:** -- Exit code 0 from `acceptance_standalone.sh` -- JSON summary shows `total=5 pass=5 fail=0 skip=0` -- All tester processes receive expected packet/frame counts +| Row | Protocol | Codec | Transport | Runner | +|-----|----------|-------|-----------|--------| +| 1 | RTP | H.264 | UDP RTP | `./scripts/acceptance_standalone.sh` | +| 2 | RTP | H.265 | UDP RTP | `./scripts/acceptance_standalone.sh` | +| 3 | RTMP | H.264 | `libavformat` | `./scripts/acceptance_standalone.sh` | +| 4 | RTMP | H.265 | `libavformat` | `./scripts/acceptance_standalone.sh` | +| 5 | RTMP | H.264 | `ffmpeg_process` | `./scripts/acceptance_standalone.sh` | +| 6 | RTMP | H.265 | `ffmpeg_process` | `./scripts/acceptance_standalone.sh` | -### Fault Scenarios +Notes: -| Scenario | Description | Test Command | Evidence | -|----------|-------------|--------------|----------| -| Torn Read | Coherent snapshot rejects torn frames | `./scripts/fault_suite.sh` | task-15-fault-suite.txt | -| Sink Stall | Backpressure containment under slow consumer | `./scripts/fault_suite.sh` | task-15-fault-suite.txt | -| Reset Storm | Stream reset recovery | `./scripts/fault_suite.sh` | task-15-fault-suite.txt | - -**Pass Criteria:** -- Exit code 0 from `fault_suite.sh` -- All fault scenarios PASS status -- No violations of latency/drop thresholds - -## Invalid Combinations (Explicitly Rejected) - -These combinations are rejected at startup with clear error messages and non-zero exit codes. - -| Combination | Error | Exit Code | -|-------------|-------|-----------| -| RTMP + H.264 + domestic mode | H.264 does not support domestic mode | 1 | -| RTP without --rtp-endpoint | Missing required RTP endpoint | 1 | -| RTMP without --rtmp-url | Missing required RTMP URL | 1 | -| --rtmp-mode without H.265 codec | Domestic mode requires H.265 | 1 | +- RTMP is Enhanced RTMP only. +- The custom RTMP packetizer and domestic mode are removed. +- `encoder.backend` remains `auto|ffmpeg`; both resolve to FFmpeg. ## Optional Checks (Non-Blocking) -These checks are provided for interoperability validation but are NOT required for release acceptance. If the environment is unavailable, these should be skipped. +These are useful interoperability checks, but they are not part of the mandatory acceptance gate: -### Server Smoke Tests +- `./scripts/rtmp_srs_smoke.sh` +- `./scripts/live_srs_forward_smoke.sh ''` +- manual WHEP playback through SRS +- manual ZLMediaKit interoperability checks +- live MCAP capture, validation, and paced replay -| Server | Protocols | H.265 Support | Status | -|--------|-----------|---------------|--------| -| SRS 6.0+ | RTMP, HTTP-FLV, HLS, WebRTC | Enhanced-RTMP | Optional | -| ZLMediaKit | RTMP, HTTP-FLV, HTTP-TS, RTSP, WebRTC | Enhanced + Domestic | Optional | +## Recording Coverage -**Skip Conditions:** -- Docker not available -- Port 1935 in use by another service -- Server container fails to start -- Network isolation prevents connection +MCAP support is validated separately with: -**Expected Behavior When Skipped:** -- Script exits with status 0 (SKIP) -- Evidence file notes the skip reason -- Mandatory acceptance still proceeds +- `./build/mcap_reader_tester` +- `./build/mcap_replay_tester` +- `./scripts/replay_mcap.sh` -## RTMP H.265 Dual Mode Details +Current recording scope: -### Enhanced-RTMP Mode (Recommended) +| Output | Codec | Notes | +|--------|-------|-------| +| MCAP `foxglove.CompressedVideo` | H.264 | Stored as Annex B access units | +| MCAP `foxglove.CompressedVideo` | H.265 | Stored as Annex B access units | -**Specification:** [Enhanced RTMP](https://github.com/veovera/enhanced-rtmp) +## Current Defaults -**Characteristics:** -- Video codec header byte: `0x90` (sequence) / `0x91` (frame) -- FourCC: `hvc1` or `hev1` -- Standardized, widely supported -- FFmpeg 6.0+ native support -- OBS 29+ support -- SRS 6.0+ native support -- ZLMediaKit default mode +| Setting | Value | +|---------|-------| +| Encoder backend | `auto` -> FFmpeg | +| RTMP transport | `libavformat` | +| RTMP mode | Enhanced only | +| Encoder device | `auto` | +| Low-latency GOP | `30` | +| B-frames | `0` | -**When to use:** -- Greenfield deployments -- Modern CDN ingestion -- Cross-platform compatibility requirements +## What Is No Longer Supported -### Domestic Extension Mode (Legacy Compatibility) - -**Specification:** Proprietary FLV extension using codec-id 12 - -**Characteristics:** -- Video codec header byte: `0x1c` (keyframe) / `0x2c` (inter) -- FLV codec-id: 12 (non-standard) -- Legacy Chinese CDN compatibility -- Requires explicit config in ZLMediaKit (`enhanced=0`) -- Not supported by all players - -**When to use:** -- Legacy CDN requirements -- Existing domestic-mode infrastructure -- Backward compatibility with older systems - -### Mode Selection Decision Tree - -``` -Is codec H.265? -├── No (H.264) ──> Use enhanced mode only (domestic invalid) -└── Yes (H.265) - ├── Target is modern CDN/player? ──> enhanced - ├── Target requires domestic mode? ──> domestic - └── Unknown? ──> enhanced (safer default) -``` - -## Validation Commands - -### Verify Mandatory Matrix - -```bash -cd downstream/cvmmap-streamer -./scripts/acceptance_standalone.sh -echo "Exit code: $?" -``` - -### Verify Individual Row (RTP H.264) - -```bash -# Terminal 1: Tester -./build/rtp_receiver_tester \ - --port 51040 \ - --expect-pt 96 \ - --packet-threshold 1 \ - --timeout-ms 10000 - -# Terminal 2: Streamer in dummy input mode -./build/cvmmap_streamer \ - --run-mode pipeline \ - --input-mode dummy \ - --shm-name test_rtp_h264 \ - --zmq-endpoint "ipc:///tmp/test_rtp_h264.ipc" \ - --dummy-label rtp_h264 \ - --dummy-frames 320 \ - --dummy-fps 200 \ - --dummy-width 640 \ - --dummy-height 360 & - -./build/cvmmap_streamer \ - --run-mode pipeline \ - --codec h264 \ - --shm-name test_rtp_h264 \ - --zmq-endpoint "ipc:///tmp/test_rtp_h264.ipc" \ - --queue-size 1 \ - --gop 30 \ - --b-frames 0 \ - --ingest-max-frames 120 \ - --rtp \ - --rtp-endpoint "127.0.0.1:51040" \ - --rtp-payload-type 96 \ - --rtp-sdp /tmp/test_rtp_h264.sdp -``` - -### Verify Invalid Combination Rejection - -```bash -# Should fail with clear error -./build/cvmmap_streamer \ - --codec h264 \ - --rtmp-mode domestic \ - --shm-name test \ - --zmq-endpoint "ipc:///tmp/test.ipc" - -echo "Exit code: $?" # Expected: 2 (invalid mode matrix) -``` - -## Evidence Locations - -All test evidence is written to `.sisyphus/evidence/`: - -| Evidence File | Description | -|---------------|-------------| -| task-14-acceptance.txt | Latest acceptance run pointer | -| task-14-acceptance-summary.json | Acceptance summary JSON | -| task-14-acceptance/RUN_ID/ | Per-run logs for each matrix row | -| task-15-fault-suite.txt | Latest fault suite run pointer | -| task-15-fault-suite-summary.json | Fault suite summary JSON | -| task-15-fault-suite/RUN_ID/ | Per-run logs for each scenario | +- GStreamer encoder backend +- custom RTMP packetizer +- RTMP domestic mode +- dummy input flags in the main runtime +- direct in-repo RTSP/WebRTC publishing diff --git a/docs/smoke/srs.local.conf b/docs/smoke/srs.local.conf index 165b691..1e6b7d0 100644 --- a/docs/smoke/srs.local.conf +++ b/docs/smoke/srs.local.conf @@ -1,7 +1,7 @@ # Local SRS config for manual RTMP and WHEP testing with cvmmap-streamer. # Start with: # cd ~/Code/srs/trunk -# ./objs/srs -c /home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf +# ./objs/srs -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf # # If WebRTC/WHEP is consumed from another host and SRS chooses the wrong NIC, # replace `candidate *;` with the reachable host IP. diff --git a/docs/smoke/srs.md b/docs/smoke/srs.md index 762f5c9..9b30b70 100644 --- a/docs/smoke/srs.md +++ b/docs/smoke/srs.md @@ -21,8 +21,6 @@ Use these checks when you want to verify RTMP interoperability or a real cvmmap- ## Reproducible Test: Synthetic RTMP Matrix -This is the fast interoperability smoke for the RTMP sink implementations: - ```bash cmake -B build -S . cmake --build build @@ -42,12 +40,10 @@ What it verifies: - `ffmpeg_process` RTMP output with `h264` - `libavformat` RTMP output with `h265` - `ffmpeg_process` RTMP output with `h265` -- Pullback from SRS HTTP-FLV with `ffprobe` +- pullback from SRS HTTP-FLV with `ffprobe` ## Reproducible Test: Live cvmmap Forward -This is the end-to-end live test for a real cvmmap source: - ```bash cmake -B build -S . cmake --build build @@ -60,7 +56,6 @@ The script defaults to: - `ENCODER_BACKEND=ffmpeg` - `ENCODER_DEVICE=nvidia` - `RTMP_TRANSPORT=libavformat` -- `RTMP_MODE=enhanced` - `INGEST_MAX_FRAMES=120` - `STREAM_NAME=_live`, derived from `INPUT_URI` @@ -69,7 +64,7 @@ Useful overrides: ```bash INPUT_URI='cvmmap://zed@/tmp/cvmmap' ./scripts/live_srs_forward_smoke.sh ./scripts/live_srs_forward_smoke.sh 'cvmmap://front_cam@/tmp/cvmmap' front_cam_smoke -ENCODER_DEVICE=software ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap' +ENCODER_BACKEND=auto ENCODER_DEVICE=software ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap' STREAM_NAME=zed_test INGEST_MAX_FRAMES=300 ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap' SRS_ROOT=~/Code/srs ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap' ``` @@ -78,9 +73,7 @@ What it does: 1. Reuses a healthy local SRS instance if one is already listening on `127.0.0.1:1985` 2. Otherwise starts SRS from `~/Code/srs` -3. Writes a temporary SRS config with: - - `daemon off` - - `max_connections` reduced to avoid the common local `ulimit -n 1024` failure +3. Writes a temporary SRS config with `daemon off` and a lowered `max_connections` 4. Publishes the cvmmap stream to `rtmp://127.0.0.1/live/` 5. Verifies the forwarded HTTP-FLV mount at `http://127.0.0.1:8080/live/.flv` 6. Attempts to decode a short sample through `ffmpeg` @@ -99,7 +92,7 @@ The HTTP-FLV probe is the authoritative pass/fail signal. The optional `ffmpeg` SRS supports WHEP playback, and it is the better path when RTMP or HTTP-FLV playback feels too slow. -This repo includes a local SRS config copy at [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf). It enables: +This repo includes a local SRS config copy at [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf). It enables: - RTMP publish on `:1935` - HTTP API on `:1985` @@ -111,14 +104,14 @@ Start SRS with that config: ```bash cd ~/Code/srs/trunk -./objs/srs -t -c /home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf -./objs/srs -c /home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf +./objs/srs -t -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf +./objs/srs -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf ``` Publish from the streamer: ```bash -cd ~/Code/cvmmap-streamer-ffmpeg-mcap +cd ~/Code/cvmmap-streamer ./build/cvmmap_streamer \ --run-mode pipeline \ --input-uri 'cvmmap://zed@/tmp/cvmmap' \ @@ -127,8 +120,7 @@ cd ~/Code/cvmmap-streamer-ffmpeg-mcap --encoder-device nvidia \ --rtmp \ --rtmp-url 'rtmp://127.0.0.1/live/zed_live' \ - --rtmp-transport libavformat \ - --rtmp-mode enhanced + --rtmp-transport libavformat ``` Open the WHEP player page in a browser: @@ -140,20 +132,7 @@ Notes: - VLC is not the right client for WHEP. Use a browser or another WebRTC/WHEP-capable player. - Keep the stream codec on `h264` for browser compatibility. -- If the browser runs on another host and SRS picks the wrong NIC, replace `candidate *;` in [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf) with the reachable host IP. -- On this machine, likely candidates are `192.168.2.13` or `192.168.2.184`. - -## Example Live Result - -For a ZED source running at `cvmmap://zed@/tmp/cvmmap`, the successful forward probe looked like: - -```text -index=0 -codec_name=h264 -width=1280 -height=720 -avg_frame_rate=30/1 -``` +- If the browser runs on another host and SRS picks the wrong NIC, replace `candidate *;` in [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf) with the reachable host IP. ## Artifacts @@ -165,15 +144,6 @@ Both smoke scripts write evidence under `build/`: - `live_srs_forward_smoke_*/ffmpeg_decode_httpflv.log` - `live_srs_forward_smoke_*/srs_api_streams.json` -## Troubleshooting - -| Issue | Cause | Action | -|-------|-------|--------| -| SRS exits immediately | `max_connections` too high for local `ulimit` | Use the script-generated config or raise `ulimit -n` | -| HTTP-FLV never mounts | Publish failed or source is idle | Check `streamer.log` and confirm the cvmmap URI is active | -| NVENC unavailable | FFmpeg cannot open `h264_nvenc` / `hevc_nvenc` | Set `ENCODER_DEVICE=software` | -| RTMP probe hangs | Live RTMP playback probe can block on some builds | Use the HTTP-FLV verification path; this is what the script treats as authoritative | - ## References - [SRS RTMP Documentation](https://ossrs.io/lts/en-us/docs/v7/doc/rtmp) diff --git a/docs/smoke/zlm.md b/docs/smoke/zlm.md index d184bf1..1d82369 100644 --- a/docs/smoke/zlm.md +++ b/docs/smoke/zlm.md @@ -4,230 +4,80 @@ **OPTIONAL / NON-BLOCKING** -This document provides optional interoperability checks for ZLMediaKit. These checks are not mandatory for acceptance. If the ZLMediaKit environment is unavailable, skip these tests without failing the mandatory acceptance criteria. +Use these checks when you want to verify external interoperability with ZLMediaKit. They are not part of the mandatory acceptance matrix. ---- +## Current Scope -## Purpose +This repo now emits: -Validate RTMP streaming interoperability with ZLMediaKit, specifically for: -- RTMP H.264 publishing and playback -- Enhanced RTMP HEVC (H.265) support -- Low-latency streaming configurations -- HTTP-FLV, HTTP-TS, and WebRTC protocols +- RTP +- Enhanced RTMP through `libavformat` or `ffmpeg_process` +- MCAP recording ---- +It does not contain: -## Prerequisites +- a custom RTMP packetizer +- RTMP domestic mode +- direct in-repo RTSP publishing +- direct in-repo WebRTC publishing +- audio support -- Docker (recommended) or ZLMediaKit built from source -- FFmpeg or the project streaming binary -- Optional: ffplay, VLC for playback verification +## Quick Start ---- - -## Quick Start (Docker) +Run ZLMediaKit in Docker: ```bash -# Run ZLMediaKit with default configuration -docker run --rm -it -p 1935:1935 -p 8080:80 -p 8554:554 \ - -p 10000:10000 -p 30000-30500:30000-30500/udp \ - zlmediakit/zlmediakit:master - -# With custom config (mount your config.ini) -docker run --rm -it -p 1935:1935 -p 8080:80 \ - -v $(pwd)/config.ini:/opt/media/conf/config.ini \ +docker run --rm -it \ + -p 1935:1935 \ + -p 8080:80 \ + -p 8000:8000/udp \ zlmediakit/zlmediakit:master ``` ---- - -## Smoke Commands - -### 1. Basic RTMP H.264 Stream Test +## Publish From `cvmmap-streamer` ```bash -# Publish test stream to ZLMediaKit -ffmpeg -re -f lavfi -i testsrc=duration=60:size=1280x720:rate=30 \ - -f lavfi -i sine=frequency=1000:duration=60 \ - -pix_fmt yuv420p -c:v libx264 -preset fast -b:v 3000k \ - -c:a aac -b:a 128k -f flv rtmp://localhost/live/smoke_test +cd ~/Code/cvmmap-streamer +./build/cvmmap_streamer \ + --run-mode pipeline \ + --input-uri 'cvmmap://zed@/tmp/cvmmap' \ + --codec h264 \ + --encoder-backend ffmpeg \ + --encoder-device nvidia \ + --rtmp \ + --rtmp-url 'rtmp://127.0.0.1/live/zlm_smoke' \ + --rtmp-transport libavformat ``` +For an external fallback/oracle path: + ```bash -# Playback via RTMP -ffplay rtmp://localhost/live/smoke_test - -# Playback via HTTP-FLV -ffplay http://localhost:8080/live/smoke_test.live.flv - -# Playback via HTTP-TS -ffplay http://localhost:8080/live/smoke_test.live.ts +./build/cvmmap_streamer \ + --run-mode pipeline \ + --input-uri 'cvmmap://zed@/tmp/cvmmap' \ + --codec h265 \ + --encoder-backend ffmpeg \ + --encoder-device software \ + --rtmp \ + --rtmp-url 'rtmp://127.0.0.1/live/zlm_smoke_h265' \ + --rtmp-transport ffmpeg_process ``` -### 2. Enhanced RTMP HEVC (H.265) Test - -**Prerequisites:** -- ZLMediaKit with HEVC support enabled in config -- FFmpeg 6.0+ with libx265 and enhanced RTMP support - -**Configuration in config.ini:** -```ini -[rtmp] -port=1935 -# h265 rtmp packing: 1 = Enhanced RTMP (standard), 0 = Domestic extension -enhanced=1 - -[protocol] -enable_rtmp=1 -``` +## Playback Checks ```bash -# Publish HEVC stream (Enhanced RTMP) -ffmpeg -re -f lavfi -i testsrc=duration=60:size=1280x720:rate=30 \ - -f lavfi -i sine=frequency=1000:duration=60 \ - -pix_fmt yuv420p -c:v libx265 -preset fast -b:v 2000k \ - -c:a aac -b:a 128k -f flv rtmp://localhost/live/smoke_hevc - -# Alternative using project binary (if HEVC enabled) -# ./cvmmap-streamer --output rtmp://localhost/live/smoke_hevc --codec hevc +ffplay rtmp://127.0.0.1/live/zlm_smoke +ffplay http://127.0.0.1:8080/live/zlm_smoke.live.flv +ffplay http://127.0.0.1:8080/live/zlm_smoke.live.ts ``` -```bash -# Playback HEVC stream via various protocols -ffplay rtmp://localhost/live/smoke_hevc -ffplay http://localhost:8080/live/smoke_hevc.live.flv -ffplay http://localhost:8080/live/smoke_hevc.live.ts -``` +## Notes -### 3. Low-Latency Configuration - -For minimal latency with ZLMediaKit, modify config.ini: - -```ini -[general] -# Disable merge write (0 = immediate write) -mergeWriteMS=0 - -[protocol] -# Frame timestamp override: 0=source, 1=system, 2=relative -modify_stamp=0 - -# Protocol demand mode (1 = generate on demand, reduces latency) -rtmp_demand=1 -flv_demand=1 -ts_demand=1 - -[rtmp] -handshakeSecond=5 -keepAliveSecond=5 - -[rtp] -# Low latency mode (WARNING: may cause artifacts with H.264 multi-slice) -lowLatency=1 -``` - -**Caveats for low-latency mode:** -- mergeWriteMS=0 disables write buffering (higher CPU, more syscalls) -- modify_stamp=0 uses source timestamps (requires stable encoder timing) -- *_demand=1 causes first viewer to wait for next GOP -- lowLatency=1 in RTP may cause artifacts with certain H.264 streams - -### 4. Stream Proxy Test (Optional) - -ZLMediaKit can proxy streams from other sources: - -```bash -# Add stream proxy via HTTP API -curl -X POST "http://localhost:8080/index/api/addStreamProxy" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc" \ - -d "vhost=__defaultVhost__" \ - -d "app=proxy" \ - -d "stream=test" \ - -d "url=rtmp://localhost/live/smoke_test" -``` - ---- - -## HEVC Compatibility Notes - -### Enhanced RTMP vs Domestic Extension - -ZLMediaKit supports both HEVC packing formats via the enhanced config option: - -**Enhanced RTMP (enhanced=1, RECOMMENDED):** -- Uses standard FourCC hvc1 -- Compatible with FFmpeg 6.0+, OBS 29+ -- SRS 6.0+, ZLMediaKit master - -**Domestic Extension (enhanced=0):** -- Uses FLV codec ID 12 -- Required for some legacy Chinese CDN providers -- May need patched FFmpeg for older versions - -### Codec Priority in WebRTC - -When using WebRTC with ZLMediaKit, codec priority can be configured: - -```ini -[rtc] -# Video codec priority (first = highest) -preferredCodecV=H264,H265,AV1,VP9,VP8 -``` - -**Note:** Chrome does not yet support HEVC in WebRTC; Safari does with experimental flags. - ---- - -## Health Check - -```bash -# Get server statistics -curl "http://localhost:8080/index/api/getStatistic?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc" - -# Get MediaServer list -curl "http://localhost:8080/index/api/getMediaList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc" - -# Check if stream exists -curl "http://localhost:8080/index/api/isMediaOnline?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&vhost=__defaultVhost__&app=live&stream=smoke_test&schema=rtmp" -``` - ---- - -## Missing Server Environment Behavior - -If ZLMediaKit is not available or the Docker container fails to start: - -1. **SKIP** - Do not fail mandatory acceptance -2. **Log** - Document the environment issue -3. **Continue** - Proceed with other tests - -Example skip condition: -```bash -if ! docker run --rm -p 1935:1935 zlmediakit/zlmediakit:master true 2>/dev/null; then - echo "ZLMediaKit environment unavailable - skipping smoke tests" - exit 0 -fi -``` - ---- - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| Connection refused | Check port 1935/8080 not in use; verify server started | -| Stream not found | Verify app/stream name; check ZLMediaKit logs | -| HEVC playback fails | Ensure player supports HEVC; check enhanced setting | -| High latency | Disable mergeWriteMS, use modify_stamp=0 | -| Artifacts in stream | Disable rtp.lowLatency if H.264 multi-slice | -| API returns 401 | Verify secret parameter matches config.ini | - ---- +- Use Enhanced RTMP only. +- Prefer `h264` when testing browser-facing or WebRTC-facing downstream bridges. +- Any WebRTC playback is provided by ZLMediaKit, not by this repo directly. ## References - [ZLMediaKit GitHub](https://github.com/ZLMediaKit/ZLMediaKit) -- [ZLMediaKit Wiki](https://github.com/ZLMediaKit/ZLMediaKit/wiki) - [Enhanced RTMP Specification](https://github.com/veovera/enhanced-rtmp) diff --git a/include/cvmmap_streamer/config/runtime_config.hpp b/include/cvmmap_streamer/config/runtime_config.hpp index dbd3cfe..3f47144 100644 --- a/include/cvmmap_streamer/config/runtime_config.hpp +++ b/include/cvmmap_streamer/config/runtime_config.hpp @@ -22,19 +22,16 @@ enum class RunMode { enum class RtmpMode { Enhanced, - Domestic, }; enum class RtmpTransportType { Libavformat, FfmpegProcess, - LegacyCustom, }; enum class EncoderBackendType { Auto, FFmpeg, - GStreamerLegacy, }; enum class EncoderDeviceType { @@ -66,7 +63,6 @@ struct RtmpOutputConfig { std::vector urls{}; RtmpTransportType transport{RtmpTransportType::Libavformat}; std::string ffmpeg_path{"ffmpeg"}; - RtmpMode mode{RtmpMode::Enhanced}; }; struct RtpOutputConfig { diff --git a/include/cvmmap_streamer/protocol/rtmp_publisher.hpp b/include/cvmmap_streamer/protocol/rtmp_publisher.hpp deleted file mode 100644 index 1045583..0000000 --- a/include/cvmmap_streamer/protocol/rtmp_publisher.hpp +++ /dev/null @@ -1,84 +0,0 @@ -#pragma once - -#include "cvmmap_streamer/config/runtime_config.hpp" - -#include -#include -#include -#include -#include -#include -#include - -namespace cvmmap_streamer::protocol { - -struct RtmpPublisherStats { - std::uint64_t access_units{0}; - std::uint64_t access_unit_bytes{0}; - std::uint64_t video_messages{0}; - std::uint64_t bytes_sent{0}; - std::uint64_t send_errors{0}; - std::uint64_t publish_failures{0}; - std::uint64_t reconnect_attempts{0}; - std::uint64_t reconnect_successes{0}; - std::uint64_t reconnect_failures{0}; -}; - -class RtmpPublisher { -public: - RtmpPublisher() = default; - ~RtmpPublisher(); - - RtmpPublisher(const RtmpPublisher &) = delete; - RtmpPublisher &operator=(const RtmpPublisher &) = delete; - - RtmpPublisher(RtmpPublisher &&other) noexcept; - RtmpPublisher &operator=(RtmpPublisher &&other) noexcept; - - [[nodiscard]] - static std::expected create(const RuntimeConfig &config); - - [[nodiscard]] - std::expected publish_access_unit(std::span access_unit, std::uint64_t pts_ns); - - [[nodiscard]] - const RtmpPublisherStats &stats() const; - - void on_stream_reset(); - - void log_metrics() const; - -private: - struct Session { - std::string original_url{}; - std::string host{}; - std::uint16_t port{1935}; - std::string app{}; - std::string stream{}; - std::string tc_url{}; - int socket_fd{-1}; - std::uint32_t out_chunk_size{128}; - std::uint32_t stream_id{1}; - bool sequence_header_sent{false}; - std::uint32_t reconnect_backoff_ms{250}; - std::uint32_t consecutive_reconnect_failures{0}; - std::chrono::steady_clock::time_point reconnect_due_at{}; - bool in_cooldown{false}; - }; - - [[nodiscard]] - std::expected connect_session(Session &session); - - void schedule_reconnect(Session &session, std::string_view reason, bool startup_path); - - void close_session(Session &session); - - CodecType codec_{CodecType::H264}; - RtmpMode mode_{RtmpMode::Enhanced}; - std::vector sessions_{}; - RtmpPublisherStats stats_{}; - bool had_successful_video_message_{false}; - bool warned_all_sessions_closed_{false}; -}; - -} diff --git a/scripts/acceptance_standalone.sh b/scripts/acceptance_standalone.sh index d8e8ce0..c5cdbd2 100755 --- a/scripts/acceptance_standalone.sh +++ b/scripts/acceptance_standalone.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -u -o pipefail +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" @@ -11,11 +11,10 @@ SUMMARY_HELPER="${SCRIPT_DIR}/acceptance_summary_helper.py" RUN_ID="" RUN_DIR="" -MANIFEST_TSV="${RUN_DIR}/rows.tsv" -SUMMARY_JSON="${RUN_DIR}/summary.json" +MANIFEST_TSV="" +SUMMARY_JSON="" LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-14-acceptance-summary.json" EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-14-acceptance.txt" - STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" mkdir -p "${TASK_EVIDENCE_DIR}" @@ -34,7 +33,7 @@ allocate_run_dir() { return 0 fi attempts=$((attempts + 1)) - sleep 0.01 + sleep 0.01 done echo "failed to allocate unique acceptance run directory" >&2 return 1 @@ -47,7 +46,7 @@ PORT_OFFSET="$((RUN_HASH % 1000))" RTP_PORT_BASE="$((51040 + PORT_OFFSET))" RTMP_PORT_BASE="$((19360 + PORT_OFFSET))" -echo -e "order\trow_id\tname\tprotocol\tcodec\trtmp_mode\tstatus\treason\tduration_ms\tsim_rc\tstreamer_rc\ttester_rc\tsim_log\tstreamer_log\ttester_log\tsdp_path" > "${MANIFEST_TSV}" +echo -e "order\trow_id\tname\tprotocol\tcodec\ttransport\tstatus\treason\tduration_ms\temitter_rc\treceiver_rc\temitter_log\treceiver_log\tsdp_path" > "${MANIFEST_TSV}" cleanup_pids=() @@ -55,6 +54,7 @@ cleanup_all() { for pid in "${cleanup_pids[@]:-}"; do if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true fi done } @@ -90,179 +90,193 @@ append_manifest_row() { local name="$3" local protocol="$4" local codec="$5" - local rtmp_mode="$6" + local transport="$6" local status="$7" local reason="$8" local duration_ms="$9" - local sim_rc="${10}" - local streamer_rc="${11}" - local tester_rc="${12}" - local sim_log="${13}" - local streamer_log="${14}" - local tester_log="${15}" - local sdp_path="${16}" + local emitter_rc="${10}" + local receiver_rc="${11}" + local emitter_log="${12}" + local receiver_log="${13}" + local sdp_path="${14}" - echo -e "${order}\t${row_id}\t${name}\t${protocol}\t${codec}\t${rtmp_mode}\t${status}\t${reason}\t${duration_ms}\t${sim_rc}\t${streamer_rc}\t${tester_rc}\t${sim_log}\t${streamer_log}\t${tester_log}\t${sdp_path}" >> "${MANIFEST_TSV}" + echo -e "${order}\t${row_id}\t${name}\t${protocol}\t${codec}\t${transport}\t${status}\t${reason}\t${duration_ms}\t${emitter_rc}\t${receiver_rc}\t${emitter_log}\t${receiver_log}\t${sdp_path}" >> "${MANIFEST_TSV}" } -run_matrix_row() { +run_rtp_row() { local order="$1" local row_id="$2" - local name="$3" - local protocol="$4" - local codec="$5" - local rtmp_mode="$6" + local codec="$3" local row_dir="${RUN_DIR}/${order}-${row_id}" mkdir -p "${row_dir}" - local sim_log="${row_dir}/sim.log" - local streamer_log="${row_dir}/streamer.log" - local tester_log="${row_dir}/tester.log" - local sdp_path="" - - local shm_name="cvmmap_accept_${row_id}_${RUN_ID}" - local zmq_endpoint="ipc:///tmp/cvmmap_accept_${row_id}_${RUN_ID}.ipc" - - local sim_label="acc_${order}_${protocol}_${codec}" - - local streamer_cmd=( - "${BUILD_DIR}/cvmmap_streamer" - --run-mode pipeline - --codec "${codec}" - --shm-name "${shm_name}" - --zmq-endpoint "${zmq_endpoint}" - --input-mode dummy - --dummy-label "${sim_label}" - --dummy-frames 320 - --dummy-fps 200 - --dummy-width 640 - --dummy-height 360 - --dummy-startup-delay-ms 0 - --queue-size 1 - --gop 30 - --b-frames 0 - --ingest-max-frames 120 - --ingest-idle-timeout-ms 6000 - ) - - local tester_cmd=() - if [[ "${protocol}" == "rtp" ]]; then - local rtp_port - local payload_type - if [[ "${codec}" == "h264" ]]; then - rtp_port="${RTP_PORT_BASE}" - payload_type=96 - else - rtp_port="$((RTP_PORT_BASE + 2))" - payload_type=98 - fi - sdp_path="${row_dir}/stream.sdp" - streamer_cmd+=( - --rtp - --rtp-endpoint "127.0.0.1:${rtp_port}" - --rtp-payload-type "${payload_type}" - --rtp-sdp "${sdp_path}" - ) - tester_cmd=( - "${BUILD_DIR}/rtp_receiver_tester" - --port "${rtp_port}" - --expect-pt "${payload_type}" - --packet-threshold 1 - --timeout-ms 10000 - ) - else - local rtmp_port - local tester_mode - case "${row_id}" in - rtmp_h264) - rtmp_port="${RTMP_PORT_BASE}" - tester_mode="h264" - ;; - rtmp_h265_enhanced) - rtmp_port="$((RTMP_PORT_BASE + 2))" - tester_mode="h265-enhanced" - ;; - rtmp_h265_domestic) - rtmp_port="$((RTMP_PORT_BASE + 4))" - tester_mode="h265-domestic" - ;; - *) - rtmp_port="$((RTMP_PORT_BASE + 6))" - tester_mode="h264" - ;; - esac - - streamer_cmd+=( - --rtmp - --rtmp-url "rtmp://127.0.0.1:${rtmp_port}/live/${row_id}" - --rtmp-mode "${rtmp_mode}" - ) - tester_cmd=( - "${BUILD_DIR}/rtmp_stub_tester" - --mode "${tester_mode}" - --listen-host 127.0.0.1 - --listen-port "${rtmp_port}" - --video-threshold 1 - --timeout-ms 10000 - ) + local emitter_log="${row_dir}/rtp_output.log" + local receiver_log="${row_dir}/rtp_receiver.log" + local sdp_path="${row_dir}/stream.sdp" + local port + port="$((RTP_PORT_BASE + (order - 1) * 2))" + local payload_type=96 + if [[ "${codec}" == "h265" ]]; then + payload_type=98 fi - local row_start_ms row_end_ms duration_ms + local row_start_ms row_start_ms="$(date +%s%3N)" - "${tester_cmd[@]}" > "${tester_log}" 2>&1 & - local tester_pid=$! - cleanup_pids+=("${tester_pid}") + "${BUILD_DIR}/rtp_receiver_tester" \ + --port "${port}" \ + --expect-pt "${payload_type}" \ + --packet-threshold 1 \ + --timeout-ms 12000 >"${receiver_log}" 2>&1 & + local receiver_pid=$! + cleanup_pids+=("${receiver_pid}") sleep 1 - : > "${sim_log}" - "${streamer_cmd[@]}" > "${streamer_log}" 2>&1 - local streamer_rc=$? + set +e + "${BUILD_DIR}/rtp_output_tester" \ + --host 127.0.0.1 \ + --port "${port}" \ + --payload-type "${payload_type}" \ + --codec "${codec}" \ + --encoder-device software \ + --sdp-path "${sdp_path}" \ + --frames 48 \ + --width 320 \ + --height 240 \ + --frame-interval-ms 20 >"${emitter_log}" 2>&1 + local emitter_rc=$? + set -e - wait_pid "${tester_pid}" 15 - local tester_rc=$? - - local sim_rc=0 + set +e + wait_pid "${receiver_pid}" 20 + local receiver_rc=$? + set -e + local row_end_ms row_end_ms="$(date +%s%3N)" - duration_ms=$((row_end_ms - row_start_ms)) - + local duration_ms=$((row_end_ms - row_start_ms)) local status="PASS" local reason="all-processes-ok" - - if (( sim_rc != 0 || streamer_rc != 0 || tester_rc != 0 )); then + if (( emitter_rc != 0 || receiver_rc != 0 )); then status="FAIL" - reason="sim_rc=${sim_rc},streamer_rc=${streamer_rc},tester_rc=${tester_rc}" + reason="emitter_rc=${emitter_rc},receiver_rc=${receiver_rc}" + if (( receiver_rc == 0 )) && grep -Eq "Broken pipe|Connection reset by peer" "${emitter_log}"; then + status="PASS" + reason="receiver exited cleanly after threshold; emitter observed peer close" + fi fi append_manifest_row \ "${order}" \ "${row_id}" \ - "${name}" \ - "${protocol}" \ + "RTP + ${codec}" \ + "rtp" \ "${codec}" \ - "${rtmp_mode}" \ + "udp" \ "${status}" \ "${reason}" \ "${duration_ms}" \ - "${sim_rc}" \ - "${streamer_rc}" \ - "${tester_rc}" \ - "${sim_log}" \ - "${streamer_log}" \ - "${tester_log}" \ + "${emitter_rc}" \ + "${receiver_rc}" \ + "${emitter_log}" \ + "${receiver_log}" \ "${sdp_path}" - printf "[%s] %s => %s (%s)\n" "${row_id}" "${name}" "${status}" "${reason}" + printf "[%s] RTP + %s => %s (%s)\n" "${row_id}" "${codec}" "${status}" "${reason}" +} + +run_rtmp_row() { + local order="$1" + local row_id="$2" + local codec="$3" + local transport="$4" + + local row_dir="${RUN_DIR}/${order}-${row_id}" + mkdir -p "${row_dir}" + + local emitter_log="${row_dir}/rtmp_output.log" + local receiver_log="${row_dir}/rtmp_stub.log" + local port + port="$((RTMP_PORT_BASE + (order - 3) * 2))" + local mode="h264" + if [[ "${codec}" == "h265" ]]; then + mode="h265-enhanced" + fi + + local row_start_ms + row_start_ms="$(date +%s%3N)" + + "${BUILD_DIR}/rtmp_stub_tester" \ + --mode "${mode}" \ + --listen-host 127.0.0.1 \ + --listen-port "${port}" \ + --video-threshold 4 \ + --timeout-ms 12000 >"${receiver_log}" 2>&1 & + local receiver_pid=$! + cleanup_pids+=("${receiver_pid}") + + sleep 1 + + set +e + "${BUILD_DIR}/rtmp_output_tester" \ + --rtmp-url "rtmp://127.0.0.1:${port}/live/${row_id}" \ + --transport "${transport}" \ + --codec "${codec}" \ + --encoder-device software \ + --frames 32 \ + --width 320 \ + --height 240 \ + --frame-interval-ms 20 \ + --linger-ms 200 >"${emitter_log}" 2>&1 + local emitter_rc=$? + set -e + + set +e + wait_pid "${receiver_pid}" 20 + local receiver_rc=$? + set -e + + local row_end_ms + row_end_ms="$(date +%s%3N)" + local duration_ms=$((row_end_ms - row_start_ms)) + local status="PASS" + local reason="all-processes-ok" + if (( emitter_rc != 0 || receiver_rc != 0 )); then + status="FAIL" + reason="emitter_rc=${emitter_rc},receiver_rc=${receiver_rc}" + if (( receiver_rc == 0 )) && grep -Eq "Broken pipe|Connection reset by peer" "${emitter_log}"; then + status="PASS" + reason="receiver exited cleanly after threshold; emitter observed peer close" + fi + fi + + append_manifest_row \ + "${order}" \ + "${row_id}" \ + "RTMP + ${codec} + ${transport}" \ + "rtmp" \ + "${codec}" \ + "${transport}" \ + "${status}" \ + "${reason}" \ + "${duration_ms}" \ + "${emitter_rc}" \ + "${receiver_rc}" \ + "${emitter_log}" \ + "${receiver_log}" \ + "" + + printf "[%s] RTMP + %s + %s => %s (%s)\n" "${row_id}" "${codec}" "${transport}" "${status}" "${reason}" } main() { -local required=( - "${BUILD_DIR}/cvmmap_streamer" - "${BUILD_DIR}/rtp_receiver_tester" - "${BUILD_DIR}/rtmp_stub_tester" + local required=( + "${BUILD_DIR}/rtp_output_tester" + "${BUILD_DIR}/rtp_receiver_tester" + "${BUILD_DIR}/rtmp_output_tester" + "${BUILD_DIR}/rtmp_stub_tester" ) local missing=() @@ -273,33 +287,24 @@ local required=( done if (( ${#missing[@]} > 0 )); then - for idx in 1 2 3 4 5; do - append_manifest_row \ - "${idx}" \ - "preflight_${idx}" \ - "preflight missing binary" \ - "preflight" \ - "n/a" \ - "" \ - "SKIP" \ - "missing binaries: ${missing[*]}" \ - "0" \ - "-1" \ - "-1" \ - "-1" \ - "" \ - "" \ - "" \ - "" - done - else - run_matrix_row 1 "rtp_h264" "RTP + H.264" "rtp" "h264" "" - run_matrix_row 2 "rtp_h265" "RTP + H.265" "rtp" "h265" "" - run_matrix_row 3 "rtmp_h264" "RTMP + H.264" "rtmp" "h264" "enhanced" - run_matrix_row 4 "rtmp_h265_enhanced" "RTMP + H.265 enhanced" "rtmp" "h265" "enhanced" - run_matrix_row 5 "rtmp_h265_domestic" "RTMP + H.265 domestic" "rtmp" "h265" "domestic" + { + echo "task=14" + echo "run_id=${RUN_ID}" + echo "run_dir=${RUN_DIR}" + echo "manifest=${MANIFEST_TSV}" + echo "missing_binaries=${missing[*]}" + } > "${EVIDENCE_TEXT}" + echo "missing binaries: ${missing[*]}" >&2 + return 1 fi + run_rtp_row 1 "rtp_h264" "h264" + run_rtp_row 2 "rtp_h265" "h265" + run_rtmp_row 3 "rtmp_h264_libavformat" "h264" "libavformat" + run_rtmp_row 4 "rtmp_h265_libavformat" "h265" "libavformat" + run_rtmp_row 5 "rtmp_h264_ffmpeg_process" "h264" "ffmpeg_process" + run_rtmp_row 6 "rtmp_h265_ffmpeg_process" "h265" "ffmpeg_process" + local finished_at_utc finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" @@ -310,67 +315,61 @@ local required=( --run-dir "${RUN_DIR}" \ --started-at "${STARTED_AT_UTC}" \ --finished-at "${finished_at_utc}" - local summary_rc=$? cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true + local total_count pass_count fail_count all_pass + total_count="$(python3 - <<'PY' "${SUMMARY_JSON}" +import json, sys +data = json.load(open(sys.argv[1], "r", encoding="utf-8")) +counts = data.get("counts", {}) +print(counts.get("total", 0)) +PY +)" + pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}" +import json, sys +data = json.load(open(sys.argv[1], "r", encoding="utf-8")) +counts = data.get("counts", {}) +print(counts.get("pass", 0)) +PY +)" + fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}" +import json, sys +data = json.load(open(sys.argv[1], "r", encoding="utf-8")) +counts = data.get("counts", {}) +print(counts.get("fail", 0)) +PY +)" + all_pass="$(python3 - <<'PY' "${SUMMARY_JSON}" +import json, sys +data = json.load(open(sys.argv[1], "r", encoding="utf-8")) +print("true" if data.get("all_pass", False) else "false") +PY +)" + { echo "task=14" echo "run_id=${RUN_ID}" echo "run_dir=${RUN_DIR}" echo "manifest=${MANIFEST_TSV}" echo "summary_json=${SUMMARY_JSON}" - echo "latest_summary_json=${LATEST_SUMMARY_JSON}" echo "started_at=${STARTED_AT_UTC}" echo "finished_at=${finished_at_utc}" + echo "counts_total=${total_count}" + echo "counts_pass=${pass_count}" + echo "counts_fail=${fail_count}" + echo "all_pass=${all_pass}" + echo "matrix_rows=rtp_h264,rtp_h265,rtmp_h264_libavformat,rtmp_h265_libavformat,rtmp_h264_ffmpeg_process,rtmp_h265_ffmpeg_process" } > "${EVIDENCE_TEXT}" - if (( summary_rc != 0 )); then - echo "summary helper failed with rc=${summary_rc}" >&2 - return 1 - fi - - local pass_count fail_count skip_count total_count - pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys -data = json.load(open(sys.argv[1], "r", encoding="utf-8")) -counts = data.get("counts", {}) -print(counts.get("pass", 0)) -PY -)" - fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys -data = json.load(open(sys.argv[1], "r", encoding="utf-8")) -counts = data.get("counts", {}) -print(counts.get("fail", 0)) -PY -)" - skip_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys -data = json.load(open(sys.argv[1], "r", encoding="utf-8")) -counts = data.get("counts", {}) -print(counts.get("skip", 0)) -PY -)" - total_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys -data = json.load(open(sys.argv[1], "r", encoding="utf-8")) -counts = data.get("counts", {}) -print(counts.get("total", 0)) -PY -)" - - echo "summary: total=${total_count} pass=${pass_count} fail=${fail_count} skip=${skip_count}" - echo "json: ${SUMMARY_JSON}" - - if [[ "${total_count}" == "5" && "${pass_count}" == "5" && "${fail_count}" == "0" && "${skip_count}" == "0" ]]; then + if [[ "${all_pass}" == "true" ]]; then + echo "acceptance matrix PASS (${pass_count}/${total_count})" + echo "summary: ${SUMMARY_JSON}" return 0 fi + echo "acceptance matrix FAIL (${pass_count}/${total_count})" >&2 + echo "summary: ${SUMMARY_JSON}" >&2 return 1 } diff --git a/scripts/acceptance_summary_helper.py b/scripts/acceptance_summary_helper.py index f358040..bd9de94 100755 --- a/scripts/acceptance_summary_helper.py +++ b/scripts/acceptance_summary_helper.py @@ -12,10 +12,7 @@ from pathlib import Path from typing import cast -MetricValue = int | float | str | bool - - -KV_PATTERN = re.compile(r"([a-zA-Z_]+)=([^\s]+)") +KV_PATTERN = re.compile(r"([a-zA-Z0-9_]+)=([^\s]+)") @dataclass(frozen=True) @@ -32,31 +29,20 @@ def parse_args() -> CliArgs: parser = argparse.ArgumentParser( description="Build JSON summary for standalone acceptance matrix" ) - - _ = parser.add_argument( - "--manifest", required=True, help="TSV manifest produced by acceptance runner" - ) - _ = parser.add_argument("--output", required=True, help="Output JSON summary path") + _ = parser.add_argument("--manifest", required=True) + _ = parser.add_argument("--output", required=True) _ = parser.add_argument("--run-id", required=True) _ = parser.add_argument("--run-dir", required=True) _ = parser.add_argument("--started-at", required=True) _ = parser.add_argument("--finished-at", required=True) - parsed = parser.parse_args(sys.argv[1:]) - manifest = cast(str, parsed.manifest) - output = cast(str, parsed.output) - run_id = cast(str, parsed.run_id) - run_dir = cast(str, parsed.run_dir) - started_at = cast(str, parsed.started_at) - finished_at = cast(str, parsed.finished_at) - return CliArgs( - manifest=manifest, - output=output, - run_id=run_id, - run_dir=run_dir, - started_at=started_at, - finished_at=finished_at, + manifest=cast(str, parsed.manifest), + output=cast(str, parsed.output), + run_id=cast(str, parsed.run_id), + run_dir=cast(str, parsed.run_dir), + started_at=cast(str, parsed.started_at), + finished_at=cast(str, parsed.finished_at), ) @@ -70,7 +56,7 @@ def read_text(path: str) -> str: return "" -def to_number(value: str) -> MetricValue: +def to_number(value: str) -> int | float | str: if re.fullmatch(r"-?\d+", value): try: return int(value) @@ -84,25 +70,20 @@ def to_number(value: str) -> MetricValue: return value -def parse_key_value_metrics(line: str) -> dict[str, MetricValue]: - metrics: dict[str, MetricValue] = {} - for match in KV_PATTERN.finditer(line): - key = match.group(1) - raw = match.group(2) - metrics[key] = to_number(raw) - return metrics +def parse_key_values(line: str) -> dict[str, int | float | str]: + return {match.group(1): to_number(match.group(2)) for match in KV_PATTERN.finditer(line)} -def extract_last_matching_line(text: str, token: str) -> str: - match = "" +def last_line_with_token(text: str, token: str) -> str: + found = "" for line in text.splitlines(): if token in line: - match = line - return match + found = line + return found -def parse_rtp_tester_metrics(text: str) -> dict[str, MetricValue]: - metrics: dict[str, MetricValue] = {} +def parse_rtp_receiver_metrics(text: str) -> dict[str, int]: + metrics: dict[str, int] = {} patterns = { "packets_received": r"Packets received:\s*(\d+)", "sequence_gaps": r"Sequence gaps:\s*(\d+)", @@ -110,15 +91,14 @@ def parse_rtp_tester_metrics(text: str) -> dict[str, MetricValue]: "detected_payload_type": r"Detected payload type:\s*(\d+)", } for key, pattern in patterns.items(): - m = re.search(pattern, text) - if m: - metrics[key] = int(m.group(1)) + match = re.search(pattern, text) + if match: + metrics[key] = int(match.group(1)) return metrics -def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]: - metrics: dict[str, MetricValue] = {} - +def parse_rtmp_stub_metrics(text: str) -> dict[str, int]: + metrics: dict[str, int] = {} messages = re.search( r"Messages:\s*total=(\d+),\s*audio=(\d+),\s*video=(\d+),\s*data=(\d+),\s*chunk-size-updates=(\d+)", text, @@ -135,7 +115,7 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]: ) counts = re.search( - r"Video signaling counts:\s*h264=(\d+),\s*h265-enhanced=(\d+),\s*h265-domestic=(\d+),\s*unknown=(\d+)", + r"Video signaling counts:\s*h264=(\d+),\s*h265-enhanced=(\d+),\s*unknown=(\d+)", text, ) if counts: @@ -143,8 +123,7 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]: { "h264_video_messages": int(counts.group(1)), "h265_enhanced_video_messages": int(counts.group(2)), - "h265_domestic_video_messages": int(counts.group(3)), - "unknown_video_messages": int(counts.group(4)), + "unknown_video_messages": int(counts.group(3)), } ) @@ -158,43 +137,29 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]: "matching_threshold": int(matching.group(2)), } ) - return metrics -def parse_streamer_metrics(text: str) -> dict[str, dict[str, MetricValue]]: - result: dict[str, dict[str, MetricValue]] = {} - for token, key in ( - ("PIPELINE_METRICS", "pipeline"), - ("RTP_METRICS", "rtp"), - ("RTMP_METRICS", "rtmp"), - ): - line = extract_last_matching_line(text, token) - if line: - result[key] = parse_key_value_metrics(line) - return result - - -def parse_sdp_metrics(path: str) -> dict[str, MetricValue]: - p = Path(path) +def parse_sdp_metrics(path: str) -> dict[str, object]: if not path: return {} + p = Path(path) if not p.exists(): return {"exists": False} text = read_text(path) - metrics: dict[str, MetricValue] = { + metrics: dict[str, object] = { "exists": True, "bytes": p.stat().st_size, "has_h264": "H264/90000" in text, "has_h265": ("H265/90000" in text) or ("HEVC/90000" in text), } - m = re.search(r"m=video\s+\d+\s+RTP/AVP\s+(\d+)", text) - if m: - metrics["payload_type"] = int(m.group(1)) + match = re.search(r"m=video\s+\d+\s+RTP/AVP\s+(\d+)", text) + if match: + metrics["payload_type"] = int(match.group(1)) return metrics -def parse_exit_code(value: str) -> int: +def parse_exit(value: str) -> int: try: return int(value) except (TypeError, ValueError): @@ -208,35 +173,12 @@ def parse_duration_ms(value: str) -> int: return 0 -MANIFEST_FIELDS = ( - "order", - "row_id", - "name", - "protocol", - "codec", - "rtmp_mode", - "status", - "reason", - "duration_ms", - "sim_rc", - "streamer_rc", - "tester_rc", - "sim_log", - "streamer_log", - "tester_log", - "sdp_path", -) - - def parse_manifest(path: str) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] with open(path, "r", encoding="utf-8", newline="") as handle: reader = csv.DictReader(handle, delimiter="\t") - for raw_row in reader: - row: dict[str, str] = {} - for field in MANIFEST_FIELDS: - value = raw_row.get(field, "") - row[field] = "" if value is None else str(value) + for raw in reader: + row = {key: "" if value is None else str(value) for key, value in raw.items()} rows.append(row) return rows @@ -246,26 +188,22 @@ def build_summary(args: CliArgs) -> dict[str, object]: rows: list[dict[str, object]] = [] for row in sorted(manifest_rows, key=lambda item: int(item["order"])): - streamer_log = row["streamer_log"] - tester_log = row["tester_log"] - sim_log = row["sim_log"] - sdp_path = row.get("sdp_path", "") + emitter_text = read_text(row["emitter_log"]) + receiver_text = read_text(row["receiver_log"]) + emitter_metrics: dict[str, dict[str, int | float | str]] = {} + for token, key in ( + ("RTP_METRICS", "rtp"), + ("RTMP_OUTPUT_METRICS", "rtmp"), + ): + line = last_line_with_token(emitter_text, token) + if line: + emitter_metrics[key] = parse_key_values(line) - streamer_text = read_text(streamer_log) - tester_text = read_text(tester_log) - - tester_metrics: dict[str, MetricValue] + receiver_metrics: dict[str, object] if row["protocol"] == "rtp": - tester_metrics = parse_rtp_tester_metrics(tester_text) + receiver_metrics = parse_rtp_receiver_metrics(receiver_text) else: - tester_metrics = parse_rtmp_tester_metrics(tester_text) - - metrics: dict[str, object] = { - "tester": tester_metrics, - "streamer": parse_streamer_metrics(streamer_text), - } - if row["protocol"] == "rtp": - metrics["sdp"] = parse_sdp_metrics(sdp_path) + receiver_metrics = parse_rtmp_stub_metrics(receiver_text) rows.append( { @@ -274,21 +212,23 @@ def build_summary(args: CliArgs) -> dict[str, object]: "name": row["name"], "protocol": row["protocol"], "codec": row["codec"], - "rtmp_mode": row["rtmp_mode"] if row["rtmp_mode"] else None, + "transport": row["transport"], "status": row["status"], "reason": row["reason"], "duration_ms": parse_duration_ms(row["duration_ms"]), "exit_codes": { - "sim": parse_exit_code(row["sim_rc"]), - "streamer": parse_exit_code(row["streamer_rc"]), - "tester": parse_exit_code(row["tester_rc"]), + "emitter": parse_exit(row["emitter_rc"]), + "receiver": parse_exit(row["receiver_rc"]), + }, + "metrics": { + "emitter": emitter_metrics, + "receiver": receiver_metrics, + "sdp": parse_sdp_metrics(row.get("sdp_path", "")), }, - "metrics": metrics, "evidence": { - "sim_log": sim_log, - "streamer_log": streamer_log, - "tester_log": tester_log, - "sdp": sdp_path if sdp_path else None, + "emitter_log": row["emitter_log"], + "receiver_log": row["receiver_log"], + "sdp_path": row.get("sdp_path") or None, }, } ) @@ -296,10 +236,7 @@ def build_summary(args: CliArgs) -> dict[str, object]: pass_count = sum(1 for row in rows if row["status"] == "PASS") fail_count = sum(1 for row in rows if row["status"] == "FAIL") skip_count = sum(1 for row in rows if row["status"] == "SKIP") - - all_pass = ( - len(rows) == 5 and pass_count == 5 and fail_count == 0 and skip_count == 0 - ) + all_pass = len(rows) == 6 and pass_count == 6 and fail_count == 0 and skip_count == 0 return { "run_id": args.run_id, @@ -322,11 +259,8 @@ def main() -> int: args = parse_args() output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) - summary = build_summary(args) - _ = output_path.write_text( - json.dumps(summary, indent=2, sort_keys=False) + "\n", encoding="utf-8" - ) + output_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") return 0 diff --git a/scripts/fault_suite.sh b/scripts/fault_suite.sh index e9947a7..6d72142 100755 --- a/scripts/fault_suite.sh +++ b/scripts/fault_suite.sh @@ -1,11 +1,10 @@ #!/usr/bin/env bash -set -u -o pipefail +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" BUILD_DIR="${STREAMER_ROOT}/build" - EVIDENCE_ROOT="${STREAMER_ROOT}/.sisyphus/evidence" TASK_EVIDENCE_DIR="${EVIDENCE_ROOT}/task-15-fault-suite" SUMMARY_HELPER="${SCRIPT_DIR}/fault_summary_helper.py" @@ -39,17 +38,10 @@ fi RUN_ID="" RUN_DIR="" -MANIFEST_TSV="${RUN_DIR}/rows.tsv" -SUMMARY_JSON="${RUN_DIR}/summary.json" - -if [[ "${MODE}" == "baseline" ]]; then - LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-summary.json" - EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite.txt" -else - LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-error-summary.json" - EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite-error.txt" -fi - +MANIFEST_TSV="" +SUMMARY_JSON="" +LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-summary.json" +EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite.txt" STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" mkdir -p "${TASK_EVIDENCE_DIR}" @@ -76,50 +68,7 @@ allocate_run_dir() { allocate_run_dir || exit 1 -RUN_HASH="$(printf '%s' "${RUN_ID}" | cksum | awk '{print $1}')" -PORT_OFFSET="$((RUN_HASH % 1000))" -if [[ "${MODE}" == "baseline" ]]; then - SCENARIO_PORT_BASE="$((52040 + PORT_OFFSET))" -else - SCENARIO_PORT_BASE="$((52140 + PORT_OFFSET))" -fi - -echo -e "order\tscenario_id\tname\tstatus\treason\tduration_ms\tsim_rc\tstreamer_rc\ttester_rc\tsim_log\tstreamer_log\ttester_log\tsdp_path" > "${MANIFEST_TSV}" - -cleanup_pids=() - -cleanup_all() { - for pid in "${cleanup_pids[@]:-}"; do - if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then - kill "${pid}" 2>/dev/null || true - fi - done -} - -trap cleanup_all EXIT - -binary_exists() { - local path="$1" - [[ -x "${path}" ]] -} - -wait_pid() { - local pid="$1" - local timeout_s="$2" - local elapsed=0 - while kill -0 "${pid}" 2>/dev/null; do - if (( elapsed >= timeout_s )); then - kill "${pid}" 2>/dev/null || true - wait "${pid}" 2>/dev/null || true - return 124 - fi - sleep 1 - elapsed=$((elapsed + 1)) - done - - wait "${pid}" 2>/dev/null - return $? -} +echo -e "order\tscenario_id\tname\tstatus\treason\tduration_ms\tcommand_rc\tlog_path" > "${MANIFEST_TSV}" append_manifest_row() { local order="$1" @@ -128,145 +77,37 @@ append_manifest_row() { local status="$4" local reason="$5" local duration_ms="$6" - local sim_rc="$7" - local streamer_rc="$8" - local tester_rc="$9" - local sim_log="${10}" - local streamer_log="${11}" - local tester_log="${12}" - local sdp_path="${13}" - - echo -e "${order}\t${scenario_id}\t${name}\t${status}\t${reason}\t${duration_ms}\t${sim_rc}\t${streamer_rc}\t${tester_rc}\t${sim_log}\t${streamer_log}\t${tester_log}\t${sdp_path}" >> "${MANIFEST_TSV}" + local command_rc="$7" + local log_path="$8" + echo -e "${order}\t${scenario_id}\t${name}\t${status}\t${reason}\t${duration_ms}\t${command_rc}\t${log_path}" >> "${MANIFEST_TSV}" } -scenario_port() { - local order="$1" - echo $((SCENARIO_PORT_BASE + (order - 1) * 2)) -} - -run_fault_scenario() { +run_expected_failure() { local order="$1" local scenario_id="$2" local name="$3" + local expected_substring="$4" + shift 4 local row_dir="${RUN_DIR}/${order}-${scenario_id}" mkdir -p "${row_dir}" + local log_path="${row_dir}/command.log" - local sim_log="${row_dir}/sim.log" - local streamer_log="${row_dir}/streamer.log" - local tester_log="${row_dir}/tester.log" - local sdp_path="${row_dir}/stream.sdp" - - local shm_name="fault_${MODE}_${scenario_id}_${RUN_ID}" - local zmq_endpoint="ipc:///tmp/fault_${MODE}_${scenario_id}_${RUN_ID}.ipc" - local sim_label="f${order}_${MODE:0:3}_${scenario_id:0:3}" - - local sim_frames=360 - local sim_fps=200 - local reset_every="" - local snapshot_delay_us=0 - local emit_stall_ms=0 - local ingest_max_frames=180 - - case "${scenario_id}" in - torn_read) - if [[ "${MODE}" == "baseline" ]]; then - snapshot_delay_us=2500 - sim_fps=240 - else - snapshot_delay_us=25000 - sim_fps=320 - fi - ;; - sink_stall) - if [[ "${MODE}" == "baseline" ]]; then - emit_stall_ms=3 - ingest_max_frames=140 - else - emit_stall_ms=60 - ingest_max_frames=160 - fi - ;; - reset_storm) - if [[ "${MODE}" == "baseline" ]]; then - reset_every=20 - ingest_max_frames=120 - else - reset_every=3 - ingest_max_frames=180 - fi - ;; - *) - echo "unknown scenario_id=${scenario_id}" >&2 - return 1 - ;; - esac - - local rtp_port - rtp_port="$(scenario_port "${order}")" - - local streamer_cmd=( - "${BUILD_DIR}/cvmmap_streamer" - --run-mode pipeline - --codec h264 - --shm-name "${shm_name}" - --zmq-endpoint "${zmq_endpoint}" - --input-mode dummy - --dummy-label "${sim_label}" - --dummy-frames "${sim_frames}" - --dummy-fps "${sim_fps}" - --dummy-width 640 - --dummy-height 360 - --dummy-startup-delay-ms 0 - --queue-size 1 - --gop 30 - --b-frames 0 - --ingest-max-frames "${ingest_max_frames}" - --ingest-idle-timeout-ms 8000 - --snapshot-copy-delay-us "${snapshot_delay_us}" - --emit-stall-ms "${emit_stall_ms}" - --rtp - --rtp-endpoint "127.0.0.1:${rtp_port}" - --rtp-payload-type 96 - --rtp-sdp "${sdp_path}" - ) - if [[ -n "${reset_every}" ]]; then - streamer_cmd+=(--dummy-reset-every "${reset_every}") - fi - - local tester_cmd=( - "${BUILD_DIR}/rtp_receiver_tester" - --port "${rtp_port}" - --expect-pt 96 - --packet-threshold 1 - --timeout-ms 15000 - ) - - local row_start_ms row_end_ms duration_ms + local row_start_ms row_start_ms="$(date +%s%3N)" - - "${tester_cmd[@]}" > "${tester_log}" 2>&1 & - local tester_pid=$! - cleanup_pids+=("${tester_pid}") - - sleep 1 - : > "${sim_log}" - - "${streamer_cmd[@]}" > "${streamer_log}" 2>&1 - local streamer_rc=$? - - wait_pid "${tester_pid}" 25 - local tester_rc=$? - local sim_rc=0 - + set +e + "$@" >"${log_path}" 2>&1 + local command_rc=$? + set -e + local row_end_ms row_end_ms="$(date +%s%3N)" - duration_ms=$((row_end_ms - row_start_ms)) + local duration_ms=$((row_end_ms - row_start_ms)) - local status="PASS" - local reason="all-processes-ok" - if (( sim_rc != 0 || streamer_rc != 0 || tester_rc != 0 )); then - status="FAIL" - reason="sim_rc=${sim_rc},streamer_rc=${streamer_rc},tester_rc=${tester_rc}" + local status="FAIL" + local reason="expected non-zero rc and log token '${expected_substring}'" + if (( command_rc != 0 )) && grep -Fq "${expected_substring}" "${log_path}"; then + status="PASS" + reason="command failed as expected" fi append_manifest_row \ @@ -276,26 +117,21 @@ run_fault_scenario() { "${status}" \ "${reason}" \ "${duration_ms}" \ - "${sim_rc}" \ - "${streamer_rc}" \ - "${tester_rc}" \ - "${sim_log}" \ - "${streamer_log}" \ - "${tester_log}" \ - "${sdp_path}" + "${command_rc}" \ + "${log_path}" printf "[%s] %s => %s (%s)\n" "${scenario_id}" "${name}" "${status}" "${reason}" } main() { local required=( - "${BUILD_DIR}/cvmmap_streamer" - "${BUILD_DIR}/rtp_receiver_tester" + "${BUILD_DIR}/cvmmap_streamer" + "${BUILD_DIR}/rtmp_output_tester" ) local missing=() for bin in "${required[@]}"; do - if ! binary_exists "${bin}"; then + if [[ ! -x "${bin}" ]]; then missing+=("${bin}") fi done @@ -313,9 +149,68 @@ main() { return 1 fi - run_fault_scenario 1 "torn_read" "fault:torn-read" - run_fault_scenario 2 "sink_stall" "fault:sink-stall" - run_fault_scenario 3 "reset_storm" "fault:reset-storm" + run_expected_failure 1 "removed_encoder_backend" "removed encoder backend rejected" \ + "invalid encoder backend: 'gstreamer_legacy' was removed; use ffmpeg" \ + "${BUILD_DIR}/cvmmap_streamer" \ + --run-mode pipeline \ + --input-uri cvmmap://default \ + --encoder-backend gstreamer_legacy + + run_expected_failure 2 "removed_rtmp_transport" "removed RTMP transport rejected" \ + "invalid rtmp transport: 'legacy_custom' was removed; use libavformat or ffmpeg_process" \ + "${BUILD_DIR}/cvmmap_streamer" \ + --run-mode pipeline \ + --input-uri cvmmap://default \ + --rtmp \ + --rtmp-url rtmp://127.0.0.1/live/test \ + --rtmp-transport legacy_custom + + run_expected_failure 3 "removed_rtmp_mode_cli" "removed RTMP mode flag rejected" \ + "unknown argument: --rtmp-mode (removed; RTMP always uses enhanced mode)" \ + "${BUILD_DIR}/cvmmap_streamer" \ + --rtmp-mode enhanced + + local mode_row_dir="${RUN_DIR}/4-removed_rtmp_mode_toml" + mkdir -p "${mode_row_dir}" + local mode_config="${mode_row_dir}/removed_rtmp_mode.toml" + cat >"${mode_config}" <<'EOF' +[outputs.rtmp] +enabled = true +urls = ["rtmp://127.0.0.1/live/test"] +mode = "enhanced" +EOF + run_expected_failure 4 "removed_rtmp_mode_toml" "removed RTMP mode TOML rejected" \ + "invalid RTMP config: outputs.rtmp.mode was removed; RTMP always uses enhanced mode" \ + "${BUILD_DIR}/cvmmap_streamer" \ + --config "${mode_config}" + + run_expected_failure 5 "missing_rtmp_url" "missing RTMP URL rejected" \ + "invalid RTMP config: enabled RTMP output requires at least one URL" \ + "${BUILD_DIR}/cvmmap_streamer" \ + --run-mode pipeline \ + --input-uri cvmmap://default \ + --rtmp + + run_expected_failure 6 "invalid_rtp_endpoint" "invalid RTP endpoint rejected" \ + "invalid RTP config: endpoint must be in ':' format" \ + "${BUILD_DIR}/cvmmap_streamer" \ + --run-mode pipeline \ + --input-uri cvmmap://default \ + --rtp \ + --rtp-endpoint invalid + + run_expected_failure 7 "ffmpeg_process_bad_binary" "ffmpeg_process child failure surfaces" \ + "child exited before publish completed" \ + "${BUILD_DIR}/rtmp_output_tester" \ + --rtmp-url rtmp://127.0.0.1/live/test \ + --transport ffmpeg_process \ + --ffmpeg-path /nonexistent/ffmpeg \ + --codec h264 \ + --frames 256 \ + --width 640 \ + --height 360 \ + --frame-interval-ms 1 \ + --linger-ms 0 local finished_at_utc finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" @@ -328,54 +223,36 @@ main() { --started-at "${STARTED_AT_UTC}" \ --finished-at "${finished_at_utc}" \ --mode "${MODE}" - local summary_rc=$? cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true local total_count pass_count fail_count all_pass total_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys +import json, sys data = json.load(open(sys.argv[1], "r", encoding="utf-8")) counts = data.get("counts", {}) print(counts.get("total", 0)) PY )" pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys +import json, sys data = json.load(open(sys.argv[1], "r", encoding="utf-8")) counts = data.get("counts", {}) print(counts.get("pass", 0)) PY )" fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys +import json, sys data = json.load(open(sys.argv[1], "r", encoding="utf-8")) counts = data.get("counts", {}) print(counts.get("fail", 0)) PY )" all_pass="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys +import json, sys data = json.load(open(sys.argv[1], "r", encoding="utf-8")) print("true" if data.get("all_pass", False) else "false") PY -)" - - local violation_lines - violation_lines="$(python3 - <<'PY' "${SUMMARY_JSON}" -import json -import sys - -data = json.load(open(sys.argv[1], "r", encoding="utf-8")) -for scenario in data.get("scenarios", []): - sid = scenario.get("id", "unknown") - for violation in scenario.get("violations", []): - print(f"{sid}:{violation}") -PY )" { @@ -385,42 +262,24 @@ PY echo "run_dir=${RUN_DIR}" echo "manifest=${MANIFEST_TSV}" echo "summary_json=${SUMMARY_JSON}" - echo "latest_summary_json=${LATEST_SUMMARY_JSON}" echo "started_at=${STARTED_AT_UTC}" echo "finished_at=${finished_at_utc}" - echo "scenario_total=${total_count}" - echo "scenario_pass=${pass_count}" - echo "scenario_fail=${fail_count}" + echo "counts_total=${total_count}" + echo "counts_pass=${pass_count}" + echo "counts_fail=${fail_count}" echo "all_pass=${all_pass}" - echo "summary_helper_rc=${summary_rc}" - echo "violated_thresholds_begin" - if [[ -n "${violation_lines}" ]]; then - echo "${violation_lines}" - fi - echo "violated_thresholds_end" + echo "scenarios=removed_encoder_backend,removed_rtmp_transport,removed_rtmp_mode_cli,removed_rtmp_mode_toml,missing_rtmp_url,invalid_rtp_endpoint,ffmpeg_process_bad_binary" } > "${EVIDENCE_TEXT}" - if (( summary_rc != 0 )); then - echo "summary helper failed with rc=${summary_rc}" >&2 - return 1 + if [[ "${all_pass}" == "true" ]]; then + echo "fault suite PASS (${pass_count}/${total_count})" + echo "summary: ${SUMMARY_JSON}" + return 0 fi - echo "fault-suite mode=${MODE} total=${total_count} pass=${pass_count} fail=${fail_count}" - echo "summary: ${SUMMARY_JSON}" - - if [[ "${MODE}" == "baseline" ]]; then - if [[ "${total_count}" == "3" && "${pass_count}" == "3" && "${fail_count}" == "0" ]]; then - return 0 - fi - return 1 - fi - - if [[ "${fail_count}" != "0" ]]; then - return 1 - fi - - echo "degraded mode did not violate thresholds" >&2 - return 2 + echo "fault suite FAIL (${pass_count}/${total_count})" >&2 + echo "summary: ${SUMMARY_JSON}" >&2 + return 1 } main "$@" diff --git a/scripts/fault_summary_helper.py b/scripts/fault_summary_helper.py index 1bb6d24..ed34a20 100755 --- a/scripts/fault_summary_helper.py +++ b/scripts/fault_summary_helper.py @@ -5,16 +5,12 @@ from __future__ import annotations import argparse import csv import json -import re import sys from dataclasses import dataclass from pathlib import Path from typing import cast -KV_PATTERN = re.compile(r"([a-zA-Z0-9_]+)=([^\s]+)") - - @dataclass(frozen=True) class CliArgs: manifest: str @@ -27,9 +23,7 @@ class CliArgs: def parse_args() -> CliArgs: - parser = argparse.ArgumentParser( - description="Build fault suite summary with threshold checks" - ) + parser = argparse.ArgumentParser(description="Build fault suite summary") _ = parser.add_argument("--manifest", required=True) _ = parser.add_argument("--output", required=True) _ = parser.add_argument("--run-id", required=True) @@ -37,7 +31,6 @@ def parse_args() -> CliArgs: _ = parser.add_argument("--started-at", required=True) _ = parser.add_argument("--finished-at", required=True) _ = parser.add_argument("--mode", required=True, choices=("baseline", "degraded")) - parsed = parser.parse_args(sys.argv[1:]) return CliArgs( manifest=cast(str, parsed.manifest), @@ -50,46 +43,17 @@ def parse_args() -> CliArgs: ) -def read_text(path: str) -> str: - p = Path(path) - if not p.exists(): - return "" - try: - return p.read_text(encoding="utf-8", errors="replace") - except OSError: - return "" +def parse_manifest(path: str) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + with open(path, "r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle, delimiter="\t") + for raw in reader: + row = {key: "" if value is None else str(value) for key, value in raw.items()} + rows.append(row) + return rows -def to_number(value: str) -> int | float | str: - if re.fullmatch(r"-?\d+", value): - try: - return int(value) - except ValueError: - return value - if re.fullmatch(r"-?\d+\.\d+", value): - try: - return float(value) - except ValueError: - return value - return value - - -def parse_key_values(line: str) -> dict[str, int | float | str]: - out: dict[str, int | float | str] = {} - for match in KV_PATTERN.finditer(line): - out[match.group(1)] = to_number(match.group(2)) - return out - - -def last_line_with_token(text: str, token: str) -> str: - found = "" - for line in text.splitlines(): - if token in line: - found = line - return found - - -def parse_exit(value: str) -> int: +def parse_int(value: str) -> int: try: return int(value) except (TypeError, ValueError): @@ -103,302 +67,51 @@ def parse_duration_ms(value: str) -> int: return 0 -def parse_manifest(path: str) -> list[dict[str, str]]: - rows: list[dict[str, str]] = [] - with open(path, "r", encoding="utf-8", newline="") as handle: - reader = csv.DictReader(handle, delimiter="\t") - for raw in reader: - row: dict[str, str] = {} - for key in ( - "order", - "scenario_id", - "name", - "status", - "reason", - "duration_ms", - "sim_rc", - "streamer_rc", - "tester_rc", - "sim_log", - "streamer_log", - "tester_log", - "sdp_path", - ): - value = raw.get(key, "") - row[key] = "" if value is None else str(value) - rows.append(row) - return rows - - -Check = dict[str, object] - - -def make_check_min(metric: str, actual: int, minimum: int) -> Check: - passed = actual >= minimum - return { - "metric": metric, - "type": "min", - "actual": actual, - "expected": minimum, - "passed": passed, - "violation": "" if passed else f"{metric}={actual} < {minimum}", - } - - -def make_check_max(metric: str, actual: int, maximum: int) -> Check: - passed = actual <= maximum - return { - "metric": metric, - "type": "max", - "actual": actual, - "expected": maximum, - "passed": passed, - "violation": "" if passed else f"{metric}={actual} > {maximum}", - } - - -def get_thresholds(mode: str) -> dict[str, dict[str, int]]: - if mode == "baseline": - return { - "torn_read": { - "torn_read_events_min": 1, - "p50_us_max": 200_000, - "p99_us_max": 400_000, - "drop_ratio_ppm_max": 980_000, - "samples_min": 10, - }, - "sink_stall": { - "sink_stall_events_min": 1, - "p50_us_max": 350_000, - "p95_us_max": 600_000, - "drop_ratio_ppm_max": 1_000_000, - "samples_min": 1, - }, - "reset_storm": { - "reset_events_min": 4, - "p50_us_max": 1_000_000, - "p99_us_max": 1_000_000, - "drop_ratio_ppm_max": 1_000_000, - "samples_min": 1, - }, - } - - return { - "torn_read": { - "torn_read_events_min": 200, - "p50_us_max": 1_000, - "p99_us_max": 2_000, - "drop_ratio_ppm_max": 20_000, - "samples_min": 100, - }, - "sink_stall": { - "sink_stall_events_min": 200, - "p50_us_max": 1_000, - "p95_us_max": 2_000, - "drop_ratio_ppm_max": 20_000, - "samples_min": 100, - }, - "reset_storm": { - "reset_events_min": 20, - "p50_us_max": 1_000, - "p99_us_max": 2_000, - "drop_ratio_ppm_max": 20_000, - "samples_min": 100, - }, - } - - -def scenario_checks( - scenario_id: str, - fault: dict[str, int | float | str], - latency: dict[str, int | float | str], - thresholds: dict[str, dict[str, int]], -) -> list[Check]: - scenario_thresholds = thresholds.get(scenario_id, {}) - - torn = int(fault.get("torn_read_events", 0)) - stall = int(fault.get("sink_stall_events", 0)) - resets = int(fault.get("reset_events", 0)) - p95 = int(latency.get("p95_us", 0)) - p99 = int(latency.get("p99_us", 0)) - p50 = int(latency.get("p50_us", 0)) - samples = int(latency.get("ingest_to_emit_samples", 0)) - drop_ratio_ppm = int(latency.get("drop_ratio_ppm", 0)) - - checks: list[Check] = [] - checks.append( - make_check_min( - "ingest_to_emit_samples", - samples, - int(scenario_thresholds.get("samples_min", 1)), - ) - ) - checks.append( - make_check_max( - "p50_us", - p50, - int(scenario_thresholds.get("p50_us_max", 500_000)), - ) - ) - checks.append( - make_check_max( - "drop_ratio_ppm", - drop_ratio_ppm, - int(scenario_thresholds.get("drop_ratio_ppm_max", 1_000_000)), - ) - ) - - if scenario_id == "torn_read": - checks.append( - make_check_min( - "torn_read_events", - torn, - int(scenario_thresholds.get("torn_read_events_min", 1)), - ) - ) - checks.append( - make_check_max( - "p99_us", - p99, - int(scenario_thresholds.get("p99_us_max", 500_000)), - ) - ) - elif scenario_id == "sink_stall": - checks.append( - make_check_min( - "sink_stall_events", - stall, - int(scenario_thresholds.get("sink_stall_events_min", 1)), - ) - ) - checks.append( - make_check_max( - "p95_us", - p95, - int(scenario_thresholds.get("p95_us_max", 500_000)), - ) - ) - elif scenario_id == "reset_storm": - checks.append( - make_check_min( - "reset_events", - resets, - int(scenario_thresholds.get("reset_events_min", 1)), - ) - ) - checks.append( - make_check_max( - "p99_us", - p99, - int(scenario_thresholds.get("p99_us_max", 500_000)), - ) - ) - - return checks - - def build_summary(args: CliArgs) -> dict[str, object]: - thresholds = get_thresholds(args.mode) - rows = parse_manifest(args.manifest) + manifest_rows = parse_manifest(args.manifest) + rows = [ + { + "order": parse_int(row["order"]), + "id": row["scenario_id"], + "name": row["name"], + "status": row["status"], + "reason": row["reason"], + "duration_ms": parse_duration_ms(row["duration_ms"]), + "exit_codes": {"command": parse_int(row["command_rc"])}, + "evidence": {"log_path": row["log_path"]}, + } + for row in sorted(manifest_rows, key=lambda item: parse_int(item["order"])) + ] - scenarios: list[dict[str, object]] = [] - for row in sorted(rows, key=lambda item: int(item["order"])): - streamer_text = read_text(row["streamer_log"]) - pipeline_line = last_line_with_token(streamer_text, "PIPELINE_METRICS") - latency_line = last_line_with_token(streamer_text, "LATENCY_METRICS") - fault_line = last_line_with_token(streamer_text, "FAULT_COUNTERS") - rtp_line = last_line_with_token(streamer_text, "RTP_METRICS") - - pipeline = parse_key_values(pipeline_line) if pipeline_line else {} - latency = parse_key_values(latency_line) if latency_line else {} - fault = parse_key_values(fault_line) if fault_line else {} - rtp = parse_key_values(rtp_line) if rtp_line else {} - - sim_rc = parse_exit(row["sim_rc"]) - streamer_rc = parse_exit(row["streamer_rc"]) - tester_rc = parse_exit(row["tester_rc"]) - - process_ok = sim_rc == 0 and streamer_rc == 0 and tester_rc == 0 - checks = scenario_checks(row["scenario_id"], fault, latency, thresholds) - violated_checks = [ - cast(str, check["violation"]) - for check in checks - if not cast(bool, check["passed"]) - ] - - scenario_pass = process_ok and len(violated_checks) == 0 - scenario_status = "PASS" if scenario_pass else "FAIL" - reason = ( - "all checks passed" - if scenario_pass - else ( - f"process_rc(sim={sim_rc},streamer={streamer_rc},tester={tester_rc})" - if not process_ok - else "; ".join(violated_checks) - ) - ) - - scenarios.append( - { - "order": int(row["order"]), - "id": row["scenario_id"], - "name": row["name"], - "status": scenario_status, - "reason": reason, - "duration_ms": parse_duration_ms(row["duration_ms"]), - "process_exit": { - "sim": sim_rc, - "streamer": streamer_rc, - "tester": tester_rc, - }, - "metrics": { - "pipeline": pipeline, - "latency": latency, - "fault": fault, - "rtp": rtp, - }, - "checks": checks, - "violations": violated_checks, - "evidence": { - "sim_log": row["sim_log"], - "streamer_log": row["streamer_log"], - "tester_log": row["tester_log"], - "sdp": row["sdp_path"], - }, - } - ) - - pass_count = sum(1 for item in scenarios if item["status"] == "PASS") - fail_count = sum(1 for item in scenarios if item["status"] == "FAIL") - all_pass = len(scenarios) == 3 and pass_count == 3 and fail_count == 0 + pass_count = sum(1 for row in rows if row["status"] == "PASS") + fail_count = sum(1 for row in rows if row["status"] == "FAIL") + skip_count = sum(1 for row in rows if row["status"] == "SKIP") + all_pass = len(rows) == 7 and pass_count == 7 and fail_count == 0 and skip_count == 0 return { - "task": 15, - "mode": args.mode, "run_id": args.run_id, "run_dir": args.run_dir, "started_at": args.started_at, "finished_at": args.finished_at, - "thresholds": thresholds, + "mode": args.mode, "counts": { - "total": len(scenarios), + "total": len(rows), "pass": pass_count, "fail": fail_count, + "skip": skip_count, }, "all_pass": all_pass, "recommended_exit_code": 0 if all_pass else 1, - "scenarios": scenarios, + "rows": rows, } def main() -> int: args = parse_args() - summary = build_summary(args) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) - _ = output_path.write_text( - json.dumps(summary, indent=2, sort_keys=False) + "\n", encoding="utf-8" - ) + summary = build_summary(args) + output_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") return 0 diff --git a/scripts/live_srs_forward_smoke.sh b/scripts/live_srs_forward_smoke.sh index fb0abab..a2b2d34 100755 --- a/scripts/live_srs_forward_smoke.sh +++ b/scripts/live_srs_forward_smoke.sh @@ -17,7 +17,6 @@ CODEC="${CODEC:-h264}" ENCODER_BACKEND="${ENCODER_BACKEND:-ffmpeg}" ENCODER_DEVICE="${ENCODER_DEVICE:-nvidia}" RTMP_TRANSPORT="${RTMP_TRANSPORT:-libavformat}" -RTMP_MODE="${RTMP_MODE:-enhanced}" INGEST_MAX_FRAMES="${INGEST_MAX_FRAMES:-120}" PROBE_TIMEOUT_S="${PROBE_TIMEOUT_S:-20}" DECODE_FRAMES="${DECODE_FRAMES:-15}" @@ -58,10 +57,9 @@ Environment overrides: INPUT_URI cvmmap source URI, if positional argument is omitted STREAM_NAME RTMP/HTTP-FLV stream name, default derived from INPUT_URI CODEC h264|h265 - ENCODER_BACKEND ffmpeg|gstreamer_legacy + ENCODER_BACKEND auto|ffmpeg ENCODER_DEVICE auto|nvidia|software - RTMP_TRANSPORT libavformat|ffmpeg_process|legacy_custom - RTMP_MODE enhanced|domestic + RTMP_TRANSPORT libavformat|ffmpeg_process INGEST_MAX_FRAMES bounded frame count for the smoke DECODE_FRAMES frames to decode from HTTP-FLV after probe SRS_ROOT local SRS checkout, default ~/Code/srs @@ -198,7 +196,6 @@ fi --rtmp \ --rtmp-url "$RTMP_URL" \ --rtmp-transport "$RTMP_TRANSPORT" \ - --rtmp-mode "$RTMP_MODE" \ --ingest-max-frames "$INGEST_MAX_FRAMES" \ >"$STREAMER_LOG" 2>&1 & STREAMER_PID=$! diff --git a/src/config/runtime_config.cpp b/src/config/runtime_config.cpp index b0e8d7a..8e6cd2e 100644 --- a/src/config/runtime_config.cpp +++ b/src/config/runtime_config.cpp @@ -14,10 +14,6 @@ #include #include -#ifndef CVMMAP_STREAMER_HAS_GSTREAMER -#define CVMMAP_STREAMER_HAS_GSTREAMER 0 -#endif - namespace cvmmap_streamer { namespace { @@ -36,10 +32,16 @@ std::string trim_copy(std::string value) { } std::string normalize_cli_error(std::string raw_message) { - if (raw_message.find("The following argument was not expected:") != std::string::npos) { + if ( + raw_message.find("The following argument was not expected:") != std::string::npos || + raw_message.find("The following arguments were not expected:") != std::string::npos) { const auto pos = raw_message.find(':'); if (pos != std::string::npos && pos + 1 < raw_message.size()) { - return "unknown argument: " + trim_copy(raw_message.substr(pos + 1)); + const auto argument = trim_copy(raw_message.substr(pos + 1)); + if (argument.rfind("--rtmp-mode", 0) == 0) { + return "unknown argument: --rtmp-mode (removed; RTMP always uses enhanced mode)"; + } + return "unknown argument: " + argument; } return "unknown argument"; } @@ -113,16 +115,6 @@ std::expected parse_run_mode(std::string_view raw) { return std::unexpected("invalid run mode: '" + std::string(raw) + "' (expected: pipeline|ingest)"); } -std::expected parse_rtmp_mode(std::string_view raw) { - if (raw == "enhanced") { - return RtmpMode::Enhanced; - } - if (raw == "domestic") { - return RtmpMode::Domestic; - } - return std::unexpected("invalid rtmp mode: '" + std::string(raw) + "' (expected: enhanced|domestic)"); -} - std::expected parse_rtmp_transport(std::string_view raw) { if (raw == "libavformat") { return RtmpTransportType::Libavformat; @@ -131,10 +123,10 @@ std::expected parse_rtmp_transport(std::string_v return RtmpTransportType::FfmpegProcess; } if (raw == "legacy_custom" || raw == "legacy-custom") { - return RtmpTransportType::LegacyCustom; + return std::unexpected( + "invalid rtmp transport: '" + std::string(raw) + "' was removed; use libavformat or ffmpeg_process"); } - return std::unexpected( - "invalid rtmp transport: '" + std::string(raw) + "' (expected: libavformat|ffmpeg_process|legacy_custom)"); + return std::unexpected("invalid rtmp transport: '" + std::string(raw) + "' (expected: libavformat|ffmpeg_process)"); } std::expected parse_encoder_backend(std::string_view raw) { @@ -145,9 +137,9 @@ std::expected parse_encoder_backend(std::string return EncoderBackendType::FFmpeg; } if (raw == "gstreamer_legacy" || raw == "gstreamer-legacy") { - return EncoderBackendType::GStreamerLegacy; + return std::unexpected("invalid encoder backend: '" + std::string(raw) + "' was removed; use ffmpeg"); } - return std::unexpected("invalid encoder backend: '" + std::string(raw) + "' (expected: auto|ffmpeg|gstreamer_legacy)"); + return std::unexpected("invalid encoder backend: '" + std::string(raw) + "' (expected: auto|ffmpeg)"); } std::expected parse_encoder_device(std::string_view raw) { @@ -352,13 +344,9 @@ std::expected apply_toml_file(RuntimeConfig &config, const st if (auto value = toml_value(table, "outputs.rtmp.ffmpeg_path")) { config.outputs.rtmp.ffmpeg_path = *value; } - if (auto value = toml_value(table, "outputs.rtmp.mode")) { - auto parsed = parse_rtmp_mode(*value); - if (!parsed) { - return std::unexpected(parsed.error()); + if (auto value = toml_value(table, "outputs.rtmp.mode")) { + return std::unexpected("invalid RTMP config: outputs.rtmp.mode was removed; RTMP always uses enhanced mode"); } - config.outputs.rtmp.mode = *parsed; - } if (auto value = toml_value(table, "record.mcap.enabled")) { config.record.mcap.enabled = *value; } @@ -502,8 +490,6 @@ std::string_view to_string(RtmpMode mode) { switch (mode) { case RtmpMode::Enhanced: return "enhanced"; - case RtmpMode::Domestic: - return "domestic"; } return "unknown"; } @@ -514,8 +500,6 @@ std::string_view to_string(RtmpTransportType transport) { return "libavformat"; case RtmpTransportType::FfmpegProcess: return "ffmpeg_process"; - case RtmpTransportType::LegacyCustom: - return "legacy_custom"; } return "unknown"; } @@ -526,8 +510,6 @@ std::string_view to_string(EncoderBackendType backend) { return "auto"; case EncoderBackendType::FFmpeg: return "ffmpeg"; - case EncoderBackendType::GStreamerLegacy: - return "gstreamer_legacy"; } return "unknown"; } @@ -565,7 +547,6 @@ std::expected parse_runtime_config(int argc, char ** std::string codec_raw{}; std::string encoder_backend_raw{}; std::string encoder_device_raw{}; - std::string rtmp_mode_raw{}; std::string rtmp_transport_raw{}; std::string rtmp_ffmpeg_path_raw{}; std::vector rtmp_urls_raw{}; @@ -605,7 +586,6 @@ std::expected parse_runtime_config(int argc, char ** app.add_option("--rtmp-url", rtmp_urls_raw); app.add_option("--rtmp-transport", rtmp_transport_raw); app.add_option("--rtmp-ffmpeg", rtmp_ffmpeg_path_raw); - app.add_option("--rtmp-mode", rtmp_mode_raw); app.add_flag("--rtp", rtp_enabled); app.add_option("--rtp-endpoint", rtp_endpoint_raw); app.add_option("--rtp-payload-type", rtp_payload_type_raw); @@ -692,14 +672,6 @@ std::expected parse_runtime_config(int argc, char ** if (!rtmp_ffmpeg_path_raw.empty()) { config.outputs.rtmp.ffmpeg_path = rtmp_ffmpeg_path_raw; } - if (!rtmp_mode_raw.empty()) { - auto parsed = parse_rtmp_mode(rtmp_mode_raw); - if (!parsed) { - return std::unexpected(parsed.error()); - } - config.outputs.rtmp.mode = *parsed; - } - config.outputs.rtp.enabled = config.outputs.rtp.enabled || rtp_enabled; if (!rtp_endpoint_raw.empty()) { config.outputs.rtp.enabled = true; @@ -831,27 +803,14 @@ std::expected validate_runtime_config(const RuntimeConfig &co return std::unexpected("invalid RTMP config: URL must not be empty"); } } - if (config.encoder.backend == EncoderBackendType::GStreamerLegacy && config.record.mcap.enabled) { - return std::unexpected("invalid backend/output matrix: MCAP recording requires the ffmpeg encoded access-unit path"); - } if (config.outputs.rtmp.enabled) { - if (config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) { - if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.encoder.codec != CodecType::H265) { - return std::unexpected("invalid mode matrix: domestic RTMP mode requires codec h265"); - } - if (config.encoder.backend != EncoderBackendType::GStreamerLegacy) { - return std::unexpected("invalid backend/output matrix: legacy_custom RTMP requires encoder.backend=gstreamer_legacy"); - } - } else { - if (config.outputs.rtmp.mode != RtmpMode::Enhanced) { - return std::unexpected("invalid RTMP config: non-legacy RTMP transports only support rtmp.mode=enhanced"); - } - if (config.encoder.backend != EncoderBackendType::FFmpeg) { - return std::unexpected("invalid backend/output matrix: RTMP transports libavformat and ffmpeg_process require encoder.backend=ffmpeg"); - } - if (config.outputs.rtmp.transport == RtmpTransportType::FfmpegProcess && config.outputs.rtmp.ffmpeg_path.empty()) { - return std::unexpected("invalid RTMP config: ffmpeg_process transport requires a non-empty ffmpeg path"); - } + if (config.encoder.backend == EncoderBackendType::Auto) { + // auto resolves to FFmpeg; nothing else is supported. + } else if (config.encoder.backend != EncoderBackendType::FFmpeg) { + return std::unexpected("invalid backend/output matrix: RTMP requires encoder.backend=ffmpeg or auto"); + } + if (config.outputs.rtmp.transport == RtmpTransportType::FfmpegProcess && config.outputs.rtmp.ffmpeg_path.empty()) { + return std::unexpected("invalid RTMP config: ffmpeg_process transport requires a non-empty ffmpeg path"); } } @@ -891,15 +850,6 @@ std::expected validate_runtime_config(const RuntimeConfig &co return std::unexpected("invalid ingest config: ingest_idle_timeout_ms must be >= 1"); } -#if !CVMMAP_STREAMER_HAS_GSTREAMER - if (config.encoder.backend == EncoderBackendType::GStreamerLegacy) { - return std::unexpected("invalid backend config: gstreamer_legacy backend requested but GStreamer support is not compiled"); - } - if (config.outputs.rtmp.enabled && config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) { - return std::unexpected("invalid output config: legacy_custom RTMP requires GStreamer support, which is not compiled"); - } -#endif - return {}; } @@ -914,7 +864,6 @@ std::string summarize_runtime_config(const RuntimeConfig &config) { ss << ", encoder.b_frames=" << config.encoder.b_frames; ss << ", rtmp.enabled=" << (config.outputs.rtmp.enabled ? "true" : "false"); ss << ", rtmp.transport=" << to_string(config.outputs.rtmp.transport); - 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 : ""); diff --git a/src/encode/encoder_backend.cpp b/src/encode/encoder_backend.cpp index 538ebc8..323ef0d 100644 --- a/src/encode/encoder_backend.cpp +++ b/src/encode/encoder_backend.cpp @@ -3,29 +3,11 @@ namespace cvmmap_streamer::encode { EncoderBackend make_ffmpeg_backend(); -EncoderBackend make_gstreamer_legacy_backend(); Result make_encoder_backend(const RuntimeConfig &config) { switch (config.encoder.backend) { case EncoderBackendType::FFmpeg: - return make_ffmpeg_backend(); - case EncoderBackendType::GStreamerLegacy: { - auto backend = make_gstreamer_legacy_backend(); - if (!backend) { - return unexpected_error(ERR_BACKEND_UNAVAILABLE, "legacy GStreamer backend is not compiled in this build"); - } - return backend; - } case EncoderBackendType::Auto: - if (config.outputs.rtmp.enabled && config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) { - auto backend = make_gstreamer_legacy_backend(); - if (!backend) { - return unexpected_error( - ERR_BACKEND_UNAVAILABLE, - "legacy_custom RTMP requires the GStreamer backend, but it is not compiled"); - } - return backend; - } return make_ffmpeg_backend(); } diff --git a/src/encode/gstreamer_legacy_backend.cpp b/src/encode/gstreamer_legacy_backend.cpp deleted file mode 100644 index 47f7302..0000000 --- a/src/encode/gstreamer_legacy_backend.cpp +++ /dev/null @@ -1,457 +0,0 @@ -#include "cvmmap_streamer/encode/encoder_backend.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#if __has_include() && __has_include() && __has_include() -#define CVMMAP_STREAMER_HAS_GSTREAMER 1 -#include -#include -#include -#else -#define CVMMAP_STREAMER_HAS_GSTREAMER 0 -#endif - -namespace cvmmap_streamer::encode { - -namespace { - -#if CVMMAP_STREAMER_HAS_GSTREAMER - -[[nodiscard]] -Result pixel_format_to_caps(ipc::PixelFormat format) { - switch (format) { - case ipc::PixelFormat::BGR: - return "BGR"; - case ipc::PixelFormat::RGB: - return "RGB"; - case ipc::PixelFormat::BGRA: - return "BGRA"; - case ipc::PixelFormat::RGBA: - return "RGBA"; - case ipc::PixelFormat::GRAY: - return "GRAY8"; - default: - return unexpected_error(ERR_UNSUPPORTED, "unsupported raw pixel format for legacy GStreamer backend"); - } -} - -void ensure_gst_initialized() { - static std::once_flag gst_init_flag; - std::call_once(gst_init_flag, []() { - gst_init(nullptr, nullptr); - spdlog::info("GStreamer initialized: {}", gst_version_string()); - }); -} - -[[nodiscard]] -std::string selected_parser_name(CodecType codec) { - return codec == CodecType::H265 ? "h265parse" : "h264parse"; -} - -struct EncoderChoice { - std::string encoder_name; - std::string parser_name; - bool is_nvenc{false}; -}; - -[[nodiscard]] -std::vector encoder_candidates(CodecType codec, bool prefer_nvenc) { - if (codec == CodecType::H265) { - if (prefer_nvenc) { - return {"nvh265enc", "x265enc", "avenc_libx265"}; - } - return {"x265enc", "avenc_libx265", "nvh265enc"}; - } - - if (prefer_nvenc) { - return {"nvh264enc", "x264enc", "openh264enc", "avenc_h264"}; - } - return {"x264enc", "openh264enc", "avenc_h264", "nvh264enc"}; -} - -[[nodiscard]] -Result pick_encoder_choice(CodecType codec, bool prefer_nvenc) { - const std::string parser_name = selected_parser_name(codec); - if (gst_element_factory_find(parser_name.c_str()) == nullptr) { - return unexpected_error( - ERR_BACKEND_UNAVAILABLE, - "required GStreamer parser element '" + parser_name + "' is unavailable"); - } - - for (const auto candidate : encoder_candidates(codec, prefer_nvenc)) { - if (gst_element_factory_find(candidate.data()) == nullptr) { - continue; - } - EncoderChoice choice{}; - choice.encoder_name = std::string(candidate); - choice.parser_name = parser_name; - choice.is_nvenc = choice.encoder_name.starts_with("nvh"); - return choice; - } - - return unexpected_error(ERR_BACKEND_UNAVAILABLE, "no usable GStreamer encoder available"); -} - -[[nodiscard]] -std::string encoder_input_format(const std::string &encoder_name) { - if (encoder_name == "x265enc" || encoder_name == "openh264enc") { - return "I420"; - } - return "NV12"; -} - -[[nodiscard]] -bool has_property(GObject *object, const char *name) { - if (object == nullptr || name == nullptr) { - return false; - } - return g_object_class_find_property(G_OBJECT_GET_CLASS(object), name) != nullptr; -} - -[[nodiscard]] -bool set_property_arg_if_exists(GObject *object, const char *name, const std::string &value) { - if (!has_property(object, name)) { - return false; - } - gst_util_set_object_arg(object, name, value.c_str()); - return true; -} - -class GstreamerLegacyBackend { -public: - GstreamerLegacyBackend() = default; - - ~GstreamerLegacyBackend() { - shutdown(); - } - - [[nodiscard]] - std::string_view backend_name() const { - return "gstreamer_legacy"; - } - - [[nodiscard]] - bool using_hardware() const { - return using_hardware_; - } - - [[nodiscard]] - Status init(const RuntimeConfig &config, const ipc::FrameInfo &frame_info) { - shutdown(); - config_ = &config; - frame_info_ = frame_info; - ensure_gst_initialized(); - - bool prefer_nvenc = config.encoder.device != EncoderDeviceType::Software; - auto encoder_choice = pick_encoder_choice(config.encoder.codec, prefer_nvenc); - if (!encoder_choice && prefer_nvenc && config.encoder.device == EncoderDeviceType::Auto) { - encoder_choice = pick_encoder_choice(config.encoder.codec, false); - } - if (!encoder_choice) { - return std::unexpected(encoder_choice.error()); - } - - using_hardware_ = encoder_choice->is_nvenc; - active_encoder_name_ = encoder_choice->encoder_name; - active_parser_name_ = encoder_choice->parser_name; - - auto pixel_format = pixel_format_to_caps(frame_info.pixel_format); - if (!pixel_format) { - return std::unexpected(pixel_format.error()); - } - - const std::string codec_caps = - config.encoder.codec == CodecType::H265 - ? "video/x-h265,stream-format=byte-stream,alignment=au" - : "video/x-h264,stream-format=byte-stream,alignment=au"; - - const std::string pipeline_desc = - std::string("appsrc name=ingest_src is-live=true format=time do-timestamp=true block=false ") + - "! queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 " + - "! videoconvert " + - "! video/x-raw,format=" + encoder_input_format(active_encoder_name_) + " " + - "! " + active_encoder_name_ + " name=encoder " + - "! " + active_parser_name_ + " name=parser config-interval=-1 disable-passthrough=true " + - "! " + codec_caps + " " + - "! appsink name=encoded_sink emit-signals=false sync=false drop=true max-buffers=1"; - - GError *error = nullptr; - pipeline_ = gst_parse_launch(pipeline_desc.c_str(), &error); - if (error != nullptr) { - const std::string message = "failed to create GStreamer pipeline: " + std::string(error->message); - g_error_free(error); - return unexpected_error(ERR_EXTERNAL_LIBRARY, message); - } - if (pipeline_ == nullptr) { - return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to create GStreamer pipeline"); - } - - appsrc_ = gst_bin_get_by_name(GST_BIN(pipeline_), "ingest_src"); - appsink_ = gst_bin_get_by_name(GST_BIN(pipeline_), "encoded_sink"); - encoder_ = gst_bin_get_by_name(GST_BIN(pipeline_), "encoder"); - if (appsrc_ == nullptr || appsink_ == nullptr || encoder_ == nullptr) { - return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to locate GStreamer pipeline elements"); - } - - const auto caps_string = - "video/x-raw,format=(string)" + - std::string(*pixel_format) + - ",width=(int)" + - std::to_string(frame_info.width) + - ",height=(int)" + - std::to_string(frame_info.height) + - ",framerate=(fraction)30/1"; - GstCaps *caps = gst_caps_from_string(caps_string.c_str()); - if (caps == nullptr) { - return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to create GStreamer caps: " + caps_string); - } - gst_app_src_set_caps(GST_APP_SRC(appsrc_), caps); - gst_caps_unref(caps); - - gst_app_src_set_stream_type(GST_APP_SRC(appsrc_), GST_APP_STREAM_TYPE_STREAM); - gst_app_src_set_max_buffers(GST_APP_SRC(appsrc_), 1); - (void)set_property_arg_if_exists(G_OBJECT(appsrc_), "leaky-type", "downstream"); - (void)set_property_arg_if_exists(G_OBJECT(appsrc_), "block", "false"); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "bframes", std::to_string(config.encoder.b_frames)); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "rc-lookahead", "0"); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "lookahead", "0"); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "zerolatency", "true"); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "gop-size", std::to_string(config.encoder.gop)); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "iframeinterval", std::to_string(config.encoder.gop)); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "preset", "llhq"); - (void)set_property_arg_if_exists(G_OBJECT(encoder_), "tune", "zerolatency"); - - bus_ = gst_element_get_bus(pipeline_); - if (gst_element_set_state(pipeline_, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { - return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to set GStreamer pipeline to PLAYING"); - } - - spdlog::info( - "ENCODER_PATH codec={} mode={} encoder={} backend=gstreamer_legacy", - to_string(config.encoder.codec), - using_hardware_ ? "hardware" : "software", - active_encoder_name_); - return {}; - } - - [[nodiscard]] - Result stream_info() const { - if (config_ == nullptr) { - return unexpected_error(ERR_NOT_READY, "legacy GStreamer backend stream info is unavailable before initialization"); - } - - EncodedStreamInfo info{}; - info.codec = config_->encoder.codec; - info.width = frame_info_.width; - info.height = frame_info_.height; - return info; - } - - [[nodiscard]] - Status poll() { - if (bus_ == nullptr) { - return {}; - } - while (auto *message = gst_bus_pop_filtered( - bus_, - static_cast(GST_MESSAGE_ERROR | GST_MESSAGE_EOS | GST_MESSAGE_WARNING))) { - if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_WARNING) { - GError *warning = nullptr; - gchar *debug = nullptr; - gst_message_parse_warning(message, &warning, &debug); - spdlog::warn( - "legacy backend warning: {} ({})", - warning != nullptr ? warning->message : "unknown", - debug != nullptr ? debug : "no-debug"); - if (warning != nullptr) { - g_error_free(warning); - } - if (debug != nullptr) { - g_free(debug); - } - gst_message_unref(message); - continue; - } - - if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS) { - gst_message_unref(message); - return unexpected_error(ERR_END_OF_STREAM, "legacy backend reached EOS"); - } - - GError *error = nullptr; - gchar *debug = nullptr; - gst_message_parse_error(message, &error, &debug); - const std::string message_text = - "legacy backend error: " + - std::string(error != nullptr ? error->message : "unknown") + - " (" + - std::string(debug != nullptr ? debug : "no-debug") + - ")"; - if (error != nullptr) { - g_error_free(error); - } - if (debug != nullptr) { - g_free(debug); - } - gst_message_unref(message); - return unexpected_error(ERR_EXTERNAL_LIBRARY, message_text); - } - return {}; - } - - [[nodiscard]] - Status push_frame(const RawVideoFrame &frame) { - if (appsrc_ == nullptr) { - return unexpected_error(ERR_NOT_READY, "legacy backend appsrc is null"); - } - - auto *buffer = gst_buffer_new_allocate(nullptr, frame.bytes.size(), nullptr); - if (buffer == nullptr) { - return unexpected_error(ERR_ALLOCATION_FAILED, "failed to allocate GStreamer buffer"); - } - - GstMapInfo map{}; - if (!gst_buffer_map(buffer, &map, GST_MAP_WRITE)) { - gst_buffer_unref(buffer); - return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to map GStreamer buffer"); - } - std::memcpy(map.data, frame.bytes.data(), frame.bytes.size()); - gst_buffer_unmap(buffer, &map); - - if (!first_source_timestamp_ns_) { - first_source_timestamp_ns_ = frame.source_timestamp_ns; - } - const auto pts_ns = - frame.source_timestamp_ns >= *first_source_timestamp_ns_ - ? frame.source_timestamp_ns - *first_source_timestamp_ns_ - : 0ull; - GST_BUFFER_PTS(buffer) = static_cast(pts_ns); - GST_BUFFER_DTS(buffer) = static_cast(pts_ns); - - const auto flow = gst_app_src_push_buffer(GST_APP_SRC(appsrc_), buffer); - if (flow != GST_FLOW_OK) { - return unexpected_error( - ERR_EXTERNAL_LIBRARY, - "legacy backend push failed with flow=" + std::to_string(static_cast(flow))); - } - return {}; - } - - [[nodiscard]] - Result> drain() { - return pull_samples(); - } - - [[nodiscard]] - Result> flush() { - if (appsrc_ != nullptr) { - (void)gst_app_src_end_of_stream(GST_APP_SRC(appsrc_)); - } - return pull_samples(); - } - - void shutdown() { - if (pipeline_ != nullptr) { - gst_element_set_state(pipeline_, GST_STATE_NULL); - } - if (bus_ != nullptr) { - gst_object_unref(bus_); - bus_ = nullptr; - } - if (appsrc_ != nullptr) { - gst_object_unref(appsrc_); - appsrc_ = nullptr; - } - if (appsink_ != nullptr) { - gst_object_unref(appsink_); - appsink_ = nullptr; - } - if (encoder_ != nullptr) { - gst_object_unref(encoder_); - encoder_ = nullptr; - } - if (pipeline_ != nullptr) { - gst_object_unref(pipeline_); - pipeline_ = nullptr; - } - active_encoder_name_.clear(); - active_parser_name_.clear(); - frame_info_ = ipc::FrameInfo{}; - first_source_timestamp_ns_.reset(); - using_hardware_ = false; - } - -private: - [[nodiscard]] - Result> pull_samples() { - std::vector access_units{}; - if (appsink_ == nullptr || config_ == nullptr) { - return access_units; - } - - while (auto *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(appsink_), 0)) { - auto *buffer = gst_sample_get_buffer(sample); - if (buffer == nullptr) { - gst_sample_unref(sample); - continue; - } - - GstMapInfo map{}; - if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) { - gst_sample_unref(sample); - return unexpected_error(ERR_EXTERNAL_LIBRARY, "failed to map legacy encoded buffer"); - } - - EncodedAccessUnit access_unit{}; - access_unit.codec = config_->encoder.codec; - const auto pts = GST_BUFFER_PTS(buffer); - if (pts != GST_CLOCK_TIME_NONE) { - access_unit.stream_pts_ns = static_cast(pts); - } - access_unit.source_timestamp_ns = first_source_timestamp_ns_.value_or(0) + access_unit.stream_pts_ns; - access_unit.keyframe = !GST_BUFFER_FLAG_IS_SET(buffer, GST_BUFFER_FLAG_DELTA_UNIT); - access_unit.annexb_bytes.assign(map.data, map.data + map.size); - access_units.push_back(std::move(access_unit)); - - gst_buffer_unmap(buffer, &map); - gst_sample_unref(sample); - } - - return access_units; - } - - const RuntimeConfig *config_{nullptr}; - ipc::FrameInfo frame_info_{}; - GstElement *pipeline_{nullptr}; - GstElement *appsrc_{nullptr}; - GstElement *appsink_{nullptr}; - GstElement *encoder_{nullptr}; - GstBus *bus_{nullptr}; - std::optional first_source_timestamp_ns_{}; - bool using_hardware_{false}; - std::string active_encoder_name_{}; - std::string active_parser_name_{}; -}; - -#endif - -} - -EncoderBackend make_gstreamer_legacy_backend() { -#if CVMMAP_STREAMER_HAS_GSTREAMER - return pro::make_proxy(); -#else - return {}; -#endif -} - -} diff --git a/src/ipc/help.cpp b/src/ipc/help.cpp index f51ca93..cee6a2b 100644 --- a/src/ipc/help.cpp +++ b/src/ipc/help.cpp @@ -19,7 +19,7 @@ constexpr std::array kHelpLines{ " --input-uri \tcvmmap source URI (example: cvmmap://default)", " --run-mode \tpipeline|ingest", " --codec \th264|h265", - " --encoder-backend \tauto|ffmpeg|gstreamer_legacy", + " --encoder-backend \tauto|ffmpeg", " --encoder-device \tauto|nvidia|software", " --gop \tencoder GOP length", " --b-frames \tencoder B-frame count", @@ -29,10 +29,9 @@ constexpr std::array kHelpLines{ " --rtp-sdp \twrite optional SDP sidecar", " --rtmp\t\tenable RTMP output", " --rtmp-url \tadd RTMP destination (repeatable)", - " --rtmp-transport \tlibavformat|ffmpeg_process|legacy_custom", - " --rtmp-ffmpeg \tffmpeg binary for ffmpeg_process transport", - " --rtmp-mode \tenhanced|domestic", - " --mcap\t\tenable MCAP recording", + " --rtmp-transport \tlibavformat|ffmpeg_process", + " --rtmp-ffmpeg \tffmpeg binary for ffmpeg_process transport", + " --mcap\t\tenable MCAP recording", " --mcap-path \tMCAP output file", " --mcap-topic \tMCAP topic name", " --mcap-frame-id \tFoxglove CompressedVideo frame_id", diff --git a/src/protocol/rtmp_output.cpp b/src/protocol/rtmp_output.cpp index 31a8d6c..9b8ff10 100644 --- a/src/protocol/rtmp_output.cpp +++ b/src/protocol/rtmp_output.cpp @@ -1,7 +1,5 @@ #include "cvmmap_streamer/protocol/rtmp_output.hpp" -#include "cvmmap_streamer/protocol/rtmp_publisher.hpp" - extern "C" { #include #include @@ -226,33 +224,6 @@ Status write_all(int fd, std::span bytes) { return {}; } -class LegacyCustomRtmpOutput { -public: - explicit LegacyCustomRtmpOutput(RtmpPublisher &&publisher) - : publisher_(std::move(publisher)) {} - - [[nodiscard]] - std::string_view backend_name() const { - return "legacy_custom"; - } - - [[nodiscard]] - Status publish_access_unit(const encode::EncodedAccessUnit &access_unit) { - auto publish = publisher_.publish_access_unit(access_unit.annexb_bytes, access_unit.stream_pts_ns); - if (!publish) { - return unexpected_error(ERR_PROTOCOL, publish.error()); - } - return {}; - } - - void log_metrics() const { - publisher_.log_metrics(); - } - -private: - RtmpPublisher publisher_{}; -}; - class LibavformatRtmpOutput { public: struct Session { @@ -681,13 +652,6 @@ Result make_rtmp_output( return LibavformatRtmpOutput::create(config, stream_info); case RtmpTransportType::FfmpegProcess: return FfmpegProcessRtmpOutput::create(config, stream_info); - case RtmpTransportType::LegacyCustom: { - auto publisher = RtmpPublisher::create(config); - if (!publisher) { - return unexpected_error(ERR_PROTOCOL, publisher.error()); - } - return pro::make_proxy(std::move(*publisher)); - } } return unexpected_error(ERR_INTERNAL, "unknown RTMP transport"); diff --git a/src/protocol/rtmp_publisher.cpp b/src/protocol/rtmp_publisher.cpp deleted file mode 100644 index da6eda2..0000000 --- a/src/protocol/rtmp_publisher.cpp +++ /dev/null @@ -1,1029 +0,0 @@ -#include "cvmmap_streamer/protocol/rtmp_publisher.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace cvmmap_streamer::protocol { - -namespace { - - constexpr std::uint8_t kRtmpVersion = 3; - constexpr std::size_t kRtmpHandshakePartLength = 1536; - constexpr std::uint32_t kDefaultChunkSize = 128; - constexpr std::uint32_t kReconnectBackoffInitialMs = 250; - constexpr std::uint32_t kReconnectBackoffMaxMs = 8'000; - - constexpr std::uint8_t kMsgAmf0Command = 20; - constexpr std::uint8_t kMsgVideo = 9; - - constexpr std::uint32_t kChunkStreamCommand = 3; - constexpr std::uint32_t kChunkStreamVideo = 6; - - constexpr std::uint64_t kErrorLogFirstPackets = 8; - constexpr std::uint64_t kErrorLogEveryNPackets = 120; - - struct ParsedRtmpUrl { - std::string host{}; - std::uint16_t port{1935}; - std::string app{}; - std::string stream{}; - std::string tc_url{}; - }; - - [[nodiscard]] - std::expected send_all(int fd, std::span data) { - std::size_t written = 0; - while (written < data.size()) { - const auto n = send( - fd, - reinterpret_cast(data.data() + written), - data.size() - written, - MSG_NOSIGNAL); - if (n < 0) { - if (errno == EINTR) { - continue; - } - return std::unexpected(std::format("send failed: {}", std::strerror(errno))); - } - if (n == 0) { - return std::unexpected("send returned 0 (peer closed)"); - } - written += static_cast(n); - } - return {}; - } - - [[nodiscard]] - std::expected recv_exact(int fd, std::span data) { - std::size_t read = 0; - while (read < data.size()) { - const auto n = recv( - fd, - reinterpret_cast(data.data() + read), - data.size() - read, - 0); - if (n < 0) { - if (errno == EINTR) { - continue; - } - return std::unexpected(std::format("recv failed: {}", std::strerror(errno))); - } - if (n == 0) { - return std::unexpected("peer closed while receiving"); - } - read += static_cast(n); - } - return {}; - } - - void append_be24(std::vector &out, std::uint32_t v) { - out.push_back(static_cast((v >> 16) & 0xffu)); - out.push_back(static_cast((v >> 8) & 0xffu)); - out.push_back(static_cast(v & 0xffu)); - } - - void append_be32(std::vector &out, std::uint32_t v) { - out.push_back(static_cast((v >> 24) & 0xffu)); - out.push_back(static_cast((v >> 16) & 0xffu)); - out.push_back(static_cast((v >> 8) & 0xffu)); - out.push_back(static_cast(v & 0xffu)); - } - - void append_le32(std::vector &out, std::uint32_t v) { - out.push_back(static_cast(v & 0xffu)); - out.push_back(static_cast((v >> 8) & 0xffu)); - out.push_back(static_cast((v >> 16) & 0xffu)); - out.push_back(static_cast((v >> 24) & 0xffu)); - } - - void append_basic_header(std::vector &out, std::uint8_t fmt, std::uint32_t csid) { - if (csid <= 63) { - out.push_back(static_cast((fmt << 6) | csid)); - return; - } - - if (csid <= 319) { - out.push_back(static_cast((fmt << 6) | 0)); - out.push_back(static_cast(csid - 64)); - return; - } - - out.push_back(static_cast((fmt << 6) | 1)); - const std::uint32_t normalized = csid - 64; - out.push_back(static_cast(normalized & 0xff)); - out.push_back(static_cast((normalized >> 8) & 0xff)); - } - - [[nodiscard]] - std::expected send_rtmp_message( - int fd, - std::uint32_t chunk_stream_id, - std::uint32_t timestamp, - std::uint8_t type_id, - std::uint32_t message_stream_id, - std::span payload, - std::uint32_t chunk_size) { - if (chunk_size == 0) { - return std::unexpected("invalid RTMP chunk size 0"); - } - - std::vector packet; - packet.reserve(payload.size() + 64); - - const std::uint32_t timestamp_field = std::min(timestamp, 0x00ffffffu); - append_basic_header(packet, 0, chunk_stream_id); - append_be24(packet, timestamp_field); - append_be24(packet, static_cast(payload.size())); - packet.push_back(type_id); - append_le32(packet, message_stream_id); - if (timestamp >= 0x00ffffffu) { - append_be32(packet, timestamp); - } - - std::size_t offset = 0; - const std::size_t first_chunk = std::min(chunk_size, payload.size()); - packet.insert(packet.end(), payload.begin(), payload.begin() + static_cast(first_chunk)); - offset += first_chunk; - - while (offset < payload.size()) { - append_basic_header(packet, 3, chunk_stream_id); - if (timestamp >= 0x00ffffffu) { - append_be32(packet, timestamp); - } - const std::size_t part = std::min(chunk_size, payload.size() - offset); - packet.insert( - packet.end(), - payload.begin() + static_cast(offset), - payload.begin() + static_cast(offset + part)); - offset += part; - } - - auto send_result = send_all(fd, packet); - if (!send_result) { - return std::unexpected(send_result.error()); - } - - return packet.size(); - } - - void append_amf0_string(std::vector &out, std::string_view value) { - out.push_back(0x02); - out.push_back(static_cast((value.size() >> 8) & 0xff)); - out.push_back(static_cast(value.size() & 0xff)); - out.insert(out.end(), value.begin(), value.end()); - } - - void append_amf0_number(std::vector &out, double value) { - out.push_back(0x00); - std::uint64_t bits{0}; - std::memcpy(&bits, &value, sizeof(bits)); - out.push_back(static_cast((bits >> 56) & 0xff)); - out.push_back(static_cast((bits >> 48) & 0xff)); - out.push_back(static_cast((bits >> 40) & 0xff)); - out.push_back(static_cast((bits >> 32) & 0xff)); - out.push_back(static_cast((bits >> 24) & 0xff)); - out.push_back(static_cast((bits >> 16) & 0xff)); - out.push_back(static_cast((bits >> 8) & 0xff)); - out.push_back(static_cast(bits & 0xff)); - } - - void append_amf0_null(std::vector &out) { - out.push_back(0x05); - } - - void append_amf0_object_start(std::vector &out) { - out.push_back(0x03); - } - - void append_amf0_object_end(std::vector &out) { - out.push_back(0x00); - out.push_back(0x00); - out.push_back(0x09); - } - - void append_amf0_object_key(std::vector &out, std::string_view key) { - out.push_back(static_cast((key.size() >> 8) & 0xff)); - out.push_back(static_cast(key.size() & 0xff)); - out.insert(out.end(), key.begin(), key.end()); - } - - void append_amf0_object_string_property( - std::vector &out, - std::string_view key, - std::string_view value) { - append_amf0_object_key(out, key); - append_amf0_string(out, value); - } - - void append_amf0_object_number_property(std::vector &out, std::string_view key, double value) { - append_amf0_object_key(out, key); - append_amf0_number(out, value); - } - - [[nodiscard]] - std::expected parse_rtmp_url(std::string_view url) { - constexpr std::string_view kPrefix{"rtmp://"}; - if (!url.starts_with(kPrefix)) { - return std::unexpected(std::format( - "invalid RTMP url '{}': must start with rtmp://", - url)); - } - - const auto raw = url.substr(kPrefix.size()); - const auto slash = raw.find('/'); - if (slash == std::string_view::npos || slash == 0 || slash + 1 >= raw.size()) { - return std::unexpected(std::format( - "invalid RTMP url '{}': expected rtmp://[:port]//", - url)); - } - - ParsedRtmpUrl parsed{}; - const auto host_port = raw.substr(0, slash); - const auto path = raw.substr(slash + 1); - - const auto colon = host_port.rfind(':'); - if (colon != std::string_view::npos && colon + 1 < host_port.size()) { - parsed.host = std::string(host_port.substr(0, colon)); - std::uint16_t port{0}; - const auto port_raw = host_port.substr(colon + 1); - auto [ptr, ec] = std::from_chars( - port_raw.data(), - port_raw.data() + port_raw.size(), - port, - 10); - if (ec != std::errc{} || ptr != port_raw.data() + port_raw.size() || port == 0) { - return std::unexpected(std::format( - "invalid RTMP url '{}': invalid port '{}'", - url, - port_raw)); - } - parsed.port = port; - } else { - parsed.host = std::string(host_port); - } - - if (parsed.host.empty()) { - return std::unexpected(std::format("invalid RTMP url '{}': host must not be empty", url)); - } - - const auto app_sep = path.find('/'); - if (app_sep == std::string_view::npos || app_sep == 0 || app_sep + 1 >= path.size()) { - return std::unexpected(std::format( - "invalid RTMP url '{}': expected //", - url)); - } - - parsed.app = std::string(path.substr(0, app_sep)); - parsed.stream = std::string(path.substr(app_sep + 1)); - if (parsed.app.empty() || parsed.stream.empty()) { - return std::unexpected(std::format("invalid RTMP url '{}': app/stream must not be empty", url)); - } - - parsed.tc_url = std::format( - "rtmp://{}:{}/{}", - parsed.host, - parsed.port, - parsed.app); - return parsed; - } - - [[nodiscard]] - std::expected open_tcp(std::string_view host, std::uint16_t port) { - addrinfo hints{}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - - addrinfo *result = nullptr; - const auto port_text = std::to_string(port); - const int gai = getaddrinfo( - std::string(host).c_str(), - port_text.c_str(), - &hints, - &result); - if (gai != 0) { - return std::unexpected(std::format( - "getaddrinfo failed for '{}:{}': {}", - host, - port, - gai_strerror(gai))); - } - - int fd{-1}; - for (auto *it = result; it != nullptr; it = it->ai_next) { - fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol); - if (fd < 0) { - continue; - } - if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) { - break; - } - close(fd); - fd = -1; - } - - freeaddrinfo(result); - - if (fd < 0) { - return std::unexpected(std::format( - "connect failed for '{}:{}': {}", - host, - port, - std::strerror(errno))); - } - - return fd; - } - - [[nodiscard]] - std::expected send_connect_message(int fd, std::uint32_t chunk_size, const ParsedRtmpUrl &url) { - std::vector payload; - payload.reserve(256); - - append_amf0_string(payload, "connect"); - append_amf0_number(payload, 1.0); - - append_amf0_object_start(payload); - append_amf0_object_string_property(payload, "app", url.app); - append_amf0_object_string_property(payload, "tcUrl", url.tc_url); - append_amf0_object_number_property(payload, "objectEncoding", 0.0); - append_amf0_object_end(payload); - - auto sent = send_rtmp_message( - fd, - kChunkStreamCommand, - 0, - kMsgAmf0Command, - 0, - payload, - chunk_size); - if (!sent) { - return std::unexpected(sent.error()); - } - return {}; - } - - [[nodiscard]] - std::expected send_create_stream_message(int fd, std::uint32_t chunk_size) { - std::vector payload; - payload.reserve(64); - - append_amf0_string(payload, "createStream"); - append_amf0_number(payload, 2.0); - append_amf0_null(payload); - - auto sent = send_rtmp_message( - fd, - kChunkStreamCommand, - 0, - kMsgAmf0Command, - 0, - payload, - chunk_size); - if (!sent) { - return std::unexpected(sent.error()); - } - return {}; - } - - [[nodiscard]] - std::expected send_publish_message( - int fd, - std::uint32_t chunk_size, - std::uint32_t stream_id, - std::string_view stream_name) { - std::vector payload; - payload.reserve(128); - - append_amf0_string(payload, "publish"); - append_amf0_number(payload, 3.0); - append_amf0_null(payload); - append_amf0_string(payload, stream_name); - append_amf0_string(payload, "live"); - - auto sent = send_rtmp_message( - fd, - kChunkStreamCommand, - 0, - kMsgAmf0Command, - stream_id, - payload, - chunk_size); - if (!sent) { - return std::unexpected(sent.error()); - } - return {}; - } - - [[nodiscard]] - std::expected run_handshake(int fd) { - std::array c0c1{}; - c0c1[0] = kRtmpVersion; - for (std::size_t i = 0; i < kRtmpHandshakePartLength; ++i) { - c0c1[1 + i] = static_cast((i * 17) & 0xff); - } - - auto c0c1_send = send_all(fd, c0c1); - if (!c0c1_send) { - return std::unexpected(std::format("write C0+C1 failed: {}", c0c1_send.error())); - } - - std::array s0s1s2{}; - auto s0s1s2_read = recv_exact(fd, s0s1s2); - if (!s0s1s2_read) { - return std::unexpected(std::format("read S0+S1+S2 failed: {}", s0s1s2_read.error())); - } - - if (s0s1s2[0] != kRtmpVersion) { - return std::unexpected(std::format( - "unexpected S0 RTMP version {} (expected {})", - static_cast(s0s1s2[0]), - static_cast(kRtmpVersion))); - } - - std::array c2{}; - std::copy_n(s0s1s2.begin() + 1, kRtmpHandshakePartLength, c2.begin()); - auto c2_send = send_all(fd, c2); - if (!c2_send) { - return std::unexpected(std::format("write C2 failed: {}", c2_send.error())); - } - - return {}; - } - - [[nodiscard]] - bool h264_idr_access_unit(std::span access_unit) { - if (access_unit.empty()) { - return false; - } - - for (std::size_t i = 0; i < access_unit.size(); ++i) { - if (i + 4 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x00 && access_unit[i + 3] == 0x01) { - const std::size_t nal = i + 4; - if (nal < access_unit.size()) { - return (access_unit[nal] & 0x1fu) == 5u; - } - } - if (i + 3 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x01) { - const std::size_t nal = i + 3; - if (nal < access_unit.size()) { - return (access_unit[nal] & 0x1fu) == 5u; - } - } - } - - return (access_unit[0] & 0x1fu) == 5u; - } - - [[nodiscard]] - std::vector make_h264_sequence_header() { - return { - 0x17, - 0x00, - 0x00, - 0x00, - 0x00, - 0x01, - 0x64, - 0x00, - 0x1f, - 0xff, - 0xe1, - 0x00, - 0x04, - 0x67, - 0x64, - 0x00, - 0x1f, - }; - } - - [[nodiscard]] - std::vector make_h264_video_payload(std::span access_unit) { - const bool is_idr = h264_idr_access_unit(access_unit); - std::vector payload; - payload.reserve(5 + access_unit.size()); - payload.push_back(is_idr ? 0x17 : 0x27); - payload.push_back(0x01); - payload.push_back(0x00); - payload.push_back(0x00); - payload.push_back(0x00); - payload.insert(payload.end(), access_unit.begin(), access_unit.end()); - return payload; - } - - [[nodiscard]] - std::vector make_h265_enhanced_sequence_header() { - return { - 0x90, - 'h', - 'v', - 'c', - '1', - 0x01, - 0x01, - 0x60, - }; - } - - [[nodiscard]] - std::vector make_h265_domestic_sequence_header() { - return { - 0x1c, - 0x00, - 0x00, - 0x00, - 0x00, - 0x01, - 0x01, - 0x60, - }; - } - - [[nodiscard]] - std::vector make_h265_enhanced_video_payload(std::span access_unit) { - std::vector payload; - payload.reserve(9 + access_unit.size()); - payload.push_back(0x91); - payload.push_back('h'); - payload.push_back('v'); - payload.push_back('c'); - payload.push_back('1'); - payload.push_back(0x00); - payload.push_back(0x00); - payload.push_back(0x00); - payload.push_back(0x00); - payload.insert(payload.end(), access_unit.begin(), access_unit.end()); - return payload; - } - - [[nodiscard]] - bool h265_idr_access_unit(std::span access_unit) { - if (access_unit.size() < 2) { - return false; - } - - for (std::size_t i = 0; i < access_unit.size(); ++i) { - std::size_t nal = access_unit.size(); - if (i + 4 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x00 && access_unit[i + 3] == 0x01) { - nal = i + 4; - } else if (i + 3 <= access_unit.size() && access_unit[i] == 0x00 && access_unit[i + 1] == 0x00 && access_unit[i + 2] == 0x01) { - nal = i + 3; - } - - if (nal + 1 >= access_unit.size()) { - continue; - } - - const std::uint8_t nal_type = static_cast((access_unit[nal] >> 1) & 0x3fu); - if (nal_type == 19u || nal_type == 20u || nal_type == 21u) { - return true; - } - } - - const std::uint8_t nal_type = static_cast((access_unit[0] >> 1) & 0x3fu); - return nal_type == 19u || nal_type == 20u || nal_type == 21u; - } - - [[nodiscard]] - std::vector make_h265_domestic_video_payload(std::span access_unit) { - const bool is_idr = h265_idr_access_unit(access_unit); - std::vector payload; - payload.reserve(5 + access_unit.size()); - payload.push_back(is_idr ? 0x1c : 0x2c); - payload.push_back(0x01); - payload.push_back(0x00); - payload.push_back(0x00); - payload.push_back(0x00); - payload.insert(payload.end(), access_unit.begin(), access_unit.end()); - return payload; - } - - [[nodiscard]] - std::uint32_t to_rtmp_timestamp_ms(std::uint64_t pts_ns) { - return static_cast((pts_ns / 1'000'000ull) & 0xffffffffu); - } - -} - -RtmpPublisher::~RtmpPublisher() { - for (auto &session : sessions_) { - if (session.socket_fd >= 0) { - close(session.socket_fd); - session.socket_fd = -1; - } - } -} - -RtmpPublisher::RtmpPublisher(RtmpPublisher &&other) noexcept { - codec_ = std::exchange(other.codec_, CodecType::H264); - mode_ = std::exchange(other.mode_, RtmpMode::Enhanced); - sessions_ = std::move(other.sessions_); - stats_ = other.stats_; -} - -RtmpPublisher &RtmpPublisher::operator=(RtmpPublisher &&other) noexcept { - if (this == &other) { - return *this; - } - - for (auto &session : sessions_) { - if (session.socket_fd >= 0) { - close(session.socket_fd); - session.socket_fd = -1; - } - } - - codec_ = std::exchange(other.codec_, CodecType::H264); - mode_ = std::exchange(other.mode_, RtmpMode::Enhanced); - sessions_ = std::move(other.sessions_); - stats_ = other.stats_; - return *this; -} - -void RtmpPublisher::close_session(Session &session) { - if (session.socket_fd >= 0) { - close(session.socket_fd); - session.socket_fd = -1; - } -} - -std::expected RtmpPublisher::connect_session(Session &session) { - auto fd = open_tcp(session.host, session.port); - if (!fd) { - return std::unexpected(std::format("RTMP connect failed: {}", fd.error())); - } - - auto handshake = run_handshake(*fd); - if (!handshake) { - close(*fd); - return std::unexpected(std::format("RTMP handshake failed: {}", handshake.error())); - } - - ParsedRtmpUrl parsed{}; - parsed.host = session.host; - parsed.port = session.port; - parsed.app = session.app; - parsed.stream = session.stream; - parsed.tc_url = session.tc_url; - - auto connect_message = send_connect_message(*fd, kDefaultChunkSize, parsed); - if (!connect_message) { - close(*fd); - return std::unexpected(std::format("RTMP connect-command failed: {}", connect_message.error())); - } - - auto create_stream = send_create_stream_message(*fd, kDefaultChunkSize); - if (!create_stream) { - close(*fd); - return std::unexpected(std::format("RTMP createStream failed: {}", create_stream.error())); - } - - auto publish_message = send_publish_message(*fd, kDefaultChunkSize, 1, session.stream); - if (!publish_message) { - close(*fd); - return std::unexpected(std::format("RTMP publish-command failed: {}", publish_message.error())); - } - - close_session(session); - session.socket_fd = *fd; - session.out_chunk_size = kDefaultChunkSize; - session.stream_id = 1; - session.sequence_header_sent = false; - session.consecutive_reconnect_failures = 0; - session.reconnect_backoff_ms = kReconnectBackoffInitialMs; - session.in_cooldown = false; - return {}; -} - -void RtmpPublisher::schedule_reconnect(Session &session, std::string_view reason, bool startup_path) { - close_session(session); - session.sequence_header_sent = false; - - const auto now = std::chrono::steady_clock::now(); - if (startup_path) { - session.reconnect_due_at = now; - session.in_cooldown = false; - } else { - session.reconnect_due_at = now + std::chrono::milliseconds(session.reconnect_backoff_ms); - session.in_cooldown = true; - } - - spdlog::warn( - "RTMP_SESSION_RECONNECT_SCHEDULED codec={} mode={} url={} reason='{}' cooldown_ms={} failures={}", - to_string(codec_), - to_string(mode_), - session.original_url, - reason, - startup_path ? 0u : session.reconnect_backoff_ms, - session.consecutive_reconnect_failures); -} - -std::expected RtmpPublisher::create(const RuntimeConfig &config) { - if (!config.outputs.rtmp.enabled) { - return std::unexpected("invalid RTMP publisher init: RTMP output disabled"); - } - if (config.outputs.rtmp.urls.empty()) { - return std::unexpected("invalid RTMP publisher init: no RTMP URL configured"); - } - - if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.encoder.codec != CodecType::H265) { - return std::unexpected( - "invalid mode matrix: --rtmp-mode domestic requires --codec h265 (h264+domestic is unsupported)"); - } - - spdlog::info( - "RTMP_MODE_SELECTED codec={} mode={} urls={}", - to_string(config.encoder.codec), - to_string(config.outputs.rtmp.mode), - config.outputs.rtmp.urls.size()); - - RtmpPublisher publisher{}; - publisher.codec_ = config.encoder.codec; - publisher.mode_ = config.outputs.rtmp.mode; - publisher.sessions_.reserve(config.outputs.rtmp.urls.size()); - - for (const auto &url : config.outputs.rtmp.urls) { - auto parsed = parse_rtmp_url(url); - if (!parsed) { - return std::unexpected(parsed.error()); - } - - Session session{}; - session.original_url = url; - session.host = parsed->host; - session.port = parsed->port; - session.app = parsed->app; - session.stream = parsed->stream; - session.tc_url = parsed->tc_url; - session.reconnect_backoff_ms = kReconnectBackoffInitialMs; - session.reconnect_due_at = std::chrono::steady_clock::now(); - - auto connect_result = publisher.connect_session(session); - if (!connect_result) { - return std::unexpected(std::format( - "RTMP initial session setup failed for '{}': {}", - url, - connect_result.error())); - } - publisher.sessions_.push_back(std::move(session)); - - spdlog::info( - "RTMP_SESSION_READY codec={} mode={} url={} app={} stream={}", - to_string(publisher.codec_), - to_string(publisher.mode_), - url, - parsed->app, - parsed->stream); - } - - return publisher; -} - -std::expected -RtmpPublisher::publish_access_unit(std::span access_unit, std::uint64_t pts_ns) { - stats_.access_units += 1; - stats_.access_unit_bytes += access_unit.size(); - - const auto now = std::chrono::steady_clock::now(); - std::size_t connected_sessions{0}; - for (auto &session : sessions_) { - if (session.socket_fd >= 0) { - connected_sessions += 1; - continue; - } - - if (session.in_cooldown && now < session.reconnect_due_at) { - continue; - } - - stats_.reconnect_attempts += 1; - spdlog::info( - "RTMP_SESSION_RECONNECT_ATTEMPT codec={} mode={} url={} attempt={} failures={} cooldown_elapsed_ms={}", - to_string(codec_), - to_string(mode_), - session.original_url, - stats_.reconnect_attempts, - session.consecutive_reconnect_failures, - session.in_cooldown ? session.reconnect_backoff_ms : 0u); - - auto reconnect = connect_session(session); - if (!reconnect) { - stats_.reconnect_failures += 1; - session.consecutive_reconnect_failures += 1; - session.reconnect_backoff_ms = std::min( - kReconnectBackoffMaxMs, - std::max(kReconnectBackoffInitialMs, session.reconnect_backoff_ms * 2)); - schedule_reconnect(session, reconnect.error(), false); - continue; - } - - stats_.reconnect_successes += 1; - connected_sessions += 1; - spdlog::info( - "RTMP_SESSION_RECONNECTED codec={} mode={} url={} successes={} failures={}", - to_string(codec_), - to_string(mode_), - session.original_url, - stats_.reconnect_successes, - session.consecutive_reconnect_failures); - } - - if (access_unit.empty()) { - return {}; - } - - if (connected_sessions == 0) { - if (!warned_all_sessions_closed_) { - spdlog::warn( - "RTMP_PUBLISH_STOPPED codec={} mode={} reason='no active RTMP sessions' reconnect_attempts={} reconnect_successes={} reconnect_failures={}", - to_string(codec_), - to_string(mode_), - stats_.reconnect_attempts, - stats_.reconnect_successes, - stats_.reconnect_failures); - warned_all_sessions_closed_ = true; - } - return {}; - } - - const std::uint32_t timestamp_ms = to_rtmp_timestamp_ms(pts_ns); - - std::size_t index{0}; - while (index < sessions_.size()) { - auto &session = sessions_[index]; - if (session.socket_fd < 0) { - ++index; - continue; - } - - if (!session.sequence_header_sent) { - std::vector sequence_header{}; - if (codec_ == CodecType::H264) { - sequence_header = make_h264_sequence_header(); - } else { - if (mode_ == RtmpMode::Enhanced) { - sequence_header = make_h265_enhanced_sequence_header(); - } else if (mode_ == RtmpMode::Domestic) { - sequence_header = make_h265_domestic_sequence_header(); - } else { - return std::unexpected(std::format( - "unsupported RTMP mode '{}' for codec '{}'", - to_string(mode_), - to_string(codec_))); - } - } - - auto config_send = send_rtmp_message( - session.socket_fd, - kChunkStreamVideo, - timestamp_ms, - kMsgVideo, - session.stream_id, - sequence_header, - session.out_chunk_size); - if (!config_send) { - stats_.send_errors += 1; - stats_.publish_failures += 1; - session.consecutive_reconnect_failures += 1; - session.reconnect_backoff_ms = std::min( - kReconnectBackoffMaxMs, - std::max(kReconnectBackoffInitialMs, session.reconnect_backoff_ms * 2)); - if (stats_.send_errors <= kErrorLogFirstPackets || (stats_.send_errors % kErrorLogEveryNPackets) == 0) { - spdlog::warn( - "RTMP_SEND_ERROR codec={} mode={} url={} stage=sequence-header detail='{}' send_errors={}", - to_string(codec_), - to_string(mode_), - session.original_url, - config_send.error(), - stats_.send_errors); - } - schedule_reconnect(session, config_send.error(), false); - continue; - } - - stats_.video_messages += 1; - stats_.bytes_sent += *config_send; - session.sequence_header_sent = true; - } - - std::vector frame_payload{}; - if (codec_ == CodecType::H264) { - frame_payload = make_h264_video_payload(access_unit); - } else { - if (mode_ == RtmpMode::Enhanced) { - frame_payload = make_h265_enhanced_video_payload(access_unit); - } else if (mode_ == RtmpMode::Domestic) { - frame_payload = make_h265_domestic_video_payload(access_unit); - } else { - return std::unexpected(std::format( - "unsupported RTMP mode '{}' for codec '{}'", - to_string(mode_), - to_string(codec_))); - } - } - - auto frame_send = send_rtmp_message( - session.socket_fd, - kChunkStreamVideo, - timestamp_ms, - kMsgVideo, - session.stream_id, - frame_payload, - session.out_chunk_size); - if (!frame_send) { - stats_.send_errors += 1; - stats_.publish_failures += 1; - session.consecutive_reconnect_failures += 1; - session.reconnect_backoff_ms = std::min( - kReconnectBackoffMaxMs, - std::max(kReconnectBackoffInitialMs, session.reconnect_backoff_ms * 2)); - if (stats_.send_errors <= kErrorLogFirstPackets || (stats_.send_errors % kErrorLogEveryNPackets) == 0) { - spdlog::warn( - "RTMP_SEND_ERROR codec={} mode={} url={} stage=video-frame detail='{}' send_errors={}", - to_string(codec_), - to_string(mode_), - session.original_url, - frame_send.error(), - stats_.send_errors); - } - schedule_reconnect(session, frame_send.error(), false); - continue; - } - - stats_.video_messages += 1; - stats_.bytes_sent += *frame_send; - had_successful_video_message_ = true; - session.consecutive_reconnect_failures = 0; - session.reconnect_backoff_ms = kReconnectBackoffInitialMs; - session.in_cooldown = false; - ++index; - } - - const bool any_connected = std::any_of(sessions_.begin(), sessions_.end(), [](const Session &session) { - return session.socket_fd >= 0; - }); - - if (!any_connected && !had_successful_video_message_ && !warned_all_sessions_closed_) { - spdlog::warn( - "RTMP_EARLY_DISCONNECT codec={} mode={} reason='all sessions disconnected before first video delivery; keeping publisher alive for reconnect/backoff'", - to_string(codec_), - to_string(mode_)); - } - - if (any_connected) { - warned_all_sessions_closed_ = false; - } - - return {}; -} - -const RtmpPublisherStats &RtmpPublisher::stats() const { - return stats_; -} - -void RtmpPublisher::on_stream_reset() { - spdlog::info("RTMP_STREAM_RESET codec={} mode={} sessions={}", to_string(codec_), to_string(mode_), sessions_.size()); - for (auto &session : sessions_) { - session.sequence_header_sent = false; - if (session.socket_fd >= 0) { - spdlog::info("RTMP_STREAM_RESET_REBASE url={} action=force_sequence_header", session.original_url); - } - } -} - -void RtmpPublisher::log_metrics() const { - spdlog::info( - "RTMP_METRICS codec={} mode={} sessions={} access_units={} access_unit_bytes={} video_messages={} bytes_sent={} send_errors={} publish_failures={} reconnect_attempts={} reconnect_successes={} reconnect_failures={}", - to_string(codec_), - to_string(mode_), - sessions_.size(), - stats_.access_units, - stats_.access_unit_bytes, - stats_.video_messages, - stats_.bytes_sent, - stats_.send_errors, - stats_.publish_failures, - stats_.reconnect_attempts, - stats_.reconnect_successes, - stats_.reconnect_failures); -} - -} diff --git a/src/testers/rtmp_output_tester.cpp b/src/testers/rtmp_output_tester.cpp index b1e8702..1103c8d 100644 --- a/src/testers/rtmp_output_tester.cpp +++ b/src/testers/rtmp_output_tester.cpp @@ -55,7 +55,7 @@ std::expected parse_args(int argc, char **argv) { CLI::App app{"rtmp_output_tester - publish synthetic encoded video to RTMP using the configured sink"}; app.add_option("--rtmp-url", config.rtmp_url, "RTMP destination URL")->required(); app.add_option("--transport", config.transport, "RTMP transport backend (libavformat|ffmpeg_process)") - ->check(CLI::IsMember({"libavformat", "ffmpeg_process", "legacy_custom"})); + ->check(CLI::IsMember({"libavformat", "ffmpeg_process"})); app.add_option("--codec", config.codec, "Video codec (h264|h265)") ->check(CLI::IsMember({"h264", "h265"})); app.add_option("--encoder-device", config.encoder_device, "Encoder device (auto|nvidia|software)") @@ -95,9 +95,6 @@ std::expected parse_transport(s if (raw == "ffmpeg_process") { return cvmmap_streamer::RtmpTransportType::FfmpegProcess; } - if (raw == "legacy_custom") { - return cvmmap_streamer::RtmpTransportType::LegacyCustom; - } return std::unexpected("unsupported transport"); } @@ -163,10 +160,6 @@ int main(int argc, char **argv) { config.outputs.rtmp.transport = *transport; config.outputs.rtmp.ffmpeg_path = args->ffmpeg_path; - if (config.outputs.rtmp.transport == cvmmap_streamer::RtmpTransportType::LegacyCustom) { - config.encoder.backend = cvmmap_streamer::EncoderBackendType::GStreamerLegacy; - } - cvmmap_streamer::ipc::FrameInfo frame_info{ .width = static_cast(args->width), .height = static_cast(args->height), diff --git a/src/testers/rtmp_stub_tester.cpp b/src/testers/rtmp_stub_tester.cpp index 86f24ba..5cffb92 100644 --- a/src/testers/rtmp_stub_tester.cpp +++ b/src/testers/rtmp_stub_tester.cpp @@ -61,14 +61,12 @@ enum class ExitCode : int { enum class ExpectMode { H264, H265Enhanced, - H265Domestic, }; enum class VideoSignal { Unknown, H264, H265Enhanced, - H265Domestic, }; struct Config { @@ -97,7 +95,6 @@ struct Stats { std::uint32_t h264_video_messages{0}; std::uint32_t h265_enhanced_video_messages{0}; - std::uint32_t h265_domestic_video_messages{0}; std::uint32_t unknown_video_messages{0}; bool mode_mismatch{false}; @@ -184,8 +181,6 @@ std::string_view to_string(ExpectMode mode) { return "h264"; case ExpectMode::H265Enhanced: return "h265-enhanced"; - case ExpectMode::H265Domestic: - return "h265-domestic"; default: return "unknown"; } @@ -198,8 +193,6 @@ std::string_view to_string(VideoSignal signal) { return "h264"; case VideoSignal::H265Enhanced: return "h265-enhanced"; - case VideoSignal::H265Domestic: - return "h265-domestic"; case VideoSignal::Unknown: default: return "unknown"; @@ -214,11 +207,8 @@ std::expected parse_mode(std::string_view raw) { if (raw == "h265-enhanced") { return ExpectMode::H265Enhanced; } - if (raw == "h265-domestic") { - return ExpectMode::H265Domestic; - } return std::unexpected(std::format( - "invalid mode '{}'; expected: h264 | h265-enhanced | h265-domestic", + "invalid mode '{}'; expected: h264 | h265-enhanced", raw)); } @@ -227,7 +217,7 @@ std::expected parse_args(int argc, char **argv) { Config config; std::string mode_raw; std::string self_test_send_mode_raw; - const std::vector accepted_modes{"h264", "h265-enhanced", "h265-domestic"}; + const std::vector accepted_modes{"h264", "h265-enhanced"}; CLI::App app{"rtmp_stub_tester - standalone RTMP ingest validator"}; app.allow_extras(false); @@ -1176,9 +1166,6 @@ VideoSignal classify_video_packet(std::span payload) { if (codec_id == 7) { return VideoSignal::H264; } - if (codec_id == 12) { - return VideoSignal::H265Domestic; - } if ((first & 0x80) != 0 && payload.size() >= 5) { const std::array hvc1{'h', 'v', 'c', '1'}; @@ -1206,9 +1193,6 @@ void update_mode_stats( case VideoSignal::H265Enhanced: stats.h265_enhanced_video_messages++; break; - case VideoSignal::H265Domestic: - stats.h265_domestic_video_messages++; - break; case VideoSignal::Unknown: default: stats.unknown_video_messages++; @@ -1226,9 +1210,6 @@ void update_mode_stats( if (expected == ExpectMode::H265Enhanced && actual != VideoSignal::H265Enhanced) { mismatch = true; } - if (expected == ExpectMode::H265Domestic && actual != VideoSignal::H265Domestic) { - mismatch = true; - } if (!mismatch) { return; @@ -1252,8 +1233,6 @@ std::uint32_t matching_count(const Stats &stats, ExpectMode mode) { return stats.h264_video_messages; case ExpectMode::H265Enhanced: return stats.h265_enhanced_video_messages; - case ExpectMode::H265Domestic: - return stats.h265_domestic_video_messages; default: return 0; } @@ -1663,31 +1642,6 @@ send_client_video_mode_packets(int fd, std::uint32_t chunk_size, std::uint32_t s }; break; } - case ExpectMode::H265Domestic: { - config_payload = { - 0x1c, - 0x00, - 0x00, - 0x00, - 0x00, - 0x01, - 0x01, - 0x60, - }; - frame_payload = { - 0x2c, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x01, - 0x26, - }; - break; - } default: return std::unexpected("unsupported self-test mode"); } @@ -1778,10 +1732,9 @@ void print_summary(const Config &config, const Stats &stats) { stats.total_video_messages, stats.total_data_messages, stats.set_chunk_size_messages); - spdlog::info("Video signaling counts: h264={}, h265-enhanced={}, h265-domestic={}, unknown={}", + spdlog::info("Video signaling counts: h264={}, h265-enhanced={}, unknown={}", stats.h264_video_messages, stats.h265_enhanced_video_messages, - stats.h265_domestic_video_messages, stats.unknown_video_messages); spdlog::info("Matching count for expected mode: {} (threshold={})", matching_count(stats, config.expect_mode), diff --git a/src/testers/rtp_output_tester.cpp b/src/testers/rtp_output_tester.cpp new file mode 100644 index 0000000..a5cfece --- /dev/null +++ b/src/testers/rtp_output_tester.cpp @@ -0,0 +1,214 @@ +#include "cvmmap_streamer/config/runtime_config.hpp" +#include "cvmmap_streamer/encode/encoder_backend.hpp" +#include "cvmmap_streamer/ipc/contracts.hpp" +#include "cvmmap_streamer/protocol/rtp_publisher.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +enum class TesterExitCode : int { + Success = 0, + InvalidArgument = 2, + BackendSelectionError = 3, + BackendInitError = 4, + PublisherInitError = 5, + PushError = 6, + DrainError = 7, + FlushError = 8, +}; + +[[nodiscard]] +constexpr int exit_code(TesterExitCode code) { + return static_cast(code); +} + +struct Config { + std::string host{"127.0.0.1"}; + std::uint16_t port{5004}; + std::uint32_t payload_type{96}; + std::string codec{"h264"}; + std::string encoder_device{"software"}; + std::string sdp_path{}; + std::uint32_t frames{48}; + std::uint32_t width{320}; + std::uint32_t height{240}; + std::uint32_t frame_interval_ms{33}; +}; + +[[nodiscard]] +std::expected parse_args(int argc, char **argv) { + Config config{}; + CLI::App app{"rtp_output_tester - publish synthetic encoded video to RTP using the FFmpeg encoder path"}; + app.add_option("--host", config.host, "RTP destination host")->required(); + app.add_option("--port", config.port, "RTP destination port")->required()->check(CLI::Range(1, 65535)); + app.add_option("--payload-type", config.payload_type, "RTP payload type (96-127)")->check(CLI::Range(96, 127)); + app.add_option("--codec", config.codec, "Video codec (h264|h265)") + ->check(CLI::IsMember({"h264", "h265"})); + app.add_option("--encoder-device", config.encoder_device, "Encoder device (auto|nvidia|software)") + ->check(CLI::IsMember({"auto", "nvidia", "software"})); + app.add_option("--sdp-path", config.sdp_path, "Optional SDP output path"); + app.add_option("--frames", config.frames, "Number of frames to publish")->check(CLI::PositiveNumber); + app.add_option("--width", config.width, "Frame width")->check(CLI::PositiveNumber); + app.add_option("--height", config.height, "Frame height")->check(CLI::PositiveNumber); + app.add_option("--frame-interval-ms", config.frame_interval_ms, "Frame interval in milliseconds")->check(CLI::PositiveNumber); + + try { + app.parse(argc, argv); + } catch (const CLI::ParseError &e) { + return std::unexpected(app.exit(e)); + } + + return config; +} + +[[nodiscard]] +std::expected parse_codec(std::string_view raw) { + if (raw == "h264") { + return cvmmap_streamer::CodecType::H264; + } + if (raw == "h265") { + return cvmmap_streamer::CodecType::H265; + } + return std::unexpected("unsupported codec"); +} + +[[nodiscard]] +std::expected parse_encoder_device(std::string_view raw) { + if (raw == "auto") { + return cvmmap_streamer::EncoderDeviceType::Auto; + } + if (raw == "nvidia") { + return cvmmap_streamer::EncoderDeviceType::Nvidia; + } + if (raw == "software") { + return cvmmap_streamer::EncoderDeviceType::Software; + } + return std::unexpected("unsupported encoder device"); +} + +void fill_pattern(std::vector &buffer, std::uint32_t width, std::uint32_t height, std::uint32_t frame_index) { + for (std::uint32_t y = 0; y < height; ++y) { + for (std::uint32_t x = 0; x < width; ++x) { + const std::size_t pixel = static_cast(y) * width * 3 + static_cast(x) * 3; + buffer[pixel + 0] = static_cast((x + frame_index * 3) & 0xffu); + buffer[pixel + 1] = static_cast((y * 2 + frame_index * 5) & 0xffu); + buffer[pixel + 2] = static_cast(((x + y) / 2 + frame_index * 7) & 0xffu); + } + } +} + +} + +int main(int argc, char **argv) { + auto args = parse_args(argc, argv); + if (!args) { + return args.error(); + } + + auto codec = parse_codec(args->codec); + if (!codec) { + spdlog::error("{}", codec.error()); + return exit_code(TesterExitCode::InvalidArgument); + } + + auto encoder_device = parse_encoder_device(args->encoder_device); + if (!encoder_device) { + spdlog::error("{}", encoder_device.error()); + return exit_code(TesterExitCode::InvalidArgument); + } + + cvmmap_streamer::RuntimeConfig config = cvmmap_streamer::RuntimeConfig::defaults(); + config.encoder.backend = cvmmap_streamer::EncoderBackendType::FFmpeg; + config.encoder.device = *encoder_device; + config.encoder.codec = *codec; + config.encoder.gop = 15; + config.encoder.b_frames = 0; + config.outputs.rtp.enabled = true; + config.outputs.rtp.endpoint = args->host + ":" + std::to_string(args->port); + config.outputs.rtp.host = args->host; + config.outputs.rtp.port = args->port; + config.outputs.rtp.payload_type = static_cast(args->payload_type); + if (!args->sdp_path.empty()) { + config.outputs.rtp.sdp_path = args->sdp_path; + } + + cvmmap_streamer::ipc::FrameInfo frame_info{ + .width = static_cast(args->width), + .height = static_cast(args->height), + .channels = 3, + .depth = cvmmap_streamer::ipc::Depth::U8, + .pixel_format = cvmmap_streamer::ipc::PixelFormat::BGR, + .buffer_size = args->width * args->height * 3, + }; + + auto backend = cvmmap_streamer::encode::make_encoder_backend(config); + if (!backend) { + spdlog::error("failed to select encoder backend: {}", cvmmap_streamer::format_error(backend.error())); + return exit_code(TesterExitCode::BackendSelectionError); + } + + auto init = (*backend)->init(config, frame_info); + if (!init) { + spdlog::error("failed to initialize encoder backend: {}", cvmmap_streamer::format_error(init.error())); + return exit_code(TesterExitCode::BackendInitError); + } + + auto publisher = cvmmap_streamer::protocol::UdpRtpPublisher::create(config); + if (!publisher) { + spdlog::error("failed to initialize RTP publisher: {}", publisher.error()); + return exit_code(TesterExitCode::PublisherInitError); + } + + std::vector frame_bytes(frame_info.buffer_size, 0); + const auto frame_interval = std::chrono::milliseconds(args->frame_interval_ms); + std::uint64_t timestamp_ns{0}; + + for (std::uint32_t frame_index = 0; frame_index < args->frames; ++frame_index) { + fill_pattern(frame_bytes, args->width, args->height, frame_index); + + auto push = (*backend)->push_frame(cvmmap_streamer::encode::RawVideoFrame{ + .info = frame_info, + .source_timestamp_ns = timestamp_ns, + .bytes = std::span(frame_bytes.data(), frame_bytes.size()), + }); + if (!push) { + spdlog::error("encoder push failed at frame {}: {}", frame_index, cvmmap_streamer::format_error(push.error())); + return exit_code(TesterExitCode::PushError); + } + + auto drained = (*backend)->drain(); + if (!drained) { + spdlog::error("encoder drain failed at frame {}: {}", frame_index, cvmmap_streamer::format_error(drained.error())); + return exit_code(TesterExitCode::DrainError); + } + for (const auto &access_unit : *drained) { + publisher->publish_access_unit(access_unit.annexb_bytes, access_unit.stream_pts_ns); + } + + std::this_thread::sleep_for(frame_interval); + timestamp_ns += static_cast(args->frame_interval_ms) * 1'000'000ull; + } + + auto flushed = (*backend)->flush(); + if (!flushed) { + spdlog::error("encoder flush failed: {}", cvmmap_streamer::format_error(flushed.error())); + return exit_code(TesterExitCode::FlushError); + } + for (const auto &access_unit : *flushed) { + publisher->publish_access_unit(access_unit.annexb_bytes, access_unit.stream_pts_ns); + } + + publisher->log_metrics(); + (*backend)->shutdown(); + return exit_code(TesterExitCode::Success); +}