From 1c9e30de28a47db2ec7fffaf93d1fac5083e18ae Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 7 Oct 2026 12:16:02 -0600 Subject: [PATCH 1/3] integrate livekit capture source-clock and source-pattern --- .gitattributes | 1 + AGENTS.md | 4 +- CMakeLists.txt | 16 + CMakePresets.json | 36 ++- build.h.in | 17 +- docs/README.md | 2 + docs/building.md | 1 + docs/capture-sources.md | 42 +++ include/livekit/capture_source.h | 232 +++++++++++++++ include/livekit/livekit.h | 1 + include/livekit/video_source.h | 6 + scripts/generate-docs.sh | 2 +- src/capture_source.cpp | 276 ++++++++++++++++++ src/ffi_client.cpp | 48 +++ src/ffi_client.h | 4 + src/tests/integration/test_capture_source.cpp | 176 +++++++++++ .../stress/test_capture_source_stress.cpp | 90 ++++++ src/tests/unit/test_capture_source.cpp | 44 +++ 18 files changed, 979 insertions(+), 19 deletions(-) create mode 100644 docs/capture-sources.md create mode 100644 include/livekit/capture_source.h create mode 100644 src/capture_source.cpp create mode 100644 src/tests/integration/test_capture_source.cpp create mode 100644 src/tests/stress/test_capture_source_stress.cpp create mode 100644 src/tests/unit/test_capture_source.cpp diff --git a/.gitattributes b/.gitattributes index 7b1bca66..7ead1943 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ data/welcome.wav filter=lfs diff=lfs merge=lfs -text build.cmd whitespace=cr-at-eol +CMakePresets.json whitespace=cr-at-eol diff --git a/AGENTS.md b/AGENTS.md index 58985b83..46f4f6d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ The SDK has three categories of threads: 1. Completes any pending async `std::promise` matched by `async_id`. 2. Invokes all registered `FfiClient` listeners (including `Room::onEvent`). -All `RoomDelegate` callbacks and stream handler callbacks (e.g., `registerTextStreamHandler`) are invoked on this FFI callback thread. **Handlers must not block**; spawn a background thread if synchronous work is needed. +All `RoomDelegate` callbacks, stream handler callbacks (e.g., `registerTextStreamHandler`), and `CaptureSource::FinishedCallback` notifications are invoked on this FFI callback thread. **Handlers must not block**; spawn a background thread if synchronous work is needed. **Per-subscription reader threads** — `SubscriptionThreadDispatcher` creates a dedicated `std::thread` for each active audio, video, or data track subscription. These threads block on `AudioStream::read()`, `VideoStream::read()`, or `DataTrackStream::read()` and invoke the registered `AudioFrameCallback`, `VideoFrameCallback`, or `DataFrameCallback` on that reader thread — not on the FFI callback thread. A hard limit of 20 concurrent reader threads is enforced. @@ -77,6 +77,7 @@ All `RoomDelegate` callbacks and stream handler callbacks (e.g., `registerTextSt | `LocalAudioTrack` / `LocalVideoTrack` | No | Thin `sendRequest` wrappers with no internal synchronization. | | `LocalDataTrack::tryPush` | No | Thin `sendRequest` wrapper with no internal synchronization. | | `TextStreamWriter` / `ByteStreamWriter` | Serialized | `write()` is serialized by an internal `write_mutex_`. | +| `CaptureSource` | Partially | Callback registration is mutex-protected; `start()` and `stop()` send FFI requests. Destruction must be synchronized with application calls. The finished callback runs on the FFI callback thread. | ### Directory Layout Be sure to update the directory layout in this file if the directory layout changes. @@ -104,6 +105,7 @@ Be sure to update the directory layout in this file if the directory layout chan - **`RoomDelegate`** — Virtual callback interface for room lifecycle events. - **`SubscriptionThreadDispatcher`** — Owns callback registrations and per-subscription reader threads for audio, video, and data tracks. `Room` delegates callback management here. - **`LocalParticipant` / `RemoteParticipant`** — Participant objects for publishing and receiving tracks. +- **`CaptureSource`** — Wrapper for Rust-owned video capture producers and their frame pumps. Pattern and clock sources are supported initially. ## Build for building, use the build.sh script for Linux and macOS, and the build.cmd script for Windows. Do not invoke CMake directly to build the SDK. diff --git a/CMakeLists.txt b/CMakeLists.txt index 1fa34d15..47688121 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ include(warnings) option(LIVEKIT_BUILD_EXAMPLES "Build LiveKit examples" OFF) option(LIVEKIT_BUILD_TESTS "Build LiveKit tests" OFF) +option(LIVEKIT_ENABLE_CAPTURE "Build the Rust FFI with pattern and clock capture sources" OFF) # vcpkg is only used on Windows; Linux/macOS use system package managers if(WIN32) @@ -167,6 +168,14 @@ set(GENERATED_BUILD_H "${LIVEKIT_ROOT_DIR}/include/livekit/build.h") set(GENERATED_BUILD_INFO_JSON "${LIVEKIT_BINARY_DIR}/build-info.json") set(RUST_ROOT ${LIVEKIT_ROOT_DIR}/client-sdk-rust) +# Cargo features for the Rust FFI build. +set(LIVEKIT_CARGO_FEATURES "") +set(LIVEKIT_CAPTURE_ENABLED 0) +if(LIVEKIT_ENABLE_CAPTURE) + set(LIVEKIT_CARGO_FEATURES "capture-pattern,capture-clock") + set(LIVEKIT_CAPTURE_ENABLED 1) +endif() + set(GIT_COMMIT "unknown") set(RUST_GIT_COMMIT "unknown") find_package(Git QUIET) @@ -262,6 +271,11 @@ if(NOT CFG STREQUAL \"Debug\") list(APPEND ARGS --release) endif() +if(DEFINED CARGO_FEATURES AND NOT CARGO_FEATURES STREQUAL \"\") + list(APPEND ARGS --features \"\${CARGO_FEATURES}\") + message(STATUS \"[run_cargo.cmake] Cargo features: \${CARGO_FEATURES}\") +endif() + if(DEFINED RUST_TARGET AND NOT RUST_TARGET STREQUAL \"\") list(APPEND ARGS --target \"\${RUST_TARGET}\") message(STATUS \"[run_cargo.cmake] Cross-compiling for target: \${RUST_TARGET}\") @@ -378,6 +392,7 @@ add_custom_command( -DPROTOC_PATH=${Protobuf_PROTOC_EXECUTABLE} -DRUST_TARGET=${RUST_TARGET_TRIPLE} -DGCC_LIB_DIR=${GCC_LIB_DIR} + -DCARGO_FEATURES=${LIVEKIT_CARGO_FEATURES} -P "${RUN_CARGO_SCRIPT}" WORKING_DIRECTORY "${RUST_ROOT}" DEPENDS ${RUST_SOURCES} @@ -396,6 +411,7 @@ include(uniffi_cpp) add_library(livekit SHARED src/audio_frame.cpp src/audio_processing_module.cpp + src/capture_source.cpp src/audio_source.cpp src/audio_stream.cpp src/data_track_frame.cpp diff --git a/CMakePresets.json b/CMakePresets.json index eaa05455..9ec524a7 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -214,7 +214,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -226,7 +227,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -238,7 +240,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -250,7 +253,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -262,7 +266,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -274,7 +279,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -286,7 +292,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -298,7 +305,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -310,7 +318,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -322,7 +331,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -334,7 +344,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } }, { @@ -346,7 +357,8 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_TESTS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "ON" } } ], diff --git a/build.h.in b/build.h.in index 68d8e814..18395f4d 100644 --- a/build.h.in +++ b/build.h.in @@ -6,16 +6,23 @@ // Version injected by CMake (CI should set it). Defaults to 0.0.0 for dev builds. #define LIVEKIT_BUILD_VERSION "@LIVEKIT_VERSION@" -#define LIVEKIT_BUILD_FLAVOR "cpp" +#define LIVEKIT_BUILD_FLAVOR "cpp" + +namespace livekit { + +/// @brief Whether the Rust FFI was built with capture-source support. +inline constexpr bool kCaptureEnabled = "@LIVEKIT_CAPTURE_ENABLED@"[0] == '1'; + +} // namespace livekit #ifdef DEBUG -#define LIVEKIT_BUILD_SUFFIX "-debug" +#define LIVEKIT_BUILD_SUFFIX "-debug" #else -#define LIVEKIT_BUILD_SUFFIX "-release" +#define LIVEKIT_BUILD_SUFFIX "-release" #endif // Follow params are auto generated by CMakeLists.txt -#define LIVEKIT_BUILD_DATE "@BUILD_DATE@" +#define LIVEKIT_BUILD_DATE "@BUILD_DATE@" #define LIVEKIT_BUILD_COMMIT "@GIT_COMMIT@" -#define LIVEKIT_BUILD_VERSION_FULL LIVEKIT_BUILD_VERSION LIVEKIT_BUILD_SUFFIX \ No newline at end of file +#define LIVEKIT_BUILD_VERSION_FULL LIVEKIT_BUILD_VERSION LIVEKIT_BUILD_SUFFIX diff --git a/docs/README.md b/docs/README.md index d4f68c8b..ba3d656b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,8 @@ Additional documentation for the SDK. - [Building](building.md) — prerequisites, build scripts, CMake presets, vcpkg, integration into your CMake project, troubleshooting. +- [Built-in capture sources](capture-sources.md) — pattern and clock sources, + publishing, and capture lifecycle. - [Authentication](authentication.md) — generating tokens, token source types (initial connect only), and in-session token refresh. - [Logging](logging.md) — compile-time vs runtime filtering, log levels, diff --git a/docs/building.md b/docs/building.md index fd4cbb83..6ce122a5 100644 --- a/docs/building.md +++ b/docs/building.md @@ -198,6 +198,7 @@ cmake --build build | Option | Default | Description | |--------|---------|-------------| | `LIVEKIT_BUILD_EXAMPLES` | OFF | Build example applications | +| `LIVEKIT_ENABLE_CAPTURE` | OFF | Build Rust pattern and clock capture sources; the repository's build presets enable this option | | `LIVEKIT_USE_SYSTEM_PROTOBUF` | OFF | Use system Protobuf instead of vcpkg's | | `LIVEKIT_LOG_LEVEL` | `TRACE` | Compile-time log threshold (see [logging.md](logging.md)) | | `LIVEKIT_VERSION` | repo-derived | SDK version string baked into the binary | diff --git a/docs/capture-sources.md b/docs/capture-sources.md new file mode 100644 index 00000000..78655910 --- /dev/null +++ b/docs/capture-sources.md @@ -0,0 +1,42 @@ +# Built-in capture sources + +`CaptureSource` connects a Rust video producer to a regular C++ `VideoSource`. +Pattern and clock sources render offscreen on the GPU. Their frames stay in +Rust until they enter the WebRTC video source, so C++ does not send one FFI +request per frame. + +The pattern source offers `Pattern::Gradient` and `Pattern::Logo`. The clock +source displays the local time with millisecond precision. + +The repository build presets enable `LIVEKIT_ENABLE_CAPTURE`. A custom CMake +configuration must set `-DLIVEKIT_ENABLE_CAPTURE=ON`; otherwise source creation +fails. A GPU adapter must be available at runtime. + +Create a source, publish its video source as a local track, and start capture +after publication: + +```cpp +#include + +auto capture = livekit::CaptureSource::create( + livekit::PatternVideoSourceConfig{{1280, 720}, 30, livekit::Pattern::Gradient}).get(); +// For a local wall clock, use: +// livekit::CaptureSource::create(livekit::ClockVideoSourceConfig{{1280, 720}, 30}).get(); + +auto track = livekit::LocalVideoTrack::createLocalVideoTrack("capture", capture->videoSource()); +auto participant = room.localParticipant().lock(); +participant->publishTrack(track, capture->publishOptions()); + +capture->setOnFinishedCallback([](const livekit::CaptureResult& result) { + // Runs on the FFI callback thread. Hand work to another thread if it blocks. +}); +if (!capture->start()) { + // Handle the start failure. +} +``` + +The example assumes `room` is connected and the SDK is initialized. Keep +`capture` alive while its track is published. Call `stop()` to finish capture; +the callback reports the exit reason and number of captured frames. Destroying +the source also stops capture. The clock displays the local time of the machine +running the Rust FFI. diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h new file mode 100644 index 00000000..350b782b --- /dev/null +++ b/include/livekit/capture_source.h @@ -0,0 +1,232 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "livekit/build.h" +#include "livekit/ffi_handle.h" +#include "livekit/room_event_types.h" +#include "livekit/video_source.h" +#include "livekit/visibility.h" + +namespace livekit { + +namespace proto { +class NewCaptureSourceRequest; +class OwnedCaptureSource; +} // namespace proto + +/// @brief Error raised when capture source creation or control fails. +class LIVEKIT_API CaptureSourceError : public std::runtime_error { +public: + /// @brief Create a capture source error. + /// + /// @param message Human-readable error message. + explicit CaptureSourceError(const std::string& message) : std::runtime_error(message) {} +}; + +/// @brief Kind of media a capture source produces. +enum class CaptureSourceKind { + /// Pixel frames, published through the WebRTC encoder. + Pixel = 0, + /// Pre-encoded access units, published as passthrough. + Encoded = 1, +}; + +/// @brief Video resolution in pixels. +struct CaptureResolution { + int width = 0; + int height = 0; +}; + +/// @brief Test pattern rendered by the built-in pattern source. +enum class Pattern { + /// Animated color gradient. + Gradient = 0, + /// Bouncing LiveKit logo. + Logo = 1, +}; + +/// @brief Configuration for the built-in test source rendering a @ref Pattern. +struct PatternVideoSourceConfig { + /// Output resolution; both components must be non-zero. + CaptureResolution resolution; + + /// Output frame rate in frames per second; must be non-zero. + std::uint32_t framerate_fps = 0; + + /// Pattern to render. + Pattern pattern = Pattern::Gradient; +}; + +/// @brief Configuration for the built-in clock source, showing local time. +struct ClockVideoSourceConfig { + /// Output resolution; both components must be non-zero. + CaptureResolution resolution; + + /// Output frame rate in frames per second; must be non-zero. + std::uint32_t framerate_fps = 0; +}; + +/// @brief Why a capture ended without error. +enum class CaptureExit { + /// Stopped via @ref CaptureSource::stop (or source destruction). + Stopped = 0, + /// The producer reached the end of its stream. + EndOfStream = 1, +}; + +/// @brief Terminal result of a started capture. +struct CaptureResult { + /// Error message when the capture failed; empty on success. + std::optional error; + + /// Number of frames or access units captured. + std::uint64_t frames_captured = 0; + + /// Why the capture ended; meaningful only when @ref error is empty. + CaptureExit exit = CaptureExit::Stopped; +}; + +/// @brief A server-side capture source (livekit-capture) that produces video +/// without per-frame FFI traffic. +/// +/// The source owns its producer and the pump that feeds an RTC video source. +/// Publish it like any other source: create +/// a track from @ref CaptureSource::videoSource(), merge application options +/// through @ref CaptureSource::publishOptions(), then call @ref CaptureSource::start(). +/// +/// Requires the FFI library to be built with the `capture-pattern` and +/// `capture-clock` features (`LIVEKIT_ENABLE_CAPTURE`); otherwise creation fails. +/// +/// @note Keep this object alive while the track is published. Destroying it +/// stops a running capture. +class LIVEKIT_API CaptureSource { +public: + /// @brief Terminal notification for a started capture, invoked exactly once on + /// the FFI event thread (like room delegate callbacks). + using FinishedCallback = std::function; + + /// @brief Create the built-in test pattern capture source. + /// + /// @param config Pattern source configuration. + /// @return A future that resolves to the created capture source. + /// @throws CaptureSourceError When awaiting the future if creation fails. + static std::future> create(PatternVideoSourceConfig config); + + /// @brief Create the built-in clock capture source. + /// + /// The clock shows the local time of the machine running the Rust FFI. + /// + /// @param config Clock source configuration. + /// @return A future that resolves to the created capture source. + /// @throws CaptureSourceError When awaiting the future if creation fails. + static std::future> create(ClockVideoSourceConfig config); + + /// @brief Destroy the source, stopping an active capture. + ~CaptureSource(); + + CaptureSource(const CaptureSource&) = delete; + CaptureSource& operator=(const CaptureSource&) = delete; + CaptureSource(CaptureSource&&) = delete; + CaptureSource& operator=(CaptureSource&&) = delete; + + /// @brief Return the kind of media this source produces. + /// @return The source kind. + CaptureSourceKind kind() const noexcept { return kind_; } + + /// @brief Return the stream width. + /// @return Width in pixels. + int width() const noexcept { return width_; } + + /// @brief Return the stream height. + /// @return Height in pixels. + int height() const noexcept { return height_; } + + /// @brief Return the codec produced by an encoded source. + /// @return The codec, or no value for a pixel source. + std::optional codec() const noexcept { return codec_; } + + /// @brief Return the RTC video source fed by this capture source. + /// + /// Use it with + /// LocalVideoTrack::createLocalVideoTrack(). + /// @return The source to use when creating a local video track. + std::shared_ptr videoSource() const noexcept { return video_source_; } + + /// @brief Return publish options for this track, applying application options. + /// + /// Recommended fields fill unset application fields. For encoded sources, + /// codec, encoder backend, and simulcast are required for publication and + /// override the application values. Other fields are taken from @p options. + /// @param options Application publish options. + /// @return Merged options for publishing the track. + TrackPublishOptions publishOptions(TrackPublishOptions options = {}) const; + + /// @brief Set the terminal notification before calling @ref start(). + /// @param callback Function called on the FFI event thread when capture ends. + void setOnFinishedCallback(FinishedCallback callback); + + /// @brief Start pumping frames into the RTC video source. + /// + /// @return @c true if the start request succeeds; otherwise @c false. + bool start(); + + /// @brief Signal a running capture to stop after the frame in flight. The + /// finished callback fires shortly after. Stopping an already-finished + /// capture is a no-op. + /// + /// @return @c true if the stop request succeeds; otherwise @c false. + bool stop(); + +private: + CaptureSource() = default; + + /// @brief Shared creation path: sends the request and maps the callback payload + /// onto a wrapper instance. + /// @param request Source creation request. + /// @return A future containing the new source. + static std::future> createFromRequest(proto::NewCaptureSourceRequest request); + + /// @brief Build the wrapper from the callback payload, adopting its handles. + /// @param owned Rust-owned capture source descriptor. + /// @return The new C++ wrapper. + static std::shared_ptr fromOwned(const proto::OwnedCaptureSource& owned); + + FfiHandle handle_; + CaptureSourceKind kind_ = CaptureSourceKind::Pixel; + int width_ = 0; + int height_ = 0; + std::optional codec_; + std::shared_ptr video_source_; + /// Fields set here are dictated by the source and win in publishOptions(). + TrackPublishOptions source_publish_options_; + + std::mutex callback_mutex_; + std::shared_ptr on_finished_; + int listener_id_ = 0; +}; + +} // namespace livekit diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index 9b22b7e9..a02e8172 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -21,6 +21,7 @@ #include "livekit/audio_source.h" #include "livekit/audio_stream.h" #include "livekit/build.h" +#include "livekit/capture_source.h" #include "livekit/e2ee.h" #include "livekit/encoded_video_source.h" #include "livekit/local_audio_track.h" diff --git a/include/livekit/video_source.h b/include/livekit/video_source.h index 0c472d8a..c747d7a8 100644 --- a/include/livekit/video_source.h +++ b/include/livekit/video_source.h @@ -18,6 +18,7 @@ #include #include +#include #include #include "livekit/ffi_handle.h" @@ -97,10 +98,15 @@ class LIVEKIT_API VideoSource { private: friend class EncodedVideoSource; + friend class CaptureSource; enum class SourceType { Native, Encoded }; VideoSource(int width, int height, SourceType source_type); + /// Adopt an existing FFI video source handle (used by CaptureSource). + VideoSource(FfiHandle&& handle, int width, int height) noexcept + : handle_(std::move(handle)), width_(width), height_(height) {} + FfiHandle handle_; // owned FFI handle int width_{0}; int height_{0}; diff --git a/scripts/generate-docs.sh b/scripts/generate-docs.sh index 60ba5588..b85d0039 100755 --- a/scripts/generate-docs.sh +++ b/scripts/generate-docs.sh @@ -154,7 +154,7 @@ cp "$repo_root/docs/doxygen/Doxyfile" "$doxyfile_tmp" { echo "" echo "# Local override generated by scripts/generate-docs.sh" - echo "INPUT = include/livekit ${mainpage_rel} docs/README.md docs/building.md docs/authentication.md docs/logging.md docs/tracing.md docs/testing.md docs/tools.md" + echo "INPUT = include/livekit ${mainpage_rel} docs/README.md docs/building.md docs/capture-sources.md docs/authentication.md docs/logging.md docs/tracing.md docs/testing.md docs/tools.md" echo "USE_MDFILE_AS_MAINPAGE = ${mainpage_rel}" } >>"$doxyfile_tmp" diff --git a/src/capture_source.cpp b/src/capture_source.cpp new file mode 100644 index 00000000..e2b04b74 --- /dev/null +++ b/src/capture_source.cpp @@ -0,0 +1,276 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "livekit/capture_source.h" + +#include +#include +#include + +#include "capture.pb.h" +#include "ffi.pb.h" +#include "ffi_client.h" +#include "lk_log.h" +#include "room_proto_converter.h" + +namespace livekit { + +namespace { + +/// A failed future carrying @p error as a @ref CaptureSourceError. +/// +/// Lets the factories report a synchronous send failure through the future +/// they return, keeping one error channel for every failure mode. +template +std::future readyCaptureError(const std::exception& error) { + std::promise promise; + promise.set_exception(std::make_exception_ptr(CaptureSourceError(error.what()))); + return promise.get_future(); +} + +/// Rejects a resolution that cannot be represented on the wire. +/// +/// @ref CaptureResolution is signed while the protocol carries unsigned +/// dimensions, so a negative value would otherwise be reinterpreted as a +/// dimension near 2^32. +void validateResolution(const CaptureResolution& resolution, const char* field) { + if (resolution.width <= 0 || resolution.height <= 0) { + throw CaptureSourceError(std::string(field) + " must be positive, got " + std::to_string(resolution.width) + "x" + + std::to_string(resolution.height)); + } +} + +proto::Pattern toProto(Pattern pattern) { + switch (pattern) { + case Pattern::Gradient: + return proto::Pattern::PATTERN_GRADIENT; + case Pattern::Logo: + return proto::Pattern::PATTERN_LOGO; + } + throw CaptureSourceError("unsupported capture pattern"); +} + +CaptureResult resultFromEvent(const proto::CaptureSourceEvent& event) { + CaptureResult result; + if (event.has_error()) { + result.error = event.error().error(); + return result; + } + if (event.has_finished()) { + result.frames_captured = event.finished().frames_captured(); + result.exit = event.finished().exit() == proto::CaptureExit::CAPTURE_EXIT_END_OF_STREAM ? CaptureExit::EndOfStream + : CaptureExit::Stopped; + } else { + result.error = "capture finished event has no result"; + } + return result; +} + +} // namespace + +std::future> CaptureSource::create(PatternVideoSourceConfig config) { + try { + validateResolution(config.resolution, "PatternVideoSourceConfig::resolution"); + if (config.framerate_fps == 0) { + throw CaptureSourceError("PatternVideoSourceConfig::framerate_fps must be positive"); + } + } catch (const std::exception& error) { + return readyCaptureError>(error); + } + + proto::NewCaptureSourceRequest request; + auto* pattern = request.mutable_pattern(); + pattern->mutable_resolution()->set_width(config.resolution.width); + pattern->mutable_resolution()->set_height(config.resolution.height); + pattern->set_framerate_fps(config.framerate_fps); + try { + pattern->set_pattern(toProto(config.pattern)); + } catch (const std::exception& error) { + return readyCaptureError>(error); + } + return createFromRequest(std::move(request)); +} + +std::future> CaptureSource::create(ClockVideoSourceConfig config) { + try { + validateResolution(config.resolution, "ClockVideoSourceConfig::resolution"); + if (config.framerate_fps == 0) { + throw CaptureSourceError("ClockVideoSourceConfig::framerate_fps must be positive"); + } + } catch (const std::exception& error) { + return readyCaptureError>(error); + } + + proto::NewCaptureSourceRequest request; + auto* clock = request.mutable_clock(); + clock->mutable_resolution()->set_width(config.resolution.width); + clock->mutable_resolution()->set_height(config.resolution.height); + clock->set_framerate_fps(config.framerate_fps); + return createFromRequest(std::move(request)); +} + +std::future> CaptureSource::createFromRequest(proto::NewCaptureSourceRequest request) { + // A synchronous send failure is delivered through the returned future. + std::future owned; + try { + owned = FfiClient::instance().newCaptureSourceAsync(std::move(request)); + } catch (const std::exception& e) { + return readyCaptureError>(e); + } + + // Map the FFI payload onto a wrapper once the callback resolves. A helper + // thread keeps the returned future's wait semantics standard. + return std::async(std::launch::async, [owned = std::move(owned)]() mutable { + try { + return fromOwned(owned.get()); + } catch (const CaptureSourceError&) { + throw; + } catch (const std::exception& e) { + throw CaptureSourceError(e.what()); + } + }); +} + +std::shared_ptr CaptureSource::fromOwned(const proto::OwnedCaptureSource& owned) { + const proto::CaptureSourceInfo& info = owned.info(); + FfiHandle capture_handle(static_cast(owned.handle().id())); + FfiHandle video_handle(static_cast(info.video_source().handle().id())); + if (!capture_handle || !video_handle) { + throw CaptureSourceError("capture source returned an invalid handle"); + } + if (info.resolution().width() == 0 || info.resolution().height() == 0 || + info.resolution().width() > static_cast(std::numeric_limits::max()) || + info.resolution().height() > static_cast(std::numeric_limits::max())) { + throw CaptureSourceError("capture source returned an invalid resolution"); + } + if (info.kind() != proto::CaptureSourceKind::CAPTURE_SOURCE_PIXEL && + info.kind() != proto::CaptureSourceKind::CAPTURE_SOURCE_ENCODED) { + throw CaptureSourceError("capture source returned an invalid kind"); + } + + std::shared_ptr source(new CaptureSource()); + source->handle_ = std::move(capture_handle); + source->kind_ = info.kind() == proto::CaptureSourceKind::CAPTURE_SOURCE_ENCODED ? CaptureSourceKind::Encoded + : CaptureSourceKind::Pixel; + source->width_ = static_cast(info.resolution().width()); + source->height_ = static_cast(info.resolution().height()); + if (info.has_codec()) { + source->codec_ = static_cast(info.codec()); + } + source->source_publish_options_ = fromProto(info.recommended_publish_options()); + source->video_source_ = + std::shared_ptr(new VideoSource(std::move(video_handle), source->width_, source->height_)); + + // The terminal CaptureSourceEvent is unsolicited (not async-id + // correlated); observe it with a listener filtered by our handle. The + // destructor removes the listener before releasing the handle, so no + // callback can outlive the wrapper. + CaptureSource* raw = source.get(); + const std::uint64_t capture_handle_id = source->handle_.get(); + source->listener_id_ = FfiClient::instance().addListener([raw, capture_handle_id](const proto::FfiEvent& event) { + if (!event.has_capture_source_event() || event.capture_source_event().capture_handle() != capture_handle_id) { + return; + } + const CaptureResult result = resultFromEvent(event.capture_source_event()); + std::shared_ptr callback; + { + const std::scoped_lock lock(raw->callback_mutex_); + callback = raw->on_finished_; + } + if (callback) { + (*callback)(result); + } + }); + + return source; +} + +TrackPublishOptions CaptureSource::publishOptions(TrackPublishOptions options) const { + // The Rust FFI currently recommends codec, encoder backend, and simulcast. + // Pixel sources may use application choices; encoded sources require their + // passthrough settings to override them. + if (kind_ == CaptureSourceKind::Encoded || !options.video_codec) { + options.video_codec = source_publish_options_.video_codec; + } + if (kind_ == CaptureSourceKind::Encoded || !options.video_encoder) { + options.video_encoder = source_publish_options_.video_encoder; + } + if (kind_ == CaptureSourceKind::Encoded || !options.simulcast) { + options.simulcast = source_publish_options_.simulcast; + } + return options; +} + +CaptureSource::~CaptureSource() { + if (listener_id_ != 0) { + FfiClient::instance().removeListener(listener_id_); + listener_id_ = 0; + } + // Dropping the handle stops a running capture. +} + +void CaptureSource::setOnFinishedCallback(FinishedCallback callback) { + std::shared_ptr next; + if (callback) { + next = std::make_shared(std::move(callback)); + } + const std::scoped_lock lock(callback_mutex_); + on_finished_ = std::move(next); +} + +bool CaptureSource::start() { + try { + proto::FfiRequest req; + req.mutable_start_capture()->set_capture_handle(handle_.get()); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_start_capture()) { + LK_LOG_WARN("Capture start request returned no start_capture response"); + return false; + } + if (resp.start_capture().has_error()) { + LK_LOG_WARN("Capture start request failed: {}", resp.start_capture().error()); + return false; + } + return true; + } catch (const std::exception& e) { + LK_LOG_WARN("Capture start request failed: {}", e.what()); + return false; + } +} + +bool CaptureSource::stop() { + try { + proto::FfiRequest req; + req.mutable_stop_capture()->set_capture_handle(handle_.get()); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_stop_capture()) { + LK_LOG_WARN("Capture stop request returned no stop_capture response"); + return false; + } + if (resp.stop_capture().has_error()) { + LK_LOG_WARN("Capture stop request failed: {}", resp.stop_capture().error()); + return false; + } + return true; + } catch (const std::exception& e) { + LK_LOG_WARN("Capture stop request failed: {}", e.what()); + return false; + } +} + +} // namespace livekit diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 6c924560..6138b390 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -109,6 +109,8 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { return event.chat_message().async_id(); case E::kPerformRpc: return event.perform_rpc().async_id(); + case E::kNewCaptureSource: + return event.new_capture_source().async_id(); // low-level data stream callbacks case E::kSendStreamHeader: @@ -162,6 +164,9 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { case E::kTextStreamReaderEvent: case E::kDataTrackStreamEvent: case E::kRpcMethodInvocation: + // Delivered to the owning CaptureSource's listener, not to a pending + // request; see CaptureSource's capture-event handler. + case E::kCaptureSourceEvent: case E::kLogs: case E::kPanic: case E::MESSAGE_NOT_SET: @@ -710,6 +715,49 @@ std::future FfiClient::publishTrackAsync(std::uint return fut; } +std::future FfiClient::newCaptureSourceAsync(proto::NewCaptureSourceRequest request) { + // Generate client-side async_id first + const AsyncId async_id = generateAsyncId(); + + // Register the async handler BEFORE sending the request + auto fut = registerAsync( + async_id, + [async_id](const proto::FfiEvent& event) { + return event.has_new_capture_source() && event.new_capture_source().async_id() == async_id; + }, + [](const proto::FfiEvent& event, std::promise& pr) { + const auto& cb = event.new_capture_source(); + + // Oneof message { string error = 2; OwnedCaptureSource source = 3; } + if (cb.has_error() && !cb.error().empty()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error()))); + return; + } + if (!cb.has_source()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error("NewCaptureSourceCallback missing source"))); + return; + } + + pr.set_value(cb.source()); + }); + + request.set_request_async_id(async_id); + proto::FfiRequest req; + req.mutable_new_capture_source()->CopyFrom(request); + + try { + const proto::FfiResponse resp = sendRequest(req); + if (!resp.has_new_capture_source()) { + logAndThrow("FfiResponse missing new_capture_source"); + } + } catch (...) { + cancelPendingByAsyncId(async_id); + throw; + } + + return fut; +} + std::future FfiClient::unpublishTrackAsync(std::uint64_t local_participant_handle, const std::string& track_sid, bool stop_on_unpublish) { // Generate client-side async_id first diff --git a/src/ffi_client.h b/src/ffi_client.h index c0835027..8ea097bc 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -29,6 +29,7 @@ #include #include +#include "capture.pb.h" #include "data_track.pb.h" #include "livekit/data_track_error.h" #include "livekit/data_track_options.h" @@ -118,6 +119,9 @@ class LIVEKIT_INTERNAL_API FfiClient { std::future publishSipDtmfAsync(std::uint64_t local_participant_handle, std::uint32_t code, const std::string& digit, const std::vector& destination_identities); + + // Capture APIs + std::future newCaptureSourceAsync(proto::NewCaptureSourceRequest request); std::future setLocalMetadataAsync(std::uint64_t local_participant_handle, const std::string& metadata); std::future captureAudioFrameAsync(std::uint64_t source_handle, const proto::AudioFrameBufferInfo& buffer); std::future performRpcAsync(std::uint64_t local_participant_handle, diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp new file mode 100644 index 00000000..ba022c44 --- /dev/null +++ b/src/tests/integration/test_capture_source.cpp @@ -0,0 +1,176 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// End-to-end tests for the built-in pattern and clock capture sources. +/// The Rust pump produces frames without C++ per-frame FFI calls. + +#include +#include +#include +#include +#include +#include +#include + +#include "../common/test_common.h" +#include "livekit/capture_source.h" + +namespace livekit::test { + +using namespace std::chrono_literals; + +namespace { + +constexpr int kMinFramesReceived = 5; +constexpr int kCaptureWidth = 1280; +constexpr int kCaptureHeight = 720; +constexpr std::uint32_t kCaptureFramerateFps = 30; +enum class SourceVariant { Gradient, Logo, Clock }; + +struct CaptureTestState { + std::mutex mutex; + std::condition_variable cv; + int frames_received = 0; + std::optional finished; +}; + +/// Skips the calling test when the FFI library was built without the capture +/// feature. A feature-less FFI reports only a generic invalid handle, so this +/// has to be decided at compile time rather than sniffed from an error string. +#define SKIP_WITHOUT_CAPTURE_FEATURE() \ + do { \ + if constexpr (!kCaptureEnabled) { \ + GTEST_SKIP() << "livekit-ffi built without the 'capture' feature; configure with -DLIVEKIT_ENABLE_CAPTURE=ON"; \ + } \ + } while (false) + +std::shared_ptr createCapture(SourceVariant variant) { + if (variant == SourceVariant::Clock) { + return CaptureSource::create(ClockVideoSourceConfig{{kCaptureWidth, kCaptureHeight}, kCaptureFramerateFps}).get(); + } + const Pattern pattern = variant == SourceVariant::Logo ? Pattern::Logo : Pattern::Gradient; + return CaptureSource::create(PatternVideoSourceConfig{{kCaptureWidth, kCaptureHeight}, kCaptureFramerateFps, pattern}) + .get(); +} + +} // namespace + +class CaptureSourceServerTest : public LiveKitTestBase, public ::testing::WithParamInterface {}; + +TEST_P(CaptureSourceServerTest, PublishesFramesEndToEnd) { + SKIP_WITHOUT_CAPTURE_FEATURE(); + failIfNotConfigured(); + + std::shared_ptr capture; + try { + capture = createCapture(GetParam()); + } catch (const CaptureSourceError& error) { + if (std::string(error.what()).find("no compatible GPU adapter") != std::string::npos) { + GTEST_SKIP() << "GPU capture unavailable: " << error.what(); + } + throw; + } + ASSERT_NE(capture, nullptr); + + // Each source reports back the resolution it was configured with. + ASSERT_EQ(capture->kind(), CaptureSourceKind::Pixel); + ASSERT_EQ(capture->width(), kCaptureWidth); + ASSERT_EQ(capture->height(), kCaptureHeight); + ASSERT_FALSE(capture->codec().has_value()); + ASSERT_NE(capture->videoSource(), nullptr); + + TrackPublishOptions requested; + requested.video_codec = VideoCodec::H264; + requested.simulcast = false; + const TrackPublishOptions merged = capture->publishOptions(requested); + EXPECT_EQ(merged.video_codec, requested.video_codec); + EXPECT_EQ(merged.simulcast, requested.simulcast); + + Room sender_room; + Room receiver_room; + const RoomOptions options; + + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + ASSERT_FALSE(sender_room.localParticipant().expired()); + ASSERT_FALSE(receiver_room.localParticipant().expired()); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_FALSE(sender_identity.empty()); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, 10s)); + + // Count frames arriving at the receiver: proof of media flowing through + // the SFU without the test pushing a single frame. + auto state = std::make_shared(); + + const std::string track_name = GetParam() == SourceVariant::Clock ? "clock-capture-track" : "pattern-capture-track"; + receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, [state](const VideoFrameEvent& /*event*/) { + const std::scoped_lock lock(state->mutex); + if (++state->frames_received >= kMinFramesReceived) { + state->cv.notify_all(); + } + }); + + // Publish a track backed by the capture source's RTC video source, + // merging application options over the source-derived ones. + auto track = LocalVideoTrack::createLocalVideoTrack(track_name, capture->videoSource()); + + // Application options are merged in; source-dictated fields win. + TrackPublishOptions app_options; + app_options.source = TrackSource::SOURCE_CAMERA; + ASSERT_NO_THROW(lockLocalParticipant(sender_room)->publishTrack(track, capture->publishOptions(app_options))); + + // Observe the capture's terminal event. + capture->setOnFinishedCallback([state](const CaptureResult& result) { + const std::scoped_lock lock(state->mutex); + state->finished = result; + state->cv.notify_all(); + }); + + ASSERT_TRUE(capture->start()); + EXPECT_FALSE(capture->start()) << "double start must be rejected"; + + { + std::unique_lock lock(state->mutex); + const bool got_frames = + state->cv.wait_for(lock, 30s, [state] { return state->frames_received >= kMinFramesReceived; }); + ASSERT_TRUE(got_frames) << "Timed out waiting for capture frames; received " << state->frames_received; + } + + // Stop is a signal; the terminal callback delivers the stats. + ASSERT_TRUE(capture->stop()); + CaptureResult result; + { + std::unique_lock lock(state->mutex); + const bool got_finished = state->cv.wait_for(lock, 10s, [state] { return state->finished.has_value(); }); + ASSERT_TRUE(got_finished) << "Timed out waiting for the capture finished callback"; + result = state->finished.value_or(CaptureResult{}); + } + + ASSERT_FALSE(result.error.has_value()) << result.error.value_or(""); + EXPECT_EQ(result.exit, CaptureExit::Stopped); + EXPECT_GT(result.frames_captured, 0u); + + receiver_room.clearOnVideoFrameCallback(sender_identity, track_name); + if (track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); + } +} + +INSTANTIATE_TEST_SUITE_P(BuiltInSources, CaptureSourceServerTest, + ::testing::Values(SourceVariant::Gradient, SourceVariant::Logo, SourceVariant::Clock)); + +} // namespace livekit::test diff --git a/src/tests/stress/test_capture_source_stress.cpp b/src/tests/stress/test_capture_source_stress.cpp new file mode 100644 index 00000000..5b8858b4 --- /dev/null +++ b/src/tests/stress/test_capture_source_stress.cpp @@ -0,0 +1,90 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace livekit::test { +namespace { + +struct CaptureCompletion { + std::mutex mutex; + std::condition_variable cv; + std::optional result; +}; + +class CaptureSourceStressTest : public ::testing::Test { +protected: + void SetUp() override { initialize(LogLevel::Warn); } + void TearDown() override { shutdown(); } +}; + +TEST_F(CaptureSourceStressTest, ReportsRenderedFramesPerSecond) { + using namespace std::chrono_literals; + if constexpr (!kCaptureEnabled) { + GTEST_SKIP() << "capture sources are disabled in this build"; + } + + std::shared_ptr source; + try { + source = CaptureSource::create(PatternVideoSourceConfig{{1280, 720}, 30, Pattern::Gradient}).get(); + } catch (const CaptureSourceError& error) { + if (std::string(error.what()).find("no compatible GPU adapter") != std::string::npos) { + GTEST_SKIP() << "GPU capture unavailable: " << error.what(); + } + throw; + } + + auto completion = std::make_shared(); + source->setOnFinishedCallback([completion](const CaptureResult& result) { + { + const std::scoped_lock lock(completion->mutex); + completion->result = result; + } + completion->cv.notify_all(); + }); + + ASSERT_TRUE(source->start()); + const auto started_at = std::chrono::steady_clock::now(); + std::this_thread::sleep_for(2s); // Fixed measurement window for this benchmark. + const auto stopped_at = std::chrono::steady_clock::now(); + ASSERT_TRUE(source->stop()); + + std::unique_lock lock(completion->mutex); + ASSERT_TRUE(completion->cv.wait_for(lock, 10s, [&completion] { return completion->result.has_value(); })); + const CaptureResult result = completion->result.value_or(CaptureResult{}); + lock.unlock(); + + ASSERT_FALSE(result.error.has_value()); + EXPECT_EQ(result.exit, CaptureExit::Stopped); + EXPECT_GT(result.frames_captured, 0u); + const double elapsed_seconds = std::chrono::duration(stopped_at - started_at).count(); + std::cout << "Pattern capture at 1280x720: " << static_cast(result.frames_captured) / elapsed_seconds + << " frames/s (" << result.frames_captured << " frames)\n"; +} + +} // namespace +} // namespace livekit::test diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp new file mode 100644 index 00000000..54c752b0 --- /dev/null +++ b/src/tests/unit/test_capture_source.cpp @@ -0,0 +1,44 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +namespace livekit::test { +namespace { + +TEST(CaptureSourceConfigTest, RejectsInvalidPatternConfigBeforeFfiRequest) { + PatternVideoSourceConfig config{{640, 480}, 30, Pattern::Gradient}; + config.resolution.width = -1; + EXPECT_THROW(CaptureSource::create(config).get(), CaptureSourceError); + + config.resolution.width = 640; + config.framerate_fps = 0; + EXPECT_THROW(CaptureSource::create(config).get(), CaptureSourceError); +} + +TEST(CaptureSourceConfigTest, RejectsInvalidClockConfigBeforeFfiRequest) { + ClockVideoSourceConfig config{{640, 480}, 30}; + config.resolution.height = 0; + EXPECT_THROW(CaptureSource::create(config).get(), CaptureSourceError); + + config.resolution.height = 480; + config.framerate_fps = 0; + EXPECT_THROW(CaptureSource::create(config).get(), CaptureSourceError); +} + +} // namespace +} // namespace livekit::test From c1a01d581af137c49dd7e0f0e1e24d6f4f58828b Mon Sep 17 00:00:00 2001 From: stavied <40528896+stephen-derosa@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:17:29 -0600 Subject: [PATCH 2/3] Update comment for output resolution requirement Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- include/livekit/capture_source.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index 350b782b..5652b2de 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -71,7 +71,7 @@ enum class Pattern { /// @brief Configuration for the built-in test source rendering a @ref Pattern. struct PatternVideoSourceConfig { - /// Output resolution; both components must be non-zero. + /// Output resolution; width and height must both be positive. CaptureResolution resolution; /// Output frame rate in frames per second; must be non-zero. From 711f3ba6acaba43ec305d9d0c1ad6ecefe90c45a Mon Sep 17 00:00:00 2001 From: stavied <40528896+stephen-derosa@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:27:14 -0600 Subject: [PATCH 3/3] Update capture sources documentation for build presets Clarify which build presets enable LIVEKIT_ENABLE_CAPTURE and the requirements for source creation. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/capture-sources.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/capture-sources.md b/docs/capture-sources.md index 78655910..980f0574 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -8,9 +8,9 @@ request per frame. The pattern source offers `Pattern::Gradient` and `Pattern::Logo`. The clock source displays the local time with millisecond precision. -The repository build presets enable `LIVEKIT_ENABLE_CAPTURE`. A custom CMake -configuration must set `-DLIVEKIT_ENABLE_CAPTURE=ON`; otherwise source creation -fails. A GPU adapter must be available at runtime. +Only the repository's `*-tests` and `*-all` build presets enable +`LIVEKIT_ENABLE_CAPTURE`. Other presets and custom CMake configurations must set +`-DLIVEKIT_ENABLE_CAPTURE=ON`; otherwise source creation fails. A GPU adapter must be available at runtime. Create a source, publish its video source as a local track, and start capture after publication: