From 2e09b8252c16042494f8090f7091001ded564871 Mon Sep 17 00:00:00 2001 From: Matthew Geiger Date: Wed, 30 Sep 2026 19:29:11 -0700 Subject: [PATCH 1/3] Reengineer ndistreamer as a single real-time pipeline Replace the three duplicated software/VAAPI/CUDA applications with one threaded pipeline (demux -> decode -> process -> present) connected by bounded queues and driven by a shared presentation clock. - Frames are sent at their presentation timestamps; audio is the master clock while it plays, and NDI timecodes derive from media timestamps. - Hardware decoding through any FFmpeg device type, with per-stream software fallback; GPU frames download straight into NDI's layout. - Pixel formats NDI accepts pass through (new default `-p auto`), cutting sender CPU by 45-67% versus converting everything to UYVY. - Graceful overload: drop late frames when newer ones are ready, skip processing of late frames, then decode keyframes only. Audio is never starved, and live sources tolerate drift and mid-GOP joins. - Seamless looping, FFmpeg filters, any FFmpeg input or capture device, multichannel float audio, clean signal handling, stats and logging. - The NDI runtime is loaded with dlopen, so building needs no NDI SDK. Fixes 1.x crashes on files without audio, the 0/0 NDI frame rate, lost final frames at end of input, and the build against FFmpeg 8+. Tests stream generated clips through the whole pipeline into a fake libndi.so, checking pacing, A/V alignment, loop continuity and frame lifetime. With the NDI SDK configured, they also verify the ABI against the official headers and stream end to end to a real receiver. CI builds with GCC, Clang and ASan/UBSan on Ubuntu 24.04 (FFmpeg 6.1). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 54 ++++ CMakeLists.txt | 146 ++++++---- TODO.txt | 3 - cmake/FindFFMPEG.cmake | 21 -- cmake/FindNDISDK.cmake | 61 +++- src/app.hpp | 14 - src/asyncndisource.cpp | 229 ---------------- src/asyncndisource.hpp | 66 ----- src/audioresampler.cpp | 179 ------------ src/audioresampler.hpp | 74 ----- src/av/audio_processor.cpp | 168 ++++++++++++ src/av/audio_processor.hpp | 65 +++++ src/av/decoder.cpp | 210 ++++++++++++++ src/av/decoder.hpp | 87 ++++++ src/av/ffmpeg.cpp | 44 +++ src/av/ffmpeg.hpp | 118 ++++++++ src/av/input.cpp | 263 ++++++++++++++++++ src/av/input.hpp | 102 +++++++ src/av/video_processor.cpp | 458 +++++++++++++++++++++++++++++++ src/av/video_processor.hpp | 117 ++++++++ src/averror.cpp | 147 ---------- src/averror.hpp | 106 ------- src/cudaapp.cpp | 239 ---------------- src/cudaapp.hpp | 59 ---- src/cudadecoder.cpp | 225 --------------- src/cudadecoder.hpp | 99 ------- src/cudafilter.cpp | 158 ----------- src/cudafilter.hpp | 48 ---- src/decoder.cpp | 181 ------------ src/decoder.hpp | 89 ------ src/demuxer.cpp | 154 ----------- src/demuxer.hpp | 92 ------- src/frame.cpp | 153 ----------- src/frame.hpp | 39 --- src/frames.cpp | 30 ++ src/frames.hpp | 66 +++++ src/frametimer.cpp | 170 ------------ src/frametimer.hpp | 77 ------ src/macro.hpp | 32 --- src/main.cpp | 109 ++++++++ src/ndi.cpp | 43 --- src/ndi.hpp | 39 --- src/ndi/abi.hpp | 95 +++++++ src/ndi/runtime.cpp | 137 +++++++++ src/ndi/runtime.hpp | 62 +++++ src/ndi/sender.cpp | 95 +++++++ src/ndi/sender.hpp | 68 +++++ src/ndisource.cpp | 179 ------------ src/ndisource.hpp | 55 ---- src/ndistreamer.cpp | 118 -------- src/options.cpp | 266 ++++++++++++++++++ src/options.hpp | 47 ++++ src/pixelencoder.cpp | 170 ------------ src/pixelencoder.hpp | 82 ------ src/simplefilter.cpp | 175 ------------ src/simplefilter.hpp | 57 ---- src/softwareapp.cpp | 261 ------------------ src/softwareapp.hpp | 60 ---- src/streamer.cpp | 411 +++++++++++++++++++++++++++ src/streamer.hpp | 146 ++++++++++ src/util/clock.cpp | 85 ++++++ src/util/clock.hpp | 89 ++++++ src/util/log.cpp | 137 +++++++++ src/util/log.hpp | 62 +++++ src/util/queue.hpp | 116 ++++++++ src/vaapiapp.cpp | 239 ---------------- src/vaapiapp.hpp | 59 ---- src/vaapidecoder.cpp | 225 --------------- src/vaapidecoder.hpp | 99 ------- src/version.hpp.in | 8 + tests/CMakeLists.txt | 133 ++++++--- tests/audioresampler_test.cpp | 63 ----- tests/decoder_test.cpp | 89 ------ tests/demuxer_test.cpp | 42 --- tests/e2e/ndi_receiver.cpp | 94 +++++++ tests/e2e/run_e2e.sh | 72 +++++ tests/fake_ndi/fake_ndi.cpp | 175 ++++++++++++ tests/fake_ndi/not_ndi.cpp | 8 + tests/media/generate_media.cmake | 105 +++++++ tests/ndi_abi_test.cpp | 98 +++++++ tests/pixelencoder_test.cpp | 77 ------ tests/support/main.cpp | 28 ++ tests/support/test_support.cpp | 100 +++++++ tests/support/test_support.hpp | 94 +++++++ tests/test_audio_processor.cpp | 194 +++++++++++++ tests/test_clock.cpp | 107 ++++++++ tests/test_input.cpp | 123 +++++++++ tests/test_log.cpp | 51 ++++ tests/test_options.cpp | 143 ++++++++++ tests/test_queue.cpp | 76 +++++ tests/test_runtime.cpp | 78 ++++++ tests/test_streamer.cpp | 329 ++++++++++++++++++++++ tests/test_video_processor.cpp | 298 ++++++++++++++++++++ 93 files changed, 6122 insertions(+), 4892 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 TODO.txt delete mode 100644 cmake/FindFFMPEG.cmake delete mode 100644 src/app.hpp delete mode 100644 src/asyncndisource.cpp delete mode 100644 src/asyncndisource.hpp delete mode 100644 src/audioresampler.cpp delete mode 100644 src/audioresampler.hpp create mode 100644 src/av/audio_processor.cpp create mode 100644 src/av/audio_processor.hpp create mode 100644 src/av/decoder.cpp create mode 100644 src/av/decoder.hpp create mode 100644 src/av/ffmpeg.cpp create mode 100644 src/av/ffmpeg.hpp create mode 100644 src/av/input.cpp create mode 100644 src/av/input.hpp create mode 100644 src/av/video_processor.cpp create mode 100644 src/av/video_processor.hpp delete mode 100644 src/averror.cpp delete mode 100644 src/averror.hpp delete mode 100644 src/cudaapp.cpp delete mode 100644 src/cudaapp.hpp delete mode 100644 src/cudadecoder.cpp delete mode 100644 src/cudadecoder.hpp delete mode 100644 src/cudafilter.cpp delete mode 100644 src/cudafilter.hpp delete mode 100644 src/decoder.cpp delete mode 100644 src/decoder.hpp delete mode 100644 src/demuxer.cpp delete mode 100644 src/demuxer.hpp delete mode 100644 src/frame.cpp delete mode 100644 src/frame.hpp create mode 100644 src/frames.cpp create mode 100644 src/frames.hpp delete mode 100644 src/frametimer.cpp delete mode 100644 src/frametimer.hpp delete mode 100644 src/macro.hpp create mode 100644 src/main.cpp delete mode 100644 src/ndi.cpp delete mode 100644 src/ndi.hpp create mode 100644 src/ndi/abi.hpp create mode 100644 src/ndi/runtime.cpp create mode 100644 src/ndi/runtime.hpp create mode 100644 src/ndi/sender.cpp create mode 100644 src/ndi/sender.hpp delete mode 100644 src/ndisource.cpp delete mode 100644 src/ndisource.hpp delete mode 100644 src/ndistreamer.cpp create mode 100644 src/options.cpp create mode 100644 src/options.hpp delete mode 100644 src/pixelencoder.cpp delete mode 100644 src/pixelencoder.hpp delete mode 100644 src/simplefilter.cpp delete mode 100644 src/simplefilter.hpp delete mode 100644 src/softwareapp.cpp delete mode 100644 src/softwareapp.hpp create mode 100644 src/streamer.cpp create mode 100644 src/streamer.hpp create mode 100644 src/util/clock.cpp create mode 100644 src/util/clock.hpp create mode 100644 src/util/log.cpp create mode 100644 src/util/log.hpp create mode 100644 src/util/queue.hpp delete mode 100644 src/vaapiapp.cpp delete mode 100644 src/vaapiapp.hpp delete mode 100644 src/vaapidecoder.cpp delete mode 100644 src/vaapidecoder.hpp create mode 100644 src/version.hpp.in delete mode 100644 tests/audioresampler_test.cpp delete mode 100644 tests/decoder_test.cpp delete mode 100644 tests/demuxer_test.cpp create mode 100644 tests/e2e/ndi_receiver.cpp create mode 100755 tests/e2e/run_e2e.sh create mode 100644 tests/fake_ndi/fake_ndi.cpp create mode 100644 tests/fake_ndi/not_ndi.cpp create mode 100644 tests/media/generate_media.cmake create mode 100644 tests/ndi_abi_test.cpp delete mode 100644 tests/pixelencoder_test.cpp create mode 100644 tests/support/main.cpp create mode 100644 tests/support/test_support.cpp create mode 100644 tests/support/test_support.hpp create mode 100644 tests/test_audio_processor.cpp create mode 100644 tests/test_clock.cpp create mode 100644 tests/test_input.cpp create mode 100644 tests/test_log.cpp create mode 100644 tests/test_options.cpp create mode 100644 tests/test_queue.cpp create mode 100644 tests/test_runtime.cpp create mode 100644 tests/test_streamer.cpp create mode 100644 tests/test_video_processor.cpp 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/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/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..dfea8ad --- /dev/null +++ b/src/av/audio_processor.cpp @@ -0,0 +1,168 @@ +/** + * @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 + +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"); + } +} + +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 sink(std::move(out)); +} + +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); + } + sink(std::move(out)); +} + +void AudioProcessor::Flush(const Sink &sink) { + if (!m_swr) return; + Resample(nullptr, 0, m_next_in_us, sink); +} + +} // namespace ndistreamer::av diff --git a/src/av/audio_processor.hpp b/src/av/audio_processor.hpp new file mode 100644 index 0000000..e9a8e79 --- /dev/null +++ b/src/av/audio_processor.hpp @@ -0,0 +1,65 @@ +/** + * @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 + +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. + */ +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 samples still buffered in the resampler at the end of the stream. + */ + void Flush(const Sink &sink); + +private: + 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; + 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..e1565bb --- /dev/null +++ b/tests/test_audio_processor.cpp @@ -0,0 +1,194 @@ +/** + * @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; +} + +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 = Process(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 = Process(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 = Process(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 = Process(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); + } + 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}); + + ASSERT_EQ(Process(processor, s16.get()).size(), 1u); + auto out = Process(processor, fltp.get()); + 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); +} 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); + } +} From d8f4ba3b4ee1abdb3e3975a295681d4074b2ef56 Mon Sep 17 00:00:00 2001 From: Matthew Geiger Date: Wed, 30 Sep 2026 19:29:11 -0700 Subject: [PATCH 2/3] Rewrite README and add changelog for 2.0.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 69 +++++++++++++++ README.md | 236 +++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 262 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.md 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/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. From 0912183ca9dfc79a7695968df90c69e71551aa36 Mon Sep 17 00:00:00 2001 From: Matthew Geiger Date: Wed, 30 Sep 2026 19:35:11 -0700 Subject: [PATCH 3/3] Trim encoder padding that overlaps the next audio block Older FFmpeg (6.1, found by CI) does not trim AAC end padding when decoding, so at a loop point the last block of a pass ran 256 samples past where the next pass begins. Receivers would play the extra samples and drift a little further behind on every loop. The audio processor now holds back one block (about 21 ms). When the next block starts slightly before it ends, the held block's tail is trimmed. Larger backward jumps are discontinuities and are left to the presentation clock. Co-Authored-By: Claude Opus 5.5 --- src/av/audio_processor.cpp | 36 ++++++++++++++++++--- src/av/audio_processor.hpp | 8 ++++- tests/test_audio_processor.cpp | 58 ++++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/av/audio_processor.cpp b/src/av/audio_processor.cpp index dfea8ad..9418806 100644 --- a/src/av/audio_processor.cpp +++ b/src/av/audio_processor.cpp @@ -6,9 +6,11 @@ #include "av/audio_processor.hpp" #include "util/log.hpp" +#include #include #include #include +#include #include extern "C" { @@ -30,6 +32,15 @@ void CopyLayout(AVChannelLayout &dst, const AVChannelLayout &src) { } } +// 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)); @@ -124,7 +135,20 @@ bool AudioProcessor::Resample(const uint8_t *const *input, int input_samples, in out.samples_per_channel = converted; out.pts_us = input_pts_us - delay_us; - return sink(std::move(out)); + 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) { @@ -157,12 +181,16 @@ void AudioProcessor::Process(const AVFrame *frame, const Sink &sink) { std::memcpy(out.samples.data() + static_cast(c) * out.channel_stride, frame->extended_data[c], plane_bytes); } - sink(std::move(out)); + Emit(std::move(out), sink); } void AudioProcessor::Flush(const Sink &sink) { - if (!m_swr) return; - Resample(nullptr, 0, m_next_in_us, 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 index e9a8e79..95c749e 100644 --- a/src/av/audio_processor.hpp +++ b/src/av/audio_processor.hpp @@ -9,6 +9,7 @@ #include "frames.hpp" #include +#include namespace ndistreamer::av { @@ -23,6 +24,9 @@ struct AudioProcessorConfig { * * 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: @@ -37,11 +41,12 @@ class AudioProcessor { void Process(const AVFrame *frame, const Sink &sink); /** - * @brief Emit samples still buffered in the resampler at the end of the stream. + * @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); @@ -59,6 +64,7 @@ class AudioProcessor { 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; }; diff --git a/tests/test_audio_processor.cpp b/tests/test_audio_processor.cpp index e1565bb..233fdea 100644 --- a/tests/test_audio_processor.cpp +++ b/tests/test_audio_processor.cpp @@ -56,6 +56,13 @@ std::vector Flush(AudioProcessor &processor) { 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; @@ -71,7 +78,7 @@ TEST(AudioProcessor, PacksPlanarFloatWithoutConversion) { auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 2, 1024, 480); FillPlanarFloat(frame.get(), {0.25f, -0.5f}); - auto out = Process(processor, frame.get()); + auto out = ProcessAndFlush(processor, frame.get()); ASSERT_EQ(out.size(), 1u); const AudioFrame &f = out[0]; EXPECT_EQ(f.sample_rate, 48000); @@ -94,7 +101,7 @@ TEST(AudioProcessor, ConvertsInterleavedS16) { samples[2 * i + 1] = -16384; } - auto out = Process(processor, frame.get()); + auto out = ProcessAndFlush(processor, frame.get()); ASSERT_EQ(out.size(), 1u); const AudioFrame &f = out[0]; ASSERT_EQ(f.samples_per_channel, 256); @@ -124,7 +131,7 @@ TEST(AudioProcessor, RemixesToRequestedChannelCount) { 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 = Process(processor, frame.get()); + 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); @@ -133,7 +140,7 @@ TEST(AudioProcessor, RemixesToRequestedChannelCount) { TEST(AudioProcessor, KeepsAllSourceChannels) { AudioProcessor processor(Config()); auto frame = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 48000, 8, 128, 0); - auto out = Process(processor, frame.get()); + auto out = ProcessAndFlush(processor, frame.get()); ASSERT_EQ(out.size(), 1u); EXPECT_EQ(out[0].channels, 8); } @@ -145,6 +152,7 @@ TEST(AudioProcessor, FramesWithoutTimestampsAreSequential) { 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})); } @@ -154,8 +162,12 @@ TEST(AudioProcessor, AdaptsWhenInputFormatChanges) { auto fltp = MakeAudioFrame(AV_SAMPLE_FMT_FLTP, 44100, 1, 256, 256); FillPlanarFloat(fltp.get(), {0.75f}); - ASSERT_EQ(Process(processor, s16.get()).size(), 1u); - auto out = Process(processor, fltp.get()); + 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); @@ -192,3 +204,37 @@ TEST(AudioProcessor, KeepsBufferedSamplesWhenInputChanges) { 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); +}