refactor(streamer): remove gstreamer and legacy rtmp paths
This commit is contained in:
+1
-22
@@ -6,8 +6,6 @@ set(CMAKE_CXX_STANDARD 23)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
option(ENABLE_GSTREAMER_LEGACY "Build the optional legacy GStreamer backend" ON)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
find_package(cppzmq QUIET)
|
||||
if (
|
||||
@@ -55,16 +53,6 @@ if (NOT EXISTS "${CVMMAP_PROXY_INCLUDE_DIR}/proxy/proxy.h")
|
||||
message(FATAL_ERROR "proxy headers not found at ${CVMMAP_PROXY_INCLUDE_DIR}")
|
||||
endif()
|
||||
|
||||
set(CVMMAP_STREAMER_HAS_GSTREAMER 0)
|
||||
if (ENABLE_GSTREAMER_LEGACY)
|
||||
pkg_check_modules(GSTREAMER IMPORTED_TARGET gstreamer-1.0>=1.14 gstreamer-video-1.0>=1.14 gstreamer-app-1.0>=1.14)
|
||||
if (GSTREAMER_FOUND AND TARGET PkgConfig::GSTREAMER)
|
||||
set(CVMMAP_STREAMER_HAS_GSTREAMER 1)
|
||||
else()
|
||||
message(WARNING "GStreamer legacy backend disabled because required GStreamer packages were not found")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_library(cvmmap_streamer_foxglove_proto STATIC)
|
||||
protobuf_generate(
|
||||
TARGET cvmmap_streamer_foxglove_proto
|
||||
@@ -101,11 +89,9 @@ add_library(cvmmap_streamer_common STATIC
|
||||
src/metrics/latency_tracker.cpp
|
||||
src/pipeline/pipeline_runtime.cpp
|
||||
src/protocol/rtmp_output.cpp
|
||||
src/protocol/rtmp_publisher.cpp
|
||||
src/protocol/rtp_publisher.cpp
|
||||
src/encode/encoder_backend.cpp
|
||||
src/encode/ffmpeg_encoder_backend.cpp
|
||||
src/encode/gstreamer_legacy_backend.cpp
|
||||
src/record/protobuf_descriptor.cpp
|
||||
src/record/mcap_record_sink.cpp)
|
||||
|
||||
@@ -118,10 +104,6 @@ target_include_directories(cvmmap_streamer_common
|
||||
"${CVMMAP_PROXY_INCLUDE_DIR}"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}")
|
||||
|
||||
target_compile_definitions(cvmmap_streamer_common
|
||||
PUBLIC
|
||||
CVMMAP_STREAMER_HAS_GSTREAMER=${CVMMAP_STREAMER_HAS_GSTREAMER})
|
||||
|
||||
set(CVMMAP_STREAMER_LINK_DEPS
|
||||
Threads::Threads
|
||||
cvmmap_streamer_foxglove_proto
|
||||
@@ -157,10 +139,6 @@ if (TARGET PkgConfig::PROTOBUF_PKG)
|
||||
list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::PROTOBUF_PKG)
|
||||
endif()
|
||||
|
||||
if (CVMMAP_STREAMER_HAS_GSTREAMER)
|
||||
list(APPEND CVMMAP_STREAMER_LINK_DEPS PkgConfig::GSTREAMER)
|
||||
endif()
|
||||
|
||||
target_link_libraries(cvmmap_streamer_common PUBLIC ${CVMMAP_STREAMER_LINK_DEPS})
|
||||
|
||||
function(add_cvmmap_binary target source)
|
||||
@@ -181,6 +159,7 @@ endfunction()
|
||||
|
||||
add_cvmmap_binary(cvmmap_streamer src/main_streamer.cpp)
|
||||
add_cvmmap_binary(rtp_receiver_tester src/testers/rtp_receiver_tester.cpp)
|
||||
add_cvmmap_binary(rtp_output_tester src/testers/rtp_output_tester.cpp)
|
||||
add_cvmmap_binary(rtmp_stub_tester src/testers/rtmp_stub_tester.cpp)
|
||||
add_cvmmap_binary(rtmp_output_tester src/testers/rtmp_output_tester.cpp)
|
||||
add_cvmmap_binary(ipc_snapshot_tester src/testers/ipc_snapshot_tester.cpp)
|
||||
|
||||
+50
-239
@@ -1,286 +1,97 @@
|
||||
# Operational Caveats
|
||||
|
||||
This document captures known environment constraints, behavioral edge cases, and operational considerations for the cv-mmap streamer.
|
||||
This document captures the current runtime constraints for `cvmmap-streamer` after the FFmpeg-only cleanup.
|
||||
|
||||
## Environment Constraints
|
||||
## Input Model
|
||||
|
||||
### Simulator Label Length Limit
|
||||
### cvmmap URI Only
|
||||
|
||||
The cv-mmap simulator uses POSIX shared memory naming that imposes a 24-byte maximum on the `--label` parameter.
|
||||
|
||||
**Constraint:**
|
||||
- Maximum label length: 24 bytes
|
||||
- Exceeding this causes immediate exit with code 2
|
||||
|
||||
**Error message:**
|
||||
```
|
||||
--label exceeds 24 bytes
|
||||
```
|
||||
|
||||
**Workaround:**
|
||||
Use compact deterministic labels:
|
||||
```bash
|
||||
# Good (19 bytes)
|
||||
./build/cvmmap_streamer --run-mode pipeline --input-mode dummy --dummy-label acc_1_rtp_h264 ...
|
||||
|
||||
# Bad (28 bytes, will fail)
|
||||
./build/cvmmap_streamer --run-mode pipeline --input-mode dummy --dummy-label acceptance_rtp_h264_test ...
|
||||
```
|
||||
|
||||
### Deterministic Simulator Sizing
|
||||
|
||||
Small frame sizes can trigger GStreamer caps negotiation failures before the first encoded access unit on certain hosts.
|
||||
|
||||
**Constraint:**
|
||||
- Minimum recommended frame size: 640x360
|
||||
- Smaller sizes may cause `not-negotiated` pipeline errors
|
||||
|
||||
**Recommended simulator parameters for validation:**
|
||||
```bash
|
||||
./build/cvmmap_streamer \
|
||||
--run-mode pipeline \
|
||||
--input-mode dummy \
|
||||
--dummy-label acc_1_rtp_h264 \
|
||||
--dummy-width 640 \
|
||||
--dummy-height 360 \
|
||||
--dummy-fps 200 \
|
||||
--dummy-frames 320
|
||||
```
|
||||
|
||||
### Build Path Isolation
|
||||
|
||||
The downstream project must use its own build directory. Sharing the root `build/` folder with the main cv-mmap project causes cache collision.
|
||||
|
||||
**Constraint:**
|
||||
- Use `downstream/cvmmap-streamer/build`
|
||||
- Do not use root `build/`
|
||||
|
||||
**Error symptom:**
|
||||
Configure errors referencing sibling repo paths or stale cache entries.
|
||||
|
||||
**Resolution:**
|
||||
```bash
|
||||
cmake --fresh -B downstream/cvmmap-streamer/build -S downstream/cvmmap-streamer
|
||||
```
|
||||
|
||||
### GStreamer Version Requirements
|
||||
|
||||
The NVENC pipeline requires GStreamer 1.20+ with full development headers. Missing elements are detected at configure time.
|
||||
|
||||
**Constraint:**
|
||||
- GStreamer 1.20+ required
|
||||
- Development headers required for build
|
||||
- `nvh264enc` and `nvh265enc` elements checked at runtime
|
||||
|
||||
**Missing NVENC error:**
|
||||
```
|
||||
FATAL: NVENC encoder not available (nvh264enc/nvh265enc)
|
||||
Run: gst-inspect-1.0 nvh264enc nvh265enc
|
||||
```
|
||||
|
||||
**Note:** The pipeline falls back to software encoding (`x264enc`, `x265enc`) if NVENC produces zero encoded access units after 60 frames.
|
||||
|
||||
## Dual-Mode H.265 RTMP Caveats
|
||||
|
||||
### Mode Selection is Binding
|
||||
|
||||
Once selected, the RTMP mode cannot be changed without restarting the streamer process. The mode determines packetization format for the entire session.
|
||||
|
||||
**Enhanced-RTMP mode:**
|
||||
- Uses `0x90`/`0x91` header bytes
|
||||
- FourCC signaling (`hvc1`)
|
||||
- Compatible with FFmpeg 6.0+, OBS 29+, SRS 6.0+, ZLMediaKit
|
||||
|
||||
**Domestic extension mode:**
|
||||
- Uses `0x1c`/`0x2c` header bytes
|
||||
- FLV codec-id 12 signaling
|
||||
- Legacy Chinese CDN compatibility
|
||||
- Not supported by all players
|
||||
|
||||
### Server Configuration Requirements
|
||||
|
||||
**ZLMediaKit:**
|
||||
Must set `enhanced=1` in config.ini for Enhanced-RTMP mode:
|
||||
```ini
|
||||
[rtmp]
|
||||
port=1935
|
||||
enhanced=1 # 1 = Enhanced RTMP, 0 = Domestic extension
|
||||
```
|
||||
|
||||
**SRS:**
|
||||
Use `hevc.flv.conf` or `hevc.ts.conf` for HEVC support:
|
||||
```bash
|
||||
docker run --rm -it -p 1935:1935 ossrs/srs:6 \
|
||||
./objs/srs -c conf/hevc.flv.conf
|
||||
```
|
||||
|
||||
### Player Compatibility
|
||||
|
||||
| Player | H.264 RTMP | H.265 Enhanced | H.265 Domestic |
|
||||
|--------|------------|----------------|----------------|
|
||||
| FFmpeg 6.0+ | Yes | Yes | With patch |
|
||||
| ffplay | Yes | Yes | Variable |
|
||||
| VLC 3.0+ | Yes | Yes | No |
|
||||
| Chrome 105+ (MSE) | Yes | HTTP-FLV/TS only | No |
|
||||
| OBS 29+ | Yes | Yes | No |
|
||||
|
||||
### Mode Mismatch Detection
|
||||
|
||||
The `rtmp_stub_tester` validates mode expectations and fails with exit code 6 on mismatch:
|
||||
The runtime ingests from a cvmmap URI, for example:
|
||||
|
||||
```bash
|
||||
# Start stub expecting domestic mode
|
||||
./build/rtmp_stub_tester --mode h265-domestic ...
|
||||
|
||||
# Streamer sends enhanced mode
|
||||
./build/cvmmap_streamer --codec h265 --rtmp-mode enhanced ...
|
||||
|
||||
# Result: tester exits 6 (ModeMismatch)
|
||||
./build/cvmmap_streamer --run-mode pipeline --input-uri 'cvmmap://zed@/tmp/cvmmap'
|
||||
```
|
||||
|
||||
## Low-Latency Configuration Trade-offs
|
||||
Legacy flags such as `--shm-name`, `--zmq-endpoint`, `--input-mode`, and the dummy-input options are no longer supported.
|
||||
|
||||
### Queue Size
|
||||
## Encoder Path
|
||||
|
||||
| Setting | Latency | Behavior |
|
||||
|---------|---------|----------|
|
||||
| `--queue-size 1` | Lowest | Latest-frame semantics, drops old frames |
|
||||
| `--queue-size 5` | Medium | Small backlog, some frame retention |
|
||||
| `--queue-size 0` | N/A | Unbounded (NOT recommended) |
|
||||
### FFmpeg Is The Only Encoder Backend
|
||||
|
||||
### Encoder Settings
|
||||
The public backend surface is:
|
||||
|
||||
| Setting | Low-Latency Value | Trade-off |
|
||||
|---------|-------------------|-----------|
|
||||
| `--b-frames 0` | Disabled | Slightly lower compression efficiency |
|
||||
| `--gop 30` | 1 second at 30fps | Larger GOP = better compression, higher seek latency |
|
||||
| `rc-lookahead` | 0 (NVENC) | Slightly lower quality prediction |
|
||||
| `zerolatency` | true | Disables encoder buffering |
|
||||
- `--encoder-backend auto`
|
||||
- `--encoder-backend ffmpeg`
|
||||
|
||||
### Server-Side Latency (Optional)
|
||||
Both resolve to the FFmpeg encoder path. The removed GStreamer backend is no longer available.
|
||||
|
||||
When using SRS or ZLMediaKit, additional latency can be introduced by server buffering.
|
||||
### NVENC Is Optional
|
||||
|
||||
**SRS low-latency settings:**
|
||||
```
|
||||
publish { mr off; }
|
||||
play { gop_cache off; queue_length 5; tcp_nodelay on; }
|
||||
When `--encoder-device nvidia` is selected, FFmpeg must expose `h264_nvenc` and `hevc_nvenc`.
|
||||
|
||||
Useful local checks:
|
||||
|
||||
```bash
|
||||
ffmpeg -hide_banner -encoders | rg 'nvenc|libx264|libx265'
|
||||
```
|
||||
|
||||
**Trade-offs:**
|
||||
- `gop_cache off`: Players wait for next I-frame (startup delay)
|
||||
- `mr off`: Higher CPU usage
|
||||
- `queue_length 5`: More susceptible to network jitter
|
||||
If NVENC is unavailable, use:
|
||||
|
||||
## Fault Scenario Behaviors
|
||||
|
||||
### Torn Frame Handling
|
||||
|
||||
When the coherent snapshot detects a torn read (metadata changed during copy):
|
||||
|
||||
1. Frame is dropped
|
||||
2. `torn_frames` counter increments
|
||||
3. Ingest loop continues
|
||||
4. Next sync message triggers new snapshot attempt
|
||||
|
||||
**Expected log:**
|
||||
```
|
||||
SNAPSHOT_TORN frame_count=A/B timestamp=X/Y
|
||||
```bash
|
||||
--encoder-device software
|
||||
```
|
||||
|
||||
### Stream Reset Handling
|
||||
### Low-Latency Defaults
|
||||
|
||||
When `MODULE_STATUS_STREAM_RESET` is received:
|
||||
The current low-latency defaults are:
|
||||
|
||||
1. Ingest queue is flushed
|
||||
2. Frame counters reset
|
||||
3. Pipeline may rebuild if resolution/format changed
|
||||
4. RTMP publishers send sequence header rebase
|
||||
5. `resets` counter increments
|
||||
- `gop=30`
|
||||
- `b_frames=0`
|
||||
- NVENC preset/tune tuned for low latency
|
||||
|
||||
**Expected log:**
|
||||
```
|
||||
STREAM_RESET_RECEIVED
|
||||
RTMP_STREAM_RESET_REBASE mode=<mode>
|
||||
```
|
||||
This favors immediacy over compression efficiency.
|
||||
|
||||
### Backpressure Containment
|
||||
## Transport Caveats
|
||||
|
||||
When downstream sinks are slower than ingest:
|
||||
### RTMP Is Enhanced RTMP Only
|
||||
|
||||
1. Queue fills to capacity
|
||||
2. Oldest frames evicted before push
|
||||
3. `dropped_frames` counter increments
|
||||
4. Latest frame always prioritized
|
||||
5. Latency remains bounded
|
||||
The repo now supports:
|
||||
|
||||
**Expected log:**
|
||||
```
|
||||
QUEUE_DROP dropped_frames=N queue_depth=1
|
||||
```
|
||||
- `libavformat` RTMP output
|
||||
- `ffmpeg_process` RTMP output
|
||||
|
||||
## Known Limitations
|
||||
The removed custom RTMP packetizer and `domestic` mode are no longer available. RTMP is always Enhanced RTMP.
|
||||
|
||||
### No Audio Support
|
||||
### RTP SDP Is Optional
|
||||
|
||||
Version 1.0 does not support audio capture or muxing. Video-only streams.
|
||||
The RTP publisher can write an SDP sidecar when `--rtp-sdp` is set. This is only for generic receivers that need an out-of-band description. If the consumer is preconfigured, skip the SDP output.
|
||||
|
||||
### No Direct RTSP/WebRTC Publishing
|
||||
|
||||
RTSP and WebRTC are not direct publisher outputs. They require server-side conversion from RTMP or RTP.
|
||||
This repo does not contain a direct RTSP publisher or a direct WebRTC/WHEP publisher.
|
||||
|
||||
### Single Codec Per Session
|
||||
If you want browser playback or WHEP, publish RTMP to a media server such as SRS and let that server expose WHEP/WebRTC.
|
||||
|
||||
Runtime codec switching is not supported. To change codecs, restart the streamer process.
|
||||
## Media Scope
|
||||
|
||||
### NVENC Requires NVIDIA GPU
|
||||
### No Audio Support
|
||||
|
||||
NVENC hardware encoding requires an NVIDIA GPU with encode support. Falls back to software encoding on non-NVIDIA systems or when NVENC is unavailable.
|
||||
This project is video-only. It publishes or records encoded video access units only.
|
||||
|
||||
### UDP RTP Only
|
||||
If you need audio transport, muxing, or A/V sync, that is outside the current repo scope.
|
||||
|
||||
RTP output uses UDP unicast only. No multicast or TCP interleaving support in v1.
|
||||
## Recording Caveats
|
||||
|
||||
## Debugging Tips
|
||||
### MCAP Stores Encoded Video Frames
|
||||
|
||||
### Enable Verbose Logging
|
||||
MCAP recording stores one `foxglove.CompressedVideo` message per encoded access unit. Replay depends on encoded keyframes carrying decoder configuration; this is handled by the current writer on keyframes.
|
||||
|
||||
All binaries use spdlog. Set the environment variable for debug output:
|
||||
## External Server Caveats
|
||||
|
||||
```bash
|
||||
export SPDLOG_LEVEL=debug
|
||||
./build/cvmmap_streamer ...
|
||||
```
|
||||
### Local SRS Defaults Can Hit `ulimit`
|
||||
|
||||
### Check Evidence Logs
|
||||
On this machine, stock SRS configs can fail at startup if `max_connections` exceeds the local `ulimit -n`. The checked-in smoke profile uses `max_connections 64` to avoid that local failure mode.
|
||||
|
||||
Failed runs leave detailed logs:
|
||||
### Server Latency Still Matters
|
||||
|
||||
```bash
|
||||
# Find latest run
|
||||
ls -lt .sisyphus/evidence/task-14-acceptance/ | head
|
||||
|
||||
# Examine specific row logs
|
||||
cat .sisyphus/evidence/task-14-acceptance/RUN_ID/1-rtp_h264/streamer.log
|
||||
```
|
||||
|
||||
### Verify Binary Existence
|
||||
|
||||
Before running scripts, verify all binaries are built:
|
||||
|
||||
```bash
|
||||
for bin in cvmmap_streamer rtp_receiver_tester rtmp_stub_tester; do
|
||||
test -x "build/$bin" || echo "Missing: $bin"
|
||||
done
|
||||
```
|
||||
|
||||
### Test Individual Components
|
||||
|
||||
```bash
|
||||
# Test simulator only
|
||||
./build/cvmmap_streamer --run-mode pipeline --help
|
||||
|
||||
# Test streamer config validation only
|
||||
./build/cvmmap_streamer --codec h264 --shm-name test --zmq-endpoint ipc:///tmp/test.ipc
|
||||
# (Will fail with "No output enabled" but validates config parsing)
|
||||
```
|
||||
Low encoder latency does not guarantee low end-to-end latency. SRS, ZLMediaKit, HTTP-FLV players, and browser buffering can all add delay on top of the encoder path.
|
||||
|
||||
+52
-166
@@ -1,188 +1,74 @@
|
||||
# Compatibility Matrix
|
||||
|
||||
This document defines the complete protocol, codec, and RTMP mode compatibility matrix for cv-mmap streamer. It explicitly separates mandatory (blocking) checks from optional (non-blocking) checks.
|
||||
## Mandatory Acceptance Matrix
|
||||
|
||||
## Mandatory Checks (Blocking for Release)
|
||||
The deterministic acceptance matrix is driven by tester binaries, not by a dummy pipeline source.
|
||||
|
||||
These checks must ALL pass for a release to be considered valid. They require no external servers and run entirely in standalone mode.
|
||||
Run it with:
|
||||
|
||||
### Protocol/Codec Matrix
|
||||
```bash
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
./scripts/acceptance_standalone.sh
|
||||
```
|
||||
|
||||
| Row | Protocol | Codec | RTMP Mode | Test Command | Evidence |
|
||||
|-----|----------|-------|-----------|--------------|----------|
|
||||
| 1 | RTP | H.264 | N/A | `./scripts/acceptance_standalone.sh` (row 1) | task-14-acceptance.txt |
|
||||
| 2 | RTP | H.265 | N/A | `./scripts/acceptance_standalone.sh` (row 2) | task-14-acceptance.txt |
|
||||
| 3 | RTMP | H.264 | enhanced | `./scripts/acceptance_standalone.sh` (row 3) | task-14-acceptance.txt |
|
||||
| 4 | RTMP | H.265 | enhanced | `./scripts/acceptance_standalone.sh` (row 4) | task-14-acceptance.txt |
|
||||
| 5 | RTMP | H.265 | domestic | `./scripts/acceptance_standalone.sh` (row 5) | task-14-acceptance.txt |
|
||||
Covered rows:
|
||||
|
||||
**Pass Criteria:**
|
||||
- Exit code 0 from `acceptance_standalone.sh`
|
||||
- JSON summary shows `total=5 pass=5 fail=0 skip=0`
|
||||
- All tester processes receive expected packet/frame counts
|
||||
| Row | Protocol | Codec | Transport | Runner |
|
||||
|-----|----------|-------|-----------|--------|
|
||||
| 1 | RTP | H.264 | UDP RTP | `./scripts/acceptance_standalone.sh` |
|
||||
| 2 | RTP | H.265 | UDP RTP | `./scripts/acceptance_standalone.sh` |
|
||||
| 3 | RTMP | H.264 | `libavformat` | `./scripts/acceptance_standalone.sh` |
|
||||
| 4 | RTMP | H.265 | `libavformat` | `./scripts/acceptance_standalone.sh` |
|
||||
| 5 | RTMP | H.264 | `ffmpeg_process` | `./scripts/acceptance_standalone.sh` |
|
||||
| 6 | RTMP | H.265 | `ffmpeg_process` | `./scripts/acceptance_standalone.sh` |
|
||||
|
||||
### Fault Scenarios
|
||||
Notes:
|
||||
|
||||
| Scenario | Description | Test Command | Evidence |
|
||||
|----------|-------------|--------------|----------|
|
||||
| Torn Read | Coherent snapshot rejects torn frames | `./scripts/fault_suite.sh` | task-15-fault-suite.txt |
|
||||
| Sink Stall | Backpressure containment under slow consumer | `./scripts/fault_suite.sh` | task-15-fault-suite.txt |
|
||||
| Reset Storm | Stream reset recovery | `./scripts/fault_suite.sh` | task-15-fault-suite.txt |
|
||||
|
||||
**Pass Criteria:**
|
||||
- Exit code 0 from `fault_suite.sh`
|
||||
- All fault scenarios PASS status
|
||||
- No violations of latency/drop thresholds
|
||||
|
||||
## Invalid Combinations (Explicitly Rejected)
|
||||
|
||||
These combinations are rejected at startup with clear error messages and non-zero exit codes.
|
||||
|
||||
| Combination | Error | Exit Code |
|
||||
|-------------|-------|-----------|
|
||||
| RTMP + H.264 + domestic mode | H.264 does not support domestic mode | 1 |
|
||||
| RTP without --rtp-endpoint | Missing required RTP endpoint | 1 |
|
||||
| RTMP without --rtmp-url | Missing required RTMP URL | 1 |
|
||||
| --rtmp-mode without H.265 codec | Domestic mode requires H.265 | 1 |
|
||||
- RTMP is Enhanced RTMP only.
|
||||
- The custom RTMP packetizer and domestic mode are removed.
|
||||
- `encoder.backend` remains `auto|ffmpeg`; both resolve to FFmpeg.
|
||||
|
||||
## Optional Checks (Non-Blocking)
|
||||
|
||||
These checks are provided for interoperability validation but are NOT required for release acceptance. If the environment is unavailable, these should be skipped.
|
||||
These are useful interoperability checks, but they are not part of the mandatory acceptance gate:
|
||||
|
||||
### Server Smoke Tests
|
||||
- `./scripts/rtmp_srs_smoke.sh`
|
||||
- `./scripts/live_srs_forward_smoke.sh '<cvmmap-uri>'`
|
||||
- manual WHEP playback through SRS
|
||||
- manual ZLMediaKit interoperability checks
|
||||
- live MCAP capture, validation, and paced replay
|
||||
|
||||
| Server | Protocols | H.265 Support | Status |
|
||||
|--------|-----------|---------------|--------|
|
||||
| SRS 6.0+ | RTMP, HTTP-FLV, HLS, WebRTC | Enhanced-RTMP | Optional |
|
||||
| ZLMediaKit | RTMP, HTTP-FLV, HTTP-TS, RTSP, WebRTC | Enhanced + Domestic | Optional |
|
||||
## Recording Coverage
|
||||
|
||||
**Skip Conditions:**
|
||||
- Docker not available
|
||||
- Port 1935 in use by another service
|
||||
- Server container fails to start
|
||||
- Network isolation prevents connection
|
||||
MCAP support is validated separately with:
|
||||
|
||||
**Expected Behavior When Skipped:**
|
||||
- Script exits with status 0 (SKIP)
|
||||
- Evidence file notes the skip reason
|
||||
- Mandatory acceptance still proceeds
|
||||
- `./build/mcap_reader_tester`
|
||||
- `./build/mcap_replay_tester`
|
||||
- `./scripts/replay_mcap.sh`
|
||||
|
||||
## RTMP H.265 Dual Mode Details
|
||||
Current recording scope:
|
||||
|
||||
### Enhanced-RTMP Mode (Recommended)
|
||||
| Output | Codec | Notes |
|
||||
|--------|-------|-------|
|
||||
| MCAP `foxglove.CompressedVideo` | H.264 | Stored as Annex B access units |
|
||||
| MCAP `foxglove.CompressedVideo` | H.265 | Stored as Annex B access units |
|
||||
|
||||
**Specification:** [Enhanced RTMP](https://github.com/veovera/enhanced-rtmp)
|
||||
## Current Defaults
|
||||
|
||||
**Characteristics:**
|
||||
- Video codec header byte: `0x90` (sequence) / `0x91` (frame)
|
||||
- FourCC: `hvc1` or `hev1`
|
||||
- Standardized, widely supported
|
||||
- FFmpeg 6.0+ native support
|
||||
- OBS 29+ support
|
||||
- SRS 6.0+ native support
|
||||
- ZLMediaKit default mode
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Encoder backend | `auto` -> FFmpeg |
|
||||
| RTMP transport | `libavformat` |
|
||||
| RTMP mode | Enhanced only |
|
||||
| Encoder device | `auto` |
|
||||
| Low-latency GOP | `30` |
|
||||
| B-frames | `0` |
|
||||
|
||||
**When to use:**
|
||||
- Greenfield deployments
|
||||
- Modern CDN ingestion
|
||||
- Cross-platform compatibility requirements
|
||||
## What Is No Longer Supported
|
||||
|
||||
### Domestic Extension Mode (Legacy Compatibility)
|
||||
|
||||
**Specification:** Proprietary FLV extension using codec-id 12
|
||||
|
||||
**Characteristics:**
|
||||
- Video codec header byte: `0x1c` (keyframe) / `0x2c` (inter)
|
||||
- FLV codec-id: 12 (non-standard)
|
||||
- Legacy Chinese CDN compatibility
|
||||
- Requires explicit config in ZLMediaKit (`enhanced=0`)
|
||||
- Not supported by all players
|
||||
|
||||
**When to use:**
|
||||
- Legacy CDN requirements
|
||||
- Existing domestic-mode infrastructure
|
||||
- Backward compatibility with older systems
|
||||
|
||||
### Mode Selection Decision Tree
|
||||
|
||||
```
|
||||
Is codec H.265?
|
||||
├── No (H.264) ──> Use enhanced mode only (domestic invalid)
|
||||
└── Yes (H.265)
|
||||
├── Target is modern CDN/player? ──> enhanced
|
||||
├── Target requires domestic mode? ──> domestic
|
||||
└── Unknown? ──> enhanced (safer default)
|
||||
```
|
||||
|
||||
## Validation Commands
|
||||
|
||||
### Verify Mandatory Matrix
|
||||
|
||||
```bash
|
||||
cd downstream/cvmmap-streamer
|
||||
./scripts/acceptance_standalone.sh
|
||||
echo "Exit code: $?"
|
||||
```
|
||||
|
||||
### Verify Individual Row (RTP H.264)
|
||||
|
||||
```bash
|
||||
# Terminal 1: Tester
|
||||
./build/rtp_receiver_tester \
|
||||
--port 51040 \
|
||||
--expect-pt 96 \
|
||||
--packet-threshold 1 \
|
||||
--timeout-ms 10000
|
||||
|
||||
# Terminal 2: Streamer in dummy input mode
|
||||
./build/cvmmap_streamer \
|
||||
--run-mode pipeline \
|
||||
--input-mode dummy \
|
||||
--shm-name test_rtp_h264 \
|
||||
--zmq-endpoint "ipc:///tmp/test_rtp_h264.ipc" \
|
||||
--dummy-label rtp_h264 \
|
||||
--dummy-frames 320 \
|
||||
--dummy-fps 200 \
|
||||
--dummy-width 640 \
|
||||
--dummy-height 360 &
|
||||
|
||||
./build/cvmmap_streamer \
|
||||
--run-mode pipeline \
|
||||
--codec h264 \
|
||||
--shm-name test_rtp_h264 \
|
||||
--zmq-endpoint "ipc:///tmp/test_rtp_h264.ipc" \
|
||||
--queue-size 1 \
|
||||
--gop 30 \
|
||||
--b-frames 0 \
|
||||
--ingest-max-frames 120 \
|
||||
--rtp \
|
||||
--rtp-endpoint "127.0.0.1:51040" \
|
||||
--rtp-payload-type 96 \
|
||||
--rtp-sdp /tmp/test_rtp_h264.sdp
|
||||
```
|
||||
|
||||
### Verify Invalid Combination Rejection
|
||||
|
||||
```bash
|
||||
# Should fail with clear error
|
||||
./build/cvmmap_streamer \
|
||||
--codec h264 \
|
||||
--rtmp-mode domestic \
|
||||
--shm-name test \
|
||||
--zmq-endpoint "ipc:///tmp/test.ipc"
|
||||
|
||||
echo "Exit code: $?" # Expected: 2 (invalid mode matrix)
|
||||
```
|
||||
|
||||
## Evidence Locations
|
||||
|
||||
All test evidence is written to `.sisyphus/evidence/`:
|
||||
|
||||
| Evidence File | Description |
|
||||
|---------------|-------------|
|
||||
| task-14-acceptance.txt | Latest acceptance run pointer |
|
||||
| task-14-acceptance-summary.json | Acceptance summary JSON |
|
||||
| task-14-acceptance/RUN_ID/ | Per-run logs for each matrix row |
|
||||
| task-15-fault-suite.txt | Latest fault suite run pointer |
|
||||
| task-15-fault-suite-summary.json | Fault suite summary JSON |
|
||||
| task-15-fault-suite/RUN_ID/ | Per-run logs for each scenario |
|
||||
- GStreamer encoder backend
|
||||
- custom RTMP packetizer
|
||||
- RTMP domestic mode
|
||||
- dummy input flags in the main runtime
|
||||
- direct in-repo RTSP/WebRTC publishing
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Local SRS config for manual RTMP and WHEP testing with cvmmap-streamer.
|
||||
# Start with:
|
||||
# cd ~/Code/srs/trunk
|
||||
# ./objs/srs -c /home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf
|
||||
# ./objs/srs -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf
|
||||
#
|
||||
# If WebRTC/WHEP is consumed from another host and SRS chooses the wrong NIC,
|
||||
# replace `candidate *;` with the reachable host IP.
|
||||
|
||||
+9
-39
@@ -21,8 +21,6 @@ Use these checks when you want to verify RTMP interoperability or a real cvmmap-
|
||||
|
||||
## Reproducible Test: Synthetic RTMP Matrix
|
||||
|
||||
This is the fast interoperability smoke for the RTMP sink implementations:
|
||||
|
||||
```bash
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
@@ -42,12 +40,10 @@ What it verifies:
|
||||
- `ffmpeg_process` RTMP output with `h264`
|
||||
- `libavformat` RTMP output with `h265`
|
||||
- `ffmpeg_process` RTMP output with `h265`
|
||||
- Pullback from SRS HTTP-FLV with `ffprobe`
|
||||
- pullback from SRS HTTP-FLV with `ffprobe`
|
||||
|
||||
## Reproducible Test: Live cvmmap Forward
|
||||
|
||||
This is the end-to-end live test for a real cvmmap source:
|
||||
|
||||
```bash
|
||||
cmake -B build -S .
|
||||
cmake --build build
|
||||
@@ -60,7 +56,6 @@ The script defaults to:
|
||||
- `ENCODER_BACKEND=ffmpeg`
|
||||
- `ENCODER_DEVICE=nvidia`
|
||||
- `RTMP_TRANSPORT=libavformat`
|
||||
- `RTMP_MODE=enhanced`
|
||||
- `INGEST_MAX_FRAMES=120`
|
||||
- `STREAM_NAME=<instance>_live`, derived from `INPUT_URI`
|
||||
|
||||
@@ -69,7 +64,7 @@ Useful overrides:
|
||||
```bash
|
||||
INPUT_URI='cvmmap://zed@/tmp/cvmmap' ./scripts/live_srs_forward_smoke.sh
|
||||
./scripts/live_srs_forward_smoke.sh 'cvmmap://front_cam@/tmp/cvmmap' front_cam_smoke
|
||||
ENCODER_DEVICE=software ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap'
|
||||
ENCODER_BACKEND=auto ENCODER_DEVICE=software ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap'
|
||||
STREAM_NAME=zed_test INGEST_MAX_FRAMES=300 ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap'
|
||||
SRS_ROOT=~/Code/srs ./scripts/live_srs_forward_smoke.sh 'cvmmap://zed@/tmp/cvmmap'
|
||||
```
|
||||
@@ -78,9 +73,7 @@ What it does:
|
||||
|
||||
1. Reuses a healthy local SRS instance if one is already listening on `127.0.0.1:1985`
|
||||
2. Otherwise starts SRS from `~/Code/srs`
|
||||
3. Writes a temporary SRS config with:
|
||||
- `daemon off`
|
||||
- `max_connections` reduced to avoid the common local `ulimit -n 1024` failure
|
||||
3. Writes a temporary SRS config with `daemon off` and a lowered `max_connections`
|
||||
4. Publishes the cvmmap stream to `rtmp://127.0.0.1/live/<stream>`
|
||||
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`
|
||||
@@ -99,7 +92,7 @@ The HTTP-FLV probe is the authoritative pass/fail signal. The optional `ffmpeg`
|
||||
|
||||
SRS supports WHEP playback, and it is the better path when RTMP or HTTP-FLV playback feels too slow.
|
||||
|
||||
This repo includes a local SRS config copy at [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf). It enables:
|
||||
This repo includes a local SRS config copy at [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf). It enables:
|
||||
|
||||
- RTMP publish on `:1935`
|
||||
- HTTP API on `:1985`
|
||||
@@ -111,14 +104,14 @@ Start SRS with that config:
|
||||
|
||||
```bash
|
||||
cd ~/Code/srs/trunk
|
||||
./objs/srs -t -c /home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf
|
||||
./objs/srs -c /home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf
|
||||
./objs/srs -t -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf
|
||||
./objs/srs -c /home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf
|
||||
```
|
||||
|
||||
Publish from the streamer:
|
||||
|
||||
```bash
|
||||
cd ~/Code/cvmmap-streamer-ffmpeg-mcap
|
||||
cd ~/Code/cvmmap-streamer
|
||||
./build/cvmmap_streamer \
|
||||
--run-mode pipeline \
|
||||
--input-uri 'cvmmap://zed@/tmp/cvmmap' \
|
||||
@@ -127,8 +120,7 @@ cd ~/Code/cvmmap-streamer-ffmpeg-mcap
|
||||
--encoder-device nvidia \
|
||||
--rtmp \
|
||||
--rtmp-url 'rtmp://127.0.0.1/live/zed_live' \
|
||||
--rtmp-transport libavformat \
|
||||
--rtmp-mode enhanced
|
||||
--rtmp-transport libavformat
|
||||
```
|
||||
|
||||
Open the WHEP player page in a browser:
|
||||
@@ -140,20 +132,7 @@ Notes:
|
||||
|
||||
- VLC is not the right client for WHEP. Use a browser or another WebRTC/WHEP-capable player.
|
||||
- Keep the stream codec on `h264` for browser compatibility.
|
||||
- If the browser runs on another host and SRS picks the wrong NIC, replace `candidate *;` in [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer-ffmpeg-mcap/docs/smoke/srs.local.conf) with the reachable host IP.
|
||||
- On this machine, likely candidates are `192.168.2.13` or `192.168.2.184`.
|
||||
|
||||
## Example Live Result
|
||||
|
||||
For a ZED source running at `cvmmap://zed@/tmp/cvmmap`, the successful forward probe looked like:
|
||||
|
||||
```text
|
||||
index=0
|
||||
codec_name=h264
|
||||
width=1280
|
||||
height=720
|
||||
avg_frame_rate=30/1
|
||||
```
|
||||
- If the browser runs on another host and SRS picks the wrong NIC, replace `candidate *;` in [srs.local.conf](/home/crosstyan/Code/cvmmap-streamer/docs/smoke/srs.local.conf) with the reachable host IP.
|
||||
|
||||
## Artifacts
|
||||
|
||||
@@ -165,15 +144,6 @@ Both smoke scripts write evidence under `build/`:
|
||||
- `live_srs_forward_smoke_*/ffmpeg_decode_httpflv.log`
|
||||
- `live_srs_forward_smoke_*/srs_api_streams.json`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Action |
|
||||
|-------|-------|--------|
|
||||
| SRS exits immediately | `max_connections` too high for local `ulimit` | Use the script-generated config or raise `ulimit -n` |
|
||||
| HTTP-FLV never mounts | Publish failed or source is idle | Check `streamer.log` and confirm the cvmmap URI is active |
|
||||
| NVENC unavailable | FFmpeg cannot open `h264_nvenc` / `hevc_nvenc` | Set `ENCODER_DEVICE=software` |
|
||||
| RTMP probe hangs | Live RTMP playback probe can block on some builds | Use the HTTP-FLV verification path; this is what the script treats as authoritative |
|
||||
|
||||
## References
|
||||
|
||||
- [SRS RTMP Documentation](https://ossrs.io/lts/en-us/docs/v7/doc/rtmp)
|
||||
|
||||
+48
-198
@@ -4,230 +4,80 @@
|
||||
|
||||
**OPTIONAL / NON-BLOCKING**
|
||||
|
||||
This document provides optional interoperability checks for ZLMediaKit. These checks are not mandatory for acceptance. If the ZLMediaKit environment is unavailable, skip these tests without failing the mandatory acceptance criteria.
|
||||
Use these checks when you want to verify external interoperability with ZLMediaKit. They are not part of the mandatory acceptance matrix.
|
||||
|
||||
---
|
||||
## Current Scope
|
||||
|
||||
## Purpose
|
||||
This repo now emits:
|
||||
|
||||
Validate RTMP streaming interoperability with ZLMediaKit, specifically for:
|
||||
- RTMP H.264 publishing and playback
|
||||
- Enhanced RTMP HEVC (H.265) support
|
||||
- Low-latency streaming configurations
|
||||
- HTTP-FLV, HTTP-TS, and WebRTC protocols
|
||||
- RTP
|
||||
- Enhanced RTMP through `libavformat` or `ffmpeg_process`
|
||||
- MCAP recording
|
||||
|
||||
---
|
||||
It does not contain:
|
||||
|
||||
## Prerequisites
|
||||
- a custom RTMP packetizer
|
||||
- RTMP domestic mode
|
||||
- direct in-repo RTSP publishing
|
||||
- direct in-repo WebRTC publishing
|
||||
- audio support
|
||||
|
||||
- Docker (recommended) or ZLMediaKit built from source
|
||||
- FFmpeg or the project streaming binary
|
||||
- Optional: ffplay, VLC for playback verification
|
||||
## Quick Start
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Docker)
|
||||
Run ZLMediaKit in Docker:
|
||||
|
||||
```bash
|
||||
# Run ZLMediaKit with default configuration
|
||||
docker run --rm -it -p 1935:1935 -p 8080:80 -p 8554:554 \
|
||||
-p 10000:10000 -p 30000-30500:30000-30500/udp \
|
||||
zlmediakit/zlmediakit:master
|
||||
|
||||
# With custom config (mount your config.ini)
|
||||
docker run --rm -it -p 1935:1935 -p 8080:80 \
|
||||
-v $(pwd)/config.ini:/opt/media/conf/config.ini \
|
||||
docker run --rm -it \
|
||||
-p 1935:1935 \
|
||||
-p 8080:80 \
|
||||
-p 8000:8000/udp \
|
||||
zlmediakit/zlmediakit:master
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Smoke Commands
|
||||
|
||||
### 1. Basic RTMP H.264 Stream Test
|
||||
## Publish From `cvmmap-streamer`
|
||||
|
||||
```bash
|
||||
# Publish test stream to ZLMediaKit
|
||||
ffmpeg -re -f lavfi -i testsrc=duration=60:size=1280x720:rate=30 \
|
||||
-f lavfi -i sine=frequency=1000:duration=60 \
|
||||
-pix_fmt yuv420p -c:v libx264 -preset fast -b:v 3000k \
|
||||
-c:a aac -b:a 128k -f flv rtmp://localhost/live/smoke_test
|
||||
cd ~/Code/cvmmap-streamer
|
||||
./build/cvmmap_streamer \
|
||||
--run-mode pipeline \
|
||||
--input-uri 'cvmmap://zed@/tmp/cvmmap' \
|
||||
--codec h264 \
|
||||
--encoder-backend ffmpeg \
|
||||
--encoder-device nvidia \
|
||||
--rtmp \
|
||||
--rtmp-url 'rtmp://127.0.0.1/live/zlm_smoke' \
|
||||
--rtmp-transport libavformat
|
||||
```
|
||||
|
||||
For an external fallback/oracle path:
|
||||
|
||||
```bash
|
||||
# Playback via RTMP
|
||||
ffplay rtmp://localhost/live/smoke_test
|
||||
|
||||
# Playback via HTTP-FLV
|
||||
ffplay http://localhost:8080/live/smoke_test.live.flv
|
||||
|
||||
# Playback via HTTP-TS
|
||||
ffplay http://localhost:8080/live/smoke_test.live.ts
|
||||
./build/cvmmap_streamer \
|
||||
--run-mode pipeline \
|
||||
--input-uri 'cvmmap://zed@/tmp/cvmmap' \
|
||||
--codec h265 \
|
||||
--encoder-backend ffmpeg \
|
||||
--encoder-device software \
|
||||
--rtmp \
|
||||
--rtmp-url 'rtmp://127.0.0.1/live/zlm_smoke_h265' \
|
||||
--rtmp-transport ffmpeg_process
|
||||
```
|
||||
|
||||
### 2. Enhanced RTMP HEVC (H.265) Test
|
||||
|
||||
**Prerequisites:**
|
||||
- ZLMediaKit with HEVC support enabled in config
|
||||
- FFmpeg 6.0+ with libx265 and enhanced RTMP support
|
||||
|
||||
**Configuration in config.ini:**
|
||||
```ini
|
||||
[rtmp]
|
||||
port=1935
|
||||
# h265 rtmp packing: 1 = Enhanced RTMP (standard), 0 = Domestic extension
|
||||
enhanced=1
|
||||
|
||||
[protocol]
|
||||
enable_rtmp=1
|
||||
```
|
||||
## Playback Checks
|
||||
|
||||
```bash
|
||||
# Publish HEVC stream (Enhanced RTMP)
|
||||
ffmpeg -re -f lavfi -i testsrc=duration=60:size=1280x720:rate=30 \
|
||||
-f lavfi -i sine=frequency=1000:duration=60 \
|
||||
-pix_fmt yuv420p -c:v libx265 -preset fast -b:v 2000k \
|
||||
-c:a aac -b:a 128k -f flv rtmp://localhost/live/smoke_hevc
|
||||
|
||||
# Alternative using project binary (if HEVC enabled)
|
||||
# ./cvmmap-streamer --output rtmp://localhost/live/smoke_hevc --codec hevc
|
||||
ffplay rtmp://127.0.0.1/live/zlm_smoke
|
||||
ffplay http://127.0.0.1:8080/live/zlm_smoke.live.flv
|
||||
ffplay http://127.0.0.1:8080/live/zlm_smoke.live.ts
|
||||
```
|
||||
|
||||
```bash
|
||||
# Playback HEVC stream via various protocols
|
||||
ffplay rtmp://localhost/live/smoke_hevc
|
||||
ffplay http://localhost:8080/live/smoke_hevc.live.flv
|
||||
ffplay http://localhost:8080/live/smoke_hevc.live.ts
|
||||
```
|
||||
## Notes
|
||||
|
||||
### 3. Low-Latency Configuration
|
||||
|
||||
For minimal latency with ZLMediaKit, modify config.ini:
|
||||
|
||||
```ini
|
||||
[general]
|
||||
# Disable merge write (0 = immediate write)
|
||||
mergeWriteMS=0
|
||||
|
||||
[protocol]
|
||||
# Frame timestamp override: 0=source, 1=system, 2=relative
|
||||
modify_stamp=0
|
||||
|
||||
# Protocol demand mode (1 = generate on demand, reduces latency)
|
||||
rtmp_demand=1
|
||||
flv_demand=1
|
||||
ts_demand=1
|
||||
|
||||
[rtmp]
|
||||
handshakeSecond=5
|
||||
keepAliveSecond=5
|
||||
|
||||
[rtp]
|
||||
# Low latency mode (WARNING: may cause artifacts with H.264 multi-slice)
|
||||
lowLatency=1
|
||||
```
|
||||
|
||||
**Caveats for low-latency mode:**
|
||||
- mergeWriteMS=0 disables write buffering (higher CPU, more syscalls)
|
||||
- modify_stamp=0 uses source timestamps (requires stable encoder timing)
|
||||
- *_demand=1 causes first viewer to wait for next GOP
|
||||
- lowLatency=1 in RTP may cause artifacts with certain H.264 streams
|
||||
|
||||
### 4. Stream Proxy Test (Optional)
|
||||
|
||||
ZLMediaKit can proxy streams from other sources:
|
||||
|
||||
```bash
|
||||
# Add stream proxy via HTTP API
|
||||
curl -X POST "http://localhost:8080/index/api/addStreamProxy" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc" \
|
||||
-d "vhost=__defaultVhost__" \
|
||||
-d "app=proxy" \
|
||||
-d "stream=test" \
|
||||
-d "url=rtmp://localhost/live/smoke_test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HEVC Compatibility Notes
|
||||
|
||||
### Enhanced RTMP vs Domestic Extension
|
||||
|
||||
ZLMediaKit supports both HEVC packing formats via the enhanced config option:
|
||||
|
||||
**Enhanced RTMP (enhanced=1, RECOMMENDED):**
|
||||
- Uses standard FourCC hvc1
|
||||
- Compatible with FFmpeg 6.0+, OBS 29+
|
||||
- SRS 6.0+, ZLMediaKit master
|
||||
|
||||
**Domestic Extension (enhanced=0):**
|
||||
- Uses FLV codec ID 12
|
||||
- Required for some legacy Chinese CDN providers
|
||||
- May need patched FFmpeg for older versions
|
||||
|
||||
### Codec Priority in WebRTC
|
||||
|
||||
When using WebRTC with ZLMediaKit, codec priority can be configured:
|
||||
|
||||
```ini
|
||||
[rtc]
|
||||
# Video codec priority (first = highest)
|
||||
preferredCodecV=H264,H265,AV1,VP9,VP8
|
||||
```
|
||||
|
||||
**Note:** Chrome does not yet support HEVC in WebRTC; Safari does with experimental flags.
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
# Get server statistics
|
||||
curl "http://localhost:8080/index/api/getStatistic?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc"
|
||||
|
||||
# Get MediaServer list
|
||||
curl "http://localhost:8080/index/api/getMediaList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc"
|
||||
|
||||
# Check if stream exists
|
||||
curl "http://localhost:8080/index/api/isMediaOnline?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&vhost=__defaultVhost__&app=live&stream=smoke_test&schema=rtmp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Missing Server Environment Behavior
|
||||
|
||||
If ZLMediaKit is not available or the Docker container fails to start:
|
||||
|
||||
1. **SKIP** - Do not fail mandatory acceptance
|
||||
2. **Log** - Document the environment issue
|
||||
3. **Continue** - Proceed with other tests
|
||||
|
||||
Example skip condition:
|
||||
```bash
|
||||
if ! docker run --rm -p 1935:1935 zlmediakit/zlmediakit:master true 2>/dev/null; then
|
||||
echo "ZLMediaKit environment unavailable - skipping smoke tests"
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Connection refused | Check port 1935/8080 not in use; verify server started |
|
||||
| Stream not found | Verify app/stream name; check ZLMediaKit logs |
|
||||
| HEVC playback fails | Ensure player supports HEVC; check enhanced setting |
|
||||
| High latency | Disable mergeWriteMS, use modify_stamp=0 |
|
||||
| Artifacts in stream | Disable rtp.lowLatency if H.264 multi-slice |
|
||||
| API returns 401 | Verify secret parameter matches config.ini |
|
||||
|
||||
---
|
||||
- Use Enhanced RTMP only.
|
||||
- Prefer `h264` when testing browser-facing or WebRTC-facing downstream bridges.
|
||||
- Any WebRTC playback is provided by ZLMediaKit, not by this repo directly.
|
||||
|
||||
## References
|
||||
|
||||
- [ZLMediaKit GitHub](https://github.com/ZLMediaKit/ZLMediaKit)
|
||||
- [ZLMediaKit Wiki](https://github.com/ZLMediaKit/ZLMediaKit/wiki)
|
||||
- [Enhanced RTMP Specification](https://github.com/veovera/enhanced-rtmp)
|
||||
|
||||
@@ -22,19 +22,16 @@ enum class RunMode {
|
||||
|
||||
enum class RtmpMode {
|
||||
Enhanced,
|
||||
Domestic,
|
||||
};
|
||||
|
||||
enum class RtmpTransportType {
|
||||
Libavformat,
|
||||
FfmpegProcess,
|
||||
LegacyCustom,
|
||||
};
|
||||
|
||||
enum class EncoderBackendType {
|
||||
Auto,
|
||||
FFmpeg,
|
||||
GStreamerLegacy,
|
||||
};
|
||||
|
||||
enum class EncoderDeviceType {
|
||||
@@ -66,7 +63,6 @@ struct RtmpOutputConfig {
|
||||
std::vector<std::string> urls{};
|
||||
RtmpTransportType transport{RtmpTransportType::Libavformat};
|
||||
std::string ffmpeg_path{"ffmpeg"};
|
||||
RtmpMode mode{RtmpMode::Enhanced};
|
||||
};
|
||||
|
||||
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};
|
||||
};
|
||||
|
||||
}
|
||||
+209
-210
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u -o pipefail
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
@@ -11,11 +11,10 @@ SUMMARY_HELPER="${SCRIPT_DIR}/acceptance_summary_helper.py"
|
||||
|
||||
RUN_ID=""
|
||||
RUN_DIR=""
|
||||
MANIFEST_TSV="${RUN_DIR}/rows.tsv"
|
||||
SUMMARY_JSON="${RUN_DIR}/summary.json"
|
||||
MANIFEST_TSV=""
|
||||
SUMMARY_JSON=""
|
||||
LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-14-acceptance-summary.json"
|
||||
EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-14-acceptance.txt"
|
||||
|
||||
STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
mkdir -p "${TASK_EVIDENCE_DIR}"
|
||||
@@ -34,7 +33,7 @@ allocate_run_dir() {
|
||||
return 0
|
||||
fi
|
||||
attempts=$((attempts + 1))
|
||||
sleep 0.01
|
||||
sleep 0.01
|
||||
done
|
||||
echo "failed to allocate unique acceptance run directory" >&2
|
||||
return 1
|
||||
@@ -47,7 +46,7 @@ PORT_OFFSET="$((RUN_HASH % 1000))"
|
||||
RTP_PORT_BASE="$((51040 + PORT_OFFSET))"
|
||||
RTMP_PORT_BASE="$((19360 + PORT_OFFSET))"
|
||||
|
||||
echo -e "order\trow_id\tname\tprotocol\tcodec\trtmp_mode\tstatus\treason\tduration_ms\tsim_rc\tstreamer_rc\ttester_rc\tsim_log\tstreamer_log\ttester_log\tsdp_path" > "${MANIFEST_TSV}"
|
||||
echo -e "order\trow_id\tname\tprotocol\tcodec\ttransport\tstatus\treason\tduration_ms\temitter_rc\treceiver_rc\temitter_log\treceiver_log\tsdp_path" > "${MANIFEST_TSV}"
|
||||
|
||||
cleanup_pids=()
|
||||
|
||||
@@ -55,6 +54,7 @@ cleanup_all() {
|
||||
for pid in "${cleanup_pids[@]:-}"; do
|
||||
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
wait "${pid}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -90,179 +90,193 @@ append_manifest_row() {
|
||||
local name="$3"
|
||||
local protocol="$4"
|
||||
local codec="$5"
|
||||
local rtmp_mode="$6"
|
||||
local transport="$6"
|
||||
local status="$7"
|
||||
local reason="$8"
|
||||
local duration_ms="$9"
|
||||
local sim_rc="${10}"
|
||||
local streamer_rc="${11}"
|
||||
local tester_rc="${12}"
|
||||
local sim_log="${13}"
|
||||
local streamer_log="${14}"
|
||||
local tester_log="${15}"
|
||||
local sdp_path="${16}"
|
||||
local emitter_rc="${10}"
|
||||
local receiver_rc="${11}"
|
||||
local emitter_log="${12}"
|
||||
local receiver_log="${13}"
|
||||
local sdp_path="${14}"
|
||||
|
||||
echo -e "${order}\t${row_id}\t${name}\t${protocol}\t${codec}\t${rtmp_mode}\t${status}\t${reason}\t${duration_ms}\t${sim_rc}\t${streamer_rc}\t${tester_rc}\t${sim_log}\t${streamer_log}\t${tester_log}\t${sdp_path}" >> "${MANIFEST_TSV}"
|
||||
echo -e "${order}\t${row_id}\t${name}\t${protocol}\t${codec}\t${transport}\t${status}\t${reason}\t${duration_ms}\t${emitter_rc}\t${receiver_rc}\t${emitter_log}\t${receiver_log}\t${sdp_path}" >> "${MANIFEST_TSV}"
|
||||
}
|
||||
|
||||
run_matrix_row() {
|
||||
run_rtp_row() {
|
||||
local order="$1"
|
||||
local row_id="$2"
|
||||
local name="$3"
|
||||
local protocol="$4"
|
||||
local codec="$5"
|
||||
local rtmp_mode="$6"
|
||||
local codec="$3"
|
||||
|
||||
local row_dir="${RUN_DIR}/${order}-${row_id}"
|
||||
mkdir -p "${row_dir}"
|
||||
|
||||
local sim_log="${row_dir}/sim.log"
|
||||
local streamer_log="${row_dir}/streamer.log"
|
||||
local tester_log="${row_dir}/tester.log"
|
||||
local sdp_path=""
|
||||
|
||||
local shm_name="cvmmap_accept_${row_id}_${RUN_ID}"
|
||||
local zmq_endpoint="ipc:///tmp/cvmmap_accept_${row_id}_${RUN_ID}.ipc"
|
||||
|
||||
local sim_label="acc_${order}_${protocol}_${codec}"
|
||||
|
||||
local streamer_cmd=(
|
||||
"${BUILD_DIR}/cvmmap_streamer"
|
||||
--run-mode pipeline
|
||||
--codec "${codec}"
|
||||
--shm-name "${shm_name}"
|
||||
--zmq-endpoint "${zmq_endpoint}"
|
||||
--input-mode dummy
|
||||
--dummy-label "${sim_label}"
|
||||
--dummy-frames 320
|
||||
--dummy-fps 200
|
||||
--dummy-width 640
|
||||
--dummy-height 360
|
||||
--dummy-startup-delay-ms 0
|
||||
--queue-size 1
|
||||
--gop 30
|
||||
--b-frames 0
|
||||
--ingest-max-frames 120
|
||||
--ingest-idle-timeout-ms 6000
|
||||
)
|
||||
|
||||
local tester_cmd=()
|
||||
if [[ "${protocol}" == "rtp" ]]; then
|
||||
local rtp_port
|
||||
local payload_type
|
||||
if [[ "${codec}" == "h264" ]]; then
|
||||
rtp_port="${RTP_PORT_BASE}"
|
||||
payload_type=96
|
||||
else
|
||||
rtp_port="$((RTP_PORT_BASE + 2))"
|
||||
payload_type=98
|
||||
fi
|
||||
sdp_path="${row_dir}/stream.sdp"
|
||||
streamer_cmd+=(
|
||||
--rtp
|
||||
--rtp-endpoint "127.0.0.1:${rtp_port}"
|
||||
--rtp-payload-type "${payload_type}"
|
||||
--rtp-sdp "${sdp_path}"
|
||||
)
|
||||
tester_cmd=(
|
||||
"${BUILD_DIR}/rtp_receiver_tester"
|
||||
--port "${rtp_port}"
|
||||
--expect-pt "${payload_type}"
|
||||
--packet-threshold 1
|
||||
--timeout-ms 10000
|
||||
)
|
||||
else
|
||||
local rtmp_port
|
||||
local tester_mode
|
||||
case "${row_id}" in
|
||||
rtmp_h264)
|
||||
rtmp_port="${RTMP_PORT_BASE}"
|
||||
tester_mode="h264"
|
||||
;;
|
||||
rtmp_h265_enhanced)
|
||||
rtmp_port="$((RTMP_PORT_BASE + 2))"
|
||||
tester_mode="h265-enhanced"
|
||||
;;
|
||||
rtmp_h265_domestic)
|
||||
rtmp_port="$((RTMP_PORT_BASE + 4))"
|
||||
tester_mode="h265-domestic"
|
||||
;;
|
||||
*)
|
||||
rtmp_port="$((RTMP_PORT_BASE + 6))"
|
||||
tester_mode="h264"
|
||||
;;
|
||||
esac
|
||||
|
||||
streamer_cmd+=(
|
||||
--rtmp
|
||||
--rtmp-url "rtmp://127.0.0.1:${rtmp_port}/live/${row_id}"
|
||||
--rtmp-mode "${rtmp_mode}"
|
||||
)
|
||||
tester_cmd=(
|
||||
"${BUILD_DIR}/rtmp_stub_tester"
|
||||
--mode "${tester_mode}"
|
||||
--listen-host 127.0.0.1
|
||||
--listen-port "${rtmp_port}"
|
||||
--video-threshold 1
|
||||
--timeout-ms 10000
|
||||
)
|
||||
local emitter_log="${row_dir}/rtp_output.log"
|
||||
local receiver_log="${row_dir}/rtp_receiver.log"
|
||||
local sdp_path="${row_dir}/stream.sdp"
|
||||
local port
|
||||
port="$((RTP_PORT_BASE + (order - 1) * 2))"
|
||||
local payload_type=96
|
||||
if [[ "${codec}" == "h265" ]]; then
|
||||
payload_type=98
|
||||
fi
|
||||
|
||||
local row_start_ms row_end_ms duration_ms
|
||||
local row_start_ms
|
||||
row_start_ms="$(date +%s%3N)"
|
||||
|
||||
"${tester_cmd[@]}" > "${tester_log}" 2>&1 &
|
||||
local tester_pid=$!
|
||||
cleanup_pids+=("${tester_pid}")
|
||||
"${BUILD_DIR}/rtp_receiver_tester" \
|
||||
--port "${port}" \
|
||||
--expect-pt "${payload_type}" \
|
||||
--packet-threshold 1 \
|
||||
--timeout-ms 12000 >"${receiver_log}" 2>&1 &
|
||||
local receiver_pid=$!
|
||||
cleanup_pids+=("${receiver_pid}")
|
||||
|
||||
sleep 1
|
||||
: > "${sim_log}"
|
||||
|
||||
"${streamer_cmd[@]}" > "${streamer_log}" 2>&1
|
||||
local streamer_rc=$?
|
||||
set +e
|
||||
"${BUILD_DIR}/rtp_output_tester" \
|
||||
--host 127.0.0.1 \
|
||||
--port "${port}" \
|
||||
--payload-type "${payload_type}" \
|
||||
--codec "${codec}" \
|
||||
--encoder-device software \
|
||||
--sdp-path "${sdp_path}" \
|
||||
--frames 48 \
|
||||
--width 320 \
|
||||
--height 240 \
|
||||
--frame-interval-ms 20 >"${emitter_log}" 2>&1
|
||||
local emitter_rc=$?
|
||||
set -e
|
||||
|
||||
wait_pid "${tester_pid}" 15
|
||||
local tester_rc=$?
|
||||
|
||||
local sim_rc=0
|
||||
set +e
|
||||
wait_pid "${receiver_pid}" 20
|
||||
local receiver_rc=$?
|
||||
set -e
|
||||
|
||||
local row_end_ms
|
||||
row_end_ms="$(date +%s%3N)"
|
||||
duration_ms=$((row_end_ms - row_start_ms))
|
||||
|
||||
local duration_ms=$((row_end_ms - row_start_ms))
|
||||
local status="PASS"
|
||||
local reason="all-processes-ok"
|
||||
|
||||
if (( sim_rc != 0 || streamer_rc != 0 || tester_rc != 0 )); then
|
||||
if (( emitter_rc != 0 || receiver_rc != 0 )); then
|
||||
status="FAIL"
|
||||
reason="sim_rc=${sim_rc},streamer_rc=${streamer_rc},tester_rc=${tester_rc}"
|
||||
reason="emitter_rc=${emitter_rc},receiver_rc=${receiver_rc}"
|
||||
if (( receiver_rc == 0 )) && grep -Eq "Broken pipe|Connection reset by peer" "${emitter_log}"; then
|
||||
status="PASS"
|
||||
reason="receiver exited cleanly after threshold; emitter observed peer close"
|
||||
fi
|
||||
fi
|
||||
|
||||
append_manifest_row \
|
||||
"${order}" \
|
||||
"${row_id}" \
|
||||
"${name}" \
|
||||
"${protocol}" \
|
||||
"RTP + ${codec}" \
|
||||
"rtp" \
|
||||
"${codec}" \
|
||||
"${rtmp_mode}" \
|
||||
"udp" \
|
||||
"${status}" \
|
||||
"${reason}" \
|
||||
"${duration_ms}" \
|
||||
"${sim_rc}" \
|
||||
"${streamer_rc}" \
|
||||
"${tester_rc}" \
|
||||
"${sim_log}" \
|
||||
"${streamer_log}" \
|
||||
"${tester_log}" \
|
||||
"${emitter_rc}" \
|
||||
"${receiver_rc}" \
|
||||
"${emitter_log}" \
|
||||
"${receiver_log}" \
|
||||
"${sdp_path}"
|
||||
|
||||
printf "[%s] %s => %s (%s)\n" "${row_id}" "${name}" "${status}" "${reason}"
|
||||
printf "[%s] RTP + %s => %s (%s)\n" "${row_id}" "${codec}" "${status}" "${reason}"
|
||||
}
|
||||
|
||||
run_rtmp_row() {
|
||||
local order="$1"
|
||||
local row_id="$2"
|
||||
local codec="$3"
|
||||
local transport="$4"
|
||||
|
||||
local row_dir="${RUN_DIR}/${order}-${row_id}"
|
||||
mkdir -p "${row_dir}"
|
||||
|
||||
local emitter_log="${row_dir}/rtmp_output.log"
|
||||
local receiver_log="${row_dir}/rtmp_stub.log"
|
||||
local port
|
||||
port="$((RTMP_PORT_BASE + (order - 3) * 2))"
|
||||
local mode="h264"
|
||||
if [[ "${codec}" == "h265" ]]; then
|
||||
mode="h265-enhanced"
|
||||
fi
|
||||
|
||||
local row_start_ms
|
||||
row_start_ms="$(date +%s%3N)"
|
||||
|
||||
"${BUILD_DIR}/rtmp_stub_tester" \
|
||||
--mode "${mode}" \
|
||||
--listen-host 127.0.0.1 \
|
||||
--listen-port "${port}" \
|
||||
--video-threshold 4 \
|
||||
--timeout-ms 12000 >"${receiver_log}" 2>&1 &
|
||||
local receiver_pid=$!
|
||||
cleanup_pids+=("${receiver_pid}")
|
||||
|
||||
sleep 1
|
||||
|
||||
set +e
|
||||
"${BUILD_DIR}/rtmp_output_tester" \
|
||||
--rtmp-url "rtmp://127.0.0.1:${port}/live/${row_id}" \
|
||||
--transport "${transport}" \
|
||||
--codec "${codec}" \
|
||||
--encoder-device software \
|
||||
--frames 32 \
|
||||
--width 320 \
|
||||
--height 240 \
|
||||
--frame-interval-ms 20 \
|
||||
--linger-ms 200 >"${emitter_log}" 2>&1
|
||||
local emitter_rc=$?
|
||||
set -e
|
||||
|
||||
set +e
|
||||
wait_pid "${receiver_pid}" 20
|
||||
local receiver_rc=$?
|
||||
set -e
|
||||
|
||||
local row_end_ms
|
||||
row_end_ms="$(date +%s%3N)"
|
||||
local duration_ms=$((row_end_ms - row_start_ms))
|
||||
local status="PASS"
|
||||
local reason="all-processes-ok"
|
||||
if (( emitter_rc != 0 || receiver_rc != 0 )); then
|
||||
status="FAIL"
|
||||
reason="emitter_rc=${emitter_rc},receiver_rc=${receiver_rc}"
|
||||
if (( receiver_rc == 0 )) && grep -Eq "Broken pipe|Connection reset by peer" "${emitter_log}"; then
|
||||
status="PASS"
|
||||
reason="receiver exited cleanly after threshold; emitter observed peer close"
|
||||
fi
|
||||
fi
|
||||
|
||||
append_manifest_row \
|
||||
"${order}" \
|
||||
"${row_id}" \
|
||||
"RTMP + ${codec} + ${transport}" \
|
||||
"rtmp" \
|
||||
"${codec}" \
|
||||
"${transport}" \
|
||||
"${status}" \
|
||||
"${reason}" \
|
||||
"${duration_ms}" \
|
||||
"${emitter_rc}" \
|
||||
"${receiver_rc}" \
|
||||
"${emitter_log}" \
|
||||
"${receiver_log}" \
|
||||
""
|
||||
|
||||
printf "[%s] RTMP + %s + %s => %s (%s)\n" "${row_id}" "${codec}" "${transport}" "${status}" "${reason}"
|
||||
}
|
||||
|
||||
main() {
|
||||
local required=(
|
||||
"${BUILD_DIR}/cvmmap_streamer"
|
||||
"${BUILD_DIR}/rtp_receiver_tester"
|
||||
"${BUILD_DIR}/rtmp_stub_tester"
|
||||
local required=(
|
||||
"${BUILD_DIR}/rtp_output_tester"
|
||||
"${BUILD_DIR}/rtp_receiver_tester"
|
||||
"${BUILD_DIR}/rtmp_output_tester"
|
||||
"${BUILD_DIR}/rtmp_stub_tester"
|
||||
)
|
||||
|
||||
local missing=()
|
||||
@@ -273,33 +287,24 @@ local required=(
|
||||
done
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
for idx in 1 2 3 4 5; do
|
||||
append_manifest_row \
|
||||
"${idx}" \
|
||||
"preflight_${idx}" \
|
||||
"preflight missing binary" \
|
||||
"preflight" \
|
||||
"n/a" \
|
||||
"" \
|
||||
"SKIP" \
|
||||
"missing binaries: ${missing[*]}" \
|
||||
"0" \
|
||||
"-1" \
|
||||
"-1" \
|
||||
"-1" \
|
||||
"" \
|
||||
"" \
|
||||
"" \
|
||||
""
|
||||
done
|
||||
else
|
||||
run_matrix_row 1 "rtp_h264" "RTP + H.264" "rtp" "h264" ""
|
||||
run_matrix_row 2 "rtp_h265" "RTP + H.265" "rtp" "h265" ""
|
||||
run_matrix_row 3 "rtmp_h264" "RTMP + H.264" "rtmp" "h264" "enhanced"
|
||||
run_matrix_row 4 "rtmp_h265_enhanced" "RTMP + H.265 enhanced" "rtmp" "h265" "enhanced"
|
||||
run_matrix_row 5 "rtmp_h265_domestic" "RTMP + H.265 domestic" "rtmp" "h265" "domestic"
|
||||
{
|
||||
echo "task=14"
|
||||
echo "run_id=${RUN_ID}"
|
||||
echo "run_dir=${RUN_DIR}"
|
||||
echo "manifest=${MANIFEST_TSV}"
|
||||
echo "missing_binaries=${missing[*]}"
|
||||
} > "${EVIDENCE_TEXT}"
|
||||
echo "missing binaries: ${missing[*]}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
run_rtp_row 1 "rtp_h264" "h264"
|
||||
run_rtp_row 2 "rtp_h265" "h265"
|
||||
run_rtmp_row 3 "rtmp_h264_libavformat" "h264" "libavformat"
|
||||
run_rtmp_row 4 "rtmp_h265_libavformat" "h265" "libavformat"
|
||||
run_rtmp_row 5 "rtmp_h264_ffmpeg_process" "h264" "ffmpeg_process"
|
||||
run_rtmp_row 6 "rtmp_h265_ffmpeg_process" "h265" "ffmpeg_process"
|
||||
|
||||
local finished_at_utc
|
||||
finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
@@ -310,67 +315,61 @@ local required=(
|
||||
--run-dir "${RUN_DIR}" \
|
||||
--started-at "${STARTED_AT_UTC}" \
|
||||
--finished-at "${finished_at_utc}"
|
||||
local summary_rc=$?
|
||||
|
||||
cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true
|
||||
|
||||
local total_count pass_count fail_count all_pass
|
||||
total_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("total", 0))
|
||||
PY
|
||||
)"
|
||||
pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("pass", 0))
|
||||
PY
|
||||
)"
|
||||
fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("fail", 0))
|
||||
PY
|
||||
)"
|
||||
all_pass="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
print("true" if data.get("all_pass", False) else "false")
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
echo "task=14"
|
||||
echo "run_id=${RUN_ID}"
|
||||
echo "run_dir=${RUN_DIR}"
|
||||
echo "manifest=${MANIFEST_TSV}"
|
||||
echo "summary_json=${SUMMARY_JSON}"
|
||||
echo "latest_summary_json=${LATEST_SUMMARY_JSON}"
|
||||
echo "started_at=${STARTED_AT_UTC}"
|
||||
echo "finished_at=${finished_at_utc}"
|
||||
echo "counts_total=${total_count}"
|
||||
echo "counts_pass=${pass_count}"
|
||||
echo "counts_fail=${fail_count}"
|
||||
echo "all_pass=${all_pass}"
|
||||
echo "matrix_rows=rtp_h264,rtp_h265,rtmp_h264_libavformat,rtmp_h265_libavformat,rtmp_h264_ffmpeg_process,rtmp_h265_ffmpeg_process"
|
||||
} > "${EVIDENCE_TEXT}"
|
||||
|
||||
if (( summary_rc != 0 )); then
|
||||
echo "summary helper failed with rc=${summary_rc}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local pass_count fail_count skip_count total_count
|
||||
pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("pass", 0))
|
||||
PY
|
||||
)"
|
||||
fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("fail", 0))
|
||||
PY
|
||||
)"
|
||||
skip_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("skip", 0))
|
||||
PY
|
||||
)"
|
||||
total_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("total", 0))
|
||||
PY
|
||||
)"
|
||||
|
||||
echo "summary: total=${total_count} pass=${pass_count} fail=${fail_count} skip=${skip_count}"
|
||||
echo "json: ${SUMMARY_JSON}"
|
||||
|
||||
if [[ "${total_count}" == "5" && "${pass_count}" == "5" && "${fail_count}" == "0" && "${skip_count}" == "0" ]]; then
|
||||
if [[ "${all_pass}" == "true" ]]; then
|
||||
echo "acceptance matrix PASS (${pass_count}/${total_count})"
|
||||
echo "summary: ${SUMMARY_JSON}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "acceptance matrix FAIL (${pass_count}/${total_count})" >&2
|
||||
echo "summary: ${SUMMARY_JSON}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
|
||||
MetricValue = int | float | str | bool
|
||||
|
||||
|
||||
KV_PATTERN = re.compile(r"([a-zA-Z_]+)=([^\s]+)")
|
||||
KV_PATTERN = re.compile(r"([a-zA-Z0-9_]+)=([^\s]+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -32,31 +29,20 @@ def parse_args() -> CliArgs:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build JSON summary for standalone acceptance matrix"
|
||||
)
|
||||
|
||||
_ = parser.add_argument(
|
||||
"--manifest", required=True, help="TSV manifest produced by acceptance runner"
|
||||
)
|
||||
_ = parser.add_argument("--output", required=True, help="Output JSON summary path")
|
||||
_ = parser.add_argument("--manifest", required=True)
|
||||
_ = parser.add_argument("--output", required=True)
|
||||
_ = parser.add_argument("--run-id", required=True)
|
||||
_ = parser.add_argument("--run-dir", required=True)
|
||||
_ = parser.add_argument("--started-at", required=True)
|
||||
_ = parser.add_argument("--finished-at", required=True)
|
||||
|
||||
parsed = parser.parse_args(sys.argv[1:])
|
||||
manifest = cast(str, parsed.manifest)
|
||||
output = cast(str, parsed.output)
|
||||
run_id = cast(str, parsed.run_id)
|
||||
run_dir = cast(str, parsed.run_dir)
|
||||
started_at = cast(str, parsed.started_at)
|
||||
finished_at = cast(str, parsed.finished_at)
|
||||
|
||||
return CliArgs(
|
||||
manifest=manifest,
|
||||
output=output,
|
||||
run_id=run_id,
|
||||
run_dir=run_dir,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
manifest=cast(str, parsed.manifest),
|
||||
output=cast(str, parsed.output),
|
||||
run_id=cast(str, parsed.run_id),
|
||||
run_dir=cast(str, parsed.run_dir),
|
||||
started_at=cast(str, parsed.started_at),
|
||||
finished_at=cast(str, parsed.finished_at),
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +56,7 @@ def read_text(path: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def to_number(value: str) -> MetricValue:
|
||||
def to_number(value: str) -> int | float | str:
|
||||
if re.fullmatch(r"-?\d+", value):
|
||||
try:
|
||||
return int(value)
|
||||
@@ -84,25 +70,20 @@ def to_number(value: str) -> MetricValue:
|
||||
return value
|
||||
|
||||
|
||||
def parse_key_value_metrics(line: str) -> dict[str, MetricValue]:
|
||||
metrics: dict[str, MetricValue] = {}
|
||||
for match in KV_PATTERN.finditer(line):
|
||||
key = match.group(1)
|
||||
raw = match.group(2)
|
||||
metrics[key] = to_number(raw)
|
||||
return metrics
|
||||
def parse_key_values(line: str) -> dict[str, int | float | str]:
|
||||
return {match.group(1): to_number(match.group(2)) for match in KV_PATTERN.finditer(line)}
|
||||
|
||||
|
||||
def extract_last_matching_line(text: str, token: str) -> str:
|
||||
match = ""
|
||||
def last_line_with_token(text: str, token: str) -> str:
|
||||
found = ""
|
||||
for line in text.splitlines():
|
||||
if token in line:
|
||||
match = line
|
||||
return match
|
||||
found = line
|
||||
return found
|
||||
|
||||
|
||||
def parse_rtp_tester_metrics(text: str) -> dict[str, MetricValue]:
|
||||
metrics: dict[str, MetricValue] = {}
|
||||
def parse_rtp_receiver_metrics(text: str) -> dict[str, int]:
|
||||
metrics: dict[str, int] = {}
|
||||
patterns = {
|
||||
"packets_received": r"Packets received:\s*(\d+)",
|
||||
"sequence_gaps": r"Sequence gaps:\s*(\d+)",
|
||||
@@ -110,15 +91,14 @@ def parse_rtp_tester_metrics(text: str) -> dict[str, MetricValue]:
|
||||
"detected_payload_type": r"Detected payload type:\s*(\d+)",
|
||||
}
|
||||
for key, pattern in patterns.items():
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
metrics[key] = int(m.group(1))
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
metrics[key] = int(match.group(1))
|
||||
return metrics
|
||||
|
||||
|
||||
def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
|
||||
metrics: dict[str, MetricValue] = {}
|
||||
|
||||
def parse_rtmp_stub_metrics(text: str) -> dict[str, int]:
|
||||
metrics: dict[str, int] = {}
|
||||
messages = re.search(
|
||||
r"Messages:\s*total=(\d+),\s*audio=(\d+),\s*video=(\d+),\s*data=(\d+),\s*chunk-size-updates=(\d+)",
|
||||
text,
|
||||
@@ -135,7 +115,7 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
|
||||
)
|
||||
|
||||
counts = re.search(
|
||||
r"Video signaling counts:\s*h264=(\d+),\s*h265-enhanced=(\d+),\s*h265-domestic=(\d+),\s*unknown=(\d+)",
|
||||
r"Video signaling counts:\s*h264=(\d+),\s*h265-enhanced=(\d+),\s*unknown=(\d+)",
|
||||
text,
|
||||
)
|
||||
if counts:
|
||||
@@ -143,8 +123,7 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
|
||||
{
|
||||
"h264_video_messages": int(counts.group(1)),
|
||||
"h265_enhanced_video_messages": int(counts.group(2)),
|
||||
"h265_domestic_video_messages": int(counts.group(3)),
|
||||
"unknown_video_messages": int(counts.group(4)),
|
||||
"unknown_video_messages": int(counts.group(3)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -158,43 +137,29 @@ def parse_rtmp_tester_metrics(text: str) -> dict[str, MetricValue]:
|
||||
"matching_threshold": int(matching.group(2)),
|
||||
}
|
||||
)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def parse_streamer_metrics(text: str) -> dict[str, dict[str, MetricValue]]:
|
||||
result: dict[str, dict[str, MetricValue]] = {}
|
||||
for token, key in (
|
||||
("PIPELINE_METRICS", "pipeline"),
|
||||
("RTP_METRICS", "rtp"),
|
||||
("RTMP_METRICS", "rtmp"),
|
||||
):
|
||||
line = extract_last_matching_line(text, token)
|
||||
if line:
|
||||
result[key] = parse_key_value_metrics(line)
|
||||
return result
|
||||
|
||||
|
||||
def parse_sdp_metrics(path: str) -> dict[str, MetricValue]:
|
||||
p = Path(path)
|
||||
def parse_sdp_metrics(path: str) -> dict[str, object]:
|
||||
if not path:
|
||||
return {}
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return {"exists": False}
|
||||
text = read_text(path)
|
||||
metrics: dict[str, MetricValue] = {
|
||||
metrics: dict[str, object] = {
|
||||
"exists": True,
|
||||
"bytes": p.stat().st_size,
|
||||
"has_h264": "H264/90000" in text,
|
||||
"has_h265": ("H265/90000" in text) or ("HEVC/90000" in text),
|
||||
}
|
||||
m = re.search(r"m=video\s+\d+\s+RTP/AVP\s+(\d+)", text)
|
||||
if m:
|
||||
metrics["payload_type"] = int(m.group(1))
|
||||
match = re.search(r"m=video\s+\d+\s+RTP/AVP\s+(\d+)", text)
|
||||
if match:
|
||||
metrics["payload_type"] = int(match.group(1))
|
||||
return metrics
|
||||
|
||||
|
||||
def parse_exit_code(value: str) -> int:
|
||||
def parse_exit(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
@@ -208,35 +173,12 @@ def parse_duration_ms(value: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
MANIFEST_FIELDS = (
|
||||
"order",
|
||||
"row_id",
|
||||
"name",
|
||||
"protocol",
|
||||
"codec",
|
||||
"rtmp_mode",
|
||||
"status",
|
||||
"reason",
|
||||
"duration_ms",
|
||||
"sim_rc",
|
||||
"streamer_rc",
|
||||
"tester_rc",
|
||||
"sim_log",
|
||||
"streamer_log",
|
||||
"tester_log",
|
||||
"sdp_path",
|
||||
)
|
||||
|
||||
|
||||
def parse_manifest(path: str) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
with open(path, "r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle, delimiter="\t")
|
||||
for raw_row in reader:
|
||||
row: dict[str, str] = {}
|
||||
for field in MANIFEST_FIELDS:
|
||||
value = raw_row.get(field, "")
|
||||
row[field] = "" if value is None else str(value)
|
||||
for raw in reader:
|
||||
row = {key: "" if value is None else str(value) for key, value in raw.items()}
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
@@ -246,26 +188,22 @@ def build_summary(args: CliArgs) -> dict[str, object]:
|
||||
rows: list[dict[str, object]] = []
|
||||
|
||||
for row in sorted(manifest_rows, key=lambda item: int(item["order"])):
|
||||
streamer_log = row["streamer_log"]
|
||||
tester_log = row["tester_log"]
|
||||
sim_log = row["sim_log"]
|
||||
sdp_path = row.get("sdp_path", "")
|
||||
emitter_text = read_text(row["emitter_log"])
|
||||
receiver_text = read_text(row["receiver_log"])
|
||||
emitter_metrics: dict[str, dict[str, int | float | str]] = {}
|
||||
for token, key in (
|
||||
("RTP_METRICS", "rtp"),
|
||||
("RTMP_OUTPUT_METRICS", "rtmp"),
|
||||
):
|
||||
line = last_line_with_token(emitter_text, token)
|
||||
if line:
|
||||
emitter_metrics[key] = parse_key_values(line)
|
||||
|
||||
streamer_text = read_text(streamer_log)
|
||||
tester_text = read_text(tester_log)
|
||||
|
||||
tester_metrics: dict[str, MetricValue]
|
||||
receiver_metrics: dict[str, object]
|
||||
if row["protocol"] == "rtp":
|
||||
tester_metrics = parse_rtp_tester_metrics(tester_text)
|
||||
receiver_metrics = parse_rtp_receiver_metrics(receiver_text)
|
||||
else:
|
||||
tester_metrics = parse_rtmp_tester_metrics(tester_text)
|
||||
|
||||
metrics: dict[str, object] = {
|
||||
"tester": tester_metrics,
|
||||
"streamer": parse_streamer_metrics(streamer_text),
|
||||
}
|
||||
if row["protocol"] == "rtp":
|
||||
metrics["sdp"] = parse_sdp_metrics(sdp_path)
|
||||
receiver_metrics = parse_rtmp_stub_metrics(receiver_text)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
@@ -274,21 +212,23 @@ def build_summary(args: CliArgs) -> dict[str, object]:
|
||||
"name": row["name"],
|
||||
"protocol": row["protocol"],
|
||||
"codec": row["codec"],
|
||||
"rtmp_mode": row["rtmp_mode"] if row["rtmp_mode"] else None,
|
||||
"transport": row["transport"],
|
||||
"status": row["status"],
|
||||
"reason": row["reason"],
|
||||
"duration_ms": parse_duration_ms(row["duration_ms"]),
|
||||
"exit_codes": {
|
||||
"sim": parse_exit_code(row["sim_rc"]),
|
||||
"streamer": parse_exit_code(row["streamer_rc"]),
|
||||
"tester": parse_exit_code(row["tester_rc"]),
|
||||
"emitter": parse_exit(row["emitter_rc"]),
|
||||
"receiver": parse_exit(row["receiver_rc"]),
|
||||
},
|
||||
"metrics": {
|
||||
"emitter": emitter_metrics,
|
||||
"receiver": receiver_metrics,
|
||||
"sdp": parse_sdp_metrics(row.get("sdp_path", "")),
|
||||
},
|
||||
"metrics": metrics,
|
||||
"evidence": {
|
||||
"sim_log": sim_log,
|
||||
"streamer_log": streamer_log,
|
||||
"tester_log": tester_log,
|
||||
"sdp": sdp_path if sdp_path else None,
|
||||
"emitter_log": row["emitter_log"],
|
||||
"receiver_log": row["receiver_log"],
|
||||
"sdp_path": row.get("sdp_path") or None,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -296,10 +236,7 @@ def build_summary(args: CliArgs) -> dict[str, object]:
|
||||
pass_count = sum(1 for row in rows if row["status"] == "PASS")
|
||||
fail_count = sum(1 for row in rows if row["status"] == "FAIL")
|
||||
skip_count = sum(1 for row in rows if row["status"] == "SKIP")
|
||||
|
||||
all_pass = (
|
||||
len(rows) == 5 and pass_count == 5 and fail_count == 0 and skip_count == 0
|
||||
)
|
||||
all_pass = len(rows) == 6 and pass_count == 6 and fail_count == 0 and skip_count == 0
|
||||
|
||||
return {
|
||||
"run_id": args.run_id,
|
||||
@@ -322,11 +259,8 @@ def main() -> int:
|
||||
args = parse_args()
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
summary = build_summary(args)
|
||||
_ = output_path.write_text(
|
||||
json.dumps(summary, indent=2, sort_keys=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
output_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+107
-248
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u -o pipefail
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STREAMER_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
BUILD_DIR="${STREAMER_ROOT}/build"
|
||||
|
||||
EVIDENCE_ROOT="${STREAMER_ROOT}/.sisyphus/evidence"
|
||||
TASK_EVIDENCE_DIR="${EVIDENCE_ROOT}/task-15-fault-suite"
|
||||
SUMMARY_HELPER="${SCRIPT_DIR}/fault_summary_helper.py"
|
||||
@@ -39,17 +38,10 @@ fi
|
||||
|
||||
RUN_ID=""
|
||||
RUN_DIR=""
|
||||
MANIFEST_TSV="${RUN_DIR}/rows.tsv"
|
||||
SUMMARY_JSON="${RUN_DIR}/summary.json"
|
||||
|
||||
if [[ "${MODE}" == "baseline" ]]; then
|
||||
LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-summary.json"
|
||||
EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite.txt"
|
||||
else
|
||||
LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-error-summary.json"
|
||||
EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite-error.txt"
|
||||
fi
|
||||
|
||||
MANIFEST_TSV=""
|
||||
SUMMARY_JSON=""
|
||||
LATEST_SUMMARY_JSON="${EVIDENCE_ROOT}/task-15-fault-suite-summary.json"
|
||||
EVIDENCE_TEXT="${EVIDENCE_ROOT}/task-15-fault-suite.txt"
|
||||
STARTED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
mkdir -p "${TASK_EVIDENCE_DIR}"
|
||||
@@ -76,50 +68,7 @@ allocate_run_dir() {
|
||||
|
||||
allocate_run_dir || exit 1
|
||||
|
||||
RUN_HASH="$(printf '%s' "${RUN_ID}" | cksum | awk '{print $1}')"
|
||||
PORT_OFFSET="$((RUN_HASH % 1000))"
|
||||
if [[ "${MODE}" == "baseline" ]]; then
|
||||
SCENARIO_PORT_BASE="$((52040 + PORT_OFFSET))"
|
||||
else
|
||||
SCENARIO_PORT_BASE="$((52140 + PORT_OFFSET))"
|
||||
fi
|
||||
|
||||
echo -e "order\tscenario_id\tname\tstatus\treason\tduration_ms\tsim_rc\tstreamer_rc\ttester_rc\tsim_log\tstreamer_log\ttester_log\tsdp_path" > "${MANIFEST_TSV}"
|
||||
|
||||
cleanup_pids=()
|
||||
|
||||
cleanup_all() {
|
||||
for pid in "${cleanup_pids[@]:-}"; do
|
||||
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
trap cleanup_all EXIT
|
||||
|
||||
binary_exists() {
|
||||
local path="$1"
|
||||
[[ -x "${path}" ]]
|
||||
}
|
||||
|
||||
wait_pid() {
|
||||
local pid="$1"
|
||||
local timeout_s="$2"
|
||||
local elapsed=0
|
||||
while kill -0 "${pid}" 2>/dev/null; do
|
||||
if (( elapsed >= timeout_s )); then
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
wait "${pid}" 2>/dev/null || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
wait "${pid}" 2>/dev/null
|
||||
return $?
|
||||
}
|
||||
echo -e "order\tscenario_id\tname\tstatus\treason\tduration_ms\tcommand_rc\tlog_path" > "${MANIFEST_TSV}"
|
||||
|
||||
append_manifest_row() {
|
||||
local order="$1"
|
||||
@@ -128,145 +77,37 @@ append_manifest_row() {
|
||||
local status="$4"
|
||||
local reason="$5"
|
||||
local duration_ms="$6"
|
||||
local sim_rc="$7"
|
||||
local streamer_rc="$8"
|
||||
local tester_rc="$9"
|
||||
local sim_log="${10}"
|
||||
local streamer_log="${11}"
|
||||
local tester_log="${12}"
|
||||
local sdp_path="${13}"
|
||||
|
||||
echo -e "${order}\t${scenario_id}\t${name}\t${status}\t${reason}\t${duration_ms}\t${sim_rc}\t${streamer_rc}\t${tester_rc}\t${sim_log}\t${streamer_log}\t${tester_log}\t${sdp_path}" >> "${MANIFEST_TSV}"
|
||||
local command_rc="$7"
|
||||
local log_path="$8"
|
||||
echo -e "${order}\t${scenario_id}\t${name}\t${status}\t${reason}\t${duration_ms}\t${command_rc}\t${log_path}" >> "${MANIFEST_TSV}"
|
||||
}
|
||||
|
||||
scenario_port() {
|
||||
local order="$1"
|
||||
echo $((SCENARIO_PORT_BASE + (order - 1) * 2))
|
||||
}
|
||||
|
||||
run_fault_scenario() {
|
||||
run_expected_failure() {
|
||||
local order="$1"
|
||||
local scenario_id="$2"
|
||||
local name="$3"
|
||||
local expected_substring="$4"
|
||||
shift 4
|
||||
|
||||
local row_dir="${RUN_DIR}/${order}-${scenario_id}"
|
||||
mkdir -p "${row_dir}"
|
||||
local log_path="${row_dir}/command.log"
|
||||
|
||||
local sim_log="${row_dir}/sim.log"
|
||||
local streamer_log="${row_dir}/streamer.log"
|
||||
local tester_log="${row_dir}/tester.log"
|
||||
local sdp_path="${row_dir}/stream.sdp"
|
||||
|
||||
local shm_name="fault_${MODE}_${scenario_id}_${RUN_ID}"
|
||||
local zmq_endpoint="ipc:///tmp/fault_${MODE}_${scenario_id}_${RUN_ID}.ipc"
|
||||
local sim_label="f${order}_${MODE:0:3}_${scenario_id:0:3}"
|
||||
|
||||
local sim_frames=360
|
||||
local sim_fps=200
|
||||
local reset_every=""
|
||||
local snapshot_delay_us=0
|
||||
local emit_stall_ms=0
|
||||
local ingest_max_frames=180
|
||||
|
||||
case "${scenario_id}" in
|
||||
torn_read)
|
||||
if [[ "${MODE}" == "baseline" ]]; then
|
||||
snapshot_delay_us=2500
|
||||
sim_fps=240
|
||||
else
|
||||
snapshot_delay_us=25000
|
||||
sim_fps=320
|
||||
fi
|
||||
;;
|
||||
sink_stall)
|
||||
if [[ "${MODE}" == "baseline" ]]; then
|
||||
emit_stall_ms=3
|
||||
ingest_max_frames=140
|
||||
else
|
||||
emit_stall_ms=60
|
||||
ingest_max_frames=160
|
||||
fi
|
||||
;;
|
||||
reset_storm)
|
||||
if [[ "${MODE}" == "baseline" ]]; then
|
||||
reset_every=20
|
||||
ingest_max_frames=120
|
||||
else
|
||||
reset_every=3
|
||||
ingest_max_frames=180
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "unknown scenario_id=${scenario_id}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local rtp_port
|
||||
rtp_port="$(scenario_port "${order}")"
|
||||
|
||||
local streamer_cmd=(
|
||||
"${BUILD_DIR}/cvmmap_streamer"
|
||||
--run-mode pipeline
|
||||
--codec h264
|
||||
--shm-name "${shm_name}"
|
||||
--zmq-endpoint "${zmq_endpoint}"
|
||||
--input-mode dummy
|
||||
--dummy-label "${sim_label}"
|
||||
--dummy-frames "${sim_frames}"
|
||||
--dummy-fps "${sim_fps}"
|
||||
--dummy-width 640
|
||||
--dummy-height 360
|
||||
--dummy-startup-delay-ms 0
|
||||
--queue-size 1
|
||||
--gop 30
|
||||
--b-frames 0
|
||||
--ingest-max-frames "${ingest_max_frames}"
|
||||
--ingest-idle-timeout-ms 8000
|
||||
--snapshot-copy-delay-us "${snapshot_delay_us}"
|
||||
--emit-stall-ms "${emit_stall_ms}"
|
||||
--rtp
|
||||
--rtp-endpoint "127.0.0.1:${rtp_port}"
|
||||
--rtp-payload-type 96
|
||||
--rtp-sdp "${sdp_path}"
|
||||
)
|
||||
if [[ -n "${reset_every}" ]]; then
|
||||
streamer_cmd+=(--dummy-reset-every "${reset_every}")
|
||||
fi
|
||||
|
||||
local tester_cmd=(
|
||||
"${BUILD_DIR}/rtp_receiver_tester"
|
||||
--port "${rtp_port}"
|
||||
--expect-pt 96
|
||||
--packet-threshold 1
|
||||
--timeout-ms 15000
|
||||
)
|
||||
|
||||
local row_start_ms row_end_ms duration_ms
|
||||
local row_start_ms
|
||||
row_start_ms="$(date +%s%3N)"
|
||||
|
||||
"${tester_cmd[@]}" > "${tester_log}" 2>&1 &
|
||||
local tester_pid=$!
|
||||
cleanup_pids+=("${tester_pid}")
|
||||
|
||||
sleep 1
|
||||
: > "${sim_log}"
|
||||
|
||||
"${streamer_cmd[@]}" > "${streamer_log}" 2>&1
|
||||
local streamer_rc=$?
|
||||
|
||||
wait_pid "${tester_pid}" 25
|
||||
local tester_rc=$?
|
||||
local sim_rc=0
|
||||
|
||||
set +e
|
||||
"$@" >"${log_path}" 2>&1
|
||||
local command_rc=$?
|
||||
set -e
|
||||
local row_end_ms
|
||||
row_end_ms="$(date +%s%3N)"
|
||||
duration_ms=$((row_end_ms - row_start_ms))
|
||||
local duration_ms=$((row_end_ms - row_start_ms))
|
||||
|
||||
local status="PASS"
|
||||
local reason="all-processes-ok"
|
||||
if (( sim_rc != 0 || streamer_rc != 0 || tester_rc != 0 )); then
|
||||
status="FAIL"
|
||||
reason="sim_rc=${sim_rc},streamer_rc=${streamer_rc},tester_rc=${tester_rc}"
|
||||
local status="FAIL"
|
||||
local reason="expected non-zero rc and log token '${expected_substring}'"
|
||||
if (( command_rc != 0 )) && grep -Fq "${expected_substring}" "${log_path}"; then
|
||||
status="PASS"
|
||||
reason="command failed as expected"
|
||||
fi
|
||||
|
||||
append_manifest_row \
|
||||
@@ -276,26 +117,21 @@ run_fault_scenario() {
|
||||
"${status}" \
|
||||
"${reason}" \
|
||||
"${duration_ms}" \
|
||||
"${sim_rc}" \
|
||||
"${streamer_rc}" \
|
||||
"${tester_rc}" \
|
||||
"${sim_log}" \
|
||||
"${streamer_log}" \
|
||||
"${tester_log}" \
|
||||
"${sdp_path}"
|
||||
"${command_rc}" \
|
||||
"${log_path}"
|
||||
|
||||
printf "[%s] %s => %s (%s)\n" "${scenario_id}" "${name}" "${status}" "${reason}"
|
||||
}
|
||||
|
||||
main() {
|
||||
local required=(
|
||||
"${BUILD_DIR}/cvmmap_streamer"
|
||||
"${BUILD_DIR}/rtp_receiver_tester"
|
||||
"${BUILD_DIR}/cvmmap_streamer"
|
||||
"${BUILD_DIR}/rtmp_output_tester"
|
||||
)
|
||||
|
||||
local missing=()
|
||||
for bin in "${required[@]}"; do
|
||||
if ! binary_exists "${bin}"; then
|
||||
if [[ ! -x "${bin}" ]]; then
|
||||
missing+=("${bin}")
|
||||
fi
|
||||
done
|
||||
@@ -313,9 +149,68 @@ main() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
run_fault_scenario 1 "torn_read" "fault:torn-read"
|
||||
run_fault_scenario 2 "sink_stall" "fault:sink-stall"
|
||||
run_fault_scenario 3 "reset_storm" "fault:reset-storm"
|
||||
run_expected_failure 1 "removed_encoder_backend" "removed encoder backend rejected" \
|
||||
"invalid encoder backend: 'gstreamer_legacy' was removed; use ffmpeg" \
|
||||
"${BUILD_DIR}/cvmmap_streamer" \
|
||||
--run-mode pipeline \
|
||||
--input-uri cvmmap://default \
|
||||
--encoder-backend gstreamer_legacy
|
||||
|
||||
run_expected_failure 2 "removed_rtmp_transport" "removed RTMP transport rejected" \
|
||||
"invalid rtmp transport: 'legacy_custom' was removed; use libavformat or ffmpeg_process" \
|
||||
"${BUILD_DIR}/cvmmap_streamer" \
|
||||
--run-mode pipeline \
|
||||
--input-uri cvmmap://default \
|
||||
--rtmp \
|
||||
--rtmp-url rtmp://127.0.0.1/live/test \
|
||||
--rtmp-transport legacy_custom
|
||||
|
||||
run_expected_failure 3 "removed_rtmp_mode_cli" "removed RTMP mode flag rejected" \
|
||||
"unknown argument: --rtmp-mode (removed; RTMP always uses enhanced mode)" \
|
||||
"${BUILD_DIR}/cvmmap_streamer" \
|
||||
--rtmp-mode enhanced
|
||||
|
||||
local mode_row_dir="${RUN_DIR}/4-removed_rtmp_mode_toml"
|
||||
mkdir -p "${mode_row_dir}"
|
||||
local mode_config="${mode_row_dir}/removed_rtmp_mode.toml"
|
||||
cat >"${mode_config}" <<'EOF'
|
||||
[outputs.rtmp]
|
||||
enabled = true
|
||||
urls = ["rtmp://127.0.0.1/live/test"]
|
||||
mode = "enhanced"
|
||||
EOF
|
||||
run_expected_failure 4 "removed_rtmp_mode_toml" "removed RTMP mode TOML rejected" \
|
||||
"invalid RTMP config: outputs.rtmp.mode was removed; RTMP always uses enhanced mode" \
|
||||
"${BUILD_DIR}/cvmmap_streamer" \
|
||||
--config "${mode_config}"
|
||||
|
||||
run_expected_failure 5 "missing_rtmp_url" "missing RTMP URL rejected" \
|
||||
"invalid RTMP config: enabled RTMP output requires at least one URL" \
|
||||
"${BUILD_DIR}/cvmmap_streamer" \
|
||||
--run-mode pipeline \
|
||||
--input-uri cvmmap://default \
|
||||
--rtmp
|
||||
|
||||
run_expected_failure 6 "invalid_rtp_endpoint" "invalid RTP endpoint rejected" \
|
||||
"invalid RTP config: endpoint must be in '<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
|
||||
finished_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
@@ -328,54 +223,36 @@ main() {
|
||||
--started-at "${STARTED_AT_UTC}" \
|
||||
--finished-at "${finished_at_utc}" \
|
||||
--mode "${MODE}"
|
||||
local summary_rc=$?
|
||||
|
||||
cp -f "${SUMMARY_JSON}" "${LATEST_SUMMARY_JSON}" 2>/dev/null || true
|
||||
|
||||
local total_count pass_count fail_count all_pass
|
||||
total_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("total", 0))
|
||||
PY
|
||||
)"
|
||||
pass_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("pass", 0))
|
||||
PY
|
||||
)"
|
||||
fail_count="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
counts = data.get("counts", {})
|
||||
print(counts.get("fail", 0))
|
||||
PY
|
||||
)"
|
||||
all_pass="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
print("true" if data.get("all_pass", False) else "false")
|
||||
PY
|
||||
)"
|
||||
|
||||
local violation_lines
|
||||
violation_lines="$(python3 - <<'PY' "${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
||||
for scenario in data.get("scenarios", []):
|
||||
sid = scenario.get("id", "unknown")
|
||||
for violation in scenario.get("violations", []):
|
||||
print(f"{sid}:{violation}")
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
@@ -385,42 +262,24 @@ PY
|
||||
echo "run_dir=${RUN_DIR}"
|
||||
echo "manifest=${MANIFEST_TSV}"
|
||||
echo "summary_json=${SUMMARY_JSON}"
|
||||
echo "latest_summary_json=${LATEST_SUMMARY_JSON}"
|
||||
echo "started_at=${STARTED_AT_UTC}"
|
||||
echo "finished_at=${finished_at_utc}"
|
||||
echo "scenario_total=${total_count}"
|
||||
echo "scenario_pass=${pass_count}"
|
||||
echo "scenario_fail=${fail_count}"
|
||||
echo "counts_total=${total_count}"
|
||||
echo "counts_pass=${pass_count}"
|
||||
echo "counts_fail=${fail_count}"
|
||||
echo "all_pass=${all_pass}"
|
||||
echo "summary_helper_rc=${summary_rc}"
|
||||
echo "violated_thresholds_begin"
|
||||
if [[ -n "${violation_lines}" ]]; then
|
||||
echo "${violation_lines}"
|
||||
fi
|
||||
echo "violated_thresholds_end"
|
||||
echo "scenarios=removed_encoder_backend,removed_rtmp_transport,removed_rtmp_mode_cli,removed_rtmp_mode_toml,missing_rtmp_url,invalid_rtp_endpoint,ffmpeg_process_bad_binary"
|
||||
} > "${EVIDENCE_TEXT}"
|
||||
|
||||
if (( summary_rc != 0 )); then
|
||||
echo "summary helper failed with rc=${summary_rc}" >&2
|
||||
return 1
|
||||
if [[ "${all_pass}" == "true" ]]; then
|
||||
echo "fault suite PASS (${pass_count}/${total_count})"
|
||||
echo "summary: ${SUMMARY_JSON}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "fault-suite mode=${MODE} total=${total_count} pass=${pass_count} fail=${fail_count}"
|
||||
echo "summary: ${SUMMARY_JSON}"
|
||||
|
||||
if [[ "${MODE}" == "baseline" ]]; then
|
||||
if [[ "${total_count}" == "3" && "${pass_count}" == "3" && "${fail_count}" == "0" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${fail_count}" != "0" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "degraded mode did not violate thresholds" >&2
|
||||
return 2
|
||||
echo "fault suite FAIL (${pass_count}/${total_count})" >&2
|
||||
echo "summary: ${SUMMARY_JSON}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+34
-321
@@ -5,16 +5,12 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
|
||||
KV_PATTERN = re.compile(r"([a-zA-Z0-9_]+)=([^\s]+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliArgs:
|
||||
manifest: str
|
||||
@@ -27,9 +23,7 @@ class CliArgs:
|
||||
|
||||
|
||||
def parse_args() -> CliArgs:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build fault suite summary with threshold checks"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="Build fault suite summary")
|
||||
_ = parser.add_argument("--manifest", required=True)
|
||||
_ = parser.add_argument("--output", required=True)
|
||||
_ = parser.add_argument("--run-id", required=True)
|
||||
@@ -37,7 +31,6 @@ def parse_args() -> CliArgs:
|
||||
_ = parser.add_argument("--started-at", required=True)
|
||||
_ = parser.add_argument("--finished-at", required=True)
|
||||
_ = parser.add_argument("--mode", required=True, choices=("baseline", "degraded"))
|
||||
|
||||
parsed = parser.parse_args(sys.argv[1:])
|
||||
return CliArgs(
|
||||
manifest=cast(str, parsed.manifest),
|
||||
@@ -50,46 +43,17 @@ def parse_args() -> CliArgs:
|
||||
)
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return ""
|
||||
try:
|
||||
return p.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
def parse_manifest(path: str) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
with open(path, "r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle, delimiter="\t")
|
||||
for raw in reader:
|
||||
row = {key: "" if value is None else str(value) for key, value in raw.items()}
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def to_number(value: str) -> int | float | str:
|
||||
if re.fullmatch(r"-?\d+", value):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if re.fullmatch(r"-?\d+\.\d+", value):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def parse_key_values(line: str) -> dict[str, int | float | str]:
|
||||
out: dict[str, int | float | str] = {}
|
||||
for match in KV_PATTERN.finditer(line):
|
||||
out[match.group(1)] = to_number(match.group(2))
|
||||
return out
|
||||
|
||||
|
||||
def last_line_with_token(text: str, token: str) -> str:
|
||||
found = ""
|
||||
for line in text.splitlines():
|
||||
if token in line:
|
||||
found = line
|
||||
return found
|
||||
|
||||
|
||||
def parse_exit(value: str) -> int:
|
||||
def parse_int(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
@@ -103,302 +67,51 @@ def parse_duration_ms(value: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def parse_manifest(path: str) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
with open(path, "r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle, delimiter="\t")
|
||||
for raw in reader:
|
||||
row: dict[str, str] = {}
|
||||
for key in (
|
||||
"order",
|
||||
"scenario_id",
|
||||
"name",
|
||||
"status",
|
||||
"reason",
|
||||
"duration_ms",
|
||||
"sim_rc",
|
||||
"streamer_rc",
|
||||
"tester_rc",
|
||||
"sim_log",
|
||||
"streamer_log",
|
||||
"tester_log",
|
||||
"sdp_path",
|
||||
):
|
||||
value = raw.get(key, "")
|
||||
row[key] = "" if value is None else str(value)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
Check = dict[str, object]
|
||||
|
||||
|
||||
def make_check_min(metric: str, actual: int, minimum: int) -> Check:
|
||||
passed = actual >= minimum
|
||||
return {
|
||||
"metric": metric,
|
||||
"type": "min",
|
||||
"actual": actual,
|
||||
"expected": minimum,
|
||||
"passed": passed,
|
||||
"violation": "" if passed else f"{metric}={actual} < {minimum}",
|
||||
}
|
||||
|
||||
|
||||
def make_check_max(metric: str, actual: int, maximum: int) -> Check:
|
||||
passed = actual <= maximum
|
||||
return {
|
||||
"metric": metric,
|
||||
"type": "max",
|
||||
"actual": actual,
|
||||
"expected": maximum,
|
||||
"passed": passed,
|
||||
"violation": "" if passed else f"{metric}={actual} > {maximum}",
|
||||
}
|
||||
|
||||
|
||||
def get_thresholds(mode: str) -> dict[str, dict[str, int]]:
|
||||
if mode == "baseline":
|
||||
return {
|
||||
"torn_read": {
|
||||
"torn_read_events_min": 1,
|
||||
"p50_us_max": 200_000,
|
||||
"p99_us_max": 400_000,
|
||||
"drop_ratio_ppm_max": 980_000,
|
||||
"samples_min": 10,
|
||||
},
|
||||
"sink_stall": {
|
||||
"sink_stall_events_min": 1,
|
||||
"p50_us_max": 350_000,
|
||||
"p95_us_max": 600_000,
|
||||
"drop_ratio_ppm_max": 1_000_000,
|
||||
"samples_min": 1,
|
||||
},
|
||||
"reset_storm": {
|
||||
"reset_events_min": 4,
|
||||
"p50_us_max": 1_000_000,
|
||||
"p99_us_max": 1_000_000,
|
||||
"drop_ratio_ppm_max": 1_000_000,
|
||||
"samples_min": 1,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"torn_read": {
|
||||
"torn_read_events_min": 200,
|
||||
"p50_us_max": 1_000,
|
||||
"p99_us_max": 2_000,
|
||||
"drop_ratio_ppm_max": 20_000,
|
||||
"samples_min": 100,
|
||||
},
|
||||
"sink_stall": {
|
||||
"sink_stall_events_min": 200,
|
||||
"p50_us_max": 1_000,
|
||||
"p95_us_max": 2_000,
|
||||
"drop_ratio_ppm_max": 20_000,
|
||||
"samples_min": 100,
|
||||
},
|
||||
"reset_storm": {
|
||||
"reset_events_min": 20,
|
||||
"p50_us_max": 1_000,
|
||||
"p99_us_max": 2_000,
|
||||
"drop_ratio_ppm_max": 20_000,
|
||||
"samples_min": 100,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def scenario_checks(
|
||||
scenario_id: str,
|
||||
fault: dict[str, int | float | str],
|
||||
latency: dict[str, int | float | str],
|
||||
thresholds: dict[str, dict[str, int]],
|
||||
) -> list[Check]:
|
||||
scenario_thresholds = thresholds.get(scenario_id, {})
|
||||
|
||||
torn = int(fault.get("torn_read_events", 0))
|
||||
stall = int(fault.get("sink_stall_events", 0))
|
||||
resets = int(fault.get("reset_events", 0))
|
||||
p95 = int(latency.get("p95_us", 0))
|
||||
p99 = int(latency.get("p99_us", 0))
|
||||
p50 = int(latency.get("p50_us", 0))
|
||||
samples = int(latency.get("ingest_to_emit_samples", 0))
|
||||
drop_ratio_ppm = int(latency.get("drop_ratio_ppm", 0))
|
||||
|
||||
checks: list[Check] = []
|
||||
checks.append(
|
||||
make_check_min(
|
||||
"ingest_to_emit_samples",
|
||||
samples,
|
||||
int(scenario_thresholds.get("samples_min", 1)),
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
make_check_max(
|
||||
"p50_us",
|
||||
p50,
|
||||
int(scenario_thresholds.get("p50_us_max", 500_000)),
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
make_check_max(
|
||||
"drop_ratio_ppm",
|
||||
drop_ratio_ppm,
|
||||
int(scenario_thresholds.get("drop_ratio_ppm_max", 1_000_000)),
|
||||
)
|
||||
)
|
||||
|
||||
if scenario_id == "torn_read":
|
||||
checks.append(
|
||||
make_check_min(
|
||||
"torn_read_events",
|
||||
torn,
|
||||
int(scenario_thresholds.get("torn_read_events_min", 1)),
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
make_check_max(
|
||||
"p99_us",
|
||||
p99,
|
||||
int(scenario_thresholds.get("p99_us_max", 500_000)),
|
||||
)
|
||||
)
|
||||
elif scenario_id == "sink_stall":
|
||||
checks.append(
|
||||
make_check_min(
|
||||
"sink_stall_events",
|
||||
stall,
|
||||
int(scenario_thresholds.get("sink_stall_events_min", 1)),
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
make_check_max(
|
||||
"p95_us",
|
||||
p95,
|
||||
int(scenario_thresholds.get("p95_us_max", 500_000)),
|
||||
)
|
||||
)
|
||||
elif scenario_id == "reset_storm":
|
||||
checks.append(
|
||||
make_check_min(
|
||||
"reset_events",
|
||||
resets,
|
||||
int(scenario_thresholds.get("reset_events_min", 1)),
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
make_check_max(
|
||||
"p99_us",
|
||||
p99,
|
||||
int(scenario_thresholds.get("p99_us_max", 500_000)),
|
||||
)
|
||||
)
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
def build_summary(args: CliArgs) -> dict[str, object]:
|
||||
thresholds = get_thresholds(args.mode)
|
||||
rows = parse_manifest(args.manifest)
|
||||
manifest_rows = parse_manifest(args.manifest)
|
||||
rows = [
|
||||
{
|
||||
"order": parse_int(row["order"]),
|
||||
"id": row["scenario_id"],
|
||||
"name": row["name"],
|
||||
"status": row["status"],
|
||||
"reason": row["reason"],
|
||||
"duration_ms": parse_duration_ms(row["duration_ms"]),
|
||||
"exit_codes": {"command": parse_int(row["command_rc"])},
|
||||
"evidence": {"log_path": row["log_path"]},
|
||||
}
|
||||
for row in sorted(manifest_rows, key=lambda item: parse_int(item["order"]))
|
||||
]
|
||||
|
||||
scenarios: list[dict[str, object]] = []
|
||||
for row in sorted(rows, key=lambda item: int(item["order"])):
|
||||
streamer_text = read_text(row["streamer_log"])
|
||||
pipeline_line = last_line_with_token(streamer_text, "PIPELINE_METRICS")
|
||||
latency_line = last_line_with_token(streamer_text, "LATENCY_METRICS")
|
||||
fault_line = last_line_with_token(streamer_text, "FAULT_COUNTERS")
|
||||
rtp_line = last_line_with_token(streamer_text, "RTP_METRICS")
|
||||
|
||||
pipeline = parse_key_values(pipeline_line) if pipeline_line else {}
|
||||
latency = parse_key_values(latency_line) if latency_line else {}
|
||||
fault = parse_key_values(fault_line) if fault_line else {}
|
||||
rtp = parse_key_values(rtp_line) if rtp_line else {}
|
||||
|
||||
sim_rc = parse_exit(row["sim_rc"])
|
||||
streamer_rc = parse_exit(row["streamer_rc"])
|
||||
tester_rc = parse_exit(row["tester_rc"])
|
||||
|
||||
process_ok = sim_rc == 0 and streamer_rc == 0 and tester_rc == 0
|
||||
checks = scenario_checks(row["scenario_id"], fault, latency, thresholds)
|
||||
violated_checks = [
|
||||
cast(str, check["violation"])
|
||||
for check in checks
|
||||
if not cast(bool, check["passed"])
|
||||
]
|
||||
|
||||
scenario_pass = process_ok and len(violated_checks) == 0
|
||||
scenario_status = "PASS" if scenario_pass else "FAIL"
|
||||
reason = (
|
||||
"all checks passed"
|
||||
if scenario_pass
|
||||
else (
|
||||
f"process_rc(sim={sim_rc},streamer={streamer_rc},tester={tester_rc})"
|
||||
if not process_ok
|
||||
else "; ".join(violated_checks)
|
||||
)
|
||||
)
|
||||
|
||||
scenarios.append(
|
||||
{
|
||||
"order": int(row["order"]),
|
||||
"id": row["scenario_id"],
|
||||
"name": row["name"],
|
||||
"status": scenario_status,
|
||||
"reason": reason,
|
||||
"duration_ms": parse_duration_ms(row["duration_ms"]),
|
||||
"process_exit": {
|
||||
"sim": sim_rc,
|
||||
"streamer": streamer_rc,
|
||||
"tester": tester_rc,
|
||||
},
|
||||
"metrics": {
|
||||
"pipeline": pipeline,
|
||||
"latency": latency,
|
||||
"fault": fault,
|
||||
"rtp": rtp,
|
||||
},
|
||||
"checks": checks,
|
||||
"violations": violated_checks,
|
||||
"evidence": {
|
||||
"sim_log": row["sim_log"],
|
||||
"streamer_log": row["streamer_log"],
|
||||
"tester_log": row["tester_log"],
|
||||
"sdp": row["sdp_path"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
pass_count = sum(1 for item in scenarios if item["status"] == "PASS")
|
||||
fail_count = sum(1 for item in scenarios if item["status"] == "FAIL")
|
||||
all_pass = len(scenarios) == 3 and pass_count == 3 and fail_count == 0
|
||||
pass_count = sum(1 for row in rows if row["status"] == "PASS")
|
||||
fail_count = sum(1 for row in rows if row["status"] == "FAIL")
|
||||
skip_count = sum(1 for row in rows if row["status"] == "SKIP")
|
||||
all_pass = len(rows) == 7 and pass_count == 7 and fail_count == 0 and skip_count == 0
|
||||
|
||||
return {
|
||||
"task": 15,
|
||||
"mode": args.mode,
|
||||
"run_id": args.run_id,
|
||||
"run_dir": args.run_dir,
|
||||
"started_at": args.started_at,
|
||||
"finished_at": args.finished_at,
|
||||
"thresholds": thresholds,
|
||||
"mode": args.mode,
|
||||
"counts": {
|
||||
"total": len(scenarios),
|
||||
"total": len(rows),
|
||||
"pass": pass_count,
|
||||
"fail": fail_count,
|
||||
"skip": skip_count,
|
||||
},
|
||||
"all_pass": all_pass,
|
||||
"recommended_exit_code": 0 if all_pass else 1,
|
||||
"scenarios": scenarios,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
summary = build_summary(args)
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_ = output_path.write_text(
|
||||
json.dumps(summary, indent=2, sort_keys=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
summary = build_summary(args)
|
||||
output_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ CODEC="${CODEC:-h264}"
|
||||
ENCODER_BACKEND="${ENCODER_BACKEND:-ffmpeg}"
|
||||
ENCODER_DEVICE="${ENCODER_DEVICE:-nvidia}"
|
||||
RTMP_TRANSPORT="${RTMP_TRANSPORT:-libavformat}"
|
||||
RTMP_MODE="${RTMP_MODE:-enhanced}"
|
||||
INGEST_MAX_FRAMES="${INGEST_MAX_FRAMES:-120}"
|
||||
PROBE_TIMEOUT_S="${PROBE_TIMEOUT_S:-20}"
|
||||
DECODE_FRAMES="${DECODE_FRAMES:-15}"
|
||||
@@ -58,10 +57,9 @@ Environment overrides:
|
||||
INPUT_URI cvmmap source URI, if positional argument is omitted
|
||||
STREAM_NAME RTMP/HTTP-FLV stream name, default derived from INPUT_URI
|
||||
CODEC h264|h265
|
||||
ENCODER_BACKEND ffmpeg|gstreamer_legacy
|
||||
ENCODER_BACKEND auto|ffmpeg
|
||||
ENCODER_DEVICE auto|nvidia|software
|
||||
RTMP_TRANSPORT libavformat|ffmpeg_process|legacy_custom
|
||||
RTMP_MODE enhanced|domestic
|
||||
RTMP_TRANSPORT libavformat|ffmpeg_process
|
||||
INGEST_MAX_FRAMES bounded frame count for the smoke
|
||||
DECODE_FRAMES frames to decode from HTTP-FLV after probe
|
||||
SRS_ROOT local SRS checkout, default ~/Code/srs
|
||||
@@ -198,7 +196,6 @@ fi
|
||||
--rtmp \
|
||||
--rtmp-url "$RTMP_URL" \
|
||||
--rtmp-transport "$RTMP_TRANSPORT" \
|
||||
--rtmp-mode "$RTMP_MODE" \
|
||||
--ingest-max-frames "$INGEST_MAX_FRAMES" \
|
||||
>"$STREAMER_LOG" 2>&1 &
|
||||
STREAMER_PID=$!
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#ifndef CVMMAP_STREAMER_HAS_GSTREAMER
|
||||
#define CVMMAP_STREAMER_HAS_GSTREAMER 0
|
||||
#endif
|
||||
|
||||
namespace cvmmap_streamer {
|
||||
|
||||
namespace {
|
||||
@@ -36,10 +32,16 @@ std::string trim_copy(std::string value) {
|
||||
}
|
||||
|
||||
std::string normalize_cli_error(std::string raw_message) {
|
||||
if (raw_message.find("The following argument was not expected:") != std::string::npos) {
|
||||
if (
|
||||
raw_message.find("The following argument was not expected:") != std::string::npos ||
|
||||
raw_message.find("The following arguments were not expected:") != std::string::npos) {
|
||||
const auto pos = raw_message.find(':');
|
||||
if (pos != std::string::npos && pos + 1 < raw_message.size()) {
|
||||
return "unknown argument: " + trim_copy(raw_message.substr(pos + 1));
|
||||
const auto argument = trim_copy(raw_message.substr(pos + 1));
|
||||
if (argument.rfind("--rtmp-mode", 0) == 0) {
|
||||
return "unknown argument: --rtmp-mode (removed; RTMP always uses enhanced mode)";
|
||||
}
|
||||
return "unknown argument: " + argument;
|
||||
}
|
||||
return "unknown argument";
|
||||
}
|
||||
@@ -113,16 +115,6 @@ std::expected<RunMode, std::string> parse_run_mode(std::string_view raw) {
|
||||
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) {
|
||||
if (raw == "libavformat") {
|
||||
return RtmpTransportType::Libavformat;
|
||||
@@ -131,10 +123,10 @@ std::expected<RtmpTransportType, std::string> parse_rtmp_transport(std::string_v
|
||||
return RtmpTransportType::FfmpegProcess;
|
||||
}
|
||||
if (raw == "legacy_custom" || raw == "legacy-custom") {
|
||||
return RtmpTransportType::LegacyCustom;
|
||||
return std::unexpected(
|
||||
"invalid rtmp transport: '" + std::string(raw) + "' was removed; use libavformat or ffmpeg_process");
|
||||
}
|
||||
return std::unexpected(
|
||||
"invalid rtmp transport: '" + std::string(raw) + "' (expected: libavformat|ffmpeg_process|legacy_custom)");
|
||||
return std::unexpected("invalid rtmp transport: '" + std::string(raw) + "' (expected: libavformat|ffmpeg_process)");
|
||||
}
|
||||
|
||||
std::expected<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;
|
||||
}
|
||||
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) {
|
||||
@@ -352,13 +344,9 @@ std::expected<void, std::string> apply_toml_file(RuntimeConfig &config, const st
|
||||
if (auto value = toml_value<std::string>(table, "outputs.rtmp.ffmpeg_path")) {
|
||||
config.outputs.rtmp.ffmpeg_path = *value;
|
||||
}
|
||||
if (auto value = toml_value<std::string>(table, "outputs.rtmp.mode")) {
|
||||
auto parsed = parse_rtmp_mode(*value);
|
||||
if (!parsed) {
|
||||
return std::unexpected(parsed.error());
|
||||
if (auto value = toml_value<std::string>(table, "outputs.rtmp.mode")) {
|
||||
return std::unexpected("invalid RTMP config: outputs.rtmp.mode was removed; RTMP always uses enhanced mode");
|
||||
}
|
||||
config.outputs.rtmp.mode = *parsed;
|
||||
}
|
||||
if (auto value = toml_value<bool>(table, "record.mcap.enabled")) {
|
||||
config.record.mcap.enabled = *value;
|
||||
}
|
||||
@@ -502,8 +490,6 @@ std::string_view to_string(RtmpMode mode) {
|
||||
switch (mode) {
|
||||
case RtmpMode::Enhanced:
|
||||
return "enhanced";
|
||||
case RtmpMode::Domestic:
|
||||
return "domestic";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -514,8 +500,6 @@ std::string_view to_string(RtmpTransportType transport) {
|
||||
return "libavformat";
|
||||
case RtmpTransportType::FfmpegProcess:
|
||||
return "ffmpeg_process";
|
||||
case RtmpTransportType::LegacyCustom:
|
||||
return "legacy_custom";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -526,8 +510,6 @@ std::string_view to_string(EncoderBackendType backend) {
|
||||
return "auto";
|
||||
case EncoderBackendType::FFmpeg:
|
||||
return "ffmpeg";
|
||||
case EncoderBackendType::GStreamerLegacy:
|
||||
return "gstreamer_legacy";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -565,7 +547,6 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
|
||||
std::string codec_raw{};
|
||||
std::string encoder_backend_raw{};
|
||||
std::string encoder_device_raw{};
|
||||
std::string rtmp_mode_raw{};
|
||||
std::string rtmp_transport_raw{};
|
||||
std::string rtmp_ffmpeg_path_raw{};
|
||||
std::vector<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-transport", rtmp_transport_raw);
|
||||
app.add_option("--rtmp-ffmpeg", rtmp_ffmpeg_path_raw);
|
||||
app.add_option("--rtmp-mode", rtmp_mode_raw);
|
||||
app.add_flag("--rtp", rtp_enabled);
|
||||
app.add_option("--rtp-endpoint", rtp_endpoint_raw);
|
||||
app.add_option("--rtp-payload-type", rtp_payload_type_raw);
|
||||
@@ -692,14 +672,6 @@ std::expected<RuntimeConfig, std::string> parse_runtime_config(int argc, char **
|
||||
if (!rtmp_ffmpeg_path_raw.empty()) {
|
||||
config.outputs.rtmp.ffmpeg_path = rtmp_ffmpeg_path_raw;
|
||||
}
|
||||
if (!rtmp_mode_raw.empty()) {
|
||||
auto parsed = parse_rtmp_mode(rtmp_mode_raw);
|
||||
if (!parsed) {
|
||||
return std::unexpected(parsed.error());
|
||||
}
|
||||
config.outputs.rtmp.mode = *parsed;
|
||||
}
|
||||
|
||||
config.outputs.rtp.enabled = config.outputs.rtp.enabled || rtp_enabled;
|
||||
if (!rtp_endpoint_raw.empty()) {
|
||||
config.outputs.rtp.enabled = true;
|
||||
@@ -831,27 +803,14 @@ std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &co
|
||||
return std::unexpected("invalid RTMP config: URL must not be empty");
|
||||
}
|
||||
}
|
||||
if (config.encoder.backend == EncoderBackendType::GStreamerLegacy && config.record.mcap.enabled) {
|
||||
return std::unexpected("invalid backend/output matrix: MCAP recording requires the ffmpeg encoded access-unit path");
|
||||
}
|
||||
if (config.outputs.rtmp.enabled) {
|
||||
if (config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) {
|
||||
if (config.outputs.rtmp.mode == RtmpMode::Domestic && config.encoder.codec != CodecType::H265) {
|
||||
return std::unexpected("invalid mode matrix: domestic RTMP mode requires codec h265");
|
||||
}
|
||||
if (config.encoder.backend != EncoderBackendType::GStreamerLegacy) {
|
||||
return std::unexpected("invalid backend/output matrix: legacy_custom RTMP requires encoder.backend=gstreamer_legacy");
|
||||
}
|
||||
} else {
|
||||
if (config.outputs.rtmp.mode != RtmpMode::Enhanced) {
|
||||
return std::unexpected("invalid RTMP config: non-legacy RTMP transports only support rtmp.mode=enhanced");
|
||||
}
|
||||
if (config.encoder.backend != EncoderBackendType::FFmpeg) {
|
||||
return std::unexpected("invalid backend/output matrix: RTMP transports libavformat and ffmpeg_process require encoder.backend=ffmpeg");
|
||||
}
|
||||
if (config.outputs.rtmp.transport == RtmpTransportType::FfmpegProcess && config.outputs.rtmp.ffmpeg_path.empty()) {
|
||||
return std::unexpected("invalid RTMP config: ffmpeg_process transport requires a non-empty ffmpeg path");
|
||||
}
|
||||
if (config.encoder.backend == EncoderBackendType::Auto) {
|
||||
// auto resolves to FFmpeg; nothing else is supported.
|
||||
} else if (config.encoder.backend != EncoderBackendType::FFmpeg) {
|
||||
return std::unexpected("invalid backend/output matrix: RTMP requires encoder.backend=ffmpeg or auto");
|
||||
}
|
||||
if (config.outputs.rtmp.transport == RtmpTransportType::FfmpegProcess && config.outputs.rtmp.ffmpeg_path.empty()) {
|
||||
return std::unexpected("invalid RTMP config: ffmpeg_process transport requires a non-empty ffmpeg path");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,15 +850,6 @@ std::expected<void, std::string> validate_runtime_config(const RuntimeConfig &co
|
||||
return std::unexpected("invalid ingest config: ingest_idle_timeout_ms must be >= 1");
|
||||
}
|
||||
|
||||
#if !CVMMAP_STREAMER_HAS_GSTREAMER
|
||||
if (config.encoder.backend == EncoderBackendType::GStreamerLegacy) {
|
||||
return std::unexpected("invalid backend config: gstreamer_legacy backend requested but GStreamer support is not compiled");
|
||||
}
|
||||
if (config.outputs.rtmp.enabled && config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) {
|
||||
return std::unexpected("invalid output config: legacy_custom RTMP requires GStreamer support, which is not compiled");
|
||||
}
|
||||
#endif
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -914,7 +864,6 @@ std::string summarize_runtime_config(const RuntimeConfig &config) {
|
||||
ss << ", encoder.b_frames=" << config.encoder.b_frames;
|
||||
ss << ", rtmp.enabled=" << (config.outputs.rtmp.enabled ? "true" : "false");
|
||||
ss << ", rtmp.transport=" << to_string(config.outputs.rtmp.transport);
|
||||
ss << ", rtmp.mode=" << to_string(config.outputs.rtmp.mode);
|
||||
ss << ", rtmp.urls=" << config.outputs.rtmp.urls.size();
|
||||
ss << ", rtp.enabled=" << (config.outputs.rtp.enabled ? "true" : "false");
|
||||
ss << ", rtp.endpoint=" << (config.outputs.rtp.endpoint ? *config.outputs.rtp.endpoint : "<unset>");
|
||||
|
||||
@@ -3,29 +3,11 @@
|
||||
namespace cvmmap_streamer::encode {
|
||||
|
||||
EncoderBackend make_ffmpeg_backend();
|
||||
EncoderBackend make_gstreamer_legacy_backend();
|
||||
|
||||
Result<EncoderBackend> make_encoder_backend(const RuntimeConfig &config) {
|
||||
switch (config.encoder.backend) {
|
||||
case EncoderBackendType::FFmpeg:
|
||||
return make_ffmpeg_backend();
|
||||
case EncoderBackendType::GStreamerLegacy: {
|
||||
auto backend = make_gstreamer_legacy_backend();
|
||||
if (!backend) {
|
||||
return unexpected_error(ERR_BACKEND_UNAVAILABLE, "legacy GStreamer backend is not compiled in this build");
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
case EncoderBackendType::Auto:
|
||||
if (config.outputs.rtmp.enabled && config.outputs.rtmp.transport == RtmpTransportType::LegacyCustom) {
|
||||
auto backend = make_gstreamer_legacy_backend();
|
||||
if (!backend) {
|
||||
return unexpected_error(
|
||||
ERR_BACKEND_UNAVAILABLE,
|
||||
"legacy_custom RTMP requires the GStreamer backend, but it is not compiled");
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
return make_ffmpeg_backend();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
+4
-5
@@ -19,7 +19,7 @@ constexpr std::array<std::string_view, 32> kHelpLines{
|
||||
" --input-uri <uri>\tcvmmap source URI (example: cvmmap://default)",
|
||||
" --run-mode <mode>\tpipeline|ingest",
|
||||
" --codec <codec>\th264|h265",
|
||||
" --encoder-backend <backend>\tauto|ffmpeg|gstreamer_legacy",
|
||||
" --encoder-backend <backend>\tauto|ffmpeg",
|
||||
" --encoder-device <device>\tauto|nvidia|software",
|
||||
" --gop <frames>\tencoder GOP length",
|
||||
" --b-frames <count>\tencoder B-frame count",
|
||||
@@ -29,10 +29,9 @@ constexpr std::array<std::string_view, 32> kHelpLines{
|
||||
" --rtp-sdp <path>\twrite optional SDP sidecar",
|
||||
" --rtmp\t\tenable RTMP output",
|
||||
" --rtmp-url <url>\tadd RTMP destination (repeatable)",
|
||||
" --rtmp-transport <mode>\tlibavformat|ffmpeg_process|legacy_custom",
|
||||
" --rtmp-ffmpeg <path>\tffmpeg binary for ffmpeg_process transport",
|
||||
" --rtmp-mode <mode>\tenhanced|domestic",
|
||||
" --mcap\t\tenable MCAP recording",
|
||||
" --rtmp-transport <mode>\tlibavformat|ffmpeg_process",
|
||||
" --rtmp-ffmpeg <path>\tffmpeg binary for ffmpeg_process transport",
|
||||
" --mcap\t\tenable MCAP recording",
|
||||
" --mcap-path <path>\tMCAP output file",
|
||||
" --mcap-topic <topic>\tMCAP topic name",
|
||||
" --mcap-frame-id <id>\tFoxglove CompressedVideo frame_id",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#include "cvmmap_streamer/protocol/rtmp_output.hpp"
|
||||
|
||||
#include "cvmmap_streamer/protocol/rtmp_publisher.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
@@ -226,33 +224,6 @@ Status write_all(int fd, std::span<const std::uint8_t> bytes) {
|
||||
return {};
|
||||
}
|
||||
|
||||
class LegacyCustomRtmpOutput {
|
||||
public:
|
||||
explicit LegacyCustomRtmpOutput(RtmpPublisher &&publisher)
|
||||
: publisher_(std::move(publisher)) {}
|
||||
|
||||
[[nodiscard]]
|
||||
std::string_view backend_name() const {
|
||||
return "legacy_custom";
|
||||
}
|
||||
|
||||
[[nodiscard]]
|
||||
Status publish_access_unit(const encode::EncodedAccessUnit &access_unit) {
|
||||
auto publish = publisher_.publish_access_unit(access_unit.annexb_bytes, access_unit.stream_pts_ns);
|
||||
if (!publish) {
|
||||
return unexpected_error(ERR_PROTOCOL, publish.error());
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void log_metrics() const {
|
||||
publisher_.log_metrics();
|
||||
}
|
||||
|
||||
private:
|
||||
RtmpPublisher publisher_{};
|
||||
};
|
||||
|
||||
class LibavformatRtmpOutput {
|
||||
public:
|
||||
struct Session {
|
||||
@@ -681,13 +652,6 @@ Result<RtmpOutput> make_rtmp_output(
|
||||
return LibavformatRtmpOutput::create(config, stream_info);
|
||||
case RtmpTransportType::FfmpegProcess:
|
||||
return FfmpegProcessRtmpOutput::create(config, stream_info);
|
||||
case RtmpTransportType::LegacyCustom: {
|
||||
auto publisher = RtmpPublisher::create(config);
|
||||
if (!publisher) {
|
||||
return unexpected_error(ERR_PROTOCOL, publisher.error());
|
||||
}
|
||||
return pro::make_proxy<RtmpOutputFacade, LegacyCustomRtmpOutput>(std::move(*publisher));
|
||||
}
|
||||
}
|
||||
|
||||
return unexpected_error(ERR_INTERNAL, "unknown RTMP transport");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"};
|
||||
app.add_option("--rtmp-url", config.rtmp_url, "RTMP destination URL")->required();
|
||||
app.add_option("--transport", config.transport, "RTMP transport backend (libavformat|ffmpeg_process)")
|
||||
->check(CLI::IsMember({"libavformat", "ffmpeg_process", "legacy_custom"}));
|
||||
->check(CLI::IsMember({"libavformat", "ffmpeg_process"}));
|
||||
app.add_option("--codec", config.codec, "Video codec (h264|h265)")
|
||||
->check(CLI::IsMember({"h264", "h265"}));
|
||||
app.add_option("--encoder-device", config.encoder_device, "Encoder device (auto|nvidia|software)")
|
||||
@@ -95,9 +95,6 @@ std::expected<cvmmap_streamer::RtmpTransportType, std::string> parse_transport(s
|
||||
if (raw == "ffmpeg_process") {
|
||||
return cvmmap_streamer::RtmpTransportType::FfmpegProcess;
|
||||
}
|
||||
if (raw == "legacy_custom") {
|
||||
return cvmmap_streamer::RtmpTransportType::LegacyCustom;
|
||||
}
|
||||
return std::unexpected("unsupported transport");
|
||||
}
|
||||
|
||||
@@ -163,10 +160,6 @@ int main(int argc, char **argv) {
|
||||
config.outputs.rtmp.transport = *transport;
|
||||
config.outputs.rtmp.ffmpeg_path = args->ffmpeg_path;
|
||||
|
||||
if (config.outputs.rtmp.transport == cvmmap_streamer::RtmpTransportType::LegacyCustom) {
|
||||
config.encoder.backend = cvmmap_streamer::EncoderBackendType::GStreamerLegacy;
|
||||
}
|
||||
|
||||
cvmmap_streamer::ipc::FrameInfo frame_info{
|
||||
.width = static_cast<std::uint16_t>(args->width),
|
||||
.height = static_cast<std::uint16_t>(args->height),
|
||||
|
||||
@@ -61,14 +61,12 @@ enum class ExitCode : int {
|
||||
enum class ExpectMode {
|
||||
H264,
|
||||
H265Enhanced,
|
||||
H265Domestic,
|
||||
};
|
||||
|
||||
enum class VideoSignal {
|
||||
Unknown,
|
||||
H264,
|
||||
H265Enhanced,
|
||||
H265Domestic,
|
||||
};
|
||||
|
||||
struct Config {
|
||||
@@ -97,7 +95,6 @@ struct Stats {
|
||||
|
||||
std::uint32_t h264_video_messages{0};
|
||||
std::uint32_t h265_enhanced_video_messages{0};
|
||||
std::uint32_t h265_domestic_video_messages{0};
|
||||
std::uint32_t unknown_video_messages{0};
|
||||
|
||||
bool mode_mismatch{false};
|
||||
@@ -184,8 +181,6 @@ std::string_view to_string(ExpectMode mode) {
|
||||
return "h264";
|
||||
case ExpectMode::H265Enhanced:
|
||||
return "h265-enhanced";
|
||||
case ExpectMode::H265Domestic:
|
||||
return "h265-domestic";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
@@ -198,8 +193,6 @@ std::string_view to_string(VideoSignal signal) {
|
||||
return "h264";
|
||||
case VideoSignal::H265Enhanced:
|
||||
return "h265-enhanced";
|
||||
case VideoSignal::H265Domestic:
|
||||
return "h265-domestic";
|
||||
case VideoSignal::Unknown:
|
||||
default:
|
||||
return "unknown";
|
||||
@@ -214,11 +207,8 @@ std::expected<ExpectMode, std::string> parse_mode(std::string_view raw) {
|
||||
if (raw == "h265-enhanced") {
|
||||
return ExpectMode::H265Enhanced;
|
||||
}
|
||||
if (raw == "h265-domestic") {
|
||||
return ExpectMode::H265Domestic;
|
||||
}
|
||||
return std::unexpected(std::format(
|
||||
"invalid mode '{}'; expected: h264 | h265-enhanced | h265-domestic",
|
||||
"invalid mode '{}'; expected: h264 | h265-enhanced",
|
||||
raw));
|
||||
}
|
||||
|
||||
@@ -227,7 +217,7 @@ std::expected<Config, std::string> parse_args(int argc, char **argv) {
|
||||
Config config;
|
||||
std::string 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"};
|
||||
app.allow_extras(false);
|
||||
@@ -1176,9 +1166,6 @@ VideoSignal classify_video_packet(std::span<const std::uint8_t> payload) {
|
||||
if (codec_id == 7) {
|
||||
return VideoSignal::H264;
|
||||
}
|
||||
if (codec_id == 12) {
|
||||
return VideoSignal::H265Domestic;
|
||||
}
|
||||
|
||||
if ((first & 0x80) != 0 && payload.size() >= 5) {
|
||||
const std::array<std::uint8_t, 4> hvc1{'h', 'v', 'c', '1'};
|
||||
@@ -1206,9 +1193,6 @@ void update_mode_stats(
|
||||
case VideoSignal::H265Enhanced:
|
||||
stats.h265_enhanced_video_messages++;
|
||||
break;
|
||||
case VideoSignal::H265Domestic:
|
||||
stats.h265_domestic_video_messages++;
|
||||
break;
|
||||
case VideoSignal::Unknown:
|
||||
default:
|
||||
stats.unknown_video_messages++;
|
||||
@@ -1226,9 +1210,6 @@ void update_mode_stats(
|
||||
if (expected == ExpectMode::H265Enhanced && actual != VideoSignal::H265Enhanced) {
|
||||
mismatch = true;
|
||||
}
|
||||
if (expected == ExpectMode::H265Domestic && actual != VideoSignal::H265Domestic) {
|
||||
mismatch = true;
|
||||
}
|
||||
|
||||
if (!mismatch) {
|
||||
return;
|
||||
@@ -1252,8 +1233,6 @@ std::uint32_t matching_count(const Stats &stats, ExpectMode mode) {
|
||||
return stats.h264_video_messages;
|
||||
case ExpectMode::H265Enhanced:
|
||||
return stats.h265_enhanced_video_messages;
|
||||
case ExpectMode::H265Domestic:
|
||||
return stats.h265_domestic_video_messages;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -1663,31 +1642,6 @@ send_client_video_mode_packets(int fd, std::uint32_t chunk_size, std::uint32_t s
|
||||
};
|
||||
break;
|
||||
}
|
||||
case ExpectMode::H265Domestic: {
|
||||
config_payload = {
|
||||
0x1c,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x60,
|
||||
};
|
||||
frame_payload = {
|
||||
0x2c,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x26,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return std::unexpected("unsupported self-test mode");
|
||||
}
|
||||
@@ -1778,10 +1732,9 @@ void print_summary(const Config &config, const Stats &stats) {
|
||||
stats.total_video_messages,
|
||||
stats.total_data_messages,
|
||||
stats.set_chunk_size_messages);
|
||||
spdlog::info("Video signaling counts: h264={}, h265-enhanced={}, h265-domestic={}, unknown={}",
|
||||
spdlog::info("Video signaling counts: h264={}, h265-enhanced={}, unknown={}",
|
||||
stats.h264_video_messages,
|
||||
stats.h265_enhanced_video_messages,
|
||||
stats.h265_domestic_video_messages,
|
||||
stats.unknown_video_messages);
|
||||
spdlog::info("Matching count for expected mode: {} (threshold={})",
|
||||
matching_count(stats, config.expect_mode),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user