diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de95419 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +jobs: + build-and-test: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: GCC release + cxx: g++ + build_type: Release + sanitize: OFF + - name: Clang release + cxx: clang++ + build_type: Release + sanitize: OFF + - name: GCC ASan+UBSan + cxx: g++ + build_type: Debug + sanitize: ON + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build pkg-config g++ clang libgtest-dev ffmpeg \ + libavformat-dev libavcodec-dev libavfilter-dev libavdevice-dev \ + libavutil-dev libswscale-dev libswresample-dev + + - name: Configure + run: > + cmake -S . -B build -G Ninja + -DCMAKE_CXX_COMPILER=${{ matrix.cxx }} + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + -DNDISTREAMER_SANITIZE=${{ matrix.sanitize }} + -DNDISTREAMER_WERROR=ON + + - name: Build + run: cmake --build build + + - name: Test + run: ctest --test-dir build --output-on-failure --timeout 120 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6b3ba74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +## 2.0.0 + +A ground-up rewrite. The goal is unchanged (stream media as an NDI source from Linux), but the +architecture, timing model and feature set are all new. + +### Highlights + +- **One pipeline for every decoder.** The three duplicated applications (software, VAAPI, CUDA) are + replaced by a single threaded pipeline: demux → decode → process → present. Bounded queues connect + the stages and a shared presentation clock drives the output. +- **Accurate A/V timing.** Frames are sent at their presentation timestamps, and audio is the master + clock while it plays. NDI timecodes are derived from media timestamps, so receivers that + synchronize on timecode get exact alignment. Variable frame rates, late-starting streams, long + pauses and inputs without timestamps are handled. +- **Graceful overload.** When the machine can't keep up, video sheds work in stages while audio + keeps playing. In order: drop late frames, skip processing of late frames, then decode keyframes + only. Live sources tolerate clock drift and mid-GOP joins. +- **Generic hardware decoding.** `-t auto|vaapi|cuda|vdpau|...` accepts any FFmpeg device type. + When the device can't handle a stream, that stream falls back to software. GPU frames are + downloaded directly into NDI's buffer layout. +- **Lower CPU use.** Formats NDI accepts natively are passed through instead of always being + converted to UYVY as 1.x did. Video is submitted with NDI's asynchronous API, and software + decoding now uses multiple threads. For 1080p60 H.264, passthrough cut sender CPU from 66 % of a + core (forcing UYVY) to 36 % with software decoding and 22 % with VAAPI. +- **No NDI SDK needed to build.** The NDI runtime is loaded at start-up from the usual locations, + `NDI_RUNTIME_DIR_V6`, or `--ndi-lib`. The previous `/opt/ndi` SDK location is still searched. + +### New features + +- Seamless looping (`--loop`) with a continuous timeline. MP3 encoder delay and padding are + accounted for. +- Any FFmpeg input: network streams (RTSP, SRT, HLS, ...), capture devices (`-f v4l2`, `x11grab`, + `pulse`, ...) and protocol options (`-o key=value`). +- FFmpeg video filters (`--vf`), including GPU filters on hardware frames. +- Output pixel format choice (`-p auto|uyvy|nv12|i420|bgra|rgba`). +- Multichannel audio is preserved and sent as 32-bit float. Optional `--audio-rate` and + `--audio-channels`. +- Stream selection (`--video-stream`, `--audio-stream`, `--no-video`, `--no-audio`), NDI groups + (`-g`), and audio-only or video-only inputs. +- Interlaced content is flagged correctly for NDI. +- Clean shutdown on Ctrl+C or SIGTERM, levelled logging (`-v`/`-q`), `--stats`, `--version`, and + meaningful exit codes. + +### Fixes compared to 1.x + +- Files without an audio stream no longer crash (the stream-count check was wrong and the code + dereferenced a missing audio stream). +- The NDI frame rate is no longer taken from `codecpar->framerate`, which is often 0/0. It now comes + from the container's real frame rate. +- Decoders are drained at the end of the input, so the last frames are no longer lost. +- Audio is no longer forced to 16-bit stereo, so no precision or channels are lost. +- Corrupt packets are skipped instead of stopping the stream. +- The CPU no longer busy-waits on queues, and a per-frame NV12 allocation and copy is gone. +- Builds with current FFmpeg: `avcodec_close` was removed upstream. + +### Compatibility notes + +- The command line is backward compatible: `-i`, `-s` and `-t software|vaapi|cuda` still work. +- Debug builds no longer produce a separately named `ndistreamer_debug` binary. +- Requires FFmpeg 6.0 or newer and a C++20 compiler (GCC 13+, Clang 17+). + +### Development + +- 85 automated tests, including whole-pipeline tests against a fake `libndi.so`. These check + pacing, A/V alignment, loop continuity and frame lifetime. Tests also verify the NDI ABI against + the official SDK headers and stream end to end to a real NDI receiver when the SDK is available. +- CI on Ubuntu 24.04 (FFmpeg 6.1) with GCC, Clang, and ASan/UBSan. diff --git a/CMakeLists.txt b/CMakeLists.txt index c61e461..1697e51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,58 +1,102 @@ +cmake_minimum_required(VERSION 3.20) -cmake_minimum_required(VERSION 3.14) -project(NDIStreamer) +project(ndistreamer + VERSION 2.0.0 + DESCRIPTION "Stream any media file, network stream or capture device as an NDI source" + HOMEPAGE_URL "https://github.com/lowlevelmetal/ndi-streamer" + LANGUAGES CXX) -# C++ Standard -set(CMAKE_CXX_STANDARD 20) # Or 20 for C++20 -set(CMAKE_CXX_STANDARD_REQUIRED True) # Ensure the compiler respects the version -set(CMAKE_CXX_EXTENSIONS OFF) # Disable compiler-specific extensions like gnu++17 +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) -set(CMAKE_EXPORT_COMPILE_COMMANDS "ON") - -message(STATUS "CMAKE_MODULE_PATH: ${CMAKE_MODULE_PATH}") - -# Set debug flags -set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -g -D_DEBUG") - -# Find NDI SDK -find_package(NDISDK REQUIRED) - -# Find FFMPEG -find_package(FFMPEG REQUIRED) - -# Include NDI SDK headers -include_directories(${NDI_INCLUDE_DIR} ${FFMPEG_INCLUDE_DIRS}) - -# Define sources -set(SOURCES - src/averror.cpp - src/demuxer.cpp - src/decoder.cpp - src/audioresampler.cpp - src/ndi.cpp - src/frametimer.cpp - src/frame.cpp - src/asyncndisource.cpp - src/softwareapp.cpp - src/vaapiapp.cpp - src/cudaapp.cpp - src/simplefilter.cpp - src/ndistreamer.cpp - src/cudadecoder.cpp - src/vaapidecoder.cpp) - -# Set executable name -if(CMAKE_BUILD_TYPE STREQUAL "Debug") - add_executable(ndistreamer_debug ${SOURCES}) - target_link_libraries(ndistreamer_debug ${NDI_LIB} ${FFMPEG_LIBRARIES}) -else() - add_executable(ndistreamer ${SOURCES}) - target_link_libraries(ndistreamer ${NDI_LIB} ${FFMPEG_LIBRARIES}) + +option(NDISTREAMER_BUILD_TESTS "Build the test suite (requires GoogleTest)" ${PROJECT_IS_TOP_LEVEL}) +option(NDISTREAMER_SANITIZE "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +option(NDISTREAMER_WERROR "Treat compiler warnings as errors" OFF) +set(NDI_SDK_DIR "" CACHE PATH + "Optional NDI SDK root. Not needed to build; enables ABI verification and end-to-end tests") + +# Dependencies --------------------------------------------------------------- + +find_package(PkgConfig REQUIRED) +# FFmpeg 6.0 or newer. +pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET + libavformat>=60 + libavcodec>=60 + libavfilter>=9 + libavdevice>=60 + libavutil>=58 + libswscale>=7 + libswresample>=4) +find_package(Threads REQUIRED) + +# Newer FFmpeg lets filter sources carry color properties. +include(CheckCXXSourceCompiles) +set(CMAKE_REQUIRED_INCLUDES ${FFMPEG_INCLUDE_DIRS}) +check_cxx_source_compiles([[ + #include + extern "C" { + #include + } + int main() { AVBufferSrcParameters p{}; p.color_space = AVCOL_SPC_BT709; p.color_range = AVCOL_RANGE_MPEG; return 0; } +]] NDISTREAMER_HAVE_BUFFERSRC_COLOR) +unset(CMAKE_REQUIRED_INCLUDES) + +# Compiler settings ---------------------------------------------------------- + +add_library(ndistreamer_warnings INTERFACE) +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(ndistreamer_warnings INTERFACE -Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor + $<$:-Werror>) endif() -# Enable testing -enable_testing() +if(NDISTREAMER_SANITIZE) + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined) + add_link_options(-fsanitize=address,undefined) +endif() + +# Targets -------------------------------------------------------------------- + +configure_file(src/version.hpp.in ${CMAKE_CURRENT_BINARY_DIR}/generated/version.hpp @ONLY) + +add_library(ndistreamer_core STATIC + src/av/audio_processor.cpp + src/av/decoder.cpp + src/av/ffmpeg.cpp + src/av/input.cpp + src/av/video_processor.cpp + src/frames.cpp + src/ndi/runtime.cpp + src/ndi/sender.cpp + src/options.cpp + src/streamer.cpp + src/util/clock.cpp + src/util/log.cpp) +target_include_directories(ndistreamer_core PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_BINARY_DIR}/generated) +target_link_libraries(ndistreamer_core + PUBLIC PkgConfig::FFMPEG Threads::Threads ${CMAKE_DL_LIBS} + PRIVATE ndistreamer_warnings) +target_compile_definitions(ndistreamer_core PRIVATE + $<$:NDISTREAMER_HAVE_BUFFERSRC_COLOR>) -# Add tests directory -add_subdirectory(tests) +add_executable(ndistreamer src/main.cpp) +target_link_libraries(ndistreamer PRIVATE ndistreamer_core ndistreamer_warnings) + +include(GNUInstallDirs) +install(TARGETS ndistreamer RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + +# Tests ---------------------------------------------------------------------- + +if(NDISTREAMER_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/README.md b/README.md index be20b42..5e3cfbf 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,218 @@ # ndistreamer -Stream the contents of an AV file over NDI -## Mission +Stream any media file, network stream or capture device as an NDI® source on Linux. -Develop a lightweight tool for streaming NDI from a linux environment. +``` +ndistreamer -i movie.mp4 -s "Lobby Screen" --loop +``` -## Building +ndistreamer decodes its input with FFmpeg and publishes it on the network as an NDI source, +sending audio and video in real time with frame-accurate timing. It is a small command line tool +built for servers, signage players and test rigs, with no GUI and no X server needed. -This application has only been built and tested using gentoo. -Porting to other distros would be trivial +## Features -### Dependencies +- **Anything FFmpeg can read.** Files (MP4, MKV, MOV, TS, ...), network streams (RTSP, SRT, HLS, + RTMP, HTTP), and capture devices (V4L2 webcams and capture cards, X11 screen capture, ALSA and + PulseAudio). +- **Accurate timing.** Every frame is sent at its presentation time from a shared clock. Audio and + video stay in sync, and NDI timecodes are derived from media timestamps. Variable frame rate + content, audio that starts late, and files without timestamps are handled. +- **Seamless looping** with `--loop`. Timestamps keep counting across the loop point, so receivers + never see a jump. +- **Hardware decoding** with `-t auto|vaapi|cuda|vdpau|...`. If the device can't decode a stream, + that stream falls back to software decoding. +- **Low overhead.** Formats NDI accepts natively are passed through untouched, and GPU frames are + downloaded straight into NDI's buffer layout. Video is sent asynchronously, so NDI compresses one + frame while the next one is being decoded. +- **Full audio.** All channels are kept (5.1, 7.1, ...) and sent as 32-bit float, with optional + resampling and remixing. +- **FFmpeg filters** with `--vf`: deinterlace, scale, crop, overlay, and more. +- **No NDI SDK needed to build.** The NDI runtime is loaded when the program starts. +- **Well behaved.** Ctrl+C or SIGTERM stops cleanly, errors are reported clearly, exit codes are + meaningful, and `--stats` gives periodic statistics. -Your basic set of requirements are as follows: +## Requirements -- C/C++ Compiler(GCC/G++ by default) -- cmake (With make preferred) -- ffmpeg dev libraries -- NDI SDK (https://ndi.video/download-ndi-sdk/) - . My CMake script expects you to install this in /opt/ndi -- avahi client development libraries (NDI uses mdns) +- Linux on x86-64 or ARM +- A C++20 compiler (GCC 13+ or Clang 17+) and CMake 3.20+ +- FFmpeg 6.0 or newer, with development headers: libavformat, libavcodec, libavfilter, libavdevice, + libavutil, libswscale and libswresample +- **At run time:** the NDI runtime (`libndi.so`), which comes with the + [NDI SDK or NDI Tools](https://ndi.video/download-ndi-sdk/) +- For NDI discovery via mDNS, the Avahi daemon must be running (`systemctl enable --now avahi-daemon`) -You might need to manually acquire more dependencies depending -on your system configuration. Refer to the build system output. +Installing the build dependencies: -### Invoking the build system +```sh +# Debian / Ubuntu (24.04 or newer) +sudo apt install build-essential cmake pkg-config libavformat-dev libavcodec-dev libavfilter-dev \ + libavdevice-dev libavutil-dev libswscale-dev libswresample-dev -Release Build -``` -mkdir build && cd build -cmake .. && make -``` -Debug Build +# Arch +sudo pacman -S base-devel cmake ffmpeg + +# Fedora (with RPM Fusion for the full FFmpeg) +sudo dnf install gcc-c++ cmake ffmpeg-devel ``` -mkdir build && cd build -cmake -DCMAKE_BUILD_TYPE=Debug .. && make + +## Building + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build +sudo cmake --install build # optional: installs to /usr/local/bin/ndistreamer ``` -This will generate the executable in your build directory +### Installing the NDI runtime + +Download the NDI SDK for Linux and run its installer. It unpacks into `NDI SDK for Linux/`. Then do +one of the following: + +- copy `lib/x86_64-linux-gnu/libndi.so*` to `/usr/local/lib` and run `sudo ldconfig`; +- point ndistreamer at it with `--ndi-lib "/path/to/NDI SDK for Linux/lib/x86_64-linux-gnu"`; +- set `NDI_RUNTIME_DIR_V6` to that directory. + +ndistreamer searches these places, in order: `--ndi-lib`, `NDI_RUNTIME_DIR_V6`, +`NDI_RUNTIME_DIR_V5`, the system library path, `/usr/local/lib`, and `/opt/ndi` (including the +`/opt/ndi/NDI SDK for Linux` location used by earlier versions of this project). ## Usage ``` -./ndistreamer - -i /path/to/your/media.mp4 - -s "NDI Source Name" - -t [software, cuda, vaapi] +ndistreamer -i INPUT [options] ``` -## Running tests -These tests are minimal and incomplete. It is on my todo list to create a better testing system. -Build the version of the software you want, as shown above, then navigate into your build directory +| Option | Description | +|---|---| +| `-i, --input INPUT` | File, URL or device to stream (required) | +| `-s, --name NAME` | NDI source name (default: `NDI Source`). It appears on the network as `HOSTNAME (NAME)` | +| `-l, --loop` | Restart the input when it ends, without a gap | +| `-t, --hwaccel TYPE` | Hardware decoding: `none` (default), `auto`, `vaapi`, `cuda`, `vdpau`, or any other FFmpeg device type | +| `--hwaccel-device DEV` | Device to use, e.g. `/dev/dri/renderD129` or a CUDA index | +| `-p, --pixel-format FMT` | NDI video layout: `auto` (default), `uyvy`, `nv12`, `i420`, `bgra`, `rgba` | +| `--vf FILTERGRAPH` | FFmpeg video filters, e.g. `yadif` or `scale=1280:-2` | +| `--audio-rate HZ` | Resample audio (default: keep the source rate) | +| `--audio-channels N` | Remix audio to N channels (default: keep all channels) | +| `-f, --format NAME` | Force the input format, e.g. `v4l2`, `x11grab`, `pulse` | +| `-o, --input-option K=V` | Demuxer or protocol option; can be repeated (e.g. `rtsp_transport=tcp`) | +| `--video-stream N`, `--audio-stream N` | Choose which streams to use (default: the best of each) | +| `--no-video`, `--no-audio` | Send only audio or only video | +| `-g, --groups LIST` | Comma separated NDI groups | +| `--ndi-lib PATH` | `libndi.so` to load, or the directory containing it | +| `--threads N` | Decoder threads (default: automatic) | +| `--no-pacing` | Send as fast as possible instead of in real time (for benchmarking) | +| `--stats[=SECONDS]` | Log statistics periodically (default interval: 5 s) | +| `-v`, `-q` | More or less logging; can be repeated | +| `-h`, `-V` | Show help or version | + +Exit status: 0 when the input ends or after Ctrl+C/SIGTERM, 1 on errors, 2 on invalid usage. + +### Examples + +```sh +# Loop a file forever with GPU decoding +ndistreamer -i promo.mp4 -s "Promo" --loop -t auto + +# An IP camera +ndistreamer -i rtsp://10.0.0.20/stream1 -o rtsp_transport=tcp -s "Camera 1" + +# A webcam or HDMI capture card, with its audio +ndistreamer -f v4l2 -i /dev/video0 -s Webcam +ndistreamer -f pulse -i default --no-video -s "Desk Mic" + +# The desktop +ndistreamer -f x11grab -o framerate=30 -i :0.0 -s Desktop + +# Deinterlace and downscale on the way out +ndistreamer -i broadcast.ts --vf "bwdif,scale=1280:-2" -s "Feed (720p)" + +# Stereo 48 kHz for receivers that expect it +ndistreamer -i concert.mkv --audio-channels 2 --audio-rate 48000 ``` -cd build + +## How it works + ``` -Then run the automated tests + ┌─> video packets ─> decode + filter + convert ─> video frames ─> present ─┐ + input ─> demux ├─> NDI + └─> audio packets ─> decode + resample ─────────> audio frames ─> present ─┘ ``` -ctest + +Each stage runs on its own thread. The stages are connected by bounded queues, so a slow stage +holds back the ones before it instead of using up memory. + +- **Timing.** The presenters release frames at their timestamps using a shared clock. NDI's own + clocking is turned off. Audio is the master clock while it is being sent; otherwise video takes + over. If the master falls more than a second behind, the clock re-synchronizes. For live inputs it + also re-synchronizes when the master runs far ahead, since timestamps there can jump. In files, long + gaps are real pauses and are honoured. +- **Overload.** If the machine can't keep up, quality degrades in this order: + 1. a late video frame is dropped when a newer one is already waiting; + 2. decoded frames that are already late skip filtering and conversion; + 3. if decoding itself is too slow, the decoder skips ahead to keyframes. + + Audio is never starved. A live source that runs slow is never thinned out, because dropping its + frames would not help. +- **Video.** Frames come from the decoder (CPU or GPU) and go through the optional filter graph. GPU + frames are downloaded before the filters run, unless the filters are hardware filters themselves. + Otherwise they are downloaded afterwards, directly into the NDI layout when the formats match. Then the frame is + either passed through, repacked into NDI's contiguous plane layout, or converted with swscale. + NDI reads single-plane frames, such as UYVY or BGRA from the decoder, in place. +- **Pixel formats.** With `auto`, formats NDI accepts (UYVY, NV12, I420, BGRA/BGRX, RGBA/RGBX) pass + through and everything else becomes UYVY. Measured with 1080p60 H.264 and the NDI 6.3 runtime, + sender CPU use was: + + | Path | CPU (% of one core) | + |---|---| + | software decode → UYVY | 66 % | + | VAAPI → UYVY | 54 % | + | software decode → I420 (`auto`) | 36 % | + | VAAPI → NV12 (`auto`) | 22 % | + + Forcing `-p uyvy` costs CPU and adds no quality for 4:2:0 sources. +- **Audio.** Audio is sent as planar 32-bit float, which is NDI's native format. Most decoders + already produce it, so it is copied once and needs no conversion. + +## Development + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build +ctest --test-dir build --output-on-failure ``` -## Common Problems -### Can't find NDI header files or binaries -Make sure you have installed the NDI SDK in your /opt/ndi directory -### Can't see my NDI source on the network -Make sure you have the avahi service daemon running on your host machine -### VAAPI and/or CUDA is not working -Make sure you build FFmpeg with the proper vaapi and/or cuda support +The test suite does not need the NDI SDK. It streams generated clips (made with the `ffmpeg` CLI) +through the whole pipeline into a fake `libndi.so` that records every frame. It then checks frame +counts, pacing, audio/video alignment, loop continuity, pixel layouts and the async frame lifetime. +Run it under AddressSanitizer and UndefinedBehaviorSanitizer with `-DNDISTREAMER_SANITIZE=ON`. + +If you have the NDI SDK, configure with `-DNDI_SDK_DIR="/path/to/NDI SDK for Linux"` to add two +more tests: + +- `ndi_abi` checks the project's NDI declarations against the official headers. +- `ndi_end_to_end` streams to a real NDI receiver. It runs a private discovery server, so Avahi is + not needed. + +## Troubleshooting + +- **"the NDI runtime (libndi.so) was not found"**: install the runtime (see above) or pass + `--ndi-lib`. +- **The source doesn't show up on other machines**: make sure `avahi-daemon` is running and that + your firewall allows mDNS (UDP 5353) and NDI's ports (TCP 5959 and up; the NDI SDK documentation + lists them all). Alternatively, use an NDI Discovery Server. +- **Hardware decoding isn't used**: run with `-v` to see why. Check `vainfo` for VAAPI, and make + sure your FFmpeg was built with the hwaccel you need (`ffmpeg -hwaccels`). +- **Frames are dropped or skipped** (`--stats` shows them): the machine can't decode, filter and send + in real time. Try `-t auto`, or downscale with `--vf scale=...`. Filters work with hardware decoding + too: frames are downloaded from the GPU automatically unless the filtergraph uses hardware filters + such as `scale_vaapi`. +- **A live stream takes a few seconds to start**: FFmpeg analyses the start of the stream first. Use + `-o analyzeduration=1000000` to shorten that. Joining mid-GOP waits for the next keyframe. + +## License + +MIT, see [LICENSE](LICENSE). + +NDI® is a registered trademark of Vizrt NDI AB. This project is not affiliated with or endorsed +by Vizrt NDI AB. The NDI runtime is licensed separately under the NDI SDK license. diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index 6fd6f0c..0000000 --- a/TODO.txt +++ /dev/null @@ -1,3 +0,0 @@ -TODO List - - Write a fragment shader that converts nv12 to uyvy. The GPU is always ahead of the NDI functions, so lets make it easier on NDI - - Cleanup the app classes \ No newline at end of file diff --git a/cmake/FindFFMPEG.cmake b/cmake/FindFFMPEG.cmake deleted file mode 100644 index e1fcd1f..0000000 --- a/cmake/FindFFMPEG.cmake +++ /dev/null @@ -1,21 +0,0 @@ -# Find FFMPEG development libraries - -find_package(PkgConfig) -pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET - libavcodec - libavformat - libavutil - libswscale - libswresample - libavdevice - libavfilter -) - -if (FFMPEG_FOUND) - message(STATUS "FFmpeg libraries found.") - message(STATUS "FFmpeg include directories: ${FFMPEG_INCLUDE_DIRS}") - message(STATUS "FFmpeg libraries: ${FFMPEG_LIBRARIES}") -else() - message(FATAL_ERROR "FFmpeg libraries not found") -endif() - diff --git a/cmake/FindNDISDK.cmake b/cmake/FindNDISDK.cmake index 2ae969f..044e67f 100644 --- a/cmake/FindNDISDK.cmake +++ b/cmake/FindNDISDK.cmake @@ -1,21 +1,54 @@ -# Find NDI SDK -# This script find the NDI SDK includes and libraries +# FindNDISDK +# ---------- +# Locates the NDI SDK for Linux. ndistreamer loads the NDI runtime at run time, so the SDK is only +# used by the test suite (ABI verification and end-to-end tests). +# +# Hints: +# NDI_SDK_DIR root of the extracted SDK ("NDI SDK for Linux") +# +# Result variables: +# NDISDK_FOUND, NDISDK_INCLUDE_DIR, NDISDK_LIBRARY, NDISDK_BIN_DIR +# +# Imported target: +# NDISDK::NDISDK -if(NOT DEFINED NDI_SDK_DIR) - set(NDI_SDK_DIR "/opt/ndi/NDI SDK for Linux" CACHE PATH "Path to NDI SDK") +if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64") + set(_ndi_arch x86_64-linux-gnu) +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(_ndi_arch aarch64-rpi4-linux-gnueabi) +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "i.86") + set(_ndi_arch i686-linux-gnu) +else() + set(_ndi_arch arm-rpi4-linux-gnueabihf) endif() -# Find NDI SDK includes -find_path(NDI_INCLUDE_DIR NAMES Processing.NDI.Lib.h - HINTS ${NDI_SDK_DIR}/include - REQUIRED -) +set(_ndi_roots ${NDI_SDK_DIR} $ENV{NDI_SDK_DIR} "/opt/ndi/NDI SDK for Linux" "/opt/ndi") -# FIND NDI SDK Libraries -find_library(NDI_LIB NAMES libndi.so - HINTS ${NDI_SDK_DIR}/lib/x86_64-linux-gnu/ - REQUIRED -) +find_path(NDISDK_INCLUDE_DIR + NAMES Processing.NDI.Lib.h + HINTS ${_ndi_roots} + PATH_SUFFIXES include) +find_library(NDISDK_LIBRARY + NAMES ndi + HINTS ${_ndi_roots} + PATH_SUFFIXES lib/${_ndi_arch} lib) +find_path(NDISDK_BIN_DIR + NAMES ndi-discovery-server + HINTS ${_ndi_roots} + PATH_SUFFIXES bin/${_ndi_arch} bin) +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(NDISDK REQUIRED_VARS NDISDK_INCLUDE_DIR NDISDK_LIBRARY) + +if(NDISDK_FOUND AND NOT TARGET NDISDK::NDISDK) + add_library(NDISDK::NDISDK UNKNOWN IMPORTED) + set_target_properties(NDISDK::NDISDK PROPERTIES + IMPORTED_LOCATION "${NDISDK_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${NDISDK_INCLUDE_DIR}") +endif() + +mark_as_advanced(NDISDK_INCLUDE_DIR NDISDK_LIBRARY NDISDK_BIN_DIR) +unset(_ndi_arch) +unset(_ndi_roots) diff --git a/src/app.hpp b/src/app.hpp deleted file mode 100644 index 4dc2401..0000000 --- a/src/app.hpp +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @file app.hpp - * @brief This file includes the main application class. - * @date 2024-09-16 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#pragma once - -class App { -public: - virtual AV::Utils::AvException Run() = 0; -}; \ No newline at end of file diff --git a/src/asyncndisource.cpp b/src/asyncndisource.cpp deleted file mode 100644 index 6be18d5..0000000 --- a/src/asyncndisource.cpp +++ /dev/null @@ -1,229 +0,0 @@ -/** - * @file asyncndisource.cpp - * @brief This file includes utilities for working with NDI sources. - * @date 2024-09-16 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#include "asyncndisource.hpp" -#include "macro.hpp" -#include "frame.hpp" - -#include -#include - -extern "C" { -#include -} - -namespace AV::Utils { - -AsyncNDISourceResult AsyncNDISource::Create(const std::string &source_name, const AVRational &frame_rate) { - FUNCTION_CALL_DEBUG(); - AvException err; - - try { - return {std::shared_ptr(new AsyncNDISource(source_name, frame_rate)), AvError::NOERROR}; - } catch(const AvException e) { - err = e; - DEBUG("Error creating NDI source: %s", e.what()); - } - - return {nullptr, err}; -} - -AvException AsyncNDISource::SendFrame(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - // Clone frame - AVFrame *frame_copy = av_frame_clone(frame); - if(frame_copy == nullptr) { - return AvError::FRAMEALLOC; - } - - // Sleep until ready to send frame - _frame_queue_mutex.lock(); - auto size = _frame_queue.size(); - _frame_queue_mutex.unlock(); - while(size >= FRAME_QUEUE_SIZE) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - _frame_queue_mutex.lock(); - size = _frame_queue.size(); - _frame_queue_mutex.unlock(); - } - - _frame_queue_mutex.lock(); - DEBUG("Frame Queue Size: %ld", _frame_queue.size()); - _frame_queue.push_back(frame_copy); - _frame_queue_mutex.unlock(); - - return AvError::NOERROR; -} - -AsyncNDISource::AsyncNDISource(const std::string &source_name, const AVRational &frame_rate) : _source_name(source_name), _frame_rate(frame_rate) { - FUNCTION_CALL_DEBUG(); - - AvError err = _Initialize(); - if(err != AvError::NOERROR) { - throw AvException(err); - } -} - -AsyncNDISource::~AsyncNDISource() { - FUNCTION_CALL_DEBUG(); - - _running = false; - if(_frame_sender_thread.joinable()) { - _frame_sender_thread.join(); - } - - if(_ndi_send_instance != nullptr) { - NDIlib_send_destroy(_ndi_send_instance); - } -} - -AvError AsyncNDISource::_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Create NDI send instance - NDIlib_send_create_t send_create_desc; - send_create_desc.p_ndi_name = _source_name.c_str(); - send_create_desc.clock_video = true; - _ndi_send_instance = NDIlib_send_create(&send_create_desc); - - if(_ndi_send_instance == nullptr) { - return AvError::NDISENDINSTANCE; - } - - _frame_sender_thread = std::thread(&AsyncNDISource::_Thread_FrameSender, this); - - return AvError::NOERROR; -} - -void AsyncNDISource::_Thread_FrameSender() { - FUNCTION_CALL_DEBUG(); - - while(_running || [this]{ std::unique_lock lock(_frame_queue_mutex); return !_frame_queue.empty(); }()) { - std::unique_lock lock(_frame_queue_mutex); - if(!_frame_queue.empty()) { - AVFrame *frame = _frame_queue.front(); - _frame_queue.pop_front(); - lock.unlock(); - - if(frame->width != 0 && frame->height != 0) { - _SendVideoFrame(frame); - } else { - _SendAudioFrame(frame); - } - - av_frame_free(&frame); - } else { - lock.unlock(); - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - } - -} - -AvError AsyncNDISource::_SendVideoFrame(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - -#ifdef _DEBUG - // profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - - bool manual_free = false; - - // Build NDI packet from frame - NDIlib_video_frame_v2_t video_frame; - - DEBUG("Frame metadata" - "\n\tWidth: %d" - "\n\tHeight: %d" - "\n\tFormat: %d" - "\n\tLinesize 1: %d" - "\n\tLinesize 2: %d", - frame->width, frame->height, frame->format, frame->linesize[0], frame->linesize[1]); - - switch(frame->format) { - case AV_PIX_FMT_UYVY422: - DEBUG("Sending UYVY frame"); - video_frame.FourCC = NDIlib_FourCC_type_UYVY; - video_frame.p_data = frame->data[0]; - video_frame.line_stride_in_bytes = frame->linesize[0]; - break; - case AV_PIX_FMT_RGB24: - DEBUG("Sending RGB24 frame"); - video_frame.FourCC = NDIlib_FourCC_type_RGBA; - video_frame.p_data = frame->data[0]; - video_frame.line_stride_in_bytes = frame->linesize[0]; - break; - case AV_PIX_FMT_NV12: - DEBUG("Sending NV12 frame"); - video_frame.FourCC = NDIlib_FourCC_type_NV12; - video_frame.line_stride_in_bytes = frame->linesize[0]; - - DEBUG("data[0]: %p", frame->data[0]); - DEBUG("data[1]: %p", frame->data[1]); - DEBUG("data[1] - data[0]: %ld | Frame 0 Size: %d", frame->data[1] - frame->data[0], frame->linesize[0] * frame->height); - - video_frame.p_data = CombinePlanesNV12(frame, 2); - manual_free = true; - - break; - default: - return AvError::NDIINVALIDPIXFMT; - } - - video_frame.xres = frame->width; - video_frame.yres = frame->height; - video_frame.frame_rate_N = _frame_rate.num; - video_frame.frame_rate_D = _frame_rate.den; - video_frame.timecode = NDIlib_send_timecode_synthesize; - video_frame.frame_format_type = NDIlib_frame_format_type_progressive; - - // Send the frame - NDIlib_send_send_video_v2(_ndi_send_instance, &video_frame); - - if(manual_free) { - delete[] video_frame.p_data; - } - -#ifdef _DEBUG - // profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("NDI Send Video Frame time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return AvError::NOERROR; -} - -/** - * Only send audio frames that are interleaved 16-bit signed PCM. - */ -AvError AsyncNDISource::_SendAudioFrame(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - if(frame->format != AV_SAMPLE_FMT_S16) { - return AvError::INVALIDSMPLFMT; - } - - // Build NDI packet from frame - NDIlib_audio_frame_interleaved_16s_t audio_frame; - - audio_frame.sample_rate = frame->sample_rate; - audio_frame.no_channels = frame->ch_layout.nb_channels; - audio_frame.no_samples = frame->nb_samples; - audio_frame.timecode = NDIlib_send_timecode_synthesize; - audio_frame.p_data = (int16_t *)frame->data[0]; - - // Send the frame - NDIlib_util_send_send_audio_interleaved_16s(_ndi_send_instance, &audio_frame); - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/asyncndisource.hpp b/src/asyncndisource.hpp deleted file mode 100644 index 95081df..0000000 --- a/src/asyncndisource.hpp +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @file asyncndisource.hpp - * @brief This file includes utilities for working with NDI sources asynchronously. - * @date 2024-09-20 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#pragma once - -// Local includes -#include "averror.hpp" -#include "ndi.hpp" - -// NDI SDK -#include - -// FFMPEG includes -extern "C" { -#include -} - -// Standard C++ includes -#include -#include -#include -#include -#include - -#define FRAME_QUEUE_SIZE 50 - -namespace AV::Utils { - -// Forward declarations and type definitions -class AsyncNDISource; -using AsyncNDISourceResult = std::pair, const AvException>; - -class AsyncNDISource : public NDI { -private: - AsyncNDISource(const std::string &source_name, const AVRational &frame_rate); - AvError _Initialize(); - AvError _SendVideoFrame(const AVFrame *frame); - AvError _SendAudioFrame(const AVFrame *frame); - void _Thread_FrameSender(); - -public: - ~AsyncNDISource(); - - // Factory - static AsyncNDISourceResult Create(const std::string &source_name, const AVRational &frame_rate); - - AvException SendFrame(const AVFrame *frame); - -private: - std::thread _frame_sender_thread; - std::string _source_name; - NDIlib_send_instance_t _ndi_send_instance = nullptr; - AVRational _frame_rate; - std::atomic _running = true; - - std::deque _frame_queue; - std::mutex _frame_queue_mutex; - -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/audioresampler.cpp b/src/audioresampler.cpp deleted file mode 100644 index e07734d..0000000 --- a/src/audioresampler.cpp +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @file audioresampler.cpp - * @brief This file includes utilities for resampling audio. - * @date 2024-09-07 - * @author Matthew Todd Geiger - */ - -#include - -#include "audioresampler.hpp" -#include "macro.hpp" - -extern "C" { -#include -} - -namespace AV::Utils { - -/** - * @brief Resample the audio frame - * - * @param src_frame The frame to resample - */ -AudioResamplerOutput AudioResampler::Resample(AVFrame *src_frame) { -FUNCTION_CALL_DEBUG(); -#ifdef _DEBUG - // Profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - // Unlike with the pixel encoder, the number of samples(or resolution for the pixel encoder) is not consistent - // so we need to reset the frame each time to be safe - // - // On modern machines this is extermely fast, but maybe in the future we can optimize this - // for smaller machines - - // Reset frame each time - av_frame_unref(m_dst_frame); - - // Setup destination frame - m_dst_frame->ch_layout = m_config.dstchannellayout; - m_dst_frame->sample_rate = m_config.dstsamplerate; - m_dst_frame->format = m_config.dstsampleformat; - m_dst_frame->nb_samples = av_rescale_rnd(swr_get_delay(m_swr_context, m_config.srcsamplerate) + src_frame->nb_samples, m_config.dstsamplerate, m_config.srcsamplerate, AV_ROUND_UP); - m_dst_frame->pts = src_frame->pts; - - // Allocate the frame buffer - int ret = av_frame_get_buffer(m_dst_frame, 0); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::FRAMEALLOC)}; - } - - // Configure the context for the frames, this could also be eventually optimized - ret = swr_config_frame(m_swr_context, m_dst_frame, src_frame); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::SWRCONFIG)}; - } - - // Resample the frame - ret = swr_convert_frame(m_swr_context, m_dst_frame, src_frame); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::SWRCONVERT)}; - } - -#ifdef _DEBUG - // Profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("Resample time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return {m_dst_frame, AvException(AvError::NOERROR)}; -} - -/** - * @brief Create a new AudioResampler object - * - * @param config The configuration for the AudioResampler object - * @return AudioResamplerResult The AudioResampler object - */ -AudioResamplerResult AudioResampler::Create(const AudioResamplerConfig &config) { - FUNCTION_CALL_DEBUG(); - - AvException error; - - // Create a new audio resampler object, return nullopt if error - try { - return {std::unique_ptr(new AudioResampler(config)), error}; - } catch (AvError err) { - error = AvException(err); - } - - return {nullptr, error}; -} - -/** - * @brief Construct a new AudioResampler object - */ -AudioResampler::AudioResampler(const AudioResamplerConfig &config) : m_config(config) { - FUNCTION_CALL_DEBUG(); - - AvError err = m_Initialize(); - if (err != AvError::NOERROR) { - throw err; - } -} - -/** - * @brief Destroy the AudioResampler object - */ -AudioResampler::~AudioResampler() { - FUNCTION_CALL_DEBUG(); - - if (m_dst_frame) { - DEBUG("Freeing frame"); - av_frame_free(&m_dst_frame); - } - - if (m_swr_context) { - DEBUG("Freeing swr context"); - swr_free(&m_swr_context); - } -} - -/** - * @brief Initialize the audio resampler - * - * @return AvError - */ -AvError AudioResampler::m_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Alloc space for swr context - m_swr_context = swr_alloc(); - if (!m_swr_context) { - return AvError::SWRALLOCS; - } - - // Set options - av_opt_set_chlayout(m_swr_context, "in_channel_layout", &m_config.srcchannellayout, 0); - av_opt_set_chlayout(m_swr_context, "out_channel_layout", &m_config.dstchannellayout, 0); - - av_opt_set_int(m_swr_context, "in_sample_rate", m_config.srcsamplerate, 0); - av_opt_set_int(m_swr_context, "out_sample_rate", m_config.dstsamplerate, 0); - - av_opt_set_sample_fmt(m_swr_context, "in_sample_fmt", m_config.srcsampleformat, 0); - av_opt_set_sample_fmt(m_swr_context, "out_sample_fmt", m_config.dstsampleformat, 0); - - DEBUG("Source Config\n" - "Sample Rate: %d\n" - "Channels: %d\n" - "Sample Format: %s\n", - m_config.srcsamplerate, m_config.srcchannellayout.nb_channels, av_get_sample_fmt_name(m_config.srcsampleformat)); - - DEBUG("Destination Config\n" - "Sample Rate: %d\n" - "Channels: %d\n" - "Sample Format: %s\n", - m_config.dstsamplerate, m_config.dstchannellayout.nb_channels, av_get_sample_fmt_name(m_config.dstsampleformat)); - - // Initialize the context - int ret = swr_init(m_swr_context); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return AvError::SWRINIT; - } - - // Setup the destination frame - m_dst_frame = av_frame_alloc(); - if (!m_dst_frame) { - return AvError::FRAMEALLOC; - } - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/audioresampler.hpp b/src/audioresampler.hpp deleted file mode 100644 index 050cb87..0000000 --- a/src/audioresampler.hpp +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @file audioresampler.hpp - * @brief This file includes utilities for resampling audio. - * @date 2024-09-07 - * @author Matthew Todd Geiger - */ - -#pragma once - -// local dependencies -#include "averror.hpp" - -// 3rd party depednencies -extern "C" { -#include -} - -// Standard c++ dependencies -#include - -namespace AV::Utils { - -// forward declarations and type definitions -class AudioResampler; -using AudioResamplerResult = std::pair, AvException>; -using AudioResamplerOutput = std::pair; - -/** - * @brief The AudioResamplerConfig struct contains the configuration for the AudioResampler object. - */ -typedef struct AudioResamplerConfig { - int srcsamplerate, dstsamplerate; - AVChannelLayout srcchannellayout, dstchannellayout; - AVSampleFormat srcsampleformat, dstsampleformat; -} AudioResamplerConfig, *pAudioResamplerConfig; - -/** - * @brief The AudioResampler class provides utilities for resampling audio. - */ -class AudioResampler { -private: - AudioResampler(const AudioResamplerConfig &config); - -public: - /** - * @brief Destroy the AudioResampler object - */ - ~AudioResampler(); - - // Factory methods - /** - * @brief Create a new AudioResampler object - * - * @param config The configuration for the AudioResampler object - * @return AudioResamplerResult The AudioResampler object - */ - static AudioResamplerResult Create(const AudioResamplerConfig &config); - - /** - * @brief Resample the audio frame - * - * @param src_frame The frame to resample - */ - AudioResamplerOutput Resample(AVFrame *src_frame); - -private: - AvError m_Initialize(); - - AudioResamplerConfig m_config; - SwrContext *m_swr_context = nullptr; - AVFrame *m_dst_frame = nullptr; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/av/audio_processor.cpp b/src/av/audio_processor.cpp new file mode 100644 index 0000000..9418806 --- /dev/null +++ b/src/av/audio_processor.cpp @@ -0,0 +1,196 @@ +/** + * @file audio_processor.cpp + * @brief Turns decoded audio frames into planar float blocks for NDI. + */ + +#include "av/audio_processor.hpp" +#include "util/log.hpp" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace ndistreamer::av { + +namespace { + +void CopyLayout(AVChannelLayout &dst, const AVChannelLayout &src) { + av_channel_layout_uninit(&dst); + if (src.order == AV_CHANNEL_ORDER_UNSPEC) { + // Resampling needs to know which channel is which; assume the default order. + av_channel_layout_default(&dst, src.nb_channels); + } else { + Check(av_channel_layout_copy(&dst, &src), "copying channel layout"); + } +} + +// When a block starts slightly before the previous one ends, the previous block's tail is trimmed. +// Larger backward jumps are timestamp discontinuities, which the presentation clock deals with. +constexpr int64_t kOverlapToleranceUs = 1000; +constexpr int64_t kMaxOverlapTrimUs = 100000; + +int64_t EndUs(const AudioFrame &frame) { + return frame.pts_us + av_rescale(frame.samples_per_channel, 1000000, frame.sample_rate); +} + +std::string DescribeLayout(const AVChannelLayout &layout) { + char text[128] = "unknown"; + av_channel_layout_describe(&layout, text, sizeof(text)); + return text; +} + +} // namespace + +AudioProcessor::AudioProcessor(AudioProcessorConfig config) : m_config(config) {} + +AudioProcessor::~AudioProcessor() { + av_channel_layout_uninit(&m_source_layout); + av_channel_layout_uninit(&m_in_layout); + av_channel_layout_uninit(&m_out_layout); +} + +bool AudioProcessor::NeedsReconfigure(const AVFrame *frame) const { + // Compare with the layout exactly as the decoder reported it: m_in_layout may have been completed. + return !m_configured || frame->sample_rate != m_in_rate || frame->format != m_in_format || + av_channel_layout_compare(&frame->ch_layout, &m_source_layout) != 0; +} + +void AudioProcessor::Configure(const AVFrame *frame) { + if (m_configured) { + log::Info("audio input changed to {} Hz, {} channels", frame->sample_rate, frame->ch_layout.nb_channels); + } + + m_in_rate = frame->sample_rate; + m_in_format = frame->format; + av_channel_layout_uninit(&m_source_layout); + Check(av_channel_layout_copy(&m_source_layout, &frame->ch_layout), "copying channel layout"); + CopyLayout(m_in_layout, frame->ch_layout); + + m_out_rate = m_config.sample_rate > 0 ? m_config.sample_rate : m_in_rate; + if (m_config.channels > 0) { + av_channel_layout_uninit(&m_out_layout); + av_channel_layout_default(&m_out_layout, m_config.channels); + } else { + CopyLayout(m_out_layout, m_in_layout); + } + + m_passthrough = frame->format == AV_SAMPLE_FMT_FLTP && m_out_rate == m_in_rate && + m_out_layout.nb_channels == m_in_layout.nb_channels; + + m_swr.reset(); + if (!m_passthrough) { + SwrContext *swr = nullptr; + Check(swr_alloc_set_opts2(&swr, &m_out_layout, AV_SAMPLE_FMT_FLTP, m_out_rate, &m_in_layout, + static_cast(m_in_format), m_in_rate, 0, nullptr), + "configuring audio resampler"); + m_swr.reset(swr); + Check(swr_init(swr), "initializing audio resampler"); + } + + if (!m_reported) { + m_reported = true; + std::string source; + if (!m_passthrough) { + source = std::format(" (converted from {}, {} Hz, {} ch)", + av_get_sample_fmt_name(static_cast(m_in_format)), m_in_rate, + m_in_layout.nb_channels); + } + log::Info("audio output: float {} Hz, {} ({} ch){}", m_out_rate, DescribeLayout(m_out_layout), + m_out_layout.nb_channels, source); + } + m_configured = true; +} + +bool AudioProcessor::Resample(const uint8_t *const *input, int input_samples, int64_t input_pts_us, + const Sink &sink) { + int capacity = swr_get_out_samples(m_swr.get(), input_samples); + if (capacity <= 0) return true; + + // Samples still inside the resampler come out first, so the output starts that much earlier. + int64_t delay_us = swr_get_delay(m_swr.get(), 1000000); + + AudioFrame out; + out.channels = m_out_layout.nb_channels; + out.sample_rate = m_out_rate; + out.channel_stride = capacity; + out.samples.resize(static_cast(out.channels) * capacity); + + std::vector planes(out.channels); + for (int c = 0; c < out.channels; c++) { + planes[c] = reinterpret_cast(out.samples.data() + static_cast(c) * capacity); + } + + int converted = Check(swr_convert(m_swr.get(), planes.data(), capacity, const_cast(input), + input_samples), + "converting audio"); + if (converted <= 0) return true; + + out.samples_per_channel = converted; + out.pts_us = input_pts_us - delay_us; + return Emit(std::move(out), sink); +} + +bool AudioProcessor::Emit(AudioFrame &&out, const Sink &sink) { + std::optional previous = std::exchange(m_held, std::move(out)); + if (!previous) return true; + + int64_t overlap_us = EndUs(*previous) - m_held->pts_us; + if (overlap_us > kOverlapToleranceUs && overlap_us < kMaxOverlapTrimUs) { + int64_t keep = av_rescale(m_held->pts_us - previous->pts_us, previous->sample_rate, 1000000); + previous->samples_per_channel = static_cast(std::clamp(keep, 0, previous->samples_per_channel)); + } + if (previous->samples_per_channel <= 0) return true; + return sink(std::move(*previous)); +} + +void AudioProcessor::Process(const AVFrame *frame, const Sink &sink) { + if (frame->nb_samples <= 0 || frame->sample_rate <= 0 || frame->ch_layout.nb_channels <= 0) return; + if (NeedsReconfigure(frame)) { + Flush(sink); // emit what the old resampler still holds + Configure(frame); + } + + int64_t ts = frame->best_effort_timestamp != AV_NOPTS_VALUE ? frame->best_effort_timestamp : frame->pts; + int64_t pts_us = ts != AV_NOPTS_VALUE ? ToMicros(ts, m_config.time_base) : m_next_in_us; + m_next_in_us = pts_us + av_rescale(frame->nb_samples, 1000000, frame->sample_rate); + + if (!m_passthrough) { + Resample(frame->extended_data, frame->nb_samples, pts_us, sink); + return; + } + + AudioFrame out; + out.channels = frame->ch_layout.nb_channels; + out.sample_rate = frame->sample_rate; + out.samples_per_channel = frame->nb_samples; + out.channel_stride = frame->nb_samples; + out.pts_us = pts_us; + out.samples.resize(static_cast(out.channels) * out.channel_stride); + + // Decoders allocate each plane separately; NDI needs them in one block. + size_t plane_bytes = static_cast(frame->nb_samples) * sizeof(float); + for (int c = 0; c < out.channels; c++) { + std::memcpy(out.samples.data() + static_cast(c) * out.channel_stride, frame->extended_data[c], + plane_bytes); + } + Emit(std::move(out), sink); +} + +void AudioProcessor::Flush(const Sink &sink) { + if (m_swr && !Resample(nullptr, 0, m_next_in_us, sink)) return; + if (m_held) { + AudioFrame last = std::move(*m_held); + m_held.reset(); + sink(std::move(last)); + } +} + +} // namespace ndistreamer::av diff --git a/src/av/audio_processor.hpp b/src/av/audio_processor.hpp new file mode 100644 index 0000000..95c749e --- /dev/null +++ b/src/av/audio_processor.hpp @@ -0,0 +1,71 @@ +/** + * @file audio_processor.hpp + * @brief Turns decoded audio frames into planar float blocks for NDI. + */ + +#pragma once + +#include "av/ffmpeg.hpp" +#include "frames.hpp" + +#include +#include + +namespace ndistreamer::av { + +struct AudioProcessorConfig { + int sample_rate = 0; ///< Output sample rate; 0 keeps the source rate. + int channels = 0; ///< Output channel count; 0 keeps the source channels. + AVRational time_base{1, 1000000}; ///< Time base of incoming frame timestamps. +}; + +/** + * @brief Converts any sample format/layout/rate to NDI's planar 32-bit float. + * + * Most decoders (AAC, MP3, AC-3, Opus...) already output planar float, in which case samples are only + * copied into one contiguous block. All source channels are kept unless a channel count is requested. + * + * One block is held back so that, if the next block starts before it ends, its tail can be trimmed. + * That happens at loop points with decoders that output encoder padding past the end of the media. + */ +class AudioProcessor { +public: + using Sink = std::function; + + explicit AudioProcessor(AudioProcessorConfig config); + ~AudioProcessor(); + + AudioProcessor(const AudioProcessor &) = delete; + AudioProcessor &operator=(const AudioProcessor &) = delete; + + void Process(const AVFrame *frame, const Sink &sink); + + /** + * @brief Emit everything still held back, including samples buffered in the resampler. + */ + void Flush(const Sink &sink); + +private: + bool Emit(AudioFrame &&out, const Sink &sink); + bool NeedsReconfigure(const AVFrame *frame) const; + void Configure(const AVFrame *frame); + bool Resample(const uint8_t *const *input, int input_samples, int64_t input_pts_us, const Sink &sink); + + AudioProcessorConfig m_config; + + SwrPtr m_swr; + bool m_configured = false; + bool m_passthrough = false; + int m_in_rate = 0; + int m_in_format = -1; + AVChannelLayout m_source_layout{}; ///< As reported by the decoder. + AVChannelLayout m_in_layout{}; ///< As given to the resampler (unspecified orders completed). + AVChannelLayout m_out_layout{}; + int m_out_rate = 0; + + int64_t m_next_in_us = 0; + std::optional m_held; ///< The most recent block, not yet emitted. + bool m_reported = false; +}; + +} // namespace ndistreamer::av diff --git a/src/av/decoder.cpp b/src/av/decoder.cpp new file mode 100644 index 0000000..5dd9f1f --- /dev/null +++ b/src/av/decoder.cpp @@ -0,0 +1,210 @@ +/** + * @file decoder.cpp + * @brief Audio/video decoder with optional hardware acceleration. + */ + +#include "av/decoder.hpp" +#include "util/log.hpp" + +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace ndistreamer::av { + +namespace { + +// A stream that decoded before but has produced only errors for this long is considered lost. +constexpr auto kGiveUpAfter = std::chrono::seconds(30); +// How often to report errors that keep happening. +constexpr auto kErrorReportInterval = std::chrono::seconds(10); + +// Device types tried by "auto", in order of preference. +constexpr AVHWDeviceType kAutoDeviceTypes[] = { + AV_HWDEVICE_TYPE_CUDA, + AV_HWDEVICE_TYPE_VAAPI, + AV_HWDEVICE_TYPE_VDPAU, +}; + +AVPixelFormat HardwareFormatFor(const AVCodec *codec, AVHWDeviceType type) { + for (int i = 0;; i++) { + const AVCodecHWConfig *config = avcodec_get_hw_config(codec, i); + if (!config) return AV_PIX_FMT_NONE; + if ((config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) && config->device_type == type) { + return config->pix_fmt; + } + } +} + +std::string AvailableDeviceTypes() { + std::string names; + for (AVHWDeviceType type = av_hwdevice_iterate_types(AV_HWDEVICE_TYPE_NONE); type != AV_HWDEVICE_TYPE_NONE; + type = av_hwdevice_iterate_types(type)) { + if (!names.empty()) names += ", "; + names += av_hwdevice_get_type_name(type); + } + return names.empty() ? "none" : names; +} + +} // namespace + +Decoder::Decoder(const AVStream *stream, const DecoderConfig &config) { + const AVCodecParameters *par = stream->codecpar; + const AVCodec *codec = avcodec_find_decoder(par->codec_id); + if (!codec) { + throw std::runtime_error(std::format("no decoder available for codec '{}'", avcodec_get_name(par->codec_id))); + } + + m_ctx.reset(avcodec_alloc_context3(codec)); + if (!m_ctx) throw std::bad_alloc(); + + Check(avcodec_parameters_to_context(m_ctx.get(), par), "copying codec parameters"); + m_ctx->pkt_timebase = stream->time_base; + m_ctx->thread_count = config.threads; + m_ctx->opaque = this; + + if (par->codec_type == AVMEDIA_TYPE_VIDEO && SetupHardware(codec, config)) { + m_ctx->hw_device_ctx = av_buffer_ref(m_hw_device.get()); + if (!m_ctx->hw_device_ctx) throw std::bad_alloc(); + m_ctx->get_format = &Decoder::GetFormat; + // Frames may be held by filters while later ones are decoded. + m_ctx->extra_hw_frames = 4; + } + + Check(avcodec_open2(m_ctx.get(), codec, nullptr), std::format("opening {} decoder", codec->name)); +} + +int Decoder::OpenDevice(const AVCodec *codec, AVHWDeviceType type, const std::string &device) { + AVBufferRef *ref = nullptr; + int ret = av_hwdevice_ctx_create(&ref, type, device.empty() ? nullptr : device.c_str(), nullptr, 0); + if (ret < 0) return ret; + + m_hw_device.reset(ref); + m_hw_type = type; + m_hw_format = HardwareFormatFor(codec, type); + return 0; +} + +bool Decoder::SetupHardware(const AVCodec *codec, const DecoderConfig &config) { + const std::string &name = config.hwaccel; + if (name.empty() || name == "none" || name == "software") return false; + + if (name == "auto") { + for (AVHWDeviceType type : kAutoDeviceTypes) { + if (HardwareFormatFor(codec, type) == AV_PIX_FMT_NONE) continue; + int ret = OpenDevice(codec, type, config.hwaccel_device); + if (ret == 0) return true; + log::Debug("{} device unavailable: {}", av_hwdevice_get_type_name(type), ErrorString(ret)); + } + log::Info("no hardware decoder available for {}; using software decoding", codec->name); + return false; + } + + AVHWDeviceType type = av_hwdevice_find_type_by_name(name.c_str()); + if (type == AV_HWDEVICE_TYPE_NONE) { + throw std::runtime_error( + std::format("unknown hardware acceleration '{}' (this FFmpeg supports: {})", name, AvailableDeviceTypes())); + } + + if (HardwareFormatFor(codec, type) == AV_PIX_FMT_NONE) { + log::Warn("{} cannot decode {}; using software decoding", name, codec->name); + return false; + } + + int ret = OpenDevice(codec, type, config.hwaccel_device); + if (ret < 0) { + std::string device = config.hwaccel_device.empty() ? "" : " '" + config.hwaccel_device + "'"; + throw std::runtime_error(std::format("cannot open {} device{}: {}", name, device, ErrorString(ret))); + } + return true; +} + +AVPixelFormat Decoder::GetFormat(AVCodecContext *ctx, const AVPixelFormat *formats) { + auto *self = static_cast(ctx->opaque); + + for (const AVPixelFormat *format = formats; *format != AV_PIX_FMT_NONE; format++) { + if (*format == self->m_hw_format) return *format; + } + + if (!self->m_fallback_reported.exchange(true)) { + log::Warn("{} cannot decode this stream; falling back to software decoding", + av_hwdevice_get_type_name(self->m_hw_type)); + } + + for (const AVPixelFormat *format = formats; *format != AV_PIX_FMT_NONE; format++) { + const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(*format); + if (desc && !(desc->flags & AV_PIX_FMT_FLAG_HWACCEL)) return *format; + } + return AV_PIX_FMT_NONE; +} + +void Decoder::NoteError(const char *operation, int ret) { + auto now = std::chrono::steady_clock::now(); + if (m_consecutive_errors++ == 0) m_errors_since = now; + + // Errors before the first frame are expected when joining a stream mid-way: the decoder waits for + // parameter sets and a keyframe. Only report those if they go on for a while. + if (m_consecutive_errors == 1 && m_frames_decoded > 0) { + log::Warn("{} decoder: {} failed: {}", m_ctx->codec->name, operation, ErrorString(ret)); + } else { + log::Debug("{} decoder: {} failed: {}", m_ctx->codec->name, operation, ErrorString(ret)); + } + + if (now - m_errors_since >= kErrorReportInterval && now - m_last_error_report >= kErrorReportInterval) { + m_last_error_report = now; + log::Warn("{} decoder: {} errors in a row ({}); {}", m_ctx->codec->name, m_consecutive_errors, + ErrorString(ret), m_frames_decoded > 0 ? "the stream may be damaged" : "waiting for a keyframe"); + } + + if (m_frames_decoded > 0 && now - m_errors_since >= kGiveUpAfter) { + throw Error(std::format("{} decoder produced nothing but errors for {} s", m_ctx->codec->name, + std::chrono::duration_cast(kGiveUpAfter).count()), + ret); + } +} + +bool Decoder::Send(const AVPacket *packet) { + int ret = avcodec_send_packet(m_ctx.get(), packet); + if (ret == AVERROR(EAGAIN)) return false; + if (ret == AVERROR_EOF) return true; // already draining + if (ret < 0) { + // Corrupt packets are skipped; the decoder resynchronizes on the next keyframe. + NoteError("sending a packet", ret); + return true; + } + return true; +} + +FramePtr Decoder::Receive() { + FramePtr frame = MakeFrame(); + int ret = avcodec_receive_frame(m_ctx.get(), frame.get()); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) return nullptr; + if (ret < 0) { + NoteError("decoding", ret); + return nullptr; + } + + m_consecutive_errors = 0; + m_frames_decoded++; + return frame; +} + +void Decoder::Reset() { + avcodec_flush_buffers(m_ctx.get()); +} + +std::string Decoder::HardwareName() const { + if (!m_hw_device || m_fallback_reported.load()) return {}; + return av_hwdevice_get_type_name(m_hw_type); +} + +std::string Decoder::CodecName() const { + return m_ctx->codec->name; +} + +} // namespace ndistreamer::av diff --git a/src/av/decoder.hpp b/src/av/decoder.hpp new file mode 100644 index 0000000..afd8657 --- /dev/null +++ b/src/av/decoder.hpp @@ -0,0 +1,87 @@ +/** + * @file decoder.hpp + * @brief Audio/video decoder with optional hardware acceleration. + */ + +#pragma once + +#include "av/ffmpeg.hpp" + +#include +#include +#include +#include + +namespace ndistreamer::av { + +struct DecoderConfig { + /// "none", "auto", or an FFmpeg hardware device type such as "vaapi" or "cuda". + /// Only used for video streams. + std::string hwaccel = "none"; + std::string hwaccel_device; ///< Optional device, e.g. "/dev/dri/renderD128" or a CUDA ordinal. + int threads = 0; ///< Decoder threads; 0 picks automatically. +}; + +/** + * @brief Thin wrapper over the send/receive decoding API. + * + * Hardware decoding falls back to software, per stream, when the device cannot decode the stream's + * codec or profile. Decoded hardware frames are returned as-is (still on the GPU). + * + * Corrupt input is skipped. A decoder that has worked but then fails continuously for 30 s throws. + */ +class Decoder { +public: + Decoder(const AVStream *stream, const DecoderConfig &config); + + Decoder(const Decoder &) = delete; + Decoder &operator=(const Decoder &) = delete; + + /** + * @brief Submit a packet, or nullptr to start draining. + * @return false if the decoder is full and frames must be received first. + */ + bool Send(const AVPacket *packet); + + /** + * @brief Fetch the next decoded frame. + * @return nullptr when more input is required or the decoder is fully drained. + */ + FramePtr Receive(); + + /** + * @brief Reset a drained decoder so it accepts input again (used at loop boundaries). + */ + void Reset(); + + /** + * @brief Name of the hardware device type in use, or empty for software decoding. + */ + std::string HardwareName() const; + + /** + * @brief Hardware device context (may be null). Shared with hardware filters. + */ + AVBufferRef *HardwareDevice() const { return m_hw_device.get(); } + + std::string CodecName() const; + +private: + bool SetupHardware(const AVCodec *codec, const DecoderConfig &config); + int OpenDevice(const AVCodec *codec, AVHWDeviceType type, const std::string &device); + void NoteError(const char *operation, int ret); + + static AVPixelFormat GetFormat(AVCodecContext *ctx, const AVPixelFormat *formats); + + CodecContextPtr m_ctx; + BufferRefPtr m_hw_device; + AVHWDeviceType m_hw_type = AV_HWDEVICE_TYPE_NONE; + AVPixelFormat m_hw_format = AV_PIX_FMT_NONE; + std::atomic m_fallback_reported{false}; + uint64_t m_frames_decoded = 0; + int m_consecutive_errors = 0; + std::chrono::steady_clock::time_point m_errors_since{}; + std::chrono::steady_clock::time_point m_last_error_report{}; +}; + +} // namespace ndistreamer::av diff --git a/src/av/ffmpeg.cpp b/src/av/ffmpeg.cpp new file mode 100644 index 0000000..115ac14 --- /dev/null +++ b/src/av/ffmpeg.cpp @@ -0,0 +1,44 @@ +/** + * @file ffmpeg.cpp + * @brief RAII ownership and error helpers for FFmpeg objects. + */ + +#include "av/ffmpeg.hpp" + +#include +#include + +extern "C" { +#include +} + +namespace ndistreamer::av { + +FramePtr MakeFrame() { + FramePtr frame(av_frame_alloc()); + if (!frame) throw std::bad_alloc(); + return frame; +} + +PacketPtr MakePacket() { + PacketPtr packet(av_packet_alloc()); + if (!packet) throw std::bad_alloc(); + return packet; +} + +std::string ErrorString(int errnum) { + char buffer[AV_ERROR_MAX_STRING_SIZE] = {}; + av_strerror(errnum, buffer, sizeof(buffer)); + return buffer; +} + +Error::Error(std::string_view operation, int code) + : std::runtime_error(std::format("{}: {}", operation, ErrorString(code))), m_code(code) {} + +std::string FormatDuration(int64_t us) { + if (us < 0) us = 0; + int64_t ms = us / 1000; + return std::format("{:02}:{:02}:{:02}.{:03}", ms / 3600000, (ms / 60000) % 60, (ms / 1000) % 60, ms % 1000); +} + +} // namespace ndistreamer::av diff --git a/src/av/ffmpeg.hpp b/src/av/ffmpeg.hpp new file mode 100644 index 0000000..2c3b5e8 --- /dev/null +++ b/src/av/ffmpeg.hpp @@ -0,0 +1,118 @@ +/** + * @file ffmpeg.hpp + * @brief RAII ownership and error helpers for FFmpeg objects. + */ + +#pragma once + +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +} + +namespace ndistreamer::av { + +struct FrameDeleter { + void operator()(AVFrame *frame) const { av_frame_free(&frame); } +}; +struct PacketDeleter { + void operator()(AVPacket *packet) const { av_packet_free(&packet); } +}; +struct CodecContextDeleter { + void operator()(AVCodecContext *ctx) const { avcodec_free_context(&ctx); } +}; +struct FormatContextDeleter { + void operator()(AVFormatContext *ctx) const { avformat_close_input(&ctx); } +}; +struct BufferRefDeleter { + void operator()(AVBufferRef *ref) const { av_buffer_unref(&ref); } +}; +struct BufferPoolDeleter { + void operator()(AVBufferPool *pool) const { av_buffer_pool_uninit(&pool); } +}; +struct FilterGraphDeleter { + void operator()(AVFilterGraph *graph) const { avfilter_graph_free(&graph); } +}; +struct SwrDeleter { + void operator()(SwrContext *ctx) const { swr_free(&ctx); } +}; +struct SwsDeleter { + void operator()(SwsContext *ctx) const { sws_freeContext(ctx); } +}; + +using FramePtr = std::unique_ptr; +using PacketPtr = std::unique_ptr; +using CodecContextPtr = std::unique_ptr; +using FormatContextPtr = std::unique_ptr; +using BufferRefPtr = std::unique_ptr; +using BufferPoolPtr = std::unique_ptr; +using FilterGraphPtr = std::unique_ptr; +using SwrPtr = std::unique_ptr; +using SwsPtr = std::unique_ptr; + +/** + * @brief Allocate an empty frame; throws std::bad_alloc on failure. + */ +FramePtr MakeFrame(); + +/** + * @brief Allocate an empty packet; throws std::bad_alloc on failure. + */ +PacketPtr MakePacket(); + +/** + * @brief Human readable description of an FFmpeg error code. + */ +std::string ErrorString(int errnum); + +/** + * @brief An FFmpeg call failed. what() includes the operation and FFmpeg's description. + */ +class Error : public std::runtime_error { +public: + Error(std::string_view operation, int code); + int code() const noexcept { return m_code; } + +private: + int m_code; +}; + +/** + * @brief Throw av::Error if @p ret is negative; otherwise return it. + */ +inline int Check(int ret, std::string_view operation) { + if (ret < 0) throw Error(operation, ret); + return ret; +} + +/** + * @brief Convert a timestamp to microseconds. + */ +inline int64_t ToMicros(int64_t ts, AVRational time_base) { + return av_rescale_q(ts, time_base, AVRational{1, 1000000}); +} + +/** + * @brief Convert microseconds to a timestamp in @p time_base. + */ +inline int64_t FromMicros(int64_t us, AVRational time_base) { + return av_rescale_q(us, AVRational{1, 1000000}, time_base); +} + +/** + * @brief Format microseconds as HH:MM:SS.mmm. + */ +std::string FormatDuration(int64_t us); + +} // namespace ndistreamer::av diff --git a/src/av/input.cpp b/src/av/input.cpp new file mode 100644 index 0000000..5b49c33 --- /dev/null +++ b/src/av/input.cpp @@ -0,0 +1,263 @@ +/** + * @file input.cpp + * @brief Opens a media input, selects streams and reads packets on a continuous timeline. + */ + +#include "av/input.hpp" +#include "util/log.hpp" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +namespace ndistreamer::av { + +namespace { + +const char *MediaTypeName(AVMediaType type) { + return type == AVMEDIA_TYPE_VIDEO ? "a video" : "an audio"; +} + +std::string DescribeCodec(const AVCodecParameters *par) { + std::string text = avcodec_get_name(par->codec_id); + if (const char *profile = avcodec_profile_name(par->codec_id, par->profile)) { + text += std::format(" ({})", profile); + } + return text; +} + +} // namespace + +MediaInput::MediaInput(const InputConfig &config, std::stop_token stop) : m_stop(std::move(stop)) { + static std::once_flag devices_registered; + std::call_once(devices_registered, [] { avdevice_register_all(); }); + + Open(config); + SelectStreams(config); +} + +int MediaInput::InterruptCallback(void *opaque) { + return static_cast(opaque)->m_stop.stop_requested() ? 1 : 0; +} + +void MediaInput::Open(const InputConfig &config) { + AVFormatContext *ctx = avformat_alloc_context(); + if (!ctx) throw std::bad_alloc(); + ctx->interrupt_callback.callback = &MediaInput::InterruptCallback; + ctx->interrupt_callback.opaque = this; + ctx->flags |= AVFMT_FLAG_GENPTS; + + const AVInputFormat *format = nullptr; + if (!config.format.empty()) { + format = av_find_input_format(config.format.c_str()); + if (!format) { + avformat_free_context(ctx); + throw std::runtime_error(std::format("unknown input format '{}'", config.format)); + } + } + + AVDictionary *options = nullptr; + for (const auto &[key, value] : config.options) { + av_dict_set(&options, key.c_str(), value.c_str(), 0); + } + + // On failure avformat_open_input frees the context itself. + int ret = avformat_open_input(&ctx, config.url.c_str(), format, &options); + if (ret < 0) { + av_dict_free(&options); + throw Error(std::format("cannot open '{}'", config.url), ret); + } + m_ctx.reset(ctx); + + const AVDictionaryEntry *unused = nullptr; + while ((unused = av_dict_iterate(options, unused))) { + log::Warn("input option '{}' was not recognized", unused->key); + } + av_dict_free(&options); + + Check(avformat_find_stream_info(m_ctx.get(), nullptr), "reading stream information"); +} + +AVStream *MediaInput::SelectStream(AVMediaType type, int requested, int related) { + if (requested >= 0) { + if (requested >= static_cast(m_ctx->nb_streams)) { + throw std::runtime_error(std::format("stream #{} does not exist (the input has {} streams)", requested, + m_ctx->nb_streams)); + } + AVStream *stream = m_ctx->streams[requested]; + if (stream->codecpar->codec_type != type) { + throw std::runtime_error(std::format("stream #{} is not {} stream", requested, MediaTypeName(type))); + } + return stream; + } + + int index = av_find_best_stream(m_ctx.get(), type, -1, related, nullptr, 0); + if (index < 0) return nullptr; + + AVStream *stream = m_ctx->streams[index]; + if (stream->disposition & AV_DISPOSITION_ATTACHED_PIC) { + log::Debug("ignoring cover art in stream #{}", index); + return nullptr; + } + return stream; +} + +void MediaInput::SelectStreams(const InputConfig &config) { + if (!config.disable_video) m_video = SelectStream(AVMEDIA_TYPE_VIDEO, config.video_stream, -1); + if (!config.disable_audio) { + m_audio = SelectStream(AVMEDIA_TYPE_AUDIO, config.audio_stream, m_video ? m_video->index : -1); + } + + if (!m_video && !m_audio) { + throw std::runtime_error(std::format("'{}' contains no usable audio or video stream", config.url)); + } + + m_tracks.assign(m_ctx->nb_streams, Track{}); + for (unsigned i = 0; i < m_ctx->nb_streams; i++) { + AVStream *stream = m_ctx->streams[i]; + Track &track = m_tracks[i]; + track.selected = stream == m_video || stream == m_audio; + if (!track.selected) { + stream->discard = AVDISCARD_ALL; + continue; + } + + if (stream == m_video) { + AVRational rate = VideoFrameRate(); + track.default_duration = av_rescale_q(1, av_inv_q(rate), stream->time_base); + } else if (stream->codecpar->frame_size > 0 && stream->codecpar->sample_rate > 0) { + track.default_duration = av_rescale_q(stream->codecpar->frame_size, + AVRational{1, stream->codecpar->sample_rate}, stream->time_base); + } + } +} + +AVRational MediaInput::VideoFrameRate() const { + if (m_video) { + AVRational rate = av_guess_frame_rate(m_ctx.get(), m_video, nullptr); + if (rate.num > 0 && rate.den > 0) return rate; + } + return AVRational{30000, 1001}; +} + +void MediaInput::Account(AVPacket *packet, Track &track) { + const AVStream *stream = m_ctx->streams[packet->stream_index]; + AVRational time_base = stream->time_base; + + // Discarded packets (e.g. encoder priming) are never presented, so they do not extend the pass. + if (packet->pts != AV_NOPTS_VALUE && !(packet->flags & AV_PKT_FLAG_DISCARD)) { + int64_t duration = packet->duration > 0 ? packet->duration : track.default_duration; + int64_t start = packet->pts; + int64_t end = packet->pts + duration; + + // Samples the decoder trims (encoder delay and padding, e.g. in MP3) are not presented either. + size_t size = 0; + const uint8_t *skip = av_packet_get_side_data(packet, AV_PKT_DATA_SKIP_SAMPLES, &size); + int sample_rate = stream->codecpar->sample_rate; + if (skip && size >= 8 && sample_rate > 0) { + start += av_rescale_q(AV_RL32(skip), AVRational{1, sample_rate}, time_base); + end -= av_rescale_q(AV_RL32(skip + 4), AVRational{1, sample_rate}, time_base); + } + + if (end > start) { + if (m_first_pass) m_pass_start_us = std::min(m_pass_start_us, ToMicros(start, time_base)); + m_pass_end_us = std::max(m_pass_end_us, ToMicros(end, time_base)); + } + } + + if (track.offset != 0) { + if (packet->pts != AV_NOPTS_VALUE) packet->pts += track.offset; + if (packet->dts != AV_NOPTS_VALUE) packet->dts += track.offset; + } +} + +bool MediaInput::IsLive() const { + bool seekable = m_ctx->pb && (m_ctx->pb->seekable & AVIO_SEEKABLE_NORMAL); + bool bounded = m_ctx->duration > 0; // AV_NOPTS_VALUE is negative + return !bounded || (m_ctx->pb && !seekable); +} + +PacketPtr MediaInput::Read() { + PacketPtr packet = MakePacket(); + + while (!m_stop.stop_requested()) { + int ret = av_read_frame(m_ctx.get(), packet.get()); + if (ret == AVERROR(EAGAIN)) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + continue; + } + if (ret == AVERROR_EOF) return nullptr; + if (ret < 0) { + if (m_stop.stop_requested()) return nullptr; + if (m_ctx->pb && avio_feof(m_ctx->pb)) return nullptr; + throw Error("reading input", ret); + } + + int index = packet->stream_index; + if (index < 0 || index >= static_cast(m_tracks.size()) || !m_tracks[index].selected) { + av_packet_unref(packet.get()); + continue; + } + + Account(packet.get(), m_tracks[index]); + return packet; + } + + return nullptr; +} + +bool MediaInput::Restart() { + if (m_pass_end_us == INT64_MIN || m_pass_start_us == INT64_MAX || m_pass_end_us <= m_pass_start_us) { + log::Warn("cannot loop: the input has no usable timestamps"); + return false; + } + + int64_t target = m_ctx->start_time != AV_NOPTS_VALUE ? m_ctx->start_time : 0; + int ret = avformat_seek_file(m_ctx.get(), -1, INT64_MIN, target, target, 0); + if (ret < 0) { + log::Warn("cannot loop: seeking to the start failed ({})", ErrorString(ret)); + return false; + } + + m_offset_us += m_pass_end_us - m_pass_start_us; + m_first_pass = false; + m_pass_end_us = INT64_MIN; + + for (unsigned i = 0; i < m_tracks.size(); i++) { + if (m_tracks[i].selected) m_tracks[i].offset = FromMicros(m_offset_us, m_ctx->streams[i]->time_base); + } + + log::Debug("restarted input; timeline offset is now {}", FormatDuration(m_offset_us)); + return true; +} + +void MediaInput::LogInfo() const { + std::string duration = m_ctx->duration > 0 ? FormatDuration(m_ctx->duration) : "unknown"; + log::Info("input: {} ({}), duration {}{}", m_ctx->url ? m_ctx->url : "?", m_ctx->iformat->name, duration, + IsLive() ? ", live" : ""); + + if (m_video) { + const AVCodecParameters *par = m_video->codecpar; + AVRational rate = VideoFrameRate(); + log::Info(" video: stream #{} {}, {}x{}, {:.3f} fps", m_video->index, DescribeCodec(par), par->width, + par->height, av_q2d(rate)); + } + if (m_audio) { + const AVCodecParameters *par = m_audio->codecpar; + char layout[128] = "unknown"; + av_channel_layout_describe(&par->ch_layout, layout, sizeof(layout)); + log::Info(" audio: stream #{} {}, {} Hz, {}", m_audio->index, DescribeCodec(par), par->sample_rate, layout); + } +} + +} // namespace ndistreamer::av diff --git a/src/av/input.hpp b/src/av/input.hpp new file mode 100644 index 0000000..04423fe --- /dev/null +++ b/src/av/input.hpp @@ -0,0 +1,102 @@ +/** + * @file input.hpp + * @brief Opens a media input, selects streams and reads packets on a continuous timeline. + */ + +#pragma once + +#include "av/ffmpeg.hpp" + +#include +#include +#include +#include +#include + +namespace ndistreamer::av { + +struct InputConfig { + std::string url; + std::string format; ///< Force an input format (e.g. "v4l2"); empty to probe. + std::vector> options; ///< Demuxer/protocol options. + int video_stream = -1; ///< Stream index, or -1 to choose automatically. + int audio_stream = -1; ///< Stream index, or -1 to choose automatically. + bool disable_video = false; + bool disable_audio = false; +}; + +/** + * @brief Demuxer front end. + * + * Only packets of the selected video and audio streams are returned. When the input is restarted + * for looping, timestamps of the following pass are shifted so that the timeline keeps increasing; + * downstream stages never see a timestamp jump. + */ +class MediaInput { +public: + /** + * @param stop interrupts blocking I/O (network reads, device waits) when triggered + */ + MediaInput(const InputConfig &config, std::stop_token stop); + + MediaInput(const MediaInput &) = delete; + MediaInput &operator=(const MediaInput &) = delete; + + AVStream *VideoStream() const { return m_video; } + AVStream *AudioStream() const { return m_audio; } + + /** + * @brief Nominal video frame rate, falling back to 30000/1001 when it cannot be determined. + */ + AVRational VideoFrameRate() const; + + /** + * @brief Whether the input is a live source (stream, device, pipe) rather than a seekable file. + * Timestamp jumps in live sources are discontinuities; in files they are real gaps. + */ + bool IsLive() const; + + /** + * @brief Read the next packet of a selected stream. + * @return nullptr at the end of the input or when stopped. + */ + PacketPtr Read(); + + /** + * @brief Seek back to the beginning for another pass. + * @return false if the input cannot be restarted (e.g. not seekable). + */ + bool Restart(); + + /** + * @brief Log a description of the input and the selected streams. + */ + void LogInfo() const; + +private: + struct Track { + bool selected = false; + int64_t offset = 0; ///< Timeline offset in stream time base. + int64_t default_duration = 0; ///< Used when packets carry no duration. + }; + + static int InterruptCallback(void *opaque); + + void Open(const InputConfig &config); + void SelectStreams(const InputConfig &config); + AVStream *SelectStream(AVMediaType type, int requested, int related); + void Account(AVPacket *packet, Track &track); + + std::stop_token m_stop; + FormatContextPtr m_ctx; + AVStream *m_video = nullptr; + AVStream *m_audio = nullptr; + std::vector m_tracks; + + bool m_first_pass = true; + int64_t m_offset_us = 0; + int64_t m_pass_start_us = INT64_MAX; + int64_t m_pass_end_us = INT64_MIN; +}; + +} // namespace ndistreamer::av diff --git a/src/av/video_processor.cpp b/src/av/video_processor.cpp new file mode 100644 index 0000000..4320908 --- /dev/null +++ b/src/av/video_processor.cpp @@ -0,0 +1,458 @@ +/** + * @file video_processor.cpp + * @brief Turns decoded video frames into NDI-ready frames. + */ + +#include "av/video_processor.hpp" +#include "util/log.hpp" + +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +namespace ndistreamer::av { + +namespace { + +// Extra bytes after each buffer so SIMD routines may safely overrun the last line. +constexpr size_t kBufferPadding = 64; + +struct PlaneLayout { + int planes = 0; + size_t offset[3] = {}; + int linesize[3] = {}; + size_t size = 0; +}; + +// The contiguous layouts NDI expects, with SIMD-friendly line strides. +PlaneLayout ContiguousLayout(AVPixelFormat format, int width, int height) { + PlaneLayout layout; + int chroma_height = (height + 1) / 2; + + switch (format) { + case AV_PIX_FMT_UYVY422: + layout.planes = 1; + layout.linesize[0] = FFALIGN(width * 2, 64); + layout.size = static_cast(layout.linesize[0]) * height; + break; + case AV_PIX_FMT_BGRA: + case AV_PIX_FMT_BGR0: + case AV_PIX_FMT_RGBA: + case AV_PIX_FMT_RGB0: + layout.planes = 1; + layout.linesize[0] = FFALIGN(width * 4, 64); + layout.size = static_cast(layout.linesize[0]) * height; + break; + case AV_PIX_FMT_NV12: + // Y plane followed immediately by interleaved UV with the same stride. + layout.planes = 2; + layout.linesize[0] = layout.linesize[1] = FFALIGN(width, 64); + layout.offset[1] = static_cast(layout.linesize[0]) * height; + layout.size = layout.offset[1] + static_cast(layout.linesize[1]) * chroma_height; + break; + case AV_PIX_FMT_YUV420P: + // Y, then U, then V; chroma stride is exactly half the luma stride. + layout.planes = 3; + layout.linesize[0] = FFALIGN(width, 128); + layout.linesize[1] = layout.linesize[2] = layout.linesize[0] / 2; + layout.offset[1] = static_cast(layout.linesize[0]) * height; + layout.offset[2] = layout.offset[1] + static_cast(layout.linesize[1]) * chroma_height; + layout.size = layout.offset[2] + static_cast(layout.linesize[2]) * chroma_height; + break; + default: + throw std::logic_error(std::format("no NDI layout for {}", av_get_pix_fmt_name(format))); + } + return layout; +} + +std::optional FourCCFor(AVPixelFormat format) { + switch (format) { + case AV_PIX_FMT_UYVY422: + return VideoFourCC::UYVY; + case AV_PIX_FMT_NV12: + return VideoFourCC::NV12; + case AV_PIX_FMT_YUV420P: + return VideoFourCC::I420; + case AV_PIX_FMT_BGRA: + return VideoFourCC::BGRA; + case AV_PIX_FMT_BGR0: + return VideoFourCC::BGRX; + case AV_PIX_FMT_RGBA: + return VideoFourCC::RGBA; + case AV_PIX_FMT_RGB0: + return VideoFourCC::RGBX; + default: + return std::nullopt; + } +} + +bool IsRgb(AVPixelFormat format) { + const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(format); + return desc && (desc->flags & AV_PIX_FMT_FLAG_RGB); +} + +bool IsFullRange(const AVFrame *frame) { + switch (frame->format) { + case AV_PIX_FMT_YUVJ420P: + case AV_PIX_FMT_YUVJ422P: + case AV_PIX_FMT_YUVJ444P: + case AV_PIX_FMT_YUVJ440P: + case AV_PIX_FMT_YUVJ411P: + return true; + default: + return frame->color_range == AVCOL_RANGE_JPEG; + } +} + +// Whether the frame's planes already sit where NDI expects them, so it can be sent without copying. +bool HasNdiLayout(const AVFrame *frame) { + const int *ls = frame->linesize; + uint8_t *const *data = frame->data; + if (ls[0] <= 0) return false; + + switch (frame->format) { + case AV_PIX_FMT_NV12: + return ls[1] == ls[0] && data[1] == data[0] + static_cast(ls[0]) * frame->height; + case AV_PIX_FMT_YUV420P: { + int chroma_height = (frame->height + 1) / 2; + return ls[0] % 2 == 0 && ls[1] == ls[0] / 2 && ls[2] == ls[1] && + data[1] == data[0] + static_cast(ls[0]) * frame->height && + data[2] == data[1] + static_cast(ls[1]) * chroma_height; + } + default: + return true; // single plane + } +} + +int SwsColorspace(AVColorSpace colorspace, int height) { + switch (colorspace) { + case AVCOL_SPC_BT709: + return SWS_CS_ITU709; + case AVCOL_SPC_FCC: + return SWS_CS_FCC; + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + return SWS_CS_ITU601; + case AVCOL_SPC_SMPTE240M: + return SWS_CS_SMPTE240M; + case AVCOL_SPC_BT2020_NCL: + case AVCOL_SPC_BT2020_CL: + return SWS_CS_BT2020; + default: + return height >= 720 ? SWS_CS_ITU709 : SWS_CS_ITU601; + } +} + +// Whether a filtergraph is written for GPU frames. Anything else gets frames in system memory. +bool IsHardwareFilter(const std::string &filter) { + for (const char *marker : {"hwdownload", "hwmap", "hwupload", "_vaapi", "_cuda", "_npp", "_qsv", "_vulkan", + "_opencl", "_vdpau"}) { + if (filter.find(marker) != std::string::npos) return true; + } + return false; +} + +bool IsInterlaced(const AVFrame *frame) { +#ifdef AV_FRAME_FLAG_INTERLACED + return frame->flags & AV_FRAME_FLAG_INTERLACED; +#else + return frame->interlaced_frame; +#endif +} + +} // namespace + +std::optional ParseOutputPixelFormat(std::string_view name) { + if (name == "uyvy") return OutputPixelFormat::UYVY; + if (name == "nv12") return OutputPixelFormat::NV12; + if (name == "i420") return OutputPixelFormat::I420; + if (name == "bgra") return OutputPixelFormat::BGRA; + if (name == "rgba") return OutputPixelFormat::RGBA; + if (name == "auto") return OutputPixelFormat::Auto; + return std::nullopt; +} + +const char *ToString(OutputPixelFormat format) { + switch (format) { + case OutputPixelFormat::UYVY: + return "uyvy"; + case OutputPixelFormat::NV12: + return "nv12"; + case OutputPixelFormat::I420: + return "i420"; + case OutputPixelFormat::BGRA: + return "bgra"; + case OutputPixelFormat::RGBA: + return "rgba"; + case OutputPixelFormat::Auto: + return "auto"; + } + return "?"; +} + +VideoProcessor::VideoProcessor(VideoProcessorConfig config) + : m_config(std::move(config)), m_filter_takes_hw_frames(IsHardwareFilter(m_config.filter)) {} + +AVPixelFormat VideoProcessor::TargetFormat(AVPixelFormat source, AVColorRange range) const { + switch (m_config.pixel_format) { + case OutputPixelFormat::UYVY: + return AV_PIX_FMT_UYVY422; + case OutputPixelFormat::NV12: + return AV_PIX_FMT_NV12; + case OutputPixelFormat::I420: + return AV_PIX_FMT_YUV420P; + case OutputPixelFormat::BGRA: + return AV_PIX_FMT_BGRA; + case OutputPixelFormat::RGBA: + return AV_PIX_FMT_RGBA; + case OutputPixelFormat::Auto: + // NDI assumes limited-range YUV, so full-range sources are converted. + if (FourCCFor(source) && (IsRgb(source) || range != AVCOL_RANGE_JPEG)) return source; + return AV_PIX_FMT_UYVY422; + } + return AV_PIX_FMT_UYVY422; +} + +FramePtr VideoProcessor::AllocateContiguous(AVPixelFormat format, int width, int height) { + PlaneLayout layout = ContiguousLayout(format, width, height); + + if (!m_pool || m_pool_size != layout.size) { + // Buffers still in flight keep the old pool alive until they are returned. + m_pool.reset(av_buffer_pool_init(layout.size + kBufferPadding, nullptr)); + if (!m_pool) throw std::bad_alloc(); + m_pool_size = layout.size; + } + + FramePtr frame = MakeFrame(); + frame->buf[0] = av_buffer_pool_get(m_pool.get()); + if (!frame->buf[0]) throw std::bad_alloc(); + + for (int i = 0; i < layout.planes; i++) { + frame->data[i] = frame->buf[0]->data + layout.offset[i]; + frame->linesize[i] = layout.linesize[i]; + } + frame->format = format; + frame->width = width; + frame->height = height; + return frame; +} + +FramePtr VideoProcessor::Download(FramePtr frame, AVPixelFormat target) { + const auto *frames_ctx = reinterpret_cast(frame->hw_frames_ctx->data); + + FramePtr sw; + if (target != AV_PIX_FMT_NONE && frames_ctx->sw_format == target) { + // Transfer straight into the buffer NDI will read from. + sw = AllocateContiguous(target, frame->width, frame->height); + } else { + sw = MakeFrame(); // FFmpeg picks the device's preferred download format + } + + Check(av_hwframe_transfer_data(sw.get(), frame.get(), 0), "downloading a frame from the GPU"); + Check(av_frame_copy_props(sw.get(), frame.get()), "copying frame properties"); + return sw; +} + +FramePtr VideoProcessor::Convert(FramePtr frame, AVPixelFormat target, const char *&path) { + if (frame->hw_frames_ctx) frame = Download(std::move(frame), target); + + if (frame->format == target) { + if (HasNdiLayout(frame.get())) { + path = "zero-copy"; + return frame; + } + path = "repacked"; + FramePtr packed = AllocateContiguous(target, frame->width, frame->height); + Check(av_frame_copy(packed.get(), frame.get()), "repacking a frame"); + return packed; + } + + path = "converted"; + auto source = static_cast(frame->format); + SwsKey key{frame->width, frame->height, source, target, SwsColorspace(frame->colorspace, frame->height), + IsFullRange(frame.get()) ? 1 : 0}; + if (!m_sws || key != m_sws_key) { + m_sws.reset(sws_getContext(key.width, key.height, source, key.width, key.height, target, SWS_BICUBIC, nullptr, + nullptr, nullptr)); + if (!m_sws) { + throw std::runtime_error(std::format("cannot convert {} to {}", av_get_pix_fmt_name(source), + av_get_pix_fmt_name(target))); + } + // NDI interprets YUV as limited range, BT.709 for HD and BT.601 for SD. + const int *dst_table = sws_getCoefficients(key.height >= 720 ? SWS_CS_ITU709 : SWS_CS_ITU601); + int dst_range = IsRgb(target) ? 1 : 0; + sws_setColorspaceDetails(m_sws.get(), sws_getCoefficients(key.colorspace), key.range, dst_table, dst_range, 0, + 1 << 16, 1 << 16); + m_sws_key = key; + } + + FramePtr converted = AllocateContiguous(target, frame->width, frame->height); + Check(sws_scale(m_sws.get(), frame->data, frame->linesize, 0, frame->height, converted->data, converted->linesize), + "converting pixel format"); + return converted; +} + +bool VideoProcessor::Emit(FramePtr frame, AVRational time_base, AVRational frame_rate, const Sink &sink) { + if (frame_rate.num <= 0 || frame_rate.den <= 0) frame_rate = m_config.frame_rate; + + // Capture metadata before conversion replaces the frame. + int64_t frame_duration_us = av_rescale(1000000, frame_rate.den, frame_rate.num); + int64_t pts_us = frame->pts != AV_NOPTS_VALUE ? ToMicros(frame->pts, time_base) : m_next_out_us; + int64_t duration_us = frame->duration > 0 ? ToMicros(frame->duration, time_base) : frame_duration_us; + m_next_out_us = pts_us + duration_us; + + AVRational sar = frame->sample_aspect_ratio; + float aspect = 0.0f; + if (sar.num > 0 && sar.den > 0 && frame->height > 0) { + aspect = static_cast(static_cast(frame->width) * sar.num / (frame->height * sar.den)); + } + bool interlaced = IsInterlaced(frame.get()); + + AVPixelFormat source = static_cast(frame->format); + if (frame->hw_frames_ctx) { + source = reinterpret_cast(frame->hw_frames_ctx->data)->sw_format; + } + AVPixelFormat target = TargetFormat(source, frame->color_range); + + const char *path = ""; + FramePtr ready = Convert(std::move(frame), target, path); + + VideoFrame out; + out.data = ready->data[0]; + out.stride = ready->linesize[0]; + out.width = ready->width; + out.height = ready->height; + out.fourcc = *FourCCFor(target); + out.frame_rate_num = frame_rate.num; + out.frame_rate_den = frame_rate.den; + out.aspect_ratio = aspect; + out.interlaced = interlaced; + out.pts_us = pts_us; + out.duration_us = duration_us; + out.storage = std::move(ready); + + if (!m_reported) { + m_reported = true; + log::Info("video output: {} {}x{} @ {:.3f} fps{} ({} from {})", ToString(out.fourcc), out.width, out.height, + av_q2d(frame_rate), interlaced ? " interlaced" : "", path, av_get_pix_fmt_name(source)); + } + + return sink(std::move(out)); +} + +void VideoProcessor::BuildFilter(const AVFrame *frame) { + m_graph.reset(avfilter_graph_alloc()); + if (!m_graph) throw std::bad_alloc(); + + m_buffersrc = avfilter_graph_alloc_filter(m_graph.get(), avfilter_get_by_name("buffer"), "in"); + m_buffersink = avfilter_graph_alloc_filter(m_graph.get(), avfilter_get_by_name("buffersink"), "out"); + if (!m_buffersrc || !m_buffersink) throw std::bad_alloc(); + + AVBufferSrcParameters *params = av_buffersrc_parameters_alloc(); + if (!params) throw std::bad_alloc(); + params->format = frame->format; + params->width = frame->width; + params->height = frame->height; + params->time_base = m_config.time_base; + params->frame_rate = m_config.frame_rate; + params->sample_aspect_ratio = frame->sample_aspect_ratio.num > 0 ? frame->sample_aspect_ratio : AVRational{1, 1}; + params->hw_frames_ctx = frame->hw_frames_ctx; +#ifdef NDISTREAMER_HAVE_BUFFERSRC_COLOR + params->color_space = frame->colorspace; + params->color_range = frame->color_range; +#endif + int ret = av_buffersrc_parameters_set(m_buffersrc, params); + av_free(params); + Check(ret, "configuring the filter source"); + Check(avfilter_init_str(m_buffersrc, nullptr), "initializing the filter source"); + Check(avfilter_init_str(m_buffersink, nullptr), "initializing the filter sink"); + + AVFilterInOut *outputs = avfilter_inout_alloc(); + AVFilterInOut *inputs = avfilter_inout_alloc(); + if (!outputs || !inputs) { + avfilter_inout_free(&outputs); + avfilter_inout_free(&inputs); + throw std::bad_alloc(); + } + outputs->name = av_strdup("in"); + outputs->filter_ctx = m_buffersrc; + inputs->name = av_strdup("out"); + inputs->filter_ctx = m_buffersink; + + ret = avfilter_graph_parse_ptr(m_graph.get(), m_config.filter.c_str(), &inputs, &outputs, nullptr); + avfilter_inout_free(&inputs); + avfilter_inout_free(&outputs); + Check(ret, std::format("parsing video filter '{}'", m_config.filter)); + + if (m_config.hw_device) { + for (unsigned i = 0; i < m_graph->nb_filters; i++) { + AVFilterContext *filter = m_graph->filters[i]; + if (!filter->hw_device_ctx) filter->hw_device_ctx = av_buffer_ref(m_config.hw_device); + } + } + + Check(avfilter_graph_config(m_graph.get(), nullptr), std::format("configuring video filter '{}'", m_config.filter)); + + m_filter_rate = av_buffersink_get_frame_rate(m_buffersink); + m_filter_input = {frame->width, frame->height, frame->format, + frame->hw_frames_ctx ? frame->hw_frames_ctx->data : nullptr}; + log::Debug("video filter configured: {}", m_config.filter); +} + +bool VideoProcessor::DrainFilter(const Sink &sink) { + AVRational time_base = av_buffersink_get_time_base(m_buffersink); + while (true) { + FramePtr filtered = MakeFrame(); + int ret = av_buffersink_get_frame(m_buffersink, filtered.get()); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) return true; + Check(ret, "filtering video"); + if (!Emit(std::move(filtered), time_base, m_filter_rate, sink)) return false; + } +} + +void VideoProcessor::Process(FramePtr frame, const Sink &sink) { + // Give every frame a timestamp; streams without them are timed by frame rate. + int64_t pts = frame->best_effort_timestamp != AV_NOPTS_VALUE ? frame->best_effort_timestamp : frame->pts; + if (pts == AV_NOPTS_VALUE) pts = m_next_in_pts != AV_NOPTS_VALUE ? m_next_in_pts : 0; + frame->pts = pts; + int64_t duration = frame->duration > 0 ? frame->duration + : av_rescale_q(1, av_inv_q(m_config.frame_rate), m_config.time_base); + m_next_in_pts = pts + duration; + + if (m_config.filter.empty()) { + Emit(std::move(frame), m_config.time_base, m_config.frame_rate, sink); + return; + } + + if (frame->hw_frames_ctx && !m_filter_takes_hw_frames) frame = Download(std::move(frame), AV_PIX_FMT_NONE); + + const void *hw_frames = frame->hw_frames_ctx ? frame->hw_frames_ctx->data : nullptr; + if (!m_graph || m_filter_input.width != frame->width || m_filter_input.height != frame->height || + m_filter_input.format != frame->format || m_filter_input.hw_frames != hw_frames) { + if (m_graph) { + log::Info("video input changed to {}x{}; rebuilding filter", frame->width, frame->height); + Flush(sink); // emit what the old graph still holds + } + BuildFilter(frame.get()); + } + + // Ownership of the frame's data passes to the filter. + Check(av_buffersrc_add_frame_flags(m_buffersrc, frame.get(), 0), "filtering video"); + DrainFilter(sink); +} + +void VideoProcessor::Flush(const Sink &sink) { + if (!m_graph) return; + Check(av_buffersrc_add_frame_flags(m_buffersrc, nullptr, 0), "flushing video filter"); + DrainFilter(sink); +} + +} // namespace ndistreamer::av diff --git a/src/av/video_processor.hpp b/src/av/video_processor.hpp new file mode 100644 index 0000000..3b1561f --- /dev/null +++ b/src/av/video_processor.hpp @@ -0,0 +1,117 @@ +/** + * @file video_processor.hpp + * @brief Turns decoded video frames into NDI-ready frames. + */ + +#pragma once + +#include "av/ffmpeg.hpp" +#include "frames.hpp" + +#include +#include +#include + +namespace ndistreamer::av { + +/** + * @brief Pixel layout requested for NDI output. + */ +enum class OutputPixelFormat { + Auto, ///< Pass through any layout NDI accepts; convert everything else to UYVY. + UYVY, ///< 8-bit 4:2:2. + NV12, ///< 8-bit 4:2:0 semi-planar. + I420, ///< 8-bit 4:2:0 planar. + BGRA, ///< 8-bit RGB with alpha. + RGBA, ///< 8-bit RGB with alpha. +}; + +std::optional ParseOutputPixelFormat(std::string_view name); +const char *ToString(OutputPixelFormat format); + +struct VideoProcessorConfig { + std::string filter; ///< Optional FFmpeg filtergraph, e.g. "yadif". + OutputPixelFormat pixel_format = OutputPixelFormat::Auto; + AVRational time_base{1, 1000000}; ///< Time base of incoming frame timestamps. + AVRational frame_rate{30000, 1001}; ///< Nominal input frame rate. + AVBufferRef *hw_device = nullptr; ///< Device for hardware filters (borrowed). +}; + +/** + * @brief Filter, download and convert decoded frames. + * + * Steps, each skipped when not needed: + * 1. the user filtergraph (built lazily from the first frame, rebuilt if the input changes); + * GPU frames are downloaded first unless the graph uses hardware filters, + * 2. download from GPU memory, directly into NDI layout when the formats match, + * 3. pixel format conversion, or repacking planes into the contiguous layout NDI requires. + * Single-plane frames that already match are passed to NDI without copying. + */ +class VideoProcessor { +public: + /// Receives each finished frame; returning false stops processing (e.g. the queue was closed). + using Sink = std::function; + + explicit VideoProcessor(VideoProcessorConfig config); + + VideoProcessor(const VideoProcessor &) = delete; + VideoProcessor &operator=(const VideoProcessor &) = delete; + + /** + * @brief Process one decoded frame. Timestamps are in the configured time base. + */ + void Process(FramePtr frame, const Sink &sink); + + /** + * @brief Flush frames buffered in the filtergraph at the end of the stream. + */ + void Flush(const Sink &sink); + +private: + struct FilterInput { + int width = 0; + int height = 0; + int format = -1; + const void *hw_frames = nullptr; + }; + + void BuildFilter(const AVFrame *frame); + bool DrainFilter(const Sink &sink); + bool Emit(FramePtr frame, AVRational time_base, AVRational frame_rate, const Sink &sink); + + AVPixelFormat TargetFormat(AVPixelFormat source, AVColorRange range) const; + FramePtr Download(FramePtr frame, AVPixelFormat target); + FramePtr Convert(FramePtr frame, AVPixelFormat target, const char *&path); + FramePtr AllocateContiguous(AVPixelFormat format, int width, int height); + + VideoProcessorConfig m_config; + const bool m_filter_takes_hw_frames; + + FilterGraphPtr m_graph; + AVFilterContext *m_buffersrc = nullptr; + AVFilterContext *m_buffersink = nullptr; + FilterInput m_filter_input; + AVRational m_filter_rate{0, 1}; + + struct SwsKey { + int width = 0; + int height = 0; + AVPixelFormat source = AV_PIX_FMT_NONE; + AVPixelFormat target = AV_PIX_FMT_NONE; + int colorspace = 0; + int range = 0; + bool operator==(const SwsKey &) const = default; + }; + + SwsPtr m_sws; + SwsKey m_sws_key; + + BufferPoolPtr m_pool; + size_t m_pool_size = 0; + + int64_t m_next_in_pts = AV_NOPTS_VALUE; + int64_t m_next_out_us = 0; + bool m_reported = false; +}; + +} // namespace ndistreamer::av diff --git a/src/averror.cpp b/src/averror.cpp deleted file mode 100644 index 967a2a8..0000000 --- a/src/averror.cpp +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @file averror.cpp - * @brief This file includes utilities for handling errors in the AV namespace. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#include "averror.hpp" - -#define DEMUXSTR "[AVERROR]" - -namespace AV::Utils { - -/** - * @brief Construct a new AvException:: AvException object - * - * @param errcode error code - */ -AvException::AvException(AvError errcode) : m_errcode(errcode) {} - -/** - * @brief Construct a new AvException:: AvException object - */ -AvException::AvException() : m_errcode(AvError::NOERROR) {} - -/** - * @brief Get the error message - * - * @return const char* - */ -const char *AvException::what() const noexcept { - switch (m_errcode) { - case AvError::NOERROR: - return DEMUXSTR " No error"; - case AvError::OPENINPUT: - return DEMUXSTR " Error opening input"; - case AvError::AVDICTSET: - return DEMUXSTR " Error setting dictionary"; - case AvError::READFRAME: - return DEMUXSTR " Error reading frame"; - case AvError::FRAMEALLOC: - return DEMUXSTR " Error allocating frame"; - case AvError::PACKETALLOC: - return DEMUXSTR " Error allocating packet"; - case AvError::FINDSTREAMINFO: - return DEMUXSTR " Error finding stream info"; - case AvError::FINDDECODER: - return DEMUXSTR " Error finding decoder"; - case AvError::DECODERALLOC: - return DEMUXSTR " Error allocating decoder"; - case AvError::DECPARAMS: - return DEMUXSTR " Error with codec parameters"; - case AvError::SENDPACKET: - return DEMUXSTR " Error sending packet to decoder"; - case AvError::RECIEVEFRAME: - return DEMUXSTR " Error receiving frame from decoder"; - case AvError::DECODEREXHAUSTED: - return DEMUXSTR " Decoder is exhausted"; - case AvError::SWSCONTEXT: - return DEMUXSTR " Error creating sws context"; - case AvError::SWSSCALE: - return DEMUXSTR " Error scaling frame"; - case AvError::AVMALLOC: - return DEMUXSTR " Error allocating memory"; - case AvError::IMAGEFILLARRAYS: - return DEMUXSTR " Error filling image arrays"; - case AvError::SWRALLOCS: - return DEMUXSTR " Error allocating swr context"; - case AvError::SWRINIT: - return DEMUXSTR " Error initializing swr context"; - case AvError::AVSAMPLESALLOC: - return DEMUXSTR " Error allocating samples"; - case AvError::SWRCONVERT: - return DEMUXSTR " Error converting samples"; - case AvError::NDISENDINSTANCE: - return DEMUXSTR " Error creating NDI send instance"; - case AvError::NDIINVALIDPIXFMT: - return DEMUXSTR " Invalid NDI pixel format"; - case AvError::FRAMEGETBUFFER: - return DEMUXSTR " Error getting frame buffer"; - case AvError::SWRCONFIG: - return DEMUXSTR " Error configuring frame"; - case AvError::STREAMCOUNT: - return DEMUXSTR " Incorrect number of streams"; - case AvError::NDISOURCECREATE: - return DEMUXSTR " Error creating NDI source"; - case AvError::FRAMEREF: - return DEMUXSTR " Error referencing frame"; - case AvError::SAMPLECOPY: - return DEMUXSTR " Error copying samples"; - case AvError::BUFFERFULL: - return DEMUXSTR " Buffer is full"; - case AvError::PACKETREF: - return DEMUXSTR " Error referencing packet"; - case AvError::BUFFEREMPTY: - return DEMUXSTR " Buffer is empty"; - case AvError::INVALIDFRAME: - return DEMUXSTR " Invalid frame"; - case AvError::INVALIDSMPLFMT: - return DEMUXSTR " Invalid sample format"; - case AvError::DEMUXEREOF: - return DEMUXSTR " Demuxer reached end of file"; - case AvError::HWDEVICECTXALLOC: - return DEMUXSTR " Error allocating hardware device context"; - case AvError::HWDEVICEGETBUF: - return DEMUXSTR " Error getting hardware buffer"; - case AvError::FILTERGRAPHALLOC: - return DEMUXSTR " Error allocating filter graph"; - case AvError::INOUTALLOC: - return DEMUXSTR " Error allocating in/out"; - case AvError::FILTER_GRAPH_ALLOC: - return DEMUXSTR " Error allocating filter graph"; - case AvError::FILTER_GRAPH_PARSE: - return DEMUXSTR " Error parsing filter graph"; - case AvError::FILTER_GRAPH_CONFIG: - return DEMUXSTR " Error configuring filter graph"; - case AvError::FILTER_GRAPH_CREATE_FILTER: - return DEMUXSTR " Error creating filter"; - case AvError::BUFFERSRC_ADD_FRAME: - return DEMUXSTR " Error adding frame to buffer source"; - case AvError::BUFFERSINK_GET_FRAME: - return DEMUXSTR " Error getting frame from buffer sink"; - case AvError::FILTER_GET_BY_NAME: - return DEMUXSTR " Error getting filter by name"; - case AvError::NOHWCONFIG: - return DEMUXSTR " No hardware configuration"; - case AvError::NOPIXFMT: - return DEMUXSTR " No pixel format"; - case AvError::HWFRAME_TRANSFER: - return DEMUXSTR " Error transferring hardware frame"; - case AvError::FRAMECOPY: - return DEMUXSTR " Error copying frame"; - default: - return DEMUXSTR " Unknown error"; - } -} - -/** - * @brief Get the error code - * - * @return const int - */ -const int AvException::code() const noexcept { - return static_cast(m_errcode); -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/averror.hpp b/src/averror.hpp deleted file mode 100644 index 91ba3e9..0000000 --- a/src/averror.hpp +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @file averror.hpp - * @brief This file includes utilities for handling errors in the AV namespace. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#pragma once - -#include -#include - -namespace AV::Utils { - -/** - * @brief The DemuxerError enum class represents the possible errors that can occur when demuxing a media file. - */ -enum class AvError { - NOERROR, - OPENINPUT, - AVDICTSET, - READFRAME, - PACKETALLOC, - FRAMEALLOC, - FINDSTREAMINFO, - FINDDECODER, - DECODERALLOC, - DECPARAMS, - SENDPACKET, - RECIEVEFRAME, - DECODEREXHAUSTED, - SWSCONTEXT, - SWSSCALE, - AVMALLOC, - IMAGEFILLARRAYS, - SWRALLOCS, - SWRINIT, - AVSAMPLESALLOC, - SWRCONVERT, - NDISENDINSTANCE, - NDIINVALIDPIXFMT, - FRAMEGETBUFFER, - SWRCONFIG, - STREAMCOUNT, - NDISOURCECREATE, - FRAMEREF, - SAMPLECOPY, - BUFFERFULL, - PACKETREF, - BUFFEREMPTY, - INVALIDFRAME, - INVALIDSMPLFMT, - DEMUXEREOF, - HWDEVICECTXALLOC, - HWDEVICEGETBUF, - FILTERGRAPHALLOC, - INOUTALLOC, - FILTER_GRAPH_ALLOC, - FILTER_GRAPH_PARSE, - FILTER_GRAPH_CONFIG, - FILTER_GRAPH_CREATE_FILTER, - FILTER_GET_BY_NAME, - BUFFERSRC_ADD_FRAME, - BUFFERSINK_GET_FRAME, - NOHWCONFIG, - NOPIXFMT, - HWFRAME_TRANSFER, - FRAMECOPY -}; - -/** - * @brief The DemuxerException class represents an exception that is thrown when an error occurs while demuxing a media file. - */ -class AvException : public std::exception { -public: - /** - * @brief Construct a new AvException:: AvException object - * - * @param errcode error code - */ - AvException(AvError errcode); - - /** - * @brief Construct a new AvException:: AvException object - */ - AvException(); - - /** - * @brief Get the error message - * - * @return const char* - */ - const char *what() const noexcept override; - - /** - * @brief Get the error code - * - * @return const int - */ - const int code() const noexcept; - -private: - AvError m_errcode; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/cudaapp.cpp b/src/cudaapp.cpp deleted file mode 100644 index 28a2474..0000000 --- a/src/cudaapp.cpp +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @file cudaapp.cpp - * @brief This file includes the main application class. - * @date 2024-09-19 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#include "cudaapp.hpp" -#include "audioresampler.hpp" -#include "averror.hpp" -#include "macro.hpp" -#include "pixelencoder.hpp" - -extern "C" { - #include - #include -} - -AV::Utils::AvException CudaApp::Run() { - bool packets_exhausted = false; - bool packet_in_decoder = false; - AVPacket *current_packet = nullptr; - - while(1) { - - // Get packet from demuxer - if(!packet_in_decoder && !packets_exhausted) { - auto [packet, packet_err] = _demuxer->ReadFrame(); - if(packet_err.code()) { - if((AV::Utils::AvError)packet_err.code() == AV::Utils::AvError::DEMUXEREOF) { - DEBUG("Packets exhausted"); - packets_exhausted = true; - } else { - ERROR("Failed to read packet: %s", packet_err.what()); - break; - } - } - - current_packet = packet; - } - - if(packets_exhausted) { - while(!_frame_timer.IsEmpty()) { - DEBUG("Draining frames!"); - auto frame = _frame_timer.GetFrame(); - - auto err = _ndi_source->SendFrame(frame); - if(err.code()) { - ERROR("Failed to send frame: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - - break; - } - - if(current_packet->stream_index == _video_stream_index) { - if(!packet_in_decoder) { - auto err = _cuda_video_decoder->FillCudaDecoder(current_packet); - if(err.code()) { - ERROR("Failed to fill video decoder: %s", err.what()); - break; - } - - packet_in_decoder = true; - } - - auto [decoded_frame, decoder_err] = _cuda_video_decoder->Decode(); - if(decoder_err.code()) { - if((AV::Utils::AvError)decoder_err.code() == AV::Utils::AvError::DECODEREXHAUSTED) { - DEBUG("Decoder exhausted"); - packet_in_decoder = false; - continue; - } - - ERROR("Failure in decoder: %s", decoder_err.what()); - break; - } - - auto err = _frame_timer.AddFrame(decoded_frame); - if(err.code()) { - ERROR("Failed to add frame to timer: %s", err.what()); - break; - } - - } else if(current_packet->stream_index == _audio_stream_index) { - if(!packet_in_decoder) { - auto err = _audio_decoder->FillDecoder(current_packet); - if(err.code()) { - ERROR("Failed to fill audio decoder: %s", err.what()); - break; - } - - packet_in_decoder = true; - } - - auto [decoded_frame, decoder_err] = _audio_decoder->Decode(); - if(decoder_err.code()) { - if((AV::Utils::AvError)decoder_err.code() == AV::Utils::AvError::DECODEREXHAUSTED) { - DEBUG("Decoder exhausted"); - packet_in_decoder = false; - continue; - } - - ERROR("Failure in decoder: %s", decoder_err.what()); - break; - } - - auto [resampled_frame, resampled_frame_err] = _audio_resampler->Resample(decoded_frame); - if(resampled_frame_err.code()) { - ERROR("Failure in resampler: %s", resampled_frame_err.what()); - break; - } - - auto err = _frame_timer.AddFrame(resampled_frame); - if (err.code()) { - ERROR("Failed to add frame to timer: %s", err.what()); - break; - } - } - - while(_frame_timer.IsHalf()) { - DEBUG("Sending out frames"); - auto frame = _frame_timer.GetFrame(); - - auto err = _ndi_source->SendFrame(frame); - if(err.code()) { - ERROR("Failed to send frame: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - } - - return AV::Utils::AvError::NOERROR; -} - -CudaAppResult CudaApp::Create(const std::string &ndi_source_name, const std::string &video_file_path) { - AV::Utils::AvException err; - - try { - return {std::shared_ptr(new CudaApp(ndi_source_name, video_file_path)), AV::Utils::AvError::NOERROR}; - } catch(AV::Utils::AvException e) { - err = e; - DEBUG("Error while creating app: %s", e.what()); - } - - return {nullptr, err}; -} - -CudaApp::CudaApp(const std::string &ndi_source_name, const std::string &video_file_path) : _ndi_source_name(ndi_source_name), _video_file_path(video_file_path) { - auto err = _Initialize(); - if(err != AV::Utils::AvError::NOERROR) { - throw err; - } -} - -AV::Utils::AvError CudaApp::_Initialize() { - // Create the demuxer - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create(_video_file_path); - if(demuxer_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Demuxer error: %s", demuxer_err.what()); - return (AV::Utils::AvError)demuxer_err.code(); - } - - _demuxer = std::move(demuxer); - - // Get codecs and stream ids - AVCodecParameters *video_cparam = nullptr, *audio_cparam = nullptr; - int vcount = 0, acount = 0; - AVRational video_time_base{}; - for (auto stream : _demuxer->GetStreamPointers()) { - if(stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { - video_cparam = stream->codecpar; - _video_stream_index = stream->index; - video_time_base = stream->time_base; - vcount++; - } else if(stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { - audio_cparam = stream->codecpar; - _audio_stream_index = stream->index; - acount++; - } - } - - if(acount != 1 && vcount != 1) { - DEBUG("Invalid amount of streams"); - return AV::Utils::AvError::STREAMCOUNT; - } - - // Create the video decoder - auto [cuda_video_decoder, cuda_video_decoder_err] = AV::Utils::CudaDecoder::Create(video_cparam); - if(cuda_video_decoder_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Video decoder error: %s", cuda_video_decoder_err.what()); - return (AV::Utils::AvError)cuda_video_decoder_err.code(); - } - - _cuda_video_decoder = std::move(cuda_video_decoder); - - // Create the audio decoder - auto [audio_decoder, audio_decoder_err] = AV::Utils::Decoder::Create(audio_cparam); - if(audio_decoder_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Audio decoder error: %s", audio_decoder_err.what()); - return (AV::Utils::AvError)audio_decoder_err.code(); - } - - _audio_decoder = std::move(audio_decoder); - - // Create the audio resampler - AV::Utils::AudioResamplerConfig audio_resampler_config{}; - audio_resampler_config.srcsamplerate = audio_cparam->sample_rate; - audio_resampler_config.dstsamplerate = audio_cparam->sample_rate; - audio_resampler_config.srcchannellayout = audio_cparam->ch_layout; - audio_resampler_config.dstchannellayout = AV_CHANNEL_LAYOUT_STEREO; - audio_resampler_config.srcsampleformat = (AVSampleFormat)audio_cparam->format; - audio_resampler_config.dstsampleformat = AV_SAMPLE_FMT_S16; - - auto [audio_resampler, audio_resampler_err] = AV::Utils::AudioResampler::Create(audio_resampler_config); - if(audio_resampler_err.code()) { - DEBUG("Audio resampler error: %s", audio_resampler_err.what()); - return (AV::Utils::AvError)audio_resampler_err.code(); - } - - _audio_resampler = std::move(audio_resampler); - - // Create NDI Source - auto [ndi_source, ndi_source_err] = AV::Utils::AsyncNDISource::Create(_ndi_source_name, video_cparam->framerate); - if(ndi_source_err.code()) { - DEBUG("NDI Source error: %s", ndi_source_err.what()); - return (AV::Utils::AvError)ndi_source_err.code(); - } - - _ndi_source = std::move(ndi_source); - - return AV::Utils::AvError::NOERROR; -} \ No newline at end of file diff --git a/src/cudaapp.hpp b/src/cudaapp.hpp deleted file mode 100644 index aae55c3..0000000 --- a/src/cudaapp.hpp +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @file cudaapp.hpp - * @brief This file includes the main application class. - * @date 2024-09-19 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#pragma once - -// Local includes -#include "averror.hpp" -#include "demuxer.hpp" -#include "asyncndisource.hpp" -#include "decoder.hpp" -#include "pixelencoder.hpp" -#include "audioresampler.hpp" -#include "frametimer.hpp" -#include "cudadecoder.hpp" -#include "app.hpp" - -extern "C" { - #include -} - -// Standard C++ includes -#include -#include - -class CudaApp; -using CudaAppResult = std::pair, AV::Utils::AvException>; - -class CudaApp : public App { -private: - CudaApp(const std::string &ndi_source_name, const std::string &video_file_path); - AV::Utils::AvError _Initialize(); - -public: - ~CudaApp() = default; - - // Factory - static CudaAppResult Create(const std::string &ndi_source_name, const std::string &video_file_path); - - AV::Utils::AvException Run() override; - -private: - std::string _ndi_source_name; - std::string _video_file_path; - std::shared_ptr _demuxer; - std::shared_ptr _audio_decoder; - std::shared_ptr _cuda_video_decoder; - std::shared_ptr _audio_resampler; - std::shared_ptr _ndi_source; - - AV::Utils::FrameTimer _frame_timer; - - int _video_stream_index = -1; - int _audio_stream_index = -1; -}; \ No newline at end of file diff --git a/src/cudadecoder.cpp b/src/cudadecoder.cpp deleted file mode 100644 index 4fb9878..0000000 --- a/src/cudadecoder.cpp +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @file cudadecoder.cpp - * @brief This file includes the CudaDecoder class. - * @version 1.0 - * @date 2024-09-19 - * @author Matthew Todd Geiger - */ - -#include "cudadecoder.hpp" -#include "macro.hpp" - -#include - -namespace AV::Utils { - -/** - * @brief Get the frame rate of the decoder - * - * @return CodecFrameRate - */ -CodecFrameRate CudaDecoder::GetFrameRate() { - FUNCTION_CALL_DEBUG(); - - return {m_codec->framerate.num, m_codec->framerate.den}; -} - -/** - * @brief Fill the decoder with a packet - * - * @param packet The packet to fill the decoder with - * @return AvException - */ -AvException CudaDecoder::FillCudaDecoder(AVPacket *packet) { - FUNCTION_CALL_DEBUG(); - - int ret = avcodec_send_packet(m_codec, packet); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return AvException(AvError::SENDPACKET); - } - - DEBUG("CudaDecoder Filled"); - - return AvException(AvError::NOERROR); -} - -/** - * @brief Decode a packet - * - * @return CudaDecoderOutput - */ -CudaDecoderOutput CudaDecoder::Decode() { - FUNCTION_CALL_DEBUG(); -#ifdef _DEBUG - // Profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - av_frame_unref(m_last_frame); - - AVFrame *tmp_frame = av_frame_alloc(); - - // Recieve frame from decoder - int ret = avcodec_receive_frame(m_codec, tmp_frame); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { - DEBUG("CudaDecoder exhausted"); - return {nullptr, AvException(AvError::DECODEREXHAUSTED)}; - } else if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::RECIEVEFRAME)}; - } - - // Print out tmp frame info - DEBUG("Temp Frame: %dx%d, format: %s, pts: %ld", tmp_frame->width, tmp_frame->height, av_get_pix_fmt_name((AVPixelFormat)tmp_frame->format), tmp_frame->pts); - - // Load from off gpu if needed - ret = av_hwframe_transfer_data(m_last_frame, tmp_frame, 0); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::HWFRAME_TRANSFER)}; - } - - m_last_frame->pts = tmp_frame->pts; - - av_frame_free(&tmp_frame); - -#ifdef _DEBUG - // Profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("CUDA Decode time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - // Print frame info - DEBUG("Frame: %dx%d, format: %s", m_last_frame->width, m_last_frame->height, av_get_pix_fmt_name((AVPixelFormat)m_last_frame->format)); - DEBUG("Time base: %d/%d", m_last_frame->time_base.num, m_last_frame->time_base.den); - - // Print some pixel data from Y and UV - uint8_t *y_data = m_last_frame->data[0]; - uint8_t *uv_data = m_last_frame->data[1]; - DEBUG("First Y bytes: %02x %02x %02x %02x", y_data[0], y_data[1], y_data[2], y_data[3]); - DEBUG("First UV bytes: %02x %02x %02x %02x", uv_data[0], uv_data[1], uv_data[2], uv_data[3]); - - return {m_last_frame, AvException(AvError::NOERROR)}; -} - -/** - * @brief Create a CudaDecoder object - * - * @param codecpar codec parameters - * @return CudaDecoderResult - */ -CudaDecoderResult CudaDecoder::Create(AVCodecParameters *codecpar) { - FUNCTION_CALL_DEBUG(); - - AvException error; - - // Create a new decoder object, return nullopt if error - try { - return {std::unique_ptr(new CudaDecoder(codecpar)), error}; - } catch (AvException e) { - error = e; - DEBUG("CudaDecoder error: %s", error.what()); - } - - return {nullptr, error}; -} - -/** - * @brief Construct a new CudaDecoder:: CudaDecoder object - * - * @param codec_id codec ID - */ -CudaDecoder::CudaDecoder(AVCodecParameters *codecpar) : m_codecpar(codecpar) { - FUNCTION_CALL_DEBUG(); - - AvError err = m_Initialize(); - if (err != AvError::NOERROR) { - throw err; - } -} - - -/** - * @brief Destroy the CudaDecoder:: CudaDecoder object - */ -CudaDecoder::~CudaDecoder() { - FUNCTION_CALL_DEBUG(); - - // Free the frame - if (m_last_frame) { - av_frame_free(&m_last_frame); - DEBUG("av_frame_free called"); - } - - // Close and free the decoder context - if (m_codec) { - avcodec_close(m_codec); - avcodec_free_context(&m_codec); - DEBUG("avcodec_free_context called"); - } - - if(m_hw_device_ctx) { - av_buffer_unref(&m_hw_device_ctx); - } -} -/** - * @brief Initialize the decoder - * - * @return AvError - */ -AvError CudaDecoder::m_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Create hardware device - int ret = av_hwdevice_ctx_create(&m_hw_device_ctx, AV_HWDEVICE_TYPE_CUDA, nullptr, nullptr, 0); - if (ret < 0) { - DEBUG("av_hwdevice_ctx_create failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::HWDEVICECTXALLOC; - } - - // Find the appropriate decoder - const AVCodec *codec = avcodec_find_decoder(m_codecpar->codec_id); - if (!codec) { - DEBUG("avcodec_find_decoder failed"); - return AvError::FINDDECODER; - } - - // Allocate the codec context - m_codec = avcodec_alloc_context3(codec); - if (!m_codec) { - DEBUG("avcodec_alloc_context3 failed"); - return AvError::DECODERALLOC; - } - - // Copy the codec parameters - ret = avcodec_parameters_to_context(m_codec, m_codecpar); - if (ret < 0) { - DEBUG("avcodec_parameters_to_context failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::DECPARAMS; - } - - // Attach hardware device to decoder - m_codec->hw_device_ctx = av_buffer_ref(m_hw_device_ctx); - - // Open the decoder context - ret = avcodec_open2(m_codec, codec, nullptr); - if (ret < 0) { - DEBUG("avcodec_open2 failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::DECPARAMS; - } - - // Allocate frame - m_last_frame = av_frame_alloc(); - if (!m_last_frame) { - DEBUG("av_frame_alloc failed"); - return AvError::FRAMEALLOC; - } - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/cudadecoder.hpp b/src/cudadecoder.hpp deleted file mode 100644 index 8b5d279..0000000 --- a/src/cudadecoder.hpp +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @file cudadecoder.hpp - * @brief This file includes the CudaDecoder class. - * @version 1.0 - * @date 2024-09-19 - * @author Matthew Todd Geiger - */ - -#pragma once - -// Local dependencies -#include "averror.hpp" -#include "demuxer.hpp" - -// 3rd party dependencies -extern "C" { -#include -#include -#include -} - -// Standard C++ dependencies -#include -#include - -namespace AV::Utils { - -class CudaDecoder; -using CudaDecoderResult = std::pair, const AvException>; -using CudaDecoderOutput = std::pair; -using CodecFrameRate = std::pair; - -/** - * @brief The CudaDecoder class provides utilities for decoding media files. - */ -class CudaDecoder { -private: - CudaDecoder(AVCodecParameters *codecpar); - -public: - /** - * @brief Destroy the CudaDecoder:: CudaDecoder object - */ - ~CudaDecoder(); - - /** - * @brief Create a CudaDecoder object - * - * @param codecpar codec parameters - * @return CudaDecoderResult - */ - static CudaDecoderResult Create(AVCodecParameters *codecpar); - - // Setters - /** - * @brief Fill the CudaDecoder with a packet - * - * @param packet The packet to fill the CudaDecoder with - * @return AvException - */ - AvException FillCudaDecoder(AVPacket *packet); - - /** - * @brief Decode a packet. If the CudaDecoder needs another packet, it will return - * an error code of CudaDecoderEXHAUSTED - * - * @return CudaDecoderOutput - */ - CudaDecoderOutput Decode(); - - // Getters - /** - * @brief Get the frame rate of the CudaDecoder - * - * @return CodecFrameRate - */ - CodecFrameRate GetFrameRate(); - - AVPixelFormat GetPixelFormat(); - -private: - AvError m_Initialize(); - - // Store the codec parameters - AVCodecParameters *m_codecpar = nullptr; - - // This is the codec context that ffmpeg uses to decode the media file - AVCodecContext *m_codec = nullptr; - - // This is the last frame that was decoded - AVFrame *m_last_frame = nullptr; - - // Reference to the hardware device context - AVBufferRef *m_hw_device_ctx = nullptr; - - AVPixelFormat m_hw_pix_fmt = AV_PIX_FMT_NONE; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/cudafilter.cpp b/src/cudafilter.cpp deleted file mode 100644 index a8794df..0000000 --- a/src/cudafilter.cpp +++ /dev/null @@ -1,158 +0,0 @@ -/** - * @file cudafilter.cpp - * @brief CudaFilter class - * @version 1.0 - * @date 2024-09-19 - * @author Matthew Todd Geiger - */ - -#include "cudafilter.hpp" -#include "macro.hpp" - -namespace AV::Utils { - -CudaFilterResult CudaFilter::Create(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base, AVPixelFormat output_format) { - FUNCTION_CALL_DEBUG(); - - try { - return {std::unique_ptr(new CudaFilter(filter_description, codec_parameters, time_base, output_format)), AvError::NOERROR}; - } catch(const AvException &e) { - return {nullptr, e}; - } -} - -CudaFilter::CudaFilter(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base, AVPixelFormat output_format) { - FUNCTION_CALL_DEBUG(); - - AvError error = _Initialize(filter_description, codec_parameters, time_base, output_format); - if (error != AvError::NOERROR) { - throw AvException(error); - } -} - -CudaFilter::~CudaFilter() { - FUNCTION_CALL_DEBUG(); - - if(_filter_graph) { - avfilter_graph_free(&_filter_graph); - } -} - -CudaFilterOutput CudaFilter::FilterFrame(AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - std::vector filtered_frames; - - // Create a frame - AVFrame *filtered_frame = av_frame_alloc(); - if (!filtered_frame) { - return {filtered_frames, AvError::FRAMEALLOC}; - } - - // Set the frame parameters - int ret = av_buffersrc_add_frame(_buffersrc_ctx, frame); - if (ret < 0) { - return {filtered_frames, AvError::BUFFERSRC_ADD_FRAME}; - } - - // Pull filtered frames from the sink - while (true) { - ret = av_buffersink_get_frame(_buffersink_ctx, filtered_frame); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { - break; - } - - if (ret < 0) { - return {filtered_frames, AvError::BUFFERSINK_GET_FRAME}; - } - - // Copy the frame - AVFrame *new_frame = av_frame_clone(filtered_frame); - if (!new_frame) { - return {filtered_frames, AvError::FRAMEALLOC}; - } - - filtered_frames.push_back(new_frame); - av_frame_unref(filtered_frame); - } - - av_frame_free(&filtered_frame); - return {filtered_frames, AvError::NOERROR}; -} - -AvError CudaFilter::_Initialize(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base, AVPixelFormat output_format) { - FUNCTION_CALL_DEBUG(); - - // Create filter graph - _filter_graph = avfilter_graph_alloc(); - if (_filter_graph == nullptr) { - return AvError::FILTERGRAPHALLOC; - } - - // Create input filter - const AVFilter *buffersrc = avfilter_get_by_name("buffer"); - if (buffersrc == nullptr) { - return AvError::FILTER_GET_BY_NAME; - } - - // Create output filter - const AVFilter *buffersink = avfilter_get_by_name("buffersink"); - if (buffersink == nullptr) { - return AvError::FILTER_GET_BY_NAME; - } - - // Define input filter parameters - char args[512]; - snprintf(args, sizeof(args), "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d/%d", - codec_parameters->width, codec_parameters->height, codec_parameters->format, - time_base.num, time_base.den, codec_parameters->sample_aspect_ratio.num, codec_parameters->sample_aspect_ratio.den); - - // Create input filter - int ret = avfilter_graph_create_filter(&_buffersrc_ctx, buffersrc, "in", args, nullptr, _filter_graph); - if (ret < 0) { - return AvError::FILTER_GRAPH_CREATE_FILTER; - } - - // Create output filter - ret = avfilter_graph_create_filter(&_buffersink_ctx, buffersink, "out", nullptr, nullptr, _filter_graph); - if (ret < 0) { - return AvError::FILTER_GRAPH_CREATE_FILTER; - } - - // Set output pixel formats - enum AVPixelFormat pix_fmts[] = {output_format, AV_PIX_FMT_NONE}; - if ((ret = av_opt_set_int_list(_buffersink_ctx, "pix_fmts", pix_fmts, AV_PIX_FMT_NONE, AV_OPT_SEARCH_CHILDREN)) < 0) { - return AvError::FILTER_GRAPH_CONFIG; - } - - // Define input and output filter parameters - AVFilterInOut *outputs = avfilter_inout_alloc(); - AVFilterInOut *inputs = avfilter_inout_alloc(); - - // Set input filter parameters - outputs->name = av_strdup("in"); - outputs->filter_ctx = _buffersrc_ctx; - outputs->pad_idx = 0; - outputs->next = nullptr; - - // Set output filter parameters - inputs->name = av_strdup("out"); - inputs->filter_ctx = _buffersink_ctx; - inputs->pad_idx = 0; - inputs->next = nullptr; - - // Add input and output filters to the graph - if ((ret = avfilter_graph_parse_ptr(_filter_graph, filter_description.c_str(), &inputs, &outputs, nullptr)) < 0) { - return AvError::FILTER_GRAPH_PARSE; - } - - // Configure graph - ret = avfilter_graph_config(_filter_graph, nullptr); - if (ret < 0) { - return AvError::FILTER_GRAPH_CONFIG; - } - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/cudafilter.hpp b/src/cudafilter.hpp deleted file mode 100644 index a0080bd..0000000 --- a/src/cudafilter.hpp +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file cudafilter.hpp - * @brief CudaFilter class - * @version 1.0 - * @date 2024-09-19 - * @author Matthew Todd Geiger - */ - -#pragma once - -#include "averror.hpp" - -extern "C" { -#include -#include -#include -#include -#include -} - -#include -#include -#include - -namespace AV::Utils { - -class CudaFilter; -using CudaFilterResult = std::pair, const AvException>; -using CudaFilterOutput = std::pair, const AvException>; - -class CudaFilter { -private: - CudaFilter() = delete; - CudaFilter(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base, AVPixelFormat output_format); - AvError _Initialize(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base, AVPixelFormat output_format); - -public: - ~CudaFilter(); - static CudaFilterResult Create(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base, AVPixelFormat output_format); - CudaFilterOutput FilterFrame(AVFrame *frame); - -private: - AVFilterGraph *_filter_graph = nullptr; - AVFilterContext *_buffersrc_ctx = nullptr; - AVFilterContext *_buffersink_ctx = nullptr; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/decoder.cpp b/src/decoder.cpp deleted file mode 100644 index 9a257af..0000000 --- a/src/decoder.cpp +++ /dev/null @@ -1,181 +0,0 @@ -/** - * @file decoder.cpp - * @brief This file includes utilities for decoding media files. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#include "decoder.hpp" -#include "macro.hpp" - -#include - -namespace AV::Utils { - -/** - * @brief Get the frame rate of the decoder - * - * @return CodecFrameRate - */ -CodecFrameRate Decoder::GetFrameRate() { - FUNCTION_CALL_DEBUG(); - - return {m_codec->framerate.num, m_codec->framerate.den}; -} - -/** - * @brief Fill the decoder with a packet - * - * @param packet The packet to fill the decoder with - * @return AvException - */ -AvException Decoder::FillDecoder(AVPacket *packet) { - FUNCTION_CALL_DEBUG(); - - int ret = avcodec_send_packet(m_codec, packet); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return AvException(AvError::SENDPACKET); - } - - DEBUG("Decoder Filled"); - - return AvException(AvError::NOERROR); -} - -/** - * @brief Decode a packet - * - * @return DecoderOutput - */ -DecoderOutput Decoder::Decode() { - FUNCTION_CALL_DEBUG(); -#ifdef _DEBUG - // Profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - // Recieve frame from decoder - int ret = avcodec_receive_frame(m_codec, m_last_frame); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { - DEBUG("Decoder exhausted"); - return {nullptr, AvException(AvError::DECODEREXHAUSTED)}; - } else if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::RECIEVEFRAME)}; - } - -#ifdef _DEBUG - // Profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("Decode time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return {m_last_frame, AvException(AvError::NOERROR)}; -} - -/** - * @brief Create a Decoder object - * - * @param codecpar codec parameters - * @return DecoderResult - */ -DecoderResult Decoder::Create(AVCodecParameters *codecpar) { - FUNCTION_CALL_DEBUG(); - - AvException error; - - // Create a new decoder object, return nullopt if error - try { - return {std::unique_ptr(new Decoder(codecpar)), error}; - } catch (AvException e) { - error = e; - DEBUG("Decoder error: %s", error.what()); - } - - return {nullptr, error}; -} - -/** - * @brief Construct a new Decoder:: Decoder object - * - * @param codec_id codec ID - */ -Decoder::Decoder(AVCodecParameters *codecpar) : m_codecpar(codecpar) { - FUNCTION_CALL_DEBUG(); - - AvError err = m_Initialize(); - if (err != AvError::NOERROR) { - throw err; - } -} - - -/** - * @brief Destroy the Decoder:: Decoder object - */ -Decoder::~Decoder() { - FUNCTION_CALL_DEBUG(); - - // Free the frame - if (m_last_frame) { - av_frame_free(&m_last_frame); - DEBUG("av_frame_free called"); - } - - // Close and free the decoder context - if (m_codec) { - avcodec_close(m_codec); - avcodec_free_context(&m_codec); - DEBUG("avcodec_free_context called"); - } -} -/** - * @brief Initialize the decoder - * - * @return AvError - */ -AvError Decoder::m_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Find the appropriate decoder - const AVCodec *codec = avcodec_find_decoder(m_codecpar->codec_id); - if (!codec) { - DEBUG("avcodec_find_decoder failed"); - return AvError::FINDDECODER; - } - - // Allocate the codec context - m_codec = avcodec_alloc_context3(codec); - if (!m_codec) { - DEBUG("avcodec_alloc_context3 failed"); - return AvError::DECODERALLOC; - } - - // Copy the codec parameters - int ret = avcodec_parameters_to_context(m_codec, m_codecpar); - if (ret < 0) { - DEBUG("avcodec_parameters_to_context failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::DECPARAMS; - } - - // Open the decoder context - ret = avcodec_open2(m_codec, codec, nullptr); - if (ret < 0) { - DEBUG("avcodec_open2 failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::DECPARAMS; - } - - // Allocate frame - m_last_frame = av_frame_alloc(); - if (!m_last_frame) { - DEBUG("av_frame_alloc failed"); - return AvError::FRAMEALLOC; - } - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/decoder.hpp b/src/decoder.hpp deleted file mode 100644 index 912da1d..0000000 --- a/src/decoder.hpp +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @file decoder.hpp - * @brief This file includes utilities for decoding media files. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#pragma once - -// Local dependencies -#include "averror.hpp" -#include "demuxer.hpp" - -// 3rd party dependencies -extern "C" { -#include -} - -// Standard C++ dependencies -#include -#include - -namespace AV::Utils { - -class Decoder; -using DecoderResult = std::pair, const AvException>; -using DecoderOutput = std::pair; -using CodecFrameRate = std::pair; - -/** - * @brief The Decoder class provides utilities for decoding media files. - */ -class Decoder { -private: - Decoder(AVCodecParameters *codecpar); - -public: - /** - * @brief Destroy the Decoder:: Decoder object - */ - ~Decoder(); - - /** - * @brief Create a Decoder object - * - * @param codecpar codec parameters - * @return DecoderResult - */ - static DecoderResult Create(AVCodecParameters *codecpar); - - // Setters - /** - * @brief Fill the decoder with a packet - * - * @param packet The packet to fill the decoder with - * @return AvException - */ - AvException FillDecoder(AVPacket *packet); - - /** - * @brief Decode a packet. If the decoder needs another packet, it will return - * an error code of DECODEREXHAUSTED - * - * @return DecoderOutput - */ - DecoderOutput Decode(); - - // Getters - /** - * @brief Get the frame rate of the decoder - * - * @return CodecFrameRate - */ - CodecFrameRate GetFrameRate(); - -private: - AvError m_Initialize(); - - // Store the codec parameters - AVCodecParameters *m_codecpar = nullptr; - - // This is the codec context that ffmpeg uses to decode the media file - AVCodecContext *m_codec = nullptr; - - // This is the last frame that was decoded - AVFrame *m_last_frame = nullptr; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/demuxer.cpp b/src/demuxer.cpp deleted file mode 100644 index 4400b41..0000000 --- a/src/demuxer.cpp +++ /dev/null @@ -1,154 +0,0 @@ -/** - * @file demuxer.cpp - * @brief This file includes utilities for demuxing media files. - * @date 2024-09-04 - * @author Matthew Todd Geiger - */ - -#include "demuxer.hpp" -#include "macro.hpp" - -/** - * @brief The AV::Utils namespace contains utilities for audio and video processing. - */ -namespace AV::Utils { - -/** - * @brief Get the AVStream pointers that were generated by ffmpeg. - * This is useful for getting the stream information inexpensively - * but keep in mind that the AVStream pointers are owned by the Demuxer object. - * - * @return std::vector - */ -std::vector Demuxer::GetStreamPointers() { - FUNCTION_CALL_DEBUG(); - - std::vector streams; - - for (unsigned int i = 0; i < m_format_ctx->nb_streams; i++) { - streams.push_back(m_format_ctx->streams[i]); - } - - return streams; -} - -/** - * @brief Read the next frame from the media file. - * - * @return ReadFrameResult - */ -ReadFrameResult Demuxer::ReadFrame() { - FUNCTION_CALL_DEBUG(); - - // Reset the packet to default values - av_packet_unref(m_packet); - - // Read the next frame - int ret = av_read_frame(m_format_ctx, m_packet); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - - // If eof return a different error - if(ret == AVERROR_EOF) { - return {nullptr, AvError::DEMUXEREOF}; - } - - return {nullptr, AvException(AvError::READFRAME)}; - } - - return {m_packet, AvException(AvError::NOERROR)}; -} - -/** - * @brief Create a Demuxer object - * - * @param path path to media file - * @return DemuxerResult - */ -DemuxerResult Demuxer::Create(const std::string &path) { - FUNCTION_CALL_DEBUG(); - - AvException error; - - // Catching errors from the constructor is important. - // I don't trust software engineers to do it correctly. - // So I'm going to do it for them. - // You're welcome. - try { - return {std::unique_ptr(new Demuxer(path)), AvException(AvError::NOERROR)}; - } catch (AvException err) { - DEBUG("Demuxer error: %s", err.what()); - error = err; - } - - return {nullptr, error}; -} - -/** - * @brief Construct a new Demuxer:: Demuxer object - * - * @param path path to media file - */ -Demuxer::Demuxer(const std::string &path) : m_path(path) { - FUNCTION_CALL_DEBUG(); - - AvError err = m_Initialize(); - if (err != AvError::NOERROR) { - throw err; // you can throw the error code because the compiler is smart enough to call the constructor - } -} - -/** - * @brief Destroy the Demuxer:: Demuxer object - */ -Demuxer::~Demuxer() { - FUNCTION_CALL_DEBUG(); - - // Free the packet - if (m_packet != nullptr) { - av_packet_free(&m_packet); - DEBUG("av_packet_free called"); - } - - // Destroy the format context - if (m_format_ctx != nullptr) { - avformat_close_input(&m_format_ctx); - DEBUG("avformat_close_input called"); - } -} - -/** - * @brief Initialize the demuxer - * - * @return AvError - */ -AvError Demuxer::m_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Create the format context - int ret = avformat_open_input(&m_format_ctx, m_path.c_str(), nullptr, nullptr); - if (ret < 0) { - DEBUG("avformat_open_input failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::OPENINPUT; - } - - // Create the packet - m_packet = av_packet_alloc(); - if (!m_packet) { - DEBUG("av_packet_alloc failed"); - return AvError::PACKETALLOC; - } - - // Load the stream information into the format context - ret = avformat_find_stream_info(m_format_ctx, nullptr); - if (ret < 0) { - DEBUG("avformat_find_stream_info failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::FINDSTREAMINFO; - } - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/demuxer.hpp b/src/demuxer.hpp deleted file mode 100644 index cc09f58..0000000 --- a/src/demuxer.hpp +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @file demuxer.hpp - * @brief This file includes utilities for demuxing media files. - * @date 2024-09-02 - * @author Matthew Todd Geiger - */ - -#pragma once - -// Local dependencies -#include "averror.hpp" - -// 3rd party dependencies -extern "C" { -#include -} - -// Standard C++ dependencies -#include -#include -#include -#include - -namespace AV::Utils { - -// Forward declarations and type definitions -class Demuxer; -using DemuxerResult = std::pair, const AvException>; -using ReadFrameResult = std::pair; - -/** - * @brief The Demuxer class provides utilities for demuxing media files. - * This class is responsible for reading packets from a media file and providing - * some basic information about the streams. - */ -class Demuxer { -private: - Demuxer(const std::string &path); // I'm keeping the constructor private so that it can only be called by the factory method - -public: - /** - * @brief Destroy the Demuxer:: Demuxer object - */ - ~Demuxer(); - - // For now we'll disable copying and assignment. - // In the future we will make sure that these are implemented correctly. - Demuxer(const Demuxer &) = delete; - Demuxer &operator=(const Demuxer &) = delete; - - // Factory methods - - /** - * @brief Create a Demuxer object - * - * @param path path to media file - * @return DemuxerResult - */ - static DemuxerResult Create(const std::string &path); - - // Getters - - /** - * @brief Read the next frame from the media file. - * - * @return ReadFrameResult - */ - ReadFrameResult ReadFrame(); - - /** - * @brief Get the AVStream pointers that were generated by ffmpeg. - * This is useful for getting the stream information inexpensively - * but keep in mind that the AVStream pointers are owned by the Demuxer object. - * - * @return std::vector - */ - std::vector GetStreamPointers(); - -private: - AvError m_Initialize(); - - // Store the path to the media file - std::string m_path; - - // This is the api context that ffmpeg uses to read the media file - AVFormatContext *m_format_ctx = nullptr; - - // This is the packet that we will use to read frames from the media file - AVPacket *m_packet = nullptr; -}; - -} // namespace AV::Utils diff --git a/src/frame.cpp b/src/frame.cpp deleted file mode 100644 index d05559e..0000000 --- a/src/frame.cpp +++ /dev/null @@ -1,153 +0,0 @@ -/** - * @file frame.cpp - * @brief AVFrame related functions - * @version 1.0 - * @date 2024-09-16 - * @author Matthew Todd Geiger - */ - -#include "frame.hpp" -#include "macro.hpp" - -#include - -extern "C" { -#include -} - -namespace AV::Utils { - -/** - * @brief Copy an AVFrame - * @param frame The frame to copy - * @return AVFrame* The copied frame - */ -AVFrame *CopyFrame(AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - AVFrame *new_frame = av_frame_alloc(); - if (!new_frame) { - return nullptr; - } - - if (av_frame_ref(new_frame, frame) < 0) { - av_frame_free(&new_frame); - return nullptr; - } - - return new_frame; -} - -/** - * @brief Combine all planes of NV12 into single buffer - * - * @param frame The frame to create the buffer from - * @return uint8_t* The NV12 buffer - */ -uint8_t *ConvertNV12BufferedToNV12(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - // Calculate correct buffer size - uint target_y_size = frame->width * frame->height; - uint target_uv_size = (frame->width / 2) * (frame->height / 2) * 2; // Each UV plane is half width and height, but interleaved so * 2 - uint target_size = target_y_size + target_uv_size; - - // Allocate new buffer - uint8_t *target_buffer = new uint8_t[target_size]; - - // Copy Y plane - memcpy(target_buffer, frame->data[0], target_y_size); - - // Fix UV plane and copy into the correct structure - uint8_t *src_uv = frame->data[1]; // UV plane from source frame - uint8_t *dst_uv = target_buffer + target_y_size; // Destination for UV in target buffer - - // Loop through the source UV data and remove null bytes between U and V - for (int i = 0, j = 0; i < target_uv_size / 2; i++, j += 2) { - dst_uv[i * 2] = src_uv[j]; // Copy U byte - dst_uv[i * 2 + 1] = src_uv[j + 1]; // Copy V byte - } - - return target_buffer; -} - -/** - * @brief Print the picture type - * - * @param type The picture type - */ -void PrintPictType(AVPictureType type) { - FUNCTION_CALL_DEBUG(); - - switch (type) { - case AV_PICTURE_TYPE_I: - PRINT("Frame Type: I"); - break; - case AV_PICTURE_TYPE_P: - PRINT("Frame Type: P"); - break; - case AV_PICTURE_TYPE_B: - PRINT("Frame Type: B"); - break; - case AV_PICTURE_TYPE_S: - PRINT("Frame Type: S"); - break; - case AV_PICTURE_TYPE_SI: - PRINT("Frame Type: SI"); - break; - case AV_PICTURE_TYPE_NONE: - PRINT("Frame Type: NONE"); - break; - case AV_PICTURE_TYPE_SP: - PRINT("Frame Type: SP"); - break; - default: - PRINT("Unkown Frame Type: %d", type); - break; - } -} - -uint8_t *CombinePlanesNV12(const AVFrame *frame, uint planes) { - FUNCTION_CALL_DEBUG(); - -#ifdef _DEBUG - // profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - // Get width and height of each plane - uint widths[2]; - uint heights[2]; - - widths[0] = frame->width; - heights[0] = frame->height; - - widths[1] = frame->width; - heights[1] = frame->height / 2; - - // Calculate correct buffer size - uint target_size = 0; - for(uint i = 0; i < planes; i++) { - target_size += frame->linesize[i] * heights[i]; - } - - // Allocate new buffer - uint8_t *target_buffer = new uint8_t[target_size]; - - // Copy each plane into the target buffer - uint offset = 0; - for(uint i = 0; i < planes; i++) { - memcpy(target_buffer + offset, frame->data[i], frame->linesize[i] * heights[i]); - offset += frame->linesize[i] * heights[i]; - } - -#ifdef _DEBUG - // profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("Combine planes time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return target_buffer; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/frame.hpp b/src/frame.hpp deleted file mode 100644 index 86e917e..0000000 --- a/src/frame.hpp +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @file frame.hpp - * @brief AVFrame related functions - * @version 1.0 - * @date 2024-09-16 - * @author Matthew Todd Geiger - */ - -#pragma once - -// 3rd Party Dependencies -extern "C" { -#include -} - -namespace AV::Utils { - -/** - * @brief Copy an AVFrame - * @param frame The frame to copy - * @return AVFrame* The copied frame - */ -AVFrame *CopyFrame(AVFrame *frame); - -/** - * @brief Create an NV12 buffer from an AVFrame - * @param frame The frame to create the buffer from - * @return uint8_t* The NV12 buffer - */ -uint8_t *ConvertNV12BufferedToNV12(const AVFrame *frame); - -/** - * - */ -void PrintPictType(AVPictureType type); - -uint8_t *CombinePlanesNV12(const AVFrame *frame, uint planes); - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/frames.cpp b/src/frames.cpp new file mode 100644 index 0000000..2528c67 --- /dev/null +++ b/src/frames.cpp @@ -0,0 +1,30 @@ +/** + * @file frames.cpp + * @brief Uncompressed frames in a layout NDI accepts directly. + */ + +#include "frames.hpp" + +namespace ndistreamer { + +const char *ToString(VideoFourCC fourcc) { + switch (fourcc) { + case VideoFourCC::UYVY: + return "UYVY"; + case VideoFourCC::NV12: + return "NV12"; + case VideoFourCC::I420: + return "I420"; + case VideoFourCC::BGRA: + return "BGRA"; + case VideoFourCC::BGRX: + return "BGRX"; + case VideoFourCC::RGBA: + return "RGBA"; + case VideoFourCC::RGBX: + return "RGBX"; + } + return "?"; +} + +} // namespace ndistreamer diff --git a/src/frames.hpp b/src/frames.hpp new file mode 100644 index 0000000..b46fa91 --- /dev/null +++ b/src/frames.hpp @@ -0,0 +1,66 @@ +/** + * @file frames.hpp + * @brief Uncompressed frames in a layout NDI accepts directly. + */ + +#pragma once + +#include "av/ffmpeg.hpp" + +#include +#include + +namespace ndistreamer { + +/** + * @brief NDI video pixel layouts produced by the pipeline. + * Values are the NDI FourCC codes, so they can be passed to the SDK unchanged. + */ +enum class VideoFourCC : uint32_t { + UYVY = 0x59565955, // 'U','Y','V','Y' + NV12 = 0x3231564E, // 'N','V','1','2' + I420 = 0x30323449, // 'I','4','2','0' + BGRA = 0x41524742, // 'B','G','R','A' + BGRX = 0x58524742, // 'B','G','R','X' + RGBA = 0x41424752, // 'R','G','B','A' + RGBX = 0x58424752, // 'R','G','B','X' +}; + +const char *ToString(VideoFourCC fourcc); + +/** + * @brief A video frame ready to hand to NDI. + * + * The pixel memory is owned by @c storage (an AVFrame reference), so the frame can be moved between + * threads and kept alive while NDI processes it asynchronously. Multi-planar layouts (NV12, I420) + * are contiguous, as NDI requires. + */ +struct VideoFrame { + av::FramePtr storage; + uint8_t *data = nullptr; + int stride = 0; ///< Bytes per line of the first plane. + int width = 0; + int height = 0; + VideoFourCC fourcc = VideoFourCC::UYVY; + int frame_rate_num = 30000; + int frame_rate_den = 1001; + float aspect_ratio = 0.0f; ///< Display aspect ratio; 0 means square pixels. + bool interlaced = false; + int64_t pts_us = 0; + int64_t duration_us = 0; +}; + +/** + * @brief A block of planar 32-bit float audio ready to hand to NDI. + * Channel @c c starts at samples[c * channel_stride]. + */ +struct AudioFrame { + std::vector samples; + int sample_rate = 0; + int channels = 0; + int samples_per_channel = 0; + int channel_stride = 0; ///< Distance between channel starts, in samples. + int64_t pts_us = 0; +}; + +} // namespace ndistreamer diff --git a/src/frametimer.cpp b/src/frametimer.cpp deleted file mode 100644 index 1ce68b3..0000000 --- a/src/frametimer.cpp +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @file frametimer.cpp - * @brief FrameTimer class implementation file - * @version 1.0 - * @date 2024-09-16 - * @author Matthew Todd Geiger - */ - -#include "frametimer.hpp" -#include "frame.hpp" -#include "macro.hpp" - -#include -#include - -namespace AV::Utils { - -/** - * @brief Construct a new FrameTimer object - * - * @param capacity The capacity of the FrameTimer - */ -FrameTimer::FrameTimer(const int capacity) { - FUNCTION_CALL_DEBUG(); - - _capacity = capacity; -} - -/** - * @brief Destroy the FrameTimer object - */ -FrameTimer::~FrameTimer() { - FUNCTION_CALL_DEBUG(); - - // Free all frames - for (auto frame : _frames) { - av_frame_free(&frame); - } -} - -/** - * @brief Add a frame to the FrameTimer - * Keep in mind that a copy(referenced) of the frame is made. - * - * @param frame The frame to add - * @return AvException - */ -AvException FrameTimer::AddFrame(AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - if(_frames.size() >= _capacity) { - return AvError::BUFFERFULL; - } - - // Check if frame has valid time_base and pts - if (frame->pts == AV_NOPTS_VALUE || frame->time_base.den == 0) { - PRINT("Invalid Frame Info: PTS: %ld, Time Base: %d/%d", frame->pts, frame->time_base.num, frame->time_base.den); - - // What type of frame is this? - PrintPictType(frame->pict_type); - - return AvError::INVALIDFRAME; - } - - // Copy the frame - AVFrame *new_frame = CopyFrame(frame); - if (!new_frame) { - return AvError::FRAMEALLOC; - } - - // Add the frame to the vector - _frames.push_back(new_frame); - - // Reorder frames - auto err = _ReorderFrames(); - if (err != AvError::NOERROR) { - return err; - } - - return AvError::NOERROR; -} - -/** - * @brief Get the next frame in the sequence - * This function will give you a copy of the frame and remove it from the FrameTimer - * - * @return AVFrame* The next frame in the sequence - */ -AVFrame *FrameTimer::GetFrame() { - FUNCTION_CALL_DEBUG(); - -#ifdef _DEBUG - // profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - if (_frames.empty()) { - return nullptr; - } - - // Pop a frame off - AVFrame *frame = _frames.back(); - _frames.pop_back(); - -#ifdef _DEBUG - // profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("Frame get time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return frame; -} - -/** - * @brief Check if the FrameTimer is full - * @return bool True if full, false otherwise - */ -bool FrameTimer::IsFull() { - FUNCTION_CALL_DEBUG(); - - return _frames.size() >= _capacity; -} - -/** - * @brief Check if the FrameTimer is empty - * @return bool True if empty, false otherwise - */ -bool FrameTimer::IsEmpty() { - FUNCTION_CALL_DEBUG(); - - return _frames.empty(); -} - -bool FrameTimer::IsHalf() { - FUNCTION_CALL_DEBUG(); - - return _frames.size() >= _capacity / 2; -} - -/** - * @brief This function will order the frame based on the - * pts, time_base, and frame_rate. - */ -AvError FrameTimer::_ReorderFrames() { - FUNCTION_CALL_DEBUG(); - -#ifdef _DEBUG - // profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - // Sort the frames based on pts order - std::sort(_frames.begin(), _frames.end(), [](AVFrame *a, AVFrame *b) { - // Force a universal time unit of microseconds - auto apts = av_rescale_q(a->pts, a->time_base, {1, 1000000}); - auto bpts = av_rescale_q(b->pts, b->time_base, {1, 1000000}); - - return apts > bpts; - }); - -#ifdef _DEBUG - // profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("Frame reorder time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/frametimer.hpp b/src/frametimer.hpp deleted file mode 100644 index 6517fcf..0000000 --- a/src/frametimer.hpp +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @file frametimer.hpp - * @brief FrameTimer class header file - * @version 1.0 - * @date 2024-09-16 - * @author Matthew Todd Geiger - */ - -#pragma once - -// Local includes -#include "averror.hpp" - -// 3rd Party Dependencies -extern "C" { -#include -} - -// Standard C++ Dependencies -#include - -// Defines -#define AVUTILS_FRAMETIMER_DEFAULT_CAPACITY 30 - -/** - * In Audio/Video, frames can be in a file out of order. - * This class is used to keep track of the order of frames. - * - * We can accomplish this by using the pts, time_base, and frame_rate. - * - * The pts is the presentation timestamp, which is the time the frame should be displayed. - * The time_base is the time unit of the pts. - * The frame_rate is the number of frames per second. - * - * An easy way to predict the next PTS is to use the formula: - * Video: pts = last_pts + (1 / frame_rate) - * Audio: pts = last_pts + (1 / sample_rate) - */ - -namespace AV::Utils { - -class FrameTimer { -public: - FrameTimer(const int capacity = AVUTILS_FRAMETIMER_DEFAULT_CAPACITY); - ~FrameTimer(); - - /** - * @brief Add a frame to the FrameTimer - * Keep in mind that a copy(referenced) of the frame is made. - * @param frame The frame to add - */ - AvException AddFrame(AVFrame *frame); - - /** - * @brief Get the next frame in the sequence - * This function will give you a copy of the frame and remove it from the FrameTimer - * @return AVFrame* The next frame in the sequence - */ - AVFrame *GetFrame(); - - /** - * @brief Check if the FrameTimer is full - */ - bool IsFull(); - - bool IsEmpty(); - - bool IsHalf(); - -private: - AvError _ReorderFrames(); - - std::vector _frames; - int _capacity; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/macro.hpp b/src/macro.hpp deleted file mode 100644 index 8cb7196..0000000 --- a/src/macro.hpp +++ /dev/null @@ -1,32 +0,0 @@ -/* - * ndi-streamer - * macro.hpp - * - * 02-19-2024 - * Matthew Todd Geiger - */ - -#pragma once - -#include -#include - -#define ERROR(str, ...) fprintf(stderr, "ERROR: " str "\n", ##__VA_ARGS__) -#define FATAL(str, ...) { ERROR(str, ##__VA_ARGS__); exit(EXIT_FAILURE); } -#define PRINT(str, ...) printf(str "\n", ##__VA_ARGS__) - -#ifdef _DEBUG -#define DEBUG(str, ...) printf("[DBG %d:" __FILE__ "] " str "\n", __LINE__, ##__VA_ARGS__); fflush(stdout) -#define MULTILINE_DEBUG(str, ...) printf("[DBG %d:" __FILE__ "]\n" str "\n", __LINE__, ##__VA_ARGS__); fflush(stdout) -#define PRINT_FFMPEG_ERR(x) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(x, errbuf, sizeof(errbuf)); DEBUG("FFmpeg error: %s", errbuf); } -#else -#define DEBUG(str, ...) -#define PRINT_FFMPEG_ERR(x) -#define MULTILINE_DEBUG(str, ...) -#endif - -#define ERRORTYPE unsigned char -#define FAILED 0 -#define SUCCESSFUL 1 - -#define FUNCTION_CALL_DEBUG() DEBUG("Function %s called", __func__) diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..9b392ec --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,109 @@ +/** + * @file main.cpp + * @brief ndistreamer entry point. + */ + +#include "options.hpp" +#include "streamer.hpp" +#include "util/log.hpp" +#include "version.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace ndistreamer; + +namespace { + +std::atomic g_interrupted{false}; + +/** + * Termination signals are blocked in every thread and received here, where it is safe to request a + * stop. A second signal exits immediately in case shutdown is stuck. + */ +void StartSignalThread(std::stop_source stop) { + sigset_t signals; + sigemptyset(&signals); + sigaddset(&signals, SIGINT); + sigaddset(&signals, SIGTERM); + pthread_sigmask(SIG_BLOCK, &signals, nullptr); + + std::thread([signals, stop]() mutable { + pthread_setname_np(pthread_self(), "nds-signals"); + for (int count = 0;;) { + int signal = 0; + if (sigwait(&signals, &signal) != 0) continue; + if (++count == 1) { + log::Info("received {}; stopping (press Ctrl+C again to force)", strsignal(signal)); + g_interrupted = true; + stop.request_stop(); + } else { + log::Warn("forced exit"); + std::_Exit(130); + } + } + }).detach(); +} + +} // namespace + +int main(int argc, char *argv[]) { + Options options; + std::string message; + switch (ParseOptions(argc, argv, options, message)) { + case ParseOutcome::ExitSuccess: + std::printf("%s\n", message.c_str()); + return EXIT_SUCCESS; + case ParseOutcome::ExitError: + std::fprintf(stderr, "%s\n", message.c_str()); + return 2; + case ParseOutcome::Run: + break; + } + + log::SetLevel(options.log_level); + log::InstallFFmpegBridge(); + std::signal(SIGPIPE, SIG_IGN); + + std::stop_source stop; + StartSignalThread(stop); + + log::Info("ndistreamer {}", NDISTREAMER_VERSION); + + try { + Streamer streamer(options, stop); + streamer.Start(); + log::Info("streaming as NDI source \"{}\"", streamer.SourceName()); + + auto interval = std::chrono::seconds(options.stats_interval); + auto next_stats = std::chrono::steady_clock::now() + interval; + while (!streamer.WaitFor(std::chrono::milliseconds(100))) { + if (options.stats_interval > 0 && std::chrono::steady_clock::now() >= next_stats) { + streamer.LogStats(); + next_stats += interval; + } + } + + streamer.Wait(); + if (options.stats_interval > 0) streamer.LogStats(); + log::Info("{}", g_interrupted ? "stopped" : "end of input reached"); + } catch (const std::exception &error) { + if (g_interrupted) { + log::Info("stopped"); + return EXIT_SUCCESS; + } + log::Error("{}", error.what()); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/src/ndi.cpp b/src/ndi.cpp deleted file mode 100644 index c8a6837..0000000 --- a/src/ndi.cpp +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @file ndi.cpp - * @brief This file includes utilities for working with NDI. - * @date 2024-09-06 - * @author Matthew Todd Geiger - **/ - -// local includes -#include "ndi.hpp" -#include "macro.hpp" - -namespace AV::Utils { - int NDI::m_open_instances = 0; - - /** - * @brief Construct a new NDI object - */ - NDI::NDI() { - FUNCTION_CALL_DEBUG(); - - if(!m_open_instances) { - DEBUG("NDI library has not been initialized yet, initializing now"); - NDIlib_initialize(); - } - - m_open_instances++; - } - - /** - * @brief Destroy the NDI object - */ - NDI::~NDI() { - FUNCTION_CALL_DEBUG(); - - m_open_instances--; - - if(!m_open_instances) { - DEBUG("No more NDI instances open, deinitializing NDI library"); - NDIlib_destroy(); - } - } - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/ndi.hpp b/src/ndi.hpp deleted file mode 100644 index d95a8d2..0000000 --- a/src/ndi.hpp +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @file ndi.hpp - * @brief This file includes utilities for working with NDI. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#pragma once - -// Standard includes -#include // Needed for NDI SDK -#include - -// NDI SDK -#include - -namespace AV::Utils { - -/** - * @brief This class just makes sure that the NDI library is initialized and deinitialized properly. - * Attach this to anything that uses the NDI SDK. - */ -class NDI { -public: - /** - * @brief Construct a new Ndi object - */ - NDI(); - - /** - * @brief Destroy the Ndi object - */ - ~NDI(); - -private: - static int m_open_instances; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/ndi/abi.hpp b/src/ndi/abi.hpp new file mode 100644 index 0000000..38ec3d0 --- /dev/null +++ b/src/ndi/abi.hpp @@ -0,0 +1,95 @@ +/** + * @file abi.hpp + * @brief The subset of the NDI C ABI used for sending. + * + * The NDI runtime is loaded at run time, so the NDI SDK is not needed to build. These declarations + * mirror the SDK's structures for interoperability; tests/ndi_abi_test.cpp checks them against the + * official headers whenever the SDK is available (configure with -DNDI_SDK_DIR=...). + */ + +#pragma once + +#include + +namespace ndistreamer::ndi::abi { + +using SendInstance = struct SendInstanceType *; + +/// Mirrors NDIlib_send_create_t. +struct SendCreate { + const char *p_ndi_name; + const char *p_groups; + bool clock_video; + bool clock_audio; +}; + +/// Mirrors NDIlib_frame_format_type_e. +enum FrameFormatType : int32_t { + FrameFormatInterleaved = 0, + FrameFormatProgressive = 1, +}; + +/// Mirrors NDIlib_video_frame_v2_t. +struct VideoFrameV2 { + int xres; + int yres; + uint32_t FourCC; + int frame_rate_N; + int frame_rate_D; + float picture_aspect_ratio; + FrameFormatType frame_format_type; + int64_t timecode; + uint8_t *p_data; + int line_stride_in_bytes; + const char *p_metadata; + int64_t timestamp; +}; + +/// Mirrors NDIlib_audio_frame_v2_t (planar 32-bit float). +struct AudioFrameV2 { + int sample_rate; + int no_channels; + int no_samples; + int64_t timecode; + float *p_data; + int channel_stride_in_bytes; + const char *p_metadata; + int64_t timestamp; +}; + +/// Mirrors NDIlib_metadata_frame_t. +struct MetadataFrame { + int length; + int64_t timecode; + char *p_data; +}; + +/// Mirrors NDIlib_source_t. +struct Source { + const char *p_ndi_name; + const char *p_url_address; +}; + +/// Mirrors NDIlib_tally_t. +struct Tally { + bool on_program; + bool on_preview; +}; + +/// Mirrors NDIlib_send_timecode_synthesize. +inline constexpr int64_t kTimecodeSynthesize = INT64_MAX; + +using InitializeFn = bool (*)(); +using DestroyFn = void (*)(); +using VersionFn = const char *(*)(); +using IsSupportedCpuFn = bool (*)(); +using SendCreateFn = SendInstance (*)(const SendCreate *); +using SendDestroyFn = void (*)(SendInstance); +using SendVideoAsyncV2Fn = void (*)(SendInstance, const VideoFrameV2 *); +using SendAudioV2Fn = void (*)(SendInstance, const AudioFrameV2 *); +using SendGetNoConnectionsFn = int (*)(SendInstance, uint32_t); +using SendGetTallyFn = bool (*)(SendInstance, Tally *, uint32_t); +using SendGetSourceNameFn = const Source *(*)(SendInstance); +using SendAddConnectionMetadataFn = void (*)(SendInstance, const MetadataFrame *); + +} // namespace ndistreamer::ndi::abi diff --git a/src/ndi/runtime.cpp b/src/ndi/runtime.cpp new file mode 100644 index 0000000..8e66351 --- /dev/null +++ b/src/ndi/runtime.cpp @@ -0,0 +1,137 @@ +/** + * @file runtime.cpp + * @brief Locates and loads the NDI runtime library (libndi.so) at run time. + */ + +#include "ndi/runtime.hpp" +#include "util/log.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace ndistreamer::ndi { + +namespace { + +constexpr const char *kLibraryNames[] = {"libndi.so.6", "libndi.so.5", "libndi.so"}; + +// Directory names used by the NDI SDK for Linux for this architecture. +#if defined(__x86_64__) +constexpr const char *kSdkArch = "x86_64-linux-gnu"; +#elif defined(__aarch64__) +constexpr const char *kSdkArch = "aarch64-rpi4-linux-gnueabi"; +#elif defined(__i386__) +constexpr const char *kSdkArch = "i686-linux-gnu"; +#elif defined(__arm__) +constexpr const char *kSdkArch = "arm-rpi4-linux-gnueabihf"; +#else +constexpr const char *kSdkArch = ""; +#endif + +void AddDirectory(std::vector &candidates, const std::filesystem::path &dir) { + for (const char *name : kLibraryNames) candidates.push_back((dir / name).string()); +} + +std::vector Candidates(const std::string &hint) { + std::vector candidates; + + if (!hint.empty()) { + if (std::filesystem::is_directory(hint)) { + AddDirectory(candidates, hint); + } else { + candidates.push_back(hint); + } + return candidates; // an explicit choice is never silently replaced + } + + for (const char *var : {"NDI_RUNTIME_DIR_V6", "NDI_RUNTIME_DIR_V5"}) { + if (const char *dir = std::getenv(var); dir && *dir) AddDirectory(candidates, dir); + } + + for (const char *name : kLibraryNames) candidates.push_back(name); // system search path + + std::filesystem::path sdk_lib = std::filesystem::path("lib") / kSdkArch; + AddDirectory(candidates, "/usr/local/lib"); + AddDirectory(candidates, std::filesystem::path("/opt/ndi") / sdk_lib); + AddDirectory(candidates, std::filesystem::path("/opt/ndi/NDI SDK for Linux") / sdk_lib); + AddDirectory(candidates, "/opt/ndi/lib"); + return candidates; +} + +template +void Resolve(void *handle, const char *symbol, Fn &fn, bool required, const std::string &path) { + fn = reinterpret_cast(dlsym(handle, symbol)); + if (!fn && required) { + throw std::runtime_error(std::format("'{}' is not a usable NDI runtime: missing {}", path, symbol)); + } +} + +} // namespace + +std::shared_ptr Runtime::Load(const std::string &hint) { + std::string last_error; + + for (const std::string &candidate : Candidates(hint)) { + void *handle = dlopen(candidate.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!handle) { + const char *error = dlerror(); + log::Trace("NDI runtime not loaded from {}: {}", candidate, error ? error : "unknown error"); + // Report why an existing file failed to load; "not found" errors are expected. + if (error && (!hint.empty() || std::filesystem::exists(candidate))) last_error = error; + continue; + } + + // Report the real path rather than a bare soname. + std::string path = candidate; + if (char resolved[4096]; dlinfo(handle, RTLD_DI_ORIGIN, resolved) == 0) { + path = (std::filesystem::path(resolved) / std::filesystem::path(candidate).filename()).string(); + } + + return std::shared_ptr(new Runtime(handle, path)); + } + + std::string message = hint.empty() ? "the NDI runtime (libndi.so) was not found" + : std::format("cannot load the NDI runtime from '{}'", hint); + if (!last_error.empty()) message += std::format(" ({})", last_error); + message += ". Install the NDI SDK or NDI Tools from https://ndi.video, then point --ndi-lib or " + "NDI_RUNTIME_DIR_V6 at the directory containing libndi.so.6"; + throw std::runtime_error(message); +} + +Runtime::Runtime(void *handle, std::string path) : m_handle(handle), m_path(std::move(path)) { + Resolve(m_handle, "NDIlib_initialize", m_api.initialize, true, m_path); + Resolve(m_handle, "NDIlib_destroy", m_api.destroy, true, m_path); + Resolve(m_handle, "NDIlib_version", m_api.version, true, m_path); + Resolve(m_handle, "NDIlib_is_supported_CPU", m_api.is_supported_cpu, true, m_path); + Resolve(m_handle, "NDIlib_send_create", m_api.send_create, true, m_path); + Resolve(m_handle, "NDIlib_send_destroy", m_api.send_destroy, true, m_path); + Resolve(m_handle, "NDIlib_send_send_video_async_v2", m_api.send_video_async_v2, true, m_path); + Resolve(m_handle, "NDIlib_send_send_audio_v2", m_api.send_audio_v2, true, m_path); + Resolve(m_handle, "NDIlib_send_get_no_connections", m_api.send_get_no_connections, true, m_path); + Resolve(m_handle, "NDIlib_send_get_source_name", m_api.send_get_source_name, false, m_path); + Resolve(m_handle, "NDIlib_send_add_connection_metadata", m_api.send_add_connection_metadata, false, m_path); + + if (!m_api.is_supported_cpu()) { + throw std::runtime_error("this CPU is not supported by the NDI runtime (SSE4.2 or NEON is required)"); + } + if (!m_api.initialize()) { + throw std::runtime_error("the NDI runtime failed to initialize"); + } +} + +Runtime::~Runtime() { + m_api.destroy(); + // The library stays mapped: unloading it while its worker threads wind down is not safe. +} + +std::string Runtime::Version() const { + const char *version = m_api.version(); + return version ? version : "unknown"; +} + +} // namespace ndistreamer::ndi diff --git a/src/ndi/runtime.hpp b/src/ndi/runtime.hpp new file mode 100644 index 0000000..4ed3cfc --- /dev/null +++ b/src/ndi/runtime.hpp @@ -0,0 +1,62 @@ +/** + * @file runtime.hpp + * @brief Locates and loads the NDI runtime library (libndi.so) at run time. + */ + +#pragma once + +#include "ndi/abi.hpp" + +#include +#include + +namespace ndistreamer::ndi { + +/** + * @brief A loaded and initialized NDI runtime. + * + * Search order: the explicit path given by the user (file or directory), the NDI_RUNTIME_DIR_V6 and + * NDI_RUNTIME_DIR_V5 environment variables, the system library path, then common SDK install + * locations such as /opt/ndi. + */ +class Runtime { +public: + struct Api { + abi::InitializeFn initialize = nullptr; + abi::DestroyFn destroy = nullptr; + abi::VersionFn version = nullptr; + abi::IsSupportedCpuFn is_supported_cpu = nullptr; + abi::SendCreateFn send_create = nullptr; + abi::SendDestroyFn send_destroy = nullptr; + abi::SendVideoAsyncV2Fn send_video_async_v2 = nullptr; + abi::SendAudioV2Fn send_audio_v2 = nullptr; + abi::SendGetNoConnectionsFn send_get_no_connections = nullptr; + // Optional; may be null on older runtimes. + abi::SendGetSourceNameFn send_get_source_name = nullptr; + abi::SendAddConnectionMetadataFn send_add_connection_metadata = nullptr; + }; + + /** + * @brief Load the runtime. Throws std::runtime_error with guidance if it cannot be found or used. + * @param hint a library file or a directory containing it; empty to search. + */ + static std::shared_ptr Load(const std::string &hint); + + ~Runtime(); + + Runtime(const Runtime &) = delete; + Runtime &operator=(const Runtime &) = delete; + + const Api &api() const { return m_api; } + const std::string &Path() const { return m_path; } + std::string Version() const; + +private: + Runtime(void *handle, std::string path); + + void *m_handle; + std::string m_path; + Api m_api; +}; + +} // namespace ndistreamer::ndi diff --git a/src/ndi/sender.cpp b/src/ndi/sender.cpp new file mode 100644 index 0000000..1301842 --- /dev/null +++ b/src/ndi/sender.cpp @@ -0,0 +1,95 @@ +/** + * @file sender.cpp + * @brief An NDI source on the network. + */ + +#include "ndi/sender.hpp" +#include "util/log.hpp" +#include "version.hpp" + +#include +#include + +namespace ndistreamer::ndi { + +Sender::Sender(std::shared_ptr runtime, const SenderConfig &config) + : m_runtime(std::move(runtime)), m_api(m_runtime->api()), m_name(config.name) { + abi::SendCreate create{}; + create.p_ndi_name = config.name.c_str(); + create.p_groups = config.groups.empty() ? nullptr : config.groups.c_str(); + create.clock_video = false; + create.clock_audio = false; + + m_instance = m_api.send_create(&create); + if (!m_instance) { + throw std::runtime_error(std::format("cannot create NDI source '{}'", config.name)); + } + + if (m_api.send_add_connection_metadata) { + // Lets receivers identify what they are connected to. + std::string product = std::format(R"()", + NDISTREAMER_VERSION); + abi::MetadataFrame metadata{}; + metadata.length = static_cast(product.size() + 1); + metadata.timecode = abi::kTimecodeSynthesize; + metadata.p_data = product.data(); + m_api.send_add_connection_metadata(m_instance, &metadata); + } +} + +Sender::~Sender() { + FlushVideo(); + m_api.send_destroy(m_instance); +} + +void Sender::SendVideo(VideoFrame frame, int64_t timecode) { + abi::VideoFrameV2 desc{}; + desc.xres = frame.width; + desc.yres = frame.height; + desc.FourCC = static_cast(frame.fourcc); + desc.frame_rate_N = frame.frame_rate_num; + desc.frame_rate_D = frame.frame_rate_den; + desc.picture_aspect_ratio = frame.aspect_ratio; + desc.frame_format_type = frame.interlaced ? abi::FrameFormatInterleaved : abi::FrameFormatProgressive; + desc.timecode = timecode; + desc.p_data = frame.data; + desc.line_stride_in_bytes = frame.stride; + + // Returns once NDI is done with the previous frame, which can then be released. + m_api.send_video_async_v2(m_instance, &desc); + m_in_flight = std::move(frame); +} + +void Sender::FlushVideo() { + if (!m_in_flight.storage) return; + m_api.send_video_async_v2(m_instance, nullptr); + m_in_flight = VideoFrame{}; +} + +void Sender::SendAudio(const AudioFrame &frame, int64_t timecode) { + abi::AudioFrameV2 desc{}; + desc.sample_rate = frame.sample_rate; + desc.no_channels = frame.channels; + desc.no_samples = frame.samples_per_channel; + desc.timecode = timecode; + desc.p_data = const_cast(frame.samples.data()); // NDI only reads + desc.channel_stride_in_bytes = frame.channel_stride * static_cast(sizeof(float)); + + m_api.send_audio_v2(m_instance, &desc); +} + +int Sender::Connections() const { + return m_api.send_get_no_connections(m_instance, 0); +} + +std::string Sender::SourceName() const { + if (m_api.send_get_source_name) { + if (const abi::Source *source = m_api.send_get_source_name(m_instance); source && source->p_ndi_name) { + return source->p_ndi_name; + } + } + return m_name; +} + +} // namespace ndistreamer::ndi diff --git a/src/ndi/sender.hpp b/src/ndi/sender.hpp new file mode 100644 index 0000000..7b264a5 --- /dev/null +++ b/src/ndi/sender.hpp @@ -0,0 +1,68 @@ +/** + * @file sender.hpp + * @brief An NDI source on the network. + */ + +#pragma once + +#include "frames.hpp" +#include "ndi/runtime.hpp" + +#include +#include + +namespace ndistreamer::ndi { + +struct SenderConfig { + std::string name; ///< Source name; NDI shows it as "HOST (name)". + std::string groups; ///< Comma separated groups; empty for the default groups. +}; + +/** + * @brief Publishes audio and video as an NDI source. + * + * Video is submitted asynchronously: NDI compresses and transmits a frame while the next one is being + * prepared, and the sender keeps each frame alive until NDI has finished with it. Timing is the + * caller's responsibility (NDI clocking is disabled) so frames go out at their media timestamps. + * + * SendVideo and SendAudio may be called from different threads. + */ +class Sender { +public: + Sender(std::shared_ptr runtime, const SenderConfig &config); + ~Sender(); + + Sender(const Sender &) = delete; + Sender &operator=(const Sender &) = delete; + + /** + * @brief Queue a video frame for asynchronous transmission and release the previous one. + */ + void SendVideo(VideoFrame frame, int64_t timecode); + + /** + * @brief Wait until the last video frame has been processed and release it. + */ + void FlushVideo(); + + void SendAudio(const AudioFrame &frame, int64_t timecode); + + /** + * @brief Number of receivers currently connected. + */ + int Connections() const; + + /** + * @brief The full network name of the source, e.g. "HOST (name)". + */ + std::string SourceName() const; + +private: + std::shared_ptr m_runtime; + const Runtime::Api &m_api; + abi::SendInstance m_instance = nullptr; + std::string m_name; + VideoFrame m_in_flight; +}; + +} // namespace ndistreamer::ndi diff --git a/src/ndisource.cpp b/src/ndisource.cpp deleted file mode 100644 index f92d67e..0000000 --- a/src/ndisource.cpp +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @file ndisource.cpp - * @brief This file includes utilities for working with NDI sources. - * @date 2024-09-16 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#include "ndisource.hpp" -#include "macro.hpp" -#include "frame.hpp" - -#include -#include - -extern "C" { -#include -} - -namespace AV::Utils { - -NDISourceResult NDISource::Create(const std::string &source_name, const AVRational &frame_rate) { - FUNCTION_CALL_DEBUG(); - AvException err; - - try { - return {std::shared_ptr(new NDISource(source_name, frame_rate)), AvError::NOERROR}; - } catch(const AvException e) { - err = e; - DEBUG("Error creating NDI source: %s", e.what()); - } - - return {nullptr, err}; -} - -AvException NDISource::SendFrame(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - if(frame->width != 0 && frame->height != 0) { - return _SendVideoFrame(frame); - } - - return _SendAudioFrame(frame); -} - -NDISource::NDISource(const std::string &source_name, const AVRational &frame_rate) : _source_name(source_name), _frame_rate(frame_rate) { - FUNCTION_CALL_DEBUG(); - - AvError err = _Initialize(); - if(err != AvError::NOERROR) { - throw AvException(err); - } -} - -NDISource::~NDISource() { - FUNCTION_CALL_DEBUG(); - - if(_ndi_send_instance != nullptr) { - NDIlib_send_destroy(_ndi_send_instance); - } -} - -AvError NDISource::_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Create NDI send instance - NDIlib_send_create_t send_create_desc; - send_create_desc.p_ndi_name = _source_name.c_str(); - send_create_desc.clock_video = true; - _ndi_send_instance = NDIlib_send_create(&send_create_desc); - - if(_ndi_send_instance == nullptr) { - return AvError::NDISENDINSTANCE; - } - - return AvError::NOERROR; -} - -AvError NDISource::_SendVideoFrame(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - -#ifdef _DEBUG - // profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - - bool manual_free = false; - - // Build NDI packet from frame - NDIlib_video_frame_v2_t video_frame; - - DEBUG("Frame metadata" - "\n\tWidth: %d" - "\n\tHeight: %d" - "\n\tFormat: %d" - "\n\tLinesize 1: %d" - "\n\tLinesize 2: %d", - frame->width, frame->height, frame->format, frame->linesize[0], frame->linesize[1]); - - switch(frame->format) { - case AV_PIX_FMT_UYVY422: - DEBUG("Sending UYVY frame"); - video_frame.FourCC = NDIlib_FourCC_type_UYVY; - video_frame.p_data = frame->data[0]; - video_frame.line_stride_in_bytes = frame->linesize[0]; - break; - case AV_PIX_FMT_RGB24: - DEBUG("Sending RGB24 frame"); - video_frame.FourCC = NDIlib_FourCC_type_RGBA; - video_frame.p_data = frame->data[0]; - video_frame.line_stride_in_bytes = frame->linesize[0]; - break; - case AV_PIX_FMT_NV12: - DEBUG("Sending NV12 frame"); - video_frame.FourCC = NDIlib_FourCC_type_NV12; - video_frame.line_stride_in_bytes = frame->linesize[0]; - - DEBUG("data[0]: %p", frame->data[0]); - DEBUG("data[1]: %p", frame->data[1]); - DEBUG("data[1] - data[0]: %ld | Frame 0 Size: %d", frame->data[1] - frame->data[0], frame->linesize[0] * frame->height); - - video_frame.p_data = CombinePlanesNV12(frame, 2); - manual_free = true; - - break; - default: - return AvError::NDIINVALIDPIXFMT; - } - - video_frame.xres = frame->width; - video_frame.yres = frame->height; - video_frame.frame_rate_N = _frame_rate.num; - video_frame.frame_rate_D = _frame_rate.den; - video_frame.timecode = NDIlib_send_timecode_synthesize; - video_frame.frame_format_type = NDIlib_frame_format_type_progressive; - - // Send the frame - NDIlib_send_send_video_v2(_ndi_send_instance, &video_frame); - - if(manual_free) { - delete[] video_frame.p_data; - } - -#ifdef _DEBUG - // profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("NDI Send Video Frame time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return AvError::NOERROR; -} - -/** - * Only send audio frames that are interleaved 16-bit signed PCM. - */ -AvError NDISource::_SendAudioFrame(const AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - if(frame->format != AV_SAMPLE_FMT_S16) { - return AvError::INVALIDSMPLFMT; - } - - // Build NDI packet from frame - NDIlib_audio_frame_interleaved_16s_t audio_frame; - - audio_frame.sample_rate = frame->sample_rate; - audio_frame.no_channels = frame->ch_layout.nb_channels; - audio_frame.no_samples = frame->nb_samples; - audio_frame.timecode = NDIlib_send_timecode_synthesize; - audio_frame.p_data = (int16_t *)frame->data[0]; - - // Send the frame - NDIlib_util_send_send_audio_interleaved_16s(_ndi_send_instance, &audio_frame); - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/ndisource.hpp b/src/ndisource.hpp deleted file mode 100644 index b158a95..0000000 --- a/src/ndisource.hpp +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @file ndisource.hpp - * @brief This file includes utilities for working with NDI sources. - * @date 2024-09-16 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#pragma once - -// Local includes -#include "ndi.hpp" -#include "averror.hpp" - -// NDI SDK -#include - -// FFMPEG includes -extern "C" { -#include -} - -// Standard C++ includes -#include -#include - -namespace AV::Utils { - -// Forward declarations and type definitions -class NDISource; -using NDISourceResult = std::pair, const AvException>; - -class NDISource : public NDI { -private: - NDISource(const std::string &source_name, const AVRational &frame_rate); - AvError _Initialize(); - AvError _SendVideoFrame(const AVFrame *frame); - AvError _SendAudioFrame(const AVFrame *frame); - -public: - ~NDISource(); - - // Factory - static NDISourceResult Create(const std::string &source_name, const AVRational &frame_rate); - - AvException SendFrame(const AVFrame *frame); - -private: - std::string _source_name; - NDIlib_send_instance_t _ndi_send_instance = nullptr; - AVRational _frame_rate; - -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/ndistreamer.cpp b/src/ndistreamer.cpp deleted file mode 100644 index 1b18e85..0000000 --- a/src/ndistreamer.cpp +++ /dev/null @@ -1,118 +0,0 @@ -/* - * ndi-streamer - * ndi-streamer.cpp - * - * 02-19-2024 - * Matthew Todd Geiger - */ - -// Standard library -#include -#include -#include - -// POSIX includes -#include - -// Local includes -#include "macro.hpp" -#include "softwareapp.hpp" -#include "vaapiapp.hpp" -#include "cudaapp.hpp" -#include "app.hpp" - -typedef struct CommandLineArguments { - std::string videofile; - std::string ndisource; - std::string hwtype; - - CommandLineArguments() : videofile(""), ndisource("NDI Source"), hwtype("software") {} -} COMMANDLINEARGUMENTS, *PCOMMANDLINEARGUMENTS; - -void Usage(const char *const argv0) { - printf("\n%s\n" - "\t-i /path/to/media.mp4\n" - "\t-s \"NDI Source Name\"\n" - "\t-t [software, cuda, vaapi]\n\n", - argv0); -} - -// Process command line arguments -ERRORTYPE ParseCommandLineArguments(COMMANDLINEARGUMENTS &cmdlineargs, int argc, char **argv) { - - int opt = 0; - while ((opt = getopt(argc, argv, "i:s:t:")) != -1) { - switch (opt) { - case 'i': - cmdlineargs.videofile = optarg; - break; - case 's': - cmdlineargs.ndisource = optarg; - break; - case 't': - cmdlineargs.hwtype = optarg; - break; - default: - return FAILED; - } - } - - DEBUG("Video file --> %s", cmdlineargs.videofile.c_str()); - DEBUG("NDI Source --> %s", cmdlineargs.ndisource.c_str()); - DEBUG("HW Type --> %s", cmdlineargs.hwtype.c_str()); - - if (cmdlineargs.videofile == "") { - ERROR("videofile required"); - return FAILED; - } - - if(cmdlineargs.hwtype != "software" && cmdlineargs.hwtype != "cuda" && cmdlineargs.hwtype != "vaapi") { - ERROR("Invalid HW type"); - return FAILED; - } - - return SUCCESSFUL; -} - -int main(int argc, char **argv) { - COMMANDLINEARGUMENTS cmdlineargs; - - // Parse command line arguments - if (!ParseCommandLineArguments(cmdlineargs, argc, argv)) { - Usage(argv[0]); - return EXIT_FAILURE; - } - - PRINT("NDI Streamer"); - PRINT("NDI Source: %s", cmdlineargs.ndisource.c_str()); - PRINT("Video File: %s", cmdlineargs.videofile.c_str()); - PRINT("HW Type: %s", cmdlineargs.hwtype.c_str()); - - std::shared_ptr app(nullptr); - AV::Utils::AvException err; - - // Create the application - if(cmdlineargs.hwtype == "software") { - std::tie(app, err) = SoftwareApp::Create(cmdlineargs.ndisource, cmdlineargs.videofile); - } else if(cmdlineargs.hwtype == "vaapi") { - std::tie(app, err) = VAAPIApp::Create(cmdlineargs.ndisource, cmdlineargs.videofile); - } else if(cmdlineargs.hwtype == "cuda") { - std::tie(app, err) = CudaApp::Create(cmdlineargs.ndisource, cmdlineargs.videofile); - } - - if (err.code()) { - FATAL("Error creating application: %s", err.what()); - } - - if(app != nullptr) { - auto err = app->Run(); - if(err.code()) { - FATAL("Error running application: %s", err.what()); - } - } - - fflush(stdout); - fflush(stderr); - - return EXIT_SUCCESS; -} diff --git a/src/options.cpp b/src/options.cpp new file mode 100644 index 0000000..13bf038 --- /dev/null +++ b/src/options.cpp @@ -0,0 +1,266 @@ +/** + * @file options.cpp + * @brief Command line options. + */ + +#include "options.hpp" +#include "version.hpp" + +#include +#include +#include +#include + +#include + +extern "C" { +#include +} + +namespace ndistreamer { + +namespace { + +enum LongOnly { + kVideoStream = 1000, + kAudioStream, + kNoVideo, + kNoAudio, + kHwaccelDevice, + kThreads, + kVideoFilter, + kAudioRate, + kAudioChannels, + kNdiLib, + kNoPacing, + kStats, +}; + +constexpr option kLongOptions[] = { + {"input", required_argument, nullptr, 'i'}, + {"format", required_argument, nullptr, 'f'}, + {"input-option", required_argument, nullptr, 'o'}, + {"loop", no_argument, nullptr, 'l'}, + {"video-stream", required_argument, nullptr, kVideoStream}, + {"audio-stream", required_argument, nullptr, kAudioStream}, + {"no-video", no_argument, nullptr, kNoVideo}, + {"no-audio", no_argument, nullptr, kNoAudio}, + {"hwaccel", required_argument, nullptr, 't'}, + {"hwaccel-device", required_argument, nullptr, kHwaccelDevice}, + {"threads", required_argument, nullptr, kThreads}, + {"vf", required_argument, nullptr, kVideoFilter}, + {"pixel-format", required_argument, nullptr, 'p'}, + {"audio-rate", required_argument, nullptr, kAudioRate}, + {"audio-channels", required_argument, nullptr, kAudioChannels}, + {"name", required_argument, nullptr, 's'}, + {"groups", required_argument, nullptr, 'g'}, + {"ndi-lib", required_argument, nullptr, kNdiLib}, + {"no-pacing", no_argument, nullptr, kNoPacing}, + {"stats", optional_argument, nullptr, kStats}, + {"verbose", no_argument, nullptr, 'v'}, + {"quiet", no_argument, nullptr, 'q'}, + {"help", no_argument, nullptr, 'h'}, + {"version", no_argument, nullptr, 'V'}, + {nullptr, 0, nullptr, 0}, +}; + +constexpr const char *kShortOptions = ":i:f:o:lt:p:s:g:vqhV"; + +std::string Usage(std::string_view program) { + return std::format(R"(Usage: {0} -i INPUT [options] + +Stream a media file, network stream or capture device as an NDI source. + +Input: + -i, --input INPUT file, URL or device to stream (required) + -f, --format NAME force the input format, e.g. v4l2, x11grab, pulse + -o, --input-option KEY=VAL demuxer/protocol option; repeatable (e.g. rtsp_transport=tcp) + -l, --loop restart the input when it ends, without a gap + --video-stream N use stream N for video (default: best) + --audio-stream N use stream N for audio (default: best) + --no-video do not send video + --no-audio do not send audio + +Decoding and processing: + -t, --hwaccel TYPE hardware decoding: none, auto, vaapi, cuda, vdpau, ... + (default: none; falls back to software per stream) + --hwaccel-device DEV e.g. /dev/dri/renderD129 or a CUDA device index + --threads N decoder threads, 0 = automatic (default: 0) + --vf FILTERGRAPH FFmpeg video filters, e.g. "yadif" or "scale=1280:-2" + -p, --pixel-format FMT NDI video layout: auto, uyvy, nv12, i420, bgra or rgba + (default: auto, which passes through layouts NDI accepts + and converts anything else to uyvy) + --audio-rate HZ resample audio (default: keep the source rate) + --audio-channels N remix audio to N channels (default: keep all channels) + +NDI: + -s, --name NAME NDI source name (default: "NDI Source") + -g, --groups LIST comma separated NDI groups (default: NDI's default groups) + --ndi-lib PATH libndi.so to load, or the directory containing it + --no-pacing send frames as fast as possible instead of in real time + +General: + --stats[=SECONDS] log statistics periodically (default interval: 5 s) + -v, --verbose more detailed logging; repeat for even more + -q, --quiet log only warnings and errors; repeat for errors only + -h, --help show this help + -V, --version show version information + +Examples: + {0} -i movie.mp4 -s "Lobby Screen" --loop + {0} -i movie.mkv -t auto + {0} -i rtsp://camera/stream -o rtsp_transport=tcp -s Camera + {0} -f v4l2 -i /dev/video0 -s Webcam +)", + program); +} + +template +bool ParseNumber(std::string_view text, T min, T max, T &out) { + T value{}; + auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), value); + if (ec != std::errc{} || end != text.data() + text.size() || value < min || value > max) return false; + out = value; + return true; +} + +} // namespace + +ParseOutcome ParseOptions(int argc, char *argv[], Options &options, std::string &message) { + std::string_view program = argc > 0 ? argv[0] : "ndistreamer"; + if (auto slash = program.rfind('/'); slash != std::string_view::npos) program.remove_prefix(slash + 1); + + auto fail = [&](std::string error) { + message = std::format("{}: {}\nTry '{} --help' for more information.", program, error, program); + return ParseOutcome::ExitError; + }; + + int verbosity = 0; + opterr = 0; + optind = 0; // fully reinitialize getopt (GNU), so parsing can run more than once + + int opt = 0; + int long_index = 0; + while ((opt = getopt_long(argc, argv, kShortOptions, kLongOptions, &long_index)) != -1) { + std::string_view arg = optarg ? optarg : ""; + switch (opt) { + case 'i': + options.input.url = arg; + break; + case 'f': + options.input.format = arg; + break; + case 'o': { + auto eq = arg.find('='); + if (eq == std::string_view::npos || eq == 0) { + return fail(std::format("input option '{}' must have the form KEY=VALUE", arg)); + } + options.input.options.emplace_back(std::string(arg.substr(0, eq)), std::string(arg.substr(eq + 1))); + break; + } + case 'l': + options.loop = true; + break; + case kVideoStream: + if (!ParseNumber(arg, 0, 65535, options.input.video_stream)) { + return fail(std::format("invalid video stream index '{}'", arg)); + } + break; + case kAudioStream: + if (!ParseNumber(arg, 0, 65535, options.input.audio_stream)) { + return fail(std::format("invalid audio stream index '{}'", arg)); + } + break; + case kNoVideo: + options.input.disable_video = true; + break; + case kNoAudio: + options.input.disable_audio = true; + break; + case 't': + options.decoder.hwaccel = arg == "software" ? "none" : std::string(arg); + break; + case kHwaccelDevice: + options.decoder.hwaccel_device = arg; + break; + case kThreads: + if (!ParseNumber(arg, 0, 128, options.decoder.threads)) { + return fail(std::format("invalid thread count '{}' (expected 0-128)", arg)); + } + break; + case kVideoFilter: + options.video_filter = arg; + break; + case 'p': { + auto format = av::ParseOutputPixelFormat(arg); + if (!format) { + return fail(std::format("invalid pixel format '{}' (expected auto, uyvy, nv12, i420, bgra or rgba)", + arg)); + } + options.pixel_format = *format; + break; + } + case kAudioRate: + if (!ParseNumber(arg, 8000, 384000, options.audio_rate)) { + return fail(std::format("invalid audio rate '{}' (expected 8000-384000)", arg)); + } + break; + case kAudioChannels: + if (!ParseNumber(arg, 1, 64, options.audio_channels)) { + return fail(std::format("invalid audio channel count '{}' (expected 1-64)", arg)); + } + break; + case 's': + options.sender.name = arg; + break; + case 'g': + options.sender.groups = arg; + break; + case kNdiLib: + options.ndi_library = arg; + break; + case kNoPacing: + options.pacing = false; + break; + case kStats: + options.stats_interval = 5; + if (optarg && !ParseNumber(arg, 1, 86400, options.stats_interval)) { + return fail(std::format("invalid statistics interval '{}'", arg)); + } + break; + case 'v': + verbosity++; + break; + case 'q': + verbosity--; + break; + case 'h': + message = Usage(program); + return ParseOutcome::ExitSuccess; + case 'V': + message = std::format("ndistreamer {}\nFFmpeg {} (libavformat {}.{}, libavcodec {}.{})", NDISTREAMER_VERSION, + av_version_info(), LIBAVFORMAT_VERSION_MAJOR, LIBAVFORMAT_VERSION_MINOR, + LIBAVCODEC_VERSION_MAJOR, LIBAVCODEC_VERSION_MINOR); + return ParseOutcome::ExitSuccess; + case ':': + return fail(std::format("option '{}' requires an argument", argv[optind - 1])); + default: + return fail(std::format("unrecognized option '{}'", argv[optind - 1])); + } + } + + if (optind < argc) return fail(std::format("unexpected argument '{}'", argv[optind])); + if (options.input.url.empty()) return fail("an input is required (-i INPUT)"); + if (options.sender.name.empty()) return fail("the NDI source name cannot be empty"); + if (options.input.disable_video && options.input.disable_audio) { + return fail("--no-video and --no-audio cannot be combined"); + } + + int level = std::clamp(static_cast(log::Level::Info) + verbosity, static_cast(log::Level::Error), + static_cast(log::Level::Trace)); + options.log_level = static_cast(level); + + return ParseOutcome::Run; +} + +} // namespace ndistreamer diff --git a/src/options.hpp b/src/options.hpp new file mode 100644 index 0000000..7df45bf --- /dev/null +++ b/src/options.hpp @@ -0,0 +1,47 @@ +/** + * @file options.hpp + * @brief Command line options. + */ + +#pragma once + +#include "av/decoder.hpp" +#include "av/input.hpp" +#include "av/video_processor.hpp" +#include "ndi/sender.hpp" +#include "util/log.hpp" + +#include + +namespace ndistreamer { + +struct Options { + av::InputConfig input; + av::DecoderConfig decoder; + std::string video_filter; + av::OutputPixelFormat pixel_format = av::OutputPixelFormat::Auto; + int audio_rate = 0; + int audio_channels = 0; + + ndi::SenderConfig sender{"NDI Source", ""}; + std::string ndi_library; + + bool loop = false; + bool pacing = true; + int stats_interval = 0; ///< Seconds between statistics lines; 0 disables them. + log::Level log_level = log::Level::Info; +}; + +enum class ParseOutcome { + Run, ///< Options are valid; start streaming. + ExitSuccess, ///< Help or version was requested; print the message and exit 0. + ExitError, ///< Invalid usage; print the message and exit 2. +}; + +/** + * @brief Parse and validate the command line. + * @param message receives help/version text or an error description + */ +ParseOutcome ParseOptions(int argc, char *argv[], Options &options, std::string &message); + +} // namespace ndistreamer diff --git a/src/pixelencoder.cpp b/src/pixelencoder.cpp deleted file mode 100644 index 464c769..0000000 --- a/src/pixelencoder.cpp +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @file pixelencoder.cpp - * @brief This file includes utilities for encoding media files. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#include - -#include "pixelencoder.hpp" -#include "macro.hpp" - -extern "C" { -#include -} - -namespace AV::Utils { - -/** - * @brief Encode a frame - * - * @param frame The frame to encode - * @return PixelEncoderOutput The encoded frame - */ -PixelEncoderOutput PixelEncoder::Encode(AVFrame *frame) { -FUNCTION_CALL_DEBUG(); -#ifdef _DEBUG - // Profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - m_dst_frame->pts = frame->pts; - m_dst_frame->pkt_dts = frame->pkt_dts; - m_dst_frame->pict_type = frame->pict_type; - m_dst_frame->quality = frame->quality; - m_dst_frame->opaque = frame->opaque; - m_dst_frame->best_effort_timestamp = frame->best_effort_timestamp; - - // Scale the frame into the destination frame - int ret = sws_scale(m_sws_ctx, frame->data, frame->linesize, 0, m_config.src_height, m_dst_frame->data, m_dst_frame->linesize); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::SWSSCALE)}; - } - - // Print new frame metadata - DEBUG("Pixel Encoded Frame Metadata\n" - " pts: %ld\n" - " pkt_dts: %ld\n" - " pict_type: %d\n" - " quality: %d\n" - " opaque: %p\n" - " best_effort_timestamp: %ld", - m_dst_frame->pts, m_dst_frame->pkt_dts, m_dst_frame->pict_type, m_dst_frame->quality, m_dst_frame->opaque, m_dst_frame->best_effort_timestamp); - -#ifdef _DEBUG - // Profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("Encode time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - return {m_dst_frame, AvException(AvError::NOERROR)}; -} - -/** - * @brief Create a new PixelEncoder object - * - * @param config The configuration for the PixelEncoder object - * @return PixelEncoderResult The PixelEncoder object - */ -PixelEncoderResult PixelEncoder::Create(const pixelencoderconfig &config) { - FUNCTION_CALL_DEBUG(); - AvException error; - - // Create a new pixel encoder object, return nullopt if error - try { - return {std::unique_ptr(new PixelEncoder(config)), error}; - } catch (AvException e) { - error = e; - DEBUG("PixelEncoder error: %s", error.what()); - } - - return {nullptr, error}; -} - -/** - * @brief Construct a new PixelEncoder object - * - * @param config The configuration for the PixelEncoder object - */ -PixelEncoder::PixelEncoder(const pixelencoderconfig &config) : m_config(config) { - FUNCTION_CALL_DEBUG(); - - AvError err = m_Initialize(); - if (err != AvError::NOERROR) { - throw err; - } -} - -/** - * @brief Destroy the PixelEncoder object - */ -PixelEncoder::~PixelEncoder() { - FUNCTION_CALL_DEBUG(); - - // Free the frame buffer - if (m_dst_frame_buffer) { - av_free(m_dst_frame_buffer); - DEBUG("av_free called"); - } - - // Free the frame - if (m_dst_frame) { - av_frame_free(&m_dst_frame); - DEBUG("av_frame_free called"); - } - - // Free the sws context - if (m_sws_ctx) { - sws_freeContext(m_sws_ctx); - DEBUG("sws_freeContext called"); - } -} - -/** - * @brief Initialize the PixelEncoder object - * - * @return AvError The error code - */ -AvError PixelEncoder::m_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Create the sws context for scaling frames - m_sws_ctx = sws_getContext(m_config.src_width, m_config.src_height, m_config.src_pix_fmt, - m_config.dst_width, m_config.dst_height, m_config.dst_pix_fmt, - SWS_BILINEAR, nullptr, nullptr, nullptr); - - if (!m_sws_ctx) { - return AvError::SWSCONTEXT; - } - - // Lets create a frame to store the converted image - m_dst_frame = av_frame_alloc(); - if (!m_dst_frame) { - return AvError::FRAMEALLOC; - } - - // Allocate the buffer for the resize frame - int buffersize = av_image_get_buffer_size(m_config.dst_pix_fmt, m_config.dst_width, m_config.dst_height, 1); - m_dst_frame_buffer = (uint8_t *)av_malloc(buffersize); - if (!m_dst_frame_buffer) { - return AvError::AVMALLOC; - } - - // Setup the data pointer to point to our buffer and set the linesize - int ret = av_image_fill_arrays(m_dst_frame->data, m_dst_frame->linesize, m_dst_frame_buffer, m_config.dst_pix_fmt, m_config.dst_width, m_config.dst_height, 1); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return AvError::IMAGEFILLARRAYS; - } - - // Copy important metadata to the destination frame - m_dst_frame->width = m_config.dst_width; - m_dst_frame->height = m_config.dst_height; - m_dst_frame->format = m_config.dst_pix_fmt; - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/pixelencoder.hpp b/src/pixelencoder.hpp deleted file mode 100644 index 4a1a4d4..0000000 --- a/src/pixelencoder.hpp +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @file pixelencoder.hpp - * @brief This file includes utilities for encoding media files. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#pragma once - -#include "averror.hpp" - -extern "C" { -#include -} - -#include -#include - -namespace AV::Utils { - -// Forward declarations and type definitions -class PixelEncoder; -using PixelEncoderResult = std::pair, const AvException>; -using PixelEncoderOutput = std::pair; - -/** - * @brief The PixelEncoderConfig struct represents the configuration for the PixelEncoder object. - */ -typedef struct PixelEncoderConfig { - int src_width{}, src_height{}; - int dst_width{}, dst_height{}; - AVPixelFormat src_pix_fmt{}, dst_pix_fmt{}; -} pixelencoderconfig, *ppixelencoderconfig; - -/** - * @brief The PixelEncoder class represents an object that encodes frames. - */ -class PixelEncoder { -private: - PixelEncoder(const pixelencoderconfig &config); - -public: - /** - * @brief Destroy the PixelEncoder object - */ - ~PixelEncoder(); - - // Factory methods - /** - * @brief Create a new PixelEncoder object - * - * @param config The configuration for the PixelEncoder object - * @return PixelEncoderResult The PixelEncoder object - */ - static PixelEncoderResult Create(const pixelencoderconfig &config); - - // Getters - /** - * @brief Encode a frame - * - * @param frame The frame to encode - * @return PixelEncoderOutput The encoded frame - */ - PixelEncoderOutput Encode(AVFrame *frame); - -private: - AvError m_Initialize(); - - // Store the configuration - pixelencoderconfig m_config; - - // Store the sws context for scaling frames - SwsContext *m_sws_ctx = nullptr; - - // Store the destination frame - AVFrame *m_dst_frame = nullptr; - - // Store the destination frame buffer; - uint8_t *m_dst_frame_buffer = nullptr; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/simplefilter.cpp b/src/simplefilter.cpp deleted file mode 100644 index 8d80e35..0000000 --- a/src/simplefilter.cpp +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @file simplefilter.cpp - * @brief SimpleFilter class - * @version 1.0 - * @date 2024-09-18 - * @author Matthew Todd Geiger - */ - -#include "simplefilter.hpp" -#include "macro.hpp" - -namespace AV::Utils { - -/** - * @brief Construct a new Simple Filter object - * - * @param filter_description The filter description - * @param codec_parameters The codec parameters - * @param time_base The time base - * @return SimpleFilterResult The SimpleFilterResult - */ -SimpleFilterResult SimpleFilter::CreateFilter(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base) { - FUNCTION_CALL_DEBUG(); - - try { - return {std::unique_ptr(new SimpleFilter(filter_description, codec_parameters, time_base)), AvError::NOERROR}; - } catch(const AvException &e) { - return {nullptr, e}; - } -} - -/** - * @brief Construct a new Simple Filter object - * - * @param filter_description The filter description - * @param codec_parameters The codec parameters - * @param time_base The time base - */ -SimpleFilter::SimpleFilter(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base) { - FUNCTION_CALL_DEBUG(); - - AvError error = _Initialize(filter_description, codec_parameters, time_base); - if (error != AvError::NOERROR) { - throw AvException(error); - } -} - -/** - * @brief Deconstruct the Simple Filter object - */ -SimpleFilter::~SimpleFilter() { - FUNCTION_CALL_DEBUG(); - - if(_filter_graph) { - avfilter_graph_free(&_filter_graph); - } - -} - -AvError SimpleFilter::_Initialize(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base) { - FUNCTION_CALL_DEBUG(); - - // Allocate memory for filter graph - _filter_graph = avfilter_graph_alloc(); - if (!_filter_graph) { - return AvError::FILTER_GRAPH_ALLOC; - } - - // Get buffer and buffersink filters - const AVFilter *_buffersrc = avfilter_get_by_name("buffer"); - const AVFilter *_buffersink = avfilter_get_by_name("buffersink"); - - // Define input filter parameters - char args[512]; - snprintf(args, sizeof(args), "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d/%d", - codec_parameters->width, codec_parameters->height, codec_parameters->format, - time_base.num, time_base.den, codec_parameters->sample_aspect_ratio.num, codec_parameters->sample_aspect_ratio.den); - - // Create input filter - int ret = avfilter_graph_create_filter(&_buffersrc_ctx, _buffersrc, "in", args, nullptr, _filter_graph); - if (ret < 0) { - return AvError::FILTER_GRAPH_CREATE_FILTER; - } - - // Create output filter - ret = avfilter_graph_create_filter(&_buffersink_ctx, _buffersink, "out", nullptr, nullptr, _filter_graph); - if (ret < 0) { - return AvError::FILTER_GRAPH_CREATE_FILTER; - } - - // Define input and output filter parameters - AVFilterInOut *outputs = avfilter_inout_alloc(); - AVFilterInOut *inputs = avfilter_inout_alloc(); - - // Set input filter parameters - outputs->name = av_strdup("in"); - outputs->filter_ctx = _buffersrc_ctx; - outputs->pad_idx = 0; - outputs->next = nullptr; - - // Set output filter parameters - inputs->name = av_strdup("out"); - inputs->filter_ctx = _buffersink_ctx; - inputs->pad_idx = 0; - inputs->next = nullptr; - - // Add input and output filters to the graph - if ((ret = avfilter_graph_parse_ptr(_filter_graph, filter_description.c_str(), &inputs, &outputs, nullptr)) < 0) { - return AvError::FILTER_GRAPH_PARSE; - } - - // Configure graph - ret = avfilter_graph_config(_filter_graph, nullptr); - if (ret < 0) { - return AvError::FILTER_GRAPH_ALLOC; - } - - return AvError::NOERROR; -} - -SimpleFilterOutput SimpleFilter::FilterFrame(AVFrame *frame) { - FUNCTION_CALL_DEBUG(); - - std::vector filtered_frames; - - // Create a frame - AVFrame *filtered_frame = av_frame_alloc(); - if (!filtered_frame) { - return {filtered_frames, AvError::FRAMEALLOC}; - } - - // Set the frame parameters - int ret = av_buffersrc_add_frame(_buffersrc_ctx, frame); - if (ret < 0) { - return {filtered_frames, AvError::BUFFERSRC_ADD_FRAME}; - } - - // Pull filtered frames from the sink - while (true) { - ret = av_buffersink_get_frame(_buffersink_ctx, filtered_frame); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { - break; - } - - if (ret < 0) { - return {filtered_frames, AvError::BUFFERSINK_GET_FRAME}; - } - - // Copy the frame - AVFrame *new_frame = av_frame_clone(filtered_frame); - if (!new_frame) { - return {filtered_frames, AvError::FRAMEALLOC}; - } - - filtered_frames.push_back(new_frame); - av_frame_unref(filtered_frame); - } - - av_frame_free(&filtered_frame); - return {filtered_frames, AvError::NOERROR}; -} - -void SimpleFilter::PrintFilters() { - FUNCTION_CALL_DEBUG(); - - // Get the filter list - const AVFilter *filter = nullptr; - void *filter_iterator = nullptr; - - while ((filter = av_filter_iterate(&filter_iterator))) { - PRINT("Filter: %s", filter->name); - } -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/simplefilter.hpp b/src/simplefilter.hpp deleted file mode 100644 index 587569e..0000000 --- a/src/simplefilter.hpp +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @file simplefilter.hpp - * @brief SimpleFilter class - * @version 1.0 - * @date 2024-09-18 - * @author Matthew Todd Geiger - */ - -#pragma once - -#include "averror.hpp" - -extern "C" { -#include -#include -#include -#include -#include -#include -} - -#include -#include -#include - -namespace AV::Utils { - -class SimpleFilter; -using SimpleFilterResult = std::pair, const AvException>; -using SimpleFilterOutput = std::pair, const AvException>; - -class SimpleFilter { -private: - SimpleFilter() = delete; - SimpleFilter(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base); - - AvError _Initialize(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base); - -public: - ~SimpleFilter(); - - static void PrintFilters(); // There if you are curious, but no serious use. - - // Factory methods - static SimpleFilterResult CreateFilter(const std::string &filter_description, const AVCodecParameters *codec_parameters, const AVRational &time_base); - - // Filter methods - SimpleFilterOutput FilterFrame(AVFrame *frame); - -private: - AVFilterGraph *_filter_graph = nullptr; - - AVFilterContext *_buffersrc_ctx = nullptr; - AVFilterContext *_buffersink_ctx = nullptr; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/softwareapp.cpp b/src/softwareapp.cpp deleted file mode 100644 index 98c8c68..0000000 --- a/src/softwareapp.cpp +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @file softwareapp.cpp - * @brief This file includes the main application class. - * @date 2024-09-16 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#include "softwareapp.hpp" -#include "audioresampler.hpp" -#include "averror.hpp" -#include "macro.hpp" -#include "pixelencoder.hpp" - -extern "C" { - #include - #include -} - -AV::Utils::AvException SoftwareApp::Run() { - bool packets_exhausted = false; - bool packet_in_decoder = false; - AVPacket *current_packet = nullptr; - - while(1) { - - // Get packet from demuxer - if(!packet_in_decoder && !packets_exhausted) { - auto [packet, packet_err] = _demuxer->ReadFrame(); - if(packet_err.code()) { - if((AV::Utils::AvError)packet_err.code() == AV::Utils::AvError::DEMUXEREOF) { - DEBUG("Packets exhausted"); - packets_exhausted = true; - } else { - ERROR("Failed to read packet: %s", packet_err.what()); - break; - } - } - - current_packet = packet; - } - - if(packets_exhausted) { - while(!_frame_timer.IsEmpty()) { - DEBUG("Draining frames!"); - auto frame = _frame_timer.GetFrame(); - - auto err = _ndi_source->SendFrame(frame); - if(err.code()) { - ERROR("Failed to send frame: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - - break; - } - - if(current_packet->stream_index == _video_stream_index) { - if(!packet_in_decoder) { - auto err = _video_decoder->FillDecoder(current_packet); - if(err.code()) { - ERROR("Failed to fill video decoder: %s", err.what()); - break; - } - - packet_in_decoder = true; - } - - auto [decoded_frame, decoder_err] = _video_decoder->Decode(); - if(decoder_err.code()) { - if((AV::Utils::AvError)decoder_err.code() == AV::Utils::AvError::DECODEREXHAUSTED) { - DEBUG("Decoder exhausted"); - packet_in_decoder = false; - continue; - } - - ERROR("Failure in decoder: %s", decoder_err.what()); - break; - } - - auto [filtered_frames, filter_err] = _simple_filter->FilterFrame(decoded_frame); - if(filter_err.code()) { - ERROR("Failure in filter: %s", filter_err.what()); - break; - } - - // Add packets to frame timer - for(auto frame : filtered_frames) { - auto err = _frame_timer.AddFrame(frame); - if(err.code()) { - ERROR("Failed to add frame to timer: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - - } else if(current_packet->stream_index == _audio_stream_index) { - if(!packet_in_decoder) { - auto err = _audio_decoder->FillDecoder(current_packet); - if(err.code()) { - ERROR("Failed to fill audio decoder: %s", err.what()); - break; - } - - packet_in_decoder = true; - } - - auto [decoded_frame, decoder_err] = _audio_decoder->Decode(); - if(decoder_err.code()) { - if((AV::Utils::AvError)decoder_err.code() == AV::Utils::AvError::DECODEREXHAUSTED) { - DEBUG("Decoder exhausted"); - packet_in_decoder = false; - continue; - } - - ERROR("Failure in decoder: %s", decoder_err.what()); - break; - } - - auto [resampled_frame, resampled_frame_err] = _audio_resampler->Resample(decoded_frame); - if(resampled_frame_err.code()) { - ERROR("Failure in resampler: %s", resampled_frame_err.what()); - break; - } - - auto err = _frame_timer.AddFrame(resampled_frame); - if (err.code()) { - ERROR("Failed to add frame to timer: %s", err.what()); - break; - } - } - - while(_frame_timer.IsHalf()) { - DEBUG("Sending out frames"); - auto frame = _frame_timer.GetFrame(); - - auto err = _ndi_source->SendFrame(frame); - if(err.code()) { - ERROR("Failed to send frame: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - } - - return AV::Utils::AvError::NOERROR; -} - -SoftwareAppResult SoftwareApp::Create(const std::string &ndi_source_name, const std::string &video_file_path) { - AV::Utils::AvException err; - - try { - return {std::shared_ptr(new SoftwareApp(ndi_source_name, video_file_path)), AV::Utils::AvError::NOERROR}; - } catch(AV::Utils::AvException e) { - err = e; - DEBUG("Error while creating app: %s", e.what()); - } - - return {nullptr, err}; -} - -SoftwareApp::SoftwareApp(const std::string &ndi_source_name, const std::string &video_file_path) : _ndi_source_name(ndi_source_name), _video_file_path(video_file_path) { - auto err = _Initialize(); - if(err != AV::Utils::AvError::NOERROR) { - throw err; - } -} - -AV::Utils::AvError SoftwareApp::_Initialize() { - // Create the demuxer - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create(_video_file_path); - if(demuxer_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Demuxer error: %s", demuxer_err.what()); - return (AV::Utils::AvError)demuxer_err.code(); - } - - _demuxer = std::move(demuxer); - - // Get codecs and stream ids - AVCodecParameters *video_cparam = nullptr, *audio_cparam = nullptr; - int vcount = 0, acount = 0; - AVRational video_time_base{}; - for (auto stream : _demuxer->GetStreamPointers()) { - if(stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { - video_cparam = stream->codecpar; - _video_stream_index = stream->index; - video_time_base = stream->time_base; - vcount++; - } else if(stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { - audio_cparam = stream->codecpar; - _audio_stream_index = stream->index; - acount++; - } - } - - if(acount != 1 && vcount != 1) { - DEBUG("Invalid amount of streams"); - return AV::Utils::AvError::STREAMCOUNT; - } - - // Create the video decoder - auto [video_decoder, video_decoder_err] = AV::Utils::Decoder::Create(video_cparam); - if(video_decoder_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Video decoder error: %s", video_decoder_err.what()); - return (AV::Utils::AvError)video_decoder_err.code(); - } - - _video_decoder = std::move(video_decoder); - - // Create simple filter - const std::string filter_description = "format=uyvy422"; - auto [simple_filter, simple_filter_err] = AV::Utils::SimpleFilter::CreateFilter(filter_description, video_cparam, video_time_base); - if(simple_filter_err.code()) { - DEBUG("Simple filter error: %s", simple_filter_err.what()); - return (AV::Utils::AvError)simple_filter_err.code(); - } - - _simple_filter = std::move(simple_filter); - - - // Create the audio decoder - auto [audio_decoder, audio_decoder_err] = AV::Utils::Decoder::Create(audio_cparam); - if(audio_decoder_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Audio decoder error: %s", audio_decoder_err.what()); - return (AV::Utils::AvError)audio_decoder_err.code(); - } - - _audio_decoder = std::move(audio_decoder); - - // Create the audio resampler - AV::Utils::AudioResamplerConfig audio_resampler_config{}; - audio_resampler_config.srcsamplerate = audio_cparam->sample_rate; - audio_resampler_config.dstsamplerate = audio_cparam->sample_rate; - audio_resampler_config.srcchannellayout = audio_cparam->ch_layout; - audio_resampler_config.dstchannellayout = AV_CHANNEL_LAYOUT_STEREO; - audio_resampler_config.srcsampleformat = (AVSampleFormat)audio_cparam->format; - audio_resampler_config.dstsampleformat = AV_SAMPLE_FMT_S16; - - auto [audio_resampler, audio_resampler_err] = AV::Utils::AudioResampler::Create(audio_resampler_config); - if(audio_resampler_err.code()) { - DEBUG("Audio resampler error: %s", audio_resampler_err.what()); - return (AV::Utils::AvError)audio_resampler_err.code(); - } - - _audio_resampler = std::move(audio_resampler); - - // Create NDI Source - auto [ndi_source, ndi_source_err] = AV::Utils::AsyncNDISource::Create(_ndi_source_name, video_cparam->framerate); - if(ndi_source_err.code()) { - DEBUG("NDI Source error: %s", ndi_source_err.what()); - return (AV::Utils::AvError)ndi_source_err.code(); - } - - _ndi_source = std::move(ndi_source); - - return AV::Utils::AvError::NOERROR; -} \ No newline at end of file diff --git a/src/softwareapp.hpp b/src/softwareapp.hpp deleted file mode 100644 index 9574e90..0000000 --- a/src/softwareapp.hpp +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @file softwareapp.hpp - * @brief This file includes the main application class. - * @date 2024-09-16 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#pragma once - -// Local includes -#include "averror.hpp" -#include "demuxer.hpp" -#include "asyncndisource.hpp" -#include "simplefilter.hpp" -#include "decoder.hpp" -#include "pixelencoder.hpp" -#include "audioresampler.hpp" -#include "frametimer.hpp" -#include "app.hpp" - -extern "C" { - #include -} - -// Standard C++ includes -#include -#include - -class SoftwareApp; -using SoftwareAppResult = std::pair, AV::Utils::AvException>; - -class SoftwareApp : public App { -private: - SoftwareApp(const std::string &ndi_source_name, const std::string &video_file_path); - AV::Utils::AvError _Initialize(); - -public: - ~SoftwareApp() = default; - - // Factory - static SoftwareAppResult Create(const std::string &ndi_source_name, const std::string &video_file_path); - - AV::Utils::AvException Run() override; - -private: - std::string _ndi_source_name; - std::string _video_file_path; - std::shared_ptr _demuxer; - std::shared_ptr _audio_decoder; - std::shared_ptr _video_decoder; - std::shared_ptr _audio_resampler; - std::shared_ptr _ndi_source; - std::shared_ptr _simple_filter; - - AV::Utils::FrameTimer _frame_timer; - - int _video_stream_index = -1; - int _audio_stream_index = -1; -}; \ No newline at end of file diff --git a/src/streamer.cpp b/src/streamer.cpp new file mode 100644 index 0000000..9efa4e8 --- /dev/null +++ b/src/streamer.cpp @@ -0,0 +1,411 @@ +/** + * @file streamer.cpp + * @brief The streaming pipeline: input -> decode -> process -> paced NDI output. + */ + +#include "streamer.hpp" +#include "util/log.hpp" + +#include + +#include + +namespace ndistreamer { + +namespace { + +using Clock = PresentationClock::Clock; +using namespace std::chrono_literals; + +// Queue depths. Packet queues absorb uneven interleaving in the input; frame queues let decoding run +// slightly ahead of presentation to ride out decoder jitter. +constexpr size_t kVideoPacketQueue = 120; +constexpr size_t kAudioPacketQueue = 256; +constexpr size_t kVideoFrameQueue = 8; +constexpr size_t kAudioFrameQueue = 64; + +// The master stream re-anchors the clock when it falls this far behind schedule, or (for live inputs, +// where timestamps can jump) when it runs this far ahead. +constexpr auto kResyncLate = 1s; +constexpr auto kResyncEarly = 5s; + +// Audio is the master clock while it is actually being sent; video takes over when it goes quiet. +constexpr auto kAudioMasterIdle = 1s; + +// A late video frame is dropped when a newer one is already waiting, but the picture never freezes +// for longer than kMaxFreeze. +constexpr auto kMinDropThreshold = 40ms; +constexpr auto kMaxFreeze = 200ms; + +// When decoded video has been late for this long, the decoder skips ahead to keyframes. That sheds +// decoding work and keeps the input flowing, so audio is never starved. +constexpr auto kVideoBehindGrace = 1s; +constexpr auto kMaxDecodeFreeze = 1s; + +// Waiting is done in short steps so a re-anchored clock takes effect promptly. +constexpr auto kMaxSleepStep = 50ms; + +double Seconds(Clock::duration d) { + return std::chrono::duration(d).count(); +} + +int64_t Ticks(Clock::time_point t) { + return t.time_since_epoch().count(); +} + +} // namespace + +Streamer::Streamer(const Options &options, std::stop_source stop) + : m_options(options), m_stop(std::move(stop)), m_video_packets(kVideoPacketQueue), + m_audio_packets(kAudioPacketQueue), m_video_frames(kVideoFrameQueue), m_audio_frames(kAudioFrameQueue) { + // Load NDI first: it is quick and fails fast, while opening a network input may take a while. + m_runtime = ndi::Runtime::Load(options.ndi_library); + log::Info("NDI runtime: {} ({})", m_runtime->Version(), m_runtime->Path()); + + m_input = std::make_unique(options.input, m_stop.get_token()); + m_input->LogInfo(); + + if (AVStream *stream = m_input->VideoStream()) { + m_video_decoder = std::make_unique(stream, options.decoder); + + av::VideoProcessorConfig config; + config.filter = options.video_filter; + config.pixel_format = options.pixel_format; + config.time_base = stream->time_base; + config.frame_rate = m_input->VideoFrameRate(); + config.hw_device = m_video_decoder->HardwareDevice(); + m_video_processor = std::make_unique(std::move(config)); + + std::string hw = m_video_decoder->HardwareName(); + log::Info("video decoder: {} ({})", m_video_decoder->CodecName(), hw.empty() ? "software" : hw); + } + + if (AVStream *stream = m_input->AudioStream()) { + m_audio_decoder = std::make_unique(stream, av::DecoderConfig{}); + + av::AudioProcessorConfig config; + config.sample_rate = options.audio_rate; + config.channels = options.audio_channels; + config.time_base = stream->time_base; + m_audio_processor = std::make_unique(config); + + log::Info("audio decoder: {}", m_audio_decoder->CodecName()); + } + + m_sender = std::make_unique(m_runtime, options.sender); + + int streams = (m_video_decoder ? 1 : 0) + (m_audio_decoder ? 1 : 0); + m_clock = std::make_unique(streams); + m_live = m_input->IsLive(); +} + +Streamer::~Streamer() { + m_stop.request_stop(); + for (auto &thread : m_threads) { + if (thread.joinable()) thread.join(); + } +} + +void Streamer::Start() { + if (m_video_decoder) { + Spawn("nds-video-dec", &Streamer::VideoDecodeLoop); + Spawn("nds-video-out", &Streamer::VideoPresentLoop); + } + if (m_audio_decoder) { + m_audio_presenting = true; + Spawn("nds-audio-dec", &Streamer::AudioDecodeLoop); + Spawn("nds-audio-out", &Streamer::AudioPresentLoop); + } + Spawn("nds-demux", &Streamer::DemuxLoop); +} + +void Streamer::Spawn(const char *name, void (Streamer::*body)(std::stop_token)) { + { + std::lock_guard lock(m_state_mutex); + m_running++; + } + + m_threads.emplace_back([this, name, body] { + pthread_setname_np(pthread_self(), name); + try { + (this->*body)(m_stop.get_token()); + } catch (...) { + Fail(std::current_exception()); + } + + std::lock_guard lock(m_state_mutex); + m_running--; + m_state_cv.notify_all(); + }); +} + +void Streamer::Fail(std::exception_ptr error) { + { + std::lock_guard lock(m_state_mutex); + if (!m_error) m_error = error; + } + m_stop.request_stop(); +} + +bool Streamer::WaitFor(std::chrono::milliseconds timeout) { + std::unique_lock lock(m_state_mutex); + return m_state_cv.wait_for(lock, timeout, [this] { return m_running == 0; }); +} + +void Streamer::Wait() { + for (auto &thread : m_threads) { + if (thread.joinable()) thread.join(); + } + + std::lock_guard lock(m_state_mutex); + if (m_error) std::rethrow_exception(m_error); +} + +void Streamer::DemuxLoop(std::stop_token stop) { + const int video_index = m_input->VideoStream() ? m_input->VideoStream()->index : -1; + + while (!stop.stop_requested()) { + av::PacketPtr packet = m_input->Read(); + if (!packet) { + if (stop.stop_requested() || !m_options.loop || !m_input->Restart()) break; + + m_loops++; + log::Debug("looping input (pass {})", m_loops.load() + 1); + // Decoders drain the previous pass before the restarted bitstream arrives. + if (m_video_decoder && !m_video_packets.Push({nullptr}, stop)) break; + if (m_audio_decoder && !m_audio_packets.Push({nullptr}, stop)) break; + continue; + } + + auto &queue = packet->stream_index == video_index ? m_video_packets : m_audio_packets; + if (!queue.Push({std::move(packet)}, stop)) break; + } + + m_video_packets.Close(); + m_audio_packets.Close(); + log::Debug("input finished"); +} + +template +void Streamer::Decode(av::Decoder &decoder, BoundedQueue &packets, std::stop_token stop, + OnFrame &&on_frame, ShouldSkip &&should_skip) { + auto drain = [&] { + while (av::FramePtr frame = decoder.Receive()) { + on_frame(std::move(frame)); + if (stop.stop_requested()) return false; + } + return true; + }; + + bool skipping = false; + while (auto item = packets.Pop(stop)) { + if (!item->packet) { + // The input looped: flush out the end of the previous pass, then reset for the new one. + decoder.Send(nullptr); + if (!drain()) return; + decoder.Reset(); + continue; + } + + // Discarding packets up to the next keyframe sheds work when decoding can't keep up. + if (!skipping && should_skip()) skipping = true; + if (skipping) { + if (!(item->packet->flags & AV_PKT_FLAG_KEY)) { + m_video_skipped++; + continue; + } + skipping = false; + } + + // A full decoder must hand out frames before it takes more input. + int attempts = 0; + while (!decoder.Send(item->packet.get())) { + if (!drain()) return; + if (++attempts > 8) { + log::Warn("{} decoder is not accepting input; skipping a packet", decoder.CodecName()); + break; + } + } + if (!drain()) return; + } + + if (stop.stop_requested()) return; + decoder.Send(nullptr); + drain(); +} + +void Streamer::VideoDecodeLoop(std::stop_token stop) { + const av::VideoProcessor::Sink sink = [&](VideoFrame &&frame) { return m_video_frames.Push(std::move(frame), stop); }; + const AVRational time_base = m_input->VideoStream()->time_base; + const AVRational frame_rate = m_input->VideoFrameRate(); + const auto drop_threshold = std::max( + std::chrono::microseconds(2 * av_rescale(1000000, frame_rate.den, frame_rate.num)), kMinDropThreshold); + + // Under overload, shed work in two steps. While packets are backing up, decoded frames that are + // already late skip filtering and conversion (at least one frame is still shown every + // kMaxDecodeFreeze). If decoding itself stays behind for kVideoBehindGrace, packets are skipped up + // to the next keyframe. Without a backlog the source itself is late, and dropping would not help. + Clock::time_point last_emitted{}; + Clock::time_point behind_since{}; + Clock::time_point last_warning{}; + + auto on_frame = [&](av::FramePtr frame) { + int64_t ts = frame->best_effort_timestamp != AV_NOPTS_VALUE ? frame->best_effort_timestamp : frame->pts; + if (m_options.pacing && ts != AV_NOPTS_VALUE && m_clock->Started()) { + auto now = Clock::now(); + bool behind = now - m_clock->DueTime(av::ToMicros(ts, time_base)) > drop_threshold && + m_video_packets.Size() > 0; + if (!behind) { + behind_since = {}; + } else if (behind_since == Clock::time_point{}) { + behind_since = now; + } + if (behind && now - last_emitted < kMaxDecodeFreeze) { + m_video_dropped++; + return; + } + } + m_video_processor->Process(std::move(frame), sink); + last_emitted = Clock::now(); + }; + + auto should_skip = [&] { + if (behind_since == Clock::time_point{} || Clock::now() - behind_since < kVideoBehindGrace) return false; + if (Clock::now() - last_warning > 10s) { + log::Warn("video decoding cannot keep up in real time; skipping ahead to keyframes"); + last_warning = Clock::now(); + } + return true; + }; + + Decode(*m_video_decoder, m_video_packets, stop, on_frame, should_skip); + if (!stop.stop_requested()) m_video_processor->Flush(sink); + + m_video_frames.Close(); +} + +void Streamer::AudioDecodeLoop(std::stop_token stop) { + const av::AudioProcessor::Sink sink = [&](AudioFrame &&frame) { return m_audio_frames.Push(std::move(frame), stop); }; + + Decode( + *m_audio_decoder, m_audio_packets, stop, [&](av::FramePtr frame) { m_audio_processor->Process(frame.get(), sink); }, + [] { return false; }); + if (!stop.stop_requested()) m_audio_processor->Flush(sink); + + m_audio_frames.Close(); +} + +bool Streamer::AudioIsMaster() const { + if (!m_audio_presenting.load()) return false; + int64_t last = m_audio_last_sent.load(); + return last != 0 && Clock::now() - Clock::time_point(Clock::duration(last)) < kAudioMasterIdle; +} + +bool Streamer::WaitUntilDue(int64_t pts_us, bool is_audio, std::stop_token stop) { + std::mutex mutex; + std::condition_variable_any cv; + + while (true) { + auto now = Clock::now(); + auto due = m_clock->DueTime(pts_us); + + // Only the master moves the clock; a slave waits for (or drops behind) the master's schedule. + bool master = is_audio || !AudioIsMaster(); + bool far_behind = now - due > kResyncLate; + bool far_ahead = m_live && due - now > kResyncEarly; + if (master && (far_behind || far_ahead)) { + m_resyncs++; + log::Warn("{} is {:.2f} s {} schedule; resynchronizing", is_audio ? "audio" : "video", + Seconds(far_behind ? now - due : due - now), far_behind ? "behind" : "ahead of"); + m_clock->Resync(pts_us, now); + return true; + } + if (now >= due) return true; + + // Sleep in short steps so a re-anchored clock or a change of master takes effect promptly. + std::unique_lock lock(mutex); + cv.wait_until(lock, stop, std::min(due, now + kMaxSleepStep), [] { return false; }); + if (stop.stop_requested()) return false; + } +} + +void Streamer::VideoPresentLoop(std::stop_token stop) { + bool started = false; + Clock::time_point last_sent{}; + + while (auto frame = m_video_frames.Pop(stop)) { + if (!started) { + if (!m_clock->WaitForStart(frame->pts_us, stop)) break; + started = true; + } + + if (m_options.pacing) { + const auto now = Clock::now(); + const auto lateness = now - m_clock->DueTime(frame->pts_us); + const auto drop_threshold = std::max(std::chrono::microseconds(2 * frame->duration_us), + kMinDropThreshold); + const bool late = lateness > drop_threshold; + + // Dropping only helps when a newer frame is ready; otherwise this is the freshest picture. + bool resync_pending = !AudioIsMaster() && lateness > kResyncLate; + if (late && !resync_pending && m_video_frames.Size() > 0 && now - last_sent < kMaxFreeze) { + m_video_dropped++; + log::Trace("dropped video frame {:.3f} s late", Seconds(lateness)); + continue; + } + if (!WaitUntilDue(frame->pts_us, false, stop)) break; + } + + int64_t timecode = m_clock->Timecode(frame->pts_us); + m_sender->SendVideo(std::move(*frame), timecode); + last_sent = Clock::now(); + m_video_sent++; + } + + if (!started) m_clock->StreamFinished(); + m_sender->FlushVideo(); +} + +void Streamer::AudioPresentLoop(std::stop_token stop) { + bool started = false; + + while (auto frame = m_audio_frames.Pop(stop)) { + if (!started) { + if (!m_clock->WaitForStart(frame->pts_us, stop)) break; + started = true; + } + + if (m_options.pacing && !WaitUntilDue(frame->pts_us, true, stop)) break; + + m_sender->SendAudio(*frame, m_clock->Timecode(frame->pts_us)); + m_audio_last_sent = Ticks(Clock::now()); + m_audio_sent++; + } + + if (!started) m_clock->StreamFinished(); + m_audio_presenting = false; +} + +StreamerStats Streamer::Stats() const { + StreamerStats stats; + stats.video_sent = m_video_sent.load(); + stats.video_dropped = m_video_dropped.load(); + stats.audio_sent = m_audio_sent.load(); + stats.video_skipped = m_video_skipped.load(); + stats.loops = m_loops.load(); + stats.resyncs = m_resyncs.load(); + stats.video_queued = m_video_frames.Size(); + stats.audio_queued = m_audio_frames.Size(); + stats.connections = m_sender->Connections(); + return stats; +} + +void Streamer::LogStats() const { + StreamerStats stats = Stats(); + log::Info("stats: video {} sent, {} dropped, {} packets skipped | audio {} blocks | queued v{}/{} a{}/{} | " + "loops {} | resyncs {} | receivers {}", + stats.video_sent, stats.video_dropped, stats.video_skipped, stats.audio_sent, stats.video_queued, + kVideoFrameQueue, stats.audio_queued, kAudioFrameQueue, stats.loops, stats.resyncs, stats.connections); +} + +} // namespace ndistreamer diff --git a/src/streamer.hpp b/src/streamer.hpp new file mode 100644 index 0000000..8a7c52f --- /dev/null +++ b/src/streamer.hpp @@ -0,0 +1,146 @@ +/** + * @file streamer.hpp + * @brief The streaming pipeline: input -> decode -> process -> paced NDI output. + */ + +#pragma once + +#include "av/audio_processor.hpp" +#include "av/decoder.hpp" +#include "av/input.hpp" +#include "av/video_processor.hpp" +#include "frames.hpp" +#include "ndi/sender.hpp" +#include "options.hpp" +#include "util/clock.hpp" +#include "util/queue.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ndistreamer { + +struct StreamerStats { + uint64_t video_sent = 0; + uint64_t video_dropped = 0; + uint64_t video_skipped = 0; ///< Packets not decoded because video could not keep up. + uint64_t audio_sent = 0; + uint64_t loops = 0; + uint64_t resyncs = 0; + size_t video_queued = 0; + size_t audio_queued = 0; + int connections = 0; +}; + +/** + * @brief Owns every pipeline stage and the threads that run them. + * + * demux --> video packets --> video decode+process --> video frames --> video presenter --+ + * \-> audio packets --> audio decode+process --> audio frames --> audio presenter --+--> NDI + * + * Stages are connected by bounded queues, so a slow consumer applies back-pressure upstream. + * Presenters release frames at their media timestamps using a shared PresentationClock. Audio is the + * master clock while it is being sent. Late video frames are dropped when newer ones are ready. If video + * cannot keep up, late frames skip filtering and conversion, and if decoding itself is too slow the + * decoder skips ahead to keyframes, so audio is never starved. + */ +class Streamer { +public: + /** + * Opens the input, the decoders and the NDI source. Throws on failure. + * @param stop requesting a stop ends streaming (and interrupts blocking input I/O) + */ + Streamer(const Options &options, std::stop_source stop); + ~Streamer(); + + Streamer(const Streamer &) = delete; + Streamer &operator=(const Streamer &) = delete; + + void Start(); + + /** + * @brief Wait up to @p timeout for every stage to finish. + * @return true once streaming has ended (input exhausted, stopped, or failed). + */ + bool WaitFor(std::chrono::milliseconds timeout); + + /** + * @brief Join all threads; rethrows the first error raised by any stage. + */ + void Wait(); + + StreamerStats Stats() const; + void LogStats() const; + + std::string SourceName() const { return m_sender->SourceName(); } + +private: + struct PacketItem { + av::PacketPtr packet; ///< nullptr marks a discontinuity (the input looped) + }; + + void Spawn(const char *name, void (Streamer::*body)(std::stop_token)); + void Fail(std::exception_ptr error); + + void DemuxLoop(std::stop_token stop); + void VideoDecodeLoop(std::stop_token stop); + void AudioDecodeLoop(std::stop_token stop); + void VideoPresentLoop(std::stop_token stop); + void AudioPresentLoop(std::stop_token stop); + + template + void Decode(av::Decoder &decoder, BoundedQueue &packets, std::stop_token stop, OnFrame &&on_frame, + ShouldSkip &&should_skip); + + /** + * @brief Sleep until @p pts_us is due, following clock re-anchoring while waiting. + * The master stream re-anchors the clock when it is far off schedule. + * @return false if stopped while waiting + */ + bool WaitUntilDue(int64_t pts_us, bool is_audio, std::stop_token stop); + + bool AudioIsMaster() const; + + Options m_options; + std::stop_source m_stop; + + std::shared_ptr m_runtime; + std::unique_ptr m_input; + std::unique_ptr m_video_decoder; + std::unique_ptr m_audio_decoder; + std::unique_ptr m_video_processor; + std::unique_ptr m_audio_processor; + std::unique_ptr m_sender; + std::unique_ptr m_clock; + + BoundedQueue m_video_packets; + BoundedQueue m_audio_packets; + BoundedQueue m_video_frames; + BoundedQueue m_audio_frames; + + std::vector m_threads; + std::mutex m_state_mutex; + std::condition_variable m_state_cv; + int m_running = 0; + std::exception_ptr m_error; + + bool m_live = false; + std::atomic m_audio_presenting{false}; + std::atomic m_audio_last_sent{0}; ///< steady_clock ticks; 0 before the first block + std::atomic m_video_sent{0}; + std::atomic m_video_dropped{0}; + std::atomic m_video_skipped{0}; + std::atomic m_audio_sent{0}; + std::atomic m_loops{0}; + std::atomic m_resyncs{0}; +}; + +} // namespace ndistreamer diff --git a/src/util/clock.cpp b/src/util/clock.cpp new file mode 100644 index 0000000..fa3ce14 --- /dev/null +++ b/src/util/clock.cpp @@ -0,0 +1,85 @@ +/** + * @file clock.cpp + * @brief Shared presentation clock that maps media timestamps onto wall-clock time. + */ + +#include "util/clock.hpp" + +#include + +namespace ndistreamer { + +namespace { + +int64_t UtcNow100ns(PresentationClock::Clock::time_point at) { + using namespace std::chrono; + auto offset = at - PresentationClock::Clock::now(); + auto utc = system_clock::now() + duration_cast(offset); + return duration_cast(utc.time_since_epoch()).count() / 100; +} + +} // namespace + +PresentationClock::PresentationClock(int streams, Clock::duration preroll, Clock::duration start_timeout) + : m_expected(streams), m_preroll(preroll), m_start_timeout(start_timeout) {} + +bool PresentationClock::WaitForStart(int64_t first_pts_us, std::stop_token stop) { + std::unique_lock lock(m_mutex); + + m_ready++; + m_min_first_pts = std::min(m_min_first_pts, first_pts_us); + if (!m_first_ready) m_first_ready = Clock::now(); + m_cv.notify_all(); + + while (!m_started) { + auto now = Clock::now(); + auto deadline = *m_first_ready + m_start_timeout; + if (m_ready >= m_expected || now >= deadline) { + StartLocked(now); + break; + } + + m_cv.wait_until(lock, stop, deadline, [this] { return m_started || m_ready >= m_expected; }); + if (stop.stop_requested()) return false; + } + + return true; +} + +void PresentationClock::StreamFinished() { + std::lock_guard lock(m_mutex); + m_expected--; + m_cv.notify_all(); +} + +void PresentationClock::StartLocked(Clock::time_point now) { + m_origin_us = m_min_first_pts; + m_epoch = now + m_preroll; + m_utc_epoch_100ns = UtcNow100ns(m_epoch); + m_started = true; + m_cv.notify_all(); +} + +PresentationClock::Clock::time_point PresentationClock::DueTime(int64_t pts_us) const { + std::lock_guard lock(m_mutex); + return m_epoch + std::chrono::microseconds(pts_us - m_origin_us); +} + +int64_t PresentationClock::Timecode(int64_t pts_us) const { + std::lock_guard lock(m_mutex); + return m_utc_epoch_100ns + (pts_us - m_origin_us) * 10; +} + +void PresentationClock::Resync(int64_t pts_us, Clock::time_point when) { + std::lock_guard lock(m_mutex); + auto offset_us = pts_us - m_origin_us; + m_epoch = when - std::chrono::microseconds(offset_us); + m_utc_epoch_100ns = UtcNow100ns(when) - offset_us * 10; +} + +bool PresentationClock::Started() const { + std::lock_guard lock(m_mutex); + return m_started; +} + +} // namespace ndistreamer diff --git a/src/util/clock.hpp b/src/util/clock.hpp new file mode 100644 index 0000000..c55d9b4 --- /dev/null +++ b/src/util/clock.hpp @@ -0,0 +1,89 @@ +/** + * @file clock.hpp + * @brief Shared presentation clock that maps media timestamps onto wall-clock time. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ndistreamer { + +/** + * @brief Maps media timestamps (microseconds) to send deadlines and NDI timecodes. + * + * Every output stream (video, audio) owns a presenter that calls WaitForStart() with its first + * timestamp. Playback starts once all expected streams are ready, or shortly after the first one if + * another is slow to produce data. The earliest first timestamp becomes the media origin, so streams + * that start later in the file keep their relative offset. + */ +class PresentationClock { +public: + using Clock = std::chrono::steady_clock; + + /** + * @param streams number of presenters that will call WaitForStart() + * @param preroll delay between the start decision and the first deadline, letting queues fill + * @param start_timeout how long to wait for slower streams after the first one is ready + */ + explicit PresentationClock(int streams, Clock::duration preroll = std::chrono::milliseconds(100), + Clock::duration start_timeout = std::chrono::milliseconds(500)); + + /** + * @brief Report a stream's first timestamp and block until playback starts. + * @return false if a stop was requested while waiting. + */ + bool WaitForStart(int64_t first_pts_us, std::stop_token stop); + + /** + * @brief Report that a stream ended without ever calling WaitForStart(). + */ + void StreamFinished(); + + /** + * @brief Wall-clock deadline at which the given media timestamp should be sent. + */ + Clock::time_point DueTime(int64_t pts_us) const; + + /** + * @brief NDI timecode (100 ns units, UTC based) for the given media timestamp. + * + * Deriving timecodes from media timestamps keeps audio and video exactly aligned for receivers that + * synchronize on timecode, independent of send jitter. + */ + int64_t Timecode(int64_t pts_us) const; + + /** + * @brief Re-anchor the clock so that @p pts_us is due at @p when. + * Used to recover from timestamp discontinuities or from falling far behind real time. + */ + void Resync(int64_t pts_us, Clock::time_point when); + + bool Started() const; + +private: + void StartLocked(Clock::time_point now); + + mutable std::mutex m_mutex; + std::condition_variable_any m_cv; + + int m_expected; + int m_ready = 0; + bool m_started = false; + std::optional m_first_ready; + int64_t m_min_first_pts = INT64_MAX; + + const Clock::duration m_preroll; + const Clock::duration m_start_timeout; + + int64_t m_origin_us = 0; + Clock::time_point m_epoch{}; + int64_t m_utc_epoch_100ns = 0; +}; + +} // namespace ndistreamer diff --git a/src/util/log.cpp b/src/util/log.cpp new file mode 100644 index 0000000..1715c16 --- /dev/null +++ b/src/util/log.cpp @@ -0,0 +1,137 @@ +/** + * @file log.cpp + * @brief Thread-safe, leveled logging to stderr. + */ + +#include "util/log.hpp" + +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +namespace ndistreamer::log { + +namespace { + +std::atomic g_level{Level::Info}; +std::mutex g_write_mutex; + +const char *LevelName(Level level) { + switch (level) { + case Level::Error: + return "error"; + case Level::Warn: + return "warn "; + case Level::Info: + return "info "; + case Level::Debug: + return "debug"; + case Level::Trace: + return "trace"; + } + return "?????"; +} + +// FFmpeg reports many recoverable conditions (corrupt packets, missing references after a seek) as +// errors. We handle failures ourselves, so its messages are demoted by one level. +Level FromFFmpegLevel(int level) { + if (level <= AV_LOG_ERROR) return Level::Warn; + if (level <= AV_LOG_WARNING) return Level::Debug; + return Level::Trace; +} + +int ToFFmpegLevel(Level level) { + switch (level) { + case Level::Error: + case Level::Warn: + case Level::Info: + return AV_LOG_ERROR; + case Level::Debug: + return AV_LOG_WARNING; + case Level::Trace: + return AV_LOG_VERBOSE; + } + return AV_LOG_ERROR; +} + +void FFmpegCallback(void *avcl, int level, const char *fmt, va_list args) { + if (level > av_log_get_level()) return; + + // FFmpeg may build one line out of several calls, so buffer until we see a newline. + thread_local std::string pending; + thread_local int print_prefix = 1; + + char line[1024]; + int prefix = print_prefix; + va_list copy; + va_copy(copy, args); + int length = av_log_format_line2(avcl, level, fmt, copy, line, sizeof(line), &print_prefix); + va_end(copy); + + if (length >= static_cast(sizeof(line))) { + // Too long for the stack buffer: format again into one that fits, so the newline is kept. + std::string long_line(static_cast(length) + 1, '\0'); + print_prefix = prefix; + av_log_format_line2(avcl, level, fmt, args, long_line.data(), length + 1, &print_prefix); + long_line.resize(static_cast(length)); + pending += long_line; + } else if (length > 0) { + pending += line; + } + + if (pending.empty() || pending.back() != '\n') return; + + pending.pop_back(); + if (!pending.empty()) { + Level ours = FromFFmpegLevel(level); + if (Enabled(ours)) Write(ours, "ffmpeg: " + pending); + } + pending.clear(); +} + +} // namespace + +void SetLevel(Level level) { + g_level.store(level, std::memory_order_relaxed); + av_log_set_level(ToFFmpegLevel(level)); +} + +Level GetLevel() { + return g_level.load(std::memory_order_relaxed); +} + +bool Enabled(Level level) { + return level <= GetLevel(); +} + +void Write(Level level, std::string_view message) { + using namespace std::chrono; + + auto now = system_clock::now(); + auto millis = duration_cast(now.time_since_epoch()).count() % 1000; + std::time_t seconds = system_clock::to_time_t(now); + std::tm local{}; + localtime_r(&seconds, &local); + + char stamp[16]; + std::strftime(stamp, sizeof(stamp), "%H:%M:%S", &local); + + std::lock_guard lock(g_write_mutex); + std::fprintf(stderr, "[%s.%03lld] [%s] %.*s\n", stamp, static_cast(millis), LevelName(level), + static_cast(message.size()), message.data()); +} + +void InstallFFmpegBridge() { + av_log_set_level(ToFFmpegLevel(GetLevel())); + av_log_set_callback(FFmpegCallback); +} + +} // namespace ndistreamer::log diff --git a/src/util/log.hpp b/src/util/log.hpp new file mode 100644 index 0000000..b36fd06 --- /dev/null +++ b/src/util/log.hpp @@ -0,0 +1,62 @@ +/** + * @file log.hpp + * @brief Thread-safe, leveled logging to stderr. + */ + +#pragma once + +#include +#include +#include + +namespace ndistreamer::log { + +enum class Level { + Error = 0, + Warn, + Info, + Debug, + Trace, +}; + +void SetLevel(Level level); +Level GetLevel(); +bool Enabled(Level level); + +/** + * @brief Write a pre-formatted message. Prefer the typed helpers below. + */ +void Write(Level level, std::string_view message); + +/** + * @brief Route FFmpeg's internal log output through this logger. + * FFmpeg verbosity follows the current log level, so call it after SetLevel(). + */ +void InstallFFmpegBridge(); + +template +void Error(std::format_string fmt, Args &&...args) { + if (Enabled(Level::Error)) Write(Level::Error, std::format(fmt, std::forward(args)...)); +} + +template +void Warn(std::format_string fmt, Args &&...args) { + if (Enabled(Level::Warn)) Write(Level::Warn, std::format(fmt, std::forward(args)...)); +} + +template +void Info(std::format_string fmt, Args &&...args) { + if (Enabled(Level::Info)) Write(Level::Info, std::format(fmt, std::forward(args)...)); +} + +template +void Debug(std::format_string fmt, Args &&...args) { + if (Enabled(Level::Debug)) Write(Level::Debug, std::format(fmt, std::forward(args)...)); +} + +template +void Trace(std::format_string fmt, Args &&...args) { + if (Enabled(Level::Trace)) Write(Level::Trace, std::format(fmt, std::forward(args)...)); +} + +} // namespace ndistreamer::log diff --git a/src/util/queue.hpp b/src/util/queue.hpp new file mode 100644 index 0000000..71ddab5 --- /dev/null +++ b/src/util/queue.hpp @@ -0,0 +1,116 @@ +/** + * @file queue.hpp + * @brief Bounded, closable, multi-producer/multi-consumer blocking queue. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace ndistreamer { + +/** + * @brief A bounded FIFO that connects pipeline stages. + * + * Producers block while the queue is full, which provides back-pressure. Consumers block while it is + * empty. Close() marks the end of the stream: pending items can still be drained, after which Pop() + * returns std::nullopt. Every blocking call also returns early when its stop token is triggered. + */ +template +class BoundedQueue { +public: + explicit BoundedQueue(std::size_t capacity) : m_capacity(capacity == 0 ? 1 : capacity) {} + + BoundedQueue(const BoundedQueue &) = delete; + BoundedQueue &operator=(const BoundedQueue &) = delete; + + /** + * @brief Append an item, waiting for space if necessary. + * @return false if the queue was closed or a stop was requested; the item is discarded. + */ + bool Push(T item, std::stop_token stop) { + std::unique_lock lock(m_mutex); + if (!m_not_full.wait(lock, stop, [this] { return m_closed || m_items.size() < m_capacity; })) { + return false; + } + if (m_closed) return false; + + m_items.push_back(std::move(item)); + lock.unlock(); + m_not_empty.notify_one(); + return true; + } + + enum class PushResult { Pushed, TimedOut, Closed }; + + /** + * @brief Append an item, waiting at most @p timeout for space. + * @p item is moved from only when the result is Pushed. Closed also covers stop requests. + */ + template + PushResult PushFor(T &item, std::chrono::duration timeout, std::stop_token stop) { + std::unique_lock lock(m_mutex); + bool ready = m_not_full.wait_for(lock, stop, timeout, + [this] { return m_closed || m_items.size() < m_capacity; }); + if (m_closed || stop.stop_requested()) return PushResult::Closed; + if (!ready) return PushResult::TimedOut; + + m_items.push_back(std::move(item)); + lock.unlock(); + m_not_empty.notify_one(); + return PushResult::Pushed; + } + + /** + * @brief Remove the oldest item, waiting for one if necessary. + * @return std::nullopt once the queue is closed and drained, or if a stop was requested. + */ + std::optional Pop(std::stop_token stop) { + std::unique_lock lock(m_mutex); + if (!m_not_empty.wait(lock, stop, [this] { return m_closed || !m_items.empty(); })) { + return std::nullopt; + } + if (m_items.empty()) return std::nullopt; + + T item = std::move(m_items.front()); + m_items.pop_front(); + lock.unlock(); + m_not_full.notify_one(); + return item; + } + + /** + * @brief Signal that no more items will be pushed. Wakes all waiters. + */ + void Close() { + { + std::lock_guard lock(m_mutex); + m_closed = true; + } + m_not_empty.notify_all(); + m_not_full.notify_all(); + } + + std::size_t Size() const { + std::lock_guard lock(m_mutex); + return m_items.size(); + } + + std::size_t Capacity() const { return m_capacity; } + +private: + mutable std::mutex m_mutex; + std::condition_variable_any m_not_empty; + std::condition_variable_any m_not_full; + std::deque m_items; + const std::size_t m_capacity; + bool m_closed = false; +}; + +} // namespace ndistreamer diff --git a/src/vaapiapp.cpp b/src/vaapiapp.cpp deleted file mode 100644 index 1865313..0000000 --- a/src/vaapiapp.cpp +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @file vaapiapp.cpp - * @brief This file includes the main application class. - * @date 2024-09-19 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#include "vaapiapp.hpp" -#include "audioresampler.hpp" -#include "averror.hpp" -#include "macro.hpp" -#include "pixelencoder.hpp" - -extern "C" { - #include - #include -} - -AV::Utils::AvException VAAPIApp::Run() { - bool packets_exhausted = false; - bool packet_in_decoder = false; - AVPacket *current_packet = nullptr; - - while(1) { - - // Get packet from demuxer - if(!packet_in_decoder && !packets_exhausted) { - auto [packet, packet_err] = _demuxer->ReadFrame(); - if(packet_err.code()) { - if((AV::Utils::AvError)packet_err.code() == AV::Utils::AvError::DEMUXEREOF) { - DEBUG("Packets exhausted"); - packets_exhausted = true; - } else { - ERROR("Failed to read packet: %s", packet_err.what()); - break; - } - } - - current_packet = packet; - } - - if(packets_exhausted) { - while(!_frame_timer.IsEmpty()) { - DEBUG("Draining frames!"); - auto frame = _frame_timer.GetFrame(); - - auto err = _ndi_source->SendFrame(frame); - if(err.code()) { - ERROR("Failed to send frame: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - - break; - } - - if(current_packet->stream_index == _video_stream_index) { - if(!packet_in_decoder) { - auto err = _vaapi_video_decoder->FillVAAPIDecoder(current_packet); - if(err.code()) { - ERROR("Failed to fill video decoder: %s", err.what()); - break; - } - - packet_in_decoder = true; - } - - auto [decoded_frame, decoder_err] = _vaapi_video_decoder->Decode(); - if(decoder_err.code()) { - if((AV::Utils::AvError)decoder_err.code() == AV::Utils::AvError::DECODEREXHAUSTED) { - DEBUG("Decoder exhausted"); - packet_in_decoder = false; - continue; - } - - ERROR("Failure in decoder: %s", decoder_err.what()); - break; - } - - auto err = _frame_timer.AddFrame(decoded_frame); - if(err.code()) { - ERROR("Failed to add frame to timer: %s", err.what()); - break; - } - - } else if(current_packet->stream_index == _audio_stream_index) { - if(!packet_in_decoder) { - auto err = _audio_decoder->FillDecoder(current_packet); - if(err.code()) { - ERROR("Failed to fill audio decoder: %s", err.what()); - break; - } - - packet_in_decoder = true; - } - - auto [decoded_frame, decoder_err] = _audio_decoder->Decode(); - if(decoder_err.code()) { - if((AV::Utils::AvError)decoder_err.code() == AV::Utils::AvError::DECODEREXHAUSTED) { - DEBUG("Decoder exhausted"); - packet_in_decoder = false; - continue; - } - - ERROR("Failure in decoder: %s", decoder_err.what()); - break; - } - - auto [resampled_frame, resampled_frame_err] = _audio_resampler->Resample(decoded_frame); - if(resampled_frame_err.code()) { - ERROR("Failure in resampler: %s", resampled_frame_err.what()); - break; - } - - auto err = _frame_timer.AddFrame(resampled_frame); - if (err.code()) { - ERROR("Failed to add frame to timer: %s", err.what()); - break; - } - } - - while(_frame_timer.IsHalf()) { - DEBUG("Sending out frames"); - auto frame = _frame_timer.GetFrame(); - - auto err = _ndi_source->SendFrame(frame); - if(err.code()) { - ERROR("Failed to send frame: %s", err.what()); - break; - } - - av_frame_free(&frame); - } - } - - return AV::Utils::AvError::NOERROR; -} - -VAAPIAppResult VAAPIApp::Create(const std::string &ndi_source_name, const std::string &video_file_path) { - AV::Utils::AvException err; - - try { - return {std::shared_ptr(new VAAPIApp(ndi_source_name, video_file_path)), AV::Utils::AvError::NOERROR}; - } catch(AV::Utils::AvException e) { - err = e; - DEBUG("Error while creating app: %s", e.what()); - } - - return {nullptr, err}; -} - -VAAPIApp::VAAPIApp(const std::string &ndi_source_name, const std::string &video_file_path) : _ndi_source_name(ndi_source_name), _video_file_path(video_file_path) { - auto err = _Initialize(); - if(err != AV::Utils::AvError::NOERROR) { - throw err; - } -} - -AV::Utils::AvError VAAPIApp::_Initialize() { - // Create the demuxer - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create(_video_file_path); - if(demuxer_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Demuxer error: %s", demuxer_err.what()); - return (AV::Utils::AvError)demuxer_err.code(); - } - - _demuxer = std::move(demuxer); - - // Get codecs and stream ids - AVCodecParameters *video_cparam = nullptr, *audio_cparam = nullptr; - int vcount = 0, acount = 0; - AVRational video_time_base{}; - for (auto stream : _demuxer->GetStreamPointers()) { - if(stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { - video_cparam = stream->codecpar; - _video_stream_index = stream->index; - video_time_base = stream->time_base; - vcount++; - } else if(stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { - audio_cparam = stream->codecpar; - _audio_stream_index = stream->index; - acount++; - } - } - - if(acount != 1 && vcount != 1) { - DEBUG("Invalid amount of streams"); - return AV::Utils::AvError::STREAMCOUNT; - } - - // Create the video decoder - auto [vaapi_video_decoder, vaapi_video_decoder_err] = AV::Utils::VAAPIDecoder::Create(video_cparam); - if(vaapi_video_decoder_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Video decoder error: %s", vaapi_video_decoder_err.what()); - return (AV::Utils::AvError)vaapi_video_decoder_err.code(); - } - - _vaapi_video_decoder = std::move(vaapi_video_decoder); - - // Create the audio decoder - auto [audio_decoder, audio_decoder_err] = AV::Utils::Decoder::Create(audio_cparam); - if(audio_decoder_err.code() != (int)AV::Utils::AvError::NOERROR) { - DEBUG("Audio decoder error: %s", audio_decoder_err.what()); - return (AV::Utils::AvError)audio_decoder_err.code(); - } - - _audio_decoder = std::move(audio_decoder); - - // Create the audio resampler - AV::Utils::AudioResamplerConfig audio_resampler_config{}; - audio_resampler_config.srcsamplerate = audio_cparam->sample_rate; - audio_resampler_config.dstsamplerate = audio_cparam->sample_rate; - audio_resampler_config.srcchannellayout = audio_cparam->ch_layout; - audio_resampler_config.dstchannellayout = AV_CHANNEL_LAYOUT_STEREO; - audio_resampler_config.srcsampleformat = (AVSampleFormat)audio_cparam->format; - audio_resampler_config.dstsampleformat = AV_SAMPLE_FMT_S16; - - auto [audio_resampler, audio_resampler_err] = AV::Utils::AudioResampler::Create(audio_resampler_config); - if(audio_resampler_err.code()) { - DEBUG("Audio resampler error: %s", audio_resampler_err.what()); - return (AV::Utils::AvError)audio_resampler_err.code(); - } - - _audio_resampler = std::move(audio_resampler); - - // Create NDI Source - auto [ndi_source, ndi_source_err] = AV::Utils::AsyncNDISource::Create(_ndi_source_name, video_cparam->framerate); - if(ndi_source_err.code()) { - DEBUG("NDI Source error: %s", ndi_source_err.what()); - return (AV::Utils::AvError)ndi_source_err.code(); - } - - _ndi_source = std::move(ndi_source); - - return AV::Utils::AvError::NOERROR; -} \ No newline at end of file diff --git a/src/vaapiapp.hpp b/src/vaapiapp.hpp deleted file mode 100644 index e1cead3..0000000 --- a/src/vaapiapp.hpp +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @file vaapiapp.hpp - * @brief This file includes the main application class. - * @date 2024-09-19 - * @author Matthew Todd Geiger - * @version 1.0 - */ - -#pragma once - -// Local includes -#include "averror.hpp" -#include "demuxer.hpp" -#include "asyncndisource.hpp" -#include "decoder.hpp" -#include "pixelencoder.hpp" -#include "audioresampler.hpp" -#include "frametimer.hpp" -#include "vaapidecoder.hpp" -#include "app.hpp" - -extern "C" { - #include -} - -// Standard C++ includes -#include -#include - -class VAAPIApp; -using VAAPIAppResult = std::pair, AV::Utils::AvException>; - -class VAAPIApp : public App { -private: - VAAPIApp(const std::string &ndi_source_name, const std::string &video_file_path); - AV::Utils::AvError _Initialize(); - -public: - ~VAAPIApp() = default; - - // Factory - static VAAPIAppResult Create(const std::string &ndi_source_name, const std::string &video_file_path); - - AV::Utils::AvException Run(); - -private: - std::string _ndi_source_name; - std::string _video_file_path; - std::shared_ptr _demuxer; - std::shared_ptr _audio_decoder; - std::shared_ptr _vaapi_video_decoder; - std::shared_ptr _audio_resampler; - std::shared_ptr _ndi_source; - - AV::Utils::FrameTimer _frame_timer; - - int _video_stream_index = -1; - int _audio_stream_index = -1; -}; \ No newline at end of file diff --git a/src/vaapidecoder.cpp b/src/vaapidecoder.cpp deleted file mode 100644 index 3fa2355..0000000 --- a/src/vaapidecoder.cpp +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @file VAAPIDecoder.cpp - * @brief This file includes the VAAPIDecoder class. - * @version 1.0 - * @date 2024-09-19 - * @author Matthew Todd Geiger - */ - -#include "vaapidecoder.hpp" -#include "macro.hpp" - -#include - -namespace AV::Utils { - -/** - * @brief Get the frame rate of the decoder - * - * @return CodecFrameRate - */ -CodecFrameRate VAAPIDecoder::GetFrameRate() { - FUNCTION_CALL_DEBUG(); - - return {m_codec->framerate.num, m_codec->framerate.den}; -} - -/** - * @brief Fill the decoder with a packet - * - * @param packet The packet to fill the decoder with - * @return AvException - */ -AvException VAAPIDecoder::FillVAAPIDecoder(AVPacket *packet) { - FUNCTION_CALL_DEBUG(); - - int ret = avcodec_send_packet(m_codec, packet); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return AvException(AvError::SENDPACKET); - } - - DEBUG("VAAPIDecoder Filled"); - - return AvException(AvError::NOERROR); -} - -/** - * @brief Decode a packet - * - * @return VAAPIDecoderOutput - */ -VAAPIDecoderOutput VAAPIDecoder::Decode() { - FUNCTION_CALL_DEBUG(); -#ifdef _DEBUG - // Profile function - auto time_start = std::chrono::high_resolution_clock::now(); -#endif - - av_frame_unref(m_last_frame); - - AVFrame *tmp_frame = av_frame_alloc(); - - // Recieve frame from decoder - int ret = avcodec_receive_frame(m_codec, tmp_frame); - if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { - DEBUG("VAAPIDecoder exhausted"); - return {nullptr, AvException(AvError::DECODEREXHAUSTED)}; - } else if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::RECIEVEFRAME)}; - } - - // Print out tmp frame info - DEBUG("Temp Frame: %dx%d, format: %s, pts: %ld", tmp_frame->width, tmp_frame->height, av_get_pix_fmt_name((AVPixelFormat)tmp_frame->format), tmp_frame->pts); - - // Load from off gpu if needed - ret = av_hwframe_transfer_data(m_last_frame, tmp_frame, 0); - if (ret < 0) { - PRINT_FFMPEG_ERR(ret); - return {nullptr, AvException(AvError::HWFRAME_TRANSFER)}; - } - - m_last_frame->pts = tmp_frame->pts; - - av_frame_free(&tmp_frame); - -#ifdef _DEBUG - // Profile function - auto time_end = std::chrono::high_resolution_clock::now(); - DEBUG("VAAPI Decode time (seconds): %f", std::chrono::duration(time_end - time_start).count()); -#endif - - // Print frame info - DEBUG("Frame: %dx%d, format: %s", m_last_frame->width, m_last_frame->height, av_get_pix_fmt_name((AVPixelFormat)m_last_frame->format)); - DEBUG("Time base: %d/%d", m_last_frame->time_base.num, m_last_frame->time_base.den); - - // Print some pixel data from Y and UV - uint8_t *y_data = m_last_frame->data[0]; - uint8_t *uv_data = m_last_frame->data[1]; - DEBUG("First Y bytes: %02x %02x %02x %02x", y_data[0], y_data[1], y_data[2], y_data[3]); - DEBUG("First UV bytes: %02x %02x %02x %02x", uv_data[0], uv_data[1], uv_data[2], uv_data[3]); - - return {m_last_frame, AvException(AvError::NOERROR)}; -} - -/** - * @brief Create a VAAPIDecoder object - * - * @param codecpar codec parameters - * @return VAAPIDecoderResult - */ -VAAPIDecoderResult VAAPIDecoder::Create(AVCodecParameters *codecpar) { - FUNCTION_CALL_DEBUG(); - - AvException error; - - // Create a new decoder object, return nullopt if error - try { - return {std::unique_ptr(new VAAPIDecoder(codecpar)), error}; - } catch (AvException e) { - error = e; - DEBUG("VAAPIDecoder error: %s", error.what()); - } - - return {nullptr, error}; -} - -/** - * @brief Construct a new VAAPIDecoder:: VAAPIDecoder object - * - * @param codec_id codec ID - */ -VAAPIDecoder::VAAPIDecoder(AVCodecParameters *codecpar) : m_codecpar(codecpar) { - FUNCTION_CALL_DEBUG(); - - AvError err = m_Initialize(); - if (err != AvError::NOERROR) { - throw err; - } -} - - -/** - * @brief Destroy the VAAPIDecoder:: VAAPIDecoder object - */ -VAAPIDecoder::~VAAPIDecoder() { - FUNCTION_CALL_DEBUG(); - - // Free the frame - if (m_last_frame) { - av_frame_free(&m_last_frame); - DEBUG("av_frame_free called"); - } - - // Close and free the decoder context - if (m_codec) { - avcodec_close(m_codec); - avcodec_free_context(&m_codec); - DEBUG("avcodec_free_context called"); - } - - if(m_hw_device_ctx) { - av_buffer_unref(&m_hw_device_ctx); - } -} -/** - * @brief Initialize the decoder - * - * @return AvError - */ -AvError VAAPIDecoder::m_Initialize() { - FUNCTION_CALL_DEBUG(); - - // Create hardware device - int ret = av_hwdevice_ctx_create(&m_hw_device_ctx, AV_HWDEVICE_TYPE_VAAPI, nullptr, nullptr, 0); - if (ret < 0) { - DEBUG("av_hwdevice_ctx_create failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::HWDEVICECTXALLOC; - } - - // Find the appropriate decoder - const AVCodec *codec = avcodec_find_decoder(m_codecpar->codec_id); - if (!codec) { - DEBUG("avcodec_find_decoder failed"); - return AvError::FINDDECODER; - } - - // Allocate the codec context - m_codec = avcodec_alloc_context3(codec); - if (!m_codec) { - DEBUG("avcodec_alloc_context3 failed"); - return AvError::DECODERALLOC; - } - - // Copy the codec parameters - ret = avcodec_parameters_to_context(m_codec, m_codecpar); - if (ret < 0) { - DEBUG("avcodec_parameters_to_context failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::DECPARAMS; - } - - // Attach hardware device to decoder - m_codec->hw_device_ctx = av_buffer_ref(m_hw_device_ctx); - - // Open the decoder context - ret = avcodec_open2(m_codec, codec, nullptr); - if (ret < 0) { - DEBUG("avcodec_open2 failed"); - PRINT_FFMPEG_ERR(ret); - return AvError::DECPARAMS; - } - - // Allocate frame - m_last_frame = av_frame_alloc(); - if (!m_last_frame) { - DEBUG("av_frame_alloc failed"); - return AvError::FRAMEALLOC; - } - - return AvError::NOERROR; -} - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/vaapidecoder.hpp b/src/vaapidecoder.hpp deleted file mode 100644 index ede7d77..0000000 --- a/src/vaapidecoder.hpp +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @file VAAPIDecoder.hpp - * @brief This file includes the VAAPIDecoder class. - * @version 1.0 - * @date 2024-09-19 - * @author Matthew Todd Geiger - */ - -#pragma once - -// Local dependencies -#include "averror.hpp" -#include "demuxer.hpp" - -// 3rd party dependencies -extern "C" { -#include -#include -#include -} - -// Standard C++ dependencies -#include -#include - -namespace AV::Utils { - -class VAAPIDecoder; -using VAAPIDecoderResult = std::pair, const AvException>; -using VAAPIDecoderOutput = std::pair; -using CodecFrameRate = std::pair; - -/** - * @brief The VAAPIDecoder class provides utilities for decoding media files. - */ -class VAAPIDecoder { -private: - VAAPIDecoder(AVCodecParameters *codecpar); - -public: - /** - * @brief Destroy the VAAPIDecoder:: VAAPIDecoder object - */ - ~VAAPIDecoder(); - - /** - * @brief Create a VAAPIDecoder object - * - * @param codecpar codec parameters - * @return VAAPIDecoderResult - */ - static VAAPIDecoderResult Create(AVCodecParameters *codecpar); - - // Setters - /** - * @brief Fill the VAAPIDecoder with a packet - * - * @param packet The packet to fill the VAAPIDecoder with - * @return AvException - */ - AvException FillVAAPIDecoder(AVPacket *packet); - - /** - * @brief Decode a packet. If the VAAPIDecoder needs another packet, it will return - * an error code of VAAPIDecoderEXHAUSTED - * - * @return VAAPIDecoderOutput - */ - VAAPIDecoderOutput Decode(); - - // Getters - /** - * @brief Get the frame rate of the VAAPIDecoder - * - * @return CodecFrameRate - */ - CodecFrameRate GetFrameRate(); - - AVPixelFormat GetPixelFormat(); - -private: - AvError m_Initialize(); - - // Store the codec parameters - AVCodecParameters *m_codecpar = nullptr; - - // This is the codec context that ffmpeg uses to decode the media file - AVCodecContext *m_codec = nullptr; - - // This is the last frame that was decoded - AVFrame *m_last_frame = nullptr; - - // Reference to the hardware device context - AVBufferRef *m_hw_device_ctx = nullptr; - - AVPixelFormat m_hw_pix_fmt = AV_PIX_FMT_NONE; -}; - -} // namespace AV::Utils \ No newline at end of file diff --git a/src/version.hpp.in b/src/version.hpp.in new file mode 100644 index 0000000..e9a56b6 --- /dev/null +++ b/src/version.hpp.in @@ -0,0 +1,8 @@ +/** + * @file version.hpp + * @brief Generated by CMake from version.hpp.in. + */ + +#pragma once + +#define NDISTREAMER_VERSION "@PROJECT_VERSION@" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5a5ac99..37d32e5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,54 +1,105 @@ -# tests/CMakeLists.txt -cmake_minimum_required(VERSION 3.14) +find_package(GTest QUIET) +if(NOT GTest_FOUND) + message(WARNING "GoogleTest not found; tests are disabled") + return() +endif() +include(GoogleTest) -# Download test video -set(VIDEO_URL "http://castus-public-resources.s3-website-us-west-2.amazonaws.com/content/videos/mp4/rickroll.mp4") -set(VIDEO_DEST "${CMAKE_CURRENT_BINARY_DIR}/testcontent/rickroll.mp4") +# Fake NDI runtime ------------------------------------------------------------- +# Built as libndi.so.6 so the real loader code paths are exercised. +add_library(fake_ndi SHARED fake_ndi/fake_ndi.cpp) +target_include_directories(fake_ndi PRIVATE ${PROJECT_SOURCE_DIR}/src) +set_target_properties(fake_ndi PROPERTIES + OUTPUT_NAME ndi + VERSION 6.0.0 + SOVERSION 6 + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/fake_ndi) -file(DOWNLOAD ${VIDEO_URL} ${VIDEO_DEST} - SHOW_PROGRESS - STATUS video_download_status) +add_library(not_ndi SHARED fake_ndi/not_ndi.cpp) +set_target_properties(not_ndi PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/not_ndi) -# Make sure the video is downloaded before running the tests -add_custom_target(download_video ALL - DEPENDS ${VIDEO_DEST}) +# Test media ----------------------------------------------------------------- +set(TEST_MEDIA_DIR ${CMAKE_CURRENT_BINARY_DIR}/media) +find_program(FFMPEG_EXECUTABLE ffmpeg) +if(FFMPEG_EXECUTABLE) + add_test(NAME generate_test_media + COMMAND ${CMAKE_COMMAND} -DFFMPEG=${FFMPEG_EXECUTABLE} -DOUTPUT_DIR=${TEST_MEDIA_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/media/generate_media.cmake) + set_tests_properties(generate_test_media PROPERTIES FIXTURES_SETUP media) +else() + message(WARNING "ffmpeg CLI not found; tests that need media files will be skipped") +endif() -find_package(GTest REQUIRED) +# Unit and integration tests --------------------------------------------------- +add_executable(ndistreamer_tests + support/main.cpp + support/test_support.cpp + test_audio_processor.cpp + test_clock.cpp + test_input.cpp + test_log.cpp + test_options.cpp + test_queue.cpp + test_runtime.cpp + test_streamer.cpp + test_video_processor.cpp) +target_include_directories(ndistreamer_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(ndistreamer_tests PRIVATE ndistreamer_core ndistreamer_warnings GTest::gtest) +target_compile_definitions(ndistreamer_tests PRIVATE + FAKE_NDI_LIBRARY="$" + FAKE_NDI_DIR="$" + NOT_NDI_LIBRARY="$" + TEST_MEDIA_DIR="${TEST_MEDIA_DIR}") +add_dependencies(ndistreamer_tests fake_ndi not_ndi) -add_executable(demuxer_test demuxer_test.cpp ../src/demuxer.cpp ../src/averror.cpp) -add_executable(decoder_test decoder_test.cpp ../src/decoder.cpp ../src/averror.cpp ../src/demuxer.cpp) -add_executable(pixelencoder_test pixelencoder_test.cpp ../src/pixelencoder.cpp ../src/decoder.cpp ../src/averror.cpp ../src/demuxer.cpp) -add_executable(audioresampler_test audioresampler_test.cpp ../src/audioresampler.cpp ../src/averror.cpp ../src/decoder ../src/demuxer.cpp) +gtest_discover_tests(ndistreamer_tests + PROPERTIES FIXTURES_REQUIRED media + DISCOVERY_TIMEOUT 30) -add_dependencies(demuxer_test download_video) -add_dependencies(decoder_test download_video) -add_dependencies(pixelencoder_test download_video) -add_dependencies(audioresampler_test download_video) +# Command line smoke tests ----------------------------------------------------- +add_test(NAME cli_help COMMAND ndistreamer --help) +set_tests_properties(cli_help PROPERTIES PASS_REGULAR_EXPRESSION "Usage:") -include_directories(${NDI_INCLUDE_DIR} ${FFMPEG_INCLUDE_DIRS} ${GTEST_INCLUDE_DIRS} ../src) +add_test(NAME cli_version COMMAND ndistreamer --version) +set_tests_properties(cli_version PROPERTIES PASS_REGULAR_EXPRESSION "ndistreamer ${PROJECT_VERSION}") -target_link_libraries(demuxer_test PRIVATE GTest::gtest GTest::gtest_main ${FFMPEG_LIBRARIES}) -target_link_libraries(decoder_test PRIVATE GTest::gtest GTest::gtest_main ${FFMPEG_LIBRARIES}) -target_link_libraries(pixelencoder_test PRIVATE GTest::gtest GTest::gtest_main ${FFMPEG_LIBRARIES}) -target_link_libraries(audioresampler_test PRIVATE GTest::gtest GTest::gtest_main ${FFMPEG_LIBRARIES}) +add_test(NAME cli_rejects_bad_usage COMMAND ndistreamer --bogus) +set_tests_properties(cli_rejects_bad_usage PROPERTIES WILL_FAIL TRUE) -# Set up demuxer tests -add_test(NAME demuxer_test COMMAND demuxer_test) -add_test(NAME valgrind_demuxer_test - COMMAND valgrind --leak-check=full --error-exitcode=1 --show-reachable=no $) +add_test(NAME cli_reports_missing_input + COMMAND ndistreamer -i ${TEST_MEDIA_DIR}/does-not-exist.mp4 --ndi-lib $) +set_tests_properties(cli_reports_missing_input PROPERTIES WILL_FAIL TRUE) -# Set up decoder tests -add_test(NAME decoder_test COMMAND decoder_test) -add_test(NAME valgrind_decoder_test - COMMAND valgrind --leak-check=full --error-exitcode=1 --show-reachable=no $) +if(FFMPEG_EXECUTABLE) + add_test(NAME cli_streams_file + COMMAND ndistreamer -i ${TEST_MEDIA_DIR}/av.mp4 --ndi-lib $ --stats=1) + set_tests_properties(cli_streams_file PROPERTIES + FIXTURES_REQUIRED media + PASS_REGULAR_EXPRESSION "end of input reached") +endif() -# Set up pixelencoder tests -add_test(NAME pixelencoder_test COMMAND pixelencoder_test) -add_test(NAME valgrind_pixelencoder_test - COMMAND valgrind --leak-check=full --error-exitcode=1 --show-reachable=no $) +# Tests against the real NDI SDK (optional) ------------------------------------ +find_package(NDISDK) +if(NDISDK_FOUND) + message(STATUS "NDI SDK found: ${NDISDK_INCLUDE_DIR}; enabling ABI and end-to-end tests") -# Set up audioresampler tests -add_test(NAME audioresampler_test COMMAND audioresampler_test) -add_test(NAME valgrind_audioresampler_test - COMMAND valgrind --leak-check=full --error-exitcode=1 --show-reachable=no $) + # Compiling this verifies src/ndi/abi.hpp against the official headers. + add_executable(ndi_abi_test ndi_abi_test.cpp) + target_include_directories(ndi_abi_test PRIVATE ${PROJECT_SOURCE_DIR}/src ${NDISDK_INCLUDE_DIR}) + target_link_libraries(ndi_abi_test PRIVATE PkgConfig::FFMPEG) + add_test(NAME ndi_abi COMMAND ndi_abi_test) + add_executable(ndi_receiver e2e/ndi_receiver.cpp) + target_link_libraries(ndi_receiver PRIVATE NDISDK::NDISDK) + get_filename_component(_ndi_lib_dir "${NDISDK_LIBRARY}" DIRECTORY) + set_target_properties(ndi_receiver PROPERTIES BUILD_RPATH "${_ndi_lib_dir}") + + if(NDISDK_BIN_DIR AND FFMPEG_EXECUTABLE) + add_test(NAME ndi_end_to_end + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/e2e/run_e2e.sh + $ $ ${_ndi_lib_dir} + ${NDISDK_BIN_DIR}/ndi-discovery-server ${TEST_MEDIA_DIR}/av.mp4 + ${CMAKE_CURRENT_BINARY_DIR}/e2e) + set_tests_properties(ndi_end_to_end PROPERTIES FIXTURES_REQUIRED media TIMEOUT 60 RUN_SERIAL TRUE) + endif() +endif() diff --git a/tests/audioresampler_test.cpp b/tests/audioresampler_test.cpp deleted file mode 100644 index 43136bb..0000000 --- a/tests/audioresampler_test.cpp +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @file audioresampler_test.cpp - * @brief This file includes tests for the AudioResampler class. - * @date 2024-09-07 - * @author Matthew Todd Geiger - */ - -#include - -#include "audioresampler.hpp" -#include "decoder.hpp" -#include "demuxer.hpp" - -TEST(AudioResamplerTest, ResampleMultipleFrames) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto streams = demuxer->GetStreamPointers(); - - AVCodecParameters *codecpar = streams[1]->codecpar; - - auto [decoder, decoder_err] = AV::Utils::Decoder::Create(codecpar); - - AV::Utils::AudioResamplerConfig config; - config.srcsamplerate = streams[1]->codecpar->sample_rate; - config.dstsamplerate = 48000; - config.srcchannellayout = streams[1]->codecpar->ch_layout; - config.dstchannellayout = AV_CHANNEL_LAYOUT_STEREO; - config.srcsampleformat = (AVSampleFormat)streams[1]->codecpar->format; - config.dstsampleformat = AV_SAMPLE_FMT_FLTP; - - auto [resampler, resampler_err] = AV::Utils::AudioResampler::Create(config); - - for (int i = 0; i < 6; i++) { - // Grab correct packet - AVPacket *pkt = nullptr; - do { - auto [packet, packet_err] = demuxer->ReadFrame(); - if (packet->stream_index == 1) { - pkt = packet; - } - - } while (pkt == nullptr); - - // Fill Decoder - auto fill_err = decoder->FillDecoder(pkt); - EXPECT_EQ(fill_err.code(), 0); - - while (true) { - auto [frame, frame_err] = decoder->Decode(); - if (frame_err.code() == (int)AV::Utils::AvError::DECODEREXHAUSTED) { - break; - } - EXPECT_EQ(frame_err.code(), 0); - - auto [resampled_frame, resampled_frame_err] = resampler->Resample(frame); - EXPECT_EQ(resampled_frame_err.code(), 0); - } - } -} - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/decoder_test.cpp b/tests/decoder_test.cpp deleted file mode 100644 index b1b7233..0000000 --- a/tests/decoder_test.cpp +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @file decoder_test.cpp - * @brief This file includes tests for the Decoder class. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#include - -#include "decoder.hpp" - -TEST(DecoderTest, CreateDecoderSimple) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto streams = demuxer->GetStreamPointers(); - - AVCodecParameters *codecpar = streams[0]->codecpar; - - auto [decoder, decoder_err] = AV::Utils::Decoder::Create(codecpar); - EXPECT_EQ(decoder_err.code(), 0); -} - -TEST(DecoderTest, DecodeSinglePacket) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto streams = demuxer->GetStreamPointers(); - - AVCodecParameters *codecpar = streams[0]->codecpar; - - auto [decoder, decoder_err] = AV::Utils::Decoder::Create(codecpar); - - // Grab correct packet - AVPacket *pkt = nullptr; - do { - auto [packet, packet_err] = demuxer->ReadFrame(); - if (packet->stream_index == 0) { - pkt = packet; - } - - } while (pkt == nullptr); - - // Fill Decoder - auto fill_err = decoder->FillDecoder(pkt); - EXPECT_EQ(fill_err.code(), 0); - - while (true) { - auto [frame, frame_err] = decoder->Decode(); - if (frame_err.code() == (int)AV::Utils::AvError::DECODEREXHAUSTED) { - break; - } - EXPECT_EQ(frame_err.code(), 0); - } -} - -TEST(DecoderTest, DecodeMultiplePackets) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto streams = demuxer->GetStreamPointers(); - - AVCodecParameters *codecpar = streams[0]->codecpar; - - auto [decoder, decoder_err] = AV::Utils::Decoder::Create(codecpar); - - for (int i = 0; i < 3; i++) { - // Grab correct packet - AVPacket *pkt = nullptr; - do { - auto [packet, packet_err] = demuxer->ReadFrame(); - if (packet->stream_index == 0) { - pkt = packet; - } - - } while (pkt == nullptr); - - // Fill Decoder - auto fill_err = decoder->FillDecoder(pkt); - EXPECT_EQ(fill_err.code(), 0); - - while (true) { - auto [frame, frame_err] = decoder->Decode(); - if (frame_err.code() == (int)AV::Utils::AvError::DECODEREXHAUSTED) { - break; - } - EXPECT_EQ(frame_err.code(), 0); - } - } -} - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/demuxer_test.cpp b/tests/demuxer_test.cpp deleted file mode 100644 index e9b3c3e..0000000 --- a/tests/demuxer_test.cpp +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @file demuxer_test.cpp - * @brief This file includes tests for the Demuxer class. - * @date 2024-09-05 - * @author Matthew Todd Geiger - */ - -#include - -#include "demuxer.hpp" - -TEST(DemuxerTest, CreateDemuxerAuto) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - EXPECT_EQ(demuxer_err.code(), 0); -} - -TEST(DemuxerTest, ReadSingleFrame) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto [packet, packet_err] = demuxer->ReadFrame(); - EXPECT_EQ(packet_err.code(), 0); -} - -TEST(DemuxerTest, ReadMultipleFrames) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto [packet, packet_err] = demuxer->ReadFrame(); - EXPECT_EQ(packet_err.code(), 0); - auto [packet2, packet_err2] = demuxer->ReadFrame(); - EXPECT_EQ(packet_err2.code(), 0); - auto [packet3, packet_err3] = demuxer->ReadFrame(); - EXPECT_EQ(packet_err3.code(), 0); -} - -TEST(DemuxerTest, GetStreamPointers) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto streams = demuxer->GetStreamPointers(); - EXPECT_EQ(streams.size(), 2); -} - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/e2e/ndi_receiver.cpp b/tests/e2e/ndi_receiver.cpp new file mode 100644 index 0000000..55f1b78 --- /dev/null +++ b/tests/e2e/ndi_receiver.cpp @@ -0,0 +1,94 @@ +/** + * @file ndi_receiver.cpp + * @brief Test helper: receive an NDI source and report what arrived. + * + * Usage: ndi_receiver SOURCE_NAME SECONDS + * + * Finds a source whose name contains SOURCE_NAME, receives for SECONDS and prints one line per frame: + * video + * audio + * Exits 1 if the source cannot be found. Built only when the NDI SDK is available. + */ + +// The SDK headers rely on these being included first. +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) { + if (argc != 3) { + std::fprintf(stderr, "usage: %s SOURCE_NAME SECONDS\n", argv[0]); + return 2; + } + const std::string wanted = argv[1]; + const double seconds = std::atof(argv[2]); + + if (!NDIlib_initialize()) return 1; + + NDIlib_find_instance_t finder = NDIlib_find_create_v2(); + const NDIlib_source_t *match = nullptr; + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(15); + while (!match && std::chrono::steady_clock::now() < deadline) { + NDIlib_find_wait_for_sources(finder, 500); + uint32_t count = 0; + const NDIlib_source_t *sources = NDIlib_find_get_current_sources(finder, &count); + for (uint32_t i = 0; i < count; i++) { + if (std::strstr(sources[i].p_ndi_name, wanted.c_str())) match = &sources[i]; + } + } + if (!match) { + std::fprintf(stderr, "source containing '%s' not found\n", wanted.c_str()); + return 1; + } + std::fprintf(stderr, "connecting to %s (%s)\n", match->p_ndi_name, match->p_url_address); + + NDIlib_recv_create_v3_t create; + create.source_to_connect_to = *match; + create.color_format = NDIlib_recv_color_format_fastest; + create.bandwidth = NDIlib_recv_bandwidth_highest; + NDIlib_recv_instance_t receiver = NDIlib_recv_create_v3(&create); + NDIlib_find_destroy(finder); + if (!receiver) return 1; + + auto start = std::chrono::steady_clock::now(); + auto end = start + std::chrono::duration(seconds); + while (std::chrono::steady_clock::now() < end) { + NDIlib_video_frame_v2_t video; + NDIlib_audio_frame_v2_t audio; + double ms = 0; + switch (NDIlib_recv_capture_v2(receiver, &video, &audio, nullptr, 100)) { + case NDIlib_frame_type_video: + ms = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + std::printf("video %.1f %d %d %.4s %d %d %lld %d\n", ms, video.xres, video.yres, + reinterpret_cast(&video.FourCC), video.frame_rate_N, video.frame_rate_D, + static_cast(video.timecode), static_cast(video.frame_format_type)); + NDIlib_recv_free_video_v2(receiver, &video); + break; + case NDIlib_frame_type_audio: { + ms = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + double sum = 0; + for (int i = 0; i < audio.no_samples; i++) sum += audio.p_data[i] * audio.p_data[i]; + double rms = audio.no_samples ? std::sqrt(sum / audio.no_samples) : 0; + std::printf("audio %.1f %d %d %d %lld %.4f\n", ms, audio.sample_rate, audio.no_channels, + audio.no_samples, static_cast(audio.timecode), rms); + NDIlib_recv_free_audio_v2(receiver, &audio); + break; + } + default: + break; + } + std::fflush(stdout); + } + + NDIlib_recv_destroy(receiver); + NDIlib_destroy(); + return 0; +} diff --git a/tests/e2e/run_e2e.sh b/tests/e2e/run_e2e.sh new file mode 100755 index 0000000..742cb4d --- /dev/null +++ b/tests/e2e/run_e2e.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# End-to-end test against the real NDI runtime: ndistreamer sends a looping clip and ndi_receiver +# receives it over the network stack. A private NDI configuration and a local discovery server are +# used, so neither avahi nor changes to ~/.ndi are needed. +# +# Usage: run_e2e.sh NDISTREAMER NDI_RECEIVER NDI_LIB_DIR DISCOVERY_SERVER MEDIA WORK_DIR + +set -euo pipefail + +streamer=$1 +receiver=$2 +ndi_lib=$3 +discovery=$4 +media=$5 +work=$6 + +mkdir -p "$work/config" +export NDI_CONFIG_DIR="$work/config" +echo '{ "ndi": { "networks": { "discovery": "127.0.0.1" } } }' > "$NDI_CONFIG_DIR/ndi-config.v1.json" + +pids=() +cleanup() { + for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done + wait 2>/dev/null || true +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" + echo "--- ndistreamer log"; cat "$work/streamer.log" || true + echo "--- receiver log"; cat "$work/receiver.log" || true + exit 1 +} + +# An already running discovery server on this machine works just as well. +"$discovery" > "$work/discovery.log" 2>&1 & +pids+=($!) +sleep 1 + +name="ndistreamer-e2e-$$" +"$streamer" -i "$media" --loop -s "$name" --ndi-lib "$ndi_lib" > "$work/streamer.log" 2>&1 & +streamer_pid=$! +pids+=("$streamer_pid") + +"$receiver" "$name" 3 > "$work/received.txt" 2> "$work/receiver.log" || fail "receiver could not connect" + +kill -INT "$streamer_pid" +wait "$streamer_pid" || fail "ndistreamer did not exit cleanly after SIGINT" +grep -q "stopped" "$work/streamer.log" || fail "ndistreamer did not report a clean stop" + +video=$(grep -c '^video' "$work/received.txt" || true) +audio=$(grep -c '^audio' "$work/received.txt" || true) +echo "received $video video frames and $audio audio blocks in 3 s" + +# 25 fps for 3 s is 75 frames; connecting takes a moment. +[ "$video" -ge 60 ] && [ "$video" -le 80 ] || fail "expected about 75 video frames, got $video" +# 48 kHz in 1024-sample blocks for 3 s is about 140 blocks. +[ "$audio" -ge 110 ] || fail "expected about 140 audio blocks, got $audio" + +awk '$1 == "video" && !($3 == 640 && $4 == 360 && $5 == "UYVY" && $6 == 25 && $7 == 1 && $9 == 1) { bad++ } + END { exit bad > 0 }' "$work/received.txt" || fail "unexpected video format" + +awk '$1 == "audio" && !($3 == 48000 && $4 == 2) { bad++ } + $1 == "audio" && $7 > 0.03 { loud++ } + END { exit bad > 0 || loud == 0 }' "$work/received.txt" || fail "unexpected or silent audio" + +# Timecodes advance exactly one frame at a time, except for at most a short wait at a loop point. +awk '$1 == "video" { if (prev && ($8 - prev < 400000 || $8 - prev > 650000)) bad++; + if (prev && $8 - prev != 400000) odd++; prev = $8 } + END { exit bad > 0 || odd > 2 }' "$work/received.txt" || fail "video timecodes are not continuous" + +echo "PASS" diff --git a/tests/fake_ndi/fake_ndi.cpp b/tests/fake_ndi/fake_ndi.cpp new file mode 100644 index 0000000..70e1dfd --- /dev/null +++ b/tests/fake_ndi/fake_ndi.cpp @@ -0,0 +1,175 @@ +/** + * @file fake_ndi.cpp + * @brief Stand-in for libndi.so used by the test suite. + * + * Implements the sending entry points ndistreamer uses and writes one line per call to the file named + * by FAKE_NDI_LOG (opened when a sender is created): + * + * create ||| + * metadata + * video + * audio + * flush + * destroy + * + * Like the real runtime it keeps reading an asynchronously submitted video frame until the next + * synchronizing call, so a frame released too early shows up as a use-after-free under AddressSanitizer. + * Setting FAKE_NDI_UNSUPPORTED_CPU makes NDIlib_is_supported_CPU() fail. + */ + +#include "ndi/abi.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace ndistreamer::ndi::abi; + +namespace { + +struct FakeSender { + std::mutex mutex; + std::FILE *log = nullptr; + std::string full_name; + Source source{}; + const uint8_t *pending = nullptr; + size_t pending_size = 0; + uint64_t checksum = 0; +}; + +long long NowNs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +size_t FrameBytes(const VideoFrameV2 *frame) { + size_t stride = static_cast(frame->line_stride_in_bytes); + size_t rows = static_cast(frame->yres); + size_t chroma_rows = (rows + 1) / 2; + switch (frame->FourCC) { + case 0x3231564E: // NV12 + return stride * rows + stride * chroma_rows; + case 0x30323449: // I420 + return stride * rows + 2 * (stride / 2) * chroma_rows; + default: // single plane + return stride * rows; + } +} + +// Touch every byte, as NDI's encoder would. +uint64_t Read(const uint8_t *data, size_t size) { + uint64_t sum = 0; + for (size_t i = 0; i < size; i++) sum += data[i]; + return sum; +} + +void FinishPending(FakeSender *sender) { + if (!sender->pending) return; + sender->checksum += Read(sender->pending, sender->pending_size); + sender->pending = nullptr; +} + +} // namespace + +#define FAKE_API extern "C" __attribute__((visibility("default"))) + +FAKE_API bool NDIlib_initialize() { + return true; +} + +FAKE_API void NDIlib_destroy() {} + +FAKE_API const char *NDIlib_version() { + return "FAKE NDI 6.0.0"; +} + +FAKE_API bool NDIlib_is_supported_CPU() { + return std::getenv("FAKE_NDI_UNSUPPORTED_CPU") == nullptr; +} + +FAKE_API SendInstance NDIlib_send_create(const SendCreate *create) { + auto *sender = new FakeSender; + if (const char *path = std::getenv("FAKE_NDI_LOG")) sender->log = std::fopen(path, "w"); + + sender->full_name = std::string("FAKEHOST (") + (create && create->p_ndi_name ? create->p_ndi_name : "") + ")"; + sender->source.p_ndi_name = sender->full_name.c_str(); + sender->source.p_url_address = "127.0.0.1:5961"; + + if (sender->log && create) { + std::fprintf(sender->log, "create %s|%s|%d|%d\n", create->p_ndi_name ? create->p_ndi_name : "", + create->p_groups ? create->p_groups : "", create->clock_video, create->clock_audio); + } + return reinterpret_cast(sender); +} + +FAKE_API void NDIlib_send_destroy(SendInstance instance) { + auto *sender = reinterpret_cast(instance); + FinishPending(sender); + if (sender->log) { + std::fprintf(sender->log, "destroy\n"); + std::fclose(sender->log); + } + delete sender; +} + +FAKE_API void NDIlib_send_send_video_async_v2(SendInstance instance, const VideoFrameV2 *frame) { + auto *sender = reinterpret_cast(instance); + std::lock_guard lock(sender->mutex); + + // Synchronizing call: the previous frame must still be readable until now. + FinishPending(sender); + + if (!frame) { + if (sender->log) std::fprintf(sender->log, "flush\n"); + return; + } + + sender->pending = frame->p_data; + sender->pending_size = FrameBytes(frame); + + if (sender->log) { + const char *fourcc = reinterpret_cast(&frame->FourCC); + std::fprintf(sender->log, "video %lld %d %d %.4s %d %d %.4f %d %lld %d\n", NowNs(), frame->xres, frame->yres, + fourcc, frame->frame_rate_N, frame->frame_rate_D, frame->picture_aspect_ratio, + static_cast(frame->frame_format_type), static_cast(frame->timecode), + frame->line_stride_in_bytes); + } +} + +FAKE_API void NDIlib_send_send_audio_v2(SendInstance instance, const AudioFrameV2 *frame) { + auto *sender = reinterpret_cast(instance); + std::lock_guard lock(sender->mutex); + + double sum = 0; + for (int c = 0; c < frame->no_channels; c++) { + const float *channel = reinterpret_cast(reinterpret_cast(frame->p_data) + + static_cast(c) * frame->channel_stride_in_bytes); + for (int i = 0; i < frame->no_samples; i++) { + if (c == 0) sum += static_cast(channel[i]) * channel[i]; + else sender->checksum += channel[i] != 0.0f; // read every channel + } + } + double rms = frame->no_samples > 0 ? std::sqrt(sum / frame->no_samples) : 0.0; + + if (sender->log) { + std::fprintf(sender->log, "audio %lld %d %d %d %d %lld %.5f\n", NowNs(), frame->sample_rate, frame->no_channels, + frame->no_samples, frame->channel_stride_in_bytes, static_cast(frame->timecode), rms); + } +} + +FAKE_API int NDIlib_send_get_no_connections(SendInstance, uint32_t) { + return 1; +} + +FAKE_API const Source *NDIlib_send_get_source_name(SendInstance instance) { + return &reinterpret_cast(instance)->source; +} + +FAKE_API void NDIlib_send_add_connection_metadata(SendInstance instance, const MetadataFrame *metadata) { + auto *sender = reinterpret_cast(instance); + if (sender->log && metadata && metadata->p_data) std::fprintf(sender->log, "metadata %s\n", metadata->p_data); +} diff --git a/tests/fake_ndi/not_ndi.cpp b/tests/fake_ndi/not_ndi.cpp new file mode 100644 index 0000000..d45b393 --- /dev/null +++ b/tests/fake_ndi/not_ndi.cpp @@ -0,0 +1,8 @@ +/** + * @file not_ndi.cpp + * @brief A shared library that is not an NDI runtime, for loader error tests. + */ + +extern "C" __attribute__((visibility("default"))) int not_ndi() { + return 0; +} diff --git a/tests/media/generate_media.cmake b/tests/media/generate_media.cmake new file mode 100644 index 0000000..eb9f989 --- /dev/null +++ b/tests/media/generate_media.cmake @@ -0,0 +1,105 @@ +# Generates the short clips used by the test suite with the ffmpeg CLI. Only FFmpeg's built-in +# encoders are used, so any FFmpeg build works. +# +# Usage: cmake -DFFMPEG=/usr/bin/ffmpeg -DOUTPUT_DIR= -P generate_media.cmake + +if(NOT FFMPEG OR NOT OUTPUT_DIR) + message(FATAL_ERROR "FFMPEG and OUTPUT_DIR must be set") +endif() + +file(MAKE_DIRECTORY "${OUTPUT_DIR}") + +function(run_ffmpeg name required) + set(output "${OUTPUT_DIR}/${name}") + if(EXISTS "${output}") + return() + endif() + execute_process( + COMMAND "${FFMPEG}" -hide_banner -loglevel error -y ${ARGN} "${output}.tmp" + RESULT_VARIABLE result + ERROR_VARIABLE error) + if(NOT result EQUAL 0) + file(REMOVE "${output}.tmp") + if(required) + message(FATAL_ERROR "generating ${name} failed: ${error}") + endif() + message(WARNING "skipping ${name} (needs an optional encoder): ${error}") + return() + endif() + file(RENAME "${output}.tmp" "${output}") +endfunction() + +function(generate name) + run_ffmpeg(${name} TRUE ${ARGN}) +endfunction() + +# For clips that need encoders not every FFmpeg build has; tests using them are skipped. +function(generate_optional name) + run_ffmpeg(${name} FALSE ${ARGN}) +endfunction() + +# 2 s of 25 fps video with B-frames, and 48 kHz stereo AAC. +generate(av.mp4 + -f lavfi -i testsrc2=size=640x360:rate=25:duration=2 + -f lavfi -i sine=frequency=440:sample_rate=48000:duration=2 + -ac 2 -c:v mpeg4 -bf 2 -q:v 5 -c:a aac -f mp4) + +# NTSC frame rate, no audio. +generate(video_only.mkv + -f lavfi -i testsrc2=size=320x240:rate=30000/1001:duration=1.5 + -c:v mpeg4 -q:v 5 -f matroska) + +# 5.1 AC-3, no video. +generate(audio_51.mka + -f lavfi -i sine=frequency=1000:sample_rate=48000:duration=1.5 + -af pan=5.1|FL=c0|FR=c0|FC=c0|LFE=c0|BL=c0|BR=c0 -c:a ac3 -f matroska) + +# 16-bit mono PCM at 44.1 kHz: exercises sample format conversion. +generate(audio_s16_mono.wav + -f lavfi -i sine=frequency=1000:sample_rate=44100:duration=1.5 + -c:a pcm_s16le -f wav) + +# Interlaced (top field first) MPEG-2. +generate(interlaced.ts + -f lavfi -i testsrc2=size=720x576:rate=25:duration=1 + -vf setfield=tff -flags +ilme+ildct -field_order tt -c:v mpeg2video -q:v 5 -f mpegts) + +# Audio starting 0.5 s after the video. +generate(late_audio.mkv + -f lavfi -i testsrc2=size=320x240:rate=25:duration=2 + -itsoffset 0.5 -f lavfi -i sine=frequency=440:sample_rate=48000:duration=1.5 + -c:v mpeg4 -q:v 5 -c:a aac -f matroska) + +# RGB with alpha, decodable straight to BGRA. +generate(bgra.mkv + -f lavfi -i testsrc2=size=160x120:rate=10:duration=1 + -pix_fmt bgra -c:v ffv1 -f matroska) + +# Raw MPEG-4 elementary stream: no container timestamps. +generate(raw.m4v + -f lavfi -i testsrc2=size=320x240:rate=25:duration=1 + -c:v mpeg4 -q:v 5 -f m4v) + +# A video frame, another 0.25 s later, then a 5.25 s gap: a legitimate pause, not a discontinuity. +generate(slideshow.mkv + -f lavfi -i testsrc2=size=160x120:rate=4:duration=0.75 + -vf "setpts='if(eq(N,2),5.5,N*0.25)/TB'" -fps_mode passthrough -c:v mpeg4 -q:v 5 -f matroska) + +# 60 fps with frequent keyframes: enough packets to fill the video queue quickly. +generate(av_60fps.mp4 + -f lavfi -i testsrc2=size=320x180:rate=60:duration=5 + -f lavfi -i sine=frequency=440:sample_rate=48000:duration=5 + -c:v mpeg4 -g 30 -q:v 5 -c:a aac -f mp4) + +# H.264 whose only IDR before the 10 s mark is cut off, like joining a live stream mid-GOP. +generate_optional(gop_source.ts + -f lavfi -i testsrc2=size=320x240:rate=25:duration=12 + -c:v libx264 -preset ultrafast -g 250 -bf 0 -f mpegts) +if(EXISTS "${OUTPUT_DIR}/gop_source.ts") + generate(midgop.ts -i "${OUTPUT_DIR}/gop_source.ts" -ss 1 -c copy -copyinkf -f mpegts) +endif() + +# MP3 carries encoder delay and padding that the decoder trims. +generate_optional(loop.mp3 + -f lavfi -i sine=frequency=440:sample_rate=44100:duration=1 + -c:a libmp3lame -b:a 128k -f mp3) diff --git a/tests/ndi_abi_test.cpp b/tests/ndi_abi_test.cpp new file mode 100644 index 0000000..6096e3e --- /dev/null +++ b/tests/ndi_abi_test.cpp @@ -0,0 +1,98 @@ +/** + * @file ndi_abi_test.cpp + * @brief Verifies src/ndi/abi.hpp against the official NDI SDK headers. + * + * Every check is a static_assert, so a successful build is the test. Built only when the SDK is + * available (configure with -DNDI_SDK_DIR=...). + */ + +// The SDK headers rely on these being included first. +#include +#include + +#include + +#include "frames.hpp" +#include "ndi/abi.hpp" + +#include + +namespace abi = ndistreamer::ndi::abi; + +#define SAME_SIZE(ours, theirs) static_assert(sizeof(ours) == sizeof(theirs) && alignof(ours) == alignof(theirs)) +#define SAME_FIELD(ours, theirs, ours_field, theirs_field) \ + static_assert(offsetof(ours, ours_field) == offsetof(theirs, theirs_field)); \ + static_assert(sizeof(std::declval().ours_field) == sizeof(std::declval().theirs_field)) + +SAME_SIZE(abi::SendCreate, NDIlib_send_create_t); +SAME_FIELD(abi::SendCreate, NDIlib_send_create_t, p_ndi_name, p_ndi_name); +SAME_FIELD(abi::SendCreate, NDIlib_send_create_t, p_groups, p_groups); +SAME_FIELD(abi::SendCreate, NDIlib_send_create_t, clock_video, clock_video); +SAME_FIELD(abi::SendCreate, NDIlib_send_create_t, clock_audio, clock_audio); + +SAME_SIZE(abi::VideoFrameV2, NDIlib_video_frame_v2_t); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, xres, xres); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, yres, yres); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, FourCC, FourCC); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, frame_rate_N, frame_rate_N); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, frame_rate_D, frame_rate_D); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, picture_aspect_ratio, picture_aspect_ratio); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, frame_format_type, frame_format_type); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, timecode, timecode); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, p_data, p_data); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, line_stride_in_bytes, line_stride_in_bytes); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, p_metadata, p_metadata); +SAME_FIELD(abi::VideoFrameV2, NDIlib_video_frame_v2_t, timestamp, timestamp); + +SAME_SIZE(abi::AudioFrameV2, NDIlib_audio_frame_v2_t); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, sample_rate, sample_rate); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, no_channels, no_channels); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, no_samples, no_samples); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, timecode, timecode); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, p_data, p_data); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, channel_stride_in_bytes, channel_stride_in_bytes); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, p_metadata, p_metadata); +SAME_FIELD(abi::AudioFrameV2, NDIlib_audio_frame_v2_t, timestamp, timestamp); + +SAME_SIZE(abi::MetadataFrame, NDIlib_metadata_frame_t); +SAME_FIELD(abi::MetadataFrame, NDIlib_metadata_frame_t, length, length); +SAME_FIELD(abi::MetadataFrame, NDIlib_metadata_frame_t, timecode, timecode); +SAME_FIELD(abi::MetadataFrame, NDIlib_metadata_frame_t, p_data, p_data); + +SAME_SIZE(abi::Source, NDIlib_source_t); +SAME_FIELD(abi::Source, NDIlib_source_t, p_ndi_name, p_ndi_name); +SAME_FIELD(abi::Source, NDIlib_source_t, p_url_address, p_url_address); + +SAME_SIZE(abi::Tally, NDIlib_tally_t); +SAME_FIELD(abi::Tally, NDIlib_tally_t, on_program, on_program); +SAME_FIELD(abi::Tally, NDIlib_tally_t, on_preview, on_preview); + +static_assert(sizeof(abi::FrameFormatType) == sizeof(NDIlib_frame_format_type_e)); +static_assert(static_cast(abi::FrameFormatProgressive) == NDIlib_frame_format_type_progressive); +static_assert(static_cast(abi::FrameFormatInterleaved) == NDIlib_frame_format_type_interleaved); +static_assert(abi::kTimecodeSynthesize == NDIlib_send_timecode_synthesize); + +using ndistreamer::VideoFourCC; +static_assert(static_cast(VideoFourCC::UYVY) == NDIlib_FourCC_video_type_UYVY); +static_assert(static_cast(VideoFourCC::NV12) == NDIlib_FourCC_video_type_NV12); +static_assert(static_cast(VideoFourCC::I420) == NDIlib_FourCC_video_type_I420); +static_assert(static_cast(VideoFourCC::BGRA) == NDIlib_FourCC_video_type_BGRA); +static_assert(static_cast(VideoFourCC::BGRX) == NDIlib_FourCC_video_type_BGRX); +static_assert(static_cast(VideoFourCC::RGBA) == NDIlib_FourCC_video_type_RGBA); +static_assert(static_cast(VideoFourCC::RGBX) == NDIlib_FourCC_video_type_RGBX); + +// Function signatures: the loader casts dlsym() results to these types. +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v, + std::invoke_result_t>); +static_assert(std::is_same_v, + std::invoke_result_t>); + +int main() { + return 0; +} diff --git a/tests/pixelencoder_test.cpp b/tests/pixelencoder_test.cpp deleted file mode 100644 index cb9d570..0000000 --- a/tests/pixelencoder_test.cpp +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @file pixelencoder_test.cpp - * @brief This file includes tests for the PixelEncoder class. - * @date 2024-09-06 - * @author Matthew Todd Geiger - */ - -#include - -#include "decoder.hpp" -#include "demuxer.hpp" -#include "pixelencoder.hpp" - -TEST(PixelEncoderTest, CreatePixelEncoderSimple) { - AV::Utils::PixelEncoderConfig config; - config.src_width = 1920; - config.src_height = 1080; - config.src_pix_fmt = AV_PIX_FMT_YUV420P; - config.dst_width = 1920; - config.dst_height = 1080; - config.dst_pix_fmt = AV_PIX_FMT_RGB24; - - auto [encoder, encoder_err] = AV::Utils::PixelEncoder::Create(config); - EXPECT_EQ(encoder_err.code(), 0); -} - -TEST(PixelEncoderTest, EncodeMultipleFrames) { - auto [demuxer, demuxer_err] = AV::Utils::Demuxer::Create("testcontent/rickroll.mp4"); - auto streams = demuxer->GetStreamPointers(); - - AVCodecParameters *codecpar = streams[0]->codecpar; - - auto [decoder, decoder_err] = AV::Utils::Decoder::Create(codecpar); - - AV::Utils::PixelEncoderConfig config; - config.src_width = streams[0]->codecpar->width; - config.src_height = streams[0]->codecpar->height; - config.src_pix_fmt = (AVPixelFormat)streams[0]->codecpar->format; - config.dst_width = 1920; - config.dst_height = 1080; - config.dst_pix_fmt = AV_PIX_FMT_YUV422P; - - auto [encoder, encoder_err] = AV::Utils::PixelEncoder::Create(config); - - for (int i = 0; i < 6; i++) { - // Grab correct packet - AVPacket *pkt = nullptr; - do { - auto [packet, packet_err] = demuxer->ReadFrame(); - if (packet->stream_index == 0) { - pkt = packet; - } - - } while (pkt == nullptr); - - // Fill Decoder - auto fill_err = decoder->FillDecoder(pkt); - EXPECT_EQ(fill_err.code(), 0); - - while (true) { - auto [frame, frame_err] = decoder->Decode(); - if (frame_err.code() == (int)AV::Utils::AvError::DECODEREXHAUSTED) { - break; - } - EXPECT_EQ(frame_err.code(), 0); - auto [encoded_frame, encoded_frame_err] = encoder->Encode(frame); - EXPECT_EQ(encoded_frame_err.code(), 0); - EXPECT_EQ(encoded_frame->width, 1920); - EXPECT_EQ(encoded_frame->height, 1080); - } - } -} - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/support/main.cpp b/tests/support/main.cpp new file mode 100644 index 0000000..f3a226e --- /dev/null +++ b/tests/support/main.cpp @@ -0,0 +1,28 @@ +/** + * @file main.cpp + * @brief Test runner: keeps pipeline and FFmpeg logging quiet unless NDISTREAMER_TEST_LOG is set. + */ + +#include "util/log.hpp" + +#include + +#include +#include + +int main(int argc, char **argv) { + testing::InitGoogleTest(&argc, argv); + + using ndistreamer::log::Level; + Level level = Level::Warn; + if (const char *env = std::getenv("NDISTREAMER_TEST_LOG")) { + std::string_view name = env; + if (name == "info") level = Level::Info; + if (name == "debug") level = Level::Debug; + if (name == "trace") level = Level::Trace; + } + ndistreamer::log::SetLevel(level); + ndistreamer::log::InstallFFmpegBridge(); + + return RUN_ALL_TESTS(); +} diff --git a/tests/support/test_support.cpp b/tests/support/test_support.cpp new file mode 100644 index 0000000..b036cbc --- /dev/null +++ b/tests/support/test_support.cpp @@ -0,0 +1,100 @@ +/** + * @file test_support.cpp + * @brief Shared helpers: generated media, the fake NDI runtime and its log. + */ + +#include "support/test_support.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace test { + +std::string MediaPath(const std::string &name) { + return std::string(TEST_MEDIA_DIR) + "/" + name; +} + +int64_t FakeLog::TotalAudioSamples() const { + int64_t total = 0; + for (const auto &event : audio) total += event.samples; + return total; +} + +FakeNdiLog::FakeNdiLog() { + static std::atomic counter{0}; + m_path = std::filesystem::temp_directory_path() / + ("ndistreamer-test-" + std::to_string(getpid()) + "-" + std::to_string(counter++) + ".log"); + setenv("FAKE_NDI_LOG", m_path.c_str(), 1); +} + +FakeNdiLog::~FakeNdiLog() { + unsetenv("FAKE_NDI_LOG"); + std::error_code ignored; + std::filesystem::remove(m_path, ignored); +} + +FakeLog FakeNdiLog::Read() const { + FakeLog log; + std::ifstream file(m_path); + std::string line; + while (std::getline(file, line)) { + std::istringstream in(line); + std::string kind; + in >> kind; + if (kind == "create") { + log.create = line.substr(7); + } else if (kind == "metadata") { + log.metadata.push_back(line.substr(9)); + } else if (kind == "video") { + VideoEvent e; + in >> e.ns >> e.width >> e.height >> e.fourcc >> e.fps_num >> e.fps_den >> e.aspect >> e.format_type >> + e.timecode >> e.stride; + log.video.push_back(e); + } else if (kind == "audio") { + AudioEvent e; + in >> e.ns >> e.sample_rate >> e.channels >> e.samples >> e.stride_bytes >> e.timecode >> e.rms; + log.audio.push_back(e); + } else if (kind == "flush") { + log.flushes++; + } else if (kind == "destroy") { + log.destroyed = true; + } + } + return log; +} + +ndistreamer::Options MakeOptions(const std::string &media) { + ndistreamer::Options options; + options.input.url = MediaPath(media); + options.ndi_library = FAKE_NDI_LIBRARY; + options.sender.name = "test"; + return options; +} + +FakeLog Stream(const ndistreamer::Options &options, std::optional stop_after) { + FakeNdiLog log; + { + std::stop_source stop; + ndistreamer::Streamer streamer(options, stop); + streamer.Start(); + + auto deadline = std::chrono::steady_clock::now() + stop_after.value_or(std::chrono::seconds(60)); + while (!streamer.WaitFor(std::chrono::milliseconds(10))) { + if (std::chrono::steady_clock::now() >= deadline) stop.request_stop(); + } + streamer.Wait(); + } // the sender closes the log when it is destroyed + return log.Read(); +} + +double Median(std::vector values) { + std::sort(values.begin(), values.end()); + return values[values.size() / 2]; +} + +} // namespace test diff --git a/tests/support/test_support.hpp b/tests/support/test_support.hpp new file mode 100644 index 0000000..ceb751c --- /dev/null +++ b/tests/support/test_support.hpp @@ -0,0 +1,94 @@ +/** + * @file test_support.hpp + * @brief Shared helpers: generated media, the fake NDI runtime and its log. + */ + +#pragma once + +#include "options.hpp" +#include "streamer.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace test { + +/// Path of a clip produced by tests/media/generate_media.cmake. +std::string MediaPath(const std::string &name); + +/// Skip the current test when the media fixture is missing (no ffmpeg CLI at build time). +#define REQUIRE_MEDIA(name) \ + do { \ + if (!std::filesystem::exists(test::MediaPath(name))) { \ + GTEST_SKIP() << "test media '" << (name) << "' is missing; is the ffmpeg CLI installed?"; \ + } \ + } while (0) + +struct VideoEvent { + int64_t ns = 0; + int width = 0; + int height = 0; + std::string fourcc; + int fps_num = 0; + int fps_den = 0; + double aspect = 0; + int format_type = -1; + int64_t timecode = 0; + int stride = 0; +}; + +struct AudioEvent { + int64_t ns = 0; + int sample_rate = 0; + int channels = 0; + int samples = 0; + int stride_bytes = 0; + int64_t timecode = 0; + double rms = 0; +}; + +/// Everything the fake NDI runtime recorded for one sender. +struct FakeLog { + std::string create; + std::vector metadata; + std::vector video; + std::vector audio; + int flushes = 0; + bool destroyed = false; + + int64_t TotalAudioSamples() const; +}; + +/** + * @brief Points the fake runtime at a fresh log file for the lifetime of the object. + */ +class FakeNdiLog { +public: + FakeNdiLog(); + ~FakeNdiLog(); + FakeLog Read() const; + +private: + std::filesystem::path m_path; +}; + +/// Options that stream @p media through the fake NDI runtime. +ndistreamer::Options MakeOptions(const std::string &media); + +/** + * @brief Stream to completion, or stop after @p stop_after, and return what was sent. + * Rethrows pipeline errors. + */ +FakeLog Stream(const ndistreamer::Options &options, + std::optional stop_after = std::nullopt); + +/// Median of a non-empty list. +double Median(std::vector values); + +} // namespace test diff --git a/tests/test_audio_processor.cpp b/tests/test_audio_processor.cpp new file mode 100644 index 0000000..233fdea --- /dev/null +++ b/tests/test_audio_processor.cpp @@ -0,0 +1,240 @@ +/** + * @file test_audio_processor.cpp + * @brief Sample format conversion, packing and timing on synthetic frames. + */ + +#include "av/audio_processor.hpp" + +#include + +#include + +extern "C" { +#include +} + +using namespace ndistreamer; +using av::AudioProcessor; +using av::AudioProcessorConfig; + +namespace { + +av::FramePtr MakeAudioFrame(AVSampleFormat format, int rate, int channels, int samples, int64_t pts) { + av::FramePtr frame = av::MakeFrame(); + frame->format = format; + frame->sample_rate = rate; + frame->nb_samples = samples; + frame->pts = pts; + av_channel_layout_default(&frame->ch_layout, channels); + av::Check(av_frame_get_buffer(frame.get(), 0), "allocating test frame"); + return frame; +} + +void FillPlanarFloat(AVFrame *frame, std::initializer_list per_channel) { + int c = 0; + for (float value : per_channel) { + auto *plane = reinterpret_cast(frame->extended_data[c++]); + for (int i = 0; i < frame->nb_samples; i++) plane[i] = value; + } +} + +std::vector Process(AudioProcessor &processor, const AVFrame *frame) { + std::vector out; + processor.Process(frame, [&](AudioFrame &&f) { + out.push_back(std::move(f)); + return true; + }); + return out; +} + +std::vector Flush(AudioProcessor &processor) { + std::vector out; + processor.Flush([&](AudioFrame &&f) { + out.push_back(std::move(f)); + return true; + }); + return out; +} + +// The processor holds back its latest block; flushing releases it. +std::vector ProcessAndFlush(AudioProcessor &processor, const AVFrame *frame) { + auto out = Process(processor, frame); + for (auto &f : Flush(processor)) out.push_back(std::move(f)); + return out; +} + +AudioProcessorConfig Config(int rate = 0, int channels = 0, AVRational time_base = {1, 48000}) { + AudioProcessorConfig config; + config.sample_rate = rate; + config.channels = channels; + config.time_base = time_base; + return config; +} + +} // namespace + +TEST(AudioProcessor, PacksPlanarFloatWithoutConversion) { + AudioProcessor processor(Config()); + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 2, 1024, 480); + FillPlanarFloat(frame.get(), {0.25f, -0.5f}); + + auto out = ProcessAndFlush(processor, frame.get()); + ASSERT_EQ(out.size(), 1u); + const AudioFrame &f = out[0]; + EXPECT_EQ(f.sample_rate, 48000); + EXPECT_EQ(f.channels, 2); + EXPECT_EQ(f.samples_per_channel, 1024); + EXPECT_EQ(f.channel_stride, 1024); + EXPECT_EQ(f.pts_us, 10000); + EXPECT_FLOAT_EQ(f.samples[0], 0.25f); + EXPECT_FLOAT_EQ(f.samples[1023], 0.25f); + EXPECT_FLOAT_EQ(f.samples[1024], -0.5f); + EXPECT_FLOAT_EQ(f.samples[2047], -0.5f); +} + +TEST(AudioProcessor, ConvertsInterleavedS16) { + AudioProcessor processor(Config()); + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_S16, 48000, 2, 256, 0); + auto *samples = reinterpret_cast(frame->data[0]); + for (int i = 0; i < 256; i++) { + samples[2 * i] = 16384; + samples[2 * i + 1] = -16384; + } + + auto out = ProcessAndFlush(processor, frame.get()); + ASSERT_EQ(out.size(), 1u); + const AudioFrame &f = out[0]; + ASSERT_EQ(f.samples_per_channel, 256); + EXPECT_FLOAT_EQ(f.samples[10], 0.5f); + EXPECT_FLOAT_EQ(f.samples[f.channel_stride + 10], -0.5f); +} + +TEST(AudioProcessor, ResamplesToRequestedRate) { + AudioProcessor processor(Config(48000, 0, {1, 44100})); + + int64_t produced = 0; + for (int i = 0; i < 10; i++) { + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 44100, 1, 1024, i * 1024); + FillPlanarFloat(frame.get(), {0.1f}); + for (auto &f : Process(processor, frame.get())) { + EXPECT_EQ(f.sample_rate, 48000); + produced += f.samples_per_channel; + } + } + for (auto &f : Flush(processor)) produced += f.samples_per_channel; + + EXPECT_NEAR(static_cast(produced), 10240.0 * 48000 / 44100, 64); +} + +TEST(AudioProcessor, RemixesToRequestedChannelCount) { + AudioProcessor processor(Config(0, 2)); + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 6, 512, 0); + FillPlanarFloat(frame.get(), {0.1f, 0.1f, 0.1f, 0.1f, 0.1f, 0.1f}); + + auto out = ProcessAndFlush(processor, frame.get()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].channels, 2); + EXPECT_GT(out[0].samples[100], 0.0f); +} + +TEST(AudioProcessor, KeepsAllSourceChannels) { + AudioProcessor processor(Config()); + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 8, 128, 0); + auto out = ProcessAndFlush(processor, frame.get()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].channels, 8); +} + +TEST(AudioProcessor, FramesWithoutTimestampsAreSequential) { + AudioProcessor processor(Config()); + std::vector pts; + for (int i = 0; i < 3; i++) { + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 2, 1024, AV_NOPTS_VALUE); + for (auto &f : Process(processor, frame.get())) pts.push_back(f.pts_us); + } + for (auto &f : Flush(processor)) pts.push_back(f.pts_us); + EXPECT_EQ(pts, (std::vector{0, 21333, 42666})); +} + +TEST(AudioProcessor, AdaptsWhenInputFormatChanges) { + AudioProcessor processor(Config()); + auto s16 = MakeAudioFrame(AV_SAMPLE_FMT_S16, 48000, 2, 256, 0); + auto fltp = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 44100, 1, 256, 256); + FillPlanarFloat(fltp.get(), {0.75f}); + + EXPECT_TRUE(Process(processor, s16.get()).empty()); // held back + auto first = Process(processor, fltp.get()); // the change releases the s16 block + ASSERT_EQ(first.size(), 1u); + EXPECT_EQ(first[0].channels, 2); + + auto out = Flush(processor); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].channels, 1); + EXPECT_EQ(out[0].sample_rate, 44100); + EXPECT_FLOAT_EQ(out[0].samples[0], 0.75f); +} + +TEST(AudioProcessor, UnspecifiedChannelLayoutKeepsResamplerState) { + // Plain WAV and raw PCM report channels without an order; that must not look like a format change. + AudioProcessor processor(Config(48000, 0, {1, 44100})); + + int64_t produced = 0; + for (int i = 0; i < 10; i++) { + auto frame = MakeAudioFrame(AV_SAMPLE_FMT_S16, 44100, 1, 1024, i * 1024); + av_channel_layout_uninit(&frame->ch_layout); + frame->ch_layout.order = AV_CHANNEL_ORDER_UNSPEC; + frame->ch_layout.nb_channels = 1; + for (auto &f : Process(processor, frame.get())) produced += f.samples_per_channel; + } + for (auto &f : Flush(processor)) produced += f.samples_per_channel; + + EXPECT_NEAR(static_cast(produced), 10240.0 * 48000 / 44100, 4); +} + +TEST(AudioProcessor, KeepsBufferedSamplesWhenInputChanges) { + AudioProcessor processor(Config(48000, 0, {1, 44100})); + + int64_t produced = 0; + auto s16 = MakeAudioFrame(AV_SAMPLE_FMT_S16, 44100, 1, 1024, 0); + auto fltp = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 44100, 1, 1024, 1024); + FillPlanarFloat(fltp.get(), {0.1f}); + for (auto &f : Process(processor, s16.get())) produced += f.samples_per_channel; + for (auto &f : Process(processor, fltp.get())) produced += f.samples_per_channel; + for (auto &f : Flush(processor)) produced += f.samples_per_channel; + + EXPECT_NEAR(static_cast(produced), 2048.0 * 48000 / 44100, 4); +} + +TEST(AudioProcessor, TrimsTailThatOverlapsTheNextBlock) { + // Like a loop point where the decoder emitted 256 samples of encoder padding past the end. + AudioProcessor processor(Config()); + auto padded = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 1, 1024, 0); + auto next = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 1, 1024, 768); + FillPlanarFloat(padded.get(), {0.25f}); + FillPlanarFloat(next.get(), {0.5f}); + + auto out = Process(processor, padded.get()); + for (auto &f : Process(processor, next.get())) out.push_back(std::move(f)); + for (auto &f : Flush(processor)) out.push_back(std::move(f)); + + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0].samples_per_channel, 768); + EXPECT_EQ(out[1].pts_us, 16000); + EXPECT_EQ(out[1].samples_per_channel, 1024); + EXPECT_FLOAT_EQ(out[1].samples[0], 0.5f); +} + +TEST(AudioProcessor, LeavesTimestampDiscontinuitiesAlone) { + // A large backward jump (e.g. a live encoder restarting) is not an overlap to trim. + AudioProcessor processor(Config()); + auto later = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 1, 1024, 480000); + auto earlier = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 1, 1024, 0); + + auto out = Process(processor, later.get()); + for (auto &f : Process(processor, earlier.get())) out.push_back(std::move(f)); + for (auto &f : Flush(processor)) out.push_back(std::move(f)); + + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0].samples_per_channel, 1024); + EXPECT_EQ(out[1].samples_per_channel, 1024); +} diff --git a/tests/test_clock.cpp b/tests/test_clock.cpp new file mode 100644 index 0000000..0ae0b1e --- /dev/null +++ b/tests/test_clock.cpp @@ -0,0 +1,107 @@ +/** + * @file test_clock.cpp + * @brief PresentationClock start-up and timestamp mapping. + */ + +#include "util/clock.hpp" + +#include + +#include + +using ndistreamer::PresentationClock; +using namespace std::chrono_literals; + +TEST(PresentationClock, StartsWhenAllStreamsAreReady) { + PresentationClock clock(2, 0ms, 5s); + std::stop_source stop; + + auto start = std::chrono::steady_clock::now(); + std::jthread other([&] { EXPECT_TRUE(clock.WaitForStart(40000, stop.get_token())); }); + EXPECT_TRUE(clock.WaitForStart(0, stop.get_token())); + other.join(); + + EXPECT_TRUE(clock.Started()); + EXPECT_LT(std::chrono::steady_clock::now() - start, 1s); +} + +TEST(PresentationClock, EarliestTimestampIsTheOrigin) { + PresentationClock clock(2, 0ms, 5s); + std::stop_source stop; + std::jthread other([&] { clock.WaitForStart(500000, stop.get_token()); }); + clock.WaitForStart(100000, stop.get_token()); + other.join(); + + // The later stream keeps its 400 ms offset. + EXPECT_EQ(clock.DueTime(500000) - clock.DueTime(100000), 400ms); + EXPECT_LE(clock.DueTime(100000), std::chrono::steady_clock::now()); +} + +TEST(PresentationClock, StartsAfterTimeoutWhenAStreamIsSlow) { + PresentationClock clock(2, 0ms, 50ms); + std::stop_source stop; + + auto start = std::chrono::steady_clock::now(); + EXPECT_TRUE(clock.WaitForStart(0, stop.get_token())); + auto waited = std::chrono::steady_clock::now() - start; + EXPECT_GE(waited, 45ms); + EXPECT_LT(waited, 1s); +} + +TEST(PresentationClock, FinishedStreamIsNotAwaited) { + PresentationClock clock(2, 0ms, 10s); + std::stop_source stop; + std::jthread finisher([&] { + std::this_thread::sleep_for(20ms); + clock.StreamFinished(); + }); + + auto start = std::chrono::steady_clock::now(); + EXPECT_TRUE(clock.WaitForStart(0, stop.get_token())); + EXPECT_LT(std::chrono::steady_clock::now() - start, 2s); +} + +TEST(PresentationClock, StopInterruptsWaiting) { + PresentationClock clock(2, 0ms, 10s); + std::stop_source stop; + std::jthread stopper([&] { + std::this_thread::sleep_for(20ms); + stop.request_stop(); + }); + EXPECT_FALSE(clock.WaitForStart(0, stop.get_token())); +} + +TEST(PresentationClock, PrerollDelaysFirstDeadline) { + PresentationClock clock(1, 100ms, 1s); + std::stop_source stop; + auto before = std::chrono::steady_clock::now(); + clock.WaitForStart(0, stop.get_token()); + EXPECT_GE(clock.DueTime(0) - before, 100ms); +} + +TEST(PresentationClock, TimecodesFollowTimestamps) { + PresentationClock clock(1, 0ms, 1s); + std::stop_source stop; + clock.WaitForStart(0, stop.get_token()); + + // 100 ns units. + EXPECT_EQ(clock.Timecode(1000000) - clock.Timecode(0), 10000000); + + // UTC based: within a second of now. + auto utc_now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count() / + 100; + EXPECT_NEAR(static_cast(clock.Timecode(0)), static_cast(utc_now), 1e7); +} + +TEST(PresentationClock, ResyncReanchorsSchedule) { + PresentationClock clock(1, 0ms, 1s); + std::stop_source stop; + clock.WaitForStart(0, stop.get_token()); + + auto when = std::chrono::steady_clock::now() + 2s; + clock.Resync(5000000, when); + EXPECT_EQ(clock.DueTime(5000000), when); + EXPECT_EQ(clock.DueTime(5040000) - when, 40ms); +} diff --git a/tests/test_input.cpp b/tests/test_input.cpp new file mode 100644 index 0000000..ed20404 --- /dev/null +++ b/tests/test_input.cpp @@ -0,0 +1,123 @@ +/** + * @file test_input.cpp + * @brief Stream selection and the continuous loop timeline. + */ + +#include "av/input.hpp" +#include "support/test_support.hpp" + +#include + +#include + +using namespace ndistreamer; + +namespace { + +av::InputConfig Config(const std::string &media) { + av::InputConfig config; + config.url = test::MediaPath(media); + return config; +} + +std::string ErrorFrom(const av::InputConfig &config) { + try { + av::MediaInput input(config, std::stop_token{}); + } catch (const std::exception &error) { + return error.what(); + } + return {}; +} + +} // namespace + +TEST(MediaInput, SelectsBestAudioAndVideoStreams) { + REQUIRE_MEDIA("av.mp4"); + av::MediaInput input(Config("av.mp4"), std::stop_token{}); + ASSERT_NE(input.VideoStream(), nullptr); + ASSERT_NE(input.AudioStream(), nullptr); + EXPECT_EQ(input.VideoStream()->index, 0); + EXPECT_EQ(input.AudioStream()->index, 1); + EXPECT_EQ(av_cmp_q(input.VideoFrameRate(), AVRational{25, 1}), 0); +} + +TEST(MediaInput, HandlesSingleStreamInputs) { + REQUIRE_MEDIA("video_only.mkv"); + REQUIRE_MEDIA("audio_51.mka"); + + av::MediaInput video(Config("video_only.mkv"), std::stop_token{}); + EXPECT_NE(video.VideoStream(), nullptr); + EXPECT_EQ(video.AudioStream(), nullptr); + EXPECT_EQ(av_cmp_q(video.VideoFrameRate(), AVRational{30000, 1001}), 0); + + av::MediaInput audio(Config("audio_51.mka"), std::stop_token{}); + EXPECT_EQ(audio.VideoStream(), nullptr); + EXPECT_NE(audio.AudioStream(), nullptr); +} + +TEST(MediaInput, StreamsCanBeDisabled) { + REQUIRE_MEDIA("av.mp4"); + auto config = Config("av.mp4"); + config.disable_audio = true; + av::MediaInput input(config, std::stop_token{}); + EXPECT_NE(input.VideoStream(), nullptr); + EXPECT_EQ(input.AudioStream(), nullptr); + + // Only packets of selected streams are returned. + while (auto packet = input.Read()) EXPECT_EQ(packet->stream_index, 0); +} + +TEST(MediaInput, ReportsUnusableSelections) { + REQUIRE_MEDIA("av.mp4"); + REQUIRE_MEDIA("audio_51.mka"); + + auto wrong_type = Config("av.mp4"); + wrong_type.video_stream = 1; + EXPECT_NE(ErrorFrom(wrong_type).find("stream #1 is not a video stream"), std::string::npos); + + auto missing = Config("av.mp4"); + missing.audio_stream = 7; + EXPECT_NE(ErrorFrom(missing).find("does not exist"), std::string::npos); + + auto nothing = Config("audio_51.mka"); + nothing.disable_audio = true; + EXPECT_NE(ErrorFrom(nothing).find("no usable audio or video"), std::string::npos); + + auto format = Config("av.mp4"); + format.format = "no_such_format"; + EXPECT_NE(ErrorFrom(format).find("unknown input format"), std::string::npos); + + EXPECT_NE(ErrorFrom(Config("does-not-exist.mp4")).find("cannot open"), std::string::npos); +} + +TEST(MediaInput, RestartContinuesTheTimeline) { + REQUIRE_MEDIA("video_only.mkv"); + av::MediaInput input(Config("video_only.mkv"), std::stop_token{}); + AVRational time_base = input.VideoStream()->time_base; + + auto read_pass = [&] { + std::vector pts; + while (auto packet = input.Read()) pts.push_back(av::ToMicros(packet->pts, time_base)); + std::sort(pts.begin(), pts.end()); + return pts; + }; + + auto first = read_pass(); + ASSERT_TRUE(input.Restart()); + auto second = read_pass(); + + ASSERT_EQ(first.size(), second.size()); + ASSERT_FALSE(first.empty()); + int64_t frame_us = av::ToMicros(1, av_inv_q(input.VideoFrameRate())); + int64_t pass_us = first.back() - first.front() + frame_us; + for (size_t i = 0; i < first.size(); i++) { + EXPECT_NEAR(static_cast(second[i] - first[i]), static_cast(pass_us), 1000) << i; + } +} + +TEST(MediaInput, UnknownInputOptionsAreNotFatal) { + REQUIRE_MEDIA("av.mp4"); + auto config = Config("av.mp4"); + config.options.emplace_back("no_such_option", "1"); + EXPECT_NO_THROW(av::MediaInput(config, std::stop_token{})); +} diff --git a/tests/test_log.cpp b/tests/test_log.cpp new file mode 100644 index 0000000..1335f54 --- /dev/null +++ b/tests/test_log.cpp @@ -0,0 +1,51 @@ +/** + * @file test_log.cpp + * @brief Logging, including FFmpeg messages routed through the logger. + */ + +#include "util/log.hpp" + +#include + +#include +#include +#include + +extern "C" { +#include +} + +using namespace ndistreamer; + +namespace { + +std::vector Lines(const std::string &text) { + std::vector lines; + std::istringstream in(text); + for (std::string line; std::getline(in, line);) lines.push_back(line); + return lines; +} + +} // namespace + +TEST(Log, FiltersByLevel) { + testing::internal::CaptureStderr(); + log::Warn("shown {}", 1); + log::Debug("hidden {}", 2); + auto lines = Lines(testing::internal::GetCapturedStderr()); + ASSERT_EQ(lines.size(), 1u); + EXPECT_NE(lines[0].find("[warn ] shown 1"), std::string::npos) << lines[0]; +} + +TEST(Log, KeepsLongFFmpegMessagesSeparate) { + std::string long_text(3000, 'x'); + testing::internal::CaptureStderr(); + av_log(nullptr, AV_LOG_ERROR, "%s\n", long_text.c_str()); + av_log(nullptr, AV_LOG_ERROR, "second %s", "message"); + av_log(nullptr, AV_LOG_ERROR, " continued\n"); + auto lines = Lines(testing::internal::GetCapturedStderr()); + + ASSERT_EQ(lines.size(), 2u); + EXPECT_NE(lines[0].find("ffmpeg: " + long_text), std::string::npos); + EXPECT_NE(lines[1].find("ffmpeg: second message continued"), std::string::npos) << lines[1]; +} diff --git a/tests/test_options.cpp b/tests/test_options.cpp new file mode 100644 index 0000000..ebeea43 --- /dev/null +++ b/tests/test_options.cpp @@ -0,0 +1,143 @@ +/** + * @file test_options.cpp + * @brief Command line parsing and validation. + */ + +#include "options.hpp" + +#include + +#include +#include +#include + +using namespace ndistreamer; + +namespace { + +struct Parsed { + ParseOutcome outcome; + Options options; + std::string message; +}; + +Parsed Parse(std::initializer_list args) { + std::vector storage{"ndistreamer"}; + storage.insert(storage.end(), args.begin(), args.end()); + std::vector argv; + for (auto &arg : storage) argv.push_back(arg.data()); + argv.push_back(nullptr); + + Parsed parsed; + parsed.outcome = ParseOptions(static_cast(storage.size()), argv.data(), parsed.options, parsed.message); + return parsed; +} + +} // namespace + +TEST(Options, AcceptsLegacyCommandLine) { + auto parsed = Parse({"-i", "movie.mp4", "-s", "Lobby", "-t", "software"}); + ASSERT_EQ(parsed.outcome, ParseOutcome::Run) << parsed.message; + EXPECT_EQ(parsed.options.input.url, "movie.mp4"); + EXPECT_EQ(parsed.options.sender.name, "Lobby"); + EXPECT_EQ(parsed.options.decoder.hwaccel, "none"); + + EXPECT_EQ(Parse({"-i", "a.mp4", "-t", "vaapi"}).options.decoder.hwaccel, "vaapi"); + EXPECT_EQ(Parse({"-i", "a.mp4", "-t", "cuda"}).options.decoder.hwaccel, "cuda"); +} + +TEST(Options, HasSensibleDefaults) { + auto parsed = Parse({"-i", "movie.mp4"}); + ASSERT_EQ(parsed.outcome, ParseOutcome::Run); + const Options &o = parsed.options; + EXPECT_EQ(o.sender.name, "NDI Source"); + EXPECT_EQ(o.decoder.hwaccel, "none"); + EXPECT_EQ(o.pixel_format, av::OutputPixelFormat::Auto); + EXPECT_TRUE(o.pacing); + EXPECT_FALSE(o.loop); + EXPECT_EQ(o.stats_interval, 0); + EXPECT_EQ(o.log_level, log::Level::Info); +} + +TEST(Options, ParsesLongOptions) { + auto parsed = Parse({"--input", "rtsp://cam", "--format", "rtsp", "-o", "rtsp_transport=tcp", "-o", "timeout=5", + "--loop", "--video-stream", "2", "--audio-stream", "3", "--hwaccel", "auto", + "--hwaccel-device", "/dev/dri/renderD128", "--threads", "4", "--vf", "yadif", + "--pixel-format", "nv12", "--audio-rate", "48000", "--audio-channels", "2", "--name", "Cam", + "--groups", "studio,public", "--ndi-lib", "/opt/ndi/lib", "--no-pacing", "--stats=2"}); + ASSERT_EQ(parsed.outcome, ParseOutcome::Run) << parsed.message; + const Options &o = parsed.options; + EXPECT_EQ(o.input.url, "rtsp://cam"); + EXPECT_EQ(o.input.format, "rtsp"); + ASSERT_EQ(o.input.options.size(), 2u); + EXPECT_EQ(o.input.options[0], (std::pair{"rtsp_transport", "tcp"})); + EXPECT_EQ(o.input.options[1], (std::pair{"timeout", "5"})); + EXPECT_TRUE(o.loop); + EXPECT_EQ(o.input.video_stream, 2); + EXPECT_EQ(o.input.audio_stream, 3); + EXPECT_EQ(o.decoder.hwaccel, "auto"); + EXPECT_EQ(o.decoder.hwaccel_device, "/dev/dri/renderD128"); + EXPECT_EQ(o.decoder.threads, 4); + EXPECT_EQ(o.video_filter, "yadif"); + EXPECT_EQ(o.pixel_format, av::OutputPixelFormat::NV12); + EXPECT_EQ(o.audio_rate, 48000); + EXPECT_EQ(o.audio_channels, 2); + EXPECT_EQ(o.sender.name, "Cam"); + EXPECT_EQ(o.sender.groups, "studio,public"); + EXPECT_EQ(o.ndi_library, "/opt/ndi/lib"); + EXPECT_FALSE(o.pacing); + EXPECT_EQ(o.stats_interval, 2); +} + +TEST(Options, StatsDefaultsToFiveSeconds) { + EXPECT_EQ(Parse({"-i", "a.mp4", "--stats"}).options.stats_interval, 5); +} + +TEST(Options, VerbosityIsClamped) { + EXPECT_EQ(Parse({"-i", "a", "-v"}).options.log_level, log::Level::Debug); + EXPECT_EQ(Parse({"-i", "a", "-vvvvv"}).options.log_level, log::Level::Trace); + EXPECT_EQ(Parse({"-i", "a", "-q"}).options.log_level, log::Level::Warn); + EXPECT_EQ(Parse({"-i", "a", "-qqqq"}).options.log_level, log::Level::Error); +} + +TEST(Options, HelpAndVersionExitSuccessfully) { + auto help = Parse({"--help"}); + EXPECT_EQ(help.outcome, ParseOutcome::ExitSuccess); + EXPECT_NE(help.message.find("Usage: ndistreamer -i INPUT"), std::string::npos); + + auto version = Parse({"-V"}); + EXPECT_EQ(version.outcome, ParseOutcome::ExitSuccess); + EXPECT_NE(version.message.find("ndistreamer 2."), std::string::npos); + EXPECT_NE(version.message.find("FFmpeg"), std::string::npos); +} + +TEST(Options, RejectsInvalidUsage) { + struct Case { + std::initializer_list args; + const char *error; + }; + const Case cases[] = { + {{}, "an input is required"}, + {{"-i"}, "requires an argument"}, + {{"-i", "a", "--bogus"}, "unrecognized option"}, + {{"-i", "a", "extra"}, "unexpected argument"}, + {{"-i", "a", "-o", "novalue"}, "KEY=VALUE"}, + {{"-i", "a", "-o", "=x"}, "KEY=VALUE"}, + {{"-i", "a", "--threads", "-1"}, "invalid thread count"}, + {{"-i", "a", "--threads", "four"}, "invalid thread count"}, + {{"-i", "a", "--audio-rate", "100"}, "invalid audio rate"}, + {{"-i", "a", "--audio-channels", "0"}, "invalid audio channel count"}, + {{"-i", "a", "--video-stream", "x"}, "invalid video stream"}, + {{"-i", "a", "-p", "yuv"}, "invalid pixel format"}, + {{"-i", "a", "--stats=0"}, "invalid statistics interval"}, + {{"-i", "a", "-s", ""}, "cannot be empty"}, + {{"-i", "a", "--no-video", "--no-audio"}, "cannot be combined"}, + }; + + for (const auto &c : cases) { + auto parsed = Parse(c.args); + EXPECT_EQ(parsed.outcome, ParseOutcome::ExitError) << c.error; + EXPECT_NE(parsed.message.find(c.error), std::string::npos) << "expected '" << c.error << "' in: " << parsed.message; + EXPECT_NE(parsed.message.find("--help"), std::string::npos); + } +} diff --git a/tests/test_queue.cpp b/tests/test_queue.cpp new file mode 100644 index 0000000..c633f99 --- /dev/null +++ b/tests/test_queue.cpp @@ -0,0 +1,76 @@ +/** + * @file test_queue.cpp + * @brief BoundedQueue behaviour. + */ + +#include "util/queue.hpp" + +#include + +#include +#include + +using ndistreamer::BoundedQueue; +using namespace std::chrono_literals; + +TEST(BoundedQueue, DeliversInOrder) { + BoundedQueue queue(4); + std::stop_source stop; + for (int i = 0; i < 4; i++) ASSERT_TRUE(queue.Push(i, stop.get_token())); + EXPECT_EQ(queue.Size(), 4u); + for (int i = 0; i < 4; i++) EXPECT_EQ(queue.Pop(stop.get_token()), i); +} + +TEST(BoundedQueue, CloseDrainsRemainingItems) { + BoundedQueue queue(4); + std::stop_source stop; + queue.Push(1, stop.get_token()); + queue.Push(2, stop.get_token()); + queue.Close(); + + EXPECT_FALSE(queue.Push(3, stop.get_token())); + EXPECT_EQ(queue.Pop(stop.get_token()), 1); + EXPECT_EQ(queue.Pop(stop.get_token()), 2); + EXPECT_EQ(queue.Pop(stop.get_token()), std::nullopt); +} + +TEST(BoundedQueue, PushWaitsForSpace) { + BoundedQueue queue(1); + std::stop_source stop; + queue.Push(1, stop.get_token()); + + std::jthread consumer([&] { + std::this_thread::sleep_for(50ms); + queue.Pop(stop.get_token()); + }); + + auto start = std::chrono::steady_clock::now(); + EXPECT_TRUE(queue.Push(2, stop.get_token())); + EXPECT_GE(std::chrono::steady_clock::now() - start, 40ms); + EXPECT_EQ(queue.Pop(stop.get_token()), 2); +} + +TEST(BoundedQueue, StopUnblocksWaiters) { + BoundedQueue empty(1); + BoundedQueue full(1); + std::stop_source stop; + full.Push(1, stop.get_token()); + + std::jthread stopper([&] { + std::this_thread::sleep_for(30ms); + stop.request_stop(); + }); + + EXPECT_EQ(empty.Pop(stop.get_token()), std::nullopt); + EXPECT_FALSE(full.Push(2, stop.get_token())); +} + +TEST(BoundedQueue, CloseWakesBlockedConsumer) { + BoundedQueue queue(1); + std::stop_source stop; + std::jthread closer([&] { + std::this_thread::sleep_for(30ms); + queue.Close(); + }); + EXPECT_EQ(queue.Pop(stop.get_token()), std::nullopt); +} diff --git a/tests/test_runtime.cpp b/tests/test_runtime.cpp new file mode 100644 index 0000000..b2dc0ad --- /dev/null +++ b/tests/test_runtime.cpp @@ -0,0 +1,78 @@ +/** + * @file test_runtime.cpp + * @brief Locating and loading the NDI runtime. + */ + +#include "ndi/runtime.hpp" +#include "ndi/sender.hpp" +#include "support/test_support.hpp" + +#include + +#include + +using namespace ndistreamer; + +namespace { + +std::string LoadError(const std::string &hint) { + try { + ndi::Runtime::Load(hint); + } catch (const std::exception &error) { + return error.what(); + } + return {}; +} + +} // namespace + +TEST(Runtime, LoadsLibraryFile) { + auto runtime = ndi::Runtime::Load(FAKE_NDI_LIBRARY); + EXPECT_EQ(runtime->Version(), "FAKE NDI 6.0.0"); + EXPECT_NE(runtime->api().send_get_source_name, nullptr); +} + +TEST(Runtime, LoadsFromDirectory) { + auto runtime = ndi::Runtime::Load(FAKE_NDI_DIR); + EXPECT_EQ(runtime->Version(), "FAKE NDI 6.0.0"); + EXPECT_NE(runtime->Path().find("libndi.so.6"), std::string::npos) << runtime->Path(); +} + +TEST(Runtime, HonoursRuntimeDirectoryVariable) { + setenv("NDI_RUNTIME_DIR_V6", FAKE_NDI_DIR, 1); + auto runtime = ndi::Runtime::Load(""); + unsetenv("NDI_RUNTIME_DIR_V6"); + EXPECT_EQ(runtime->Version(), "FAKE NDI 6.0.0"); +} + +TEST(Runtime, ExplainsMissingLibrary) { + std::string error = LoadError("/nonexistent/libndi.so.6"); + EXPECT_NE(error.find("cannot load the NDI runtime from '/nonexistent/libndi.so.6'"), std::string::npos) << error; + EXPECT_NE(error.find("--ndi-lib"), std::string::npos) << error; +} + +TEST(Runtime, RejectsLibrariesThatAreNotNdi) { + std::string error = LoadError(NOT_NDI_LIBRARY); + EXPECT_NE(error.find("not a usable NDI runtime"), std::string::npos) << error; +} + +TEST(Runtime, RejectsUnsupportedCpu) { + setenv("FAKE_NDI_UNSUPPORTED_CPU", "1", 1); + std::string error = LoadError(FAKE_NDI_LIBRARY); + unsetenv("FAKE_NDI_UNSUPPORTED_CPU"); + EXPECT_NE(error.find("CPU is not supported"), std::string::npos) << error; +} + +TEST(Sender, CreatesUnclockedSourceWithProductMetadata) { + test::FakeNdiLog log; + { + ndi::Sender sender(ndi::Runtime::Load(FAKE_NDI_LIBRARY), {"Studio A", "cameras,studio"}); + EXPECT_EQ(sender.SourceName(), "FAKEHOST (Studio A)"); + EXPECT_EQ(sender.Connections(), 1); + } + test::FakeLog sent = log.Read(); + EXPECT_EQ(sent.create, "Studio A|cameras,studio|0|0"); + ASSERT_EQ(sent.metadata.size(), 1u); + EXPECT_NE(sent.metadata[0].find("short_name=\"ndistreamer\""), std::string::npos); + EXPECT_TRUE(sent.destroyed); +} diff --git a/tests/test_streamer.cpp b/tests/test_streamer.cpp new file mode 100644 index 0000000..0a2c3c6 --- /dev/null +++ b/tests/test_streamer.cpp @@ -0,0 +1,329 @@ +/** + * @file test_streamer.cpp + * @brief Whole-pipeline tests: real media files streamed into the fake NDI runtime. + */ + +#include "support/test_support.hpp" + +#include + +#include + +using namespace ndistreamer; +using namespace std::chrono_literals; + +namespace { + +constexpr int64_t kFrame25 = 400000; // 40 ms in NDI's 100 ns units + +// Offset between when something was sent and its timecode. Constant across every frame of both +// streams when pacing is exact and audio/video are aligned. +std::vector SendOffsetsMs(const test::FakeLog &log) { + std::vector offsets; + for (const auto &v : log.video) offsets.push_back(v.ns / 1e6 - v.timecode / 1e4); + for (const auto &a : log.audio) offsets.push_back(a.ns / 1e6 - a.timecode / 1e4); + return offsets; +} + +} // namespace + +TEST(Streamer, StreamsAudioAndVideoInRealTime) { + REQUIRE_MEDIA("av.mp4"); + auto log = test::Stream(test::MakeOptions("av.mp4")); + + EXPECT_EQ(log.create, "test||0|0") << "NDI's own clocking must be disabled"; + ASSERT_EQ(log.video.size(), 50u); + for (const auto &v : log.video) { + ASSERT_EQ(v.fourcc, "I420"); // 4:2:0 sources pass through by default + ASSERT_EQ(v.width, 640); + ASSERT_EQ(v.height, 360); + ASSERT_EQ(v.fps_num, 25); + ASSERT_EQ(v.fps_den, 1); + ASSERT_EQ(v.format_type, 1); // progressive + } + EXPECT_NEAR(static_cast(log.TotalAudioSamples()), 96000.0, 1024.0); + EXPECT_EQ(log.audio.front().sample_rate, 48000); + EXPECT_EQ(log.audio.front().channels, 2); + EXPECT_GT(log.audio[10].rms, 0.05); + + // Two seconds of media take two seconds to send. + double span_ms = (log.video.back().ns - log.video.front().ns) / 1e6; + EXPECT_NEAR(span_ms, 1960.0, 60.0); + + // Every frame goes out on schedule, and audio is aligned with video. + auto offsets = SendOffsetsMs(log); + double median = test::Median(offsets); + for (double offset : offsets) ASSERT_NEAR(offset, median, 25.0); + + for (size_t i = 1; i < log.video.size(); i++) ASSERT_EQ(log.video[i].timecode - log.video[i - 1].timecode, kFrame25); + + EXPECT_GE(log.flushes, 1); + EXPECT_TRUE(log.destroyed); +} + +TEST(Streamer, NoPacingSendsAsFastAsPossible) { + REQUIRE_MEDIA("av.mp4"); + auto options = test::MakeOptions("av.mp4"); + options.pacing = false; + + auto start = std::chrono::steady_clock::now(); + auto log = test::Stream(options); + EXPECT_LT(std::chrono::steady_clock::now() - start, 1500ms); + EXPECT_EQ(log.video.size(), 50u); + EXPECT_NEAR(static_cast(log.TotalAudioSamples()), 96000.0, 1024.0); +} + +TEST(Streamer, LoopsWithoutGapsOrOverlaps) { + REQUIRE_MEDIA("video_only.mkv"); + auto options = test::MakeOptions("video_only.mkv"); + options.loop = true; + options.pacing = false; + + auto log = test::Stream(options, 1500ms); + ASSERT_GT(log.video.size(), 45u * 3) << "expected several passes"; + + // 30000/1001 fps is 333667 (100 ns units) per frame; Matroska rounds timestamps to 1 ms. + for (size_t i = 1; i < log.video.size(); i++) { + int64_t delta = log.video[i].timecode - log.video[i - 1].timecode; + ASSERT_NEAR(static_cast(delta), 333667.0, 10000.0) << "at frame " << i; + } +} + +TEST(Streamer, LoopKeepsAudioContinuous) { + REQUIRE_MEDIA("av.mp4"); + auto options = test::MakeOptions("av.mp4"); + options.loop = true; + options.pacing = false; + + auto log = test::Stream(options, 1000ms); + ASSERT_GT(log.video.size(), 100u) << "expected more than one pass"; + + for (size_t i = 1; i < log.video.size(); i++) { + int64_t delta = log.video[i].timecode - log.video[i - 1].timecode; + // At the loop point the shorter stream may wait for the longer one, but never overlap it. + ASSERT_GE(delta, kFrame25) << "at frame " << i; + ASSERT_LE(delta, kFrame25 + 250000) << "at frame " << i; + } + for (size_t i = 1; i < log.audio.size(); i++) { + const auto &prev = log.audio[i - 1]; + int64_t prev_end = prev.timecode + static_cast(prev.samples) * 10000000 / prev.sample_rate; + // Timestamps carry microsecond precision; timecodes are in 100 ns units. + ASSERT_GE(log.audio[i].timecode, prev_end - 10) << "audio overlaps at block " << i; + ASSERT_LE(log.audio[i].timecode, prev_end + 250000) << "audio gap at block " << i; + } +} + +TEST(Streamer, VideoOnlyInput) { + REQUIRE_MEDIA("video_only.mkv"); + auto options = test::MakeOptions("video_only.mkv"); + options.pacing = false; + auto log = test::Stream(options); + + EXPECT_TRUE(log.audio.empty()); + ASSERT_EQ(log.video.size(), 45u); + EXPECT_EQ(log.video[0].fps_num, 30000); + EXPECT_EQ(log.video[0].fps_den, 1001); +} + +TEST(Streamer, AudioOnlyInputKeepsAllChannels) { + REQUIRE_MEDIA("audio_51.mka"); + auto log = test::Stream(test::MakeOptions("audio_51.mka")); + + EXPECT_TRUE(log.video.empty()); + ASSERT_FALSE(log.audio.empty()); + EXPECT_EQ(log.audio[0].channels, 6); + EXPECT_EQ(log.audio[0].sample_rate, 48000); + EXPECT_NEAR(static_cast(log.TotalAudioSamples()), 72000.0, 1536.0); + + // Paced in real time even without video. + double span_ms = (log.audio.back().ns - log.audio.front().ns) / 1e6; + EXPECT_NEAR(span_ms, 1500.0, 80.0); +} + +TEST(Streamer, ConvertsAndResamplesAudio) { + REQUIRE_MEDIA("audio_s16_mono.wav"); + auto options = test::MakeOptions("audio_s16_mono.wav"); + options.pacing = false; + options.audio_rate = 48000; + options.audio_channels = 2; + auto log = test::Stream(options); + + ASSERT_FALSE(log.audio.empty()); + EXPECT_EQ(log.audio[0].sample_rate, 48000); + EXPECT_EQ(log.audio[0].channels, 2); + EXPECT_NEAR(static_cast(log.TotalAudioSamples()), 72000.0, 512.0); + EXPECT_GT(log.audio[5].rms, 0.03); +} + +TEST(Streamer, MarksInterlacedVideo) { + REQUIRE_MEDIA("interlaced.ts"); + auto options = test::MakeOptions("interlaced.ts"); + options.pacing = false; + auto log = test::Stream(options); + + ASSERT_EQ(log.video.size(), 25u); + for (const auto &v : log.video) ASSERT_EQ(v.format_type, 0) << "expected NDI interleaved frames"; +} + +TEST(Streamer, DelayedAudioKeepsItsOffset) { + REQUIRE_MEDIA("late_audio.mkv"); + auto log = test::Stream(test::MakeOptions("late_audio.mkv")); + ASSERT_FALSE(log.video.empty()); + ASSERT_FALSE(log.audio.empty()); + + double timecode_ms = (log.audio.front().timecode - log.video.front().timecode) / 1e4; + double sent_ms = (log.audio.front().ns - log.video.front().ns) / 1e6; + EXPECT_NEAR(timecode_ms, 500.0, 25.0); + EXPECT_NEAR(sent_ms, 500.0, 50.0); +} + +TEST(Streamer, AutoPixelFormatPassesBgraThrough) { + REQUIRE_MEDIA("bgra.mkv"); + auto options = test::MakeOptions("bgra.mkv"); + options.pacing = false; + auto log = test::Stream(options); + + ASSERT_EQ(log.video.size(), 10u); + EXPECT_EQ(log.video[0].fourcc, "BGRA"); + EXPECT_GE(log.video[0].stride, 160 * 4); +} + +TEST(Streamer, HonoursRequestedPixelFormat) { + REQUIRE_MEDIA("av.mp4"); + for (auto [format, fourcc] : {std::pair{av::OutputPixelFormat::UYVY, "UYVY"}, + std::pair{av::OutputPixelFormat::NV12, "NV12"}, + std::pair{av::OutputPixelFormat::I420, "I420"}, + std::pair{av::OutputPixelFormat::BGRA, "BGRA"}}) { + auto options = test::MakeOptions("av.mp4"); + options.pacing = false; + options.input.disable_audio = true; + options.pixel_format = format; + auto log = test::Stream(options); + ASSERT_EQ(log.video.size(), 50u) << fourcc; + EXPECT_EQ(log.video[0].fourcc, fourcc); + } +} + +TEST(Streamer, AppliesVideoFilters) { + REQUIRE_MEDIA("av.mp4"); + auto options = test::MakeOptions("av.mp4"); + options.pacing = false; + options.video_filter = "scale=320:180,fps=50"; + auto log = test::Stream(options); + + ASSERT_GE(log.video.size(), 99u); + EXPECT_EQ(log.video[0].width, 320); + EXPECT_EQ(log.video[0].height, 180); + EXPECT_EQ(log.video[0].fps_num, 50); +} + +TEST(Streamer, TimesStreamsWithoutContainerTimestamps) { + REQUIRE_MEDIA("raw.m4v"); + auto options = test::MakeOptions("raw.m4v"); + options.pacing = false; + auto log = test::Stream(options); + + ASSERT_EQ(log.video.size(), 25u); + for (size_t i = 1; i < log.video.size(); i++) ASSERT_EQ(log.video[i].timecode - log.video[i - 1].timecode, kFrame25); +} + +TEST(Streamer, AutomaticHardwareDecodingFallsBackToSoftware) { + REQUIRE_MEDIA("av.mp4"); + auto options = test::MakeOptions("av.mp4"); + options.pacing = false; + options.decoder.hwaccel = "auto"; + auto log = test::Stream(options); + EXPECT_EQ(log.video.size(), 50u); +} + +TEST(Streamer, StopsPromptlyWhenAsked) { + REQUIRE_MEDIA("av.mp4"); + auto options = test::MakeOptions("av.mp4"); + options.loop = true; + + auto start = std::chrono::steady_clock::now(); + auto log = test::Stream(options, 400ms); + EXPECT_LT(std::chrono::steady_clock::now() - start, 1500ms); + EXPECT_FALSE(log.video.empty()); + EXPECT_TRUE(log.destroyed); +} + +TEST(Streamer, ReportsSetupErrors) { + REQUIRE_MEDIA("av.mp4"); + auto expect_error = [](const Options &options, const std::string &text) { + try { + test::Stream(options); + ADD_FAILURE() << "expected an error containing '" << text << "'"; + } catch (const std::exception &error) { + EXPECT_NE(std::string(error.what()).find(text), std::string::npos) << error.what(); + } + }; + + auto hwaccel = test::MakeOptions("av.mp4"); + hwaccel.decoder.hwaccel = "bogus"; + expect_error(hwaccel, "unknown hardware acceleration 'bogus'"); + + expect_error(test::MakeOptions("missing.mp4"), "cannot open"); + + auto filter = test::MakeOptions("av.mp4"); + filter.video_filter = "nonsense_filter"; + expect_error(filter, "parsing video filter"); + + auto runtime = test::MakeOptions("av.mp4"); + runtime.ndi_library = "/nonexistent/libndi.so.6"; + expect_error(runtime, "cannot load the NDI runtime"); +} + +TEST(Streamer, JoinsStreamsMidGop) { + REQUIRE_MEDIA("midgop.ts"); + auto options = test::MakeOptions("midgop.ts"); + options.pacing = false; + + // Hundreds of undecodable packets precede the first keyframe; that is not a reason to give up. + auto log = test::Stream(options); + EXPECT_GE(log.video.size(), 40u); +} + +TEST(Streamer, HonoursLongGapsInFiles) { + REQUIRE_MEDIA("slideshow.mkv"); + auto log = test::Stream(test::MakeOptions("slideshow.mkv")); + + ASSERT_EQ(log.video.size(), 3u); + double gap_s = (log.video[2].ns - log.video[1].ns) / 1e9; + EXPECT_NEAR(gap_s, 5.25, 0.15) << "a pause in a file must not be treated as a discontinuity"; +} + +TEST(Streamer, SlowVideoDoesNotStarveAudio) { + REQUIRE_MEDIA("av_60fps.mp4"); + auto options = test::MakeOptions("av_60fps.mp4"); + // Far slower than real time on any machine (about 4 fps on a 32-core desktop). + options.video_filter = "scale=1280:720:flags=lanczos,nlmeans,scale=320:180"; + + auto log = test::Stream(options); + ASSERT_FALSE(log.video.empty()); + EXPECT_NEAR(static_cast(log.TotalAudioSamples()), 240000.0, 2048.0); + + // Audio is still delivered in real time, on a single uninterrupted schedule. + double span_s = (log.audio.back().ns - log.audio.front().ns) / 1e9; + EXPECT_NEAR(span_s, 4.98, 0.3); + std::vector offsets; + for (const auto &a : log.audio) offsets.push_back(a.ns / 1e6 - a.timecode / 1e4); + double median = test::Median(offsets); + for (double offset : offsets) ASSERT_NEAR(offset, median, 60.0); +} + +TEST(Streamer, LoopsMp3WithoutGaps) { + REQUIRE_MEDIA("loop.mp3"); + auto options = test::MakeOptions("loop.mp3"); + options.loop = true; + options.pacing = false; + + auto log = test::Stream(options, 500ms); + ASSERT_GT(log.TotalAudioSamples(), 44100 * 3) << "expected several passes"; + for (size_t i = 1; i < log.audio.size(); i++) { + const auto &prev = log.audio[i - 1]; + int64_t prev_end = prev.timecode + static_cast(prev.samples) * 10000000 / prev.sample_rate; + // Encoder delay and padding are trimmed by the decoder and must not leave holes at the loop point. + ASSERT_NEAR(static_cast(log.audio[i].timecode - prev_end), 0.0, 20.0) << "at block " << i; + } +} diff --git a/tests/test_video_processor.cpp b/tests/test_video_processor.cpp new file mode 100644 index 0000000..c10f499 --- /dev/null +++ b/tests/test_video_processor.cpp @@ -0,0 +1,298 @@ +/** + * @file test_video_processor.cpp + * @brief Pixel conversion, NDI layouts and timing metadata on synthetic frames. + */ + +#include "av/video_processor.hpp" + +#include + +#include +#include + +extern "C" { +#include +} + +using namespace ndistreamer; +using av::OutputPixelFormat; +using av::VideoProcessor; +using av::VideoProcessorConfig; + +namespace { + +av::FramePtr MakeVideoFrame(AVPixelFormat format, int width, int height, int64_t pts = 0) { + av::FramePtr frame = av::MakeFrame(); + frame->format = format; + frame->width = width; + frame->height = height; + frame->pts = pts; + av::Check(av_frame_get_buffer(frame.get(), 0), "allocating test frame"); + return frame; +} + +int PlaneHeight(const AVFrame *frame, int plane) { + const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(static_cast(frame->format)); + bool chroma = plane == 1 || plane == 2; + return chroma ? AV_CEIL_RSHIFT(frame->height, desc->log2_chroma_h) : frame->height; +} + +void FillPlane(AVFrame *frame, int plane, uint8_t value) { + std::memset(frame->data[plane], value, static_cast(frame->linesize[plane]) * PlaneHeight(frame, plane)); +} + +void FillNv12(AVFrame *frame, uint8_t y, uint8_t u, uint8_t v) { + FillPlane(frame, 0, y); + for (int row = 0; row < PlaneHeight(frame, 1); row++) { + uint8_t *line = frame->data[1] + static_cast(row) * frame->linesize[1]; + for (int x = 0; x < frame->width; x += 2) { + line[x] = u; + line[x + 1] = v; + } + } +} + +std::vector Process(VideoProcessor &processor, av::FramePtr frame) { + std::vector out; + processor.Process(std::move(frame), [&](VideoFrame &&f) { + out.push_back(std::move(f)); + return true; + }); + return out; +} + +std::vector Flush(VideoProcessor &processor) { + std::vector out; + processor.Flush([&](VideoFrame &&f) { + out.push_back(std::move(f)); + return true; + }); + return out; +} + +VideoProcessorConfig Config(OutputPixelFormat format = OutputPixelFormat::Auto) { + VideoProcessorConfig config; + config.pixel_format = format; + config.time_base = {1, 25}; + config.frame_rate = {25, 1}; + return config; +} + +} // namespace + +TEST(VideoProcessor, ConvertsYuv420pToUyvy) { + VideoProcessor processor(Config(OutputPixelFormat::UYVY)); + auto frame = MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 48); + FillPlane(frame.get(), 0, 81); + FillPlane(frame.get(), 1, 90); + FillPlane(frame.get(), 2, 240); + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + const VideoFrame &f = out[0]; + EXPECT_EQ(f.fourcc, VideoFourCC::UYVY); + EXPECT_EQ(f.width, 64); + EXPECT_EQ(f.height, 48); + EXPECT_GE(f.stride, 128); + EXPECT_EQ(f.stride % 64, 0); + + for (int y = 0; y < f.height; y++) { + const uint8_t *line = f.data + static_cast(y) * f.stride; + for (int x = 0; x < f.width * 2; x += 4) { + ASSERT_NEAR(line[x + 0], 90, 1) << "U at " << x << "," << y; + ASSERT_NEAR(line[x + 1], 81, 1) << "Y at " << x << "," << y; + ASSERT_NEAR(line[x + 2], 240, 1) << "V at " << x << "," << y; + ASSERT_NEAR(line[x + 3], 81, 1) << "Y at " << x << "," << y; + } + } +} + +TEST(VideoProcessor, UyvyPassesThroughWithoutCopying) { + VideoProcessor processor(Config(OutputPixelFormat::UYVY)); + auto frame = MakeVideoFrame(AV_PIX_FMT_UYVY422, 64, 48); + const uint8_t *original = frame->data[0]; + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].fourcc, VideoFourCC::UYVY); + EXPECT_EQ(out[0].data, original); +} + +TEST(VideoProcessor, Nv12IsRepackedIntoOneContiguousBuffer) { + VideoProcessor processor(Config(OutputPixelFormat::NV12)); + // 50 rows: FFmpeg pads the luma plane, leaving a gap before the chroma plane. + auto frame = MakeVideoFrame(AV_PIX_FMT_NV12, 64, 50); + FillNv12(frame.get(), 100, 110, 120); + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + const VideoFrame &f = out[0]; + EXPECT_EQ(f.fourcc, VideoFourCC::NV12); + + const uint8_t *uv = f.data + static_cast(f.stride) * f.height; + EXPECT_EQ(f.storage->data[1], uv) << "chroma must follow luma immediately"; + for (int y = 0; y < f.height; y++) ASSERT_EQ(f.data[static_cast(y) * f.stride + 10], 100); + for (int y = 0; y < 25; y++) { + ASSERT_EQ(uv[static_cast(y) * f.stride + 0], 110); + ASSERT_EQ(uv[static_cast(y) * f.stride + 1], 120); + } +} + +TEST(VideoProcessor, I420UsesHalfStrideChromaPlanes) { + VideoProcessor processor(Config(OutputPixelFormat::I420)); + auto frame = MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 50); + FillPlane(frame.get(), 0, 50); + FillPlane(frame.get(), 1, 60); + FillPlane(frame.get(), 2, 70); + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + const VideoFrame &f = out[0]; + EXPECT_EQ(f.fourcc, VideoFourCC::I420); + + const AVFrame *storage = f.storage.get(); + EXPECT_EQ(storage->linesize[1], f.stride / 2); + EXPECT_EQ(storage->linesize[2], f.stride / 2); + EXPECT_EQ(storage->data[1], f.data + static_cast(f.stride) * 50); + EXPECT_EQ(storage->data[2], storage->data[1] + static_cast(f.stride / 2) * 25); + EXPECT_EQ(storage->data[0][0], 50); + EXPECT_EQ(storage->data[1][0], 60); + EXPECT_EQ(storage->data[2][0], 70); +} + +TEST(VideoProcessor, AutoConvertsFullRangeYuvToLimitedRange) { + VideoProcessor processor(Config(OutputPixelFormat::Auto)); + auto frame = MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 48); + frame->color_range = AVCOL_RANGE_JPEG; + FillPlane(frame.get(), 0, 0); // full-range black + FillPlane(frame.get(), 1, 128); + FillPlane(frame.get(), 2, 128); + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].fourcc, VideoFourCC::UYVY); + EXPECT_NEAR(out[0].data[1], 16, 1); // limited-range black +} + +TEST(VideoProcessor, AutoKeepsFormatsNdiAccepts) { + VideoProcessor processor(Config(OutputPixelFormat::Auto)); + auto frame = MakeVideoFrame(AV_PIX_FMT_BGRA, 32, 16); + const uint8_t *original = frame->data[0]; + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].fourcc, VideoFourCC::BGRA); + EXPECT_EQ(out[0].data, original); + + auto nv12 = MakeVideoFrame(AV_PIX_FMT_NV12, 32, 16); + out = Process(processor, std::move(nv12)); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].fourcc, VideoFourCC::NV12); +} + +TEST(VideoProcessor, ConvertsYuvToRgba) { + VideoProcessor processor(Config(OutputPixelFormat::RGBA)); + auto frame = MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 48); + frame->colorspace = AVCOL_SPC_SMPTE170M; + FillPlane(frame.get(), 0, 81); // BT.601 limited-range red + FillPlane(frame.get(), 1, 90); + FillPlane(frame.get(), 2, 240); + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].fourcc, VideoFourCC::RGBA); + const uint8_t *pixel = out[0].data + out[0].stride * 10 + 4 * 10; + EXPECT_NEAR(pixel[0], 255, 4); + EXPECT_NEAR(pixel[1], 0, 4); + EXPECT_NEAR(pixel[2], 0, 4); + EXPECT_EQ(pixel[3], 255); +} + +TEST(VideoProcessor, CarriesTimingAspectAndInterlacing) { + VideoProcessorConfig config = Config(); + config.time_base = {1, 1000}; + VideoProcessor processor(config); + + auto frame = MakeVideoFrame(AV_PIX_FMT_YUV420P, 720, 576, 100); + frame->sample_aspect_ratio = {16, 15}; +#ifdef AV_FRAME_FLAG_INTERLACED + frame->flags |= AV_FRAME_FLAG_INTERLACED; +#else + frame->interlaced_frame = 1; +#endif + + auto out = Process(processor, std::move(frame)); + ASSERT_EQ(out.size(), 1u); + const VideoFrame &f = out[0]; + EXPECT_EQ(f.pts_us, 100000); + EXPECT_EQ(f.duration_us, 40000); + EXPECT_EQ(f.frame_rate_num, 25); + EXPECT_EQ(f.frame_rate_den, 1); + EXPECT_NEAR(f.aspect_ratio, 4.0 / 3.0, 1e-4); + EXPECT_TRUE(f.interlaced); +} + +TEST(VideoProcessor, FramesWithoutTimestampsAreSequential) { + VideoProcessor processor(Config()); + std::vector pts; + for (int i = 0; i < 3; i++) { + auto frame = MakeVideoFrame(AV_PIX_FMT_UYVY422, 16, 16, AV_NOPTS_VALUE); + for (auto &f : Process(processor, std::move(frame))) pts.push_back(f.pts_us); + } + EXPECT_EQ(pts, (std::vector{0, 40000, 80000})); +} + +TEST(VideoProcessor, FiltergraphScalesAndChangesFrameRate) { + VideoProcessorConfig config = Config(); + config.filter = "scale=32:24,fps=50"; + VideoProcessor processor(config); + + std::vector out; + for (int i = 0; i < 10; i++) { + for (auto &f : Process(processor, MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 48, i))) out.push_back(std::move(f)); + } + for (auto &f : Flush(processor)) out.push_back(std::move(f)); + + // Twice the input frames, give or take how the fps filter rounds at the end of the stream. + ASSERT_GE(out.size(), 18u); + ASSERT_LE(out.size(), 20u); + EXPECT_EQ(out[0].width, 32); + EXPECT_EQ(out[0].height, 24); + EXPECT_EQ(out[0].frame_rate_num, 50); + EXPECT_EQ(out[0].frame_rate_den, 1); + EXPECT_EQ(out[1].pts_us - out[0].pts_us, 20000); +} + +TEST(VideoProcessor, InvalidFiltergraphIsReported) { + VideoProcessorConfig config = Config(); + config.filter = "definitely_not_a_filter"; + VideoProcessor processor(config); + try { + Process(processor, MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 48)); + FAIL() << "expected an error"; + } catch (const av::Error &error) { + EXPECT_NE(std::string(error.what()).find("parsing video filter"), std::string::npos) << error.what(); + } +} + +TEST(VideoProcessor, HandlesResolutionChanges) { + VideoProcessor processor(Config()); + auto first = Process(processor, MakeVideoFrame(AV_PIX_FMT_YUV420P, 64, 48, 0)); + auto second = Process(processor, MakeVideoFrame(AV_PIX_FMT_YUV420P, 32, 16, 1)); + ASSERT_EQ(first.size(), 1u); + ASSERT_EQ(second.size(), 1u); + EXPECT_EQ(first[0].width, 64); + EXPECT_EQ(second[0].width, 32); + EXPECT_EQ(second[0].height, 16); +} + +TEST(OutputPixelFormat, ParsesNames) { + EXPECT_EQ(av::ParseOutputPixelFormat("uyvy"), OutputPixelFormat::UYVY); + EXPECT_EQ(av::ParseOutputPixelFormat("auto"), OutputPixelFormat::Auto); + EXPECT_EQ(av::ParseOutputPixelFormat("native"), std::nullopt); + EXPECT_EQ(av::ParseOutputPixelFormat("UYVY"), std::nullopt); + for (auto format : {OutputPixelFormat::UYVY, OutputPixelFormat::NV12, OutputPixelFormat::I420, + OutputPixelFormat::BGRA, OutputPixelFormat::RGBA, OutputPixelFormat::Auto}) { + EXPECT_EQ(av::ParseOutputPixelFormat(av::ToString(format)), format); + } +}