refactor(streamer): remove gstreamer and legacy rtmp paths

This commit is contained in:
2026-03-11 16:43:29 +08:00
parent ed3f32ff6e
commit 782af9481c
22 changed files with 817 additions and 3339 deletions
+1 -22
View File
@@ -6,8 +6,6 @@ set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
option(ENABLE_GSTREAMER_LEGACY "Build the optional legacy GStreamer backend" ON)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
find_package(cppzmq QUIET) find_package(cppzmq QUIET)
if ( 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}") message(FATAL_ERROR "proxy headers not found at ${CVMMAP_PROXY_INCLUDE_DIR}")
endif() 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) add_library(cvmmap_streamer_foxglove_proto STATIC)
protobuf_generate( protobuf_generate(
TARGET cvmmap_streamer_foxglove_proto TARGET cvmmap_streamer_foxglove_proto
@@ -101,11 +89,9 @@ add_library(cvmmap_streamer_common STATIC
src/metrics/latency_tracker.cpp src/metrics/latency_tracker.cpp
src/pipeline/pipeline_runtime.cpp src/pipeline/pipeline_runtime.cpp
src/protocol/rtmp_output.cpp src/protocol/rtmp_output.cpp
src/protocol/rtmp_publisher.cpp
src/protocol/rtp_publisher.cpp src/protocol/rtp_publisher.cpp
src/encode/encoder_backend.cpp src/encode/encoder_backend.cpp
src/encode/ffmpeg_encoder_backend.cpp src/encode/ffmpeg_encoder_backend.cpp
src/encode/gstreamer_legacy_backend.cpp
src/record/protobuf_descriptor.cpp src/record/protobuf_descriptor.cpp
src/record/mcap_record_sink.cpp) src/record/mcap_record_sink.cpp)
@@ -118,10 +104,6 @@ target_include_directories(cvmmap_streamer_common
"${CVMMAP_PROXY_INCLUDE_DIR}" "${CVMMAP_PROXY_INCLUDE_DIR}"
"${CMAKE_CURRENT_BINARY_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 set(CVMMAP_STREAMER_LINK_DEPS
Threads::Threads Threads::Threads
cvmmap_streamer_foxglove_proto cvmmap_streamer_foxglove_proto
@@ -157,10 +139,6 @@ if (TARGET PkgConfig::PROTOBUF_PKG)
list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::PROTOBUF_PKG) list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::PROTOBUF_PKG)
endif() 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}) target_link_libraries(cvmmap_streamer_common PUBLIC ${CVMMAP_STREAMER_LINK_DEPS})
function(add_cvmmap_binary target source) function(add_cvmmap_binary target source)
@@ -181,6 +159,7 @@ endfunction()
add_cvmmap_binary(cvmmap_streamer src/main_streamer.cpp) 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_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_stub_tester src/testers/rtmp_stub_tester.cpp)
add_cvmmap_binary(rtmp_output_tester src/testers/rtmp_output_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) add_cvmmap_binary(ipc_snapshot_tester src/testers/ipc_snapshot_tester.cpp)
+50 -239
View File
@@ -1,286 +1,97 @@
# Operational Caveats # 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. The runtime ingests from a cvmmap URI, for example:
**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:
```bash ```bash
# Start stub expecting domestic mode ./build/cvmmap_streamer --run-mode pipeline --input-uri 'cvmmap://zed@/tmp/cvmmap'
./build/rtmp_stub_tester --mode h265-domestic ...
# Streamer sends enhanced mode
./build/cvmmap_streamer --codec h265 --rtmp-mode enhanced ...
# Result: tester exits 6 (ModeMismatch)
``` ```
## 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 | ### FFmpeg Is The Only Encoder Backend
|---------|---------|----------|
| `--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) |
### Encoder Settings The public backend surface is:
| Setting | Low-Latency Value | Trade-off | - `--encoder-backend auto`
|---------|-------------------|-----------| - `--encoder-backend ffmpeg`
| `--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 |
### 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:** When `--encoder-device nvidia` is selected, FFmpeg must expose `h264_nvenc` and `hevc_nvenc`.
```
publish { mr off; } Useful local checks:
play { gop_cache off; queue_length 5; tcp_nodelay on; }
```bash
ffmpeg -hide_banner -encoders | rg 'nvenc|libx264|libx265'
``` ```
**Trade-offs:** If NVENC is unavailable, use:
- `gop_cache off`: Players wait for next I-frame (startup delay)
- `mr off`: Higher CPU usage
- `queue_length 5`: More susceptible to network jitter
## Fault Scenario Behaviors ```bash
--encoder-device software
### 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
``` ```
### Stream Reset Handling ### Low-Latency Defaults
When `MODULE_STATUS_STREAM_RESET` is received: The current low-latency defaults are:
1. Ingest queue is flushed - `gop=30`
2. Frame counters reset - `b_frames=0`
3. Pipeline may rebuild if resolution/format changed - NVENC preset/tune tuned for low latency
4. RTMP publishers send sequence header rebase
5. `resets` counter increments
**Expected log:** This favors immediacy over compression efficiency.
```
STREAM_RESET_RECEIVED
RTMP_STREAM_RESET_REBASE mode=<mode>
```
### Backpressure Containment ## Transport Caveats
When downstream sinks are slower than ingest: ### RTMP Is Enhanced RTMP Only
1. Queue fills to capacity The repo now supports:
2. Oldest frames evicted before push
3. `dropped_frames` counter increments
4. Latest frame always prioritized
5. Latency remains bounded
**Expected log:** - `libavformat` RTMP output
``` - `ffmpeg_process` RTMP output
QUEUE_DROP dropped_frames=N queue_depth=1
```
## 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 ### 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 ### Local SRS Defaults Can Hit `ulimit`
export SPDLOG_LEVEL=debug
./build/cvmmap_streamer ...
```
### 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 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.
# 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)
```
+52 -166
View File
@@ -1,188 +1,74 @@
# Compatibility Matrix # 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 | Covered rows:
|-----|----------|-------|-----------|--------------|----------|
| 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 |
**Pass Criteria:** | Row | Protocol | Codec | Transport | Runner |
- Exit code 0 from `acceptance_standalone.sh` |-----|----------|-------|-----------|--------|
- JSON summary shows `total=5 pass=5 fail=0 skip=0` | 1 | RTP | H.264 | UDP RTP | `./scripts/acceptance_standalone.sh` |
- All tester processes receive expected packet/frame counts | 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 | - RTMP is Enhanced RTMP only.
|----------|-------------|--------------|----------| - The custom RTMP packetizer and domestic mode are removed.
| Torn Read | Coherent snapshot rejects torn frames | `./scripts/fault_suite.sh` | task-15-fault-suite.txt | - `encoder.backend` remains `auto|ffmpeg`; both resolve to FFmpeg.
| 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 |
## Optional Checks (Non-Blocking) ## 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 '<cvmmap-uri>'`
- manual WHEP playback through SRS
- manual ZLMediaKit interoperability checks
- live MCAP capture, validation, and paced replay
| Server | Protocols | H.265 Support | Status | ## Recording Coverage
|--------|-----------|---------------|--------|
| SRS 6.0+ | RTMP, HTTP-FLV, HLS, WebRTC | Enhanced-RTMP | Optional |
| ZLMediaKit | RTMP, HTTP-FLV, HTTP-TS, RTSP, WebRTC | Enhanced + Domestic | Optional |
**Skip Conditions:** MCAP support is validated separately with:
- Docker not available
- Port 1935 in use by another service
- Server container fails to start
- Network isolation prevents connection
**Expected Behavior When Skipped:** - `./build/mcap_reader_tester`
- Script exits with status 0 (SKIP) - `./build/mcap_replay_tester`
- Evidence file notes the skip reason - `./scripts/replay_mcap.sh`
- Mandatory acceptance still proceeds
## 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:** | Setting | Value |
- Video codec header byte: `0x90` (sequence) / `0x91` (frame) |---------|-------|
- FourCC: `hvc1` or `hev1` | Encoder backend | `auto` -> FFmpeg |
- Standardized, widely supported | RTMP transport | `libavformat` |
- FFmpeg 6.0+ native support | RTMP mode | Enhanced only |
- OBS 29+ support | Encoder device | `auto` |
- SRS 6.0+ native support | Low-latency GOP | `30` |
- ZLMediaKit default mode | B-frames | `0` |
**When to use:** ## What Is No Longer Supported
- Greenfield deployments
- Modern CDN ingestion
- Cross-platform compatibility requirements
### Domestic Extension Mode (Legacy Compatibility) - GStreamer encoder backend
- custom RTMP packetizer
**Specification:** Proprietary FLV extension using codec-id 12 - RTMP domestic mode
- dummy input flags in the main runtime
**Characteristics:** - direct in-repo RTSP/WebRTC publishing
- 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 |
+1 -1
View File
@@ -1,7 +1,7 @@
# Local SRS config for manual RTMP and WHEP testing with cvmmap-streamer. # Local SRS config for manual RTMP and WHEP testing with cvmmap-streamer.
# Start with: # Start with:
# cd ~/Code/srs/trunk # 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, # If WebRTC/WHEP is consumed from another host and SRS chooses the wrong NIC,
# replace `candidate *;` with the reachable host IP. # replace `candidate *;` with the reachable host IP.
+9 -39
View File
@@ -21,8 +21,6 @@ Use these checks when you want to verify RTMP interoperability or a real cvmmap-
## Reproducible Test: Synthetic RTMP Matrix ## Reproducible Test: Synthetic RTMP Matrix
This is the fast interoperability smoke for the RTMP sink implementations:
```bash ```bash
cmake -B build -S . cmake -B build -S .
cmake --build build cmake --build build
@@ -42,12 +40,10 @@ What it verifies:
- `ffmpeg_process` RTMP output with `h264` - `ffmpeg_process` RTMP output with `h264`
- `libavformat` RTMP output with `h265` - `libavformat` RTMP output with `h265`
- `ffmpeg_process` 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 ## Reproducible Test: Live cvmmap Forward
This is the end-to-end live test for a real cvmmap source:
```bash ```bash
cmake -B build -S . cmake -B build -S .
cmake --build build cmake --build build
@@ -60,7 +56,6 @@ The script defaults to:
- `ENCODER_BACKEND=ffmpeg` - `ENCODER_BACKEND=ffmpeg`
- `ENCODER_DEVICE=nvidia` - `ENCODER_DEVICE=nvidia`
- `RTMP_TRANSPORT=libavformat` - `RTMP_TRANSPORT=libavformat`
- `RTMP_MODE=enhanced`
- `INGEST_MAX_FRAMES=120` - `INGEST_MAX_FRAMES=120`
- `STREAM_NAME=<instance>_live`, derived from `INPUT_URI` - `STREAM_NAME=<instance>_live`, derived from `INPUT_URI`
@@ -69,7 +64,7 @@ Useful overrides:
```bash ```bash
INPUT_URI='cvmmap://zed@/tmp/cvmmap' ./scripts/live_srs_forward_smoke.sh 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 ./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' 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' 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` 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` 2. Otherwise starts SRS from `~/Code/srs`
3. Writes a temporary SRS config with: 3. Writes a temporary SRS config with `daemon off` and a lowered `max_connections`
- `daemon off`
- `max_connections` reduced to avoid the common local `ulimit -n 1024` failure
4. Publishes the cvmmap stream to `rtmp://127.0.0.1/live/<stream>` 4. Publishes the cvmmap stream to `rtmp://127.0.0.1/live/<stream>`
5. Verifies the forwarded HTTP-FLV mount at `http://127.0.0.1:8080/live/<stream>.flv` 5. Verifies the forwarded HTTP-FLV mount at `http://127.0.0.1:8080/live/<stream>.flv`
6. Attempts to decode a short sample through `ffmpeg` 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. 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` - RTMP publish on `:1935`
- HTTP API on `:1985` - HTTP API on `:1985`
@@ -111,14 +104,14 @@ Start SRS with that config:
```bash ```bash
cd ~/Code/srs/trunk cd ~/Code/srs/trunk
./objs/srs -t -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-ffmpeg-mcap/docs/smoke/srs.local.conf ./objs/srs -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf
``` ```
Publish from the streamer: Publish from the streamer:
```bash ```bash
cd ~/Code/cvmmap-streamer-ffmpeg-mcap cd ~/Code/cvmmap-streamer
./build/cvmmap_streamer \ ./build/cvmmap_streamer \
--run-mode pipeline \ --run-mode pipeline \
--input-uri 'cvmmap://zed@/tmp/cvmmap' \ --input-uri 'cvmmap://zed@/tmp/cvmmap' \
@@ -127,8 +120,7 @@ cd ~/Code/cvmmap-streamer-ffmpeg-mcap
--encoder-device nvidia \ --encoder-device nvidia \
--rtmp \ --rtmp \
--rtmp-url 'rtmp://127.0.0.1/live/zed_live' \ --rtmp-url 'rtmp://127.0.0.1/live/zed_live' \
--rtmp-transport libavformat \ --rtmp-transport libavformat
--rtmp-mode enhanced
``` ```
Open the WHEP player page in a browser: 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. - 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. - 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. - 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.
- 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
```
## Artifacts ## Artifacts
@@ -165,15 +144,6 @@ Both smoke scripts write evidence under `build/`:
- `live_srs_forward_smoke_*/ffmpeg_decode_httpflv.log` - `live_srs_forward_smoke_*/ffmpeg_decode_httpflv.log`
- `live_srs_forward_smoke_*/srs_api_streams.json` - `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 ## References
- [SRS RTMP Documentation](https://ossrs.io/lts/en-us/docs/v7/doc/rtmp) - [SRS RTMP Documentation](https://ossrs.io/lts/en-us/docs/v7/doc/rtmp)
+48 -198
View File
@@ -4,230 +4,80 @@
**OPTIONAL / NON-BLOCKING** **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: - RTP
- RTMP H.264 publishing and playback - Enhanced RTMP through `libavformat` or `ffmpeg_process`
- Enhanced RTMP HEVC (H.265) support - MCAP recording
- Low-latency streaming configurations
- HTTP-FLV, HTTP-TS, and WebRTC protocols
--- 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 ## Quick Start
- FFmpeg or the project streaming binary
- Optional: ffplay, VLC for playback verification
--- Run ZLMediaKit in Docker:
## Quick Start (Docker)
```bash ```bash
# Run ZLMediaKit with default configuration docker run --rm -it \
docker run --rm -it -p 1935:1935 -p 8080:80 -p 8554:554 \ -p 1935:1935 \
-p 10000:10000 -p 30000-30500:30000-30500/udp \ -p 8080:80 \
zlmediakit/zlmediakit:master -p 8000:8000/udp \
# 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 \
zlmediakit/zlmediakit:master zlmediakit/zlmediakit:master
``` ```
--- ## Publish From `cvmmap-streamer`
## Smoke Commands
### 1. Basic RTMP H.264 Stream Test
```bash ```bash
# Publish test stream to ZLMediaKit cd ~/Code/cvmmap-streamer
ffmpeg -re -f lavfi -i testsrc=duration=60:size=1280x720:rate=30 \ ./build/cvmmap_streamer \
-f lavfi -i sine=frequency=1000:duration=60 \ --run-mode pipeline \
-pix_fmt yuv420p -c:v libx264 -preset fast -b:v 3000k \ --input-uri 'cvmmap://zed@/tmp/cvmmap' \
-c:a aac -b:a 128k -f flv rtmp://localhost/live/smoke_test --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 ```bash
# Playback via RTMP ./build/cvmmap_streamer \
ffplay rtmp://localhost/live/smoke_test --run-mode pipeline \
--input-uri 'cvmmap://zed@/tmp/cvmmap' \
# Playback via HTTP-FLV --codec h265 \
ffplay http://localhost:8080/live/smoke_test.live.flv --encoder-backend ffmpeg \
--encoder-device software \
# Playback via HTTP-TS --rtmp \
ffplay http://localhost:8080/live/smoke_test.live.ts --rtmp-url 'rtmp://127.0.0.1/live/zlm_smoke_h265' \
--rtmp-transport ffmpeg_process
``` ```
### 2. Enhanced RTMP HEVC (H.265) Test ## Playback Checks
**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
```
```bash ```bash
# Publish HEVC stream (Enhanced RTMP) ffplay rtmp://127.0.0.1/live/zlm_smoke
ffmpeg -re -f lavfi -i testsrc=duration=60:size=1280x720:rate=30 \ ffplay http://127.0.0.1:8080/live/zlm_smoke.live.flv
-f lavfi -i sine=frequency=1000:duration=60 \ ffplay http://127.0.0.1:8080/live/zlm_smoke.live.ts
-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
``` ```
```bash ## Notes
# 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
```
### 3. Low-Latency Configuration - Use Enhanced RTMP only.
- Prefer `h264` when testing browser-facing or WebRTC-facing downstream bridges.
For minimal latency with ZLMediaKit, modify config.ini: - Any WebRTC playback is provided by ZLMediaKit, not by this repo directly.
```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 |
---
## References ## References
- [ZLMediaKit GitHub](https://github.com/ZLMediaKit/ZLMediaKit) - [ZLMediaKit GitHub](https://github.com/ZLMediaKit/ZLMediaKit)
- [ZLMediaKit Wiki](https://github.com/ZLMediaKit/ZLMediaKit/wiki)
- [Enhanced RTMP Specification](https://github.com/veovera/enhanced-rtmp) - [Enhanced RTMP Specification](https://github.com/veovera/enhanced-rtmp)
@@ -22,19 +22,16 @@ enum class RunMode {
enum class RtmpMode { enum class RtmpMode {
Enhanced, Enhanced,
Domestic,
}; };
enum class RtmpTransportType { enum class RtmpTransportType {
Libavformat, Libavformat,
FfmpegProcess, FfmpegProcess,
LegacyCustom,
}; };
enum class EncoderBackendType { enum class EncoderBackendType {
Auto, Auto,
FFmpeg, FFmpeg,
GStreamerLegacy,
}; };
enum class EncoderDeviceType { enum class EncoderDeviceType {
@@ -66,7 +63,6 @@ struct RtmpOutputConfig {
std::vector<std::string> urls{}; std::vector<std::string> urls{};
RtmpTransportType transport{RtmpTransportType::Libavformat}; RtmpTransportType transport{RtmpTransportType::Libavformat};
std::string ffmpeg_path{"ffmpeg"}; std::string ffmpeg_path{"ffmpeg"};
RtmpMode mode{RtmpMode::Enhanced};
}; };
struct RtpOutputConfig { struct RtpOutputConfig {
@@ -1,84 +0,0 @@
#pragma once
#include "cvmmap_streamer/config/runtime_config.hpp"
#include <chrono>
#include <cstdint>
#include <expected>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace cvmmap_streamer::protocol {
struct RtmpPublisherStats {
std::uint64_t access_units{0};
std::uint64_t access_unit_bytes{0};
std::uint64_t video_messages{0};
std::uint64_t bytes_sent{0};
std::uint64_t send_errors{0};
std::uint64_t publish_failures{0};
std::uint64_t reconnect_attempts{0};
std::uint64_t reconnect_successes{0};
std::uint64_t reconnect_failures{0};
};
class RtmpPublisher {
public:
RtmpPublisher() = default;
~RtmpPublisher();
RtmpPublisher(const RtmpPublisher &) = delete;
RtmpPublisher &operator=(const RtmpPublisher &) = delete;
RtmpPublisher(RtmpPublisher &&other) noexcept;
RtmpPublisher &operator=(RtmpPublisher &&other) noexcept;
[[nodiscard]]
static std::expected<RtmpPublisher, std::string> create(const RuntimeConfig &config);
[[nodiscard]]
std::expected<void, std::string> publish_access_unit(std::span<const std::uint8_t> access_unit, std::uint64_t pts_ns);
[[nodiscard]]
const RtmpPublisherStats &stats() const;
void on_stream_reset();
void log_metrics() const;
private:
struct Session {
std::string original_url{};
std::string host{};
std::uint16_t port{1935};
std::string app{};
std::string stream{};
std::string tc_url{};
int socket_fd{-1};
std::uint32_t out_chunk_size{128};
std::uint32_t stream_id{1};
bool sequence_header_sent{false};
std::uint32_t reconnect_backoff_ms{250};
std::uint32_t consecutive_reconnect_failures{0};
std::chrono::steady_clock::time_point reconnect_due_at{};
bool in_cooldown{false};
};
[[nodiscard]]
std::expected<void, std::string> connect_session(Session &session);
void schedule_reconnect(Session &session, std::string_view reason, bool startup_path);
void close_session(Session &session);
CodecType codec_{CodecType::H264};
RtmpMode mode_{RtmpMode::Enhanced};
std::vector<Session> sessions_{};
RtmpPublisherStats stats_{};
bool had_successful_video_message_{false};
bool warned_all_sessions_closed_{false};
};
}
+205 -206
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -u -o pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@@ -11,11 +11,10 @@ SUMMARY_HELPER="${SCRIPT_DIR}/acceptance_summary_helper.py"
RUN_ID="" RUN_ID=""
RUN_DIR="" RUN_DIR=""
MANIFEST_TSV="${RUN_DIR}/rows.tsv" MANIFEST_TSV=""
SUMMARY_JSON="${RUN_DIR}/summary.json" SUMMARY_JSON=""
LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-14-acceptance-summary.json" LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-14-acceptance-summary.json"
EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-14-acceptance.txt" EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-14-acceptance.txt"
STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
mkdir -p "${TASK_EVIDENCE_DIR}" mkdir -p "${TASK_EVIDENCE_DIR}"
@@ -47,7 +46,7 @@ PORT_OFFSET="$((RUN_HASH % 1000))"
RTP_PORT_BASE="$((51040 + PORT_OFFSET))" RTP_PORT_BASE="$((51040 + PORT_OFFSET))"
RTMP_PORT_BASE="$((19360 + 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=() cleanup_pids=()
@@ -55,6 +54,7 @@ cleanup_all() {
for pid in "${cleanup_pids[@]:-}"; do for pid in "${cleanup_pids[@]:-}"; do
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
kill "${pid}" 2>/dev/null || true kill "${pid}" 2>/dev/null || true
wait "${pid}" 2>/dev/null || true
fi fi
done done
} }
@@ -90,178 +90,192 @@ append_manifest_row() {
local name="$3" local name="$3"
local protocol="$4" local protocol="$4"
local codec="$5" local codec="$5"
local rtmp_mode="$6" local transport="$6"
local status="$7" local status="$7"
local reason="$8" local reason="$8"
local duration_ms="$9" local duration_ms="$9"
local sim_rc="${10}" local emitter_rc="${10}"
local streamer_rc="${11}" local receiver_rc="${11}"
local tester_rc="${12}" local emitter_log="${12}"
local sim_log="${13}" local receiver_log="${13}"
local streamer_log="${14}" local sdp_path="${14}"
local tester_log="${15}"
local sdp_path="${16}"
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 order="$1"
local row_id="$2" local row_id="$2"
local name="$3" local codec="$3"
local protocol="$4"
local codec="$5"
local rtmp_mode="$6"
local row_dir="${RUN_DIR}/${order}-${row_id}" local row_dir="${RUN_DIR}/${order}-${row_id}"
mkdir -p "${row_dir}" mkdir -p "${row_dir}"
local sim_log="${row_dir}/sim.log" local emitter_log="${row_dir}/rtp_output.log"
local streamer_log="${row_dir}/streamer.log" local receiver_log="${row_dir}/rtp_receiver.log"
local tester_log="${row_dir}/tester.log" local sdp_path="${row_dir}/stream.sdp"
local sdp_path="" local port
port="$((RTP_PORT_BASE + (order - 1) * 2))"
local shm_name="cvmmap_accept_${row_id}_${RUN_ID}" local payload_type=96
local zmq_endpoint="ipc:///tmp/cvmmap_accept_${row_id}_${RUN_ID}.ipc" if [[ "${codec}" == "h265" ]]; then
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 payload_type=98
fi 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+=( local row_start_ms
--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
)
fi
local row_start_ms row_end_ms duration_ms
row_start_ms="$(date +%s%3N)" row_start_ms="$(date +%s%3N)"
"${tester_cmd[@]}" > "${tester_log}" 2>&1 & "${BUILD_DIR}/rtp_receiver_tester" \
local tester_pid=$! --port "${port}" \
cleanup_pids+=("${tester_pid}") --expect-pt "${payload_type}" \
--packet-threshold 1 \
--timeout-ms 12000 >"${receiver_log}" 2>&1 &
local receiver_pid=$!
cleanup_pids+=("${receiver_pid}")
sleep 1 sleep 1
: > "${sim_log}"
"${streamer_cmd[@]}" > "${streamer_log}" 2>&1 set +e
local streamer_rc=$? "${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 set +e
local tester_rc=$? wait_pid "${receiver_pid}" 20
local receiver_rc=$?
local sim_rc=0 set -e
local row_end_ms
row_end_ms="$(date +%s%3N)" 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 status="PASS"
local reason="all-processes-ok" local reason="all-processes-ok"
if (( emitter_rc != 0 || receiver_rc != 0 )); then
if (( sim_rc != 0 || streamer_rc != 0 || tester_rc != 0 )); then
status="FAIL" 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 fi
append_manifest_row \ append_manifest_row \
"${order}" \ "${order}" \
"${row_id}" \ "${row_id}" \
"${name}" \ "RTP + ${codec}" \
"${protocol}" \ "rtp" \
"${codec}" \ "${codec}" \
"${rtmp_mode}" \ "udp" \
"${status}" \ "${status}" \
"${reason}" \ "${reason}" \
"${duration_ms}" \ "${duration_ms}" \
"${sim_rc}" \ "${emitter_rc}" \
"${streamer_rc}" \ "${receiver_rc}" \
"${tester_rc}" \ "${emitter_log}" \
"${sim_log}" \ "${receiver_log}" \
"${streamer_log}" \
"${tester_log}" \
"${sdp_path}" "${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() { main() {
local required=( local required=(
"${BUILD_DIR}/cvmmap_streamer" "${BUILD_DIR}/rtp_output_tester"
"${BUILD_DIR}/rtp_receiver_tester" "${BUILD_DIR}/rtp_receiver_tester"
"${BUILD_DIR}/rtmp_output_tester"
"${BUILD_DIR}/rtmp_stub_tester" "${BUILD_DIR}/rtmp_stub_tester"
) )
@@ -273,33 +287,24 @@ local required=(
done done
if (( ${#missing[@]} > 0 )); then if (( ${#missing[@]} > 0 )); then
for idx in 1 2 3 4 5; do {
append_manifest_row \ echo "task=14"
"${idx}" \ echo "run_id=${RUN_ID}"
"preflight_${idx}" \ echo "run_dir=${RUN_DIR}"
"preflight missing binary" \ echo "manifest=${MANIFEST_TSV}"
"preflight" \ echo "missing_binaries=${missing[*]}"
"n/a" \ } > "${EVIDENCE_TEXT}"
"" \ echo "missing binaries: ${missing[*]}" >&2
"SKIP" \ return 1
"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"
fi 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 local finished_at_utc
finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
@@ -310,67 +315,61 @@ local required=(
--run-dir "${RUN_DIR}" \ --run-dir "${RUN_DIR}" \
--started-at "${STARTED_AT_UTC}" \ --started-at "${STARTED_AT_UTC}" \
--finished-at "${finished_at_utc}" --finished-at "${finished_at_utc}"
local summary_rc=$?
cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true 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 "task=14"
echo "run_id=${RUN_ID}" echo "run_id=${RUN_ID}"
echo "run_dir=${RUN_DIR}" echo "run_dir=${RUN_DIR}"
echo "manifest=${MANIFEST_TSV}" echo "manifest=${MANIFEST_TSV}"
echo "summary_json=${SUMMARY_JSON}" echo "summary_json=${SUMMARY_JSON}"
echo "latest_summary_json=${LATEST_SUMMARY_JSON}"
echo "started_at=${STARTED_AT_UTC}" echo "started_at=${STARTED_AT_UTC}"
echo "finished_at=${finished_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}" } > "${EVIDENCE_TEXT}"
if (( summary_rc != 0 )); then if [[ "${all_pass}" == "true" ]]; then
echo "summary helper failed with rc=${summary_rc}" >&2 echo "acceptance matrix PASS (${pass_count}/${total_count})"
return 1 echo "summary: ${SUMMARY_JSON}"
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
return 0 return 0
fi fi
echo "acceptance matrix FAIL (${pass_count}/${total_count})" >&2
echo "summary: ${SUMMARY_JSON}" >&2
return 1 return 1
} }
+60 -126
View File
@@ -12,10 +12,7 @@ from pathlib import Path
from typing import cast from typing import cast
MetricValue = int | float | str | bool KV_PATTERN = re.compile(r"([a-zA-Z0-9_]+)=([^\s]+)")
KV_PATTERN = re.compile(r"([a-zA-Z_]+)=([^\s]+)")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -32,31 +29,20 @@ def parse_args() -> CliArgs:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Build JSON summary for standalone acceptance matrix" description="Build JSON summary for standalone acceptance matrix"
) )
_ = parser.add_argument("--manifest", required=True)
_ = parser.add_argument( _ = parser.add_argument("--output", required=True)
"--manifest", required=True, help="TSV manifest produced by acceptance runner"
)
_ = parser.add_argument("--output", required=True, help="Output JSON summary path")
_ = parser.add_argument("--run-id", required=True) _ = parser.add_argument("--run-id", required=True)
_ = parser.add_argument("--run-dir", required=True) _ = parser.add_argument("--run-dir", required=True)
_ = parser.add_argument("--started-at", required=True) _ = parser.add_argument("--started-at", required=True)
_ = parser.add_argument("--finished-at", required=True) _ = parser.add_argument("--finished-at", required=True)
parsed = parser.parse_args(sys.argv[1:]) 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( return CliArgs(
manifest=manifest, manifest=cast(str, parsed.manifest),
output=output, output=cast(str, parsed.output),
run_id=run_id, run_id=cast(str, parsed.run_id),
run_dir=run_dir, run_dir=cast(str, parsed.run_dir),
started_at=started_at, started_at=cast(str, parsed.started_at),
finished_at=finished_at, finished_at=cast(str, parsed.finished_at),
) )
@@ -70,7 +56,7 @@ def read_text(path: str) -> str:
return "" return ""
def to_number(value: str) -> MetricValue: def to_number(value: str) -> int | float | str:
if re.fullmatch(r"-?\d+", value): if re.fullmatch(r"-?\d+", value):
try: try:
return int(value) return int(value)
@@ -84,25 +70,20 @@ def to_number(value: str) -> MetricValue:
return value return value
def parse_key_value_metrics(line: str) -> dict[str, MetricValue]: def parse_key_values(line: str) -> dict[str, int | float | str]:
metrics: dict[str, MetricValue] = {} return {match.group(1): to_number(match.group(2)) for match in KV_PATTERN.finditer(line)}
for match in KV_PATTERN.finditer(line):
key = match.group(1)
raw = match.group(2)
metrics[key] = to_number(raw)
return metrics
def extract_last_matching_line(text: str, token: str) -> str: def last_line_with_token(text: str, token: str) -> str:
match = "" found = ""
for line in text.splitlines(): for line in text.splitlines():
if token in line: if token in line:
match = line found = line
return match return found
def parse_rtp_tester_metrics(text: str) -> dict[str, MetricValue]: def parse_rtp_receiver_metrics(text: str) -> dict[str, int]:
metrics: dict[str, MetricValue] = {} metrics: dict[str, int] = {}
patterns = { patterns = {
"packets_received": r"Packets received:\s*(\d+)", "packets_received": r"Packets received:\s*(\d+)",
"sequence_gaps": r"Sequence gaps:\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+)", "detected_payload_type": r"Detected payload type:\s*(\d+)",
} }
for key, pattern in patterns.items(): for key, pattern in patterns.items():
m = re.search(pattern, text) match = re.search(pattern, text)
if m: if match:
metrics[key] = int(m.group(1)) metrics[key] = int(match.group(1))
return metrics return metrics
def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]: def parse_rtmp_stub_metrics(text: str) -> dict[str, int]:
metrics: dict[str, MetricValue] = {} metrics: dict[str, int] = {}
messages = re.search( messages = re.search(
r"Messages:\s*total=(\d+),\s*audio=(\d+),\s*video=(\d+),\s*data=(\d+),\s*chunk-size-updates=(\d+)", r"Messages:\s*total=(\d+),\s*audio=(\d+),\s*video=(\d+),\s*data=(\d+),\s*chunk-size-updates=(\d+)",
text, text,
@@ -135,7 +115,7 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
) )
counts = re.search( 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, text,
) )
if counts: if counts:
@@ -143,8 +123,7 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
{ {
"h264_video_messages": int(counts.group(1)), "h264_video_messages": int(counts.group(1)),
"h265_enhanced_video_messages": int(counts.group(2)), "h265_enhanced_video_messages": int(counts.group(2)),
"h265_domestic_video_messages": int(counts.group(3)), "unknown_video_messages": int(counts.group(3)),
"unknown_video_messages": int(counts.group(4)),
} }
) )
@@ -158,43 +137,29 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
"matching_threshold": int(matching.group(2)), "matching_threshold": int(matching.group(2)),
} }
) )
return metrics return metrics
def parse_streamer_metrics(text: str) -> dict[str, dict[str, MetricValue]]: def parse_sdp_metrics(path: str) -> dict[str, object]:
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)
if not path: if not path:
return {} return {}
p = Path(path)
if not p.exists(): if not p.exists():
return {"exists": False} return {"exists": False}
text = read_text(path) text = read_text(path)
metrics: dict[str, MetricValue] = { metrics: dict[str, object] = {
"exists": True, "exists": True,
"bytes": p.stat().st_size, "bytes": p.stat().st_size,
"has_h264": "H264/90000" in text, "has_h264": "H264/90000" in text,
"has_h265": ("H265/90000" in text) or ("HEVC/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) match = re.search(r"m=video\s+\d+\s+RTP/AVP\s+(\d+)", text)
if m: if match:
metrics["payload_type"] = int(m.group(1)) metrics["payload_type"] = int(match.group(1))
return metrics return metrics
def parse_exit_code(value: str) -> int: def parse_exit(value: str) -> int:
try: try:
return int(value) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -208,35 +173,12 @@ def parse_duration_ms(value: str) -> int:
return 0 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]]: def parse_manifest(path: str) -> list[dict[str, str]]:
rows: list[dict[str, str]] = [] rows: list[dict[str, str]] = []
with open(path, "r", encoding="utf-8", newline="") as handle: with open(path, "r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle, delimiter="\t") reader = csv.DictReader(handle, delimiter="\t")
for raw_row in reader: for raw in reader:
row: dict[str, str] = {} row = {key: "" if value is None else str(value) for key, value in raw.items()}
for field in MANIFEST_FIELDS:
value = raw_row.get(field, "")
row[field] = "" if value is None else str(value)
rows.append(row) rows.append(row)
return rows return rows
@@ -246,26 +188,22 @@ def build_summary(args: CliArgs) -> dict[str, object]:
rows: list[dict[str, object]] = [] rows: list[dict[str, object]] = []
for row in sorted(manifest_rows, key=lambda item: int(item["order"])): for row in sorted(manifest_rows, key=lambda item: int(item["order"])):
streamer_log = row["streamer_log"] emitter_text = read_text(row["emitter_log"])
tester_log = row["tester_log"] receiver_text = read_text(row["receiver_log"])
sim_log = row["sim_log"] emitter_metrics: dict[str, dict[str, int | float | str]] = {}
sdp_path = row.get("sdp_path", "") 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) receiver_metrics: dict[str, object]
tester_text = read_text(tester_log)
tester_metrics: dict[str, MetricValue]
if row["protocol"] == "rtp": if row["protocol"] == "rtp":
tester_metrics = parse_rtp_tester_metrics(tester_text) receiver_metrics = parse_rtp_receiver_metrics(receiver_text)
else: else:
tester_metrics = parse_rtmp_tester_metrics(tester_text) receiver_metrics = parse_rtmp_stub_metrics(receiver_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)
rows.append( rows.append(
{ {
@@ -274,21 +212,23 @@ def build_summary(args: CliArgs) -> dict[str, object]:
"name": row["name"], "name": row["name"],
"protocol": row["protocol"], "protocol": row["protocol"],
"codec": row["codec"], "codec": row["codec"],
"rtmp_mode": row["rtmp_mode"] if row["rtmp_mode"] else None, "transport": row["transport"],
"status": row["status"], "status": row["status"],
"reason": row["reason"], "reason": row["reason"],
"duration_ms": parse_duration_ms(row["duration_ms"]), "duration_ms": parse_duration_ms(row["duration_ms"]),
"exit_codes": { "exit_codes": {
"sim": parse_exit_code(row["sim_rc"]), "emitter": parse_exit(row["emitter_rc"]),
"streamer": parse_exit_code(row["streamer_rc"]), "receiver": parse_exit(row["receiver_rc"]),
"tester": parse_exit_code(row["tester_rc"]), },
"metrics": {
"emitter": emitter_metrics,
"receiver": receiver_metrics,
"sdp": parse_sdp_metrics(row.get("sdp_path", "")),
}, },
"metrics": metrics,
"evidence": { "evidence": {
"sim_log": sim_log, "emitter_log": row["emitter_log"],
"streamer_log": streamer_log, "receiver_log": row["receiver_log"],
"tester_log": tester_log, "sdp_path": row.get("sdp_path") or None,
"sdp": sdp_path if sdp_path else 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") pass_count = sum(1 for row in rows if row["status"] == "PASS")
fail_count = sum(1 for row in rows if row["status"] == "FAIL") fail_count = sum(1 for row in rows if row["status"] == "FAIL")
skip_count = sum(1 for row in rows if row["status"] == "SKIP") skip_count = sum(1 for row in rows if row["status"] == "SKIP")
all_pass = len(rows) == 6 and pass_count == 6 and fail_count == 0 and skip_count == 0
all_pass = (
len(rows) == 5 and pass_count == 5 and fail_count == 0 and skip_count == 0
)
return { return {
"run_id": args.run_id, "run_id": args.run_id,
@@ -322,11 +259,8 @@ def main() -> int:
args = parse_args() args = parse_args()
output_path = Path(args.output) output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
summary = build_summary(args) summary = build_summary(args)
_ = output_path.write_text( output_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
json.dumps(summary, indent=2, sort_keys=False) + "\n", encoding="utf-8"
)
return 0 return 0
+103 -244
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -u -o pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUILD_DIR="${STREAMER_ROOT}/build" BUILD_DIR="${STREAMER_ROOT}/build"
EVIDENCE_ROOT="${STREAMER_ROOT}/.sisyphus/evidence" EVIDENCE_ROOT="${STREAMER_ROOT}/.sisyphus/evidence"
TASK_EVIDENCE_DIR="${EVIDENCE_ROOT}/task-15-fault-suite" TASK_EVIDENCE_DIR="${EVIDENCE_ROOT}/task-15-fault-suite"
SUMMARY_HELPER="${SCRIPT_DIR}/fault_summary_helper.py" SUMMARY_HELPER="${SCRIPT_DIR}/fault_summary_helper.py"
@@ -39,17 +38,10 @@ fi
RUN_ID="" RUN_ID=""
RUN_DIR="" RUN_DIR=""
MANIFEST_TSV="${RUN_DIR}/rows.tsv" MANIFEST_TSV=""
SUMMARY_JSON="${RUN_DIR}/summary.json" SUMMARY_JSON=""
LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-summary.json"
if [[ "${MODE}" == "baseline" ]]; then EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite.txt"
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
STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
mkdir -p "${TASK_EVIDENCE_DIR}" mkdir -p "${TASK_EVIDENCE_DIR}"
@@ -76,50 +68,7 @@ allocate_run_dir() {
allocate_run_dir || exit 1 allocate_run_dir || exit 1
RUN_HASH="$(printf '%s' "${RUN_ID}" | cksum | awk '{print $1}')" echo -e "order\tscenario_id\tname\tstatus\treason\tduration_ms\tcommand_rc\tlog_path" > "${MANIFEST_TSV}"
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 $?
}
append_manifest_row() { append_manifest_row() {
local order="$1" local order="$1"
@@ -128,145 +77,37 @@ append_manifest_row() {
local status="$4" local status="$4"
local reason="$5" local reason="$5"
local duration_ms="$6" local duration_ms="$6"
local sim_rc="$7" local command_rc="$7"
local streamer_rc="$8" local log_path="$8"
local tester_rc="$9" echo -e "${order}\t${scenario_id}\t${name}\t${status}\t${reason}\t${duration_ms}\t${command_rc}\t${log_path}" >> "${MANIFEST_TSV}"
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}"
} }
scenario_port() { run_expected_failure() {
local order="$1"
echo $((SCENARIO_PORT_BASE + (order - 1) * 2))
}
run_fault_scenario() {
local order="$1" local order="$1"
local scenario_id="$2" local scenario_id="$2"
local name="$3" local name="$3"
local expected_substring="$4"
shift 4
local row_dir="${RUN_DIR}/${order}-${scenario_id}" local row_dir="${RUN_DIR}/${order}-${scenario_id}"
mkdir -p "${row_dir}" mkdir -p "${row_dir}"
local log_path="${row_dir}/command.log"
local sim_log="${row_dir}/sim.log" local row_start_ms
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
row_start_ms="$(date +%s%3N)" row_start_ms="$(date +%s%3N)"
set +e
"${tester_cmd[@]}" > "${tester_log}" 2>&1 & "$@" >"${log_path}" 2>&1
local tester_pid=$! local command_rc=$?
cleanup_pids+=("${tester_pid}") set -e
local row_end_ms
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
row_end_ms="$(date +%s%3N)" 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 status="FAIL"
local reason="all-processes-ok" local reason="expected non-zero rc and log token '${expected_substring}'"
if (( sim_rc != 0 || streamer_rc != 0 || tester_rc != 0 )); then if (( command_rc != 0 )) && grep -Fq "${expected_substring}" "${log_path}"; then
status="FAIL" status="PASS"
reason="sim_rc=${sim_rc},streamer_rc=${streamer_rc},tester_rc=${tester_rc}" reason="command failed as expected"
fi fi
append_manifest_row \ append_manifest_row \
@@ -276,13 +117,8 @@ run_fault_scenario() {
"${status}" \ "${status}" \
"${reason}" \ "${reason}" \
"${duration_ms}" \ "${duration_ms}" \
"${sim_rc}" \ "${command_rc}" \
"${streamer_rc}" \ "${log_path}"
"${tester_rc}" \
"${sim_log}" \
"${streamer_log}" \
"${tester_log}" \
"${sdp_path}"
printf "[%s] %s => %s (%s)\n" "${scenario_id}" "${name}" "${status}" "${reason}" printf "[%s] %s => %s (%s)\n" "${scenario_id}" "${name}" "${status}" "${reason}"
} }
@@ -290,12 +126,12 @@ run_fault_scenario() {
main() { main() {
local required=( local required=(
"${BUILD_DIR}/cvmmap_streamer" "${BUILD_DIR}/cvmmap_streamer"
"${BUILD_DIR}/rtp_receiver_tester" "${BUILD_DIR}/rtmp_output_tester"
) )
local missing=() local missing=()
for bin in "${required[@]}"; do for bin in "${required[@]}"; do
if ! binary_exists "${bin}"; then if [[ ! -x "${bin}" ]]; then
missing+=("${bin}") missing+=("${bin}")
fi fi
done done
@@ -313,9 +149,68 @@ main() {
return 1 return 1
fi fi
run_fault_scenario 1 "torn_read" "fault:torn-read" run_expected_failure 1 "removed_encoder_backend" "removed encoder backend rejected" \
run_fault_scenario 2 "sink_stall" "fault:sink-stall" "invalid encoder backend: 'gstreamer_legacy' was removed; use ffmpeg" \
run_fault_scenario 3 "reset_storm" "fault:reset-storm" "${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 '<host>:<port>' 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 local finished_at_utc
finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
@@ -328,54 +223,36 @@ main() {
--started-at "${STARTED_AT_UTC}" \ --started-at "${STARTED_AT_UTC}" \
--finished-at "${finished_at_utc}" \ --finished-at "${finished_at_utc}" \
--mode "${MODE}" --mode "${MODE}"
local summary_rc=$?
cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true
local total_count pass_count fail_count all_pass local total_count pass_count fail_count all_pass
total_count="$(python3 - <<'PY' "${SUMMARY_JSON}" total_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
import json import json, sys
import sys
data = json.load(open(sys.argv[1], "r", encoding="utf-8")) data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
counts = data.get("counts", {}) counts = data.get("counts", {})
print(counts.get("total", 0)) print(counts.get("total", 0))
PY PY
)" )"
pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}" pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
import json import json, sys
import sys
data = json.load(open(sys.argv[1], "r", encoding="utf-8")) data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
counts = data.get("counts", {}) counts = data.get("counts", {})
print(counts.get("pass", 0)) print(counts.get("pass", 0))
PY PY
)" )"
fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}" fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
import json import json, sys
import sys
data = json.load(open(sys.argv[1], "r", encoding="utf-8")) data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
counts = data.get("counts", {}) counts = data.get("counts", {})
print(counts.get("fail", 0)) print(counts.get("fail", 0))
PY PY
)" )"
all_pass="$(python3 - <<'PY' "${SUMMARY_JSON}" all_pass="$(python3 - <<'PY' "${SUMMARY_JSON}"
import json import json, sys
import sys
data = json.load(open(sys.argv[1], "r", encoding="utf-8")) data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
print("true" if data.get("all_pass", False) else "false") print("true" if data.get("all_pass", False) else "false")
PY 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 "run_dir=${RUN_DIR}"
echo "manifest=${MANIFEST_TSV}" echo "manifest=${MANIFEST_TSV}"
echo "summary_json=${SUMMARY_JSON}" echo "summary_json=${SUMMARY_JSON}"
echo "latest_summary_json=${LATEST_SUMMARY_JSON}"
echo "started_at=${STARTED_AT_UTC}" echo "started_at=${STARTED_AT_UTC}"
echo "finished_at=${finished_at_utc}" echo "finished_at=${finished_at_utc}"
echo "scenario_total=${total_count}" echo "counts_total=${total_count}"
echo "scenario_pass=${pass_count}" echo "counts_pass=${pass_count}"
echo "scenario_fail=${fail_count}" echo "counts_fail=${fail_count}"
echo "all_pass=${all_pass}" echo "all_pass=${all_pass}"
echo "summary_helper_rc=${summary_rc}" 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"
echo "violated_thresholds_begin"
if [[ -n "${violation_lines}" ]]; then
echo "${violation_lines}"
fi
echo "violated_thresholds_end"
} > "${EVIDENCE_TEXT}" } > "${EVIDENCE_TEXT}"
if (( summary_rc != 0 )); then if [[ "${all_pass}" == "true" ]]; then
echo "summary helper failed with rc=${summary_rc}" >&2 echo "fault suite PASS (${pass_count}/${total_count})"
return 1
fi
echo "fault-suite mode=${MODE} total=${total_count} pass=${pass_count} fail=${fail_count}"
echo "summary: ${SUMMARY_JSON}" echo "summary: ${SUMMARY_JSON}"
if [[ "${MODE}" == "baseline" ]]; then
if [[ "${total_count}" == "3" && "${pass_count}" == "3" && "${fail_count}" == "0" ]]; then
return 0 return 0
fi fi
return 1
fi
if [[ "${fail_count}" != "0" ]]; then echo "fault suite FAIL (${pass_count}/${total_count})" >&2
echo "summary: ${SUMMARY_JSON}" >&2
return 1 return 1
fi
echo "degraded mode did not violate thresholds" >&2
return 2
} }
main "$@" main "$@"
+29 -316
View File
@@ -5,16 +5,12 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import json import json
import re
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
KV_PATTERN = re.compile(r"([a-zA-Z0-9_]+)=([^\s]+)")
@dataclass(frozen=True) @dataclass(frozen=True)
class CliArgs: class CliArgs:
manifest: str manifest: str
@@ -27,9 +23,7 @@ class CliArgs:
def parse_args() -> CliArgs: def parse_args() -> CliArgs:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Build fault suite summary")
description="Build fault suite summary with threshold checks"
)
_ = parser.add_argument("--manifest", required=True) _ = parser.add_argument("--manifest", required=True)
_ = parser.add_argument("--output", required=True) _ = parser.add_argument("--output", required=True)
_ = parser.add_argument("--run-id", 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("--started-at", required=True)
_ = parser.add_argument("--finished-at", required=True) _ = parser.add_argument("--finished-at", required=True)
_ = parser.add_argument("--mode", required=True, choices=("baseline", "degraded")) _ = parser.add_argument("--mode", required=True, choices=("baseline", "degraded"))
parsed = parser.parse_args(sys.argv[1:]) parsed = parser.parse_args(sys.argv[1:])
return CliArgs( return CliArgs(
manifest=cast(str, parsed.manifest), manifest=cast(str, parsed.manifest),
@@ -50,46 +43,17 @@ def parse_args() -> CliArgs:
) )
def read_text(path: str) -> str: def parse_manifest(path: str) -> list[dict[str, str]]:
p = Path(path) rows: list[dict[str, str]] = []
if not p.exists(): with open(path, "r", encoding="utf-8", newline="") as handle:
return "" reader = csv.DictReader(handle, delimiter="\t")
try: for raw in reader:
return p.read_text(encoding="utf-8", errors="replace") row = {key: "" if value is None else str(value) for key, value in raw.items()}
except OSError: rows.append(row)
return "" return rows
def to_number(value: str) -> int | float | str: def parse_int(value: str) -> int:
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:
try: try:
return int(value) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -103,302 +67,51 @@ def parse_duration_ms(value: str) -> int:
return 0 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]: def build_summary(args: CliArgs) -> dict[str, object]:
thresholds = get_thresholds(args.mode) manifest_rows = parse_manifest(args.manifest)
rows = parse_manifest(args.manifest) rows = [
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"]), "order": parse_int(row["order"]),
"id": row["scenario_id"], "id": row["scenario_id"],
"name": row["name"], "name": row["name"],
"status": scenario_status, "status": row["status"],
"reason": reason, "reason": row["reason"],
"duration_ms": parse_duration_ms(row["duration_ms"]), "duration_ms": parse_duration_ms(row["duration_ms"]),
"process_exit": { "exit_codes": {"command": parse_int(row["command_rc"])},
"sim": sim_rc, "evidence": {"log_path": row["log_path"]},
"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"],
},
} }
) for row in sorted(manifest_rows, key=lambda item: parse_int(item["order"]))
]
pass_count = sum(1 for item in scenarios if item["status"] == "PASS") pass_count = sum(1 for row in rows if row["status"] == "PASS")
fail_count = sum(1 for item in scenarios if item["status"] == "FAIL") fail_count = sum(1 for row in rows if row["status"] == "FAIL")
all_pass = len(scenarios) == 3 and pass_count == 3 and fail_count == 0 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 { return {
"task": 15,
"mode": args.mode,
"run_id": args.run_id, "run_id": args.run_id,
"run_dir": args.run_dir, "run_dir": args.run_dir,
"started_at": args.started_at, "started_at": args.started_at,
"finished_at": args.finished_at, "finished_at": args.finished_at,
"thresholds": thresholds, "mode": args.mode,
"counts": { "counts": {
"total": len(scenarios), "total": len(rows),
"pass": pass_count, "pass": pass_count,
"fail": fail_count, "fail": fail_count,
"skip": skip_count,
}, },
"all_pass": all_pass, "all_pass": all_pass,
"recommended_exit_code": 0 if all_pass else 1, "recommended_exit_code": 0 if all_pass else 1,
"scenarios": scenarios, "rows": rows,
} }
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
summary = build_summary(args)
output_path = Path(args.output) output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
_ = output_path.write_text( summary = build_summary(args)
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 return 0
+2 -5
View File
@@ -17,7 +17,6 @@ CODEC="${CODEC:-h264}"
ENCODER_BACKEND="${ENCODER_BACKEND:-ffmpeg}" ENCODER_BACKEND="${ENCODER_BACKEND:-ffmpeg}"
ENCODER_DEVICE="${ENCODER_DEVICE:-nvidia}" ENCODER_DEVICE="${ENCODER_DEVICE:-nvidia}"
RTMP_TRANSPORT="${RTMP_TRANSPORT:-libavformat}" RTMP_TRANSPORT="${RTMP_TRANSPORT:-libavformat}"
RTMP_MODE="${RTMP_MODE:-enhanced}"
INGEST_MAX_FRAMES="${INGEST_MAX_FRAMES:-120}" INGEST_MAX_FRAMES="${INGEST_MAX_FRAMES:-120}"
PROBE_TIMEOUT_S="${PROBE_TIMEOUT_S:-20}" PROBE_TIMEOUT_S="${PROBE_TIMEOUT_S:-20}"
DECODE_FRAMES="${DECODE_FRAMES:-15}" DECODE_FRAMES="${DECODE_FRAMES:-15}"
@@ -58,10 +57,9 @@ Environment overrides:
INPUT_URI cvmmap source URI, if positional argument is omitted INPUT_URI cvmmap source URI, if positional argument is omitted
STREAM_NAME RTMP/HTTP-FLV stream name, default derived from INPUT_URI STREAM_NAME RTMP/HTTP-FLV stream name, default derived from INPUT_URI
CODEC h264|h265 CODEC h264|h265
ENCODER_BACKEND ffmpeg|gstreamer_legacy ENCODER_BACKEND auto|ffmpeg
ENCODER_DEVICE auto|nvidia|software ENCODER_DEVICE auto|nvidia|software
RTMP_TRANSPORT libavformat|ffmpeg_process|legacy_custom RTMP_TRANSPORT libavformat|ffmpeg_process
RTMP_MODE enhanced|domestic
INGEST_MAX_FRAMES bounded frame count for the smoke INGEST_MAX_FRAMES bounded frame count for the smoke
DECODE_FRAMES frames to decode from HTTP-FLV after probe DECODE_FRAMES frames to decode from HTTP-FLV after probe
SRS_ROOT local SRS checkout, default ~/Code/srs SRS_ROOT local SRS checkout, default ~/Code/srs
@@ -198,7 +196,6 @@ fi
--rtmp \ --rtmp \
--rtmp-url "$RTMP_URL" \ --rtmp-url "$RTMP_URL" \
--rtmp-transport "$RTMP_TRANSPORT" \ --rtmp-transport "$RTMP_TRANSPORT" \
--rtmp-mode "$RTMP_MODE" \
--ingest-max-frames "$INGEST_MAX_FRAMES" \ --ingest-max-frames "$INGEST_MAX_FRAMES" \
>"$STREAMER_LOG" 2>&1 & >"$STREAMER_LOG" 2>&1 &
STREAMER_PID=$! STREAMER_PID=$!
+18 -69
View File
@@ -14,10 +14,6 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#ifndef CVMMAP_STREAMER_HAS_GSTREAMER
#define CVMMAP_STREAMER_HAS_GSTREAMER 0
#endif
namespace cvmmap_streamer { namespace cvmmap_streamer {
namespace { namespace {
@@ -36,10 +32,16 @@ std::string trim_copy(std::string value) {
} }
std::string normalize_cli_error(std::string raw_message) { 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(':'); const auto pos = raw_message.find(':');
if (pos != std::string::npos && pos + 1 < raw_message.size()) { 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"; return "unknown argument";
} }
@@ -113,16 +115,6 @@ std::expected<RunMode, std::string> parse_run_mode(std::string_view raw) {
return std::unexpected("invalid run mode: '" + std::string(raw) + "' (expected: pipeline|ingest)"); return std::unexpected("invalid run mode: '" + std::string(raw) + "' (expected: pipeline|ingest)");
} }
std::expected<RtmpMode, std::string> parse_rtmp_mode(std::string_view raw) {
if (raw == "enhanced") {
return RtmpMode::Enhanced;
}
if (raw == "domestic") {
return RtmpMode::Domestic;
}
return std::unexpected("invalid rtmp mode: '" + std::string(raw) + "' (expected: enhanced|domestic)");
}
std::expected<RtmpTransportType, std::string> parse_rtmp_transport(std::string_view raw) { std::expected<RtmpTransportType, std::string> parse_rtmp_transport(std::string_view raw) {
if (raw == "libavformat") { if (raw == "libavformat") {
return RtmpTransportType::Libavformat; return RtmpTransportType::Libavformat;
@@ -131,10 +123,10 @@ std::expected<RtmpTransportType, std::string> parse_rtmp_transport(std::string_v
return RtmpTransportType::FfmpegProcess; return RtmpTransportType::FfmpegProcess;
} }
if (raw == "legacy_custom" || raw == "legacy-custom") { if (raw == "legacy_custom" || raw == "legacy-custom") {
return RtmpTransportType::LegacyCustom;
}
return std::unexpected( return std::unexpected(
"invalid rtmp transport: '" + std::string(raw) + "' (expected: libavformat|ffmpeg_process|legacy_custom)"); "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)");
} }
std::expected<EncoderBackendType, std::string> parse_encoder_backend(std::string_view raw) { std::expected<EncoderBackendType, std::string> parse_encoder_backend(std::string_view raw) {
@@ -145,9 +137,9 @@ std::expected<EncoderBackendType, std::string> parse_encoder_backend(std::string
return EncoderBackendType::FFmpeg; return EncoderBackendType::FFmpeg;
} }
if (raw == "gstreamer_legacy" || raw == "gstreamer-legacy") { 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<EncoderDeviceType, std::string> parse_encoder_device(std::string_view raw) { std::expected<EncoderDeviceType, std::string> parse_encoder_device(std::string_view raw) {
@@ -353,11 +345,7 @@ std::expected<void, std::string> apply_toml_file(RuntimeConfig &config, const st
config.outputs.rtmp.ffmpeg_path = *value; config.outputs.rtmp.ffmpeg_path = *value;
} }
if (auto value = toml_value<std::string>(table, "outputs.rtmp.mode")) { if (auto value = toml_value<std::string>(table, "outputs.rtmp.mode")) {
auto parsed = parse_rtmp_mode(*value); return std::unexpected("invalid RTMP config: outputs.rtmp.mode was removed; RTMP always uses enhanced mode");
if (!parsed) {
return std::unexpected(parsed.error());
}
config.outputs.rtmp.mode = *parsed;
} }
if (auto value = toml_value<bool>(table, "record.mcap.enabled")) { if (auto value = toml_value<bool>(table, "record.mcap.enabled")) {
config.record.mcap.enabled = *value; config.record.mcap.enabled = *value;
@@ -502,8 +490,6 @@ std::string_view to_string(RtmpMode mode) {
switch (mode) { switch (mode) {
case RtmpMode::Enhanced: case RtmpMode::Enhanced:
return "enhanced"; return "enhanced";
case RtmpMode::Domestic:
return "domestic";
} }
return "unknown"; return "unknown";
} }
@@ -514,8 +500,6 @@ std::string_view to_string(RtmpTransportType transport) {
return "libavformat"; return "libavformat";
case RtmpTransportType::FfmpegProcess: case RtmpTransportType::FfmpegProcess:
return "ffmpeg_process"; return "ffmpeg_process";
case RtmpTransportType::LegacyCustom:
return "legacy_custom";
} }
return "unknown"; return "unknown";
} }
@@ -526,8 +510,6 @@ std::string_view to_string(EncoderBackendType backend) {
return "auto"; return "auto";
case EncoderBackendType::FFmpeg: case EncoderBackendType::FFmpeg:
return "ffmpeg"; return "ffmpeg";
case EncoderBackendType::GStreamerLegacy:
return "gstreamer_legacy";
} }
return "unknown"; return "unknown";
} }
@@ -565,7 +547,6 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
std::string codec_raw{}; std::string codec_raw{};
std::string encoder_backend_raw{}; std::string encoder_backend_raw{};
std::string encoder_device_raw{}; std::string encoder_device_raw{};
std::string rtmp_mode_raw{};
std::string rtmp_transport_raw{}; std::string rtmp_transport_raw{};
std::string rtmp_ffmpeg_path_raw{}; std::string rtmp_ffmpeg_path_raw{};
std::vector<std::string> rtmp_urls_raw{}; std::vector<std::string> rtmp_urls_raw{};
@@ -605,7 +586,6 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
app.add_option("--rtmp-url", rtmp_urls_raw); app.add_option("--rtmp-url", rtmp_urls_raw);
app.add_option("--rtmp-transport", rtmp_transport_raw); app.add_option("--rtmp-transport", rtmp_transport_raw);
app.add_option("--rtmp-ffmpeg", rtmp_ffmpeg_path_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_flag("--rtp", rtp_enabled);
app.add_option("--rtp-endpoint", rtp_endpoint_raw); app.add_option("--rtp-endpoint", rtp_endpoint_raw);
app.add_option("--rtp-payload-type", rtp_payload_type_raw); app.add_option("--rtp-payload-type", rtp_payload_type_raw);
@@ -692,14 +672,6 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
if (!rtmp_ffmpeg_path_raw.empty()) { if (!rtmp_ffmpeg_path_raw.empty()) {
config.outputs.rtmp.ffmpeg_path = rtmp_ffmpeg_path_raw; 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; config.outputs.rtp.enabled = config.outputs.rtp.enabled || rtp_enabled;
if (!rtp_endpoint_raw.empty()) { if (!rtp_endpoint_raw.empty()) {
config.outputs.rtp.enabled = true; config.outputs.rtp.enabled = true;
@@ -831,29 +803,16 @@ std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &co
return std::unexpected("invalid RTMP config: URL must not be empty"); 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.enabled) {
if (config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) { if (config.encoder.backend == EncoderBackendType::Auto) {
if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.encoder.codec != CodecType::H265) { // auto resolves to FFmpeg; nothing else is supported.
return std::unexpected("invalid mode matrix: domestic RTMP mode requires codec h265"); } else if (config.encoder.backend != EncoderBackendType::FFmpeg) {
} return std::unexpected("invalid backend/output matrix: RTMP requires encoder.backend=ffmpeg or auto");
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()) { 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"); return std::unexpected("invalid RTMP config: ffmpeg_process transport requires a non-empty ffmpeg path");
} }
} }
}
if (config.outputs.rtp.enabled) { if (config.outputs.rtp.enabled) {
if (!config.outputs.rtp.endpoint || config.outputs.rtp.endpoint->empty()) { if (!config.outputs.rtp.endpoint || config.outputs.rtp.endpoint->empty()) {
@@ -891,15 +850,6 @@ std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &co
return std::unexpected("invalid ingest config: ingest_idle_timeout_ms must be >= 1"); 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 {}; return {};
} }
@@ -914,7 +864,6 @@ std::string summarize_runtime_config(const RuntimeConfig &config) {
ss << ", encoder.b_frames=" << config.encoder.b_frames; ss << ", encoder.b_frames=" << config.encoder.b_frames;
ss << ", rtmp.enabled=" << (config.outputs.rtmp.enabled ? "true" : "false"); ss << ", rtmp.enabled=" << (config.outputs.rtmp.enabled ? "true" : "false");
ss << ", rtmp.transport=" << to_string(config.outputs.rtmp.transport); 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 << ", rtmp.urls=" << config.outputs.rtmp.urls.size();
ss << ", rtp.enabled=" << (config.outputs.rtp.enabled ? "true" : "false"); ss << ", rtp.enabled=" << (config.outputs.rtp.enabled ? "true" : "false");
ss << ", rtp.endpoint=" << (config.outputs.rtp.endpoint ? *config.outputs.rtp.endpoint : "<unset>"); ss << ", rtp.endpoint=" << (config.outputs.rtp.endpoint ? *config.outputs.rtp.endpoint : "<unset>");
-18
View File
@@ -3,29 +3,11 @@
namespace cvmmap_streamer::encode { namespace cvmmap_streamer::encode {
EncoderBackend make_ffmpeg_backend(); EncoderBackend make_ffmpeg_backend();
EncoderBackend make_gstreamer_legacy_backend();
Result<EncoderBackend> make_encoder_backend(const RuntimeConfig &config) { Result<EncoderBackend> make_encoder_backend(const RuntimeConfig &config) {
switch (config.encoder.backend) { switch (config.encoder.backend) {
case EncoderBackendType::FFmpeg: 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: 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(); return make_ffmpeg_backend();
} }
-457
View File
@@ -1,457 +0,0 @@
#include "cvmmap_streamer/encode/encoder_backend.hpp"
#include <array>
#include <cstring>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <spdlog/spdlog.h>
#if __has_include(<gst/app/gstappsink.h>) && __has_include(<gst/app/gstappsrc.h>) && __has_include(<gst/gst.h>)
#define CVMMAP_STREAMER_HAS_GSTREAMER 1
#include <gst/app/gstappsink.h>
#include <gst/app/gstappsrc.h>
#include <gst/gst.h>
#else
#define CVMMAP_STREAMER_HAS_GSTREAMER 0
#endif
namespace cvmmap_streamer::encode {
namespace {
#if CVMMAP_STREAMER_HAS_GSTREAMER
[[nodiscard]]
Result<const char *> 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<std::string_view> 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<EncoderChoice> 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<EncodedStreamInfo> 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<GstMessageType>(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<GstClockTime>(pts_ns);
GST_BUFFER_DTS(buffer) = static_cast<GstClockTime>(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<int>(flow)));
}
return {};
}
[[nodiscard]]
Result<std::vector<EncodedAccessUnit>> drain() {
return pull_samples();
}
[[nodiscard]]
Result<std::vector<EncodedAccessUnit>> 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<std::vector<EncodedAccessUnit>> pull_samples() {
std::vector<EncodedAccessUnit> 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<std::uint64_t>(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<std::uint64_t> 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<EncoderBackendFacade, GstreamerLegacyBackend>();
#else
return {};
#endif
}
}
+2 -3
View File
@@ -19,7 +19,7 @@ constexpr std::array<std::string_view, 32> kHelpLines{
" --input-uri <uri>\tcvmmap source URI (example: cvmmap://default)", " --input-uri <uri>\tcvmmap source URI (example: cvmmap://default)",
" --run-mode <mode>\tpipeline|ingest", " --run-mode <mode>\tpipeline|ingest",
" --codec <codec>\th264|h265", " --codec <codec>\th264|h265",
" --encoder-backend <backend>\tauto|ffmpeg|gstreamer_legacy", " --encoder-backend <backend>\tauto|ffmpeg",
" --encoder-device <device>\tauto|nvidia|software", " --encoder-device <device>\tauto|nvidia|software",
" --gop <frames>\tencoder GOP length", " --gop <frames>\tencoder GOP length",
" --b-frames <count>\tencoder B-frame count", " --b-frames <count>\tencoder B-frame count",
@@ -29,9 +29,8 @@ constexpr std::array<std::string_view, 32> kHelpLines{
" --rtp-sdp <path>\twrite optional SDP sidecar", " --rtp-sdp <path>\twrite optional SDP sidecar",
" --rtmp\t\tenable RTMP output", " --rtmp\t\tenable RTMP output",
" --rtmp-url <url>\tadd RTMP destination (repeatable)", " --rtmp-url <url>\tadd RTMP destination (repeatable)",
" --rtmp-transport <mode>\tlibavformat|ffmpeg_process|legacy_custom", " --rtmp-transport <mode>\tlibavformat|ffmpeg_process",
" --rtmp-ffmpeg <path>\tffmpeg binary for ffmpeg_process transport", " --rtmp-ffmpeg <path>\tffmpeg binary for ffmpeg_process transport",
" --rtmp-mode <mode>\tenhanced|domestic",
" --mcap\t\tenable MCAP recording", " --mcap\t\tenable MCAP recording",
" --mcap-path <path>\tMCAP output file", " --mcap-path <path>\tMCAP output file",
" --mcap-topic <topic>\tMCAP topic name", " --mcap-topic <topic>\tMCAP topic name",
-36
View File
@@ -1,7 +1,5 @@
#include "cvmmap_streamer/protocol/rtmp_output.hpp" #include "cvmmap_streamer/protocol/rtmp_output.hpp"
#include "cvmmap_streamer/protocol/rtmp_publisher.hpp"
extern "C" { extern "C" {
#include <libavcodec/avcodec.h> #include <libavcodec/avcodec.h>
#include <libavformat/avformat.h> #include <libavformat/avformat.h>
@@ -226,33 +224,6 @@ Status write_all(int fd, std::span<const std::uint8_t> bytes) {
return {}; 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 { class LibavformatRtmpOutput {
public: public:
struct Session { struct Session {
@@ -681,13 +652,6 @@ Result<RtmpOutput> make_rtmp_output(
return LibavformatRtmpOutput::create(config, stream_info); return LibavformatRtmpOutput::create(config, stream_info);
case RtmpTransportType::FfmpegProcess: case RtmpTransportType::FfmpegProcess:
return FfmpegProcessRtmpOutput::create(config, stream_info); 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<RtmpOutputFacade, LegacyCustomRtmpOutput>(std::move(*publisher));
}
} }
return unexpected_error(ERR_INTERNAL, "unknown RTMP transport"); return unexpected_error(ERR_INTERNAL, "unknown RTMP transport");
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -55,7 +55,7 @@ std::expected<Config, int> parse_args(int argc, char **argv) {
CLI::App app{"rtmp_output_tester - publish synthetic encoded video to RTMP using the configured sink"}; 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("--rtmp-url", config.rtmp_url, "RTMP destination URL")->required();
app.add_option("--transport", config.transport, "RTMP transport backend (libavformat|ffmpeg_process)") 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)") app.add_option("--codec", config.codec, "Video codec (h264|h265)")
->check(CLI::IsMember({"h264", "h265"})); ->check(CLI::IsMember({"h264", "h265"}));
app.add_option("--encoder-device", config.encoder_device, "Encoder device (auto|nvidia|software)") app.add_option("--encoder-device", config.encoder_device, "Encoder device (auto|nvidia|software)")
@@ -95,9 +95,6 @@ std::expected<cvmmap_streamer::RtmpTransportType, std::string> parse_transport(s
if (raw == "ffmpeg_process") { if (raw == "ffmpeg_process") {
return cvmmap_streamer::RtmpTransportType::FfmpegProcess; return cvmmap_streamer::RtmpTransportType::FfmpegProcess;
} }
if (raw == "legacy_custom") {
return cvmmap_streamer::RtmpTransportType::LegacyCustom;
}
return std::unexpected("unsupported transport"); return std::unexpected("unsupported transport");
} }
@@ -163,10 +160,6 @@ int main(int argc, char **argv) {
config.outputs.rtmp.transport = *transport; config.outputs.rtmp.transport = *transport;
config.outputs.rtmp.ffmpeg_path = args->ffmpeg_path; 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{ cvmmap_streamer::ipc::FrameInfo frame_info{
.width = static_cast<std::uint16_t>(args->width), .width = static_cast<std::uint16_t>(args->width),
.height = static_cast<std::uint16_t>(args->height), .height = static_cast<std::uint16_t>(args->height),
+3 -50
View File
@@ -61,14 +61,12 @@ enum class ExitCode : int {
enum class ExpectMode { enum class ExpectMode {
H264, H264,
H265Enhanced, H265Enhanced,
H265Domestic,
}; };
enum class VideoSignal { enum class VideoSignal {
Unknown, Unknown,
H264, H264,
H265Enhanced, H265Enhanced,
H265Domestic,
}; };
struct Config { struct Config {
@@ -97,7 +95,6 @@ struct Stats {
std::uint32_t h264_video_messages{0}; std::uint32_t h264_video_messages{0};
std::uint32_t h265_enhanced_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}; std::uint32_t unknown_video_messages{0};
bool mode_mismatch{false}; bool mode_mismatch{false};
@@ -184,8 +181,6 @@ std::string_view to_string(ExpectMode mode) {
return "h264"; return "h264";
case ExpectMode::H265Enhanced: case ExpectMode::H265Enhanced:
return "h265-enhanced"; return "h265-enhanced";
case ExpectMode::H265Domestic:
return "h265-domestic";
default: default:
return "unknown"; return "unknown";
} }
@@ -198,8 +193,6 @@ std::string_view to_string(VideoSignal signal) {
return "h264"; return "h264";
case VideoSignal::H265Enhanced: case VideoSignal::H265Enhanced:
return "h265-enhanced"; return "h265-enhanced";
case VideoSignal::H265Domestic:
return "h265-domestic";
case VideoSignal::Unknown: case VideoSignal::Unknown:
default: default:
return "unknown"; return "unknown";
@@ -214,11 +207,8 @@ std::expected<ExpectMode, std::string> parse_mode(std::string_view raw) {
if (raw == "h265-enhanced") { if (raw == "h265-enhanced") {
return ExpectMode::H265Enhanced; return ExpectMode::H265Enhanced;
} }
if (raw == "h265-domestic") {
return ExpectMode::H265Domestic;
}
return std::unexpected(std::format( return std::unexpected(std::format(
"invalid mode '{}'; expected: h264 | h265-enhanced | h265-domestic", "invalid mode '{}'; expected: h264 | h265-enhanced",
raw)); raw));
} }
@@ -227,7 +217,7 @@ std::expected<Config, std::string> parse_args(int argc, char **argv) {
Config config; Config config;
std::string mode_raw; std::string mode_raw;
std::string self_test_send_mode_raw; std::string self_test_send_mode_raw;
const std::vector<std::string> accepted_modes{"h264", "h265-enhanced", "h265-domestic"}; const std::vector<std::string> accepted_modes{"h264", "h265-enhanced"};
CLI::App app{"rtmp_stub_tester - standalone RTMP ingest validator"}; CLI::App app{"rtmp_stub_tester - standalone RTMP ingest validator"};
app.allow_extras(false); app.allow_extras(false);
@@ -1176,9 +1166,6 @@ VideoSignal classify_video_packet(std::span<const std::uint8_t> payload) {
if (codec_id == 7) { if (codec_id == 7) {
return VideoSignal::H264; return VideoSignal::H264;
} }
if (codec_id == 12) {
return VideoSignal::H265Domestic;
}
if ((first & 0x80) != 0 && payload.size() >= 5) { if ((first & 0x80) != 0 && payload.size() >= 5) {
const std::array<std::uint8_t, 4> hvc1{'h', 'v', 'c', '1'}; const std::array<std::uint8_t, 4> hvc1{'h', 'v', 'c', '1'};
@@ -1206,9 +1193,6 @@ void update_mode_stats(
case VideoSignal::H265Enhanced: case VideoSignal::H265Enhanced:
stats.h265_enhanced_video_messages++; stats.h265_enhanced_video_messages++;
break; break;
case VideoSignal::H265Domestic:
stats.h265_domestic_video_messages++;
break;
case VideoSignal::Unknown: case VideoSignal::Unknown:
default: default:
stats.unknown_video_messages++; stats.unknown_video_messages++;
@@ -1226,9 +1210,6 @@ void update_mode_stats(
if (expected == ExpectMode::H265Enhanced && actual != VideoSignal::H265Enhanced) { if (expected == ExpectMode::H265Enhanced && actual != VideoSignal::H265Enhanced) {
mismatch = true; mismatch = true;
} }
if (expected == ExpectMode::H265Domestic && actual != VideoSignal::H265Domestic) {
mismatch = true;
}
if (!mismatch) { if (!mismatch) {
return; return;
@@ -1252,8 +1233,6 @@ std::uint32_t matching_count(const Stats &stats, ExpectMode mode) {
return stats.h264_video_messages; return stats.h264_video_messages;
case ExpectMode::H265Enhanced: case ExpectMode::H265Enhanced:
return stats.h265_enhanced_video_messages; return stats.h265_enhanced_video_messages;
case ExpectMode::H265Domestic:
return stats.h265_domestic_video_messages;
default: default:
return 0; return 0;
} }
@@ -1663,31 +1642,6 @@ send_client_video_mode_packets(int fd, std::uint32_t chunk_size, std::uint32_t s
}; };
break; 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: default:
return std::unexpected("unsupported self-test mode"); 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_video_messages,
stats.total_data_messages, stats.total_data_messages,
stats.set_chunk_size_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.h264_video_messages,
stats.h265_enhanced_video_messages, stats.h265_enhanced_video_messages,
stats.h265_domestic_video_messages,
stats.unknown_video_messages); stats.unknown_video_messages);
spdlog::info("Matching count for expected mode: {} (threshold={})", spdlog::info("Matching count for expected mode: {} (threshold={})",
matching_count(stats, config.expect_mode), matching_count(stats, config.expect_mode),
+214
View File
@@ -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 <CLI/CLI.hpp>
#include <chrono>
#include <cstdint>
#include <expected>
#include <string>
#include <thread>
#include <vector>
#include <spdlog/spdlog.h>
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<int>(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<Config, int> 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<cvmmap_streamer::CodecType, std::string> 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<cvmmap_streamer::EncoderDeviceType, std::string> 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<std::uint8_t> &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<std::size_t>(y) * width * 3 + static_cast<std::size_t>(x) * 3;
buffer[pixel + 0] = static_cast<std::uint8_t>((x + frame_index * 3) & 0xffu);
buffer[pixel + 1] = static_cast<std::uint8_t>((y * 2 + frame_index * 5) & 0xffu);
buffer[pixel + 2] = static_cast<std::uint8_t>(((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<std::uint8_t>(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<std::uint16_t>(args->width),
.height = static_cast<std::uint16_t>(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<std::uint8_t> 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<const std::uint8_t>(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<std::uint64_t>(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);
}