From a7207c5a35d56cc66bd403efb67e19aeaef01387 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:41:23 -0700 Subject: [PATCH 01/29] LiveKit Capture integrated through FFI --- .github/workflows/tests.yml | 47 +- CMakeLists.txt | 18 + CMakePresets.json | 36 +- build.h.in | 3 +- client-sdk-rust | 2 +- include/livekit/capture_source.h | 485 ++++++++++++++++++ include/livekit/livekit.h | 1 + include/livekit/video_source.h | 5 + src/capture_source.cpp | 479 +++++++++++++++++ src/capture_source_internal.h | 35 ++ src/ffi_client.cpp | 92 ++++ src/ffi_client.h | 5 + src/tests/integration/test_capture_source.cpp | 256 +++++++++ src/tests/unit/test_capture_source.cpp | 59 +++ 14 files changed, 1507 insertions(+), 16 deletions(-) create mode 100644 include/livekit/capture_source.h create mode 100644 src/capture_source.cpp create mode 100644 src/capture_source_internal.h create mode 100644 src/tests/integration/test_capture_source.cpp create mode 100644 src/tests/unit/test_capture_source.cpp diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index efeaf448..b2395c65 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -132,6 +132,11 @@ env: VCPKG_DEFAULT_TRIPLET: x64-windows-static-md VCPKG_DEFAULT_HOST_TRIPLET: x64-windows-static-md VCPKG_TARGET_TRIPLET: x64-windows-static-md + # Windows uses an explicit, checksum-pinned GStreamer SDK. Linux and macOS + # intentionally use their supported platform-package versions; `1.0` in + # Ubuntu package names denotes the GStreamer ABI, not the installed release. + GSTREAMER_WINDOWS_VERSION: "1.28.5" + GSTREAMER_WINDOWS_SHA256: "51ee5eaec33008e8409d8cf6f6884457f22aa3bd515f8856f993a3eaab903530" jobs: test: @@ -209,6 +214,34 @@ jobs: with: vcpkgGitCommitId: ${{ env.VCPKG_GIT_COMMIT }} + - name: Install GStreamer (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $installer = Join-Path $env:RUNNER_TEMP "gstreamer-installer.exe" + $installDir = Join-Path $env:RUNNER_TEMP "gstreamer" + $uri = "https://gstreamer.freedesktop.org/data/pkg/windows/$env:GSTREAMER_WINDOWS_VERSION/msvc/gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_WINDOWS_VERSION.exe" + + Invoke-WebRequest -Uri $uri -OutFile $installer + $actualHash = (Get-FileHash -Algorithm SHA256 $installer).Hash.ToLowerInvariant() + if ($actualHash -ne $env:GSTREAMER_WINDOWS_SHA256) { + throw "GStreamer installer SHA-256 mismatch: expected $env:GSTREAMER_WINDOWS_SHA256, got $actualHash" + } + + $process = Start-Process -FilePath $installer -ArgumentList @( + "/TYPE=devel", + "/DIR=$installDir", + "/CURRENTUSER", + "/VERYSILENT", + "/NORESTART" + ) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "GStreamer installer failed with exit code $($process.ExitCode)" + } + + "$installDir\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "PKG_CONFIG_PATH=$installDir\lib\pkgconfig" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # ---------- OS-specific deps ---------- - name: Install deps (Ubuntu) if: runner.os == 'Linux' @@ -227,6 +260,11 @@ jobs: libabsl-dev \ libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ + libunwind-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ gdb jq # The libwebrtc artifact ships a hermetic libc++ built from LLVM trunk. @@ -242,7 +280,7 @@ jobs: run: | set -eux brew update - brew install cmake ninja protobuf abseil + brew install cmake ninja protobuf abseil gstreamer # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -517,7 +555,12 @@ jobs: libprotobuf-dev protobuf-compiler \ libabsl-dev \ libcurl4-openssl-dev \ - libwayland-dev libdecor-0-dev + libwayland-dev libdecor-0-dev \ + libunwind-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav pip install --break-system-packages gcovr # The coverage build compiles the Rust FFI too, so it needs the same diff --git a/CMakeLists.txt b/CMakeLists.txt index 1fa34d15..08e8dc1d 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 the capture feature (livekit-capture sources; links system GStreamer)" OFF) # vcpkg is only used on Windows; Linux/macOS use system package managers if(WIN32) @@ -86,6 +87,7 @@ set(FFI_PROTO_FILES ${FFI_PROTO_DIR}/data_track.proto ${FFI_PROTO_DIR}/rpc.proto ${FFI_PROTO_DIR}/track_publication.proto + ${FFI_PROTO_DIR}/capture.proto ) set(PROTO_BINARY_DIR ${LIVEKIT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${PROTO_BINARY_DIR}) @@ -167,6 +169,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") + set(LIVEKIT_CAPTURE_ENABLED 1) +endif() + set(GIT_COMMIT "unknown") set(RUST_GIT_COMMIT "unknown") find_package(Git QUIET) @@ -262,6 +272,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 +393,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 +412,8 @@ include(uniffi_cpp) add_library(livekit SHARED src/audio_frame.cpp src/audio_processing_module.cpp + src/capture_source.cpp + src/capture_source_internal.h 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..5e736f4c 100644 --- a/build.h.in +++ b/build.h.in @@ -7,6 +7,7 @@ // 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_CAPTURE_ENABLED @LIVEKIT_CAPTURE_ENABLED@ #ifdef DEBUG #define LIVEKIT_BUILD_SUFFIX "-debug" @@ -18,4 +19,4 @@ #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/client-sdk-rust b/client-sdk-rust index a6f93a16..b402677a 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit a6f93a16fa71fe811d040de175e070e233d9b1fe +Subproject commit b402677a767ad68b0a061f6ee3191a363141a620 diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h new file mode 100644 index 00000000..459ada82 --- /dev/null +++ b/include/livekit/capture_source.h @@ -0,0 +1,485 @@ +/* + * 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 + +#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; +class CaptureDeviceList; +} // namespace proto + +/// Error raised when capture source creation or control fails. +class LIVEKIT_API CaptureSourceError : public std::runtime_error { +public: + /// Create a capture source error. + /// + /// @param message Human-readable error message. + explicit CaptureSourceError(const std::string& message) : std::runtime_error(message) {} +}; + +/// 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, +}; + +/// Bitrate unit expected by a GStreamer encoder property. +enum class GstreamerBitrateUnit { + /// Bits per second. + Bps = 0, + /// Kilobits per second. + Kbps = 1, +}; + +/// Video resolution in pixels. +struct CaptureResolution { + int width = 0; + int height = 0; +}; + +/// Binding from WebRTC rate-control targets to a GStreamer encoder property. +struct GstreamerRateControl { + /// Name of the encoder element in the pipeline (e.g. `lk_encoder`). + std::string element; + + /// Bitrate property to set on the element (e.g. `bitrate` for x264enc, + /// `target-bitrate` for vp8enc/vp9enc). + std::string property; + + /// Unit the property expects. + GstreamerBitrateUnit unit = GstreamerBitrateUnit::Bps; +}; + +/// Configuration for encoded ingest from a GStreamer pipeline. +struct GstreamerVideoSourceConfig { + /// GStreamer launch description for the encoded producer pipeline. + /// + /// @note Must contain `appsink name=lk_appsink`, or leave exactly one encoded + /// video source pad unlinked for the source to attach one to. + std::string pipeline; + + /// Codec expected from the pipeline; inferred from pipeline caps when + /// omitted. + std::optional codec; + + /// Encoded frame resolution. When omitted, it is discovered from the + /// pipeline's negotiated caps; when set, the pipeline output is verified + /// against it. + std::optional resolution; + + /// Forwards WebRTC rate-control targets to an encoder element's bitrate + /// property. Without this, the pipeline encodes at a fixed bitrate. + std::optional rate_control; +}; + +/// Test pattern rendered by the built-in pattern source. +enum class Pattern { + /// Animated color gradient. + Gradient = 0, + /// Bouncing LiveKit logo. + Logo = 1, +}; + +/// 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; +}; + +/// Frame format delivered by a capture device. +enum class DeviceFrameFormat { + /// Planar I420/YUV420P. + I420 = 0, + /// Biplanar NV12. + NV12 = 1, + /// Packed BGRA. + BGRA = 2, + /// Packed RGB24. + RGB24 = 3, + /// Packed BGR24. + BGR24 = 4, + /// Packed YUYV/YUY2. + YUYV = 5, + /// Packed UYVY. + UYVY = 6, + /// Single-plane 8-bit luma. + GREY = 7, + /// Encoded MJPEG frames. + MJPEG = 8, +}; + +/// Capture format offered by or requested from a device. +/// +/// Width, height, and frame rate must be non-zero. The frame format defaults +/// to @ref DeviceFrameFormat::NV12, the only value accepted by both device +/// backends. +struct DeviceFormat { + /// Frame dimensions. + CaptureResolution resolution; + + /// Frame rate in frames per second. + std::uint32_t framerate_fps = 0; + + /// Frame format. + DeviceFrameFormat frame_format = DeviceFrameFormat::NV12; + + DeviceFormat() = default; + + DeviceFormat(CaptureResolution resolution, std::uint32_t framerate_fps, DeviceFrameFormat frame_format) + : resolution(resolution), framerate_fps(framerate_fps), frame_format(frame_format) {} +}; + +/// How a capture device should pick the format it delivers. +/// +/// The device negotiates the delivered format; the created source reports the +/// negotiated resolution through @c width() and @c height(). The negotiated +/// frame rate and frame format are not reported back. +/// +/// @note Only resolution and frame rate participate in format selection. +/// A @c frame_format is validated and then treated as a preference the backend +/// may substitute, so the delivered format can differ from the one requested: +/// +/// - macOS accepts only @ref DeviceFrameFormat::I420, +/// @ref DeviceFrameFormat::NV12, and @ref DeviceFrameFormat::BGRA -- +/// requesting any other value fails creation -- and then delivers NV12 +/// regardless of which of the three was asked for. +/// - V4L2 rejects @ref DeviceFrameFormat::I420 and @ref DeviceFrameFormat::BGRA. +/// For @ref exact and @ref closest it tries the requested format first and +/// then falls back through the formats it supports; for @ref highestFramerate +/// and @ref highestResolution the constraint selects the format outright, with +/// no fallback. +/// +/// @ref DeviceFrameFormat::NV12 is the only value both backends accept, which +/// is why it is @ref DeviceFormat's default. +class LIVEKIT_API DeviceFormatRequest { +public: + /// Optional constraints for @ref highestFramerate. + struct HighestFramerateConstraint { + /// Only consider formats with this resolution. + std::optional resolution; + /// Only consider formats with this frame format. + std::optional frame_format; + }; + + /// Optional constraints for @ref highestResolution. + struct HighestResolutionConstraint { + /// Only consider formats with this frame rate. + std::optional framerate_fps; + /// Only consider formats with this frame format. + std::optional frame_format; + }; + + /// Let the device choose its default format. + DeviceFormatRequest() = default; + + /// Require the resolution to match exactly; creation fails if the device + /// offers no such format. + /// + /// Frame-rate matching is rounding-tolerant, because devices commonly + /// advertise near-integral rates: a request for 30 fps is satisfied by a + /// device advertising 30.00003 fps, and the device is then driven at its + /// advertised rate. + /// + /// @param format Required resolution, frame rate, and preferred frame format. + /// @return A request that requires the device to satisfy @p format. + static DeviceFormatRequest exact(DeviceFormat format); + + /// Use the device's closest supported resolution and frame rate. + /// + /// @param format Preferred resolution, frame rate, and frame format. + /// @return A request that selects the closest supported format to @p format. + static DeviceFormatRequest closest(DeviceFormat format); + + /// Prefer the highest frame rate, optionally constrained. + /// + /// @param constraint Optional resolution and frame-format constraints. + /// @return A request that selects the highest frame rate satisfying @p constraint. + static DeviceFormatRequest highestFramerate(HighestFramerateConstraint constraint = {}); + + /// Prefer the highest resolution, optionally constrained. + /// + /// @param constraint Optional frame-rate and frame-format constraints. + /// @return A request that selects the highest resolution satisfying @p constraint. + static DeviceFormatRequest highestResolution(HighestResolutionConstraint constraint = {}); + +private: + friend class CaptureSource; + + /// Which selection strategy this request carries. + enum class Kind { Default, Exact, Closest, HighestFramerate, HighestResolution }; + + Kind kind_ = Kind::Default; + DeviceFormat format_; + HighestFramerateConstraint highest_framerate_; + HighestResolutionConstraint highest_resolution_; +}; + +/// Selects which video device a capture source opens. +class LIVEKIT_API DeviceSelector { +public: + /// The platform default video device. + DeviceSelector() = default; + + /// The device at this position in the platform's own enumeration order. + /// + /// The meaning is platform-defined and is not necessarily the position of + /// the device in @ref CaptureSource::listDevices(). On macOS, it indexes the + /// AVFoundation video-device list; on Linux, it is the V4L2 node number + /// (@c /dev/videoN). Either way the value can shift as devices are attached + /// and removed. Prefer @ref id, which is stable on macOS. + /// + /// @param device_index Platform-defined device index. + /// @return A selector that targets the device at @p device_index. + static DeviceSelector index(std::uint32_t device_index); + + /// A platform-stable identifier, as reported by @ref CaptureDeviceInfo::id. + /// + /// @param device_id Platform device identifier. + /// @return A selector that targets the device with @p device_id. + static DeviceSelector id(std::string device_id); + +private: + friend class CaptureSource; + + /// Which form of device selection this carries. + enum class Kind { Default, Index, Id }; + + Kind kind_ = Kind::Default; + std::uint32_t index_ = 0; + std::string id_; +}; + +/// Configuration for camera device capture using the platform's native +/// capture stack. +struct DeviceVideoSourceConfig { + /// Device to capture from; the platform default device when unset. + DeviceSelector device; + + /// Format requested from the device; the device default when unset. + DeviceFormatRequest format; +}; + +/// A video capture device discovered by @ref CaptureSource::listDevices. +struct CaptureDeviceInfo { + /// Device identifier; pass to @ref DeviceSelector::id. + /// + /// Stable across reboots and re-plugging on macOS, where it is the + /// AVFoundation unique id. On V4L2 it is the node number, so it can change + /// as devices are attached and removed; re-enumerate rather than persisting + /// it there. + std::string id; + + /// Human-readable device name. + std::string name; + + /// Device model identifier, when available. + std::optional model_id; + + /// Device manufacturer, when available. + std::optional manufacturer; + + /// Capture formats reported by the device. + /// + /// Empty unless @ref formats_complete is true. Do not use this to decide + /// what to request: ask for a format and let the device negotiate. + std::vector formats; + + /// Whether @ref formats is a complete list; some platforms do not + /// enumerate formats up front. + /// + /// False on macOS, where AVFoundation formats are not enumerated; true on + /// V4L2. + bool formats_complete = false; +}; + +/// 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, +}; + +/// 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; +}; + +/// A server-side capture source (livekit-capture) that produces video +/// without per-frame FFI traffic. +/// +/// The source owns its producer (e.g. a GStreamer pipeline) 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` feature +/// (`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: + /// Terminal notification for a started capture, invoked exactly once on + /// the FFI event thread (like room delegate callbacks). + using FinishedCallback = std::function; + + /// Create a capture source from a GStreamer pipeline configuration. + /// + /// Completes asynchronously: construction starts the pipeline and may wait + /// for its first output to discover stream settings. Errors (invalid + /// pipeline, missing capture feature, discovery timeout) are thrown from + /// the future as @ref CaptureSourceError. + /// + /// @param config GStreamer pipeline configuration. + /// @return A future that resolves to the created capture source. + /// @throws CaptureSourceError When awaiting the future if creation fails. + static std::future> create(GstreamerVideoSourceConfig config); + + /// 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); + + /// Create a capture source from a camera device. + /// + /// Completes asynchronously: construction opens the device and negotiates + /// the capture format, so @c width() and @c height() report the negotiated + /// resolution before the first frame is pumped. Errors (missing device, + /// unsatisfiable + /// format request, unsupported platform, missing capture feature) are + /// thrown from the future as @ref CaptureSourceError. + /// + /// @param config Device capture configuration. + /// @return A future that resolves to the created capture source. + /// @throws CaptureSourceError When awaiting the future if creation fails. + static std::future> create(DeviceVideoSourceConfig config); + + /// List the video capture devices available on this machine. + /// + /// Completes asynchronously: enumeration queries the platform capture stack + /// and may block briefly. Throws @ref CaptureSourceError from the future on + /// failure, including on platforms without a capture backend. + /// + /// @return A future that resolves to the discovered devices. + /// @throws CaptureSourceError When awaiting the future if enumeration fails. + static std::future> listDevices(); + + ~CaptureSource(); + + CaptureSource(const CaptureSource&) = delete; + CaptureSource& operator=(const CaptureSource&) = delete; + CaptureSource(CaptureSource&&) = delete; + CaptureSource& operator=(CaptureSource&&) = delete; + + /// Kind of media this source produces. + CaptureSourceKind kind() const noexcept { return kind_; } + + /// Declared or discovered stream resolution. + int width() const noexcept { return width_; } + int height() const noexcept { return height_; } + + /// Codec produced by the source; encoded sources only. + std::optional codec() const noexcept { return codec_; } + + /// RTC video source fed by this capture source; use it with + /// LocalVideoTrack::createLocalVideoTrack(). + std::shared_ptr videoSource() const noexcept { return video_source_; } + + /// Returns publish options for this track, applying application options. + /// + /// Fields the source dictates (e.g. codec, encoder backend, and simulcast + /// for encoded sources) are required for correct publication and override + /// the application values; all other fields are taken from @p options + /// unchanged. + TrackPublishOptions publishOptions(TrackPublishOptions options = {}) const; + + /// Set the terminal notification. Set this before @ref start(). + void setOnFinishedCallback(FinishedCallback callback); + + /// Start pumping frames into the RTC video source. + /// + /// @return @c true if the start request succeeds; otherwise @c false. + bool start(); + + /// 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; + + /// Shared creation path: sends the request and maps the callback payload + /// onto a wrapper instance. + static std::future> createFromRequest(proto::NewCaptureSourceRequest request); + + /// Builds the wrapper from the callback payload, adopting its handles. + 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_; + FinishedCallback 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..fcba21b4 100644 --- a/include/livekit/video_source.h +++ b/include/livekit/video_source.h @@ -97,10 +97,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/src/capture_source.cpp b/src/capture_source.cpp new file mode 100644 index 00000000..3379b207 --- /dev/null +++ b/src/capture_source.cpp @@ -0,0 +1,479 @@ +/* + * 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 "capture.pb.h" +#include "capture_source_internal.h" +#include "ffi.pb.h" +#include "ffi_client.h" +#include "lk_log.h" +#include "room_proto_converter.h" + +namespace livekit { + +namespace { + +/// A already-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::DeviceFrameFormat toProto(DeviceFrameFormat format) { + switch (format) { + case DeviceFrameFormat::I420: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_I420; + case DeviceFrameFormat::NV12: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_NV12; + case DeviceFrameFormat::BGRA: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGRA; + case DeviceFrameFormat::RGB24: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_RGB24; + case DeviceFrameFormat::BGR24: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGR24; + case DeviceFrameFormat::YUYV: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_YUYV; + case DeviceFrameFormat::UYVY: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_UYVY; + case DeviceFrameFormat::GREY: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_GREY; + case DeviceFrameFormat::MJPEG: + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_MJPEG; + } + return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_I420; +} + +DeviceFrameFormat fromProto(proto::DeviceFrameFormat format) { + switch (format) { + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_I420: + return DeviceFrameFormat::I420; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_NV12: + return DeviceFrameFormat::NV12; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGRA: + return DeviceFrameFormat::BGRA; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_RGB24: + return DeviceFrameFormat::RGB24; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGR24: + return DeviceFrameFormat::BGR24; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_YUYV: + return DeviceFrameFormat::YUYV; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_UYVY: + return DeviceFrameFormat::UYVY; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_GREY: + return DeviceFrameFormat::GREY; + case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_MJPEG: + return DeviceFrameFormat::MJPEG; + } + return DeviceFrameFormat::I420; +} + +void toProto(const DeviceFormat& format, proto::DeviceFormat* out) { + validateResolution(format.resolution, "DeviceFormat::resolution"); + out->mutable_resolution()->set_width(format.resolution.width); + out->mutable_resolution()->set_height(format.resolution.height); + out->set_framerate_fps(format.framerate_fps); + out->set_frame_format(toProto(format.frame_format)); +} + +DeviceFormat fromProto(const proto::DeviceFormat& format) { + DeviceFormat out; + out.resolution.width = static_cast(format.resolution().width()); + out.resolution.height = static_cast(format.resolution().height()); + out.framerate_fps = format.framerate_fps(); + out.frame_format = fromProto(format.frame_format()); + return out; +} + +CaptureDeviceInfo fromProto(const proto::CaptureDeviceInfo& info) { + CaptureDeviceInfo out; + out.id = info.id(); + out.name = info.name(); + if (info.has_model_id()) { + out.model_id = info.model_id(); + } + if (info.has_manufacturer()) { + out.manufacturer = info.manufacturer(); + } + out.formats.reserve(static_cast(info.formats_size())); + for (const proto::DeviceFormat& format : info.formats()) { + out.formats.push_back(fromProto(format)); + } + out.formats_complete = info.formats_complete(); + return out; +} + +proto::GstreamerBitrateUnit toProto(GstreamerBitrateUnit unit) { + switch (unit) { + case GstreamerBitrateUnit::Bps: + return proto::GstreamerBitrateUnit::GSTREAMER_BITRATE_UNIT_BPS; + case GstreamerBitrateUnit::Kbps: + return proto::GstreamerBitrateUnit::GSTREAMER_BITRATE_UNIT_KBPS; + } + return proto::GstreamerBitrateUnit::GSTREAMER_BITRATE_UNIT_BPS; +} + +proto::Pattern toProto(Pattern pattern) { + switch (pattern) { + case Pattern::Gradient: + return proto::Pattern::PATTERN_GRADIENT; + case Pattern::Logo: + return proto::Pattern::PATTERN_LOGO; + } + return proto::Pattern::PATTERN_GRADIENT; +} + +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; + } + return result; +} + +} // namespace + +std::vector fromProto(const proto::CaptureDeviceList& list) { + std::vector devices; + devices.reserve(static_cast(list.devices_size())); + for (const proto::CaptureDeviceInfo& device : list.devices()) { + devices.push_back(fromProto(device)); + } + return devices; +} + +std::future> CaptureSource::create(GstreamerVideoSourceConfig config) { + proto::NewCaptureSourceRequest request; + auto* gstreamer = request.mutable_gstreamer(); + gstreamer->set_pipeline(config.pipeline); + if (config.codec) { + gstreamer->set_codec(static_cast(*config.codec)); + } + if (config.resolution) { + gstreamer->mutable_resolution()->set_width(config.resolution->width); + gstreamer->mutable_resolution()->set_height(config.resolution->height); + } + if (config.rate_control) { + auto* rate_control = gstreamer->mutable_rate_control(); + rate_control->set_element(config.rate_control->element); + rate_control->set_property(config.rate_control->property); + rate_control->set_unit(toProto(config.rate_control->unit)); + } + return createFromRequest(std::move(request)); +} + +std::future> CaptureSource::create(PatternVideoSourceConfig config) { + 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); + pattern->set_pattern(toProto(config.pattern)); + return createFromRequest(std::move(request)); +} + +DeviceFormatRequest DeviceFormatRequest::exact(DeviceFormat format) { + DeviceFormatRequest request; + request.kind_ = Kind::Exact; + request.format_ = format; + return request; +} + +DeviceFormatRequest DeviceFormatRequest::closest(DeviceFormat format) { + DeviceFormatRequest request; + request.kind_ = Kind::Closest; + request.format_ = format; + return request; +} + +DeviceFormatRequest DeviceFormatRequest::highestFramerate(HighestFramerateConstraint constraint) { + DeviceFormatRequest request; + request.kind_ = Kind::HighestFramerate; + request.highest_framerate_ = constraint; + return request; +} + +DeviceFormatRequest DeviceFormatRequest::highestResolution(HighestResolutionConstraint constraint) { + DeviceFormatRequest request; + request.kind_ = Kind::HighestResolution; + request.highest_resolution_ = constraint; + return request; +} + +DeviceSelector DeviceSelector::index(std::uint32_t device_index) { + DeviceSelector selector; + selector.kind_ = Kind::Index; + selector.index_ = device_index; + return selector; +} + +DeviceSelector DeviceSelector::id(std::string device_id) { + DeviceSelector selector; + selector.kind_ = Kind::Id; + selector.id_ = std::move(device_id); + return selector; +} + +std::future> CaptureSource::create(DeviceVideoSourceConfig config) { + proto::NewCaptureSourceRequest request; + auto* device = request.mutable_device(); + + switch (config.device.kind_) { + case DeviceSelector::Kind::Default: + // Leave the `device` oneof unset for the platform default. + break; + case DeviceSelector::Kind::Index: + device->set_device_index(config.device.index_); + break; + case DeviceSelector::Kind::Id: + device->set_device_id(config.device.id_); + break; + } + + // Leave `format` unset for the device default. An absent field and an empty + // DeviceFormatRequest are equivalent -- the server collapses both to + // DeviceFormatRequest::Default -- but omitting it keeps has_format() + // meaningful. What is not interchangeable is an empty *inner* message: + // highest_framerate{} and highest_resolution{} select a format, so they must + // be emitted even with no constraints set. + const DeviceFormatRequest& format = config.format; + switch (format.kind_) { + case DeviceFormatRequest::Kind::Default: + break; + case DeviceFormatRequest::Kind::Exact: + toProto(format.format_, device->mutable_format()->mutable_exact()); + break; + case DeviceFormatRequest::Kind::Closest: + toProto(format.format_, device->mutable_format()->mutable_closest()); + break; + case DeviceFormatRequest::Kind::HighestFramerate: { + auto* highest = device->mutable_format()->mutable_highest_framerate(); + if (format.highest_framerate_.resolution) { + validateResolution(*format.highest_framerate_.resolution, "HighestFramerateConstraint::resolution"); + highest->mutable_resolution()->set_width(format.highest_framerate_.resolution->width); + highest->mutable_resolution()->set_height(format.highest_framerate_.resolution->height); + } + if (format.highest_framerate_.frame_format) { + highest->set_frame_format(toProto(*format.highest_framerate_.frame_format)); + } + break; + } + case DeviceFormatRequest::Kind::HighestResolution: { + auto* highest = device->mutable_format()->mutable_highest_resolution(); + if (format.highest_resolution_.framerate_fps) { + highest->set_framerate_fps(*format.highest_resolution_.framerate_fps); + } + if (format.highest_resolution_.frame_format) { + highest->set_frame_format(toProto(*format.highest_resolution_.frame_format)); + } + break; + } + } + + return createFromRequest(std::move(request)); +} + +std::future> CaptureSource::listDevices() { + // Issuing the request can fail synchronously (an FFI library built without + // the capture feature rejects it outright). Report that through the returned + // future so every failure reaches the caller as a CaptureSourceError from + // exactly one place, as documented. + std::future devices; + try { + devices = FfiClient::instance().listCaptureDevicesAsync(); + } catch (const std::exception& e) { + return readyCaptureError>(e); + } + + // Map the FFI payload onto the public type once the callback resolves. A + // helper thread keeps the returned future's wait semantics standard. + return std::async(std::launch::async, [devices = std::move(devices)]() mutable { + try { + const proto::CaptureDeviceList list = devices.get(); + return fromProto(list); + } catch (const CaptureSourceError&) { + throw; + } catch (const std::exception& e) { + throw CaptureSourceError(e.what()); + } + }); +} + +std::future> CaptureSource::createFromRequest(proto::NewCaptureSourceRequest request) { + // See listDevices(): a synchronous send failure is delivered through the + // future rather than thrown from the factory. + 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(); + + std::shared_ptr source(new CaptureSource()); + source->handle_ = FfiHandle(static_cast(owned.handle().id())); + 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( + FfiHandle(static_cast(info.video_source().handle().id())), 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 = source->handle_.get(); + source->listener_id_ = FfiClient::instance().addListener([raw, capture_handle](const proto::FfiEvent& event) { + if (!event.has_capture_source_event() || event.capture_source_event().capture_handle() != capture_handle) { + return; + } + const CaptureResult result = resultFromEvent(event.capture_source_event()); + FinishedCallback 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 { + const auto overlay = [](auto& target, const auto& source) { + if (source.has_value()) { + target = source; + } + }; + overlay(options.video_encoding, source_publish_options_.video_encoding); + overlay(options.audio_encoding, source_publish_options_.audio_encoding); + overlay(options.video_codec, source_publish_options_.video_codec); + overlay(options.dtx, source_publish_options_.dtx); + overlay(options.red, source_publish_options_.red); + overlay(options.simulcast, source_publish_options_.simulcast); + overlay(options.source, source_publish_options_.source); + overlay(options.stream, source_publish_options_.stream); + overlay(options.preconnect_buffer, source_publish_options_.preconnect_buffer); + overlay(options.frame_metadata_features, source_publish_options_.frame_metadata_features); + overlay(options.degradation_preference, source_publish_options_.degradation_preference); + overlay(options.video_encoder, source_publish_options_.video_encoder); + 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) { + const std::scoped_lock lock(callback_mutex_); + on_finished_ = std::move(callback); +} + +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/capture_source_internal.h b/src/capture_source_internal.h new file mode 100644 index 00000000..0d177879 --- /dev/null +++ b/src/capture_source_internal.h @@ -0,0 +1,35 @@ +/* + * 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 "livekit/capture_source.h" +#include "livekit/visibility.h" + +namespace livekit { + +namespace proto { +class CaptureDeviceList; +} + +/// Convert an FFI capture-device list to public device information. +/// +/// Internal test seam; not part of the public SDK API. +LIVEKIT_INTERNAL_API std::vector fromProto(const proto::CaptureDeviceList& list); + +} // namespace livekit diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 6c924560..c52c25ed 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -109,6 +109,10 @@ 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(); + case E::kListCaptureDevices: + return event.list_capture_devices().async_id(); // low-level data stream callbacks case E::kSendStreamHeader: @@ -162,6 +166,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 +717,91 @@ 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::listCaptureDevicesAsync() { + // 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_list_capture_devices() && event.list_capture_devices().async_id() == async_id; + }, + [](const proto::FfiEvent& event, std::promise& pr) { + const auto& cb = event.list_capture_devices(); + + // Oneof message { string error = 2; CaptureDeviceList devices = 3; } + if (cb.has_error() && !cb.error().empty()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error()))); + return; + } + if (!cb.has_devices()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error("ListCaptureDevicesCallback missing devices"))); + return; + } + + pr.set_value(cb.devices()); + }); + + proto::FfiRequest req; + req.mutable_list_capture_devices()->set_request_async_id(async_id); + + try { + const proto::FfiResponse resp = sendRequest(req); + if (!resp.has_list_capture_devices()) { + logAndThrow("FfiResponse missing list_capture_devices"); + } + } 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..caf038c5 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,10 @@ 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 listCaptureDevicesAsync(); 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..c6f4782a --- /dev/null +++ b/src/tests/integration/test_capture_source.cpp @@ -0,0 +1,256 @@ +/* + * 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. + */ + +/// Tests for capture sources (livekit-capture over FFI). +/// +/// Two groups. `CaptureSourceServerTest` publishes the built-in pattern source — +/// the capture pump pushes frames server-side, so the test drives no frames +/// itself — and verifies that a second participant receives real video through +/// the SFU. `CaptureDeviceTest` covers camera device enumeration and format +/// negotiation, which need no room; those tests skip when the machine has no +/// camera. +/// +/// All of them require the Rust FFI built with the `capture` feature +/// (-DLIVEKIT_ENABLE_CAPTURE=ON); otherwise they skip. + +#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; + +/// 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. +#if LIVEKIT_CAPTURE_ENABLED +#define SKIP_WITHOUT_CAPTURE_FEATURE() ((void)0) +#else +#define SKIP_WITHOUT_CAPTURE_FEATURE() \ + GTEST_SKIP() << "livekit-ffi built without the 'capture' feature; configure with -DLIVEKIT_ENABLE_CAPTURE=ON" +#endif + +std::shared_ptr createPatternCapture() { + PatternVideoSourceConfig config; + config.resolution = {kCaptureWidth, kCaptureHeight}; + config.framerate_fps = kCaptureFramerateFps; + return CaptureSource::create(config).get(); +} + +} // namespace + +class CaptureSourceServerTest : public LiveKitTestBase {}; + +/// Device tests need an initialized SDK but no room, so they share the base +/// fixture without requiring the server environment variables. +class CaptureDeviceTest : public LiveKitTestBase {}; + +/// Device enumeration is a pure local query: it needs no server, and a +/// machine with no camera is a valid (empty) result. +TEST_F(CaptureDeviceTest, ListDevicesReportsWellFormedDevices) { + SKIP_WITHOUT_CAPTURE_FEATURE(); + + std::vector devices; + try { + devices = CaptureSource::listDevices().get(); + } catch (const CaptureSourceError& e) { + // A platform with no capture backend reports UnsupportedPlatform. + GTEST_SKIP() << "capture device enumeration unavailable: " << e.what(); + } + + for (const CaptureDeviceInfo& device : devices) { + EXPECT_FALSE(device.id.empty()) << "device id must be a stable identifier"; + EXPECT_FALSE(device.name.empty()) << "device name must be human-readable"; + for (const DeviceFormat& format : device.formats) { + EXPECT_GT(format.resolution.width, 0); + EXPECT_GT(format.resolution.height, 0); + EXPECT_GT(format.framerate_fps, 0u); + } + // A device reporting a complete format list must report at least one. + if (device.formats_complete) { + EXPECT_FALSE(device.formats.empty()) << "device " << device.id << " claims a complete but empty format list"; + } + } +} + +/// An unsatisfiable exact format request must fail loudly at construction +/// rather than silently falling back to another format. +/// +/// The resolution is deliberately odd-sized rather than merely large: no +/// camera offers 7x3, and its odd dimensions survive any driver rounding to a +/// macroblock or 2-pixel boundary. +/// +/// CaptureSourceError carries only a message, so this cannot assert on a +/// specific cause. The preceding skips narrow it: the capture feature is +/// present and at least one device enumerated, so the throw is attributable to +/// the format request. +TEST_F(CaptureDeviceTest, ExactFormatRequestRejectsUnsupportedFormat) { + SKIP_WITHOUT_CAPTURE_FEATURE(); + + std::vector devices; + try { + devices = CaptureSource::listDevices().get(); + } catch (const CaptureSourceError& e) { + GTEST_SKIP() << "capture device enumeration unavailable: " << e.what(); + } + if (devices.empty()) { + GTEST_SKIP() << "no capture devices attached to this machine"; + } + + DeviceVideoSourceConfig config; + config.device = DeviceSelector::id(devices.front().id); + // NV12 is requestable on every backend, so the rejection is attributable to + // the resolution rather than to frame-format validation. + config.format = DeviceFormatRequest::exact(DeviceFormat{{7, 3}, 1, DeviceFrameFormat::NV12}); + + EXPECT_THROW(CaptureSource::create(config).get(), CaptureSourceError); +} + +/// Opening the platform default device with the default format request must +/// negotiate a usable format and report the negotiated resolution. +TEST_F(CaptureDeviceTest, DefaultDeviceNegotiatesAFormat) { + SKIP_WITHOUT_CAPTURE_FEATURE(); + + std::vector devices; + try { + devices = CaptureSource::listDevices().get(); + } catch (const CaptureSourceError& e) { + GTEST_SKIP() << "capture device enumeration unavailable: " << e.what(); + } + if (devices.empty()) { + GTEST_SKIP() << "no capture devices attached to this machine"; + } + + std::shared_ptr capture; + try { + capture = CaptureSource::create(DeviceVideoSourceConfig{}).get(); + } catch (const CaptureSourceError& e) { + // A camera present but claimed by another process, or denied by the + // platform's privacy controls, is not a binding failure. + GTEST_SKIP() << "default capture device unavailable: " << e.what(); + } + + ASSERT_NE(capture, nullptr); + EXPECT_EQ(capture->kind(), CaptureSourceKind::Pixel); + EXPECT_GT(capture->width(), 0); + EXPECT_GT(capture->height(), 0); + EXPECT_FALSE(capture->codec().has_value()) << "device capture is a pixel source"; + EXPECT_NE(capture->videoSource(), nullptr); +} + +TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { + SKIP_WITHOUT_CAPTURE_FEATURE(); + failIfNotConfigured(); + + auto capture = createPatternCapture(); + ASSERT_NE(capture, nullptr); + + // The pattern 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); + + Room sender_room; + Room receiver_room; + 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. + std::mutex mutex; + std::condition_variable cv; + int frames_received = 0; + + const std::string track_name = "pattern-capture-track"; + receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, + [&mutex, &cv, &frames_received](const VideoFrameEvent& /*event*/) { + std::lock_guard lock(mutex); + if (++frames_received >= kMinFramesReceived) { + 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. + std::optional finished; + capture->setOnFinishedCallback([&mutex, &cv, &finished](const CaptureResult& result) { + std::lock_guard lock(mutex); + finished = result; + cv.notify_all(); + }); + + ASSERT_TRUE(capture->start()); + EXPECT_FALSE(capture->start()) << "double start must be rejected"; + + { + std::unique_lock lock(mutex); + const bool got_frames = + cv.wait_for(lock, 30s, [&frames_received] { return frames_received >= kMinFramesReceived; }); + ASSERT_TRUE(got_frames) << "Timed out waiting for pattern capture frames; received " << frames_received; + } + + // Stop is a signal; the terminal callback delivers the stats. + ASSERT_TRUE(capture->stop()); + { + std::unique_lock lock(mutex); + const bool got_finished = cv.wait_for(lock, 10s, [&finished] { return finished.has_value(); }); + ASSERT_TRUE(got_finished) << "Timed out waiting for the capture finished callback"; + } + + ASSERT_FALSE(finished->error.has_value()) << *finished->error; + EXPECT_EQ(finished->exit, CaptureExit::Stopped); + EXPECT_GT(finished->frames_captured, 0u); + + receiver_room.clearOnVideoFrameCallback(sender_identity, track_name); + if (track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); + } +} + +} // 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..e69093cb --- /dev/null +++ b/src/tests/unit/test_capture_source.cpp @@ -0,0 +1,59 @@ +/* + * 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 "capture.pb.h" +#include "capture_source_internal.h" + +namespace livekit::test { +namespace { + +TEST(CaptureSourceProtoTest, ConvertsDeviceListToPublicTypes) { + proto::CaptureDeviceList list; + auto* device = list.add_devices(); + device->set_id("camera-1"); + device->set_name("Front camera"); + device->set_model_id("model-1"); + device->set_manufacturer("LiveKit"); + device->set_formats_complete(true); + + auto* format = device->add_formats(); + format->mutable_resolution()->set_width(1920); + format->mutable_resolution()->set_height(1080); + format->set_framerate_fps(30); + format->set_frame_format(proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_NV12); + + const std::vector devices = fromProto(list); + + ASSERT_EQ(devices.size(), 1u); + const CaptureDeviceInfo& converted = devices.front(); + EXPECT_EQ(converted.id, "camera-1"); + EXPECT_EQ(converted.name, "Front camera"); + ASSERT_TRUE(converted.model_id.has_value()); + EXPECT_EQ(*converted.model_id, "model-1"); + ASSERT_TRUE(converted.manufacturer.has_value()); + EXPECT_EQ(*converted.manufacturer, "LiveKit"); + EXPECT_TRUE(converted.formats_complete); + ASSERT_EQ(converted.formats.size(), 1u); + EXPECT_EQ(converted.formats.front().resolution.width, 1920); + EXPECT_EQ(converted.formats.front().resolution.height, 1080); + EXPECT_EQ(converted.formats.front().framerate_fps, 30u); + EXPECT_EQ(converted.formats.front().frame_format, DeviceFrameFormat::NV12); +} + +} // namespace +} // namespace livekit::test From 9be6f0a6be1032df202837531d0fbfd2071799f5 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 25 Aug 2026 15:44:09 -0600 Subject: [PATCH 02/29] dep on libjpeg --- .github/workflows/tests.yml | 4 ++-- build.h.in | 16 +++++++++++----- client-sdk-rust | 2 +- src/tests/integration/test_capture_source.cpp | 12 ++++++------ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2395c65..178831f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -260,7 +260,7 @@ jobs: libabsl-dev \ libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ - libunwind-dev \ + libunwind-dev libjpeg-dev \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ @@ -556,7 +556,7 @@ jobs: libabsl-dev \ libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ - libunwind-dev \ + libunwind-dev libjpeg-dev \ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ diff --git a/build.h.in b/build.h.in index 5e736f4c..18395f4d 100644 --- a/build.h.in +++ b/build.h.in @@ -6,17 +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_CAPTURE_ENABLED @LIVEKIT_CAPTURE_ENABLED@ +#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 diff --git a/client-sdk-rust b/client-sdk-rust index b402677a..31eeea76 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit b402677a767ad68b0a061f6ee3191a363141a620 +Subproject commit 31eeea76246e89424a655888ca0af175edc0ad37 diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index c6f4782a..dcd4ab87 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -51,12 +51,12 @@ constexpr std::uint32_t kCaptureFramerateFps = 30; /// 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. -#if LIVEKIT_CAPTURE_ENABLED -#define SKIP_WITHOUT_CAPTURE_FEATURE() ((void)0) -#else -#define SKIP_WITHOUT_CAPTURE_FEATURE() \ - GTEST_SKIP() << "livekit-ffi built without the 'capture' feature; configure with -DLIVEKIT_ENABLE_CAPTURE=ON" -#endif +#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 createPatternCapture() { PatternVideoSourceConfig config; From 4f168ff2fabb41ea579ec19d7c40ba3fe88647f4 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 9 Oct 2026 10:54:44 -0600 Subject: [PATCH 03/29] capture specific releases/builds and tooling. effort to reduce the scope of the public API WRT capture. --- .build-info.json.in | 2 + .gitattributes | 1 + .github/actions/setup-capture/action.yml | 47 +++ .github/scripts/check_no_private_symbols.py | 3 + .github/workflows/builds.yml | 68 +++- .github/workflows/ci.yml | 4 + .github/workflows/make-release.yml | 98 ++++- AGENTS.md | 15 + CMakeLists.txt | 103 ++--- CMakePresets.json | 9 +- build.cmd | 15 + build.sh | 14 + client-sdk-rust | 2 +- cmake/run_cargo.cmake | 59 +++ cmake/uniffi_cpp.cmake | 17 +- docs/README.md | 2 + docs/building.md | 24 ++ docs/capture-sources.md | 102 +++++ include/livekit/capture_source.h | 361 ++++-------------- scripts/generate-docs.sh | 2 +- src/capture_source.cpp | 351 +++++------------ src/capture_source_internal.h | 13 +- src/ffi_client.cpp | 44 --- src/ffi_client.h | 1 - src/tests/CMakeLists.txt | 3 + src/tests/integration/test_capture_source.cpp | 148 ++----- src/tests/unit/test_capture_source.cpp | 85 +++-- 27 files changed, 755 insertions(+), 838 deletions(-) create mode 100644 .github/actions/setup-capture/action.yml create mode 100644 cmake/run_cargo.cmake create mode 100644 docs/capture-sources.md diff --git a/.build-info.json.in b/.build-info.json.in index 7dd75637..a0e4b8ca 100644 --- a/.build-info.json.in +++ b/.build-info.json.in @@ -4,5 +4,7 @@ "cpp_git_commit": "@GIT_COMMIT@", "rust_submodule_commit": "@RUST_GIT_COMMIT@", "livekit_ffi_version": "@LIVEKIT_FFI_VERSION@", + "capture_enabled": @LIVEKIT_CAPTURE_ENABLED@, + "cargo_features": "@LIVEKIT_CARGO_FEATURES@", "build_date": "@BUILD_DATE@" } 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/.github/actions/setup-capture/action.yml b/.github/actions/setup-capture/action.yml new file mode 100644 index 00000000..d1d3fa7b --- /dev/null +++ b/.github/actions/setup-capture/action.yml @@ -0,0 +1,47 @@ +name: Set up capture dependencies +description: Install GStreamer development files and runtime plugins for capture bundles. +runs: + using: composite + steps: + - name: Install GStreamer (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav + - name: Install GStreamer (macOS) + if: runner.os == 'macOS' + shell: bash + run: brew install gstreamer + - name: Install GStreamer (Windows) + if: runner.os == 'Windows' + env: + GSTREAMER_WINDOWS_VERSION: "1.28.5" + GSTREAMER_WINDOWS_SHA256: "51ee5eaec33008e8409d8cf6f6884457f22aa3bd515f8856f993a3eaab903530" + shell: pwsh + run: | + $installer = Join-Path $env:RUNNER_TEMP "gstreamer-installer.exe" + $installDir = Join-Path $env:RUNNER_TEMP "gstreamer" + $uri = "https://gstreamer.freedesktop.org/data/pkg/windows/$env:GSTREAMER_WINDOWS_VERSION/msvc/gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_WINDOWS_VERSION.exe" + + Invoke-WebRequest -Uri $uri -OutFile $installer + $actualHash = (Get-FileHash -Algorithm SHA256 $installer).Hash.ToLowerInvariant() + if ($actualHash -ne $env:GSTREAMER_WINDOWS_SHA256) { + throw "GStreamer installer SHA-256 mismatch: expected $env:GSTREAMER_WINDOWS_SHA256, got $actualHash" + } + + $process = Start-Process -FilePath $installer -ArgumentList @( + "/TYPE=devel", + "/DIR=$installDir", + "/CURRENTUSER", + "/VERYSILENT", + "/NORESTART" + ) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "GStreamer installer failed with exit code $($process.ExitCode)" + } + + "$installDir\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "PKG_CONFIG_PATH=$installDir\lib\pkgconfig" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/check_no_private_symbols.py b/.github/scripts/check_no_private_symbols.py index f73645f8..704d51f7 100755 --- a/.github/scripts/check_no_private_symbols.py +++ b/.github/scripts/check_no_private_symbols.py @@ -57,6 +57,9 @@ "google::protobuf", "absl::", "nlohmann::", + # Capture pipelines are private Rust/GStreamer implementation details. + "gstreamer::", + "gst_", # Generated UniFFI C++ binding API and runtime implementation. "livekit_ffi::", "uniffi::", diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 4db2ec45..133ecc37 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -62,6 +62,43 @@ jobs: build_cmd: .\build.cmd release-examples --bundle --prefix sdk-out\livekit-sdk-windows-x64 build_dir: build-release + - os: ubuntu-22.04 + name: ubuntu-22.04-x64-capture + build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64-capture + build_dir: build-release + capture: true + - os: ubuntu-22.04-arm + name: ubuntu-22.04-arm64-capture + build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64-capture + build_dir: build-release + capture: true + - os: ubuntu-24.04 + name: ubuntu-24.04-x64-capture + build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64-capture + build_dir: build-release + capture: true + - os: ubuntu-24.04-arm + name: ubuntu-24.04-arm64-capture + build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64-capture + build_dir: build-release + capture: true + - os: macos-26-xlarge + name: macos-arm64-capture + build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-arm64-capture + build_dir: build-release + capture: true + - os: macos-26-large + name: macos-x64-capture + build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-x64-capture --macos-arch x86_64 + build_dir: build-release + # Pinned to Windows 2022 for current VS 17 implementation + capture: true + - os: windows-2022 + name: windows-x64-capture + build_cmd: .\build.cmd release-examples --capture --bundle --prefix sdk-out\livekit-sdk-windows-x64-capture + build_dir: build-release + capture: true + name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} @@ -140,6 +177,10 @@ jobs: brew update brew install cmake ninja protobuf abseil + - name: Set up capture dependencies + if: matrix.capture == true + uses: ./.github/actions/setup-capture + # ---------- Rust toolchain ---------- - name: Install Rust (stable) uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 @@ -147,7 +188,7 @@ jobs: toolchain: stable - name: Install Rust cross-compilation target - if: matrix.name == 'macos-x64' + if: startsWith(matrix.name, 'macos-x64') run: rustup target add x86_64-apple-darwin # ---------- Cache Cargo ---------- @@ -345,6 +386,31 @@ jobs: test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfigVersion.cmake" test -f "${bundle}/lib/cmake/LiveKit/LiveKitTargets.cmake" + - name: Smoke-test installed capture SDK (Unix) + if: matrix.capture == true && runner.os != 'Windows' + shell: bash + run: | + bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}" + arch_args=() + if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi + cmake -S scripts/capture-bundle-smoke -B capture-smoke -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" "${arch_args[@]}" + cmake --build capture-smoke + ./capture-smoke/capture_bundle_smoke + + - name: Smoke-test installed capture SDK (Windows) + if: matrix.capture == true && runner.os == 'Windows' + shell: pwsh + run: | + $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}" + cmake -S scripts/capture-bundle-smoke -B capture-smoke -DCMAKE_PREFIX_PATH="$bundle" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build capture-smoke --config Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:PATH = "$bundle\bin;$env:PATH" + & .\capture-smoke\Release\capture_bundle_smoke.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # ---------- Upload artifacts ---------- - name: Upload SDK bundle uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c20ff88..7eaf82c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,10 @@ jobs: - vcpkg.json - .github/workflows/ci.yml - .github/workflows/builds.yml + - .github/workflows/make-release.yml + - .github/actions/setup-capture/** + - .github/scripts/check_no_private_symbols.py + - scripts/capture-bundle-smoke/** tests: - src/** - include/** diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index ca085dd9..940bddc6 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -11,6 +11,9 @@ on: required: true type: string +permissions: + contents: read + env: CARGO_TERM_COLOR: always VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" @@ -47,6 +50,36 @@ jobs: name: windows-x64 generator: "Visual Studio 17 2022" + - os: ubuntu-22.04 + name: ubuntu-22.04-x64-capture + generator: Ninja + capture: true + - os: ubuntu-22.04-arm + name: ubuntu-22.04-arm64-capture + generator: Ninja + capture: true + - os: ubuntu-24.04 + name: ubuntu-24.04-x64-capture + generator: Ninja + capture: true + - os: ubuntu-24.04-arm + name: ubuntu-24.04-arm64-capture + generator: Ninja + capture: true + - os: macos-26-xlarge + name: macos-arm64-capture + generator: Ninja + capture: true + - os: macos-26-large + name: macos-x64-capture + generator: Ninja + # Pinned to Windows 2022 for current VS 17 implementation + capture: true + - os: windows-2022 + name: windows-x64-capture + generator: "Visual Studio 17 2022" + capture: true + name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} @@ -140,6 +173,10 @@ jobs: brew update brew install cmake ninja protobuf abseil + - name: Set up capture dependencies + if: matrix.capture == true + uses: ./.github/actions/setup-capture + # ---------- Rust toolchain ---------- - name: Install Rust (stable) uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 @@ -147,7 +184,7 @@ jobs: toolchain: stable - name: Install Rust cross-compilation target - if: matrix.name == 'macos-x64' + if: startsWith(matrix.name, 'macos-x64') run: rustup target add x86_64-apple-darwin # ---------- Cache Cargo ---------- @@ -217,10 +254,12 @@ jobs: exit 1 fi - if [[ "${{ matrix.name }}" == "macos-x64" ]]; then - ./build.sh release-examples --version "$version" --bundle --prefix "$bundleDir" --macos-arch x86_64 + capture_args=() + if [[ "${{ matrix.capture }}" == "true" ]]; then capture_args+=(--capture); fi + if [[ "${{ matrix.name }}" == macos-x64* ]]; then + ./build.sh release-examples "${capture_args[@]}" --version "$version" --bundle --prefix "$bundleDir" --macos-arch x86_64 else - ./build.sh release-examples --version "$version" --bundle --prefix "$bundleDir" + ./build.sh release-examples "${capture_args[@]}" --version "$version" --bundle --prefix "$bundleDir" fi # List bundle contents @@ -257,7 +296,10 @@ jobs: $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}-$version" if ([string]::IsNullOrWhiteSpace($version)) { throw "version is empty" } - .\build.cmd release-examples --version "$version" --bundle --prefix "$bundleDir" + $captureArgs = @() + if ("${{ matrix.capture }}" -eq "true") { $captureArgs += "--capture" } + .\build.cmd release-examples @captureArgs --version "$version" --bundle --prefix "$bundleDir" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "Bundle contents:" Get-ChildItem -Recurse -File $bundleDir | Select-Object -First 200 | ForEach-Object { $_.FullName } @@ -338,6 +380,31 @@ jobs: throw "Build info is missing livekit-ffi version" } + - name: Smoke-test installed capture SDK (Unix) + if: matrix.capture == true && runner.os != 'Windows' + shell: bash + run: | + bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" + arch_args=() + if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi + cmake -S scripts/capture-bundle-smoke -B capture-smoke -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" "${arch_args[@]}" + cmake --build capture-smoke + ./capture-smoke/capture_bundle_smoke + + - name: Smoke-test installed capture SDK (Windows) + if: matrix.capture == true && runner.os == 'Windows' + shell: pwsh + run: | + $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" + cmake -S scripts/capture-bundle-smoke -B capture-smoke -DCMAKE_PREFIX_PATH="$bundle" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build capture-smoke --config Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:PATH = "$bundle\bin;$env:PATH" + & .\capture-smoke\Release\capture_bundle_smoke.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # ---------- Upload artifact (raw directory, no pre-compression) ---------- - name: Upload build artifact uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -412,23 +479,22 @@ jobs: echo "Artifacts downloaded:" ls -la - # Create tar.gz for Ubuntu and macOS + # Standard and capture-enabled Ubuntu and macOS bundles. for platform in ubuntu-22.04-x64 ubuntu-22.04-arm64 ubuntu-24.04-x64 ubuntu-24.04-arm64 macos-arm64 macos-x64; do - dirName="livekit-sdk-${platform}-${VERSION}" - if [[ -d "${dirName}" ]]; then - echo "Creating archive for ${platform}..." + for suffix in "" "-capture"; do + dirName="livekit-sdk-${platform}${suffix}-${VERSION}" + test -d "${dirName}" tar -czf "${{ github.workspace }}/release-assets/${dirName}.tar.gz" "${dirName}" echo "Created: ${dirName}.tar.gz" - fi + done done - # Create zip for Windows - dirName="livekit-sdk-windows-x64-${VERSION}" - if [[ -d "${dirName}" ]]; then - echo "Creating archive for windows-x64..." + # Standard and capture-enabled Windows bundles. + for suffix in "" "-capture"; do + dirName="livekit-sdk-windows-x64${suffix}-${VERSION}" + test -d "${dirName}" zip -r "${{ github.workspace }}/release-assets/${dirName}.zip" "${dirName}" - echo "Created: ${dirName}.zip" - fi + done - name: List release assets run: ls -la ${{ github.workspace }}/release-assets/ diff --git a/AGENTS.md b/AGENTS.md index 58985b83..1573e267 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,8 @@ Be sure to update the directory layout in this file if the directory layout chan | `scripts/` | Local helper scripts for SDK-specific development tasks | | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | +| `scripts/capture-bundle-smoke/` | Standalone downstream CMake consumer that validates capture-enabled SDK bundles | +| `.github/actions/setup-capture/` | Shared CI setup for capture runtime and development dependencies | | `.github/workflows/` | GitHub Actions CI workflows | ### Key Types @@ -126,6 +128,18 @@ To create an installable SDK bundle, use the same build command with `./build.sh release --bundle --prefix sdk-out/livekit-sdk`). On Windows, use `build.cmd release --bundle --prefix C:\path\to\livekit-sdk`. +Both build scripts accept `--capture` to enable GStreamer, pattern, and clock +sources, and `--no-capture` to disable them. Test/all presets enable capture by +default; normal and example presets disable it. Capture-enabled builds need +GStreamer development and runtime libraries. Release workflows publish separate +`-capture` bundles and validate creation through the installed SDK using +`scripts/capture-bundle-smoke/`. + +Rust FFI outputs live under `client-sdk-rust/target/cpp-/` to isolate +Cargo feature configurations. Generated public build metadata lives under each +build directory's `generated/include/livekit/build.h`; do not generate it into +the source tree. Track capture Rust sources and shaders in build dependencies. + The build scripts pass an explicit job count to `cmake --build --parallel`. Set `CMAKE_BUILD_PARALLEL_LEVEL` to override the default detected logical CPU count. @@ -338,6 +352,7 @@ malformed table, missing `@param` on a documented function, …) fails the build | nlohmann/json | **Private** | Header-only; vendored via FetchContent (Unix) or vcpkg (Windows); must NOT leak into public API | | client-sdk-rust | Build-time | Git submodule, built via cargo during CMake build | | cpp-tools | Developer / CI | Git submodule containing shared LiveKit C++ formatting and static-analysis tooling | +| GStreamer | Capture builds / runtime | System libraries and plugins; accessed through Rust; not redistributed in SDK bundles | | Google Test | Test only | FetchContent in `src/tests/CMakeLists.txt` | When adding a new private/vendored dependency to this table, also add a diff --git a/CMakeLists.txt b/CMakeLists.txt index 08e8dc1d..8a06327a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,7 +87,6 @@ set(FFI_PROTO_FILES ${FFI_PROTO_DIR}/data_track.proto ${FFI_PROTO_DIR}/rpc.proto ${FFI_PROTO_DIR}/track_publication.proto - ${FFI_PROTO_DIR}/capture.proto ) set(PROTO_BINARY_DIR ${LIVEKIT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${PROTO_BINARY_DIR}) @@ -164,8 +163,9 @@ add_custom_command( target_sources(livekit_proto PRIVATE ${PROTO_SRCS} ${PROTO_HDRS}) -# Generate build.h directly into include/livekit/ for simpler include paths -set(GENERATED_BUILD_H "${LIVEKIT_ROOT_DIR}/include/livekit/build.h") +# Keep feature metadata local to each build directory. +set(LIVEKIT_GENERATED_INCLUDE_DIR "${LIVEKIT_BINARY_DIR}/generated/include") +set(GENERATED_BUILD_H "${LIVEKIT_GENERATED_INCLUDE_DIR}/livekit/build.h") set(GENERATED_BUILD_INFO_JSON "${LIVEKIT_BINARY_DIR}/build-info.json") set(RUST_ROOT ${LIVEKIT_ROOT_DIR}/client-sdk-rust) @@ -173,7 +173,7 @@ set(RUST_ROOT ${LIVEKIT_ROOT_DIR}/client-sdk-rust) set(LIVEKIT_CARGO_FEATURES "") set(LIVEKIT_CAPTURE_ENABLED 0) if(LIVEKIT_ENABLE_CAPTURE) - set(LIVEKIT_CARGO_FEATURES "capture") + set(LIVEKIT_CARGO_FEATURES "capture-gstreamer,capture-pattern,capture-clock") set(LIVEKIT_CAPTURE_ENABLED 1) endif() @@ -235,10 +235,15 @@ if(APPLE AND CMAKE_OSX_ARCHITECTURES) endif() endif() +# Cargo artifacts cannot be shared between capture-enabled and disabled SDKs. +# Include the feature selection in the path, including future feature changes. +string(SHA256 LIVEKIT_CARGO_FEATURE_HASH "${LIVEKIT_CARGO_FEATURES}") +string(SUBSTRING "${LIVEKIT_CARGO_FEATURE_HASH}" 0 16 LIVEKIT_CARGO_FEATURE_HASH) +set(LIVEKIT_CARGO_TARGET_DIR "${RUST_ROOT}/target/cpp-${LIVEKIT_CARGO_FEATURE_HASH}") if(RUST_TARGET_TRIPLE) - set(RUST_TARGET_DIR "${RUST_ROOT}/target/${RUST_TARGET_TRIPLE}") + set(RUST_TARGET_DIR "${LIVEKIT_CARGO_TARGET_DIR}/${RUST_TARGET_TRIPLE}") else() - set(RUST_TARGET_DIR "${RUST_ROOT}/target") + set(RUST_TARGET_DIR "${LIVEKIT_CARGO_TARGET_DIR}") endif() # Detect the GCC toolchain's library directory so cargo can find the matching libstdc++. @@ -255,64 +260,17 @@ if(NOT WIN32) endif() endif() -set(RUN_CARGO_SCRIPT ${LIVEKIT_BINARY_DIR}/run_cargo.cmake) -file(WRITE ${RUN_CARGO_SCRIPT} -"if(NOT DEFINED CFG) - set(CFG Debug) -endif() -if(NOT DEFINED RUST_ROOT) - message(FATAL_ERROR \"RUST_ROOT not set\") -endif() -if(NOT DEFINED CARGO) - message(FATAL_ERROR \"CARGO not set\") -endif() - -set(ARGS build --package livekit-ffi) -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}\") -endif() - -message(STATUS \"[run_cargo.cmake] CFG=\${CFG} CARGO=\${CARGO} PROTOC=\${PROTOC_PATH}\") -set(ENV{PROTOC} \"\${PROTOC_PATH}\") - -if(DEFINED CARGO_TARGET_DIR AND NOT CARGO_TARGET_DIR STREQUAL \"\") - set(ENV{CARGO_TARGET_DIR} \"\${CARGO_TARGET_DIR}\") -endif() - -if(DEFINED GCC_LIB_DIR AND NOT GCC_LIB_DIR STREQUAL \"\") - set(ENV{RUSTFLAGS} \"-L \${GCC_LIB_DIR} \$ENV{RUSTFLAGS}\") - set(ENV{LD_LIBRARY_PATH} \"\${GCC_LIB_DIR}:\$ENV{LD_LIBRARY_PATH}\") -endif() - -execute_process( - COMMAND \"\${CARGO}\" \${ARGS} - WORKING_DIRECTORY \"\${RUST_ROOT}\" - RESULT_VARIABLE rv -) -if(rv) - message(FATAL_ERROR \"cargo build failed with code: \${rv}\") -endif() -") +set(RUN_CARGO_SCRIPT "${LIVEKIT_ROOT_DIR}/cmake/run_cargo.cmake") # Use SHARED library (DLL) on Windows to isolate /MT CRT from consuming /MD apps if(WIN32) add_library(livekit_ffi SHARED IMPORTED GLOBAL) # DLL import libraries (.dll.lib) - set(RUST_IMPLIB_DEBUG "${RUST_ROOT}/target/debug/livekit_ffi.dll.lib") - set(RUST_IMPLIB_RELEASE "${RUST_ROOT}/target/release/livekit_ffi.dll.lib") + set(RUST_IMPLIB_DEBUG "${RUST_TARGET_DIR}/debug/livekit_ffi.dll.lib") + set(RUST_IMPLIB_RELEASE "${RUST_TARGET_DIR}/release/livekit_ffi.dll.lib") # Actual DLL files - set(RUST_DLL_DEBUG "${RUST_ROOT}/target/debug/livekit_ffi.dll") - set(RUST_DLL_RELEASE "${RUST_ROOT}/target/release/livekit_ffi.dll") + set(RUST_DLL_DEBUG "${RUST_TARGET_DIR}/debug/livekit_ffi.dll") + set(RUST_DLL_RELEASE "${RUST_TARGET_DIR}/release/livekit_ffi.dll") # For OUTPUT dependency checking set(RUST_LIB_DEBUG "${RUST_IMPLIB_DEBUG}") set(RUST_LIB_RELEASE "${RUST_IMPLIB_RELEASE}") @@ -368,6 +326,10 @@ file(GLOB_RECURSE RUST_SOURCES CONFIGURE_DEPENDS "${RUST_ROOT}/livekit-ffi/protocol/*.proto" "${RUST_ROOT}/livekit-ffi/build.rs" "${RUST_ROOT}/livekit-ffi/Cargo.toml" + "${RUST_ROOT}/livekit-capture/Cargo.toml" + "${RUST_ROOT}/livekit-capture/src/*" + "${RUST_ROOT}/livekit-capture/shaders/*" + "${RUST_ROOT}/livekit-capture/build.rs" ) list(APPEND RUST_SOURCES "${RUST_ROOT}/Cargo.toml" @@ -394,9 +356,10 @@ add_custom_command( -DRUST_TARGET=${RUST_TARGET_TRIPLE} -DGCC_LIB_DIR=${GCC_LIB_DIR} -DCARGO_FEATURES=${LIVEKIT_CARGO_FEATURES} + -DCARGO_TARGET_DIR=${LIVEKIT_CARGO_TARGET_DIR} -P "${RUN_CARGO_SCRIPT}" WORKING_DIRECTORY "${RUST_ROOT}" - DEPENDS ${RUST_SOURCES} + DEPENDS ${RUST_SOURCES} "${RUN_CARGO_SCRIPT}" COMMENT "Building Rust FFI via cargo" VERBATIM ) @@ -498,6 +461,7 @@ set_source_files_properties( target_include_directories(livekit PUBLIC + $ $ $ PRIVATE @@ -538,22 +502,24 @@ message(STATUS "Protobuf: version=${Protobuf_VERSION}; protoc=${Protobuf_PROTOC_ add_dependencies(livekit build_rust_ffi) if(LIVEKIT_IS_TOPLEVEL) - # Copy public headers to build/include (build.h is already in include/livekit/) + # Copy public headers and build-local feature metadata to build/include add_custom_command( TARGET livekit POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${LIVEKIT_ROOT_DIR}/include ${CMAKE_BINARY_DIR}/include + COMMAND ${CMAKE_COMMAND} -E copy + "${GENERATED_BUILD_H}" "${CMAKE_BINARY_DIR}/include/livekit/build.h" COMMENT "Copying public headers to build/include" ) # Copy livekit_ffi library to output directory alongside livekit library if(WIN32) # Copy both DLL and import library - set(FFI_DLL_DEBUG "${RUST_ROOT}/target/debug/livekit_ffi.dll") - set(FFI_DLL_RELEASE "${RUST_ROOT}/target/release/livekit_ffi.dll") - set(FFI_IMPLIB_DEBUG "${RUST_ROOT}/target/debug/livekit_ffi.dll.lib") - set(FFI_IMPLIB_RELEASE "${RUST_ROOT}/target/release/livekit_ffi.dll.lib") + set(FFI_DLL_DEBUG "${RUST_TARGET_DIR}/debug/livekit_ffi.dll") + set(FFI_DLL_RELEASE "${RUST_TARGET_DIR}/release/livekit_ffi.dll") + set(FFI_IMPLIB_DEBUG "${RUST_TARGET_DIR}/debug/livekit_ffi.dll.lib") + set(FFI_IMPLIB_RELEASE "${RUST_TARGET_DIR}/release/livekit_ffi.dll.lib") # Copy DLL file add_custom_command( @@ -795,11 +761,12 @@ if(APPLE) endif() -# Install public headers (includes generated build.h which is now in include/livekit/) +# Install public headers with this build directory's generated feature metadata. install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" - FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" + FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" PATTERN "build.h" EXCLUDE ) +install(FILES "${GENERATED_BUILD_H}" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/livekit") # ---- Create Config + Version + Targets for find_package(LiveKit CONFIG) ---- @@ -849,7 +816,7 @@ add_custom_target(clean_generated ) add_custom_target(cargo_clean - COMMAND "${CARGO_EXECUTABLE}" clean + COMMAND "${CMAKE_COMMAND}" -E env "CARGO_TARGET_DIR=${LIVEKIT_CARGO_TARGET_DIR}" "${CARGO_EXECUTABLE}" clean WORKING_DIRECTORY "${RUST_ROOT}" COMMENT "Clean Rust target directory via cargo" VERBATIM @@ -861,7 +828,7 @@ add_custom_target(clean_all COMMAND ${CMAKE_COMMAND} --build "${CMAKE_BINARY_DIR}" --target clean # 2) Cargo clean (Rust target/) COMMAND ${CMAKE_COMMAND} -E echo "==> cargo clean in: ${RUST_ROOT}" - COMMAND ${CMAKE_COMMAND} -E chdir "${RUST_ROOT}" "${CARGO_EXECUTABLE}" clean + COMMAND ${CMAKE_COMMAND} -E chdir "${RUST_ROOT}" "${CMAKE_COMMAND}" -E env "CARGO_TARGET_DIR=${LIVEKIT_CARGO_TARGET_DIR}" "${CARGO_EXECUTABLE}" clean # 3) Remove generated protobufs (lives under build/) COMMAND ${CMAKE_COMMAND} -E echo "==> removing generated protobufs: ${PROTO_BINARY_DIR}" COMMAND ${CMAKE_COMMAND} -E rm -rf "${PROTO_BINARY_DIR}" diff --git a/CMakePresets.json b/CMakePresets.json index 9ec524a7..bdc89ad3 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -30,7 +30,8 @@ "strategy": "set" }, "cacheVariables": { - "VCPKG_TARGET_TRIPLET": "x64-windows-static-md" + "VCPKG_TARGET_TRIPLET": "x64-windows-static-md", + "LIVEKIT_ENABLE_CAPTURE": "OFF" } }, { @@ -39,7 +40,8 @@ "generator": "Ninja", "cacheVariables": { "LIVEKIT_USE_VCPKG": "OFF", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "OFF" }, "condition": { "type": "equals", @@ -53,7 +55,8 @@ "generator": "Ninja", "cacheVariables": { "LIVEKIT_USE_VCPKG": "OFF", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "LIVEKIT_ENABLE_CAPTURE": "OFF" }, "condition": { "type": "equals", diff --git a/build.cmd b/build.cmd index eb38aade..2ca8cd35 100644 --- a/build.cmd +++ b/build.cmd @@ -8,6 +8,7 @@ set "BUILD_TYPE=Release" set "PRESET=windows-release" set "LIVEKIT_VERSION=" set "CMAKE_EXTRA_ARGS=" +set "CAPTURE_OVERRIDE=" set "BUILD_PARALLEL_JOBS=" set "DO_BUNDLE=" set "PREFIX=" @@ -89,6 +90,17 @@ if /I "%~1"=="--version" ( goto :get_version_value ) +if /I "%~1"=="--capture" ( + set "CAPTURE_OVERRIDE=ON" + shift + goto parse_all +) +if /I "%~1"=="--no-capture" ( + set "CAPTURE_OVERRIDE=OFF" + shift + goto parse_all +) + :: 3. Install the SDK bundle after a successful build. if /I "%~1"=="--bundle" ( set "DO_BUNDLE=1" @@ -132,6 +144,7 @@ if defined LIVEKIT_VERSION ( set "CMAKE_EXTRA_ARGS=-DLIVEKIT_VERSION=%LIVEKIT_VERSION%" echo ==^> Injecting LIVEKIT_VERSION=%LIVEKIT_VERSION% ) +if defined CAPTURE_OVERRIDE set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_ENABLE_CAPTURE=%CAPTURE_OVERRIDE%" goto dispatch :dispatch @@ -215,6 +228,8 @@ echo clean-all Full clean (build dirs + local-install + Rust targets) echo help Show this help echo. echo Options (for debug / release): +echo --capture Enable all capture sources ^(requires GStreamer^) +echo --no-capture Disable capture sources, overriding the preset echo --bundle Install the SDK bundle using cmake --install echo --prefix ^ Install prefix for --bundle ^(default: .\sdk-out\livekit-sdk^) echo. diff --git a/build.sh b/build.sh index 48cb27f1..9d56b59e 100755 --- a/build.sh +++ b/build.sh @@ -14,6 +14,7 @@ ARCHIVE_NAME="" GENERATOR="" MACOS_ARCH="" LIVEKIT_VERSION="" +CAPTURE_OVERRIDE="" # Detect OS for preset selection detect_os() { @@ -46,6 +47,8 @@ Commands: help Show this help message Options (for debug / release): + --capture Enable all capture sources (requires GStreamer) + --no-capture Disable capture sources, overriding the preset --bundle Install the SDK bundle using 'cmake --install' --prefix Install prefix for --bundle (default: ./sdk-out/livekit-sdk) @@ -75,6 +78,14 @@ parse_opts() { shift || true while [[ $# -gt 0 ]]; do case "$1" in + --capture) + CAPTURE_OVERRIDE="ON" + shift + ;; + --no-capture) + CAPTURE_OVERRIDE="OFF" + shift + ;; --bundle) DO_BUNDLE="1" shift @@ -136,6 +147,9 @@ parse_opts() { configure() { echo "==> Configuring CMake (${BUILD_TYPE}) using preset ${PRESET}..." local -a extra_args=() + if [[ -n "${CAPTURE_OVERRIDE:-}" ]]; then + extra_args+=("-DLIVEKIT_ENABLE_CAPTURE=${CAPTURE_OVERRIDE}") + fi if [[ -n "${LIVEKIT_VERSION}" ]]; then echo "==> Injecting LIVEKIT_VERSION=${LIVEKIT_VERSION}" extra_args+=("-DLIVEKIT_VERSION=${LIVEKIT_VERSION}") diff --git a/client-sdk-rust b/client-sdk-rust index 31eeea76..35d7655c 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit 31eeea76246e89424a655888ca0af175edc0ad37 +Subproject commit 35d7655c580147c2ad05fc8ffa5d885e88c822b9 diff --git a/cmake/run_cargo.cmake b/cmake/run_cargo.cmake new file mode 100644 index 00000000..06be61fb --- /dev/null +++ b/cmake/run_cargo.cmake @@ -0,0 +1,59 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +if(NOT DEFINED CFG) + set(CFG Debug) +endif() +if(NOT DEFINED RUST_ROOT) + message(FATAL_ERROR "RUST_ROOT not set") +endif() +if(NOT DEFINED CARGO) + message(FATAL_ERROR "CARGO not set") +endif() + +set(ARGS build --package livekit-ffi) +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}") +endif() + +message(STATUS "[run_cargo.cmake] CFG=${CFG} CARGO=${CARGO} PROTOC=${PROTOC_PATH}") +set(ENV{PROTOC} "${PROTOC_PATH}") + +if(DEFINED CARGO_TARGET_DIR AND NOT CARGO_TARGET_DIR STREQUAL "") + set(ENV{CARGO_TARGET_DIR} "${CARGO_TARGET_DIR}") +endif() + +if(DEFINED GCC_LIB_DIR AND NOT GCC_LIB_DIR STREQUAL "") + set(ENV{RUSTFLAGS} "-L ${GCC_LIB_DIR} $ENV{RUSTFLAGS}") + set(ENV{LD_LIBRARY_PATH} "${GCC_LIB_DIR}:$ENV{LD_LIBRARY_PATH}") +endif() + +execute_process( + COMMAND "${CARGO}" ${ARGS} + WORKING_DIRECTORY "${RUST_ROOT}" + RESULT_VARIABLE rv +) +if(rv) + message(FATAL_ERROR "cargo build failed with code: ${rv}") +endif() diff --git a/cmake/uniffi_cpp.cmake b/cmake/uniffi_cpp.cmake index eb3fcc2f..574b9a32 100644 --- a/cmake/uniffi_cpp.cmake +++ b/cmake/uniffi_cpp.cmake @@ -26,22 +26,12 @@ set(LIVEKIT_UNIFFI_CPP_SCAFFOLDING_HEADER # the selected CMake configuration. if(UNIX AND NOT APPLE) set(LIVEKIT_UNIFFI_METADATA_TARGET_DIR - "${RUST_ROOT}/target/uniffi-cpp-metadata") + "${LIVEKIT_CARGO_TARGET_DIR}/uniffi-cpp-metadata") set(LIVEKIT_UNIFFI_METADATA_LIBRARY "${LIVEKIT_UNIFFI_METADATA_TARGET_DIR}/debug/liblivekit_ffi.so") endif() -file(GLOB_RECURSE LIVEKIT_UNIFFI_RUST_SOURCES CONFIGURE_DEPENDS - "${RUST_ROOT}/livekit-ffi/src/*.rs" - "${RUST_ROOT}/livekit-ffi/protocol/*.proto" - "${RUST_ROOT}/livekit-ffi/build.rs" - "${RUST_ROOT}/livekit-ffi/Cargo.toml" -) -list(APPEND LIVEKIT_UNIFFI_RUST_SOURCES - "${RUST_ROOT}/Cargo.toml" - "${RUST_ROOT}/Cargo.lock" - "${RUST_ROOT}/rust-toolchain.toml" -) +set(LIVEKIT_UNIFFI_RUST_SOURCES ${RUST_SOURCES}) file(GLOB_RECURSE LIVEKIT_UNIFFI_BINDGEN_SOURCES CONFIGURE_DEPENDS "${RUST_ROOT}/tools/bindgens/src/*.rs" @@ -58,9 +48,10 @@ if(UNIX AND NOT APPLE) -DPROTOC_PATH=${Protobuf_PROTOC_EXECUTABLE} -DGCC_LIB_DIR=${GCC_LIB_DIR} -DCARGO_TARGET_DIR=${LIVEKIT_UNIFFI_METADATA_TARGET_DIR} + -DCARGO_FEATURES=${LIVEKIT_CARGO_FEATURES} -P "${RUN_CARGO_SCRIPT}" WORKING_DIRECTORY "${RUST_ROOT}" - DEPENDS ${LIVEKIT_UNIFFI_RUST_SOURCES} + DEPENDS ${LIVEKIT_UNIFFI_RUST_SOURCES} "${RUN_CARGO_SCRIPT}" COMMENT "Building unstripped livekit-ffi metadata library" VERBATIM ) diff --git a/docs/README.md b/docs/README.md index d4f68c8b..ab5bc305 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. +- [Capture sources](capture-sources.md) — pattern, clock, and GStreamer sources; + build features, release bundles, and publishing. - [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..a54e76e7 100644 --- a/docs/building.md +++ b/docs/building.md @@ -348,6 +348,30 @@ cmake --build build --target clean_generated # Generated protobuf headers cmake --build build --target clean_all # Full clean ``` +## Capture sources + +Standard `debug`, `release`, and `*-examples` builds disable capture. The +`*-tests` and `*-all` presets enable it by default. Both build scripts accept +`--capture` and `--no-capture` to override the preset: + +```bash +./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture +``` + +On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. +Capture builds enable GStreamer, GPU test patterns, and the GPU wall clock. +They require GStreamer development libraries at build time and its runtime +libraries when the SDK is loaded. Release artifacts with `-capture` in their +names include capture; standard artifacts disable it. + +Rust artifacts are stored under `client-sdk-rust/target/cpp-/`, +with separate directories for capture-enabled and disabled builds. Generated +`build.h` files live in each CMake build directory, so feature metadata follows +the installed SDK instead of another configuration's most recent build. + +See [Capture sources](capture-sources.md) for dependencies and usage. + + ## Support - GitHub Issues: diff --git a/docs/capture-sources.md b/docs/capture-sources.md new file mode 100644 index 00000000..9a20cce8 --- /dev/null +++ b/docs/capture-sources.md @@ -0,0 +1,102 @@ +# Capture sources + +`CaptureSource` connects a Rust video producer to a regular C++ `VideoSource`. +The capture pump runs in Rust, so C++ does not send one FFI request per frame. +The SDK supports three source configurations: + +| Configuration | Output | Runtime requirements | +| --- | --- | --- | +| `PatternVideoSourceConfig` | GPU-rendered gradient or LiveKit logo | Compatible GPU adapter | +| `ClockVideoSourceConfig` | GPU-rendered local time with millisecond precision | Compatible GPU adapter | +| `GstreamerVideoSourceConfig` | Pre-encoded video from a GStreamer pipeline | GStreamer runtime and the pipeline's plugins | + +Native camera device capture and device enumeration are not part of this API. + +## Build and release bundles + +Only the repository's `*-tests` and `*-all` presets enable capture by default. +The `debug`, `release`, and `*-examples` presets disable it. Use `--capture` +with any build command to enable all three sources, or `--no-capture` to disable +them explicitly: + +```bash +./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture +./build.sh debug-tests --no-capture +``` + +On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. +Custom CMake configurations can set `LIVEKIT_ENABLE_CAPTURE=ON`. The build enables +`capture-gstreamer`, `capture-pattern`, and `capture-clock` in the Rust FFI. + +Release downloads with `-capture` in their names include these features, for +example `livekit-sdk-macos-arm64-capture-.tar.gz`. Standard release +bundles have capture disabled. The generated `livekit::kCaptureEnabled` constant +and `share/livekit/build-info.json` describe the selected bundle's build features; +they do not test whether a GPU or a requested GStreamer plugin is available. + +Building a capture SDK requires GStreamer development libraries accessible to +`pkg-config`. On macOS, install `gstreamer` with Homebrew. On Ubuntu, install +`libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, plus the runtime +plugins your pipeline uses. On Windows, install the MSVC runtime and development +packages from the [GStreamer downloads page](https://gstreamer.freedesktop.org/download/) +and put the installation's `bin` directory on `PATH` and `lib/pkgconfig` directory +on `PKG_CONFIG_PATH`. + +Capture bundles require GStreamer at runtime, including when using pattern or +clock sources. The SDK does not redistribute GStreamer libraries or plugins. + +## Create and publish a source + +Dimensions must be positive. Pattern and clock frame rates must also be positive. +Invalid configurations produce `CaptureSourceError` when the returned future is +awaited. For GStreamer, resolution and codec can be omitted to discover them from +negotiated pipeline caps. + +```cpp +#include + +// The SDK is initialized, and room is connected. +auto pending = livekit::CaptureSource::create( + livekit::PatternVideoSourceConfig{{1280, 720}, 30, livekit::Pattern::Gradient}); +// Other application setup can run while the producer is being constructed. +auto capture = pending.get(); +// For a wall clock, use ClockVideoSourceConfig{{1280, 720}, 30} instead. + +auto track = livekit::LocalVideoTrack::createLocalVideoTrack("capture", capture->videoSource()); +auto participant = room.localParticipant().lock(); +if (!participant) { + return; +} +participant->publishTrack(track, capture->publishOptions()); + +capture->setOnFinishedCallback([](const livekit::CaptureResult& result) { + // Runs on the FFI callback thread. Offload work that would block. +}); +if (!capture->start()) { + // Handle the start failure. +} +``` + +Keep `capture` alive while its track is published. `stop()` signals the pump to +finish. The callback reports the exit reason and frame count. Destroying the +source also stops capture. + +## GStreamer encoded pipelines + +Provide `appsink name=lk_appsink`, or leave exactly one encoded video source pad +unlinked so the source can attach its own sink: + +```cpp +livekit::GstreamerVideoSourceConfig config; +config.pipeline = "videotestsrc ! video/x-raw,width=1280,height=720,framerate=30/1 ! " + "vp8enc name=lk_encoder deadline=1 ! appsink name=lk_appsink"; +config.rate_control = livekit::GstreamerRateControl{ + "lk_encoder", "target-bitrate", livekit::GstreamerBitrateUnit::Bps}; +auto capture = livekit::CaptureSource::create(config).get(); +``` + +The optional rate-control binding forwards WebRTC bitrate targets to the named +encoder property. Without it, the pipeline uses its configured fixed bitrate. +Use `capture->publishOptions()` when publishing: encoded sources require the +source's codec and encoder settings, which override conflicting application +options. diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index 459ada82..1d6401ee 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -20,14 +20,11 @@ #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" @@ -37,322 +34,125 @@ namespace livekit { namespace proto { class NewCaptureSourceRequest; class OwnedCaptureSource; -class CaptureDeviceList; } // namespace proto -/// Error raised when capture source creation or control fails. +/// @brief Error raised when capture source creation or control fails. class LIVEKIT_API CaptureSourceError : public std::runtime_error { public: - /// Create a capture source error. + /// @brief Create a capture source error. /// /// @param message Human-readable error message. explicit CaptureSourceError(const std::string& message) : std::runtime_error(message) {} }; -/// Kind of media a capture source produces. +/// @brief Kind of media a capture source produces. enum class CaptureSourceKind { - /// Pixel frames, published through the WebRTC encoder. + /// @brief Pixel frames, published through the WebRTC encoder. Pixel = 0, - /// Pre-encoded access units, published as passthrough. + /// @brief Pre-encoded access units, published as passthrough. Encoded = 1, }; -/// Bitrate unit expected by a GStreamer encoder property. +/// @brief Bitrate unit expected by a GStreamer encoder property. enum class GstreamerBitrateUnit { - /// Bits per second. + /// @brief Bits per second. Bps = 0, - /// Kilobits per second. + /// @brief Kilobits per second. Kbps = 1, }; -/// Video resolution in pixels. +/// @brief Video resolution in pixels. struct CaptureResolution { int width = 0; int height = 0; }; -/// Binding from WebRTC rate-control targets to a GStreamer encoder property. +/// @brief Binding from WebRTC rate-control targets to a GStreamer encoder property. struct GstreamerRateControl { - /// Name of the encoder element in the pipeline (e.g. `lk_encoder`). + /// @brief Name of the encoder element in the pipeline (e.g. `lk_encoder`). std::string element; - /// Bitrate property to set on the element (e.g. `bitrate` for x264enc, + /// @brief Bitrate property to set on the element (e.g. `bitrate` for x264enc, /// `target-bitrate` for vp8enc/vp9enc). std::string property; - /// Unit the property expects. + /// @brief Unit the property expects. GstreamerBitrateUnit unit = GstreamerBitrateUnit::Bps; }; -/// Configuration for encoded ingest from a GStreamer pipeline. +/// @brief Configuration for encoded ingest from a GStreamer pipeline. struct GstreamerVideoSourceConfig { - /// GStreamer launch description for the encoded producer pipeline. + /// @brief GStreamer launch description for the encoded producer pipeline. /// /// @note Must contain `appsink name=lk_appsink`, or leave exactly one encoded /// video source pad unlinked for the source to attach one to. std::string pipeline; - /// Codec expected from the pipeline; inferred from pipeline caps when + /// @brief Codec expected from the pipeline; inferred from pipeline caps when /// omitted. std::optional codec; - /// Encoded frame resolution. When omitted, it is discovered from the + /// @brief Encoded frame resolution. When omitted, it is discovered from the /// pipeline's negotiated caps; when set, the pipeline output is verified /// against it. std::optional resolution; - /// Forwards WebRTC rate-control targets to an encoder element's bitrate + /// @brief Forwards WebRTC rate-control targets to an encoder element's bitrate /// property. Without this, the pipeline encodes at a fixed bitrate. std::optional rate_control; }; -/// Test pattern rendered by the built-in pattern source. +/// @brief Test pattern rendered by the built-in pattern source. enum class Pattern { - /// Animated color gradient. + /// @brief Animated color gradient. Gradient = 0, - /// Bouncing LiveKit logo. + /// @brief Bouncing LiveKit logo. Logo = 1, }; /// Configuration for the built-in test source rendering a @ref Pattern. struct PatternVideoSourceConfig { - /// Output resolution; both components must be non-zero. + /// @brief Output resolution; both components must be positive. CaptureResolution resolution; - /// Output frame rate in frames per second; must be non-zero. + /// @brief Output frame rate in frames per second; must be non-zero. std::uint32_t framerate_fps = 0; - /// Pattern to render. + /// @brief Pattern to render. Pattern pattern = Pattern::Gradient; }; -/// Frame format delivered by a capture device. -enum class DeviceFrameFormat { - /// Planar I420/YUV420P. - I420 = 0, - /// Biplanar NV12. - NV12 = 1, - /// Packed BGRA. - BGRA = 2, - /// Packed RGB24. - RGB24 = 3, - /// Packed BGR24. - BGR24 = 4, - /// Packed YUYV/YUY2. - YUYV = 5, - /// Packed UYVY. - UYVY = 6, - /// Single-plane 8-bit luma. - GREY = 7, - /// Encoded MJPEG frames. - MJPEG = 8, -}; - -/// Capture format offered by or requested from a device. -/// -/// Width, height, and frame rate must be non-zero. The frame format defaults -/// to @ref DeviceFrameFormat::NV12, the only value accepted by both device -/// backends. -struct DeviceFormat { - /// Frame dimensions. +/// @brief Wall clock with millisecond precision, rendered on the GPU. +/// Shows the local time of the machine running the FFI server. +struct ClockVideoSourceConfig { + /// @brief Output resolution; both components must be positive. CaptureResolution resolution; - /// Frame rate in frames per second. + /// @brief Output frame rate in frames per second; must be non-zero. std::uint32_t framerate_fps = 0; - - /// Frame format. - DeviceFrameFormat frame_format = DeviceFrameFormat::NV12; - - DeviceFormat() = default; - - DeviceFormat(CaptureResolution resolution, std::uint32_t framerate_fps, DeviceFrameFormat frame_format) - : resolution(resolution), framerate_fps(framerate_fps), frame_format(frame_format) {} -}; - -/// How a capture device should pick the format it delivers. -/// -/// The device negotiates the delivered format; the created source reports the -/// negotiated resolution through @c width() and @c height(). The negotiated -/// frame rate and frame format are not reported back. -/// -/// @note Only resolution and frame rate participate in format selection. -/// A @c frame_format is validated and then treated as a preference the backend -/// may substitute, so the delivered format can differ from the one requested: -/// -/// - macOS accepts only @ref DeviceFrameFormat::I420, -/// @ref DeviceFrameFormat::NV12, and @ref DeviceFrameFormat::BGRA -- -/// requesting any other value fails creation -- and then delivers NV12 -/// regardless of which of the three was asked for. -/// - V4L2 rejects @ref DeviceFrameFormat::I420 and @ref DeviceFrameFormat::BGRA. -/// For @ref exact and @ref closest it tries the requested format first and -/// then falls back through the formats it supports; for @ref highestFramerate -/// and @ref highestResolution the constraint selects the format outright, with -/// no fallback. -/// -/// @ref DeviceFrameFormat::NV12 is the only value both backends accept, which -/// is why it is @ref DeviceFormat's default. -class LIVEKIT_API DeviceFormatRequest { -public: - /// Optional constraints for @ref highestFramerate. - struct HighestFramerateConstraint { - /// Only consider formats with this resolution. - std::optional resolution; - /// Only consider formats with this frame format. - std::optional frame_format; - }; - - /// Optional constraints for @ref highestResolution. - struct HighestResolutionConstraint { - /// Only consider formats with this frame rate. - std::optional framerate_fps; - /// Only consider formats with this frame format. - std::optional frame_format; - }; - - /// Let the device choose its default format. - DeviceFormatRequest() = default; - - /// Require the resolution to match exactly; creation fails if the device - /// offers no such format. - /// - /// Frame-rate matching is rounding-tolerant, because devices commonly - /// advertise near-integral rates: a request for 30 fps is satisfied by a - /// device advertising 30.00003 fps, and the device is then driven at its - /// advertised rate. - /// - /// @param format Required resolution, frame rate, and preferred frame format. - /// @return A request that requires the device to satisfy @p format. - static DeviceFormatRequest exact(DeviceFormat format); - - /// Use the device's closest supported resolution and frame rate. - /// - /// @param format Preferred resolution, frame rate, and frame format. - /// @return A request that selects the closest supported format to @p format. - static DeviceFormatRequest closest(DeviceFormat format); - - /// Prefer the highest frame rate, optionally constrained. - /// - /// @param constraint Optional resolution and frame-format constraints. - /// @return A request that selects the highest frame rate satisfying @p constraint. - static DeviceFormatRequest highestFramerate(HighestFramerateConstraint constraint = {}); - - /// Prefer the highest resolution, optionally constrained. - /// - /// @param constraint Optional frame-rate and frame-format constraints. - /// @return A request that selects the highest resolution satisfying @p constraint. - static DeviceFormatRequest highestResolution(HighestResolutionConstraint constraint = {}); - -private: - friend class CaptureSource; - - /// Which selection strategy this request carries. - enum class Kind { Default, Exact, Closest, HighestFramerate, HighestResolution }; - - Kind kind_ = Kind::Default; - DeviceFormat format_; - HighestFramerateConstraint highest_framerate_; - HighestResolutionConstraint highest_resolution_; -}; - -/// Selects which video device a capture source opens. -class LIVEKIT_API DeviceSelector { -public: - /// The platform default video device. - DeviceSelector() = default; - - /// The device at this position in the platform's own enumeration order. - /// - /// The meaning is platform-defined and is not necessarily the position of - /// the device in @ref CaptureSource::listDevices(). On macOS, it indexes the - /// AVFoundation video-device list; on Linux, it is the V4L2 node number - /// (@c /dev/videoN). Either way the value can shift as devices are attached - /// and removed. Prefer @ref id, which is stable on macOS. - /// - /// @param device_index Platform-defined device index. - /// @return A selector that targets the device at @p device_index. - static DeviceSelector index(std::uint32_t device_index); - - /// A platform-stable identifier, as reported by @ref CaptureDeviceInfo::id. - /// - /// @param device_id Platform device identifier. - /// @return A selector that targets the device with @p device_id. - static DeviceSelector id(std::string device_id); - -private: - friend class CaptureSource; - - /// Which form of device selection this carries. - enum class Kind { Default, Index, Id }; - - Kind kind_ = Kind::Default; - std::uint32_t index_ = 0; - std::string id_; -}; - -/// Configuration for camera device capture using the platform's native -/// capture stack. -struct DeviceVideoSourceConfig { - /// Device to capture from; the platform default device when unset. - DeviceSelector device; - - /// Format requested from the device; the device default when unset. - DeviceFormatRequest format; -}; - -/// A video capture device discovered by @ref CaptureSource::listDevices. -struct CaptureDeviceInfo { - /// Device identifier; pass to @ref DeviceSelector::id. - /// - /// Stable across reboots and re-plugging on macOS, where it is the - /// AVFoundation unique id. On V4L2 it is the node number, so it can change - /// as devices are attached and removed; re-enumerate rather than persisting - /// it there. - std::string id; - - /// Human-readable device name. - std::string name; - - /// Device model identifier, when available. - std::optional model_id; - - /// Device manufacturer, when available. - std::optional manufacturer; - - /// Capture formats reported by the device. - /// - /// Empty unless @ref formats_complete is true. Do not use this to decide - /// what to request: ask for a format and let the device negotiate. - std::vector formats; - - /// Whether @ref formats is a complete list; some platforms do not - /// enumerate formats up front. - /// - /// False on macOS, where AVFoundation formats are not enumerated; true on - /// V4L2. - bool formats_complete = false; }; -/// Why a capture ended without error. +/// @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. + /// @brief The producer reached the end of its stream. EndOfStream = 1, }; -/// Terminal result of a started capture. +/// @brief Terminal result of a started capture. struct CaptureResult { - /// Error message when the capture failed; empty on success. + /// @brief Error message when the capture failed; empty on success. std::optional error; - /// Number of frames or access units captured. + /// @brief 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; }; -/// A server-side capture source (livekit-capture) that produces video +/// @brief A server-side capture source (livekit-capture) that produces video /// without per-frame FFI traffic. /// /// The source owns its producer (e.g. a GStreamer pipeline) and the pump @@ -360,18 +160,19 @@ struct CaptureResult { /// 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` feature -/// (`LIVEKIT_ENABLE_CAPTURE`); otherwise creation fails. +/// Requires a capture-enabled SDK build (`LIVEKIT_ENABLE_CAPTURE`), with +/// `capture-gstreamer`, `capture-pattern`, and `capture-clock` Rust features. +/// Otherwise creation fails. /// /// @note Keep this object alive while the track is published. Destroying it /// stops a running capture. class LIVEKIT_API CaptureSource { public: - /// Terminal notification for a started capture, invoked exactly once on + /// @brief Terminal notification for a started capture, invoked exactly once on /// the FFI event thread (like room delegate callbacks). using FinishedCallback = std::function; - /// Create a capture source from a GStreamer pipeline configuration. + /// @brief Create a capture source from a GStreamer pipeline configuration. /// /// Completes asynchronously: construction starts the pipeline and may wait /// for its first output to discover stream settings. Errors (invalid @@ -383,36 +184,18 @@ class LIVEKIT_API CaptureSource { /// @throws CaptureSourceError When awaiting the future if creation fails. static std::future> create(GstreamerVideoSourceConfig config); - /// Create the built-in test pattern capture source. + /// @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); - /// Create a capture source from a camera device. - /// - /// Completes asynchronously: construction opens the device and negotiates - /// the capture format, so @c width() and @c height() report the negotiated - /// resolution before the first frame is pumped. Errors (missing device, - /// unsatisfiable - /// format request, unsupported platform, missing capture feature) are - /// thrown from the future as @ref CaptureSourceError. - /// - /// @param config Device capture configuration. + /// @brief Create the built-in wall clock capture source. + /// @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(DeviceVideoSourceConfig config); - - /// List the video capture devices available on this machine. - /// - /// Completes asynchronously: enumeration queries the platform capture stack - /// and may block briefly. Throws @ref CaptureSourceError from the future on - /// failure, including on platforms without a capture backend. - /// - /// @return A future that resolves to the discovered devices. - /// @throws CaptureSourceError When awaiting the future if enumeration fails. - static std::future> listDevices(); + static std::future> create(ClockVideoSourceConfig config); ~CaptureSource(); @@ -421,37 +204,47 @@ class LIVEKIT_API CaptureSource { CaptureSource(CaptureSource&&) = delete; CaptureSource& operator=(CaptureSource&&) = delete; - /// Kind of media this source produces. - CaptureSourceKind kind() const noexcept { return kind_; } + /// @brief Kind of media this source produces. + /// @return The source kind. + CaptureSourceKind kind() const noexcept; + + /// @brief Declared or discovered stream resolution. + /// @return Width in pixels. + int width() const noexcept; - /// Declared or discovered stream resolution. - int width() const noexcept { return width_; } - int height() const noexcept { return height_; } + /// @brief Declared or discovered stream height. + /// @return Height in pixels. + int height() const noexcept; - /// Codec produced by the source; encoded sources only. - std::optional codec() const noexcept { return codec_; } + /// @brief Codec produced by the source; encoded sources only. + /// @return The codec, or no value for a pixel source. + std::optional codec() const noexcept; - /// RTC video source fed by this capture source; use it with + /// @brief RTC video source fed by this capture source; use it with /// LocalVideoTrack::createLocalVideoTrack(). - std::shared_ptr videoSource() const noexcept { return video_source_; } + /// @return The RTC video source. + std::shared_ptr videoSource() const noexcept; - /// Returns publish options for this track, applying application options. + /// @brief Returns publish options for this track, applying application options. /// /// Fields the source dictates (e.g. codec, encoder backend, and simulcast /// for encoded sources) are required for correct publication and override /// the application values; all other fields are taken from @p options /// unchanged. + /// @param options Application publish options. + /// @return Options with source-required fields applied. TrackPublishOptions publishOptions(TrackPublishOptions options = {}) const; - /// Set the terminal notification. Set this before @ref start(). + /// @brief Set the terminal notification. Set this before @ref start(). + /// @param callback Terminal notification, called on the FFI callback thread. void setOnFinishedCallback(FinishedCallback callback); - /// Start pumping frames into the RTC video source. + /// @brief Start pumping frames into the RTC video source. /// /// @return @c true if the start request succeeds; otherwise @c false. bool start(); - /// Signal a running capture to stop after the frame in flight. The + /// @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. /// @@ -459,27 +252,17 @@ class LIVEKIT_API CaptureSource { bool stop(); private: - CaptureSource() = default; + CaptureSource(); - /// Shared creation path: sends the request and maps the callback payload + /// @brief Shared creation path: sends the request and maps the callback payload /// onto a wrapper instance. static std::future> createFromRequest(proto::NewCaptureSourceRequest request); - /// Builds the wrapper from the callback payload, adopting its handles. + /// @brief Builds the wrapper from the callback payload, adopting its handles. 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_; - FinishedCallback on_finished_; - int listener_id_ = 0; + struct Impl; + std::unique_ptr impl_; }; } // namespace livekit diff --git a/scripts/generate-docs.sh b/scripts/generate-docs.sh index 60ba5588..49a9e6c4 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/authentication.md docs/capture-sources.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 index 3379b207..68c3c543 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -16,6 +16,7 @@ #include "livekit/capture_source.h" +#include #include #include "capture.pb.h" @@ -27,6 +28,27 @@ namespace livekit { +struct CaptureSource::Impl { + FfiHandle handle; + CaptureSourceKind kind = CaptureSourceKind::Pixel; + int width = 0; + int height = 0; + std::optional codec; + std::shared_ptr video_source; + TrackPublishOptions source_publish_options; + std::mutex callback_mutex; + FinishedCallback on_finished; + int listener_id = 0; +}; + +CaptureSource::CaptureSource() : impl_(std::make_unique()) {} + +CaptureSourceKind CaptureSource::kind() const noexcept { return impl_->kind; } +int CaptureSource::width() const noexcept { return impl_->width; } +int CaptureSource::height() const noexcept { return impl_->height; } +std::optional CaptureSource::codec() const noexcept { return impl_->codec; } +std::shared_ptr CaptureSource::videoSource() const noexcept { return impl_->video_source; } + namespace { /// A already-failed future carrying @p error as a @ref CaptureSourceError. @@ -40,99 +62,17 @@ std::future readyCaptureError(const std::exception& error) { 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) { +void validateResolution(const CaptureResolution& resolution) { 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::DeviceFrameFormat toProto(DeviceFrameFormat format) { - switch (format) { - case DeviceFrameFormat::I420: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_I420; - case DeviceFrameFormat::NV12: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_NV12; - case DeviceFrameFormat::BGRA: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGRA; - case DeviceFrameFormat::RGB24: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_RGB24; - case DeviceFrameFormat::BGR24: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGR24; - case DeviceFrameFormat::YUYV: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_YUYV; - case DeviceFrameFormat::UYVY: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_UYVY; - case DeviceFrameFormat::GREY: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_GREY; - case DeviceFrameFormat::MJPEG: - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_MJPEG; + throw CaptureSourceError("Capture resolution dimensions must be positive"); } - return proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_I420; } -DeviceFrameFormat fromProto(proto::DeviceFrameFormat format) { - switch (format) { - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_I420: - return DeviceFrameFormat::I420; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_NV12: - return DeviceFrameFormat::NV12; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGRA: - return DeviceFrameFormat::BGRA; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_RGB24: - return DeviceFrameFormat::RGB24; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_BGR24: - return DeviceFrameFormat::BGR24; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_YUYV: - return DeviceFrameFormat::YUYV; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_UYVY: - return DeviceFrameFormat::UYVY; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_GREY: - return DeviceFrameFormat::GREY; - case proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_MJPEG: - return DeviceFrameFormat::MJPEG; +void validatePixelConfig(const CaptureResolution& resolution, std::uint32_t framerate_fps) { + validateResolution(resolution); + if (framerate_fps == 0) { + throw CaptureSourceError("Capture frame rate must be positive"); } - return DeviceFrameFormat::I420; -} - -void toProto(const DeviceFormat& format, proto::DeviceFormat* out) { - validateResolution(format.resolution, "DeviceFormat::resolution"); - out->mutable_resolution()->set_width(format.resolution.width); - out->mutable_resolution()->set_height(format.resolution.height); - out->set_framerate_fps(format.framerate_fps); - out->set_frame_format(toProto(format.frame_format)); -} - -DeviceFormat fromProto(const proto::DeviceFormat& format) { - DeviceFormat out; - out.resolution.width = static_cast(format.resolution().width()); - out.resolution.height = static_cast(format.resolution().height()); - out.framerate_fps = format.framerate_fps(); - out.frame_format = fromProto(format.frame_format()); - return out; -} - -CaptureDeviceInfo fromProto(const proto::CaptureDeviceInfo& info) { - CaptureDeviceInfo out; - out.id = info.id(); - out.name = info.name(); - if (info.has_model_id()) { - out.model_id = info.model_id(); - } - if (info.has_manufacturer()) { - out.manufacturer = info.manufacturer(); - } - out.formats.reserve(static_cast(info.formats_size())); - for (const proto::DeviceFormat& format : info.formats()) { - out.formats.push_back(fromProto(format)); - } - out.formats_complete = info.formats_complete(); - return out; } proto::GstreamerBitrateUnit toProto(GstreamerBitrateUnit unit) { @@ -171,19 +111,21 @@ CaptureResult resultFromEvent(const proto::CaptureSourceEvent& event) { } // namespace -std::vector fromProto(const proto::CaptureDeviceList& list) { - std::vector devices; - devices.reserve(static_cast(list.devices_size())); - for (const proto::CaptureDeviceInfo& device : list.devices()) { - devices.push_back(fromProto(device)); +std::future> CaptureSource::create(GstreamerVideoSourceConfig config) { + try { + return createFromRequest(toProto(std::move(config))); + } catch (const CaptureSourceError& error) { + return readyCaptureError>(error); } - return devices; } -std::future> CaptureSource::create(GstreamerVideoSourceConfig config) { +proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config) { + if (config.resolution) { + validateResolution(*config.resolution); + } proto::NewCaptureSourceRequest request; auto* gstreamer = request.mutable_gstreamer(); - gstreamer->set_pipeline(config.pipeline); + gstreamer->set_pipeline(std::move(config.pipeline)); if (config.codec) { gstreamer->set_codec(static_cast(*config.codec)); } @@ -193,152 +135,52 @@ std::future> CaptureSource::create(GstreamerVideo } if (config.rate_control) { auto* rate_control = gstreamer->mutable_rate_control(); - rate_control->set_element(config.rate_control->element); - rate_control->set_property(config.rate_control->property); + rate_control->set_element(std::move(config.rate_control->element)); + rate_control->set_property(std::move(config.rate_control->property)); rate_control->set_unit(toProto(config.rate_control->unit)); } - return createFromRequest(std::move(request)); + return request; } std::future> CaptureSource::create(PatternVideoSourceConfig config) { + try { + return createFromRequest(toProto(config)); + } catch (const CaptureSourceError& error) { + return readyCaptureError>(error); + } +} + +proto::NewCaptureSourceRequest toProto(const PatternVideoSourceConfig& config) { + validatePixelConfig(config.resolution, config.framerate_fps); 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); pattern->set_pattern(toProto(config.pattern)); - return createFromRequest(std::move(request)); -} - -DeviceFormatRequest DeviceFormatRequest::exact(DeviceFormat format) { - DeviceFormatRequest request; - request.kind_ = Kind::Exact; - request.format_ = format; return request; } -DeviceFormatRequest DeviceFormatRequest::closest(DeviceFormat format) { - DeviceFormatRequest request; - request.kind_ = Kind::Closest; - request.format_ = format; - return request; -} - -DeviceFormatRequest DeviceFormatRequest::highestFramerate(HighestFramerateConstraint constraint) { - DeviceFormatRequest request; - request.kind_ = Kind::HighestFramerate; - request.highest_framerate_ = constraint; - return request; -} - -DeviceFormatRequest DeviceFormatRequest::highestResolution(HighestResolutionConstraint constraint) { - DeviceFormatRequest request; - request.kind_ = Kind::HighestResolution; - request.highest_resolution_ = constraint; - return request; -} - -DeviceSelector DeviceSelector::index(std::uint32_t device_index) { - DeviceSelector selector; - selector.kind_ = Kind::Index; - selector.index_ = device_index; - return selector; -} - -DeviceSelector DeviceSelector::id(std::string device_id) { - DeviceSelector selector; - selector.kind_ = Kind::Id; - selector.id_ = std::move(device_id); - return selector; -} - -std::future> CaptureSource::create(DeviceVideoSourceConfig config) { - proto::NewCaptureSourceRequest request; - auto* device = request.mutable_device(); - - switch (config.device.kind_) { - case DeviceSelector::Kind::Default: - // Leave the `device` oneof unset for the platform default. - break; - case DeviceSelector::Kind::Index: - device->set_device_index(config.device.index_); - break; - case DeviceSelector::Kind::Id: - device->set_device_id(config.device.id_); - break; - } - - // Leave `format` unset for the device default. An absent field and an empty - // DeviceFormatRequest are equivalent -- the server collapses both to - // DeviceFormatRequest::Default -- but omitting it keeps has_format() - // meaningful. What is not interchangeable is an empty *inner* message: - // highest_framerate{} and highest_resolution{} select a format, so they must - // be emitted even with no constraints set. - const DeviceFormatRequest& format = config.format; - switch (format.kind_) { - case DeviceFormatRequest::Kind::Default: - break; - case DeviceFormatRequest::Kind::Exact: - toProto(format.format_, device->mutable_format()->mutable_exact()); - break; - case DeviceFormatRequest::Kind::Closest: - toProto(format.format_, device->mutable_format()->mutable_closest()); - break; - case DeviceFormatRequest::Kind::HighestFramerate: { - auto* highest = device->mutable_format()->mutable_highest_framerate(); - if (format.highest_framerate_.resolution) { - validateResolution(*format.highest_framerate_.resolution, "HighestFramerateConstraint::resolution"); - highest->mutable_resolution()->set_width(format.highest_framerate_.resolution->width); - highest->mutable_resolution()->set_height(format.highest_framerate_.resolution->height); - } - if (format.highest_framerate_.frame_format) { - highest->set_frame_format(toProto(*format.highest_framerate_.frame_format)); - } - break; - } - case DeviceFormatRequest::Kind::HighestResolution: { - auto* highest = device->mutable_format()->mutable_highest_resolution(); - if (format.highest_resolution_.framerate_fps) { - highest->set_framerate_fps(*format.highest_resolution_.framerate_fps); - } - if (format.highest_resolution_.frame_format) { - highest->set_frame_format(toProto(*format.highest_resolution_.frame_format)); - } - break; - } - } - - return createFromRequest(std::move(request)); -} - -std::future> CaptureSource::listDevices() { - // Issuing the request can fail synchronously (an FFI library built without - // the capture feature rejects it outright). Report that through the returned - // future so every failure reaches the caller as a CaptureSourceError from - // exactly one place, as documented. - std::future devices; +std::future> CaptureSource::create(ClockVideoSourceConfig config) { try { - devices = FfiClient::instance().listCaptureDevicesAsync(); - } catch (const std::exception& e) { - return readyCaptureError>(e); + return createFromRequest(toProto(config)); + } catch (const CaptureSourceError& error) { + return readyCaptureError>(error); } +} - // Map the FFI payload onto the public type once the callback resolves. A - // helper thread keeps the returned future's wait semantics standard. - return std::async(std::launch::async, [devices = std::move(devices)]() mutable { - try { - const proto::CaptureDeviceList list = devices.get(); - return fromProto(list); - } catch (const CaptureSourceError&) { - throw; - } catch (const std::exception& e) { - throw CaptureSourceError(e.what()); - } - }); +proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config) { + validatePixelConfig(config.resolution, config.framerate_fps); + 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 request; } std::future> CaptureSource::createFromRequest(proto::NewCaptureSourceRequest request) { - // See listDevices(): a synchronous send failure is delivered through the + // A synchronous send failure is delivered through the // future rather than thrown from the factory. std::future owned; try { @@ -364,33 +206,34 @@ std::shared_ptr CaptureSource::fromOwned(const proto::OwnedCaptur const proto::CaptureSourceInfo& info = owned.info(); std::shared_ptr source(new CaptureSource()); - source->handle_ = FfiHandle(static_cast(owned.handle().id())); - 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()); + source->impl_->handle = FfiHandle(static_cast(owned.handle().id())); + source->impl_->kind = info.kind() == proto::CaptureSourceKind::CAPTURE_SOURCE_ENCODED ? CaptureSourceKind::Encoded + : CaptureSourceKind::Pixel; + source->impl_->width = static_cast(info.resolution().width()); + source->impl_->height = static_cast(info.resolution().height()); if (info.has_codec()) { - source->codec_ = static_cast(info.codec()); + source->impl_->codec = static_cast(info.codec()); } - source->source_publish_options_ = fromProto(info.recommended_publish_options()); - source->video_source_ = std::shared_ptr(new VideoSource( - FfiHandle(static_cast(info.video_source().handle().id())), source->width_, source->height_)); + source->impl_->source_publish_options = fromProto(info.recommended_publish_options()); + source->impl_->video_source = + std::shared_ptr(new VideoSource(FfiHandle(static_cast(info.video_source().handle().id())), + source->impl_->width, source->impl_->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 = source->handle_.get(); - source->listener_id_ = FfiClient::instance().addListener([raw, capture_handle](const proto::FfiEvent& event) { + const std::uint64_t capture_handle = source->impl_->handle.get(); + source->impl_->listener_id = FfiClient::instance().addListener([raw, capture_handle](const proto::FfiEvent& event) { if (!event.has_capture_source_event() || event.capture_source_event().capture_handle() != capture_handle) { return; } const CaptureResult result = resultFromEvent(event.capture_source_event()); FinishedCallback callback; { - const std::scoped_lock lock(raw->callback_mutex_); - callback = raw->on_finished_; + const std::scoped_lock lock(raw->impl_->callback_mutex); + callback = raw->impl_->on_finished; } if (callback) { callback(result); @@ -406,38 +249,38 @@ TrackPublishOptions CaptureSource::publishOptions(TrackPublishOptions options) c target = source; } }; - overlay(options.video_encoding, source_publish_options_.video_encoding); - overlay(options.audio_encoding, source_publish_options_.audio_encoding); - overlay(options.video_codec, source_publish_options_.video_codec); - overlay(options.dtx, source_publish_options_.dtx); - overlay(options.red, source_publish_options_.red); - overlay(options.simulcast, source_publish_options_.simulcast); - overlay(options.source, source_publish_options_.source); - overlay(options.stream, source_publish_options_.stream); - overlay(options.preconnect_buffer, source_publish_options_.preconnect_buffer); - overlay(options.frame_metadata_features, source_publish_options_.frame_metadata_features); - overlay(options.degradation_preference, source_publish_options_.degradation_preference); - overlay(options.video_encoder, source_publish_options_.video_encoder); + overlay(options.video_encoding, impl_->source_publish_options.video_encoding); + overlay(options.audio_encoding, impl_->source_publish_options.audio_encoding); + overlay(options.video_codec, impl_->source_publish_options.video_codec); + overlay(options.dtx, impl_->source_publish_options.dtx); + overlay(options.red, impl_->source_publish_options.red); + overlay(options.simulcast, impl_->source_publish_options.simulcast); + overlay(options.source, impl_->source_publish_options.source); + overlay(options.stream, impl_->source_publish_options.stream); + overlay(options.preconnect_buffer, impl_->source_publish_options.preconnect_buffer); + overlay(options.frame_metadata_features, impl_->source_publish_options.frame_metadata_features); + overlay(options.degradation_preference, impl_->source_publish_options.degradation_preference); + overlay(options.video_encoder, impl_->source_publish_options.video_encoder); return options; } CaptureSource::~CaptureSource() { - if (listener_id_ != 0) { - FfiClient::instance().removeListener(listener_id_); - listener_id_ = 0; + if (impl_->listener_id != 0) { + FfiClient::instance().removeListener(impl_->listener_id); + impl_->listener_id = 0; } // Dropping the handle stops a running capture. } void CaptureSource::setOnFinishedCallback(FinishedCallback callback) { - const std::scoped_lock lock(callback_mutex_); - on_finished_ = std::move(callback); + const std::scoped_lock lock(impl_->callback_mutex); + impl_->on_finished = std::move(callback); } bool CaptureSource::start() { try { proto::FfiRequest req; - req.mutable_start_capture()->set_capture_handle(handle_.get()); + req.mutable_start_capture()->set_capture_handle(impl_->handle.get()); const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); if (!resp.has_start_capture()) { @@ -458,7 +301,7 @@ bool CaptureSource::start() { bool CaptureSource::stop() { try { proto::FfiRequest req; - req.mutable_stop_capture()->set_capture_handle(handle_.get()); + req.mutable_stop_capture()->set_capture_handle(impl_->handle.get()); const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); if (!resp.has_stop_capture()) { diff --git a/src/capture_source_internal.h b/src/capture_source_internal.h index 0d177879..934be8b3 100644 --- a/src/capture_source_internal.h +++ b/src/capture_source_internal.h @@ -16,20 +16,15 @@ #pragma once -#include - #include "livekit/capture_source.h" #include "livekit/visibility.h" namespace livekit { -namespace proto { -class CaptureDeviceList; -} - -/// Convert an FFI capture-device list to public device information. -/// +/// Serialize capture configurations for the FFI request. /// Internal test seam; not part of the public SDK API. -LIVEKIT_INTERNAL_API std::vector fromProto(const proto::CaptureDeviceList& list); +LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config); +LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const PatternVideoSourceConfig& config); +LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config); } // namespace livekit diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index c52c25ed..6138b390 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -111,8 +111,6 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { return event.perform_rpc().async_id(); case E::kNewCaptureSource: return event.new_capture_source().async_id(); - case E::kListCaptureDevices: - return event.list_capture_devices().async_id(); // low-level data stream callbacks case E::kSendStreamHeader: @@ -760,48 +758,6 @@ std::future FfiClient::newCaptureSourceAsync(proto::N return fut; } -std::future FfiClient::listCaptureDevicesAsync() { - // 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_list_capture_devices() && event.list_capture_devices().async_id() == async_id; - }, - [](const proto::FfiEvent& event, std::promise& pr) { - const auto& cb = event.list_capture_devices(); - - // Oneof message { string error = 2; CaptureDeviceList devices = 3; } - if (cb.has_error() && !cb.error().empty()) { - pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error()))); - return; - } - if (!cb.has_devices()) { - pr.set_exception(std::make_exception_ptr(std::runtime_error("ListCaptureDevicesCallback missing devices"))); - return; - } - - pr.set_value(cb.devices()); - }); - - proto::FfiRequest req; - req.mutable_list_capture_devices()->set_request_async_id(async_id); - - try { - const proto::FfiResponse resp = sendRequest(req); - if (!resp.has_list_capture_devices()) { - logAndThrow("FfiResponse missing list_capture_devices"); - } - } 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 caf038c5..8ea097bc 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -122,7 +122,6 @@ class LIVEKIT_INTERNAL_API FfiClient { // Capture APIs std::future newCaptureSourceAsync(proto::NewCaptureSourceRequest request); - std::future listCaptureDevicesAsync(); 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/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..a4c65f4c 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -96,6 +96,7 @@ if(UNIT_TEST_SOURCES) target_include_directories(livekit_unit_tests PRIVATE + ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${LIVEKIT_ROOT_DIR}/src/trace @@ -189,6 +190,7 @@ if(INTEGRATION_TEST_SOURCES) target_include_directories(livekit_integration_tests PRIVATE + ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/benchmark @@ -271,6 +273,7 @@ if(STRESS_TEST_SOURCES) target_include_directories(livekit_stress_tests PRIVATE + ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${LIVEKIT_TEST_PROTOBUF_DEP_INCLUDE_DIRS} diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index dcd4ab87..53cc1743 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -16,16 +16,14 @@ /// Tests for capture sources (livekit-capture over FFI). /// -/// Two groups. `CaptureSourceServerTest` publishes the built-in pattern source — -/// the capture pump pushes frames server-side, so the test drives no frames -/// itself — and verifies that a second participant receives real video through -/// the SFU. `CaptureDeviceTest` covers camera device enumeration and format -/// negotiation, which need no room; those tests skip when the machine has no -/// camera. +/// Publishes the built-in pattern and clock sources and verifies that a second +/// participant receives video through the SFU without per-frame FFI traffic. /// /// All of them require the Rust FFI built with the `capture` feature /// (-DLIVEKIT_ENABLE_CAPTURE=ON); otherwise they skip. +#include + #include #include #include @@ -35,7 +33,6 @@ #include #include "../common/test_common.h" -#include "livekit/capture_source.h" namespace livekit::test { @@ -58,7 +55,13 @@ constexpr std::uint32_t kCaptureFramerateFps = 30; } \ } while (false) -std::shared_ptr createPatternCapture() { +std::shared_ptr createCapture(bool clock) { + if (clock) { + ClockVideoSourceConfig config; + config.resolution = {kCaptureWidth, kCaptureHeight}; + config.framerate_fps = kCaptureFramerateFps; + return CaptureSource::create(config).get(); + } PatternVideoSourceConfig config; config.resolution = {kCaptureWidth, kCaptureHeight}; config.framerate_fps = kCaptureFramerateFps; @@ -67,113 +70,36 @@ std::shared_ptr createPatternCapture() { } // namespace -class CaptureSourceServerTest : public LiveKitTestBase {}; +class CaptureSourceServerTest : public LiveKitTestBase, public ::testing::WithParamInterface {}; -/// Device tests need an initialized SDK but no room, so they share the base -/// fixture without requiring the server environment variables. -class CaptureDeviceTest : public LiveKitTestBase {}; - -/// Device enumeration is a pure local query: it needs no server, and a -/// machine with no camera is a valid (empty) result. -TEST_F(CaptureDeviceTest, ListDevicesReportsWellFormedDevices) { +TEST_P(CaptureSourceServerTest, InvalidResolutionIsRejected) { SKIP_WITHOUT_CAPTURE_FEATURE(); - - std::vector devices; - try { - devices = CaptureSource::listDevices().get(); - } catch (const CaptureSourceError& e) { - // A platform with no capture backend reports UnsupportedPlatform. - GTEST_SKIP() << "capture device enumeration unavailable: " << e.what(); - } - - for (const CaptureDeviceInfo& device : devices) { - EXPECT_FALSE(device.id.empty()) << "device id must be a stable identifier"; - EXPECT_FALSE(device.name.empty()) << "device name must be human-readable"; - for (const DeviceFormat& format : device.formats) { - EXPECT_GT(format.resolution.width, 0); - EXPECT_GT(format.resolution.height, 0); - EXPECT_GT(format.framerate_fps, 0u); - } - // A device reporting a complete format list must report at least one. - if (device.formats_complete) { - EXPECT_FALSE(device.formats.empty()) << "device " << device.id << " claims a complete but empty format list"; - } + if (GetParam()) { + ClockVideoSourceConfig config; + config.resolution = {0, kCaptureHeight}; + config.framerate_fps = kCaptureFramerateFps; + EXPECT_THROW(static_cast(CaptureSource::create(config).get()), CaptureSourceError); + } else { + PatternVideoSourceConfig config; + config.resolution = {0, kCaptureHeight}; + config.framerate_fps = kCaptureFramerateFps; + EXPECT_THROW(static_cast(CaptureSource::create(config).get()), CaptureSourceError); } } -/// An unsatisfiable exact format request must fail loudly at construction -/// rather than silently falling back to another format. -/// -/// The resolution is deliberately odd-sized rather than merely large: no -/// camera offers 7x3, and its odd dimensions survive any driver rounding to a -/// macroblock or 2-pixel boundary. -/// -/// CaptureSourceError carries only a message, so this cannot assert on a -/// specific cause. The preceding skips narrow it: the capture feature is -/// present and at least one device enumerated, so the throw is attributable to -/// the format request. -TEST_F(CaptureDeviceTest, ExactFormatRequestRejectsUnsupportedFormat) { - SKIP_WITHOUT_CAPTURE_FEATURE(); - - std::vector devices; - try { - devices = CaptureSource::listDevices().get(); - } catch (const CaptureSourceError& e) { - GTEST_SKIP() << "capture device enumeration unavailable: " << e.what(); - } - if (devices.empty()) { - GTEST_SKIP() << "no capture devices attached to this machine"; - } - - DeviceVideoSourceConfig config; - config.device = DeviceSelector::id(devices.front().id); - // NV12 is requestable on every backend, so the rejection is attributable to - // the resolution rather than to frame-format validation. - config.format = DeviceFormatRequest::exact(DeviceFormat{{7, 3}, 1, DeviceFrameFormat::NV12}); - - EXPECT_THROW(CaptureSource::create(config).get(), CaptureSourceError); -} - -/// Opening the platform default device with the default format request must -/// negotiate a usable format and report the negotiated resolution. -TEST_F(CaptureDeviceTest, DefaultDeviceNegotiatesAFormat) { - SKIP_WITHOUT_CAPTURE_FEATURE(); - - std::vector devices; - try { - devices = CaptureSource::listDevices().get(); - } catch (const CaptureSourceError& e) { - GTEST_SKIP() << "capture device enumeration unavailable: " << e.what(); - } - if (devices.empty()) { - GTEST_SKIP() << "no capture devices attached to this machine"; - } - - std::shared_ptr capture; - try { - capture = CaptureSource::create(DeviceVideoSourceConfig{}).get(); - } catch (const CaptureSourceError& e) { - // A camera present but claimed by another process, or denied by the - // platform's privacy controls, is not a binding failure. - GTEST_SKIP() << "default capture device unavailable: " << e.what(); - } - - ASSERT_NE(capture, nullptr); - EXPECT_EQ(capture->kind(), CaptureSourceKind::Pixel); - EXPECT_GT(capture->width(), 0); - EXPECT_GT(capture->height(), 0); - EXPECT_FALSE(capture->codec().has_value()) << "device capture is a pixel source"; - EXPECT_NE(capture->videoSource(), nullptr); -} - -TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { +TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { SKIP_WITHOUT_CAPTURE_FEATURE(); failIfNotConfigured(); - auto capture = createPatternCapture(); + const auto creation_started = std::chrono::steady_clock::now(); + auto capture = createCapture(GetParam()); + RecordProperty( + "capture_creation_ms", + std::to_string( + std::chrono::duration(std::chrono::steady_clock::now() - creation_started).count())); ASSERT_NE(capture, nullptr); - // The pattern source reports back the resolution it was configured with. + // The pixel 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); @@ -199,7 +125,7 @@ TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { std::condition_variable cv; int frames_received = 0; - const std::string track_name = "pattern-capture-track"; + const std::string track_name = "pixel-capture-track"; receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, [&mutex, &cv, &frames_received](const VideoFrameEvent& /*event*/) { std::lock_guard lock(mutex); @@ -225,6 +151,7 @@ TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { cv.notify_all(); }); + const auto capture_started = std::chrono::steady_clock::now(); ASSERT_TRUE(capture->start()); EXPECT_FALSE(capture->start()) << "double start must be rejected"; @@ -232,7 +159,7 @@ TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { std::unique_lock lock(mutex); const bool got_frames = cv.wait_for(lock, 30s, [&frames_received] { return frames_received >= kMinFramesReceived; }); - ASSERT_TRUE(got_frames) << "Timed out waiting for pattern capture frames; received " << frames_received; + ASSERT_TRUE(got_frames) << "Timed out waiting for capture frames; received " << frames_received; } // Stop is a signal; the terminal callback delivers the stats. @@ -246,6 +173,10 @@ TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { ASSERT_FALSE(finished->error.has_value()) << *finished->error; EXPECT_EQ(finished->exit, CaptureExit::Stopped); EXPECT_GT(finished->frames_captured, 0u); + const auto capture_seconds = + std::chrono::duration(std::chrono::steady_clock::now() - capture_started).count(); + RecordProperty("capture_frames_per_second", + std::to_string(static_cast(finished->frames_captured) / capture_seconds)); receiver_room.clearOnVideoFrameCallback(sender_identity, track_name); if (track->publication()) { @@ -253,4 +184,7 @@ TEST_F(CaptureSourceServerTest, PatternCaptureSourcePublishesFramesEndToEnd) { } } +INSTANTIATE_TEST_SUITE_P(PixelSources, CaptureSourceServerTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { return info.param ? "Clock" : "Pattern"; }); + } // namespace livekit::test diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp index e69093cb..b81ac638 100644 --- a/src/tests/unit/test_capture_source.cpp +++ b/src/tests/unit/test_capture_source.cpp @@ -22,37 +22,60 @@ namespace livekit::test { namespace { -TEST(CaptureSourceProtoTest, ConvertsDeviceListToPublicTypes) { - proto::CaptureDeviceList list; - auto* device = list.add_devices(); - device->set_id("camera-1"); - device->set_name("Front camera"); - device->set_model_id("model-1"); - device->set_manufacturer("LiveKit"); - device->set_formats_complete(true); - - auto* format = device->add_formats(); - format->mutable_resolution()->set_width(1920); - format->mutable_resolution()->set_height(1080); - format->set_framerate_fps(30); - format->set_frame_format(proto::DeviceFrameFormat::DEVICE_FRAME_FORMAT_NV12); - - const std::vector devices = fromProto(list); - - ASSERT_EQ(devices.size(), 1u); - const CaptureDeviceInfo& converted = devices.front(); - EXPECT_EQ(converted.id, "camera-1"); - EXPECT_EQ(converted.name, "Front camera"); - ASSERT_TRUE(converted.model_id.has_value()); - EXPECT_EQ(*converted.model_id, "model-1"); - ASSERT_TRUE(converted.manufacturer.has_value()); - EXPECT_EQ(*converted.manufacturer, "LiveKit"); - EXPECT_TRUE(converted.formats_complete); - ASSERT_EQ(converted.formats.size(), 1u); - EXPECT_EQ(converted.formats.front().resolution.width, 1920); - EXPECT_EQ(converted.formats.front().resolution.height, 1080); - EXPECT_EQ(converted.formats.front().framerate_fps, 30u); - EXPECT_EQ(converted.formats.front().frame_format, DeviceFrameFormat::NV12); +TEST(CaptureSourceProtoTest, ClockConfigurationUsesMainlineWireContract) { + ClockVideoSourceConfig config; + config.resolution = {1920, 1080}; + config.framerate_fps = 30; + + const auto request = toProto(config); + ASSERT_TRUE(request.IsInitialized()); + ASSERT_TRUE(request.has_clock()); + EXPECT_EQ(request.clock().resolution().width(), 1920u); + EXPECT_EQ(request.clock().resolution().height(), 1080u); + EXPECT_EQ(request.clock().framerate_fps(), 30u); + EXPECT_FALSE(request.has_gstreamer()); + EXPECT_FALSE(request.has_pattern()); +} + +TEST(CaptureSourceProtoTest, RejectsNonPositiveDimensionsBeforeUnsignedConversion) { + for (const CaptureResolution resolution : {CaptureResolution{0, 720}, CaptureResolution{-1, 720}, + CaptureResolution{1280, 0}, CaptureResolution{1280, -1}}) { + PatternVideoSourceConfig pattern; + pattern.resolution = resolution; + pattern.framerate_fps = 30; + EXPECT_THROW(toProto(pattern), CaptureSourceError); + ClockVideoSourceConfig clock; + clock.resolution = resolution; + clock.framerate_fps = 30; + EXPECT_THROW(toProto(clock), CaptureSourceError); + GstreamerVideoSourceConfig gstreamer; + gstreamer.resolution = resolution; + EXPECT_THROW(toProto(gstreamer), CaptureSourceError); + } +} + +TEST(CaptureSourceProtoTest, RejectsZeroFrameRate) { + PatternVideoSourceConfig pattern; + pattern.resolution = {1280, 720}; + EXPECT_THROW(toProto(pattern), CaptureSourceError); + ClockVideoSourceConfig clock; + clock.resolution = {1280, 720}; + EXPECT_THROW(toProto(clock), CaptureSourceError); +} + +TEST(CaptureSourceProtoTest, ValidationErrorsAreDeliveredThroughFuture) { + std::future> future; + EXPECT_NO_THROW(future = CaptureSource::create(ClockVideoSourceConfig{})); + ASSERT_TRUE(future.valid()); + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); +} + +TEST(CaptureSourceProtoTest, GstreamerCanDiscoverResolution) { + GstreamerVideoSourceConfig config; + config.pipeline = "videotestsrc ! vp8enc ! appsink name=lk_appsink"; + const auto request = toProto(config); + EXPECT_TRUE(request.IsInitialized()); + EXPECT_FALSE(request.gstreamer().has_resolution()); } } // namespace From e8615765bbc6e8ace8ea5d9e16e910fc368833a4 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 9 Oct 2026 11:16:38 -0600 Subject: [PATCH 04/29] packaging tests --- .github/workflows/builds.yml | 19 +++---- .github/workflows/ci.yml | 1 - .github/workflows/make-release.yml | 19 +++---- AGENTS.md | 6 +-- docs/testing.md | 26 +++++++++ src/tests/packaging/CMakeLists.txt | 38 +++++++++++++ src/tests/packaging/test_capture_bundle.cpp | 59 +++++++++++++++++++++ 7 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 src/tests/packaging/CMakeLists.txt create mode 100644 src/tests/packaging/test_capture_bundle.cpp diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 133ecc37..9aeea7f2 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -386,29 +386,30 @@ jobs: test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfigVersion.cmake" test -f "${bundle}/lib/cmake/LiveKit/LiveKitTargets.cmake" - - name: Smoke-test installed capture SDK (Unix) + - name: Test installed capture SDK (Unix) if: matrix.capture == true && runner.os != 'Windows' shell: bash run: | bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}" arch_args=() if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S scripts/capture-bundle-smoke -B capture-smoke -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" "${arch_args[@]}" - cmake --build capture-smoke - ./capture-smoke/capture_bundle_smoke + cmake -S src/tests/packaging -B build-packaging-tests -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ + -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" + cmake --build build-packaging-tests + ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging - - name: Smoke-test installed capture SDK (Windows) + - name: Test installed capture SDK (Windows) if: matrix.capture == true && runner.os == 'Windows' shell: pwsh run: | $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}" - cmake -S scripts/capture-bundle-smoke -B capture-smoke -DCMAKE_PREFIX_PATH="$bundle" + cmake -S src/tests/packaging -B build-packaging-tests -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build capture-smoke --config Release + cmake --build build-packaging-tests --config Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $env:PATH = "$bundle\bin;$env:PATH" - & .\capture-smoke\Release\capture_bundle_smoke.exe + ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # ---------- Upload artifacts ---------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eaf82c3..a6a2f86a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,6 @@ jobs: - .github/workflows/make-release.yml - .github/actions/setup-capture/** - .github/scripts/check_no_private_symbols.py - - scripts/capture-bundle-smoke/** tests: - src/** - include/** diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 940bddc6..4e70fa6c 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -380,29 +380,30 @@ jobs: throw "Build info is missing livekit-ffi version" } - - name: Smoke-test installed capture SDK (Unix) + - name: Test installed capture SDK (Unix) if: matrix.capture == true && runner.os != 'Windows' shell: bash run: | bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" arch_args=() if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S scripts/capture-bundle-smoke -B capture-smoke -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" "${arch_args[@]}" - cmake --build capture-smoke - ./capture-smoke/capture_bundle_smoke + cmake -S src/tests/packaging -B build-packaging-tests -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ + -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" + cmake --build build-packaging-tests + ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging - - name: Smoke-test installed capture SDK (Windows) + - name: Test installed capture SDK (Windows) if: matrix.capture == true && runner.os == 'Windows' shell: pwsh run: | $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" - cmake -S scripts/capture-bundle-smoke -B capture-smoke -DCMAKE_PREFIX_PATH="$bundle" + cmake -S src/tests/packaging -B build-packaging-tests -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build capture-smoke --config Release + cmake --build build-packaging-tests --config Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $env:PATH = "$bundle\bin;$env:PATH" - & .\capture-smoke\Release\capture_bundle_smoke.exe + ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # ---------- Upload artifact (raw directory, no pre-compression) ---------- diff --git a/AGENTS.md b/AGENTS.md index 1573e267..1bd7169c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Be sure to update the directory layout in this file if the directory layout chan | `scripts/` | Local helper scripts for SDK-specific development tasks | | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | -| `scripts/capture-bundle-smoke/` | Standalone downstream CMake consumer that validates capture-enabled SDK bundles | +| `src/tests/packaging/` | Standalone CTest integration tests consuming installed SDK bundles | | `.github/actions/setup-capture/` | Shared CI setup for capture runtime and development dependencies | | `.github/workflows/` | GitHub Actions CI workflows | @@ -132,8 +132,8 @@ Both build scripts accept `--capture` to enable GStreamer, pattern, and clock sources, and `--no-capture` to disable them. Test/all presets enable capture by default; normal and example presets disable it. Capture-enabled builds need GStreamer development and runtime libraries. Release workflows publish separate -`-capture` bundles and validate creation through the installed SDK using -`scripts/capture-bundle-smoke/`. +`-capture` bundles and validate creation through the installed SDK using the standalone CTest project in +`src/tests/packaging/`. Rust FFI outputs live under `client-sdk-rust/target/cpp-/` to isolate Cargo feature configurations. Generated public build metadata lives under each diff --git a/docs/testing.md b/docs/testing.md index 33919c0e..137aca36 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -98,6 +98,32 @@ export LIVEKIT_TOKEN_B="$(lk token create --api-key devkey --api-secret secret - --token-only)" ``` +## Installed SDK packaging tests + +`src/tests/packaging/` is a standalone CMake consumer of an installed SDK. +CTest registers `InstalledSdk.Capture` with the `packaging` and `integration` +labels and a 30-second timeout. It checks the bundle's capture metadata and +creates a GStreamer VP8 source through the installed library. It needs no +LiveKit server; capture-enabled runs require GStreamer and the pipeline's plugins. + +From the repository root: + +```bash +source scripts/set-test-tokens.sh +./build.sh debug --capture --bundle --prefix sdk-out/livekit-sdk-capture +cmake -S src/tests/packaging -B build-packaging-tests \ + -DCMAKE_BUILD_TYPE=Debug -DCMAKE_PREFIX_PATH="$PWD/sdk-out/livekit-sdk-capture" \ + -DLIVEKIT_EXPECT_CAPTURE=ON +cmake --build build-packaging-tests --config Debug +ctest --test-dir build-packaging-tests -C Debug --output-on-failure -L packaging +``` + +Use `LIVEKIT_EXPECT_CAPTURE=OFF` when testing a standard bundle. A mismatch +between the expected capability and the installed header fails the test. In +capture-enabled CI release jobs, the expectation is explicitly `ON`; a disabled +bundle cannot pass by rejecting source creation. On Windows, add the installed +SDK's `bin` directory and GStreamer's `bin` directory to `PATH` before running CTest. + ## Test coverage - **SDK initialization**: initialize / shutdown lifecycle. diff --git a/src/tests/packaging/CMakeLists.txt b/src/tests/packaging/CMakeLists.txt new file mode 100644 index 00000000..25a633f7 --- /dev/null +++ b/src/tests/packaging/CMakeLists.txt @@ -0,0 +1,38 @@ +# 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. + +cmake_minimum_required(VERSION 3.20) +project(livekit_packaging_tests LANGUAGES CXX) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# This is a standalone consumer of an installed SDK, not the in-tree target. +find_package(LiveKit CONFIG REQUIRED) +set(LIVEKIT_EXPECT_CAPTURE "ON" CACHE STRING "Expected capture support in the installed SDK (ON or OFF)") +set_property(CACHE LIVEKIT_EXPECT_CAPTURE PROPERTY STRINGS ON OFF) +if(NOT LIVEKIT_EXPECT_CAPTURE STREQUAL "ON" AND NOT LIVEKIT_EXPECT_CAPTURE STREQUAL "OFF") + message(FATAL_ERROR "LIVEKIT_EXPECT_CAPTURE must be ON or OFF") +endif() + +add_executable(livekit_capture_package_test test_capture_bundle.cpp) +target_compile_features(livekit_capture_package_test PRIVATE cxx_std_17) +target_link_libraries(livekit_capture_package_test PRIVATE LiveKit::livekit) +# The installed macOS SDK uses @rpath for its FFI dependency. +if(APPLE) + set_property(TARGET livekit_capture_package_test PROPERTY BUILD_RPATH "$") +endif() + +enable_testing() +add_test(NAME InstalledSdk.Capture + COMMAND livekit_capture_package_test "--expect-capture=${LIVEKIT_EXPECT_CAPTURE}") +set_tests_properties(InstalledSdk.Capture PROPERTIES + LABELS "packaging;integration" + TIMEOUT 30 +) diff --git a/src/tests/packaging/test_capture_bundle.cpp b/src/tests/packaging/test_capture_bundle.cpp new file mode 100644 index 00000000..c1c82caf --- /dev/null +++ b/src/tests/packaging/test_capture_bundle.cpp @@ -0,0 +1,59 @@ +/* + * 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 + +int main(int argc, char** argv) { + if (argc != 2 || + (std::string_view(argv[1]) != "--expect-capture=ON" && std::string_view(argv[1]) != "--expect-capture=OFF")) { + std::cerr << "Usage: livekit_capture_package_test --expect-capture=ON|OFF\n"; + return 2; + } + const bool expect_capture = std::string_view(argv[1]) == "--expect-capture=ON"; + if (livekit::kCaptureEnabled != expect_capture) { + std::cerr << "Installed SDK capture metadata does not match the requested expectation\n"; + return 1; + } + + livekit::initialize(livekit::LogLevel::Warn); + int status = 0; + try { + livekit::GstreamerVideoSourceConfig config; + config.pipeline = + "videotestsrc num-buffers=4 ! video/x-raw,width=320,height=240,framerate=30/1 ! " + "vp8enc deadline=1 ! appsink name=lk_appsink"; + auto source = livekit::CaptureSource::create(config).get(); + if (!expect_capture || !source || source->kind() != livekit::CaptureSourceKind::Encoded || source->width() != 320 || + source->height() != 240 || source->codec() != livekit::VideoCodec::VP8 || !source->videoSource()) { + std::cerr << "Installed capture SDK returned unexpected source metadata\n"; + status = 1; + } + } catch (const livekit::CaptureSourceError& error) { + if (expect_capture) { + std::cerr << "Installed capture SDK failed: " << error.what() << '\n'; + status = 1; + } + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + status = 1; + } + livekit::shutdown(); + return status; +} From 885e5b0a7b9de728f486e7dfec01d03ed57a186f Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 9 Oct 2026 13:18:58 -0600 Subject: [PATCH 05/29] build flag LIVEKIT_BUILD_CAPTURE sets LIVEKIT_CAPTURE_ENABLED macro --- AGENTS.md | 3 ++ CMakeLists.txt | 4 +-- CMakePresets.json | 30 +++++++++---------- build.cmd | 2 +- build.h.in | 8 ++--- build.sh | 2 +- docs/building.md | 3 ++ docs/capture-sources.md | 4 +-- include/livekit/capture_source.h | 9 +++++- src/tests/integration/test_capture_source.cpp | 20 ++++++------- src/tests/packaging/test_capture_bundle.cpp | 2 +- 11 files changed, 47 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bd7169c..23e101e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,9 @@ To create an installable SDK bundle, use the same build command with `./build.sh release --bundle --prefix sdk-out/livekit-sdk`). On Windows, use `build.cmd release --bundle --prefix C:\path\to\livekit-sdk`. +The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated +`LIVEKIT_CAPTURE_ENABLED` macro is 0 or 1 and supports `#if` checks in consumers. + Both build scripts accept `--capture` to enable GStreamer, pattern, and clock sources, and `--no-capture` to disable them. Test/all presets enable capture by default; normal and example presets disable it. Capture-enabled builds need diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a06327a..760cb310 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,7 +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 the capture feature (livekit-capture sources; links system GStreamer)" OFF) +option(LIVEKIT_BUILD_CAPTURE "Build the Rust FFI with the capture feature (livekit-capture sources; links system GStreamer)" OFF) # vcpkg is only used on Windows; Linux/macOS use system package managers if(WIN32) @@ -172,7 +172,7 @@ 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) +if(LIVEKIT_BUILD_CAPTURE) set(LIVEKIT_CARGO_FEATURES "capture-gstreamer,capture-pattern,capture-clock") set(LIVEKIT_CAPTURE_ENABLED 1) endif() diff --git a/CMakePresets.json b/CMakePresets.json index bdc89ad3..e9e7986e 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -31,7 +31,7 @@ }, "cacheVariables": { "VCPKG_TARGET_TRIPLET": "x64-windows-static-md", - "LIVEKIT_ENABLE_CAPTURE": "OFF" + "LIVEKIT_BUILD_CAPTURE": "OFF" } }, { @@ -41,7 +41,7 @@ "cacheVariables": { "LIVEKIT_USE_VCPKG": "OFF", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "OFF" + "LIVEKIT_BUILD_CAPTURE": "OFF" }, "condition": { "type": "equals", @@ -56,7 +56,7 @@ "cacheVariables": { "LIVEKIT_USE_VCPKG": "OFF", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "OFF" + "LIVEKIT_BUILD_CAPTURE": "OFF" }, "condition": { "type": "equals", @@ -218,7 +218,7 @@ "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -231,7 +231,7 @@ "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -244,7 +244,7 @@ "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "OFF", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -257,7 +257,7 @@ "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "OFF", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -270,7 +270,7 @@ "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -283,7 +283,7 @@ "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -296,7 +296,7 @@ "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "OFF", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -309,7 +309,7 @@ "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "OFF", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -322,7 +322,7 @@ "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "ON", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -335,7 +335,7 @@ "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "ON", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -348,7 +348,7 @@ "CMAKE_BUILD_TYPE": "Release", "LIVEKIT_BUILD_EXAMPLES": "OFF", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } }, { @@ -361,7 +361,7 @@ "CMAKE_BUILD_TYPE": "Debug", "LIVEKIT_BUILD_EXAMPLES": "OFF", "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_ENABLE_CAPTURE": "ON" + "LIVEKIT_BUILD_CAPTURE": "ON" } } ], diff --git a/build.cmd b/build.cmd index 2ca8cd35..47f1fd78 100644 --- a/build.cmd +++ b/build.cmd @@ -144,7 +144,7 @@ if defined LIVEKIT_VERSION ( set "CMAKE_EXTRA_ARGS=-DLIVEKIT_VERSION=%LIVEKIT_VERSION%" echo ==^> Injecting LIVEKIT_VERSION=%LIVEKIT_VERSION% ) -if defined CAPTURE_OVERRIDE set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_ENABLE_CAPTURE=%CAPTURE_OVERRIDE%" +if defined CAPTURE_OVERRIDE set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_BUILD_CAPTURE=%CAPTURE_OVERRIDE%" goto dispatch :dispatch diff --git a/build.h.in b/build.h.in index 18395f4d..df106d4f 100644 --- a/build.h.in +++ b/build.h.in @@ -8,12 +8,8 @@ #define LIVEKIT_BUILD_VERSION "@LIVEKIT_VERSION@" #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 +/// @brief Whether the Rust FFI was built with capture-source support (0 or 1). +#define LIVEKIT_CAPTURE_ENABLED @LIVEKIT_CAPTURE_ENABLED@ #ifdef DEBUG #define LIVEKIT_BUILD_SUFFIX "-debug" diff --git a/build.sh b/build.sh index 9d56b59e..ff33b466 100755 --- a/build.sh +++ b/build.sh @@ -148,7 +148,7 @@ configure() { echo "==> Configuring CMake (${BUILD_TYPE}) using preset ${PRESET}..." local -a extra_args=() if [[ -n "${CAPTURE_OVERRIDE:-}" ]]; then - extra_args+=("-DLIVEKIT_ENABLE_CAPTURE=${CAPTURE_OVERRIDE}") + extra_args+=("-DLIVEKIT_BUILD_CAPTURE=${CAPTURE_OVERRIDE}") fi if [[ -n "${LIVEKIT_VERSION}" ]]; then echo "==> Injecting LIVEKIT_VERSION=${LIVEKIT_VERSION}" diff --git a/docs/building.md b/docs/building.md index a54e76e7..901e611d 100644 --- a/docs/building.md +++ b/docs/building.md @@ -358,6 +358,9 @@ Standard `debug`, `release`, and `*-examples` builds disable capture. The ./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture ``` +The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated +`LIVEKIT_CAPTURE_ENABLED` macro is 0 or 1 and supports `#if` checks in consumers. + On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. Capture builds enable GStreamer, GPU test patterns, and the GPU wall clock. They require GStreamer development libraries at build time and its runtime diff --git a/docs/capture-sources.md b/docs/capture-sources.md index 9a20cce8..25b7a3a3 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -25,12 +25,12 @@ them explicitly: ``` On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. -Custom CMake configurations can set `LIVEKIT_ENABLE_CAPTURE=ON`. The build enables +Custom CMake configurations can set `LIVEKIT_BUILD_CAPTURE=ON`. The build enables `capture-gstreamer`, `capture-pattern`, and `capture-clock` in the Rust FFI. Release downloads with `-capture` in their names include these features, for example `livekit-sdk-macos-arm64-capture-.tar.gz`. Standard release -bundles have capture disabled. The generated `livekit::kCaptureEnabled` constant +bundles have capture disabled. The generated numeric `LIVEKIT_CAPTURE_ENABLED` macro (0 or 1) and `share/livekit/build-info.json` describe the selected bundle's build features; they do not test whether a GPU or a requested GStreamer plugin is available. diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index 1d6401ee..e3cc9f28 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -160,7 +160,7 @@ struct CaptureResult { /// a track from @ref CaptureSource::videoSource(), merge application options /// through @ref CaptureSource::publishOptions(), then call @ref CaptureSource::start(). /// -/// Requires a capture-enabled SDK build (`LIVEKIT_ENABLE_CAPTURE`), with +/// Requires a capture-enabled SDK build (`LIVEKIT_BUILD_CAPTURE`), with /// `capture-gstreamer`, `capture-pattern`, and `capture-clock` Rust features. /// Otherwise creation fails. /// @@ -186,12 +186,19 @@ class LIVEKIT_API CaptureSource { /// @brief Create the built-in test pattern capture source. /// + /// Completes asynchronously after Rust initializes the GPU renderer. + /// Keep the future to overlap setup with other work, or await it immediately. + /// /// @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 wall clock capture source. + /// + /// Completes asynchronously after Rust initializes the GPU renderer. + /// Keep the future to overlap setup with other work, or await it immediately. + /// /// @param config Clock source configuration. /// @return A future that resolves to the created capture source. /// @throws CaptureSourceError When awaiting the future if creation fails. diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index 53cc1743..7a20a1ea 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -19,8 +19,8 @@ /// Publishes the built-in pattern and clock sources and verifies that a second /// participant receives video through the SFU without per-frame FFI traffic. /// -/// All of them require the Rust FFI built with the `capture` feature -/// (-DLIVEKIT_ENABLE_CAPTURE=ON); otherwise they skip. +/// All of them require the Rust FFI built with capture-source support +/// (-DLIVEKIT_BUILD_CAPTURE=ON); otherwise they skip. #include @@ -45,15 +45,13 @@ constexpr int kCaptureWidth = 1280; constexpr int kCaptureHeight = 720; constexpr std::uint32_t kCaptureFramerateFps = 30; -/// 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) +// Decide from build metadata rather than interpreting source-creation errors. +#if LIVEKIT_CAPTURE_ENABLED +#define SKIP_WITHOUT_CAPTURE_FEATURE() ((void)0) +#else +#define SKIP_WITHOUT_CAPTURE_FEATURE() \ + GTEST_SKIP() << "livekit-ffi built without capture support; configure with -DLIVEKIT_BUILD_CAPTURE=ON" +#endif std::shared_ptr createCapture(bool clock) { if (clock) { diff --git a/src/tests/packaging/test_capture_bundle.cpp b/src/tests/packaging/test_capture_bundle.cpp index c1c82caf..af3f83b2 100644 --- a/src/tests/packaging/test_capture_bundle.cpp +++ b/src/tests/packaging/test_capture_bundle.cpp @@ -27,7 +27,7 @@ int main(int argc, char** argv) { return 2; } const bool expect_capture = std::string_view(argv[1]) == "--expect-capture=ON"; - if (livekit::kCaptureEnabled != expect_capture) { + if ((LIVEKIT_CAPTURE_ENABLED != 0) != expect_capture) { std::cerr << "Installed SDK capture metadata does not match the requested expectation\n"; return 1; } From 3f9e399ed6064a9fc6c30f6c967c7c3ae1fc604b Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 9 Oct 2026 13:33:37 -0600 Subject: [PATCH 06/29] Fix capture CI dependencies and Windows Cargo path lengths --- .github/actions/setup-capture/action.yml | 4 +++- .github/workflows/builds.yml | 25 ++++++++++++++++++------ .github/workflows/make-release.yml | 19 ++++++++++++++---- .github/workflows/tests.yml | 19 ++++++++++++++---- AGENTS.md | 5 ++++- CMakeLists.txt | 9 ++++++++- docs/building.md | 11 +++++++++++ 7 files changed, 75 insertions(+), 17 deletions(-) diff --git a/.github/actions/setup-capture/action.yml b/.github/actions/setup-capture/action.yml index d1d3fa7b..a7f11b06 100644 --- a/.github/actions/setup-capture/action.yml +++ b/.github/actions/setup-capture/action.yml @@ -8,7 +8,9 @@ runs: shell: bash run: | sudo apt-get update - sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + # Request the distro unwinder explicitly so apt can replace conflicting + # LLVM unwinder development packages on Ubuntu 22.04 runners. + sudo apt-get install -y libunwind-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav - name: Install GStreamer (macOS) diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 9aeea7f2..e28ee40f 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -103,6 +103,15 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Set Cargo output root + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" + else + echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" + fi + - name: Checkout (with submodules) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -222,17 +231,19 @@ jobs: id: cache-cargo-target uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: client-sdk-rust/target/ - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ steps.rust_sha.outputs.sha }} + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. - name: Drop stale cargo target on submodule SHA cache miss if: steps.cache-cargo-target.outputs.cache-hit != 'true' shell: bash - run: rm -rf client-sdk-rust/target/ + run: rm -rf client-sdk-rust/target/ "$CARGO_TARGET_DIR" # ---------- Build environment setup ---------- - name: Set Linux build environment @@ -265,8 +276,10 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.cache-cargo-target.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: client-sdk-rust/target/ - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ steps.rust_sha.outputs.sha }} + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} - name: Save vcpkg binary cache if: runner.os == 'Windows' && steps.cache-vcpkg-binary.outputs.cache-hit != 'true' diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 4e70fa6c..954db05e 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -84,6 +84,15 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Set Cargo output root + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" + else + echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" + fi + - name: Checkout (with submodules) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -214,17 +223,19 @@ jobs: id: cache-cargo-target uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: client-sdk-rust/target/ - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ steps.rust_sha.outputs.sha }} + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. - name: Drop stale cargo target on submodule SHA cache miss if: steps.cache-cargo-target.outputs.cache-hit != 'true' shell: bash - run: rm -rf client-sdk-rust/target/ + run: rm -rf client-sdk-rust/target/ "$CARGO_TARGET_DIR" # ---------- Build environment setup ---------- - name: Set Linux build environment diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 178831f7..b728fe3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -174,6 +174,15 @@ jobs: BUILD_DIR: ${{ inputs.build_type == 'debug' && 'build-debug' || 'build-release' }} steps: + - name: Set Cargo output root + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" + else + echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" + fi + - name: Checkout (with submodules) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -319,17 +328,19 @@ jobs: id: cache-cargo-target uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: client-sdk-rust/target/ - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ steps.rust_sha.outputs.sha }} + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. - name: Drop stale cargo target on submodule SHA cache miss if: steps.cache-cargo-target.outputs.cache-hit != 'true' shell: bash - run: rm -rf client-sdk-rust/target/ + run: rm -rf client-sdk-rust/target/ "$CARGO_TARGET_DIR" # ---------- Build environment setup ---------- - name: Set Linux build environment diff --git a/AGENTS.md b/AGENTS.md index 23e101e0..b4d0da94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,10 @@ GStreamer development and runtime libraries. Release workflows publish separate `src/tests/packaging/`. Rust FFI outputs live under `client-sdk-rust/target/cpp-/` to isolate -Cargo feature configurations. Generated public build metadata lives under each +Cargo feature configurations. Set `CARGO_TARGET_DIR` to override the root; +feature-specific directories are appended. Windows CI uses a short root under +`runner.temp` to keep nested WebRTC header paths below the Windows path limit. +Generated public build metadata lives under each build directory's `generated/include/livekit/build.h`; do not generate it into the source tree. Track capture Rust sources and shaders in build dependencies. diff --git a/CMakeLists.txt b/CMakeLists.txt index 760cb310..3a9cd515 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -239,7 +239,14 @@ endif() # Include the feature selection in the path, including future feature changes. string(SHA256 LIVEKIT_CARGO_FEATURE_HASH "${LIVEKIT_CARGO_FEATURES}") string(SUBSTRING "${LIVEKIT_CARGO_FEATURE_HASH}" 0 16 LIVEKIT_CARGO_FEATURE_HASH) -set(LIVEKIT_CARGO_TARGET_DIR "${RUST_ROOT}/target/cpp-${LIVEKIT_CARGO_FEATURE_HASH}") +# A short Cargo root avoids MAX_PATH failures in nested WebRTC headers on Windows. +# Treat CARGO_TARGET_DIR as the root; retain feature-specific subdirectories. +set(LIVEKIT_CARGO_TARGET_ROOT "${RUST_ROOT}/target") +if(DEFINED ENV{CARGO_TARGET_DIR} AND NOT "$ENV{CARGO_TARGET_DIR}" STREQUAL "") + get_filename_component(LIVEKIT_CARGO_TARGET_ROOT "$ENV{CARGO_TARGET_DIR}" + ABSOLUTE BASE_DIR "${RUST_ROOT}") +endif() +set(LIVEKIT_CARGO_TARGET_DIR "${LIVEKIT_CARGO_TARGET_ROOT}/cpp-${LIVEKIT_CARGO_FEATURE_HASH}") if(RUST_TARGET_TRIPLE) set(RUST_TARGET_DIR "${LIVEKIT_CARGO_TARGET_DIR}/${RUST_TARGET_TRIPLE}") else() diff --git a/docs/building.md b/docs/building.md index 901e611d..f67ac364 100644 --- a/docs/building.md +++ b/docs/building.md @@ -348,6 +348,17 @@ cmake --build build --target clean_generated # Generated protobuf headers cmake --build build --target clean_all # Full clean ``` +## Cargo output paths on Windows + +The SDK places Rust FFI outputs in `client-sdk-rust/target/cpp-/`. +Deep checkout paths can make WebRTC header paths exceed the Windows path limit. +Set `CARGO_TARGET_DIR` to a short absolute path before building, for example +`set CARGO_TARGET_DIR=C:\lk-rust` in Command Prompt. The SDK appends the +feature directory so capture-enabled and disabled outputs remain separate. +Windows CI uses this setting for builds, tests, and release bundles. +`clean-all` removes the default Rust target directory; remove a custom root +separately when it is no longer needed. + ## Capture sources Standard `debug`, `release`, and `*-examples` builds disable capture. The From 3d619c6c346eed4e329959080072fbe7cc21bbc6 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 9 Oct 2026 13:54:29 -0600 Subject: [PATCH 07/29] address cancelled async future comments --- docs/capture-sources.md | 9 +- include/livekit/capture_source.h | 16 +- src/capture_source.cpp | 57 ++--- src/ffi_client.cpp | 43 ++-- src/ffi_client.h | 17 +- src/tests/CMakeLists.txt | 2 + src/tests/unit/test_capture_source.cpp | 299 +++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 64 deletions(-) diff --git a/docs/capture-sources.md b/docs/capture-sources.md index 25b7a3a3..967892d3 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -52,6 +52,9 @@ Invalid configurations produce `CaptureSourceError` when the returned future is awaited. For GStreamer, resolution and codec can be omitted to discover them from negotiated pipeline caps. +Discarding a creation future does not wait for creation to finish. Creation +continues, and the source is released when the callback arrives. + ```cpp #include @@ -98,5 +101,7 @@ auto capture = livekit::CaptureSource::create(config).get(); The optional rate-control binding forwards WebRTC bitrate targets to the named encoder property. Without it, the pipeline uses its configured fixed bitrate. Use `capture->publishOptions()` when publishing: encoded sources require the -source's codec and encoder settings, which override conflicting application -options. +source's codec, the `PreEncoded` encoder, and disabled simulcast, which override +conflicting application options. All other application options are preserved. +Pattern and clock sources preserve every application option, including explicit +codec and simulcast choices. Rust supplies defaults for fields left unset. diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index e3cc9f28..693ee75f 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -178,6 +178,8 @@ class LIVEKIT_API CaptureSource { /// for its first output to discover stream settings. Errors (invalid /// pipeline, missing capture feature, discovery timeout) are thrown from /// the future as @ref CaptureSourceError. + /// Destroying the future does not wait for creation. The source is released + /// when creation completes if the future was discarded. /// /// @param config GStreamer pipeline configuration. /// @return A future that resolves to the created capture source. @@ -188,6 +190,8 @@ class LIVEKIT_API CaptureSource { /// /// Completes asynchronously after Rust initializes the GPU renderer. /// Keep the future to overlap setup with other work, or await it immediately. + /// Destroying the future does not wait for creation; an abandoned result is + /// released when creation completes. /// /// @param config Pattern source configuration. /// @return A future that resolves to the created capture source. @@ -198,6 +202,8 @@ class LIVEKIT_API CaptureSource { /// /// Completes asynchronously after Rust initializes the GPU renderer. /// Keep the future to overlap setup with other work, or await it immediately. + /// Destroying the future does not wait for creation; an abandoned result is + /// released when creation completes. /// /// @param config Clock source configuration. /// @return A future that resolves to the created capture source. @@ -234,10 +240,10 @@ class LIVEKIT_API CaptureSource { /// @brief Returns publish options for this track, applying application options. /// - /// Fields the source dictates (e.g. codec, encoder backend, and simulcast - /// for encoded sources) are required for correct publication and override - /// the application values; all other fields are taken from @p options - /// unchanged. + /// Encoded sources override codec with the source codec, select the + /// PreEncoded encoder backend, and disable simulcast. All other fields are + /// taken from @p options unchanged. Pixel sources preserve every application + /// option; Rust supplies defaults for fields left unset. /// @param options Application publish options. /// @return Options with source-required fields applied. TrackPublishOptions publishOptions(TrackPublishOptions options = {}) const; @@ -259,6 +265,8 @@ class LIVEKIT_API CaptureSource { bool stop(); private: + friend class FfiClient; + CaptureSource(); /// @brief Shared creation path: sends the request and maps the callback payload diff --git a/src/capture_source.cpp b/src/capture_source.cpp index 68c3c543..ae15a4ae 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -16,6 +16,7 @@ #include "livekit/capture_source.h" +#include #include #include @@ -24,7 +25,6 @@ #include "ffi.pb.h" #include "ffi_client.h" #include "lk_log.h" -#include "room_proto_converter.h" namespace livekit { @@ -35,7 +35,6 @@ struct CaptureSource::Impl { int height = 0; std::optional codec; std::shared_ptr video_source; - TrackPublishOptions source_publish_options; std::mutex callback_mutex; FinishedCallback on_finished; int listener_id = 0; @@ -182,31 +181,27 @@ proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config) { std::future> CaptureSource::createFromRequest(proto::NewCaptureSourceRequest request) { // A synchronous send failure is delivered through the // future rather than thrown from the factory. - std::future owned; try { - owned = FfiClient::instance().newCaptureSourceAsync(std::move(request)); + return 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(); + // Guard both Rust-owned handles before validation or wrapper allocations. + FfiHandle capture_handle_owner(static_cast(owned.handle().id())); + FfiHandle video_handle_owner(static_cast(info.video_source().handle().id())); + if (!owned.IsInitialized() || 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()) || + (info.kind() == proto::CAPTURE_SOURCE_ENCODED && !info.has_codec())) { + throw CaptureSourceError("Invalid capture source info"); + } std::shared_ptr source(new CaptureSource()); - source->impl_->handle = FfiHandle(static_cast(owned.handle().id())); + source->impl_->handle = std::move(capture_handle_owner); source->impl_->kind = info.kind() == proto::CaptureSourceKind::CAPTURE_SOURCE_ENCODED ? CaptureSourceKind::Encoded : CaptureSourceKind::Pixel; source->impl_->width = static_cast(info.resolution().width()); @@ -214,10 +209,8 @@ std::shared_ptr CaptureSource::fromOwned(const proto::OwnedCaptur if (info.has_codec()) { source->impl_->codec = static_cast(info.codec()); } - source->impl_->source_publish_options = fromProto(info.recommended_publish_options()); - source->impl_->video_source = - std::shared_ptr(new VideoSource(FfiHandle(static_cast(info.video_source().handle().id())), - source->impl_->width, source->impl_->height)); + source->impl_->video_source = std::shared_ptr( + new VideoSource(std::move(video_handle_owner), source->impl_->width, source->impl_->height)); // The terminal CaptureSourceEvent is unsolicited (not async-id // correlated); observe it with a listener filtered by our handle. The @@ -244,23 +237,11 @@ std::shared_ptr CaptureSource::fromOwned(const proto::OwnedCaptur } TrackPublishOptions CaptureSource::publishOptions(TrackPublishOptions options) const { - const auto overlay = [](auto& target, const auto& source) { - if (source.has_value()) { - target = source; - } - }; - overlay(options.video_encoding, impl_->source_publish_options.video_encoding); - overlay(options.audio_encoding, impl_->source_publish_options.audio_encoding); - overlay(options.video_codec, impl_->source_publish_options.video_codec); - overlay(options.dtx, impl_->source_publish_options.dtx); - overlay(options.red, impl_->source_publish_options.red); - overlay(options.simulcast, impl_->source_publish_options.simulcast); - overlay(options.source, impl_->source_publish_options.source); - overlay(options.stream, impl_->source_publish_options.stream); - overlay(options.preconnect_buffer, impl_->source_publish_options.preconnect_buffer); - overlay(options.frame_metadata_features, impl_->source_publish_options.frame_metadata_features); - overlay(options.degradation_preference, impl_->source_publish_options.degradation_preference); - overlay(options.video_encoder, impl_->source_publish_options.video_encoder); + if (impl_->kind == CaptureSourceKind::Encoded) { + options.video_codec = impl_->codec; + options.video_encoder = VideoEncoderBackend::PreEncoded; + options.simulcast = false; + } return options; } diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 6138b390..1449b957 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -26,6 +26,7 @@ #include "data_track_proto_converter.h" #include "ffi.pb.h" #include "livekit/build.h" +#include "livekit/capture_source.h" #include "livekit/data_track_error.h" #include "livekit/ffi_handle.h" #include "livekit/room.h" @@ -463,12 +464,14 @@ bool FfiClient::cancelPendingByAsyncId(AsyncId async_id) { template std::future FfiClient::registerAsync(AsyncId async_id, std::function match, - std::function&)> handler) { + std::function&)> handler, + std::exception_ptr cancellation_error) { auto pending = std::make_unique>(); pending->async_id = async_id; auto fut = pending->promise.get_future(); pending->match = std::move(match); pending->handler = std::move(handler); + pending->cancellation_error = std::move(cancellation_error); { const std::scoped_lock guard(lock_); pending_by_id_.emplace(async_id, std::move(pending)); @@ -715,38 +718,44 @@ std::future FfiClient::publishTrackAsync(std::uint return fut; } -std::future FfiClient::newCaptureSourceAsync(proto::NewCaptureSourceRequest request) { +std::future> FfiClient::newCaptureSourceAsync( + proto::NewCaptureSourceRequest request, const std::function& send) { // Generate client-side async_id first const AsyncId async_id = generateAsyncId(); // Register the async handler BEFORE sending the request - auto fut = registerAsync( + 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 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; + try { + if (cb.has_error()) { + throw CaptureSourceError(cb.error()); + } + if (!cb.has_source()) { + throw CaptureSourceError("NewCaptureSourceCallback missing source"); + } + // Adopt the handles before satisfying the promise. If the caller has + // discarded its future, destroying the pending promise releases the + // wrapper and both handles after this callback completes. + pr.set_value(CaptureSource::fromOwned(cb.source())); + } catch (const std::exception& error) { + pr.set_exception(std::make_exception_ptr(CaptureSourceError(error.what()))); + } catch (...) { + pr.set_exception(std::make_exception_ptr(CaptureSourceError("Capture source construction failed"))); } - - pr.set_value(cb.source()); - }); + }, + std::make_exception_ptr(CaptureSourceError("Async operation cancelled"))); request.set_request_async_id(async_id); proto::FfiRequest req; req.mutable_new_capture_source()->CopyFrom(request); try { - const proto::FfiResponse resp = sendRequest(req); + const proto::FfiResponse resp = send ? send(req) : sendRequest(req); if (!resp.has_new_capture_source()) { logAndThrow("FfiResponse missing new_capture_source"); } diff --git a/src/ffi_client.h b/src/ffi_client.h index 8ea097bc..d0dd6be0 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -58,6 +58,7 @@ class DataStream; struct RoomOptions; struct TrackPublishOptions; +class CaptureSource; using FfiCallbackFn = void (*)(const uint8_t*, size_t); extern "C" void livekit_ffi_initialize(FfiCallbackFn cb, bool capture_logs, const char* sdk, const char* sdk_version); @@ -121,7 +122,11 @@ class LIVEKIT_INTERNAL_API FfiClient { const std::vector& destination_identities); // Capture APIs - std::future newCaptureSourceAsync(proto::NewCaptureSourceRequest request); + // Optional request transport permits deterministic callback tests without + // starting a GPU renderer or GStreamer pipeline. + std::future> newCaptureSourceAsync( + proto::NewCaptureSourceRequest request, + const std::function& send = {}); 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, @@ -162,6 +167,8 @@ class LIVEKIT_INTERNAL_API FfiClient { proto::FfiResponse sendRequest(const proto::FfiRequest& request) const; private: + friend struct FfiClientTestAccess; + FfiClient() = default; /// Lifecycle state of the FfiClient @@ -186,6 +193,7 @@ class LIVEKIT_INTERNAL_API FfiClient { std::promise promise; std::function match; std::function&)> handler; + std::exception_ptr cancellation_error; bool matches(const proto::FfiEvent& event) const override { return match && match(event); } @@ -193,7 +201,9 @@ class LIVEKIT_INTERNAL_API FfiClient { void cancel() override { try { - promise.set_exception(std::make_exception_ptr(std::runtime_error("Async operation cancelled"))); + promise.set_exception(cancellation_error + ? cancellation_error + : std::make_exception_ptr(std::runtime_error("Async operation cancelled"))); } catch (const std::future_error& e) { // Unlikely to throw here as the promise should be satisfied before // cancel() Logging a debug message to avoid clang empty catch warning @@ -223,7 +233,8 @@ class LIVEKIT_INTERNAL_API FfiClient { template std::future registerAsync(AsyncId async_id, std::function match, - std::function&)> handler); + std::function&)> handler, + std::exception_ptr cancellation_error = {}); // Generate a unique client-side async ID for request correlation AsyncId generateAsyncId(); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index a4c65f4c..1da96054 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -89,6 +89,7 @@ if(UNIT_TEST_SOURCES) target_link_libraries(livekit_unit_tests PRIVATE livekit + livekit_ffi # Capture ownership tests verify Rust handle disposal directly. spdlog::spdlog ${LIVEKIT_PROTOBUF_TARGET} GTest::gtest_main @@ -104,6 +105,7 @@ if(UNIT_TEST_SOURCES) ${LIVEKIT_TEST_PROTOBUF_DEP_INCLUDE_DIRS} ) target_include_directories(livekit_unit_tests SYSTEM PRIVATE + ${LIVEKIT_ROOT_DIR}/client-sdk-rust/livekit-ffi/include ${LIVEKIT_TEST_SYSTEM_INCLUDE_DIRS} ) diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp index b81ac638..e9e41728 100644 --- a/src/tests/unit/test_capture_source.cpp +++ b/src/tests/unit/test_capture_source.cpp @@ -15,13 +15,300 @@ */ #include +#include +#include +#include +#include + +#include "../common/ffi_utils.h" #include "capture.pb.h" #include "capture_source_internal.h" +#include "ffi_client.h" +#include "livekit_ffi.h" + +namespace livekit { + +struct FfiClientTestAccess { + static std::size_t listenerCount() { + auto& client = FfiClient::instance(); + const std::scoped_lock lock(client.lock_); + return client.listeners_.size(); + } + + static std::size_t pendingCount() { + auto& client = FfiClient::instance(); + const std::scoped_lock lock(client.lock_); + return client.pending_by_id_.size(); + } +}; + +} // namespace livekit namespace livekit::test { namespace { +// Zero handles stand in for Rust resources: these tests exercise ownership of +// the C++ wrapper and its listener without starting a capture producer. +proto::OwnedCaptureSource makeOwned(proto::CaptureSourceKind kind = proto::CAPTURE_SOURCE_PIXEL) { + proto::OwnedCaptureSource owned; + owned.mutable_handle()->set_id(0); + auto* info = owned.mutable_info(); + info->set_kind(kind); + info->mutable_resolution()->set_width(1280); + info->mutable_resolution()->set_height(720); + auto* video = info->mutable_video_source(); + video->mutable_handle()->set_id(0); + video->mutable_info()->set_type(kind == proto::CAPTURE_SOURCE_ENCODED ? proto::VIDEO_SOURCE_ENCODED + : proto::VIDEO_SOURCE_NATIVE); + auto* recommended = info->mutable_recommended_publish_options(); + recommended->set_video_codec(proto::VP8); + recommended->set_simulcast(true); + if (kind == proto::CAPTURE_SOURCE_ENCODED) { + info->set_codec(proto::H264); + recommended->set_video_codec(proto::H264); + recommended->set_video_encoder(proto::ENCODER_BACKEND_PRE_ENCODED); + recommended->set_simulcast(false); + } + return owned; +} + +proto::FfiEvent creationEvent(std::uint64_t async_id, const proto::OwnedCaptureSource& owned) { + proto::FfiEvent event; + event.mutable_new_capture_source()->set_async_id(async_id); + event.mutable_new_capture_source()->mutable_source()->CopyFrom(owned); + return event; +} + +proto::OwnedVideoSource newNativeVideoSource() { + proto::FfiRequest request; + auto* video = request.mutable_new_video_source(); + video->set_type(proto::VIDEO_SOURCE_NATIVE); + video->mutable_resolution()->set_width(1280); + video->mutable_resolution()->set_height(720); + return FfiClient::instance().sendRequest(request).new_video_source().source(); +} + +class CaptureSourceCallbackTest : public ::testing::Test { +protected: + void SetUp() override { ASSERT_TRUE(FfiClient::instance().initialize(false)); } + void TearDown() override { FfiClient::instance().shutdown(); } + + std::future> beginCreation() { + return FfiClient::instance().newCaptureSourceAsync({}, [this](const proto::FfiRequest& request) { + async_id_ = request.new_capture_source().request_async_id(); + proto::FfiResponse response; + response.mutable_new_capture_source()->set_async_id(async_id_); + return response; + }); + } + + std::shared_ptr completeCreation(const proto::OwnedCaptureSource& owned) { + auto future = beginCreation(); + emitFfiEvent(creationEvent(async_id_, owned)); + return future.get(); + } + + std::uint64_t async_id_ = 0; +}; + +TEST_F(CaptureSourceCallbackTest, AbandonedFutureDoesNotWaitForCallbackAndReleasesWrapper) { + auto future = beginCreation(); + std::promise discarded; + auto discarded_future = discarded.get_future(); + std::thread destroyer([future = std::move(future), &discarded]() mutable { + future = {}; + discarded.set_value(); + }); + // A generous failure bound, not a latency assertion. Complete the callback + // even on failure so that a regression cannot leave the worker hanging. + EXPECT_EQ(discarded_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + emitFfiEvent(creationEvent(async_id_, makeOwned())); + destroyer.join(); + EXPECT_EQ(FfiClientTestAccess::pendingCount(), 0u); + EXPECT_EQ(FfiClientTestAccess::listenerCount(), 0u); +} + +TEST_F(CaptureSourceCallbackTest, CallbackReturnsWrapperAndOwnsVideoSource) { + auto source = completeCreation(makeOwned()); + ASSERT_NE(source, nullptr); + EXPECT_EQ(source->kind(), CaptureSourceKind::Pixel); + EXPECT_EQ(source->width(), 1280); + EXPECT_EQ(source->height(), 720); + EXPECT_EQ(FfiClientTestAccess::listenerCount(), 1u); + std::weak_ptr weak_source = source; + std::weak_ptr weak_video = source->videoSource(); + source.reset(); + EXPECT_TRUE(weak_source.expired()); + EXPECT_TRUE(weak_video.expired()); + EXPECT_EQ(FfiClientTestAccess::listenerCount(), 0u); +} + +TEST_F(CaptureSourceCallbackTest, AbandonedAndInvalidResultsReleaseBothRustHandles) { + for (const bool invalid : {false, true}) { + auto owned = makeOwned(proto::CAPTURE_SOURCE_ENCODED); + // The capture handle can be any Rust-owned resource in this synthetic + // callback; disposal is type-independent. Native video sources need no GPU. + const auto capture = newNativeVideoSource(); + const auto video = newNativeVideoSource(); + FfiHandle capture_guard(static_cast(capture.handle().id())); + FfiHandle video_guard(static_cast(video.handle().id())); + owned.mutable_handle()->CopyFrom(capture.handle()); + owned.mutable_info()->mutable_video_source()->CopyFrom(video); + if (invalid) { + owned.mutable_info()->clear_codec(); + } + auto future = beginCreation(); + if (!invalid) { + future = {}; + } + static_cast(capture_guard.release()); + static_cast(video_guard.release()); + emitFfiEvent(creationEvent(async_id_, owned)); + if (invalid) { + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + } + // A second drop must fail. If ownership leaked, this also cleans it up. + EXPECT_FALSE(livekit_ffi_drop_handle(capture.handle().id())); + EXPECT_FALSE(livekit_ffi_drop_handle(video.handle().id())); + EXPECT_EQ(FfiClientTestAccess::listenerCount(), 0u); + } +} + +TEST_F(CaptureSourceCallbackTest, CallbackMayCompleteBeforeSendReturns) { + auto future = FfiClient::instance().newCaptureSourceAsync({}, [](const proto::FfiRequest& request) { + const auto async_id = request.new_capture_source().request_async_id(); + emitFfiEvent(creationEvent(async_id, makeOwned())); + proto::FfiResponse response; + response.mutable_new_capture_source()->set_async_id(async_id); + return response; + }); + EXPECT_EQ(future.wait_for(std::chrono::seconds(0)), std::future_status::ready); + EXPECT_NE(future.get(), nullptr); +} + +TEST_F(CaptureSourceCallbackTest, CreationErrorUsesCaptureSourceError) { + auto future = beginCreation(); + proto::FfiEvent event; + event.mutable_new_capture_source()->set_async_id(async_id_); + event.mutable_new_capture_source()->set_error("discovery failed"); + emitFfiEvent(event); + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); +} + +TEST_F(CaptureSourceCallbackTest, UnrelatedCallbackDoesNotCompleteFuture) { + auto future = beginCreation(); + proto::FfiEvent event; + event.mutable_new_capture_source()->set_async_id(async_id_ + 1); + event.mutable_new_capture_source()->set_error("unrelated failure"); + emitFfiEvent(event); + EXPECT_EQ(future.wait_for(std::chrono::seconds(0)), std::future_status::timeout); + emitFfiEvent(creationEvent(async_id_, makeOwned())); + EXPECT_NE(future.get(), nullptr); +} + +TEST_F(CaptureSourceCallbackTest, MissingSourceUsesCaptureSourceError) { + auto future = beginCreation(); + proto::FfiEvent event; + event.mutable_new_capture_source()->set_async_id(async_id_); + emitFfiEvent(event); + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); +} + +TEST_F(CaptureSourceCallbackTest, InvalidEncodedInfoFailsWithoutRetainingListener) { + auto future = beginCreation(); + auto owned = makeOwned(proto::CAPTURE_SOURCE_ENCODED); + owned.mutable_info()->clear_codec(); + emitFfiEvent(creationEvent(async_id_, owned)); + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_EQ(FfiClientTestAccess::listenerCount(), 0u); +} + +TEST_F(CaptureSourceCallbackTest, ShutdownCancelsPendingFutureWithCaptureSourceError) { + auto future = beginCreation(); + FfiClient::instance().shutdown(); + EXPECT_EQ(future.wait_for(std::chrono::seconds(0)), std::future_status::ready); + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); +} + +TEST_F(CaptureSourceCallbackTest, SendFailureRemovesPendingOperation) { + EXPECT_THROW(FfiClient::instance().newCaptureSourceAsync( + {}, [](const proto::FfiRequest&) -> proto::FfiResponse { throw std::runtime_error("send failed"); }), + std::runtime_error); + EXPECT_EQ(FfiClientTestAccess::pendingCount(), 0u); +} + +TEST_F(CaptureSourceCallbackTest, PixelPreservesCodecEncoderAndDisabledSimulcast) { + auto source = completeCreation(makeOwned()); + TrackPublishOptions application; + application.video_codec = VideoCodec::H264; + application.video_encoder = VideoEncoderBackend::Hardware; + application.simulcast = false; + const auto options = source->publishOptions(application); + EXPECT_EQ(options.video_codec, application.video_codec); + EXPECT_EQ(options.video_encoder, application.video_encoder); + EXPECT_EQ(options.simulcast, application.simulcast); +} + +TEST_F(CaptureSourceCallbackTest, PixelLeavesDefaultsUnset) { + auto source = completeCreation(makeOwned()); + const auto options = source->publishOptions(); + EXPECT_FALSE(options.video_codec.has_value()); + EXPECT_FALSE(options.video_encoder.has_value()); + EXPECT_FALSE(options.simulcast.has_value()); +} + +TEST_F(CaptureSourceCallbackTest, EncodedEnforcesCodecPassthroughAndNoSimulcast) { + auto source = completeCreation(makeOwned(proto::CAPTURE_SOURCE_ENCODED)); + TrackPublishOptions application; + application.video_codec = VideoCodec::VP9; + application.video_encoder = VideoEncoderBackend::Hardware; + application.simulcast = true; + for (const auto& options : {source->publishOptions(application), source->publishOptions()}) { + EXPECT_EQ(options.video_codec, VideoCodec::H264); + EXPECT_EQ(options.video_encoder, VideoEncoderBackend::PreEncoded); + EXPECT_EQ(options.simulcast, false); + } +} + +TEST_F(CaptureSourceCallbackTest, RecommendationsCannotReplaceOtherApplicationOptions) { + for (const auto kind : {proto::CAPTURE_SOURCE_PIXEL, proto::CAPTURE_SOURCE_ENCODED}) { + auto owned = makeOwned(kind); + auto* recommended = owned.mutable_info()->mutable_recommended_publish_options(); + recommended->set_stream("camera"); + recommended->set_source(proto::SOURCE_CAMERA); + recommended->mutable_video_encoding()->set_max_bitrate(100000); + recommended->mutable_video_encoding()->set_max_framerate(15); + auto source = completeCreation(owned); + TrackPublishOptions application; + application.stream = "presentation"; + application.source = TrackSource::SOURCE_SCREENSHARE; + application.video_encoding = VideoEncodingOptions{2000000, 30}; + application.audio_encoding = AudioEncodingOptions{64000}; + application.dtx = false; + application.red = false; + application.preconnect_buffer = true; + application.frame_metadata_features = FrameMetadataFeatures{true, true, true}; + application.degradation_preference = DegradationPreference::MaintainResolution; + const auto options = source->publishOptions(application); + EXPECT_EQ(options.stream, application.stream); + EXPECT_EQ(options.source, application.source); + ASSERT_TRUE(options.video_encoding.has_value()); + EXPECT_EQ(options.video_encoding->max_bitrate, 2000000u); + EXPECT_EQ(options.video_encoding->max_framerate, 30); + ASSERT_TRUE(options.audio_encoding.has_value()); + EXPECT_EQ(options.audio_encoding->max_bitrate, 64000u); + EXPECT_EQ(options.dtx, application.dtx); + EXPECT_EQ(options.red, application.red); + EXPECT_EQ(options.preconnect_buffer, application.preconnect_buffer); + ASSERT_TRUE(options.frame_metadata_features.has_value()); + EXPECT_TRUE(options.frame_metadata_features->user_timestamp); + EXPECT_TRUE(options.frame_metadata_features->frame_id); + EXPECT_TRUE(options.frame_metadata_features->user_data); + EXPECT_EQ(options.degradation_preference, application.degradation_preference); + } +} + TEST(CaptureSourceProtoTest, ClockConfigurationUsesMainlineWireContract) { ClockVideoSourceConfig config; config.resolution = {1920, 1080}; @@ -70,6 +357,18 @@ TEST(CaptureSourceProtoTest, ValidationErrorsAreDeliveredThroughFuture) { EXPECT_THROW(static_cast(future.get()), CaptureSourceError); } +TEST(CaptureSourceProtoTest, SendErrorsAreDeliveredThroughFuture) { + FfiClient::instance().shutdown(); + ClockVideoSourceConfig config; + config.resolution = {1280, 720}; + config.framerate_fps = 30; + std::future> future; + EXPECT_NO_THROW(future = CaptureSource::create(config)); + ASSERT_TRUE(future.valid()); + EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_EQ(FfiClientTestAccess::pendingCount(), 0u); +} + TEST(CaptureSourceProtoTest, GstreamerCanDiscoverResolution) { GstreamerVideoSourceConfig config; config.pipeline = "videotestsrc ! vp8enc ! appsink name=lk_appsink"; From 771299e2c7b9e2d2be18492e8283671b1c36a1df Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 12 Oct 2026 12:06:22 -0600 Subject: [PATCH 08/29] two step build/test: base sdk, then with capture --- .github/workflows/builds.yml | 295 ++++++++++++++++++++--------- .github/workflows/make-release.yml | 251 ++++++++++++------------ .github/workflows/tests.yml | 64 +++++-- AGENTS.md | 6 +- docs/building.md | 12 ++ 5 files changed, 405 insertions(+), 223 deletions(-) diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index e28ee40f..b580386d 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -12,10 +12,8 @@ permissions: env: CARGO_TERM_COLOR: always - # Disable Cargo incremental artifacts. The Rust FFI is built once per - # submodule SHA and cached whole (see the cargo target cache below), so - # incremental dirs add nothing but bloat the cached target/ directory and - # slow the cache upload/download. + # Cache both feature-specific Rust builds per submodule SHA. Cargo + # incremental codegen directories add cache size without helping CMake reuse. CARGO_INCREMENTAL: "0" # vcpkg binary caching for Windows. The x-gha backend was removed from vcpkg, # so use vcpkg's files provider and persist that directory with actions/cache. @@ -34,71 +32,41 @@ jobs: include: - os: ubuntu-22.04 name: ubuntu-22.04-x64 - build_cmd: ./build.sh release-examples --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64 + build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64 + capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64-capture build_dir: build-release - os: ubuntu-22.04-arm name: ubuntu-22.04-arm64 - build_cmd: ./build.sh release-examples --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64 + build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64 + capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64-capture build_dir: build-release - os: ubuntu-24.04 name: ubuntu-24.04-x64 - build_cmd: ./build.sh release-examples --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64 + build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64 + capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64-capture build_dir: build-release - os: ubuntu-24.04-arm name: ubuntu-24.04-arm64 - build_cmd: ./build.sh release-examples --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64 + build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64 + capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64-capture build_dir: build-release - os: macos-26-xlarge name: macos-arm64 - build_cmd: ./build.sh release-examples --bundle --prefix sdk-out/livekit-sdk-macos-arm64 + build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-macos-arm64 + capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-arm64-capture build_dir: build-release - os: macos-26-large name: macos-x64 - build_cmd: ./build.sh release-examples --bundle --prefix sdk-out/livekit-sdk-macos-x64 --macos-arch x86_64 + build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-macos-x64 --macos-arch x86_64 + capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-x64-capture --macos-arch x86_64 build_dir: build-release # Pinned to Windows 2022 for current VS 17 implementation - os: windows-2022 name: windows-x64 - build_cmd: .\build.cmd release-examples --bundle --prefix sdk-out\livekit-sdk-windows-x64 + build_cmd: .\build.cmd release-examples --no-capture --bundle --prefix sdk-out\livekit-sdk-windows-x64 + capture_build_cmd: .\build.cmd release-examples --capture --bundle --prefix sdk-out\livekit-sdk-windows-x64-capture build_dir: build-release - - os: ubuntu-22.04 - name: ubuntu-22.04-x64-capture - build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64-capture - build_dir: build-release - capture: true - - os: ubuntu-22.04-arm - name: ubuntu-22.04-arm64-capture - build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64-capture - build_dir: build-release - capture: true - - os: ubuntu-24.04 - name: ubuntu-24.04-x64-capture - build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64-capture - build_dir: build-release - capture: true - - os: ubuntu-24.04-arm - name: ubuntu-24.04-arm64-capture - build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64-capture - build_dir: build-release - capture: true - - os: macos-26-xlarge - name: macos-arm64-capture - build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-arm64-capture - build_dir: build-release - capture: true - - os: macos-26-large - name: macos-x64-capture - build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-x64-capture --macos-arch x86_64 - build_dir: build-release - # Pinned to Windows 2022 for current VS 17 implementation - capture: true - - os: windows-2022 - name: windows-x64-capture - build_cmd: .\build.cmd release-examples --capture --bundle --prefix sdk-out\livekit-sdk-windows-x64-capture - build_dir: build-release - capture: true - name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} @@ -186,10 +154,6 @@ jobs: brew update brew install cmake ninja protobuf abseil - - name: Set up capture dependencies - if: matrix.capture == true - uses: ./.github/actions/setup-capture - # ---------- Rust toolchain ---------- - name: Install Rust (stable) uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 @@ -234,9 +198,9 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. @@ -268,26 +232,6 @@ jobs: shell: pwsh run: ${{ matrix.build_cmd }} - # Save the populated target/ now, while the build output still exists and - # before the "Clean after build" step runs clean-all. Save only from main - # pushes so PRs restore from the shared main cache instead of creating - # large PR-scoped target caches that quickly evict useful entries. - - name: Save cargo target - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.cache-cargo-target.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - client-sdk-rust/target/ - ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} - - - name: Save vcpkg binary cache - if: runner.os == 'Windows' && steps.cache-vcpkg-binary.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ env.VCPKG_BINARY_CACHE_DIR }} - key: ${{ runner.os }}-${{ matrix.name }}-vcpkg-binary-${{ env.VCPKG_TARGET_TRIPLET }}-${{ env.VCPKG_GIT_COMMIT }}-${{ hashFiles('vcpkg.json') }} - # ---------- Smoke test cpp-example-collection binaries ---------- # Built under cpp-example-collection-build/ (not build-dir/bin). Visual Studio # multi-config places executables in per-target Release/ (or Debug/) subdirs. @@ -393,36 +337,37 @@ jobs: run: | set -euo pipefail bundle="sdk-out/livekit-sdk-${{ matrix.name }}" + grep -F "#define LIVEKIT_CAPTURE_ENABLED 0" "${bundle}/include/livekit/build.h" test -f "${bundle}/include/livekit/livekit.h" test -f "${bundle}/share/livekit/build-info.json" test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfig.cmake" test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfigVersion.cmake" test -f "${bundle}/lib/cmake/LiveKit/LiveKitTargets.cmake" - - name: Test installed capture SDK (Unix) - if: matrix.capture == true && runner.os != 'Windows' + - name: Test installed standard SDK (Unix) + if: runner.os != 'Windows' shell: bash run: | bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}" arch_args=() if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-tests -G Ninja \ + cmake -S src/tests/packaging -B build-packaging-standard -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" - cmake --build build-packaging-tests - ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging + -DLIVEKIT_EXPECT_CAPTURE=OFF "${arch_args[@]}" + cmake --build build-packaging-standard + ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging - - name: Test installed capture SDK (Windows) - if: matrix.capture == true && runner.os == 'Windows' + - name: Test installed standard SDK (Windows) + if: runner.os == 'Windows' shell: pwsh run: | $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}" - cmake -S src/tests/packaging -B build-packaging-tests -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON + cmake -S src/tests/packaging -B build-packaging-standard -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=OFF if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-tests --config Release + cmake --build build-packaging-standard --config Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging + ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # ---------- Upload artifacts ---------- @@ -433,6 +378,186 @@ jobs: path: sdk-out/livekit-sdk-${{ matrix.name }} retention-days: 7 + # Reconfigure the same CMake tree after the standard bundle and aliases + # have been validated and uploaded. Keep both install prefixes separate. + - name: Set up capture dependencies + uses: ./.github/actions/setup-capture + + - name: Rebuild with capture (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + chmod +x build.sh + ${{ matrix.capture_build_cmd }} + + - name: Rebuild with capture (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ${{ matrix.capture_build_cmd }} + + - name: Smoke test capture examples (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -x + failed=false + examples_base="${{ matrix.build_dir }}/cpp-example-collection-build" + resolve_exe() { + local dir="$1" name="$2" + local p="${examples_base}/${dir}/${name}" + if [[ -x "${p}" ]]; then + printf '%s' "${p}" + return 0 + fi + p="${examples_base}/${dir}/Release/${name}" + if [[ -x "${p}" ]]; then + printf '%s' "${p}" + return 0 + fi + return 1 + } + for pair in "SimpleRoom:simple_room" "SimpleRpc:simple_rpc" "SimpleDataStream:simple_data_stream"; do + exe="${pair%%:*}" + dir="${pair#*:}" + if ! exe_path="$(resolve_exe "${dir}" "${exe}")"; then + echo "ERROR: ${exe} not found under ${examples_base}/${dir}/" + failed=true + continue + fi + echo "Testing ${exe}..." + output=$("${exe_path}" --help 2>&1) || true + if [[ -z "${output}" ]]; then + echo "ERROR: ${exe} produced no output" + failed=true + else + echo "${output}" + echo "${exe} ran successfully" + fi + done + if [[ "$failed" == "true" ]]; then exit 1; fi + + - name: Smoke test capture examples (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $examplesBase = "${{ matrix.build_dir }}/cpp-example-collection-build" + $examples = @( + @{ Name = 'SimpleRoom'; Dir = 'simple_room' }, + @{ Name = 'SimpleRpc'; Dir = 'simple_rpc' }, + @{ Name = 'SimpleDataStream'; Dir = 'simple_data_stream' } + ) + $failed = $false + foreach ($ex in $examples) { + $name = $ex.Name + $dir = $ex.Dir + $inDir = Join-Path $examplesBase $dir + $candidates = @( + (Join-Path $inDir "$name.exe"), + (Join-Path (Join-Path $inDir 'Release') "$name.exe") + ) + $exePath = $null + foreach ($p in $candidates) { + if (Test-Path -LiteralPath $p) { + $exePath = $p + break + } + } + if ($null -ne $exePath) { + Write-Host "Testing ${name}..." + $pinfo = New-Object System.Diagnostics.ProcessStartInfo + $pinfo.FileName = $exePath + $pinfo.Arguments = "--help" + $pinfo.RedirectStandardOutput = $true + $pinfo.RedirectStandardError = $true + $pinfo.UseShellExecute = $false + $p = New-Object System.Diagnostics.Process + $p.StartInfo = $pinfo + $p.Start() | Out-Null + $stdout = $p.StandardOutput.ReadToEnd() + $stderr = $p.StandardError.ReadToEnd() + $p.WaitForExit() + $output = $stdout + $stderr + if ([string]::IsNullOrWhiteSpace($output)) { + Write-Host "ERROR: ${name} produced no output" + $failed = $true + } else { + Write-Host $output + Write-Host "${name} ran successfully" + } + } else { + Write-Host "ERROR: ${name} not found under ${examplesBase}/${dir}/" + $failed = $true + } + } + if ($failed) { exit 1 } else { exit 0 } + + - name: Verify capture SDK bundle + shell: bash + run: | + set -euo pipefail + bundle="sdk-out/livekit-sdk-${{ matrix.name }}-capture" + grep -F "#define LIVEKIT_CAPTURE_ENABLED 1" "${bundle}/include/livekit/build.h" + test -f "${bundle}/include/livekit/livekit.h" + test -f "${bundle}/share/livekit/build-info.json" + test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfig.cmake" + test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfigVersion.cmake" + test -f "${bundle}/lib/cmake/LiveKit/LiveKitTargets.cmake" + + - name: Test installed capture SDK (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-capture" + arch_args=() + if [[ "${{ matrix.name }}" == macos-x64 ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi + cmake -S src/tests/packaging -B build-packaging-capture -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ + -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" + cmake --build build-packaging-capture + ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging + + - name: Test installed capture SDK (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-capture" + cmake -S src/tests/packaging -B build-packaging-capture -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build build-packaging-capture --config Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:PATH = "$bundle\bin;$env:PATH" + ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload capture SDK bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: livekit-sdk-${{ matrix.name }}-capture + path: sdk-out/livekit-sdk-${{ matrix.name }}-capture + retention-days: 7 + + # Save the populated target/ now, while the build output still exists and + # before the "Clean after build" step runs clean-all. Save only from main + # pushes so PRs restore from the shared main cache instead of creating + # large PR-scoped target caches that quickly evict useful entries. + - name: Save cargo target + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.cache-cargo-target.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} + + - name: Save vcpkg binary cache + if: runner.os == 'Windows' && steps.cache-vcpkg-binary.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.VCPKG_BINARY_CACHE_DIR }} + key: ${{ runner.os }}-${{ matrix.name }}-vcpkg-binary-${{ env.VCPKG_TARGET_TRIPLET }}-${{ env.VCPKG_GIT_COMMIT }}-${{ hashFiles('vcpkg.json') }} + + # ---------- Cleanup ---------- - name: Clean after build (best-effort) if: always() diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 954db05e..f3640770 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -50,36 +50,6 @@ jobs: name: windows-x64 generator: "Visual Studio 17 2022" - - os: ubuntu-22.04 - name: ubuntu-22.04-x64-capture - generator: Ninja - capture: true - - os: ubuntu-22.04-arm - name: ubuntu-22.04-arm64-capture - generator: Ninja - capture: true - - os: ubuntu-24.04 - name: ubuntu-24.04-x64-capture - generator: Ninja - capture: true - - os: ubuntu-24.04-arm - name: ubuntu-24.04-arm64-capture - generator: Ninja - capture: true - - os: macos-26-xlarge - name: macos-arm64-capture - generator: Ninja - capture: true - - os: macos-26-large - name: macos-x64-capture - generator: Ninja - # Pinned to Windows 2022 for current VS 17 implementation - capture: true - - os: windows-2022 - name: windows-x64-capture - generator: "Visual Studio 17 2022" - capture: true - name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} @@ -183,7 +153,6 @@ jobs: brew install cmake ninja protobuf abseil - name: Set up capture dependencies - if: matrix.capture == true uses: ./.github/actions/setup-capture # ---------- Rust toolchain ---------- @@ -226,9 +195,9 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. @@ -252,6 +221,7 @@ jobs: if: runner.os != 'Windows' shell: bash run: | + set -euo pipefail chmod +x build.sh version="${{ steps.version.outputs.version }}" bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}-${version}" @@ -265,17 +235,19 @@ jobs: exit 1 fi - capture_args=() - if [[ "${{ matrix.capture }}" == "true" ]]; then capture_args+=(--capture); fi - if [[ "${{ matrix.name }}" == macos-x64* ]]; then - ./build.sh release-examples "${capture_args[@]}" --version "$version" --bundle --prefix "$bundleDir" --macos-arch x86_64 - else - ./build.sh release-examples "${capture_args[@]}" --version "$version" --bundle --prefix "$bundleDir" - fi - - # List bundle contents - echo "Bundle contents:" - find "$bundleDir" -type f | head -120 + arch_args=() + if [[ "${{ matrix.name }}" == macos-x64 ]]; then arch_args+=(--macos-arch x86_64); fi + for variant in standard capture; do + if [[ "$variant" == standard ]]; then + capture_flag=--no-capture + suffix="" + else + capture_flag=--capture + suffix="-capture" + fi + bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}${suffix}-${version}" + ./build.sh release-examples "$capture_flag" --version "$version" --bundle --prefix "$bundleDir" "${arch_args[@]}" + done - name: Validate bundle version metadata (Unix) if: runner.os != 'Windows' @@ -283,20 +255,22 @@ jobs: run: | set -euo pipefail version="${{ steps.version.outputs.version }}" - bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}-${version}" - header="${bundleDir}/include/livekit/build.h" - buildInfo="${bundleDir}/share/livekit/build-info.json" - - test -f "$header" - test -f "$buildInfo" - grep -F "#define LIVEKIT_BUILD_VERSION \"${version}\"" "$header" - if grep -F '#define LIVEKIT_BUILD_VERSION "0.0.0"' "$header"; then - echo "ERROR: bundle reports fallback SDK version" - exit 1 - fi - grep -F "\"sdk_version\": \"${version}\"" "$buildInfo" - grep -Eq '"rust_submodule_commit": "[0-9a-f]{7,}"' "$buildInfo" - grep -Eq '"livekit_ffi_version": "[0-9]+\.[0-9]+\.[0-9]+' "$buildInfo" + for suffix in "" "-capture"; do + bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}${suffix}-${version}" + header="${bundleDir}/include/livekit/build.h" + buildInfo="${bundleDir}/share/livekit/build-info.json" + + test -f "$header" + test -f "$buildInfo" + grep -F "#define LIVEKIT_BUILD_VERSION \"${version}\"" "$header" + if grep -F '#define LIVEKIT_BUILD_VERSION "0.0.0"' "$header"; then + echo "ERROR: bundle reports fallback SDK version" + exit 1 + fi + grep -F "\"sdk_version\": \"${version}\"" "$buildInfo" + grep -Eq '"rust_submodule_commit": "[0-9a-f]{7,}"' "$buildInfo" + grep -Eq '"livekit_ffi_version": "[0-9]+\.[0-9]+\.[0-9]+' "$buildInfo" + done # ---------- Build + Bundle (Windows) ---------- - name: Build and Bundle (Windows) @@ -307,13 +281,17 @@ jobs: $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}-$version" if ([string]::IsNullOrWhiteSpace($version)) { throw "version is empty" } - $captureArgs = @() - if ("${{ matrix.capture }}" -eq "true") { $captureArgs += "--capture" } - .\build.cmd release-examples @captureArgs --version "$version" --bundle --prefix "$bundleDir" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - Write-Host "Bundle contents:" - Get-ChildItem -Recurse -File $bundleDir | Select-Object -First 200 | ForEach-Object { $_.FullName } + foreach ($variant in @("standard", "capture")) { + $captureFlag = "--no-capture" + $suffix = "" + if ($variant -eq "capture") { + $captureFlag = "--capture" + $suffix = "-capture" + } + $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}$suffix-$version" + .\build.cmd release-examples $captureFlag --version "$version" --bundle --prefix "$bundleDir" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } # ---------- Verify exported ABI: no third-party symbol leaks ---------- - name: Setup Python @@ -325,96 +303,111 @@ jobs: if: runner.os != 'Windows' shell: bash run: | - set -eux - libdir="build-release/lib" - if [[ "$RUNNER_OS" == "macOS" ]]; then - lib="${libdir}/liblivekit.dylib" - if [[ ! -f "${lib}" ]]; then - lib="build-release/bin/liblivekit.dylib" - fi - else + set -euo pipefail + version="${{ steps.version.outputs.version }}" + for suffix in "" "-capture"; do + libdir="sdk-out/livekit-sdk-${{ matrix.name }}${suffix}-${version}/lib" lib="${libdir}/liblivekit.so" - if [[ ! -f "${lib}" ]]; then - lib="build-release/bin/liblivekit.so" - fi - fi - python3 .github/scripts/check_no_private_symbols.py "${lib}" + if [[ "$RUNNER_OS" == "macOS" ]]; then lib="${libdir}/liblivekit.dylib"; fi + python3 .github/scripts/check_no_private_symbols.py "$lib" + done - name: Symbol leak check (Windows) if: runner.os == 'Windows' shell: pwsh run: | - $candidates = @( - "build-release/bin/livekit.dll", - "build-release/lib/livekit.dll" - ) - $lib = $null - foreach ($p in $candidates) { - if (Test-Path -LiteralPath $p) { $lib = $p; break } - } - if ($null -eq $lib) { - Write-Error "livekit.dll not found in any of: $($candidates -join ', ')" - exit 1 + $version = "${{ steps.version.outputs.version }}" + foreach ($suffix in @("", "-capture")) { + $lib = "sdk-out/livekit-sdk-${{ matrix.name }}$suffix-$version/bin/livekit.dll" + python .github/scripts/check_no_private_symbols.py "$lib" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - python .github/scripts/check_no_private_symbols.py "$lib" - name: Validate bundle version metadata (Windows) if: runner.os == 'Windows' shell: pwsh run: | $version = "${{ steps.version.outputs.version }}" - $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}-$version" - $header = Join-Path $bundleDir "include/livekit/build.h" - $buildInfo = Join-Path $bundleDir "share/livekit/build-info.json" - - if (!(Test-Path $header)) { throw "Missing $header" } - if (!(Test-Path $buildInfo)) { throw "Missing $buildInfo" } - - $headerText = Get-Content -Raw $header - $expectedHeaderVersion = "#define LIVEKIT_BUILD_VERSION `"$version`"" - if (!$headerText.Contains($expectedHeaderVersion)) { - throw "Header does not report expected SDK version $version" - } - if ($headerText.Contains('#define LIVEKIT_BUILD_VERSION "0.0.0"')) { - throw "Bundle reports fallback SDK version" - } - - $buildInfoText = Get-Content -Raw $buildInfo - $expectedBuildInfoVersion = "`"sdk_version`": `"$version`"" - if (!$buildInfoText.Contains($expectedBuildInfoVersion)) { - throw "Build info does not report expected SDK version $version" - } - if ($buildInfoText -match '"rust_submodule_commit": "unknown"') { - throw "Build info is missing Rust submodule commit" - } - if ($buildInfoText -match '"livekit_ffi_version": "unknown"') { - throw "Build info is missing livekit-ffi version" + foreach ($suffix in @("", "-capture")) { + $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}$suffix-$version" + $header = Join-Path $bundleDir "include/livekit/build.h" + $buildInfo = Join-Path $bundleDir "share/livekit/build-info.json" + + if (!(Test-Path $header)) { throw "Missing $header" } + if (!(Test-Path $buildInfo)) { throw "Missing $buildInfo" } + + $headerText = Get-Content -Raw $header + $expectedHeaderVersion = "#define LIVEKIT_BUILD_VERSION `"$version`"" + if (!$headerText.Contains($expectedHeaderVersion)) { + throw "Header does not report expected SDK version $version" + } + if ($headerText.Contains('#define LIVEKIT_BUILD_VERSION "0.0.0"')) { + throw "Bundle reports fallback SDK version" + } + + $buildInfoText = Get-Content -Raw $buildInfo + $expectedBuildInfoVersion = "`"sdk_version`": `"$version`"" + if (!$buildInfoText.Contains($expectedBuildInfoVersion)) { + throw "Build info does not report expected SDK version $version" + } + if ($buildInfoText -match '"rust_submodule_commit": "unknown"') { + throw "Build info is missing Rust submodule commit" + } + if ($buildInfoText -match '"livekit_ffi_version": "unknown"') { + throw "Build info is missing livekit-ffi version" + } } - - name: Test installed capture SDK (Unix) - if: matrix.capture == true && runner.os != 'Windows' + - name: Test installed standard SDK (Unix) + if: runner.os != 'Windows' shell: bash run: | bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" arch_args=() if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-tests -G Ninja \ + cmake -S src/tests/packaging -B build-packaging-standard -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ + -DLIVEKIT_EXPECT_CAPTURE=OFF "${arch_args[@]}" + cmake --build build-packaging-standard + ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging + + - name: Test installed standard SDK (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" + cmake -S src/tests/packaging -B build-packaging-standard -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=OFF + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build build-packaging-standard --config Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:PATH = "$bundle\bin;$env:PATH" + ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Test installed capture SDK (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }}" + arch_args=() + if [[ "${{ matrix.name }}" == macos-x64 ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi + cmake -S src/tests/packaging -B build-packaging-capture -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" - cmake --build build-packaging-tests - ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging + cmake --build build-packaging-capture + ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging - name: Test installed capture SDK (Windows) - if: matrix.capture == true && runner.os == 'Windows' + if: runner.os == 'Windows' shell: pwsh run: | - $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" - cmake -S src/tests/packaging -B build-packaging-tests -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON + $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }}" + cmake -S src/tests/packaging -B build-packaging-capture -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-tests --config Release + cmake --build build-packaging-capture --config Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-tests -C Release --output-on-failure -L packaging + ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # ---------- Upload artifact (raw directory, no pre-compression) ---------- @@ -424,6 +417,12 @@ jobs: name: livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} path: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} + - name: Upload capture build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + path: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + # ---------- Release Job ---------- release: name: Create GitHub Release diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b728fe3c..d4aa0a15 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -118,10 +118,8 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: full - # Disable Cargo incremental artifacts. The Rust FFI is built once per - # submodule SHA and cached whole (see the cargo target cache below), so - # incremental dirs add nothing but bloat the cached target/ directory and - # slow the cache upload/download. + # Restore the cache containing both feature-specific Rust builds. Cargo + # incremental codegen directories add cache size without helping CMake reuse. CARGO_INCREMENTAL: "0" # vcpkg binary caching for Windows (mirrors builds.yml). The x-gha backend # was removed from vcpkg, so use vcpkg's files provider and persist that @@ -331,9 +329,9 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v2- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. @@ -365,23 +363,67 @@ jobs: sudo sysctl -w "kern.corefile=${RUNNER_TEMP}/livekit-core.%P" fi - # ---------- Build (tests on, examples off) ---------- - - name: Build tests (Unix) + # Validate the standard installed SDK, then enable capture in the same + # CMake tree. Compile and run the complete test suite once, with capture. + - name: Build standard SDK (Unix) if: runner.os != 'Windows' shell: bash run: | set -euo pipefail chmod +x build.sh - build_cmd=(./build.sh "${{ inputs.build_type }}-tests") + build_cmd=(./build.sh "${{ inputs.build_type }}" --no-capture --bundle --prefix sdk-out/test-sdk-standard) if [[ -n "${{ matrix.macos_arch || '' }}" ]]; then build_cmd+=(--macos-arch "${{ matrix.macos_arch }}") fi "${build_cmd[@]}" - - name: Build tests (Windows) + - name: Build standard SDK (Windows) if: runner.os == 'Windows' shell: pwsh - run: .\build.cmd ${{ inputs.build_type }}-tests + run: .\build.cmd ${{ inputs.build_type }} --no-capture --bundle --prefix sdk-out/test-sdk-standard + + - name: Test installed standard SDK (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + bundle="$GITHUB_WORKSPACE/sdk-out/test-sdk-standard" + arch_args=() + if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi + cmake -S src/tests/packaging -B build-packaging-standard -G Ninja \ + -DCMAKE_BUILD_TYPE=${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} -DCMAKE_PREFIX_PATH="$bundle" \ + -DLIVEKIT_EXPECT_CAPTURE=OFF "${arch_args[@]}" + cmake --build build-packaging-standard + ctest --test-dir build-packaging-standard -C ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} --output-on-failure -L packaging + + - name: Test installed standard SDK (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/test-sdk-standard" + cmake -S src/tests/packaging -B build-packaging-standard -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=OFF + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build build-packaging-standard --config ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:PATH = "$bundle\bin;$env:PATH" + ctest --test-dir build-packaging-standard -C ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} --output-on-failure -L packaging + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Rebuild tests with capture (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + chmod +x build.sh + build_cmd=(./build.sh "${{ inputs.build_type }}-tests" --capture) + if [[ -n "${{ matrix.macos_arch || '' }}" ]]; then + build_cmd+=(--macos-arch "${{ matrix.macos_arch }}") + fi + "${build_cmd[@]}" + + - name: Rebuild tests with capture (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: .\build.cmd ${{ inputs.build_type }}-tests --capture # ---------- Run unit tests ---------- - name: Run unit tests (Unix) diff --git a/AGENTS.md b/AGENTS.md index b4d0da94..2f6e31d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -408,8 +408,12 @@ all filtered stages; normal pull requests and pushes use the path filters. - `.github/workflows/ci.yml` — Top-level PR/push/manual aggregator and path filter owner. - `.github/workflows/builds.yml` — Reusable SDK and example-collection build - matrix. + matrix. Each platform job builds, validates, and uploads the standard SDK, + then enables capture in the same CMake tree and produces a separate capture + bundle. Save the Cargo target cache only after both builds complete. - `.github/workflows/tests.yml` — Reusable unit/integration test matrix. + Each platform job first validates an installed standard SDK, then rebuilds + with capture enabled and runs the complete test suite once. - `.github/workflows/cpp-tools.yml` — Reusable SDK-specific `clang-format` and `clang-tidy` workflow. It prepares the build environment and invokes the project wrappers backed by the shared `cpp-tools` scripts. diff --git a/docs/building.md b/docs/building.md index f67ac364..943b5ee7 100644 --- a/docs/building.md +++ b/docs/building.md @@ -378,6 +378,18 @@ They require GStreamer development libraries at build time and its runtime libraries when the SDK is loaded. Release artifacts with `-capture` in their names include capture; standard artifacts disable it. +CI builds both variants in one job per platform. It first builds and bundles +the standard SDK, then runs the build script with `--capture` in the same CMake +build directory. Unchanged C++ dependencies and generated protobuf outputs are +reused. Both bundles have separate install directories and artifact names, and +both are checked through the installed-SDK packaging tests. Release jobs also +check version metadata and exported symbols in both bundles. + +Test jobs validate the standard installed SDK first, then enable capture and +run the full unit, integration, and optional stress suites once. Rust outputs +remain separate for each feature configuration; the shared Cargo cache stores +both configurations. + Rust artifacts are stored under `client-sdk-rust/target/cpp-/`, with separate directories for capture-enabled and disabled builds. Generated `build.h` files live in each CMake build directory, so feature metadata follows From 8028ce7e3eeb09670d710523293908cc395365f6 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 12 Oct 2026 13:33:28 -0600 Subject: [PATCH 09/29] remove duplicate code --- .github/actions/build-sdk/action.yml | 74 ++++ .github/actions/cargo-target-root/action.yml | 26 ++ .github/actions/check-sdk-abi/action.yml | 34 ++ .github/actions/smoke-examples/action.yml | 120 ++++++ .github/actions/test-sdk-bundle/action.yml | 72 ++++ .github/workflows/builds.yml | 363 ++---------------- .github/workflows/ci.yml | 9 + .github/workflows/make-release.yml | 253 +++--------- .github/workflows/tests.yml | 138 ++----- AGENTS.md | 4 +- CMakeLists.txt | 18 +- CMakePresets.json | 147 ++++--- cmake/uniffi_cpp.cmake | 6 +- docs/testing.md | 3 + include/livekit/capture_source.h | 5 - src/capture_source.cpp | 119 +++--- src/capture_source_internal.h | 4 + src/tests/CMakeLists.txt | 4 - src/tests/integration/test_capture_source.cpp | 27 +- src/tests/packaging/CMakeLists.txt | 45 +++ src/tests/unit/test_capture_source.cpp | 9 - 21 files changed, 657 insertions(+), 823 deletions(-) create mode 100644 .github/actions/build-sdk/action.yml create mode 100644 .github/actions/cargo-target-root/action.yml create mode 100644 .github/actions/check-sdk-abi/action.yml create mode 100644 .github/actions/smoke-examples/action.yml create mode 100644 .github/actions/test-sdk-bundle/action.yml diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml new file mode 100644 index 00000000..a0c911ef --- /dev/null +++ b/.github/actions/build-sdk/action.yml @@ -0,0 +1,74 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Build SDK +description: Configure and build an SDK variant with the repository build scripts. +inputs: + preset: + description: Build script command, such as release-examples or debug-tests. + required: true + capture: + description: Whether to enable capture (ON or OFF). + default: 'OFF' + bundle-dir: + description: Optional installation directory for an SDK bundle. + default: '' + version: + description: Optional SDK version to inject. + default: '' + macos-arch: + description: Optional macOS architecture override. + default: '' +runs: + using: composite + steps: + - name: Build (Unix) + if: runner.os != 'Windows' + shell: bash + env: + SDK_PRESET: ${{ inputs.preset }} + SDK_CAPTURE: ${{ inputs.capture }} + SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} + SDK_VERSION: ${{ inputs.version }} + SDK_MACOS_ARCH: ${{ inputs.macos-arch }} + run: | + args=(./build.sh "$SDK_PRESET") + case "$SDK_CAPTURE" in + ON) args+=(--capture) ;; + OFF) args+=(--no-capture) ;; + *) echo "Invalid capture setting: $SDK_CAPTURE" >&2; exit 1 ;; + esac + if [[ -n "$SDK_BUNDLE_DIR" ]]; then args+=(--bundle --prefix "$SDK_BUNDLE_DIR"); fi + if [[ -n "$SDK_VERSION" ]]; then args+=(--version "$SDK_VERSION"); fi + if [[ -n "$SDK_MACOS_ARCH" ]]; then args+=(--macos-arch "$SDK_MACOS_ARCH"); fi + "${args[@]}" + - name: Build (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SDK_PRESET: ${{ inputs.preset }} + SDK_CAPTURE: ${{ inputs.capture }} + SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} + SDK_VERSION: ${{ inputs.version }} + run: | + $buildArgs = @($env:SDK_PRESET) + switch ($env:SDK_CAPTURE) { + 'ON' { $buildArgs += '--capture' } + 'OFF' { $buildArgs += '--no-capture' } + default { throw "Invalid capture setting: $env:SDK_CAPTURE" } + } + if ($env:SDK_BUNDLE_DIR) { $buildArgs += @('--bundle', '--prefix', $env:SDK_BUNDLE_DIR) } + if ($env:SDK_VERSION) { $buildArgs += @('--version', $env:SDK_VERSION) } + & .\build.cmd @buildArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/actions/cargo-target-root/action.yml b/.github/actions/cargo-target-root/action.yml new file mode 100644 index 00000000..f5934ece --- /dev/null +++ b/.github/actions/cargo-target-root/action.yml @@ -0,0 +1,26 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Set Cargo output root +description: Use a short Cargo root on Windows and the submodule target directory elsewhere. +runs: + using: composite + steps: + - shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" + else + echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" + fi diff --git a/.github/actions/check-sdk-abi/action.yml b/.github/actions/check-sdk-abi/action.yml new file mode 100644 index 00000000..6b26d62b --- /dev/null +++ b/.github/actions/check-sdk-abi/action.yml @@ -0,0 +1,34 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Check SDK exported ABI +description: Reject private dependency symbols exported by an installed SDK. +inputs: + bundle-dir: + description: SDK installation directory relative to the workspace. + required: true +runs: + using: composite + steps: + - shell: bash + env: + SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} + run: | + case "$RUNNER_OS" in + Windows) lib="$SDK_BUNDLE_DIR/bin/livekit.dll" ;; + macOS) lib="$SDK_BUNDLE_DIR/lib/liblivekit.dylib" ;; + Linux) lib="$SDK_BUNDLE_DIR/lib/liblivekit.so" ;; + *) echo "Unsupported runner OS: $RUNNER_OS" >&2; exit 1 ;; + esac + python .github/scripts/check_no_private_symbols.py "$lib" diff --git a/.github/actions/smoke-examples/action.yml b/.github/actions/smoke-examples/action.yml new file mode 100644 index 00000000..db5b4223 --- /dev/null +++ b/.github/actions/smoke-examples/action.yml @@ -0,0 +1,120 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Smoke test example binaries +description: Run the example collection help commands through the built SDK. +inputs: + build-dir: + description: SDK CMake build directory. + required: true +runs: + using: composite + steps: + - name: Smoke test examples (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -x + failed=false + examples_base="${{ inputs.build-dir }}/cpp-example-collection-build" + resolve_exe() { + local dir="$1" name="$2" + local p="${examples_base}/${dir}/${name}" + if [[ -x "${p}" ]]; then + printf '%s' "${p}" + return 0 + fi + p="${examples_base}/${dir}/Release/${name}" + if [[ -x "${p}" ]]; then + printf '%s' "${p}" + return 0 + fi + return 1 + } + for pair in "SimpleRoom:simple_room" "SimpleRpc:simple_rpc" "SimpleDataStream:simple_data_stream"; do + exe="${pair%%:*}" + dir="${pair#*:}" + if ! exe_path="$(resolve_exe "${dir}" "${exe}")"; then + echo "ERROR: ${exe} not found under ${examples_base}/${dir}/" + failed=true + continue + fi + echo "Testing ${exe}..." + output=$("${exe_path}" --help 2>&1) || true + if [[ -z "${output}" ]]; then + echo "ERROR: ${exe} produced no output" + failed=true + else + echo "${output}" + echo "${exe} ran successfully" + fi + done + if [[ "$failed" == "true" ]]; then exit 1; fi + + - name: Smoke test examples (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $examplesBase = "${{ inputs.build-dir }}/cpp-example-collection-build" + $examples = @( + @{ Name = 'SimpleRoom'; Dir = 'simple_room' }, + @{ Name = 'SimpleRpc'; Dir = 'simple_rpc' }, + @{ Name = 'SimpleDataStream'; Dir = 'simple_data_stream' } + ) + $failed = $false + foreach ($ex in $examples) { + $name = $ex.Name + $dir = $ex.Dir + $inDir = Join-Path $examplesBase $dir + $candidates = @( + (Join-Path $inDir "$name.exe"), + (Join-Path (Join-Path $inDir 'Release') "$name.exe") + ) + $exePath = $null + foreach ($p in $candidates) { + if (Test-Path -LiteralPath $p) { + $exePath = $p + break + } + } + if ($null -ne $exePath) { + Write-Host "Testing ${name}..." + $pinfo = New-Object System.Diagnostics.ProcessStartInfo + $pinfo.FileName = $exePath + $pinfo.Arguments = "--help" + $pinfo.RedirectStandardOutput = $true + $pinfo.RedirectStandardError = $true + $pinfo.UseShellExecute = $false + $p = New-Object System.Diagnostics.Process + $p.StartInfo = $pinfo + $p.Start() | Out-Null + $stdout = $p.StandardOutput.ReadToEnd() + $stderr = $p.StandardError.ReadToEnd() + $p.WaitForExit() + $output = $stdout + $stderr + if ([string]::IsNullOrWhiteSpace($output)) { + Write-Host "ERROR: ${name} produced no output" + $failed = $true + } else { + Write-Host $output + Write-Host "${name} ran successfully" + } + } else { + Write-Host "ERROR: ${name} not found under ${examplesBase}/${dir}/" + $failed = $true + } + } + if ($failed) { exit 1 } else { exit 0 } + diff --git a/.github/actions/test-sdk-bundle/action.yml b/.github/actions/test-sdk-bundle/action.yml new file mode 100644 index 00000000..cf25f17b --- /dev/null +++ b/.github/actions/test-sdk-bundle/action.yml @@ -0,0 +1,72 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Test installed SDK bundle +description: Validate an SDK bundle through the standalone CTest consumer. +inputs: + bundle-dir: + description: SDK installation directory relative to the workspace. + required: true + capture: + description: Expected capture support (ON or OFF). + required: true + build-type: + description: Consumer configuration (Debug or Release). + default: Release + version: + description: Optional expected SDK version for release metadata checks. + default: '' + macos-arch: + description: Optional macOS architecture override. + default: '' +runs: + using: composite + steps: + - name: Test bundle (Unix) + if: runner.os != 'Windows' + shell: bash + env: + SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} + SDK_CAPTURE: ${{ inputs.capture }} + SDK_BUILD_TYPE: ${{ inputs.build-type }} + SDK_VERSION: ${{ inputs.version }} + SDK_MACOS_ARCH: ${{ inputs.macos-arch }} + run: | + bundle="$GITHUB_WORKSPACE/$SDK_BUNDLE_DIR" + build_dir="build-packaging-$SDK_CAPTURE" + arch_args=() + if [[ -n "$SDK_MACOS_ARCH" ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES="$SDK_MACOS_ARCH"); fi + cmake -S src/tests/packaging -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE="$SDK_BUILD_TYPE" -DCMAKE_PREFIX_PATH="$bundle" \ + -DLIVEKIT_EXPECT_CAPTURE="$SDK_CAPTURE" -DLIVEKIT_EXPECT_VERSION="$SDK_VERSION" "${arch_args[@]}" + cmake --build "$build_dir" --config "$SDK_BUILD_TYPE" + ctest --test-dir "$build_dir" -C "$SDK_BUILD_TYPE" --output-on-failure -L packaging + - name: Test bundle (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} + SDK_CAPTURE: ${{ inputs.capture }} + SDK_BUILD_TYPE: ${{ inputs.build-type }} + SDK_VERSION: ${{ inputs.version }} + run: | + $bundle = Join-Path $env:GITHUB_WORKSPACE $env:SDK_BUNDLE_DIR + $buildDir = "build-packaging-$env:SDK_CAPTURE" + cmake -S src/tests/packaging -B "$buildDir" -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE="$env:SDK_CAPTURE" -DLIVEKIT_EXPECT_VERSION="$env:SDK_VERSION" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build "$buildDir" --config "$env:SDK_BUILD_TYPE" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:PATH = "$bundle\bin;$env:PATH" + ctest --test-dir "$buildDir" -C "$env:SDK_BUILD_TYPE" --output-on-failure -L packaging + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index b580386d..77fe6b56 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -32,60 +32,36 @@ jobs: include: - os: ubuntu-22.04 name: ubuntu-22.04-x64 - build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64 - capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-x64-capture - build_dir: build-release - os: ubuntu-22.04-arm name: ubuntu-22.04-arm64 - build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64 - capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-22.04-arm64-capture - build_dir: build-release - os: ubuntu-24.04 name: ubuntu-24.04-x64 - build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64 - capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-x64-capture - build_dir: build-release - os: ubuntu-24.04-arm name: ubuntu-24.04-arm64 - build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64 - capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-ubuntu-24.04-arm64-capture - build_dir: build-release - os: macos-26-xlarge name: macos-arm64 - build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-macos-arm64 - capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-arm64-capture - build_dir: build-release - os: macos-26-large name: macos-x64 - build_cmd: ./build.sh release-examples --no-capture --bundle --prefix sdk-out/livekit-sdk-macos-x64 --macos-arch x86_64 - capture_build_cmd: ./build.sh release-examples --capture --bundle --prefix sdk-out/livekit-sdk-macos-x64-capture --macos-arch x86_64 - build_dir: build-release + macos_arch: x86_64 # Pinned to Windows 2022 for current VS 17 implementation - os: windows-2022 name: windows-x64 - build_cmd: .\build.cmd release-examples --no-capture --bundle --prefix sdk-out\livekit-sdk-windows-x64 - capture_build_cmd: .\build.cmd release-examples --capture --bundle --prefix sdk-out\livekit-sdk-windows-x64-capture - build_dir: build-release name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} + env: + BUILD_DIR: build-release steps: - - name: Set Cargo output root - shell: bash - run: | - if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" - else - echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" - fi - - name: Checkout (with submodules) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive fetch-depth: 1 + - name: Set Cargo output root + uses: ./.github/actions/cargo-target-root + - name: Pull LFS files run: git lfs pull @@ -161,7 +137,7 @@ jobs: toolchain: stable - name: Install Rust cross-compilation target - if: startsWith(matrix.name, 'macos-x64') + if: matrix.name == 'macos-x64' run: rustup target add x86_64-apple-darwin # ---------- Cache Cargo ---------- @@ -220,155 +196,28 @@ jobs: echo "LIBCLANG_PATH=${LLVM_ROOT}/lib" >> "$GITHUB_ENV" # ---------- Build ---------- - - name: Build (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - chmod +x build.sh - ${{ matrix.build_cmd }} - - - name: Build (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: ${{ matrix.build_cmd }} + - name: Build standard SDK + uses: ./.github/actions/build-sdk + with: + preset: release-examples + capture: 'OFF' + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }} + macos-arch: ${{ matrix.macos_arch || '' }} # ---------- Smoke test cpp-example-collection binaries ---------- # Built under cpp-example-collection-build/ (not build-dir/bin). Visual Studio # multi-config places executables in per-target Release/ (or Debug/) subdirs. - - name: Smoke test examples (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -x - failed=false - examples_base="${{ matrix.build_dir }}/cpp-example-collection-build" - resolve_exe() { - local dir="$1" name="$2" - local p="${examples_base}/${dir}/${name}" - if [[ -x "${p}" ]]; then - printf '%s' "${p}" - return 0 - fi - p="${examples_base}/${dir}/Release/${name}" - if [[ -x "${p}" ]]; then - printf '%s' "${p}" - return 0 - fi - return 1 - } - for pair in "SimpleRoom:simple_room" "SimpleRpc:simple_rpc" "SimpleDataStream:simple_data_stream"; do - exe="${pair%%:*}" - dir="${pair#*:}" - if ! exe_path="$(resolve_exe "${dir}" "${exe}")"; then - echo "ERROR: ${exe} not found under ${examples_base}/${dir}/" - failed=true - continue - fi - echo "Testing ${exe}..." - output=$("${exe_path}" --help 2>&1) || true - if [[ -z "${output}" ]]; then - echo "ERROR: ${exe} produced no output" - failed=true - else - echo "${output}" - echo "${exe} ran successfully" - fi - done - if [[ "$failed" == "true" ]]; then exit 1; fi - - - name: Smoke test examples (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - $examplesBase = "${{ matrix.build_dir }}/cpp-example-collection-build" - $examples = @( - @{ Name = 'SimpleRoom'; Dir = 'simple_room' }, - @{ Name = 'SimpleRpc'; Dir = 'simple_rpc' }, - @{ Name = 'SimpleDataStream'; Dir = 'simple_data_stream' } - ) - $failed = $false - foreach ($ex in $examples) { - $name = $ex.Name - $dir = $ex.Dir - $inDir = Join-Path $examplesBase $dir - $candidates = @( - (Join-Path $inDir "$name.exe"), - (Join-Path (Join-Path $inDir 'Release') "$name.exe") - ) - $exePath = $null - foreach ($p in $candidates) { - if (Test-Path -LiteralPath $p) { - $exePath = $p - break - } - } - if ($null -ne $exePath) { - Write-Host "Testing ${name}..." - $pinfo = New-Object System.Diagnostics.ProcessStartInfo - $pinfo.FileName = $exePath - $pinfo.Arguments = "--help" - $pinfo.RedirectStandardOutput = $true - $pinfo.RedirectStandardError = $true - $pinfo.UseShellExecute = $false - $p = New-Object System.Diagnostics.Process - $p.StartInfo = $pinfo - $p.Start() | Out-Null - $stdout = $p.StandardOutput.ReadToEnd() - $stderr = $p.StandardError.ReadToEnd() - $p.WaitForExit() - $output = $stdout + $stderr - if ([string]::IsNullOrWhiteSpace($output)) { - Write-Host "ERROR: ${name} produced no output" - $failed = $true - } else { - Write-Host $output - Write-Host "${name} ran successfully" - } - } else { - Write-Host "ERROR: ${name} not found under ${examplesBase}/${dir}/" - $failed = $true - } - } - if ($failed) { exit 1 } else { exit 0 } - - - name: Verify SDK bundle - shell: bash - run: | - set -euo pipefail - bundle="sdk-out/livekit-sdk-${{ matrix.name }}" - grep -F "#define LIVEKIT_CAPTURE_ENABLED 0" "${bundle}/include/livekit/build.h" - test -f "${bundle}/include/livekit/livekit.h" - test -f "${bundle}/share/livekit/build-info.json" - test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfig.cmake" - test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfigVersion.cmake" - test -f "${bundle}/lib/cmake/LiveKit/LiveKitTargets.cmake" - - - name: Test installed standard SDK (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}" - arch_args=() - if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-standard -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE=OFF "${arch_args[@]}" - cmake --build build-packaging-standard - ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging - - - name: Test installed standard SDK (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}" - cmake -S src/tests/packaging -B build-packaging-standard -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=OFF - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-standard --config Release - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Smoke test examples + uses: ./.github/actions/smoke-examples + with: + build-dir: ${{ env.BUILD_DIR }} + + - name: Test installed standard SDK + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }} + capture: 'OFF' + macos-arch: ${{ matrix.macos_arch || '' }} # ---------- Upload artifacts ---------- - name: Upload SDK bundle @@ -383,152 +232,25 @@ jobs: - name: Set up capture dependencies uses: ./.github/actions/setup-capture - - name: Rebuild with capture (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - chmod +x build.sh - ${{ matrix.capture_build_cmd }} + - name: Build capture SDK + uses: ./.github/actions/build-sdk + with: + preset: release-examples + capture: 'ON' + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture + macos-arch: ${{ matrix.macos_arch || '' }} - - name: Rebuild with capture (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: ${{ matrix.capture_build_cmd }} + - name: Smoke test capture examples + uses: ./.github/actions/smoke-examples + with: + build-dir: ${{ env.BUILD_DIR }} - - name: Smoke test capture examples (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -x - failed=false - examples_base="${{ matrix.build_dir }}/cpp-example-collection-build" - resolve_exe() { - local dir="$1" name="$2" - local p="${examples_base}/${dir}/${name}" - if [[ -x "${p}" ]]; then - printf '%s' "${p}" - return 0 - fi - p="${examples_base}/${dir}/Release/${name}" - if [[ -x "${p}" ]]; then - printf '%s' "${p}" - return 0 - fi - return 1 - } - for pair in "SimpleRoom:simple_room" "SimpleRpc:simple_rpc" "SimpleDataStream:simple_data_stream"; do - exe="${pair%%:*}" - dir="${pair#*:}" - if ! exe_path="$(resolve_exe "${dir}" "${exe}")"; then - echo "ERROR: ${exe} not found under ${examples_base}/${dir}/" - failed=true - continue - fi - echo "Testing ${exe}..." - output=$("${exe_path}" --help 2>&1) || true - if [[ -z "${output}" ]]; then - echo "ERROR: ${exe} produced no output" - failed=true - else - echo "${output}" - echo "${exe} ran successfully" - fi - done - if [[ "$failed" == "true" ]]; then exit 1; fi - - - name: Smoke test capture examples (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - $examplesBase = "${{ matrix.build_dir }}/cpp-example-collection-build" - $examples = @( - @{ Name = 'SimpleRoom'; Dir = 'simple_room' }, - @{ Name = 'SimpleRpc'; Dir = 'simple_rpc' }, - @{ Name = 'SimpleDataStream'; Dir = 'simple_data_stream' } - ) - $failed = $false - foreach ($ex in $examples) { - $name = $ex.Name - $dir = $ex.Dir - $inDir = Join-Path $examplesBase $dir - $candidates = @( - (Join-Path $inDir "$name.exe"), - (Join-Path (Join-Path $inDir 'Release') "$name.exe") - ) - $exePath = $null - foreach ($p in $candidates) { - if (Test-Path -LiteralPath $p) { - $exePath = $p - break - } - } - if ($null -ne $exePath) { - Write-Host "Testing ${name}..." - $pinfo = New-Object System.Diagnostics.ProcessStartInfo - $pinfo.FileName = $exePath - $pinfo.Arguments = "--help" - $pinfo.RedirectStandardOutput = $true - $pinfo.RedirectStandardError = $true - $pinfo.UseShellExecute = $false - $p = New-Object System.Diagnostics.Process - $p.StartInfo = $pinfo - $p.Start() | Out-Null - $stdout = $p.StandardOutput.ReadToEnd() - $stderr = $p.StandardError.ReadToEnd() - $p.WaitForExit() - $output = $stdout + $stderr - if ([string]::IsNullOrWhiteSpace($output)) { - Write-Host "ERROR: ${name} produced no output" - $failed = $true - } else { - Write-Host $output - Write-Host "${name} ran successfully" - } - } else { - Write-Host "ERROR: ${name} not found under ${examplesBase}/${dir}/" - $failed = $true - } - } - if ($failed) { exit 1 } else { exit 0 } - - - name: Verify capture SDK bundle - shell: bash - run: | - set -euo pipefail - bundle="sdk-out/livekit-sdk-${{ matrix.name }}-capture" - grep -F "#define LIVEKIT_CAPTURE_ENABLED 1" "${bundle}/include/livekit/build.h" - test -f "${bundle}/include/livekit/livekit.h" - test -f "${bundle}/share/livekit/build-info.json" - test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfig.cmake" - test -f "${bundle}/lib/cmake/LiveKit/LiveKitConfigVersion.cmake" - test -f "${bundle}/lib/cmake/LiveKit/LiveKitTargets.cmake" - - - name: Test installed capture SDK (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-capture" - arch_args=() - if [[ "${{ matrix.name }}" == macos-x64 ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-capture -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" - cmake --build build-packaging-capture - ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging - - - name: Test installed capture SDK (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-capture" - cmake -S src/tests/packaging -B build-packaging-capture -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-capture --config Release - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test installed capture SDK + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture + capture: 'ON' + macos-arch: ${{ matrix.macos_arch || '' }} - name: Upload capture SDK bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -557,7 +279,6 @@ jobs: path: ${{ env.VCPKG_BINARY_CACHE_DIR }} key: ${{ runner.os }}-${{ matrix.name }}-vcpkg-binary-${{ env.VCPKG_TARGET_TRIPLET }}-${{ env.VCPKG_GIT_COMMIT }}-${{ hashFiles('vcpkg.json') }} - # ---------- Cleanup ---------- - name: Clean after build (best-effort) if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6a2f86a..0d75e795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: - .github/workflows/builds.yml - .github/workflows/make-release.yml - .github/actions/setup-capture/** + - .github/actions/build-sdk/** + - .github/actions/cargo-target-root/** + - .github/actions/smoke-examples/** + - .github/actions/test-sdk-bundle/** + - .github/actions/check-sdk-abi/** - .github/scripts/check_no_private_symbols.py tests: - src/** @@ -68,6 +73,10 @@ jobs: - .github/workflows/ci.yml - .github/workflows/tests.yml - .github/workflows/nightly.yml + - .github/actions/setup-capture/** + - .github/actions/build-sdk/** + - .github/actions/cargo-target-root/** + - .github/actions/test-sdk-bundle/** docs: - README.md - include/** diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index f3640770..940ac37b 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -29,46 +29,34 @@ jobs: include: - os: ubuntu-22.04 name: ubuntu-22.04-x64 - generator: Ninja - os: ubuntu-22.04-arm name: ubuntu-22.04-arm64 - generator: Ninja - os: ubuntu-24.04 name: ubuntu-24.04-x64 - generator: Ninja - os: ubuntu-24.04-arm name: ubuntu-24.04-arm64 - generator: Ninja - os: macos-26-xlarge name: macos-arm64 - generator: Ninja - os: macos-26-large name: macos-x64 - generator: Ninja + macos_arch: x86_64 # Pinned to Windows 2022 for current VS 17 implementation - os: windows-2022 name: windows-x64 - generator: "Visual Studio 17 2022" name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} steps: - - name: Set Cargo output root - shell: bash - run: | - if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" - else - echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" - fi - - name: Checkout (with submodules) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive fetch-depth: 0 + - name: Set Cargo output root + uses: ./.github/actions/cargo-target-root + # ---------- Extract version ---------- - name: Extract version id: version @@ -100,6 +88,10 @@ jobs: exit 1 fi + if [[ "$VERSION" == "0.0.0-dev" ]]; then + echo "ERROR: refusing to build release with fallback version: $VERSION" + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "SDK Version: $VERSION" @@ -162,7 +154,7 @@ jobs: toolchain: stable - name: Install Rust cross-compilation target - if: startsWith(matrix.name, 'macos-x64') + if: matrix.name == 'macos-x64' run: rustup target add x86_64-apple-darwin # ---------- Cache Cargo ---------- @@ -216,82 +208,24 @@ jobs: test -f "${LLVM_ROOT}/lib/libclang.so" echo "LIBCLANG_PATH=${LLVM_ROOT}/lib" >> "$GITHUB_ENV" - # ---------- Build + Bundle (Unix) ---------- - - name: Build and Bundle (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - chmod +x build.sh - version="${{ steps.version.outputs.version }}" - bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}-${version}" - if [[ -z "$version" ]]; then - echo "ERROR: version is empty" - exit 1 - fi - - if [[ "$version" == "0.0.0-dev" ]]; then - echo "ERROR: refusing to build release with fallback version: $version" - exit 1 - fi - - arch_args=() - if [[ "${{ matrix.name }}" == macos-x64 ]]; then arch_args+=(--macos-arch x86_64); fi - for variant in standard capture; do - if [[ "$variant" == standard ]]; then - capture_flag=--no-capture - suffix="" - else - capture_flag=--capture - suffix="-capture" - fi - bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}${suffix}-${version}" - ./build.sh release-examples "$capture_flag" --version "$version" --bundle --prefix "$bundleDir" "${arch_args[@]}" - done - - - name: Validate bundle version metadata (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.version }}" - for suffix in "" "-capture"; do - bundleDir="sdk-out/livekit-sdk-${{ matrix.name }}${suffix}-${version}" - header="${bundleDir}/include/livekit/build.h" - buildInfo="${bundleDir}/share/livekit/build-info.json" - - test -f "$header" - test -f "$buildInfo" - grep -F "#define LIVEKIT_BUILD_VERSION \"${version}\"" "$header" - if grep -F '#define LIVEKIT_BUILD_VERSION "0.0.0"' "$header"; then - echo "ERROR: bundle reports fallback SDK version" - exit 1 - fi - grep -F "\"sdk_version\": \"${version}\"" "$buildInfo" - grep -Eq '"rust_submodule_commit": "[0-9a-f]{7,}"' "$buildInfo" - grep -Eq '"livekit_ffi_version": "[0-9]+\.[0-9]+\.[0-9]+' "$buildInfo" - done - - # ---------- Build + Bundle (Windows) ---------- - - name: Build and Bundle (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.version }}" - $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}-$version" - - if ([string]::IsNullOrWhiteSpace($version)) { throw "version is empty" } - foreach ($variant in @("standard", "capture")) { - $captureFlag = "--no-capture" - $suffix = "" - if ($variant -eq "capture") { - $captureFlag = "--capture" - $suffix = "-capture" - } - $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}$suffix-$version" - .\build.cmd release-examples $captureFlag --version "$version" --bundle --prefix "$bundleDir" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } + # ---------- Build both variants in the same CMake tree ---------- + - name: Build standard SDK + uses: ./.github/actions/build-sdk + with: + preset: release-examples + capture: 'OFF' + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} + version: ${{ steps.version.outputs.version }} + macos-arch: ${{ matrix.macos_arch || '' }} + + - name: Rebuild with capture + uses: ./.github/actions/build-sdk + with: + preset: release-examples + capture: 'ON' + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + version: ${{ steps.version.outputs.version }} + macos-arch: ${{ matrix.macos_arch || '' }} # ---------- Verify exported ABI: no third-party symbol leaks ---------- - name: Setup Python @@ -299,116 +233,31 @@ jobs: with: python-version: '3.x' - - name: Symbol leak check (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.version }}" - for suffix in "" "-capture"; do - libdir="sdk-out/livekit-sdk-${{ matrix.name }}${suffix}-${version}/lib" - lib="${libdir}/liblivekit.so" - if [[ "$RUNNER_OS" == "macOS" ]]; then lib="${libdir}/liblivekit.dylib"; fi - python3 .github/scripts/check_no_private_symbols.py "$lib" - done + - name: Symbol leak check (standard SDK) + uses: ./.github/actions/check-sdk-abi + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} - - name: Symbol leak check (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.version }}" - foreach ($suffix in @("", "-capture")) { - $lib = "sdk-out/livekit-sdk-${{ matrix.name }}$suffix-$version/bin/livekit.dll" - python .github/scripts/check_no_private_symbols.py "$lib" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } - - - name: Validate bundle version metadata (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.version }}" - foreach ($suffix in @("", "-capture")) { - $bundleDir = "sdk-out/livekit-sdk-${{ matrix.name }}$suffix-$version" - $header = Join-Path $bundleDir "include/livekit/build.h" - $buildInfo = Join-Path $bundleDir "share/livekit/build-info.json" - - if (!(Test-Path $header)) { throw "Missing $header" } - if (!(Test-Path $buildInfo)) { throw "Missing $buildInfo" } - - $headerText = Get-Content -Raw $header - $expectedHeaderVersion = "#define LIVEKIT_BUILD_VERSION `"$version`"" - if (!$headerText.Contains($expectedHeaderVersion)) { - throw "Header does not report expected SDK version $version" - } - if ($headerText.Contains('#define LIVEKIT_BUILD_VERSION "0.0.0"')) { - throw "Bundle reports fallback SDK version" - } - - $buildInfoText = Get-Content -Raw $buildInfo - $expectedBuildInfoVersion = "`"sdk_version`": `"$version`"" - if (!$buildInfoText.Contains($expectedBuildInfoVersion)) { - throw "Build info does not report expected SDK version $version" - } - if ($buildInfoText -match '"rust_submodule_commit": "unknown"') { - throw "Build info is missing Rust submodule commit" - } - if ($buildInfoText -match '"livekit_ffi_version": "unknown"') { - throw "Build info is missing livekit-ffi version" - } - } - - - name: Test installed standard SDK (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" - arch_args=() - if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-standard -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE=OFF "${arch_args[@]}" - cmake --build build-packaging-standard - ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging - - - name: Test installed standard SDK (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }}" - cmake -S src/tests/packaging -B build-packaging-standard -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=OFF - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-standard --config Release - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-standard -C Release --output-on-failure -L packaging - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Test installed capture SDK (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - bundle="$GITHUB_WORKSPACE/sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }}" - arch_args=() - if [[ "${{ matrix.name }}" == macos-x64 ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-capture -G Ninja \ - -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE=ON "${arch_args[@]}" - cmake --build build-packaging-capture - ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging - - - name: Test installed capture SDK (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }}" - cmake -S src/tests/packaging -B build-packaging-capture -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=ON - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-capture --config Release - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-capture -C Release --output-on-failure -L packaging - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Symbol leak check (capture SDK) + uses: ./.github/actions/check-sdk-abi + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + + - name: Test installed standard SDK + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} + capture: 'OFF' + macos-arch: ${{ matrix.macos_arch || '' }} + version: ${{ steps.version.outputs.version }} + + - name: Test installed capture SDK + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + capture: 'ON' + macos-arch: ${{ matrix.macos_arch || '' }} + version: ${{ steps.version.outputs.version }} # ---------- Upload artifact (raw directory, no pre-compression) ---------- - name: Upload build artifact diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4aa0a15..1ddd98d0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -130,11 +130,6 @@ env: VCPKG_DEFAULT_TRIPLET: x64-windows-static-md VCPKG_DEFAULT_HOST_TRIPLET: x64-windows-static-md VCPKG_TARGET_TRIPLET: x64-windows-static-md - # Windows uses an explicit, checksum-pinned GStreamer SDK. Linux and macOS - # intentionally use their supported platform-package versions; `1.0` in - # Ubuntu package names denotes the GStreamer ABI, not the installed release. - GSTREAMER_WINDOWS_VERSION: "1.28.5" - GSTREAMER_WINDOWS_SHA256: "51ee5eaec33008e8409d8cf6f6884457f22aa3bd515f8856f993a3eaab903530" jobs: test: @@ -172,21 +167,15 @@ jobs: BUILD_DIR: ${{ inputs.build_type == 'debug' && 'build-debug' || 'build-release' }} steps: - - name: Set Cargo output root - shell: bash - run: | - if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "CARGO_TARGET_DIR=$(cygpath -m "$RUNNER_TEMP")/lk-rust" >> "$GITHUB_ENV" - else - echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/client-sdk-rust/target" >> "$GITHUB_ENV" - fi - - name: Checkout (with submodules) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive fetch-depth: 1 + - name: Set Cargo output root + uses: ./.github/actions/cargo-target-root + - name: Pull LFS files run: git lfs pull @@ -221,34 +210,6 @@ jobs: with: vcpkgGitCommitId: ${{ env.VCPKG_GIT_COMMIT }} - - name: Install GStreamer (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $installer = Join-Path $env:RUNNER_TEMP "gstreamer-installer.exe" - $installDir = Join-Path $env:RUNNER_TEMP "gstreamer" - $uri = "https://gstreamer.freedesktop.org/data/pkg/windows/$env:GSTREAMER_WINDOWS_VERSION/msvc/gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_WINDOWS_VERSION.exe" - - Invoke-WebRequest -Uri $uri -OutFile $installer - $actualHash = (Get-FileHash -Algorithm SHA256 $installer).Hash.ToLowerInvariant() - if ($actualHash -ne $env:GSTREAMER_WINDOWS_SHA256) { - throw "GStreamer installer SHA-256 mismatch: expected $env:GSTREAMER_WINDOWS_SHA256, got $actualHash" - } - - $process = Start-Process -FilePath $installer -ArgumentList @( - "/TYPE=devel", - "/DIR=$installDir", - "/CURRENTUSER", - "/VERYSILENT", - "/NORESTART" - ) -Wait -PassThru - if ($process.ExitCode -ne 0) { - throw "GStreamer installer failed with exit code $($process.ExitCode)" - } - - "$installDir\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - "PKG_CONFIG_PATH=$installDir\lib\pkgconfig" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # ---------- OS-specific deps ---------- - name: Install deps (Ubuntu) if: runner.os == 'Linux' @@ -268,10 +229,6 @@ jobs: libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ libunwind-dev libjpeg-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ - gstreamer1.0-libav \ gdb jq # The libwebrtc artifact ships a hermetic libc++ built from LLVM trunk. @@ -287,7 +244,7 @@ jobs: run: | set -eux brew update - brew install cmake ninja protobuf abseil gstreamer + brew install cmake ninja protobuf abseil # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -365,65 +322,31 @@ jobs: # Validate the standard installed SDK, then enable capture in the same # CMake tree. Compile and run the complete test suite once, with capture. - - name: Build standard SDK (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - chmod +x build.sh - build_cmd=(./build.sh "${{ inputs.build_type }}" --no-capture --bundle --prefix sdk-out/test-sdk-standard) - if [[ -n "${{ matrix.macos_arch || '' }}" ]]; then - build_cmd+=(--macos-arch "${{ matrix.macos_arch }}") - fi - "${build_cmd[@]}" + - name: Build standard SDK + uses: ./.github/actions/build-sdk + with: + preset: ${{ inputs.build_type }} + capture: 'OFF' + bundle-dir: sdk-out/test-sdk-standard + macos-arch: ${{ matrix.macos_arch || '' }} - - name: Build standard SDK (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: .\build.cmd ${{ inputs.build_type }} --no-capture --bundle --prefix sdk-out/test-sdk-standard + - name: Test installed standard SDK + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: sdk-out/test-sdk-standard + capture: 'OFF' + macos-arch: ${{ matrix.macos_arch || '' }} + build-type: ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} - - name: Test installed standard SDK (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - bundle="$GITHUB_WORKSPACE/sdk-out/test-sdk-standard" - arch_args=() - if [[ "${{ matrix.name }}" == macos-x64* ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES=x86_64); fi - cmake -S src/tests/packaging -B build-packaging-standard -G Ninja \ - -DCMAKE_BUILD_TYPE=${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE=OFF "${arch_args[@]}" - cmake --build build-packaging-standard - ctest --test-dir build-packaging-standard -C ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} --output-on-failure -L packaging - - - name: Test installed standard SDK (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $bundle = Join-Path $env:GITHUB_WORKSPACE "sdk-out/test-sdk-standard" - cmake -S src/tests/packaging -B build-packaging-standard -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE=OFF - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cmake --build build-packaging-standard --config ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $env:PATH = "$bundle\bin;$env:PATH" - ctest --test-dir build-packaging-standard -C ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} --output-on-failure -L packaging - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Rebuild tests with capture (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - chmod +x build.sh - build_cmd=(./build.sh "${{ inputs.build_type }}-tests" --capture) - if [[ -n "${{ matrix.macos_arch || '' }}" ]]; then - build_cmd+=(--macos-arch "${{ matrix.macos_arch }}") - fi - "${build_cmd[@]}" + - name: Set up capture dependencies + uses: ./.github/actions/setup-capture - - name: Rebuild tests with capture (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: .\build.cmd ${{ inputs.build_type }}-tests --capture + - name: Rebuild tests with capture + uses: ./.github/actions/build-sdk + with: + preset: ${{ inputs.build_type }}-tests + capture: 'ON' + macos-arch: ${{ matrix.macos_arch || '' }} # ---------- Run unit tests ---------- - name: Run unit tests (Unix) @@ -609,13 +532,12 @@ jobs: libabsl-dev \ libcurl4-openssl-dev \ libwayland-dev libdecor-0-dev \ - libunwind-dev libjpeg-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ - gstreamer1.0-libav + libunwind-dev libjpeg-dev pip install --break-system-packages gcovr + - name: Set up capture dependencies + uses: ./.github/actions/setup-capture + # The coverage build compiles the Rust FFI too, so it needs the same # compiler floor as the normal Linux build/test matrices. - name: Install clang for libwebrtc diff --git a/AGENTS.md b/AGENTS.md index 2f6e31d8..908de94c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ Be sure to update the directory layout in this file if the directory layout chan | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | | `src/tests/packaging/` | Standalone CTest integration tests consuming installed SDK bundles | -| `.github/actions/setup-capture/` | Shared CI setup for capture runtime and development dependencies | +| `.github/actions/` | Shared CI actions for SDK builds, Cargo paths, capture dependencies, example smoke checks, packaging tests, and exported ABI checks | | `.github/workflows/` | GitHub Actions CI workflows | ### Key Types @@ -414,6 +414,8 @@ all filtered stages; normal pull requests and pushes use the path filters. - `.github/workflows/tests.yml` — Reusable unit/integration test matrix. Each platform job first validates an installed standard SDK, then rebuilds with capture enabled and runs the complete test suite once. + The build, test, and release jobs use the shared actions under + `.github/actions/`; keep platform-specific build and bundle checks there. - `.github/workflows/cpp-tools.yml` — Reusable SDK-specific `clang-format` and `clang-tidy` workflow. It prepares the build environment and invokes the project wrappers backed by the shared `cpp-tools` scripts. diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a9cd515..e1934ba4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -336,7 +336,6 @@ file(GLOB_RECURSE RUST_SOURCES CONFIGURE_DEPENDS "${RUST_ROOT}/livekit-capture/Cargo.toml" "${RUST_ROOT}/livekit-capture/src/*" "${RUST_ROOT}/livekit-capture/shaders/*" - "${RUST_ROOT}/livekit-capture/build.rs" ) list(APPEND RUST_SOURCES "${RUST_ROOT}/Cargo.toml" @@ -383,7 +382,6 @@ add_library(livekit SHARED src/audio_frame.cpp src/audio_processing_module.cpp src/capture_source.cpp - src/capture_source_internal.h src/audio_source.cpp src/audio_stream.cpp src/data_track_frame.cpp @@ -523,16 +521,12 @@ if(LIVEKIT_IS_TOPLEVEL) # Copy livekit_ffi library to output directory alongside livekit library if(WIN32) # Copy both DLL and import library - set(FFI_DLL_DEBUG "${RUST_TARGET_DIR}/debug/livekit_ffi.dll") - set(FFI_DLL_RELEASE "${RUST_TARGET_DIR}/release/livekit_ffi.dll") - set(FFI_IMPLIB_DEBUG "${RUST_TARGET_DIR}/debug/livekit_ffi.dll.lib") - set(FFI_IMPLIB_RELEASE "${RUST_TARGET_DIR}/release/livekit_ffi.dll.lib") # Copy DLL file add_custom_command( TARGET livekit POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - "$,${FFI_DLL_DEBUG},${FFI_DLL_RELEASE}>" + "$" "$/livekit_ffi.dll" COMMENT "Copying livekit_ffi.dll to output directory" ) @@ -540,18 +534,16 @@ if(LIVEKIT_IS_TOPLEVEL) add_custom_command( TARGET livekit POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - "$,${FFI_IMPLIB_DEBUG},${FFI_IMPLIB_RELEASE}>" + "$" "$/livekit_ffi.dll.lib" COMMENT "Copying livekit_ffi.dll.lib to output directory" ) elseif(APPLE) # macOS: copy .dylib - set(FFI_LIB_DEBUG "${RUST_TARGET_DIR}/debug/liblivekit_ffi.dylib") - set(FFI_LIB_RELEASE "${RUST_TARGET_DIR}/release/liblivekit_ffi.dylib") add_custom_command( TARGET livekit POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - "$,${FFI_LIB_DEBUG},${FFI_LIB_RELEASE}>" + "$" "$/liblivekit_ffi.dylib" COMMENT "Copying liblivekit_ffi.dylib to output directory" ) @@ -579,12 +571,10 @@ if(LIVEKIT_IS_TOPLEVEL) ) else() # Linux: copy .so - set(FFI_LIB_DEBUG "${RUST_TARGET_DIR}/debug/liblivekit_ffi.so") - set(FFI_LIB_RELEASE "${RUST_TARGET_DIR}/release/liblivekit_ffi.so") add_custom_command( TARGET livekit POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - "$,${FFI_LIB_DEBUG},${FFI_LIB_RELEASE}>" + "$" "$/liblivekit_ffi.so" COMMENT "Copying liblivekit_ffi.so to output directory" ) diff --git a/CMakePresets.json b/CMakePresets.json index e9e7986e..3d1d5511 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -6,6 +6,21 @@ "patch": 0 }, "configurePresets": [ + { + "name": "sdk-defaults", + "hidden": true, + "cacheVariables": { + "LIVEKIT_BUILD_CAPTURE": "OFF" + } + }, + { + "name": "capture-tests", + "hidden": true, + "cacheVariables": { + "LIVEKIT_BUILD_CAPTURE": "ON", + "LIVEKIT_BUILD_TESTS": "ON" + } + }, { "name": "vcpkg", "hidden": true, @@ -23,15 +38,17 @@ { "name": "windows-base", "hidden": true, - "inherits": "vcpkg", + "inherits": [ + "sdk-defaults", + "vcpkg" + ], "generator": "Visual Studio 17 2022", "architecture": { "value": "x64", "strategy": "set" }, "cacheVariables": { - "VCPKG_TARGET_TRIPLET": "x64-windows-static-md", - "LIVEKIT_BUILD_CAPTURE": "OFF" + "VCPKG_TARGET_TRIPLET": "x64-windows-static-md" } }, { @@ -40,14 +57,16 @@ "generator": "Ninja", "cacheVariables": { "LIVEKIT_USE_VCPKG": "OFF", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", - "LIVEKIT_BUILD_CAPTURE": "OFF" + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, "condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" - } + }, + "inherits": [ + "sdk-defaults" + ] }, { "name": "macos-base", @@ -55,14 +74,16 @@ "generator": "Ninja", "cacheVariables": { "LIVEKIT_USE_VCPKG": "OFF", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", - "LIVEKIT_BUILD_CAPTURE": "OFF" + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, "condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Darwin" - } + }, + "inherits": [ + "sdk-defaults" + ] }, { "name": "windows-release", @@ -212,156 +233,168 @@ "name": "windows-release-all", "displayName": "Windows x64 Release with Tests + Examples", "description": "Build for Windows x64 Release with tests and examples", - "inherits": "windows-base", + "inherits": [ + "capture-tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "ON" } }, { "name": "windows-debug-all", "displayName": "Windows x64 Debug with Tests + Examples", "description": "Build for Windows x64 Debug with tests and examples", - "inherits": "windows-base", + "inherits": [ + "capture-tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "ON" } }, { "name": "windows-release-tests", "displayName": "Windows x64 Release with Tests", "description": "Build for Windows x64 Release with test suite", - "inherits": "windows-base", + "inherits": [ + "capture-tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } }, { "name": "windows-debug-tests", "displayName": "Windows x64 Debug with Tests", "description": "Build for Windows x64 Debug with test suite", - "inherits": "windows-base", + "inherits": [ + "capture-tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } }, { "name": "linux-release-all", "displayName": "Linux Release with Tests + Examples", "description": "Build for Linux Release with tests and examples", - "inherits": "linux-base", + "inherits": [ + "capture-tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "ON" } }, { "name": "linux-debug-all", "displayName": "Linux Debug with Tests + Examples", "description": "Build for Linux Debug with tests and examples", - "inherits": "linux-base", + "inherits": [ + "capture-tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "ON" } }, { "name": "linux-release-tests", "displayName": "Linux Release with Tests", "description": "Build for Linux Release with test suite", - "inherits": "linux-base", + "inherits": [ + "capture-tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } }, { "name": "linux-debug-tests", "displayName": "Linux Debug with Tests", "description": "Build for Linux Debug with test suite", - "inherits": "linux-base", + "inherits": [ + "capture-tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } }, { "name": "macos-release-all", "displayName": "macOS Release with Tests + Examples", "description": "Build for macOS Release with tests and examples", - "inherits": "macos-base", + "inherits": [ + "capture-tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "ON" } }, { "name": "macos-debug-all", "displayName": "macOS Debug with Tests + Examples", "description": "Build for macOS Debug with tests and examples", - "inherits": "macos-base", + "inherits": [ + "capture-tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "ON" } }, { "name": "macos-release-tests", "displayName": "macOS Release with Tests", "description": "Build for macOS Release with test suite", - "inherits": "macos-base", + "inherits": [ + "capture-tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } }, { "name": "macos-debug-tests", "displayName": "macOS Debug with Tests", "description": "Build for macOS Debug with test suite", - "inherits": "macos-base", + "inherits": [ + "capture-tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON", - "LIVEKIT_BUILD_CAPTURE": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } } ], diff --git a/cmake/uniffi_cpp.cmake b/cmake/uniffi_cpp.cmake index 574b9a32..f5d5f1a5 100644 --- a/cmake/uniffi_cpp.cmake +++ b/cmake/uniffi_cpp.cmake @@ -31,8 +31,6 @@ if(UNIX AND NOT APPLE) "${LIVEKIT_UNIFFI_METADATA_TARGET_DIR}/debug/liblivekit_ffi.so") endif() -set(LIVEKIT_UNIFFI_RUST_SOURCES ${RUST_SOURCES}) - file(GLOB_RECURSE LIVEKIT_UNIFFI_BINDGEN_SOURCES CONFIGURE_DEPENDS "${RUST_ROOT}/tools/bindgens/src/*.rs" "${RUST_ROOT}/tools/bindgens/Cargo.toml" @@ -51,7 +49,7 @@ if(UNIX AND NOT APPLE) -DCARGO_FEATURES=${LIVEKIT_CARGO_FEATURES} -P "${RUN_CARGO_SCRIPT}" WORKING_DIRECTORY "${RUST_ROOT}" - DEPENDS ${LIVEKIT_UNIFFI_RUST_SOURCES} "${RUN_CARGO_SCRIPT}" + DEPENDS ${RUST_SOURCES} "${RUN_CARGO_SCRIPT}" COMMENT "Building unstripped livekit-ffi metadata library" VERBATIM ) @@ -86,7 +84,7 @@ add_custom_command( WORKING_DIRECTORY "${RUST_ROOT}" DEPENDS build_livekit_ffi_metadata - ${LIVEKIT_UNIFFI_RUST_SOURCES} + ${RUST_SOURCES} ${LIVEKIT_UNIFFI_BINDGEN_SOURCES} COMMENT "Generating UniFFI C++ bindings" VERBATIM diff --git a/docs/testing.md b/docs/testing.md index 137aca36..5093b1f5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -105,6 +105,9 @@ CTest registers `InstalledSdk.Capture` with the `packaging` and `integration` labels and a 30-second timeout. It checks the bundle's capture metadata and creates a GStreamer VP8 source through the installed library. It needs no LiveKit server; capture-enabled runs require GStreamer and the pipeline's plugins. +The consumer also checks `share/livekit/build-info.json`. Release jobs pass +`LIVEKIT_EXPECT_VERSION` to check version metadata and the recorded Rust commit +and FFI version through this same project. From the repository root: diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index 693ee75f..968c8f20 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -32,7 +32,6 @@ namespace livekit { namespace proto { -class NewCaptureSourceRequest; class OwnedCaptureSource; } // namespace proto @@ -269,10 +268,6 @@ class LIVEKIT_API CaptureSource { CaptureSource(); - /// @brief Shared creation path: sends the request and maps the callback payload - /// onto a wrapper instance. - static std::future> createFromRequest(proto::NewCaptureSourceRequest request); - /// @brief Builds the wrapper from the callback payload, adopting its handles. static std::shared_ptr fromOwned(const proto::OwnedCaptureSource& owned); diff --git a/src/capture_source.cpp b/src/capture_source.cpp index ae15a4ae..da58599b 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -50,15 +50,17 @@ std::shared_ptr CaptureSource::videoSource() const noexcept { retur namespace { -/// A already-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(); +// Report validation and synchronous send failures through the same future as +// asynchronous creation errors. All public factories share this path. +template +std::future> createCaptureSource(Config config) { + try { + return FfiClient::instance().newCaptureSourceAsync(toProto(std::move(config))); + } catch (const std::exception& error) { + std::promise> promise; + promise.set_exception(std::make_exception_ptr(CaptureSourceError(error.what()))); + return promise.get_future(); + } } void validateResolution(const CaptureResolution& resolution) { @@ -74,6 +76,14 @@ void validatePixelConfig(const CaptureResolution& resolution, std::uint32_t fram } } +template +void copyPixelConfig(ProtoConfig& target, const Config& config) { + validatePixelConfig(config.resolution, config.framerate_fps); + target.mutable_resolution()->set_width(config.resolution.width); + target.mutable_resolution()->set_height(config.resolution.height); + target.set_framerate_fps(config.framerate_fps); +} + proto::GstreamerBitrateUnit toProto(GstreamerBitrateUnit unit) { switch (unit) { case GstreamerBitrateUnit::Bps: @@ -111,11 +121,7 @@ CaptureResult resultFromEvent(const proto::CaptureSourceEvent& event) { } // namespace std::future> CaptureSource::create(GstreamerVideoSourceConfig config) { - try { - return createFromRequest(toProto(std::move(config))); - } catch (const CaptureSourceError& error) { - return readyCaptureError>(error); - } + return createCaptureSource(std::move(config)); } proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config) { @@ -142,52 +148,27 @@ proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config) { } std::future> CaptureSource::create(PatternVideoSourceConfig config) { - try { - return createFromRequest(toProto(config)); - } catch (const CaptureSourceError& error) { - return readyCaptureError>(error); - } + return createCaptureSource(config); } proto::NewCaptureSourceRequest toProto(const PatternVideoSourceConfig& config) { - validatePixelConfig(config.resolution, config.framerate_fps); 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); + copyPixelConfig(*pattern, config); pattern->set_pattern(toProto(config.pattern)); return request; } std::future> CaptureSource::create(ClockVideoSourceConfig config) { - try { - return createFromRequest(toProto(config)); - } catch (const CaptureSourceError& error) { - return readyCaptureError>(error); - } + return createCaptureSource(config); } proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config) { - validatePixelConfig(config.resolution, config.framerate_fps); 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); + copyPixelConfig(*request.mutable_clock(), config); return request; } -std::future> CaptureSource::createFromRequest(proto::NewCaptureSourceRequest request) { - // A synchronous send failure is delivered through the - // future rather than thrown from the factory. - try { - return FfiClient::instance().newCaptureSourceAsync(std::move(request)); - } catch (const std::exception& e) { - return readyCaptureError>(e); - } -} - std::shared_ptr CaptureSource::fromOwned(const proto::OwnedCaptureSource& owned) { const proto::CaptureSourceInfo& info = owned.info(); // Guard both Rust-owned handles before validation or wrapper allocations. @@ -248,7 +229,6 @@ TrackPublishOptions CaptureSource::publishOptions(TrackPublishOptions options) c CaptureSource::~CaptureSource() { if (impl_->listener_id != 0) { FfiClient::instance().removeListener(impl_->listener_id); - impl_->listener_id = 0; } // Dropping the handle stops a running capture. } @@ -258,46 +238,37 @@ void CaptureSource::setOnFinishedCallback(FinishedCallback callback) { impl_->on_finished = std::move(callback); } -bool CaptureSource::start() { - try { - proto::FfiRequest req; - req.mutable_start_capture()->set_capture_handle(impl_->handle.get()); +namespace { - const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); - if (!resp.has_start_capture()) { - LK_LOG_WARN("Capture start request returned no start_capture response"); +bool controlCapture(std::uint64_t handle, bool start) { + const char* operation = start ? "start" : "stop"; + try { + proto::FfiRequest request; + if (start) { + request.mutable_start_capture()->set_capture_handle(handle); + } else { + request.mutable_stop_capture()->set_capture_handle(handle); + } + const auto response = FfiClient::instance().sendRequest(request); + if (!(start ? response.has_start_capture() : response.has_stop_capture())) { + LK_LOG_WARN("Capture {} request returned no {}_capture response", operation, operation); return false; } - if (resp.start_capture().has_error()) { - LK_LOG_WARN("Capture start request failed: {}", resp.start_capture().error()); + if (start ? response.start_capture().has_error() : response.stop_capture().has_error()) { + const auto& error = start ? response.start_capture().error() : response.stop_capture().error(); + LK_LOG_WARN("Capture {} request failed: {}", operation, error); return false; } return true; - } catch (const std::exception& e) { - LK_LOG_WARN("Capture start request failed: {}", e.what()); + } catch (const std::exception& error) { + LK_LOG_WARN("Capture {} request failed: {}", operation, error.what()); return false; } } -bool CaptureSource::stop() { - try { - proto::FfiRequest req; - req.mutable_stop_capture()->set_capture_handle(impl_->handle.get()); +} // namespace - 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; - } -} +bool CaptureSource::start() { return controlCapture(impl_->handle.get(), true); } +bool CaptureSource::stop() { return controlCapture(impl_->handle.get(), false); } } // namespace livekit diff --git a/src/capture_source_internal.h b/src/capture_source_internal.h index 934be8b3..34321ed4 100644 --- a/src/capture_source_internal.h +++ b/src/capture_source_internal.h @@ -21,6 +21,10 @@ namespace livekit { +namespace proto { +class NewCaptureSourceRequest; +} // namespace proto + /// Serialize capture configurations for the FFI request. /// Internal test seam; not part of the public SDK API. LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 1da96054..0fcc4b84 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -97,7 +97,6 @@ if(UNIT_TEST_SOURCES) target_include_directories(livekit_unit_tests PRIVATE - ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${LIVEKIT_ROOT_DIR}/src/trace @@ -105,7 +104,6 @@ if(UNIT_TEST_SOURCES) ${LIVEKIT_TEST_PROTOBUF_DEP_INCLUDE_DIRS} ) target_include_directories(livekit_unit_tests SYSTEM PRIVATE - ${LIVEKIT_ROOT_DIR}/client-sdk-rust/livekit-ffi/include ${LIVEKIT_TEST_SYSTEM_INCLUDE_DIRS} ) @@ -192,7 +190,6 @@ if(INTEGRATION_TEST_SOURCES) target_include_directories(livekit_integration_tests PRIVATE - ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/benchmark @@ -275,7 +272,6 @@ if(STRESS_TEST_SOURCES) target_include_directories(livekit_stress_tests PRIVATE - ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${LIVEKIT_TEST_PROTOBUF_DEP_INCLUDE_DIRS} diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index 7a20a1ea..2ea974c9 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -45,14 +45,6 @@ constexpr int kCaptureWidth = 1280; constexpr int kCaptureHeight = 720; constexpr std::uint32_t kCaptureFramerateFps = 30; -// Decide from build metadata rather than interpreting source-creation errors. -#if LIVEKIT_CAPTURE_ENABLED -#define SKIP_WITHOUT_CAPTURE_FEATURE() ((void)0) -#else -#define SKIP_WITHOUT_CAPTURE_FEATURE() \ - GTEST_SKIP() << "livekit-ffi built without capture support; configure with -DLIVEKIT_BUILD_CAPTURE=ON" -#endif - std::shared_ptr createCapture(bool clock) { if (clock) { ClockVideoSourceConfig config; @@ -70,23 +62,10 @@ std::shared_ptr createCapture(bool clock) { class CaptureSourceServerTest : public LiveKitTestBase, public ::testing::WithParamInterface {}; -TEST_P(CaptureSourceServerTest, InvalidResolutionIsRejected) { - SKIP_WITHOUT_CAPTURE_FEATURE(); - if (GetParam()) { - ClockVideoSourceConfig config; - config.resolution = {0, kCaptureHeight}; - config.framerate_fps = kCaptureFramerateFps; - EXPECT_THROW(static_cast(CaptureSource::create(config).get()), CaptureSourceError); - } else { - PatternVideoSourceConfig config; - config.resolution = {0, kCaptureHeight}; - config.framerate_fps = kCaptureFramerateFps; - EXPECT_THROW(static_cast(CaptureSource::create(config).get()), CaptureSourceError); - } -} - TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { - SKIP_WITHOUT_CAPTURE_FEATURE(); +#if !LIVEKIT_CAPTURE_ENABLED + GTEST_SKIP() << "livekit-ffi built without capture support; configure with -DLIVEKIT_BUILD_CAPTURE=ON"; +#endif failIfNotConfigured(); const auto creation_started = std::chrono::steady_clock::now(); diff --git a/src/tests/packaging/CMakeLists.txt b/src/tests/packaging/CMakeLists.txt index 25a633f7..775f0c17 100644 --- a/src/tests/packaging/CMakeLists.txt +++ b/src/tests/packaging/CMakeLists.txt @@ -20,6 +20,51 @@ set_property(CACHE LIVEKIT_EXPECT_CAPTURE PROPERTY STRINGS ON OFF) if(NOT LIVEKIT_EXPECT_CAPTURE STREQUAL "ON" AND NOT LIVEKIT_EXPECT_CAPTURE STREQUAL "OFF") message(FATAL_ERROR "LIVEKIT_EXPECT_CAPTURE must be ON or OFF") endif() +set(LIVEKIT_EXPECT_VERSION "" CACHE STRING "Expected release version; empty skips release-specific checks") + +# Check the installed snapshot, not whichever variant the SDK build tree last held. +get_target_property(_sdk_include_dirs LiveKit::livekit INTERFACE_INCLUDE_DIRECTORIES) +list(GET _sdk_include_dirs 0 _sdk_include_dir) +get_filename_component(_sdk_root "${_sdk_include_dir}/.." ABSOLUTE) +foreach(_file IN ITEMS + "${_sdk_root}/include/livekit/livekit.h" + "${_sdk_root}/include/livekit/build.h" + "${_sdk_root}/share/livekit/build-info.json" + "${LiveKit_DIR}/LiveKitConfig.cmake" + "${LiveKit_DIR}/LiveKitConfigVersion.cmake" + "${LiveKit_DIR}/LiveKitTargets.cmake") + if(NOT EXISTS "${_file}") + message(FATAL_ERROR "Installed SDK is missing ${_file}") + endif() +endforeach() +file(READ "${_sdk_root}/share/livekit/build-info.json" _build_info) +string(JSON _capture GET "${_build_info}" capture_enabled) +if(LIVEKIT_EXPECT_CAPTURE STREQUAL "ON") + set(_expected_capture 1) +else() + set(_expected_capture 0) +endif() +if(NOT _capture STREQUAL "${_expected_capture}") + message(FATAL_ERROR "Installed build-info.json capture setting does not match ${LIVEKIT_EXPECT_CAPTURE}") +endif() + +if(NOT LIVEKIT_EXPECT_VERSION STREQUAL "") + file(READ "${_sdk_root}/include/livekit/build.h" _build_header) + string(FIND "${_build_header}" "#define LIVEKIT_BUILD_VERSION \"${LIVEKIT_EXPECT_VERSION}\"" _version_position) + string(JSON _version GET "${_build_info}" sdk_version) + string(JSON _rust_commit GET "${_build_info}" rust_submodule_commit) + string(JSON _ffi_version GET "${_build_info}" livekit_ffi_version) + string(LENGTH "${_rust_commit}" _commit_length) + if(_version_position EQUAL -1 OR NOT _version STREQUAL LIVEKIT_EXPECT_VERSION OR _version STREQUAL "0.0.0") + message(FATAL_ERROR "Installed SDK version does not match ${LIVEKIT_EXPECT_VERSION}") + endif() + if(NOT _rust_commit MATCHES "^[0-9a-f]+$" OR _commit_length LESS 7) + message(FATAL_ERROR "Installed SDK is missing its Rust submodule commit") + endif() + if(NOT _ffi_version MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+") + message(FATAL_ERROR "Installed SDK is missing its livekit-ffi version") + endif() +endif() add_executable(livekit_capture_package_test test_capture_bundle.cpp) target_compile_features(livekit_capture_package_test PRIVATE cxx_std_17) diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp index e9e41728..8a652c9b 100644 --- a/src/tests/unit/test_capture_source.cpp +++ b/src/tests/unit/test_capture_source.cpp @@ -215,15 +215,6 @@ TEST_F(CaptureSourceCallbackTest, MissingSourceUsesCaptureSourceError) { EXPECT_THROW(static_cast(future.get()), CaptureSourceError); } -TEST_F(CaptureSourceCallbackTest, InvalidEncodedInfoFailsWithoutRetainingListener) { - auto future = beginCreation(); - auto owned = makeOwned(proto::CAPTURE_SOURCE_ENCODED); - owned.mutable_info()->clear_codec(); - emitFfiEvent(creationEvent(async_id_, owned)); - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); - EXPECT_EQ(FfiClientTestAccess::listenerCount(), 0u); -} - TEST_F(CaptureSourceCallbackTest, ShutdownCancelsPendingFutureWithCaptureSourceError) { auto future = beginCreation(); FfiClient::instance().shutdown(); From 7f6ddca49a68a1dee703803c69452e5ad156ddf2 Mon Sep 17 00:00:00 2001 From: stavied <40528896+stephen-derosa@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:49:35 -0600 Subject: [PATCH 10/29] Update CMakeLists.txt to trigger rebuild on a changed livekit-capture/build.rs file Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index e1934ba4..2d963cba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -336,6 +336,7 @@ file(GLOB_RECURSE RUST_SOURCES CONFIGURE_DEPENDS "${RUST_ROOT}/livekit-capture/Cargo.toml" "${RUST_ROOT}/livekit-capture/src/*" "${RUST_ROOT}/livekit-capture/shaders/*" + "${RUST_ROOT}/livekit-capture/build.rs" ) list(APPEND RUST_SOURCES "${RUST_ROOT}/Cargo.toml" From bef62f0793dfe8db7308d3e8510f624f33fd9f10 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 12 Oct 2026 13:50:03 -0600 Subject: [PATCH 11/29] amalgamate --- docs/README.md | 3 ++- docs/building.md | 41 +++++++++++++++++++++++++++++++---------- docs/capture-sources.md | 32 ++------------------------------ 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/docs/README.md b/docs/README.md index ab5bc305..67899d82 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,8 @@ Additional documentation for the SDK. - [Building](building.md) — prerequisites, build scripts, CMake presets, vcpkg, integration into your CMake project, troubleshooting. - [Capture sources](capture-sources.md) — pattern, clock, and GStreamer sources; - build features, release bundles, and publishing. + build features, release bundles, and publishing. Use capture to stream video through LiveKit + without maintaining your own video pipeline. - [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 943b5ee7..2d5c5f0e 100644 --- a/docs/building.md +++ b/docs/building.md @@ -201,6 +201,7 @@ cmake --build build | `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 | +| `LIVEKIT_BUILD_CAPTURE` | preset-dependent | Enable GStreamer, pattern, and clock capture sources in the Rust FFI | ## Build output @@ -361,22 +362,42 @@ separately when it is no longer needed. ## Capture sources -Standard `debug`, `release`, and `*-examples` builds disable capture. The -`*-tests` and `*-all` presets enable it by default. Both build scripts accept -`--capture` and `--no-capture` to override the preset: +Only the repository's `*-tests` and `*-all` presets enable capture by default. +The `debug`, `release`, and `*-examples` presets disable it. Use `--capture` +with any build command to enable all three sources (GStreamer, GPU pattern, and +GPU wall clock), or `--no-capture` to disable them explicitly: ```bash ./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture +./build.sh debug-tests --no-capture ``` -The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated -`LIVEKIT_CAPTURE_ENABLED` macro is 0 or 1 and supports `#if` checks in consumers. - On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. -Capture builds enable GStreamer, GPU test patterns, and the GPU wall clock. -They require GStreamer development libraries at build time and its runtime -libraries when the SDK is loaded. Release artifacts with `-capture` in their -names include capture; standard artifacts disable it. +Custom CMake configurations can set `LIVEKIT_BUILD_CAPTURE=ON`. The build +enables `capture-gstreamer`, `capture-pattern`, and `capture-clock` in the +Rust FFI. + +The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated numeric +`LIVEKIT_CAPTURE_ENABLED` macro (0 or 1) supports `#if` checks in consumers. +`share/livekit/build-info.json` in an installed bundle describes the same build +features; neither value tests whether a GPU or a requested GStreamer plugin is +available at runtime. + +Release downloads with `-capture` in their names include these features, for +example `livekit-sdk-macos-arm64-capture-.tar.gz`. Standard release +bundles have capture disabled. + +Building a capture-enabled SDK requires GStreamer development libraries +accessible to `pkg-config`. On macOS, install `gstreamer` with Homebrew. On +Ubuntu, install `libgstreamer1.0-dev` and +`libgstreamer-plugins-base1.0-dev`, plus the runtime plugins your pipeline +uses. On Windows, install the MSVC runtime and development packages from the +[GStreamer downloads page](https://gstreamer.freedesktop.org/download/) and put +the installation's `bin` directory on `PATH` and `lib/pkgconfig` directory on +`PKG_CONFIG_PATH`. + +Capture bundles require GStreamer at runtime, including when using pattern or +clock sources. The SDK does not redistribute GStreamer libraries or plugins. CI builds both variants in one job per platform. It first builds and bundles the standard SDK, then runs the build script with `--capture` in the same CMake diff --git a/docs/capture-sources.md b/docs/capture-sources.md index 967892d3..e64555df 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -14,36 +14,8 @@ Native camera device capture and device enumeration are not part of this API. ## Build and release bundles -Only the repository's `*-tests` and `*-all` presets enable capture by default. -The `debug`, `release`, and `*-examples` presets disable it. Use `--capture` -with any build command to enable all three sources, or `--no-capture` to disable -them explicitly: - -```bash -./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture -./build.sh debug-tests --no-capture -``` - -On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. -Custom CMake configurations can set `LIVEKIT_BUILD_CAPTURE=ON`. The build enables -`capture-gstreamer`, `capture-pattern`, and `capture-clock` in the Rust FFI. - -Release downloads with `-capture` in their names include these features, for -example `livekit-sdk-macos-arm64-capture-.tar.gz`. Standard release -bundles have capture disabled. The generated numeric `LIVEKIT_CAPTURE_ENABLED` macro (0 or 1) -and `share/livekit/build-info.json` describe the selected bundle's build features; -they do not test whether a GPU or a requested GStreamer plugin is available. - -Building a capture SDK requires GStreamer development libraries accessible to -`pkg-config`. On macOS, install `gstreamer` with Homebrew. On Ubuntu, install -`libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, plus the runtime -plugins your pipeline uses. On Windows, install the MSVC runtime and development -packages from the [GStreamer downloads page](https://gstreamer.freedesktop.org/download/) -and put the installation's `bin` directory on `PATH` and `lib/pkgconfig` directory -on `PKG_CONFIG_PATH`. - -Capture bundles require GStreamer at runtime, including when using pattern or -clock sources. The SDK does not redistribute GStreamer libraries or plugins. +For build flags, release bundle naming, GStreamer prerequisites, and runtime +requirements, see [Building — Capture sources](building.md#capture-sources). ## Create and publish a source From 68c59835c993a71c1b73d5f6809e22c8e8649920 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 12 Oct 2026 14:00:22 -0600 Subject: [PATCH 12/29] LIVEKIT_INTERNAL_API bool dropFfiHandle --- src/ffi_client.h | 6 ++++++ src/ffi_handle.cpp | 5 ++++- src/tests/CMakeLists.txt | 1 - src/tests/unit/test_capture_source.cpp | 5 ++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/ffi_client.h b/src/ffi_client.h index d0dd6be0..7ecdabf0 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -67,6 +67,12 @@ extern "C" void livekit_ffi_dispose(); extern "C" LIVEKIT_INTERNAL_API void ffiEventCallback(const uint8_t* buf, size_t len); +/// @brief Release a Rust FFI handle and report whether it existed. +/// Internal ownership test seam; not part of the public SDK API. +/// @param handle Rust resource handle to release. +/// @return True if the handle was released; false if it did not exist. +[[nodiscard]] LIVEKIT_INTERNAL_API bool dropFfiHandle(std::uint64_t handle) noexcept; + // The FfiClient is used to communicate with the FFI interface of the Rust SDK // We use the generated protocol messages to facilitate the communication. class LIVEKIT_INTERNAL_API FfiClient { diff --git a/src/ffi_handle.cpp b/src/ffi_handle.cpp index b19ebe94..ea05ede9 100644 --- a/src/ffi_handle.cpp +++ b/src/ffi_handle.cpp @@ -16,10 +16,13 @@ #include "livekit/ffi_handle.h" +#include "ffi_client.h" #include "livekit_ffi.h" namespace livekit { +bool dropFfiHandle(std::uint64_t handle) noexcept { return livekit_ffi_drop_handle(handle); } + FfiHandle::FfiHandle(uintptr_t h) noexcept : handle_(h) {} FfiHandle::~FfiHandle() { reset(); } @@ -35,7 +38,7 @@ FfiHandle& FfiHandle::operator=(FfiHandle&& other) noexcept { void FfiHandle::reset(uintptr_t new_handle) noexcept { if (handle_) { - livekit_ffi_drop_handle(handle_); + static_cast(dropFfiHandle(handle_)); } handle_ = new_handle; } diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 0fcc4b84..9583af73 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -89,7 +89,6 @@ if(UNIT_TEST_SOURCES) target_link_libraries(livekit_unit_tests PRIVATE livekit - livekit_ffi # Capture ownership tests verify Rust handle disposal directly. spdlog::spdlog ${LIVEKIT_PROTOBUF_TARGET} GTest::gtest_main diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp index 8a652c9b..c46a2cdb 100644 --- a/src/tests/unit/test_capture_source.cpp +++ b/src/tests/unit/test_capture_source.cpp @@ -25,7 +25,6 @@ #include "capture.pb.h" #include "capture_source_internal.h" #include "ffi_client.h" -#include "livekit_ffi.h" namespace livekit { @@ -169,8 +168,8 @@ TEST_F(CaptureSourceCallbackTest, AbandonedAndInvalidResultsReleaseBothRustHandl EXPECT_THROW(static_cast(future.get()), CaptureSourceError); } // A second drop must fail. If ownership leaked, this also cleans it up. - EXPECT_FALSE(livekit_ffi_drop_handle(capture.handle().id())); - EXPECT_FALSE(livekit_ffi_drop_handle(video.handle().id())); + EXPECT_FALSE(dropFfiHandle(capture.handle().id())); + EXPECT_FALSE(dropFfiHandle(video.handle().id())); EXPECT_EQ(FfiClientTestAccess::listenerCount(), 0u); } } From 517c75e68c1acde1b099a7004c8753942b75f371 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 12 Oct 2026 14:14:04 -0600 Subject: [PATCH 13/29] const ref --- src/capture_source.cpp | 8 ++++---- src/capture_source_internal.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/capture_source.cpp b/src/capture_source.cpp index da58599b..b926c923 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -124,13 +124,13 @@ std::future> CaptureSource::create(GstreamerVideo return createCaptureSource(std::move(config)); } -proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config) { +proto::NewCaptureSourceRequest toProto(const GstreamerVideoSourceConfig& config) { if (config.resolution) { validateResolution(*config.resolution); } proto::NewCaptureSourceRequest request; auto* gstreamer = request.mutable_gstreamer(); - gstreamer->set_pipeline(std::move(config.pipeline)); + gstreamer->set_pipeline(config.pipeline); if (config.codec) { gstreamer->set_codec(static_cast(*config.codec)); } @@ -140,8 +140,8 @@ proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config) { } if (config.rate_control) { auto* rate_control = gstreamer->mutable_rate_control(); - rate_control->set_element(std::move(config.rate_control->element)); - rate_control->set_property(std::move(config.rate_control->property)); + rate_control->set_element(config.rate_control->element); + rate_control->set_property(config.rate_control->property); rate_control->set_unit(toProto(config.rate_control->unit)); } return request; diff --git a/src/capture_source_internal.h b/src/capture_source_internal.h index 34321ed4..446f1534 100644 --- a/src/capture_source_internal.h +++ b/src/capture_source_internal.h @@ -27,7 +27,7 @@ class NewCaptureSourceRequest; /// Serialize capture configurations for the FFI request. /// Internal test seam; not part of the public SDK API. -LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(GstreamerVideoSourceConfig config); +LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const GstreamerVideoSourceConfig& config); LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const PatternVideoSourceConfig& config); LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config); From cb20ce5f1da6cdc04864bf9e897997a90dd776d4 Mon Sep 17 00:00:00 2001 From: Alan George Date: Mon, 12 Oct 2026 16:39:07 -0600 Subject: [PATCH 14/29] Fix default off for capture --- .github/workflows/tests.yml | 1 + AGENTS.md | 6 +++--- CMakePresets.json | 27 +++++++++++++-------------- build.cmd | 6 +++--- build.sh | 9 +++------ docs/building.md | 7 +++---- 6 files changed, 26 insertions(+), 30 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1ddd98d0..8a574094 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -588,6 +588,7 @@ jobs: - name: Configure (linux-debug-tests preset + coverage flags) run: | cmake --preset linux-debug-tests \ + -DLIVEKIT_BUILD_CAPTURE=ON \ -DCMAKE_C_FLAGS="-Wno-deprecated-declarations --coverage" \ -DCMAKE_CXX_FLAGS="-Wno-deprecated-declarations --coverage" \ -DCMAKE_EXE_LINKER_FLAGS="--coverage" \ diff --git a/AGENTS.md b/AGENTS.md index 908de94c..5a022aad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,9 +132,9 @@ The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated `LIVEKIT_CAPTURE_ENABLED` macro is 0 or 1 and supports `#if` checks in consumers. Both build scripts accept `--capture` to enable GStreamer, pattern, and clock -sources, and `--no-capture` to disable them. Test/all presets enable capture by -default; normal and example presets disable it. Capture-enabled builds need -GStreamer development and runtime libraries. Release workflows publish separate +sources, and `--no-capture` to disable them explicitly. Capture is disabled by +default for every preset. Capture-enabled builds need GStreamer development and +runtime libraries. Release workflows publish separate `-capture` bundles and validate creation through the installed SDK using the standalone CTest project in `src/tests/packaging/`. diff --git a/CMakePresets.json b/CMakePresets.json index 3d1d5511..9157e760 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -14,10 +14,9 @@ } }, { - "name": "capture-tests", + "name": "tests", "hidden": true, "cacheVariables": { - "LIVEKIT_BUILD_CAPTURE": "ON", "LIVEKIT_BUILD_TESTS": "ON" } }, @@ -234,7 +233,7 @@ "displayName": "Windows x64 Release with Tests + Examples", "description": "Build for Windows x64 Release with tests and examples", "inherits": [ - "capture-tests", + "tests", "windows-base" ], "binaryDir": "${sourceDir}/build-release", @@ -248,7 +247,7 @@ "displayName": "Windows x64 Debug with Tests + Examples", "description": "Build for Windows x64 Debug with tests and examples", "inherits": [ - "capture-tests", + "tests", "windows-base" ], "binaryDir": "${sourceDir}/build-debug", @@ -262,7 +261,7 @@ "displayName": "Windows x64 Release with Tests", "description": "Build for Windows x64 Release with test suite", "inherits": [ - "capture-tests", + "tests", "windows-base" ], "binaryDir": "${sourceDir}/build-release", @@ -276,7 +275,7 @@ "displayName": "Windows x64 Debug with Tests", "description": "Build for Windows x64 Debug with test suite", "inherits": [ - "capture-tests", + "tests", "windows-base" ], "binaryDir": "${sourceDir}/build-debug", @@ -290,7 +289,7 @@ "displayName": "Linux Release with Tests + Examples", "description": "Build for Linux Release with tests and examples", "inherits": [ - "capture-tests", + "tests", "linux-base" ], "binaryDir": "${sourceDir}/build-release", @@ -304,7 +303,7 @@ "displayName": "Linux Debug with Tests + Examples", "description": "Build for Linux Debug with tests and examples", "inherits": [ - "capture-tests", + "tests", "linux-base" ], "binaryDir": "${sourceDir}/build-debug", @@ -318,7 +317,7 @@ "displayName": "Linux Release with Tests", "description": "Build for Linux Release with test suite", "inherits": [ - "capture-tests", + "tests", "linux-base" ], "binaryDir": "${sourceDir}/build-release", @@ -332,7 +331,7 @@ "displayName": "Linux Debug with Tests", "description": "Build for Linux Debug with test suite", "inherits": [ - "capture-tests", + "tests", "linux-base" ], "binaryDir": "${sourceDir}/build-debug", @@ -346,7 +345,7 @@ "displayName": "macOS Release with Tests + Examples", "description": "Build for macOS Release with tests and examples", "inherits": [ - "capture-tests", + "tests", "macos-base" ], "binaryDir": "${sourceDir}/build-release", @@ -360,7 +359,7 @@ "displayName": "macOS Debug with Tests + Examples", "description": "Build for macOS Debug with tests and examples", "inherits": [ - "capture-tests", + "tests", "macos-base" ], "binaryDir": "${sourceDir}/build-debug", @@ -374,7 +373,7 @@ "displayName": "macOS Release with Tests", "description": "Build for macOS Release with test suite", "inherits": [ - "capture-tests", + "tests", "macos-base" ], "binaryDir": "${sourceDir}/build-release", @@ -388,7 +387,7 @@ "displayName": "macOS Debug with Tests", "description": "Build for macOS Debug with test suite", "inherits": [ - "capture-tests", + "tests", "macos-base" ], "binaryDir": "${sourceDir}/build-debug", diff --git a/build.cmd b/build.cmd index 47f1fd78..1a60802f 100644 --- a/build.cmd +++ b/build.cmd @@ -8,7 +8,7 @@ set "BUILD_TYPE=Release" set "PRESET=windows-release" set "LIVEKIT_VERSION=" set "CMAKE_EXTRA_ARGS=" -set "CAPTURE_OVERRIDE=" +set "CAPTURE_OVERRIDE=OFF" set "BUILD_PARALLEL_JOBS=" set "DO_BUNDLE=" set "PREFIX=" @@ -144,7 +144,7 @@ if defined LIVEKIT_VERSION ( set "CMAKE_EXTRA_ARGS=-DLIVEKIT_VERSION=%LIVEKIT_VERSION%" echo ==^> Injecting LIVEKIT_VERSION=%LIVEKIT_VERSION% ) -if defined CAPTURE_OVERRIDE set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_BUILD_CAPTURE=%CAPTURE_OVERRIDE%" +set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_BUILD_CAPTURE=%CAPTURE_OVERRIDE%" goto dispatch :dispatch @@ -229,7 +229,7 @@ echo help Show this help echo. echo Options (for debug / release): echo --capture Enable all capture sources ^(requires GStreamer^) -echo --no-capture Disable capture sources, overriding the preset +echo --no-capture Explicitly disable capture sources ^(the default^) echo --bundle Install the SDK bundle using cmake --install echo --prefix ^ Install prefix for --bundle ^(default: .\sdk-out\livekit-sdk^) echo. diff --git a/build.sh b/build.sh index ff33b466..84220a4d 100755 --- a/build.sh +++ b/build.sh @@ -14,7 +14,7 @@ ARCHIVE_NAME="" GENERATOR="" MACOS_ARCH="" LIVEKIT_VERSION="" -CAPTURE_OVERRIDE="" +CAPTURE_OVERRIDE="OFF" # Detect OS for preset selection detect_os() { @@ -48,7 +48,7 @@ Commands: Options (for debug / release): --capture Enable all capture sources (requires GStreamer) - --no-capture Disable capture sources, overriding the preset + --no-capture Explicitly disable capture sources (the default) --bundle Install the SDK bundle using 'cmake --install' --prefix Install prefix for --bundle (default: ./sdk-out/livekit-sdk) @@ -146,10 +146,7 @@ parse_opts() { configure() { echo "==> Configuring CMake (${BUILD_TYPE}) using preset ${PRESET}..." - local -a extra_args=() - if [[ -n "${CAPTURE_OVERRIDE:-}" ]]; then - extra_args+=("-DLIVEKIT_BUILD_CAPTURE=${CAPTURE_OVERRIDE}") - fi + local -a extra_args=("-DLIVEKIT_BUILD_CAPTURE=${CAPTURE_OVERRIDE}") if [[ -n "${LIVEKIT_VERSION}" ]]; then echo "==> Injecting LIVEKIT_VERSION=${LIVEKIT_VERSION}" extra_args+=("-DLIVEKIT_VERSION=${LIVEKIT_VERSION}") diff --git a/docs/building.md b/docs/building.md index 2d5c5f0e..73bf6c01 100644 --- a/docs/building.md +++ b/docs/building.md @@ -201,7 +201,7 @@ cmake --build build | `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 | -| `LIVEKIT_BUILD_CAPTURE` | preset-dependent | Enable GStreamer, pattern, and clock capture sources in the Rust FFI | +| `LIVEKIT_BUILD_CAPTURE` | OFF | Enable GStreamer, pattern, and clock capture sources in the Rust FFI | ## Build output @@ -362,14 +362,13 @@ separately when it is no longer needed. ## Capture sources -Only the repository's `*-tests` and `*-all` presets enable capture by default. -The `debug`, `release`, and `*-examples` presets disable it. Use `--capture` +Capture is disabled by default for every repository preset. Use `--capture` with any build command to enable all three sources (GStreamer, GPU pattern, and GPU wall clock), or `--no-capture` to disable them explicitly: ```bash ./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture -./build.sh debug-tests --no-capture +./build.sh debug-tests ``` On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. From b4e67d9c97bd24aa1d7032f02ad30105bfea64b3 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 13 Oct 2026 11:15:04 -0600 Subject: [PATCH 15/29] dockerfile for testing --- AGENTS.md | 5 ++ docker/linux-build.Dockerfile | 66 ++++++++++++++++++++++ docker/linux-build.Dockerfile.dockerignore | 7 +++ docs/README.md | 4 +- docs/building.md | 60 ++++++++++++++++++++ src/capture_source.cpp | 2 +- 6 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 docker/linux-build.Dockerfile create mode 100644 docker/linux-build.Dockerfile.dockerignore diff --git a/AGENTS.md b/AGENTS.md index 5a022aad..89c2e319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ Be sure to update the directory layout in this file if the directory layout chan | `client-sdk-rust/livekit-ffi/protocol/*.proto` | FFI contract (protobuf definitions, read-only reference) | | `cmake/` | Build helpers (`protobuf.cmake`, `spdlog.cmake`, `nlohmann_json.cmake`, `LiveKitConfig.cmake.in`) | | `scripts/` | Local helper scripts for SDK-specific development tasks | +| `docker/` | Dockerfiles for local Linux build environments (`linux-build.Dockerfile`); not used by CI | | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | | `src/tests/packaging/` | Standalone CTest integration tests consuming installed SDK bundles | @@ -123,6 +124,10 @@ Updates to ./build.sh and ./build.cmd should be accompanied by updates to this f ./build.sh clean-all # Full clean (C++ + local-install + Rust targets) ``` +To build for Linux from another host, use the container in +`docker/linux-build.Dockerfile`. See "Build in a Linux container" in +`docs/building.md` for the image and `docker run` commands. + To create an installable SDK bundle, use the same build command with `--bundle --prefix ` (for example, `./build.sh release --bundle --prefix sdk-out/livekit-sdk`). On Windows, use diff --git a/docker/linux-build.Dockerfile b/docker/linux-build.Dockerfile new file mode 100644 index 00000000..0717ce07 --- /dev/null +++ b/docker/linux-build.Dockerfile @@ -0,0 +1,66 @@ +# Linux build environment for client-sdk-cpp. +# Ubuntu packages match the Linux jobs in .github/workflows/builds.yml, +# tests.yml, and make-release.yml. Pass --build-arg INSTALL_GSTREAMER=1 to also +# install the GStreamer packages from .github/actions/setup-capture. +# The source tree is bind-mounted at /work. +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive +# Empty means the default version in install-clang.sh. +ARG LLVM_VERSION + +# ca-certificates, curl, git, git-lfs, xz-utils, sudo, and python3 are already +# present on GitHub-hosted runners. python3 is what the coverage job uses for +# gcovr. git is needed because FetchContent clones GoogleTest. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git rsync xz-utils \ + python3 \ + build-essential cmake ninja-build pkg-config \ + llvm-dev libclang-dev clang \ + libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ + libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ + libxrandr-dev libxi-dev libxkbcommon-dev \ + libasound2-dev libpulse-dev \ + libssl-dev \ + libprotobuf-dev protobuf-compiler \ + libabsl-dev \ + libcurl4-openssl-dev \ + libwayland-dev libdecor-0-dev \ + libunwind-dev libjpeg-dev \ + gdb jq \ + && rm -rf /var/lib/apt/lists/* + +# webrtc-sys needs a recent clang for the hermetic libc++ in libwebrtc. Use the +# same script as CI. The build context is the repository root, and +# linux-build.Dockerfile.dockerignore limits it to this one file. +COPY client-sdk-rust/.github/scripts/install-clang.sh /tmp/install-clang.sh +RUN LLVM_ROOT=/opt/llvm /tmp/install-clang.sh \ + && rm /tmp/install-clang.sh + +ENV CC=/opt/llvm/bin/clang \ + CXX=/opt/llvm/bin/clang++ \ + LIBCLANG_PATH=/opt/llvm/lib \ + RUSTUP_HOME=/opt/rustup \ + CARGO_HOME=/opt/cargo \ + PATH=/opt/cargo/bin:$PATH + +RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \ + && chmod -R a+rwX /opt/rustup /opt/cargo \ + && cargo --version + +# Keep Rust outputs out of the synced source tree. +ENV CARGO_TARGET_DIR=/cargo-target + +# Last install so a GStreamer toggle reuses the toolchain layers above. +# libunwind-dev is already in the base package list. +ARG INSTALL_GSTREAMER=0 +RUN if [ "$INSTALL_GSTREAMER" = "1" ]; then \ + apt-get update && apt-get install -y --no-install-recommends \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav \ + && rm -rf /var/lib/apt/lists/*; \ + fi + +WORKDIR /work +CMD ["bash"] diff --git a/docker/linux-build.Dockerfile.dockerignore b/docker/linux-build.Dockerfile.dockerignore new file mode 100644 index 00000000..fa0d2700 --- /dev/null +++ b/docker/linux-build.Dockerfile.dockerignore @@ -0,0 +1,7 @@ +# Build context allowlist for linux-build.Dockerfile. The context is the +# repository root, but the image needs only the clang install script. +* +!client-sdk-rust/.github/scripts/install-clang.sh +build-debug +build-release + diff --git a/docs/README.md b/docs/README.md index 67899d82..17158b5b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,8 @@ Additional documentation for the SDK. -- [Building](building.md) — prerequisites, build scripts, CMake presets, - vcpkg, integration into your CMake project, troubleshooting. +- [Building](building.md) — prerequisites, build scripts, Linux build + container, CMake presets, vcpkg, integration into your CMake project, troubleshooting. - [Capture sources](capture-sources.md) — pattern, clock, and GStreamer sources; build features, release bundles, and publishing. Use capture to stream video through LiveKit without maintaining your own video pipeline. diff --git a/docs/building.md b/docs/building.md index 73bf6c01..1a6b49f8 100644 --- a/docs/building.md +++ b/docs/building.md @@ -417,6 +417,66 @@ the installed SDK instead of another configuration's most recent build. See [Capture sources](capture-sources.md) for dependencies and usage. +## Build in a Linux container + +You can test a clean Linux build and its dependency tree from a macOS or Linux +host with `docker/linux-build.Dockerfile`. The container is a build check, not +a development environment. The image uses Ubuntu 24.04 with the same packages +as the Linux CI jobs. It also includes GStreamer for `--capture` builds and +the stable Rust toolchain. + +The container bind-mounts the source paths that the build reads from the host +into `/work`. Changes on the host show up in the container immediately. The +`/work` directory itself is the `lk-cpp-work` volume, so build outputs such as +`build-*`, `local-install`, and `sdk-out` stay in the container. Host build +directories and `.git` data are not mounted. + +1. From the repository root, build the image. The `client-sdk-rust` submodule + must be checked out, because the image copies its clang install script. + + ```bash + docker build -f docker/linux-build.Dockerfile -t livekit-cpp-build . + ``` + + To install gstreamer: + ```bash + docker build -f docker/linux-build.Dockerfile \ + --build-arg INSTALL_GSTREAMER=1 \ + -t livekit-cpp-build . + ``` + + The build context is the repository root. The file + `docker/linux-build.Dockerfile.dockerignore` limits the context to the + clang install script, so Docker does not send build outputs or `.git`. + +2. From the repository root, start the container: + + ```bash + docker run -d --name lk-cpp --network host \ + -v lk-cpp-work:/work \ + -v lk-cargo-target:/cargo-target \ + -v lk-cargo-registry:/opt/cargo/registry \ + -v "$(pwd)/build.sh:/work/build.sh" \ + -v "$(pwd)/CMakeLists.txt:/work/CMakeLists.txt" \ + -v "$(pwd)/CMakePresets.json:/work/CMakePresets.json" \ + -v "$(pwd)/build.h.in:/work/build.h.in" \ + -v "$(pwd)/.build-info.json.in:/work/.build-info.json.in" \ + -v "$(pwd)/cmake:/work/cmake" \ + -v "$(pwd)/include:/work/include" \ + -v "$(pwd)/src:/work/src" \ + -v "$(pwd)/scripts:/work/scripts" \ + -v "$(pwd)/client-sdk-rust:/work/client-sdk-rust" \ + -v lk-rust-source-target:/work/client-sdk-rust/target \ + -v "$(pwd)/cpp-example-collection:/work/cpp-example-collection" \ + livekit-cpp-build sleep infinity + ``` + +3. Open a shell in the container: + + ```bash + docker exec -it lk-cpp bash + ``` +For a fully clean build, run `./build.sh clean-all` in the container, or remove the volumes. ## Support diff --git a/src/capture_source.cpp b/src/capture_source.cpp index b926c923..c8cc7973 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -53,7 +53,7 @@ namespace { // Report validation and synchronous send failures through the same future as // asynchronous creation errors. All public factories share this path. template -std::future> createCaptureSource(Config config) { +std::future> createCaptureSource(const Config& config) { try { return FfiClient::instance().newCaptureSourceAsync(toProto(std::move(config))); } catch (const std::exception& error) { From 3c0a66d8df6fffe01cbccb754d0eb775e7ca4f95 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 13 Oct 2026 14:48:36 -0600 Subject: [PATCH 16/29] clang --- include/livekit/capture_source.h | 2 +- src/capture_source.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index 968c8f20..c6eeb2cf 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -183,7 +183,7 @@ class LIVEKIT_API CaptureSource { /// @param config GStreamer pipeline configuration. /// @return A future that resolves to the created capture source. /// @throws CaptureSourceError When awaiting the future if creation fails. - static std::future> create(GstreamerVideoSourceConfig config); + static std::future> create(const GstreamerVideoSourceConfig& config); /// @brief Create the built-in test pattern capture source. /// diff --git a/src/capture_source.cpp b/src/capture_source.cpp index c8cc7973..dcf32a47 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -120,8 +120,8 @@ CaptureResult resultFromEvent(const proto::CaptureSourceEvent& event) { } // namespace -std::future> CaptureSource::create(GstreamerVideoSourceConfig config) { - return createCaptureSource(std::move(config)); +std::future> CaptureSource::create(const GstreamerVideoSourceConfig& config) { + return createCaptureSource(config); } proto::NewCaptureSourceRequest toProto(const GstreamerVideoSourceConfig& config) { From 0663d22752f1e2617ac9972986f4c311332bf98c Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 13 Oct 2026 14:51:30 -0600 Subject: [PATCH 17/29] rename --- AGENTS.md | 5 ----- docker/linux-build.Dockerfile | 2 +- docker/linux-build.Dockerfile.dockerignore | 2 +- docs/building.md | 8 ++++---- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 89c2e319..5a022aad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,6 @@ Be sure to update the directory layout in this file if the directory layout chan | `client-sdk-rust/livekit-ffi/protocol/*.proto` | FFI contract (protobuf definitions, read-only reference) | | `cmake/` | Build helpers (`protobuf.cmake`, `spdlog.cmake`, `nlohmann_json.cmake`, `LiveKitConfig.cmake.in`) | | `scripts/` | Local helper scripts for SDK-specific development tasks | -| `docker/` | Dockerfiles for local Linux build environments (`linux-build.Dockerfile`); not used by CI | | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | | `src/tests/packaging/` | Standalone CTest integration tests consuming installed SDK bundles | @@ -124,10 +123,6 @@ Updates to ./build.sh and ./build.cmd should be accompanied by updates to this f ./build.sh clean-all # Full clean (C++ + local-install + Rust targets) ``` -To build for Linux from another host, use the container in -`docker/linux-build.Dockerfile`. See "Build in a Linux container" in -`docs/building.md` for the image and `docker run` commands. - To create an installable SDK bundle, use the same build command with `--bundle --prefix ` (for example, `./build.sh release --bundle --prefix sdk-out/livekit-sdk`). On Windows, use diff --git a/docker/linux-build.Dockerfile b/docker/linux-build.Dockerfile index 0717ce07..f34a5391 100644 --- a/docker/linux-build.Dockerfile +++ b/docker/linux-build.Dockerfile @@ -32,7 +32,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # webrtc-sys needs a recent clang for the hermetic libc++ in libwebrtc. Use the # same script as CI. The build context is the repository root, and -# linux-build.Dockerfile.dockerignore limits it to this one file. +# linux.Dockerfile.dockerignore limits it to this one file. COPY client-sdk-rust/.github/scripts/install-clang.sh /tmp/install-clang.sh RUN LLVM_ROOT=/opt/llvm /tmp/install-clang.sh \ && rm /tmp/install-clang.sh diff --git a/docker/linux-build.Dockerfile.dockerignore b/docker/linux-build.Dockerfile.dockerignore index fa0d2700..778bf9e0 100644 --- a/docker/linux-build.Dockerfile.dockerignore +++ b/docker/linux-build.Dockerfile.dockerignore @@ -1,4 +1,4 @@ -# Build context allowlist for linux-build.Dockerfile. The context is the +# Build context allowlist for linux.Dockerfile. The context is the # repository root, but the image needs only the clang install script. * !client-sdk-rust/.github/scripts/install-clang.sh diff --git a/docs/building.md b/docs/building.md index 1a6b49f8..48e7e7d7 100644 --- a/docs/building.md +++ b/docs/building.md @@ -420,7 +420,7 @@ See [Capture sources](capture-sources.md) for dependencies and usage. ## Build in a Linux container You can test a clean Linux build and its dependency tree from a macOS or Linux -host with `docker/linux-build.Dockerfile`. The container is a build check, not +host with `docker/linux.Dockerfile`. The container is a build check, not a development environment. The image uses Ubuntu 24.04 with the same packages as the Linux CI jobs. It also includes GStreamer for `--capture` builds and the stable Rust toolchain. @@ -435,18 +435,18 @@ directories and `.git` data are not mounted. must be checked out, because the image copies its clang install script. ```bash - docker build -f docker/linux-build.Dockerfile -t livekit-cpp-build . + docker build -f docker/linux.Dockerfile -t livekit-cpp-build . ``` To install gstreamer: ```bash - docker build -f docker/linux-build.Dockerfile \ + docker build -f docker/linux.Dockerfile \ --build-arg INSTALL_GSTREAMER=1 \ -t livekit-cpp-build . ``` The build context is the repository root. The file - `docker/linux-build.Dockerfile.dockerignore` limits the context to the + `docker/linux.Dockerfile.dockerignore` limits the context to the clang install script, so Docker does not send build outputs or `.git`. 2. From the repository root, start the container: From 5b245294b77ac3a517af2d045d336c94ce28a71c Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 13 Oct 2026 15:26:16 -0600 Subject: [PATCH 18/29] rust hash points to release --- client-sdk-rust | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client-sdk-rust b/client-sdk-rust index 35d7655c..49b3ebe9 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit 35d7655c580147c2ad05fc8ffa5d885e88c822b9 +Subproject commit 49b3ebe9ab1c574114b164868ad226cc26a9b1be From 69e334dcace9085c84c418bfa1d7338d48b8faf8 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 13 Oct 2026 19:59:12 -0600 Subject: [PATCH 19/29] client-sdk-cpp base first, then install gstreamer and build capture --- .github/actions/setup-sdk-deps/action.yml | 72 +++++++++++++++++++++ .github/workflows/builds.yml | 41 +----------- .github/workflows/ci.yml | 2 + .github/workflows/make-release.yml | 75 ++++++---------------- .github/workflows/tests.yml | 77 ++++------------------- AGENTS.md | 3 +- docs/building.md | 17 +++-- 7 files changed, 121 insertions(+), 166 deletions(-) create mode 100644 .github/actions/setup-sdk-deps/action.yml diff --git a/.github/actions/setup-sdk-deps/action.yml b/.github/actions/setup-sdk-deps/action.yml new file mode 100644 index 00000000..ff4590a0 --- /dev/null +++ b/.github/actions/setup-sdk-deps/action.yml @@ -0,0 +1,72 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Set up base SDK dependencies +description: Install base SDK packages and the build compiler, without capture dependencies. +inputs: + vcpkg-commit: + description: Pinned vcpkg revision for Windows builds. + default: fb87e2bb3fe69e16c224989acb5a61349166c782 + linux-extra-packages: + description: Optional space-separated Linux packages to install with the base dependencies. + default: '' +runs: + using: composite + steps: + - name: Setup vcpkg (Windows only) + if: runner.os == 'Windows' + uses: lukka/run-vcpkg@5e0cab206a5ea620130caf672fce3e4a6b5666a1 # v11.5 + with: + vcpkgGitCommitId: ${{ inputs.vcpkg-commit }} + + - name: Install base deps (Ubuntu) + if: runner.os == 'Linux' + shell: bash + env: + SDK_EXTRA_PACKAGES: ${{ inputs.linux-extra-packages }} + run: | + set -eux + extra_packages=() + if [[ -n "$SDK_EXTRA_PACKAGES" ]]; then + read -r -a extra_packages <<< "$SDK_EXTRA_PACKAGES" + fi + sudo apt-get update + sudo apt-get install -y \ + build-essential cmake ninja-build pkg-config \ + llvm-dev libclang-dev clang \ + libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ + libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ + libxrandr-dev libxi-dev libxkbcommon-dev \ + libasound2-dev libpulse-dev \ + libssl-dev \ + libprotobuf-dev protobuf-compiler \ + libabsl-dev \ + libcurl4-openssl-dev \ + libwayland-dev libdecor-0-dev \ + "${extra_packages[@]}" + + # libwebrtc ships hermetic libc++ headers that require a newer clang than + # Ubuntu provides. The submodule script exports CC/CXX for later steps. + - name: Install clang for libwebrtc + if: runner.os == 'Linux' + shell: bash + run: client-sdk-rust/.github/scripts/install-clang.sh + + - name: Install base deps (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + set -eux + brew update + brew install cmake ninja protobuf abseil diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 77fe6b56..0d7c6e96 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -90,45 +90,10 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-vcpkg-binary-${{ env.VCPKG_TARGET_TRIPLET }}-${{ env.VCPKG_GIT_COMMIT }}- - - name: Setup vcpkg (Windows only) - if: runner.os == 'Windows' - uses: lukka/run-vcpkg@5e0cab206a5ea620130caf672fce3e4a6b5666a1 # v11.5 + - name: Set up base SDK dependencies + uses: ./.github/actions/setup-sdk-deps with: - vcpkgGitCommitId: ${{ env.VCPKG_GIT_COMMIT }} - - # ---------- OS-specific deps ---------- - - name: Install deps (Ubuntu) - if: runner.os == 'Linux' - run: | - set -eux - sudo apt-get update - sudo apt-get install -y \ - build-essential cmake ninja-build pkg-config \ - llvm-dev libclang-dev clang \ - libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ - libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ - libxrandr-dev libxi-dev libxkbcommon-dev \ - libasound2-dev libpulse-dev \ - libssl-dev \ - libprotobuf-dev protobuf-compiler \ - libabsl-dev \ - libcurl4-openssl-dev \ - libwayland-dev libdecor-0-dev - - # The libwebrtc artifact ships a hermetic libc++ built from LLVM trunk. - # Distro clang (14 on Ubuntu 22.04, 18 on 24.04) cannot compile its - # headers; the submodule script installs LLVM 21 and exports CC/CXX. - - name: Install clang for libwebrtc - if: runner.os == 'Linux' - shell: bash - run: client-sdk-rust/.github/scripts/install-clang.sh - - - name: Install deps (macOS) - if: runner.os == 'macOS' - run: | - set -eux - brew update - brew install cmake ninja protobuf abseil + vcpkg-commit: ${{ env.VCPKG_GIT_COMMIT }} # ---------- Rust toolchain ---------- - name: Install Rust (stable) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d75e795..559d9a84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: - .github/workflows/ci.yml - .github/workflows/builds.yml - .github/workflows/make-release.yml + - .github/actions/setup-sdk-deps/** - .github/actions/setup-capture/** - .github/actions/build-sdk/** - .github/actions/cargo-target-root/** @@ -73,6 +74,7 @@ jobs: - .github/workflows/ci.yml - .github/workflows/tests.yml - .github/workflows/nightly.yml + - .github/actions/setup-sdk-deps/** - .github/actions/setup-capture/** - .github/actions/build-sdk/** - .github/actions/cargo-target-root/** diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 940ac37b..591423a3 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -104,48 +104,8 @@ jobs: core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - name: Setup vcpkg (Windows only) - if: runner.os == 'Windows' - uses: lukka/run-vcpkg@5e0cab206a5ea620130caf672fce3e4a6b5666a1 # v11.5 - with: - vcpkgGitCommitId: 'fb87e2bb3fe69e16c224989acb5a61349166c782' - - # ---------- OS-specific deps ---------- - - name: Install deps (Ubuntu) - if: runner.os == 'Linux' - run: | - set -eux - sudo apt-get update - sudo apt-get install -y \ - build-essential cmake ninja-build pkg-config \ - llvm-dev libclang-dev clang \ - libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ - libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ - libxrandr-dev libxi-dev libxkbcommon-dev \ - libasound2-dev libpulse-dev \ - libssl-dev \ - libprotobuf-dev protobuf-compiler \ - libabsl-dev \ - libcurl4-openssl-dev \ - libwayland-dev libdecor-0-dev - - # The libwebrtc artifact ships a hermetic libc++ built from LLVM trunk. - # Distro clang cannot compile its headers; install the same LLVM version - # used by the Linux build and test workflows. - - name: Install clang for libwebrtc - if: runner.os == 'Linux' - shell: bash - run: client-sdk-rust/.github/scripts/install-clang.sh - - - name: Install deps (macOS) - if: runner.os == 'macOS' - run: | - set -eux - brew update - brew install cmake ninja protobuf abseil - - - name: Set up capture dependencies - uses: ./.github/actions/setup-capture + - name: Set up base SDK dependencies + uses: ./.github/actions/setup-sdk-deps # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -218,15 +178,6 @@ jobs: version: ${{ steps.version.outputs.version }} macos-arch: ${{ matrix.macos_arch || '' }} - - name: Rebuild with capture - uses: ./.github/actions/build-sdk - with: - preset: release-examples - capture: 'ON' - bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} - version: ${{ steps.version.outputs.version }} - macos-arch: ${{ matrix.macos_arch || '' }} - # ---------- Verify exported ABI: no third-party symbol leaks ---------- - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -238,11 +189,6 @@ jobs: with: bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} - - name: Symbol leak check (capture SDK) - uses: ./.github/actions/check-sdk-abi - with: - bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} - - name: Test installed standard SDK uses: ./.github/actions/test-sdk-bundle with: @@ -251,6 +197,23 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} version: ${{ steps.version.outputs.version }} + - name: Set up capture dependencies + uses: ./.github/actions/setup-capture + + - name: Rebuild with capture + uses: ./.github/actions/build-sdk + with: + preset: release-examples + capture: 'ON' + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + version: ${{ steps.version.outputs.version }} + macos-arch: ${{ matrix.macos_arch || '' }} + + - name: Symbol leak check (capture SDK) + uses: ./.github/actions/check-sdk-abi + with: + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} + - name: Test installed capture SDK uses: ./.github/actions/test-sdk-bundle with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a574094..06acdf50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -204,47 +204,11 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-vcpkg-binary-${{ env.VCPKG_TARGET_TRIPLET }}-${{ env.VCPKG_GIT_COMMIT }}- - - name: Setup vcpkg (Windows only) - if: runner.os == 'Windows' - uses: lukka/run-vcpkg@5e0cab206a5ea620130caf672fce3e4a6b5666a1 # v11.5 + - name: Set up base SDK dependencies + uses: ./.github/actions/setup-sdk-deps with: - vcpkgGitCommitId: ${{ env.VCPKG_GIT_COMMIT }} - - # ---------- OS-specific deps ---------- - - name: Install deps (Ubuntu) - if: runner.os == 'Linux' - run: | - set -eux - sudo apt-get update - sudo apt-get install -y \ - build-essential cmake ninja-build pkg-config \ - llvm-dev libclang-dev clang \ - libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ - libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ - libxrandr-dev libxi-dev libxkbcommon-dev \ - libasound2-dev libpulse-dev \ - libssl-dev \ - libprotobuf-dev protobuf-compiler \ - libabsl-dev \ - libcurl4-openssl-dev \ - libwayland-dev libdecor-0-dev \ - libunwind-dev libjpeg-dev \ - gdb jq - - # The libwebrtc artifact ships a hermetic libc++ built from LLVM trunk. - # Distro clang (14 on Ubuntu 22.04, 18 on 24.04) cannot compile its - # headers; the submodule script installs LLVM 21 and exports CC/CXX. - - name: Install clang for libwebrtc - if: runner.os == 'Linux' - shell: bash - run: client-sdk-rust/.github/scripts/install-clang.sh - - - name: Install deps (macOS) - if: runner.os == 'macOS' - run: | - set -eux - brew update - brew install cmake ninja protobuf abseil + vcpkg-commit: ${{ env.VCPKG_GIT_COMMIT }} + linux-extra-packages: libunwind-dev libjpeg-dev gdb jq # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -515,35 +479,18 @@ jobs: docker-images: true swap-storage: false - # ---------- OS deps ---------- - - name: Install deps - run: | - set -eux - sudo apt-get update - sudo apt-get install -y \ - build-essential cmake ninja-build pkg-config \ - llvm-dev libclang-dev clang \ - libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ - libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ - libxrandr-dev libxi-dev libxkbcommon-dev \ - libasound2-dev libpulse-dev \ - libssl-dev \ - libprotobuf-dev protobuf-compiler \ - libabsl-dev \ - libcurl4-openssl-dev \ - libwayland-dev libdecor-0-dev \ - libunwind-dev libjpeg-dev - pip install --break-system-packages gcovr + - name: Set up base SDK dependencies + uses: ./.github/actions/setup-sdk-deps + with: + linux-extra-packages: libunwind-dev libjpeg-dev + - name: Install coverage tools + run: pip install --break-system-packages gcovr + + # Coverage only needs the instrumented capture variant. - name: Set up capture dependencies uses: ./.github/actions/setup-capture - # The coverage build compiles the Rust FFI too, so it needs the same - # compiler floor as the normal Linux build/test matrices. - - name: Install clang for libwebrtc - shell: bash - run: client-sdk-rust/.github/scripts/install-clang.sh - # ---------- Rust toolchain ---------- - name: Install Rust (stable) uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 diff --git a/AGENTS.md b/AGENTS.md index 5a022aad..302d27b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ Be sure to update the directory layout in this file if the directory layout chan | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | | `src/tests/packaging/` | Standalone CTest integration tests consuming installed SDK bundles | -| `.github/actions/` | Shared CI actions for SDK builds, Cargo paths, capture dependencies, example smoke checks, packaging tests, and exported ABI checks | +| `.github/actions/` | Shared CI actions for base and capture dependencies, SDK builds, Cargo paths, example smoke checks, packaging tests, and exported ABI checks | | `.github/workflows/` | GitHub Actions CI workflows | ### Key Types @@ -405,6 +405,7 @@ When adding new client facing functionality, add benchmarking to understand the expensive stages. Manual `workflow_dispatch` runs intentionally opt back into all filtered stages; normal pull requests and pushes use the path filters. + - `.github/workflows/ci.yml` — Top-level PR/push/manual aggregator and path filter owner. - `.github/workflows/builds.yml` — Reusable SDK and example-collection build diff --git a/docs/building.md b/docs/building.md index 48e7e7d7..7da290cb 100644 --- a/docs/building.md +++ b/docs/building.md @@ -398,18 +398,23 @@ the installation's `bin` directory on `PATH` and `lib/pkgconfig` directory on Capture bundles require GStreamer at runtime, including when using pattern or clock sources. The SDK does not redistribute GStreamer libraries or plugins. -CI builds both variants in one job per platform. It first builds and bundles -the standard SDK, then runs the build script with `--capture` in the same CMake -build directory. Unchanged C++ dependencies and generated protobuf outputs are -reused. Both bundles have separate install directories and artifact names, and -both are checked through the installed-SDK packaging tests. Release jobs also -check version metadata and exported symbols in both bundles. +CI and release jobs build both variants in one job per platform. The shared +`setup-sdk-deps` action installs base dependencies first. Each job builds and +validates the standard SDK before the `setup-capture` action installs GStreamer. +It then runs the build script with `--capture` in the same CMake build directory. +Unchanged C++ dependencies and generated protobuf outputs are reused. Both +bundles have separate install directories and artifact names, and both are +checked through the installed-SDK packaging tests. Release jobs also check +version metadata and exported symbols in both bundles. Test jobs validate the standard installed SDK first, then enable capture and run the full unit, integration, and optional stress suites once. Rust outputs remain separate for each feature configuration; the shared Cargo cache stores both configurations. +Coverage shares the dependency setup actions and builds only the instrumented +capture variant. + Rust artifacts are stored under `client-sdk-rust/target/cpp-/`, with separate directories for capture-enabled and disabled builds. Generated `build.h` files live in each CMake build directory, so feature metadata follows From a990fad76f25e436fe2863f4dcfe6c37bfd6f3ce Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 10:26:53 -0600 Subject: [PATCH 20/29] macOS x86/64 working? --- .github/actions/setup-capture/action.yml | 39 +++++++++++++++++++++++- .github/workflows/builds.yml | 8 +++-- .github/workflows/make-release.yml | 6 ++-- .github/workflows/tests.yml | 6 ++-- docs/building.md | 10 ++++-- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/.github/actions/setup-capture/action.yml b/.github/actions/setup-capture/action.yml index a7f11b06..1f0106dc 100644 --- a/.github/actions/setup-capture/action.yml +++ b/.github/actions/setup-capture/action.yml @@ -15,8 +15,45 @@ runs: gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav - name: Install GStreamer (macOS) if: runner.os == 'macOS' + env: + GSTREAMER_MACOS_VERSION: "1.28.5" + GSTREAMER_MACOS_RUNTIME_SHA256: "0a8fc7a1cf8d7bac833ca0ebe2fd196a199c2465e810cd5b1e4b4f720c258f43" + GSTREAMER_MACOS_DEVEL_SHA256: "6f7b55e8fb86dcc615c9cae46b79b7785851e5c77f79a938648a81dfa2603729" shell: bash - run: brew install gstreamer + run: | + set -euo pipefail + # Official universal packages cover both x86_64 and arm64. Homebrew + # no longer produces Intel bottles and may compile its dependency tree. + base_url="https://gstreamer.freedesktop.org/data/pkg/osx/$GSTREAMER_MACOS_VERSION" + installer_dir="$RUNNER_TEMP/gstreamer-installers" + runtime_pkg="gstreamer-1.0-$GSTREAMER_MACOS_VERSION-universal.pkg" + devel_pkg="gstreamer-1.0-devel-$GSTREAMER_MACOS_VERSION-universal.pkg" + mkdir -p "$installer_dir" + + for package in "$runtime_pkg" "$devel_pkg"; do + expected_hash="$GSTREAMER_MACOS_RUNTIME_SHA256" + if [[ "$package" == "$devel_pkg" ]]; then + expected_hash="$GSTREAMER_MACOS_DEVEL_SHA256" + fi + curl --fail --location --retry 3 --connect-timeout 30 --max-time 600 \ + "$base_url/$package" --output "$installer_dir/$package" + printf '%s %s\n' "$expected_hash" "$installer_dir/$package" | shasum -a 256 -c - + done + + # Verify both downloads before installing either package. + sudo installer -pkg "$installer_dir/$runtime_pkg" -target / + sudo installer -pkg "$installer_dir/$devel_pkg" -target / + + framework="/Library/Frameworks/GStreamer.framework/Versions/1.0" + echo "$framework/bin" >> "$GITHUB_PATH" + export PATH="$framework/bin:$PATH" + export PKG_CONFIG_PATH="$framework/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + echo "PKG_CONFIG_PATH=$PKG_CONFIG_PATH" >> "$GITHUB_ENV" + # The framework dylibs use @rpath. Rust sys-crate linker arguments do + # not propagate to the final FFI dylib, so set its runtime search path. + echo "RUSTFLAGS=${RUSTFLAGS:+$RUSTFLAGS }-C link-arg=-Wl,-rpath,$framework/lib" >> "$GITHUB_ENV" + pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 + gst-inspect-1.0 --version - name: Install GStreamer (Windows) if: runner.os == 'Windows' env: diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 0d7c6e96..ac1dc9cb 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -127,6 +127,8 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-cargo-registry- + # macOS FFI libraries retain GStreamer link paths. Include capture setup + # in the key so changes to the framework installation invalidate them. # Restore-only here; the matching save runs after a successful build (see # "Save cargo target" below). A combined actions/cache saves in a post-job # step that runs AFTER the "Clean after build" cleanup wipes target/debug @@ -139,9 +141,9 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. @@ -235,7 +237,7 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} - name: Save vcpkg binary cache if: runner.os == 'Windows' && steps.cache-vcpkg-binary.outputs.cache-hit != 'true' diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 591423a3..b88b8be4 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -138,6 +138,8 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-cargo-registry- + # macOS FFI libraries retain GStreamer link paths. Include capture setup + # in the key so changes to the framework installation invalidate them. # Compiled target/ artifacts are glibc-bound and must not restore across # Ubuntu major versions (e.g. 24.04 → 22.04 → GLIBC_2.39 not found). - name: Cache cargo target @@ -147,9 +149,9 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 06acdf50..eeccad1e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -241,6 +241,8 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-cargo-registry- + # macOS FFI libraries retain GStreamer link paths. Include capture setup + # in the key so changes to the framework installation invalidate them. # Compiled target/ artifacts are glibc-bound and must not restore across # Ubuntu major versions (e.g. 24.04 → 22.04 → GLIBC_2.39 not found). - name: Restore cargo target @@ -250,9 +252,9 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-v3- + ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}- # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge # artifacts in webrtc-sys are not compatible across those bumps. diff --git a/docs/building.md b/docs/building.md index 7da290cb..cf1f9c27 100644 --- a/docs/building.md +++ b/docs/building.md @@ -387,8 +387,14 @@ example `livekit-sdk-macos-arm64-capture-.tar.gz`. Standard release bundles have capture disabled. Building a capture-enabled SDK requires GStreamer development libraries -accessible to `pkg-config`. On macOS, install `gstreamer` with Homebrew. On -Ubuntu, install `libgstreamer1.0-dev` and +accessible to `pkg-config`. On macOS, install both the official universal +runtime and development packages from the +[GStreamer downloads page](https://gstreamer.freedesktop.org/download/#macos). +Add `/Library/Frameworks/GStreamer.framework/Versions/1.0/bin` to the front of +`PATH` and `/Library/Frameworks/GStreamer.framework/Versions/1.0/lib/pkgconfig` +to the front of `PKG_CONFIG_PATH`. These packages support both x86_64 and arm64. +Use one GStreamer installation method per machine to keep its libraries and +plugins consistent. On Ubuntu, install `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, plus the runtime plugins your pipeline uses. On Windows, install the MSVC runtime and development packages from the [GStreamer downloads page](https://gstreamer.freedesktop.org/download/) and put From 7f53a2f453742d2bc4ab7b3eedd43cacb8da978d Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 10:58:51 -0600 Subject: [PATCH 21/29] trim docs --- docs/building.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/docs/building.md b/docs/building.md index cf1f9c27..f321d668 100644 --- a/docs/building.md +++ b/docs/building.md @@ -404,23 +404,6 @@ the installation's `bin` directory on `PATH` and `lib/pkgconfig` directory on Capture bundles require GStreamer at runtime, including when using pattern or clock sources. The SDK does not redistribute GStreamer libraries or plugins. -CI and release jobs build both variants in one job per platform. The shared -`setup-sdk-deps` action installs base dependencies first. Each job builds and -validates the standard SDK before the `setup-capture` action installs GStreamer. -It then runs the build script with `--capture` in the same CMake build directory. -Unchanged C++ dependencies and generated protobuf outputs are reused. Both -bundles have separate install directories and artifact names, and both are -checked through the installed-SDK packaging tests. Release jobs also check -version metadata and exported symbols in both bundles. - -Test jobs validate the standard installed SDK first, then enable capture and -run the full unit, integration, and optional stress suites once. Rust outputs -remain separate for each feature configuration; the shared Cargo cache stores -both configurations. - -Coverage shares the dependency setup actions and builds only the instrumented -capture variant. - Rust artifacts are stored under `client-sdk-rust/target/cpp-/`, with separate directories for capture-enabled and disabled builds. Generated `build.h` files live in each CMake build directory, so feature metadata follows From f01b51e2953673195ef5eae043de36f038f527b2 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 12:09:16 -0600 Subject: [PATCH 22/29] mr comments --- .../action.yml | 2 +- .github/actions/setup-capture/action.yml | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/make-release.yml | 4 +- docker/linux-build.Dockerfile | 66 --------- docker/linux-build.Dockerfile.dockerignore | 7 - docs/building.md | 125 +++++++----------- docs/testing.md | 7 +- include/livekit/capture_source.h | 8 +- include/livekit/livekit.h | 2 + src/tests/CMakeLists.txt | 13 ++ src/tests/packaging/CMakeLists.txt | 18 +++ src/tests/packaging/test_capture_bundle.cpp | 4 + 13 files changed, 101 insertions(+), 159 deletions(-) rename .github/actions/{check-sdk-abi => check-sdk-symbols}/action.yml (97%) delete mode 100644 docker/linux-build.Dockerfile delete mode 100644 docker/linux-build.Dockerfile.dockerignore diff --git a/.github/actions/check-sdk-abi/action.yml b/.github/actions/check-sdk-symbols/action.yml similarity index 97% rename from .github/actions/check-sdk-abi/action.yml rename to .github/actions/check-sdk-symbols/action.yml index 6b26d62b..ecc6bbc9 100644 --- a/.github/actions/check-sdk-abi/action.yml +++ b/.github/actions/check-sdk-symbols/action.yml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -name: Check SDK exported ABI +name: Check SDK exported symbols description: Reject private dependency symbols exported by an installed SDK. inputs: bundle-dir: diff --git a/.github/actions/setup-capture/action.yml b/.github/actions/setup-capture/action.yml index 1f0106dc..1c64e6b7 100644 --- a/.github/actions/setup-capture/action.yml +++ b/.github/actions/setup-capture/action.yml @@ -1,5 +1,5 @@ name: Set up capture dependencies -description: Install GStreamer development files and runtime plugins for capture bundles. +description: Install additional build and runtime dependencies for capture-enabled SDK builds. runs: using: composite steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 559d9a84..bd9ef1eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - .github/actions/cargo-target-root/** - .github/actions/smoke-examples/** - .github/actions/test-sdk-bundle/** - - .github/actions/check-sdk-abi/** + - .github/actions/check-sdk-symbols/** - .github/scripts/check_no_private_symbols.py tests: - src/** diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index b88b8be4..64f1d084 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -187,7 +187,7 @@ jobs: python-version: '3.x' - name: Symbol leak check (standard SDK) - uses: ./.github/actions/check-sdk-abi + uses: ./.github/actions/check-sdk-symbols with: bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} @@ -212,7 +212,7 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} - name: Symbol leak check (capture SDK) - uses: ./.github/actions/check-sdk-abi + uses: ./.github/actions/check-sdk-symbols with: bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} diff --git a/docker/linux-build.Dockerfile b/docker/linux-build.Dockerfile deleted file mode 100644 index f34a5391..00000000 --- a/docker/linux-build.Dockerfile +++ /dev/null @@ -1,66 +0,0 @@ -# Linux build environment for client-sdk-cpp. -# Ubuntu packages match the Linux jobs in .github/workflows/builds.yml, -# tests.yml, and make-release.yml. Pass --build-arg INSTALL_GSTREAMER=1 to also -# install the GStreamer packages from .github/actions/setup-capture. -# The source tree is bind-mounted at /work. -FROM ubuntu:24.04 - -ARG DEBIAN_FRONTEND=noninteractive -# Empty means the default version in install-clang.sh. -ARG LLVM_VERSION - -# ca-certificates, curl, git, git-lfs, xz-utils, sudo, and python3 are already -# present on GitHub-hosted runners. python3 is what the coverage job uses for -# gcovr. git is needed because FetchContent clones GoogleTest. -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl git rsync xz-utils \ - python3 \ - build-essential cmake ninja-build pkg-config \ - llvm-dev libclang-dev clang \ - libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ - libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ - libxrandr-dev libxi-dev libxkbcommon-dev \ - libasound2-dev libpulse-dev \ - libssl-dev \ - libprotobuf-dev protobuf-compiler \ - libabsl-dev \ - libcurl4-openssl-dev \ - libwayland-dev libdecor-0-dev \ - libunwind-dev libjpeg-dev \ - gdb jq \ - && rm -rf /var/lib/apt/lists/* - -# webrtc-sys needs a recent clang for the hermetic libc++ in libwebrtc. Use the -# same script as CI. The build context is the repository root, and -# linux.Dockerfile.dockerignore limits it to this one file. -COPY client-sdk-rust/.github/scripts/install-clang.sh /tmp/install-clang.sh -RUN LLVM_ROOT=/opt/llvm /tmp/install-clang.sh \ - && rm /tmp/install-clang.sh - -ENV CC=/opt/llvm/bin/clang \ - CXX=/opt/llvm/bin/clang++ \ - LIBCLANG_PATH=/opt/llvm/lib \ - RUSTUP_HOME=/opt/rustup \ - CARGO_HOME=/opt/cargo \ - PATH=/opt/cargo/bin:$PATH - -RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \ - && chmod -R a+rwX /opt/rustup /opt/cargo \ - && cargo --version - -# Keep Rust outputs out of the synced source tree. -ENV CARGO_TARGET_DIR=/cargo-target - -# Last install so a GStreamer toggle reuses the toolchain layers above. -# libunwind-dev is already in the base package list. -ARG INSTALL_GSTREAMER=0 -RUN if [ "$INSTALL_GSTREAMER" = "1" ]; then \ - apt-get update && apt-get install -y --no-install-recommends \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav \ - && rm -rf /var/lib/apt/lists/*; \ - fi - -WORKDIR /work -CMD ["bash"] diff --git a/docker/linux-build.Dockerfile.dockerignore b/docker/linux-build.Dockerfile.dockerignore deleted file mode 100644 index 778bf9e0..00000000 --- a/docker/linux-build.Dockerfile.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -# Build context allowlist for linux.Dockerfile. The context is the -# repository root, but the image needs only the clang install script. -* -!client-sdk-rust/.github/scripts/install-clang.sh -build-debug -build-release - diff --git a/docs/building.md b/docs/building.md index f321d668..6d47b42f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -23,6 +23,49 @@ CMake/vcpkg flows. | Linux | GCC 9+ or Clang 10+ | `apt` / `dnf` (or vcpkg) | | macOS | Xcode 12+ (macOS 12.3+ for ScreenCaptureKit) | Homebrew (or vcpkg) | + + +### Additional capture dependencies + +Standard SDK builds do not require GStreamer. Capture-enabled builds +(`--capture`) require GStreamer development libraries at build time and +GStreamer libraries at runtime, including when using pattern or clock sources. +The SDK does not redistribute these libraries or plugins. Install the runtime +plugins required by your GStreamer pipeline too. + +**macOS:** Install both the official universal runtime and development packages +from the [GStreamer downloads page](https://gstreamer.freedesktop.org/download/#macos). +These packages support both x86_64 and arm64. Configure the framework paths: + +```bash +export PATH="/Library/Frameworks/GStreamer.framework/Versions/1.0/bin:$PATH" +export PKG_CONFIG_PATH="/Library/Frameworks/GStreamer.framework/Versions/1.0/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" +``` + +Use one GStreamer installation method per machine to keep libraries and plugins +consistent. + +**Ubuntu / Debian:** Install the development packages and runtime plugins: + +```bash +sudo apt install -y pkg-config libunwind-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav +``` + +**Windows:** Install the MSVC x86_64 runtime and development components from the +[GStreamer downloads page](https://gstreamer.freedesktop.org/download/#windows). +Add the installation's `bin` directory to `PATH` and its `lib/pkgconfig` +directory to `PKG_CONFIG_PATH`. + +Development files must be discoverable through `pkg-config`. You can verify +that they are available with: + +```bash +pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 +``` + ## Clone the repository The SDK depends on the [`client-sdk-rust`](https://github.com/livekit/rust-sdks) @@ -378,6 +421,8 @@ Rust FFI. The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated numeric `LIVEKIT_CAPTURE_ENABLED` macro (0 or 1) supports `#if` checks in consumers. +Direct inclusion of `livekit/capture_source.h` fails at compile time in standard +SDK builds. `livekit/livekit.h` includes capture declarations only when enabled. `share/livekit/build-info.json` in an installed bundle describes the same build features; neither value tests whether a GPU or a requested GStreamer plugin is available at runtime. @@ -386,23 +431,8 @@ Release downloads with `-capture` in their names include these features, for example `livekit-sdk-macos-arm64-capture-.tar.gz`. Standard release bundles have capture disabled. -Building a capture-enabled SDK requires GStreamer development libraries -accessible to `pkg-config`. On macOS, install both the official universal -runtime and development packages from the -[GStreamer downloads page](https://gstreamer.freedesktop.org/download/#macos). -Add `/Library/Frameworks/GStreamer.framework/Versions/1.0/bin` to the front of -`PATH` and `/Library/Frameworks/GStreamer.framework/Versions/1.0/lib/pkgconfig` -to the front of `PKG_CONFIG_PATH`. These packages support both x86_64 and arm64. -Use one GStreamer installation method per machine to keep its libraries and -plugins consistent. On Ubuntu, install `libgstreamer1.0-dev` and -`libgstreamer-plugins-base1.0-dev`, plus the runtime plugins your pipeline -uses. On Windows, install the MSVC runtime and development packages from the -[GStreamer downloads page](https://gstreamer.freedesktop.org/download/) and put -the installation's `bin` directory on `PATH` and `lib/pkgconfig` directory on -`PKG_CONFIG_PATH`. - -Capture bundles require GStreamer at runtime, including when using pattern or -clock sources. The SDK does not redistribute GStreamer libraries or plugins. +See [Additional capture dependencies](#additional-capture-dependencies) before +building or running a capture-enabled SDK. Rust artifacts are stored under `client-sdk-rust/target/cpp-/`, with separate directories for capture-enabled and disabled builds. Generated @@ -411,67 +441,6 @@ the installed SDK instead of another configuration's most recent build. See [Capture sources](capture-sources.md) for dependencies and usage. -## Build in a Linux container - -You can test a clean Linux build and its dependency tree from a macOS or Linux -host with `docker/linux.Dockerfile`. The container is a build check, not -a development environment. The image uses Ubuntu 24.04 with the same packages -as the Linux CI jobs. It also includes GStreamer for `--capture` builds and -the stable Rust toolchain. - -The container bind-mounts the source paths that the build reads from the host -into `/work`. Changes on the host show up in the container immediately. The -`/work` directory itself is the `lk-cpp-work` volume, so build outputs such as -`build-*`, `local-install`, and `sdk-out` stay in the container. Host build -directories and `.git` data are not mounted. - -1. From the repository root, build the image. The `client-sdk-rust` submodule - must be checked out, because the image copies its clang install script. - - ```bash - docker build -f docker/linux.Dockerfile -t livekit-cpp-build . - ``` - - To install gstreamer: - ```bash - docker build -f docker/linux.Dockerfile \ - --build-arg INSTALL_GSTREAMER=1 \ - -t livekit-cpp-build . - ``` - - The build context is the repository root. The file - `docker/linux.Dockerfile.dockerignore` limits the context to the - clang install script, so Docker does not send build outputs or `.git`. - -2. From the repository root, start the container: - - ```bash - docker run -d --name lk-cpp --network host \ - -v lk-cpp-work:/work \ - -v lk-cargo-target:/cargo-target \ - -v lk-cargo-registry:/opt/cargo/registry \ - -v "$(pwd)/build.sh:/work/build.sh" \ - -v "$(pwd)/CMakeLists.txt:/work/CMakeLists.txt" \ - -v "$(pwd)/CMakePresets.json:/work/CMakePresets.json" \ - -v "$(pwd)/build.h.in:/work/build.h.in" \ - -v "$(pwd)/.build-info.json.in:/work/.build-info.json.in" \ - -v "$(pwd)/cmake:/work/cmake" \ - -v "$(pwd)/include:/work/include" \ - -v "$(pwd)/src:/work/src" \ - -v "$(pwd)/scripts:/work/scripts" \ - -v "$(pwd)/client-sdk-rust:/work/client-sdk-rust" \ - -v lk-rust-source-target:/work/client-sdk-rust/target \ - -v "$(pwd)/cpp-example-collection:/work/cpp-example-collection" \ - livekit-cpp-build sleep infinity - ``` - -3. Open a shell in the container: - - ```bash - docker exec -it lk-cpp bash - ``` -For a fully clean build, run `./build.sh clean-all` in the container, or remove the volumes. - ## Support - GitHub Issues: diff --git a/docs/testing.md b/docs/testing.md index 5093b1f5..519fe96e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -103,8 +103,11 @@ export LIVEKIT_TOKEN_B="$(lk token create --api-key devkey --api-secret secret - `src/tests/packaging/` is a standalone CMake consumer of an installed SDK. CTest registers `InstalledSdk.Capture` with the `packaging` and `integration` labels and a 30-second timeout. It checks the bundle's capture metadata and -creates a GStreamer VP8 source through the installed library. It needs no -LiveKit server; capture-enabled runs require GStreamer and the pipeline's plugins. +creates a GStreamer VP8 source through the installed library when capture is +enabled. Configuration checks that direct inclusion of `livekit/capture_source.h` +succeeds for capture bundles and fails for standard bundles. Both variants must +still compile with `livekit/livekit.h`. It needs no LiveKit server; capture-enabled +runs require GStreamer and the pipeline's plugins. The consumer also checks `share/livekit/build-info.json`. Release jobs pass `LIVEKIT_EXPECT_VERSION` to check version metadata and the recorded Rust commit and FFI version through this same project. diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index c6eeb2cf..504d6536 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -16,6 +16,13 @@ #pragma once +#include "livekit/build.h" + +// Internal SDK wrappers are built for both variants; consumers require capture. +#if !LIVEKIT_CAPTURE_ENABLED && !defined(LIVEKIT_BUILDING_SDK) +#error "Capture sources require a capture-enabled LiveKit SDK; rebuild with --capture." +#endif + #include #include #include @@ -24,7 +31,6 @@ #include #include -#include "livekit/build.h" #include "livekit/room_event_types.h" #include "livekit/video_source.h" #include "livekit/visibility.h" diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index a02e8172..e3756c34 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -21,7 +21,9 @@ #include "livekit/audio_source.h" #include "livekit/audio_stream.h" #include "livekit/build.h" +#if LIVEKIT_CAPTURE_ENABLED #include "livekit/capture_source.h" +#endif #include "livekit/e2ee.h" #include "livekit/encoded_video_source.h" #include "livekit/local_audio_track.h" diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..c169fa1f 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -72,6 +72,11 @@ file(GLOB UNIT_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/unit/*.cpp" ) +if(NOT LIVEKIT_CAPTURE_ENABLED) + list(REMOVE_ITEM UNIT_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_capture_source.cpp") +endif() + if(UNIT_TEST_SOURCES) add_executable(livekit_unit_tests ${UNIT_TEST_SOURCES} @@ -96,6 +101,7 @@ if(UNIT_TEST_SOURCES) target_include_directories(livekit_unit_tests PRIVATE + ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${LIVEKIT_ROOT_DIR}/src/trace @@ -165,6 +171,11 @@ file(GLOB INTEGRATION_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/integration/*.cpp" ) +if(NOT LIVEKIT_CAPTURE_ENABLED) + list(REMOVE_ITEM INTEGRATION_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/integration/test_capture_source.cpp") +endif() + if(INTEGRATION_TEST_SOURCES) add_executable(livekit_integration_tests ${INTEGRATION_TEST_SOURCES} @@ -189,6 +200,7 @@ if(INTEGRATION_TEST_SOURCES) target_include_directories(livekit_integration_tests PRIVATE + ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/benchmark @@ -271,6 +283,7 @@ if(STRESS_TEST_SOURCES) target_include_directories(livekit_stress_tests PRIVATE + ${LIVEKIT_GENERATED_INCLUDE_DIR} ${LIVEKIT_ROOT_DIR}/include ${LIVEKIT_ROOT_DIR}/src ${LIVEKIT_TEST_PROTOBUF_DEP_INCLUDE_DIRS} diff --git a/src/tests/packaging/CMakeLists.txt b/src/tests/packaging/CMakeLists.txt index 775f0c17..fbf4ff50 100644 --- a/src/tests/packaging/CMakeLists.txt +++ b/src/tests/packaging/CMakeLists.txt @@ -12,6 +12,8 @@ cmake_minimum_required(VERSION 3.20) project(livekit_packaging_tests LANGUAGES CXX) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) # This is a standalone consumer of an installed SDK, not the in-tree target. find_package(LiveKit CONFIG REQUIRED) @@ -66,6 +68,22 @@ if(NOT LIVEKIT_EXPECT_VERSION STREQUAL "") endif() endif() +# Direct capture includes must fail in standard bundles and work in capture bundles. +include(CheckCXXSourceCompiles) +set(CMAKE_REQUIRED_INCLUDES "${_sdk_include_dir}") +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) +unset(_capture_header_compiles CACHE) +check_cxx_source_compiles("#include +int main() { return 0; }" + _capture_header_compiles) +unset(CMAKE_REQUIRED_INCLUDES) +unset(CMAKE_TRY_COMPILE_TARGET_TYPE) +if(LIVEKIT_EXPECT_CAPTURE STREQUAL "ON" AND NOT _capture_header_compiles) + message(FATAL_ERROR "Capture SDK must allow including livekit/capture_source.h") +elseif(LIVEKIT_EXPECT_CAPTURE STREQUAL "OFF" AND _capture_header_compiles) + message(FATAL_ERROR "Standard SDK must reject including livekit/capture_source.h") +endif() + add_executable(livekit_capture_package_test test_capture_bundle.cpp) target_compile_features(livekit_capture_package_test PRIVATE cxx_std_17) target_link_libraries(livekit_capture_package_test PRIVATE LiveKit::livekit) diff --git a/src/tests/packaging/test_capture_bundle.cpp b/src/tests/packaging/test_capture_bundle.cpp index af3f83b2..b9c46f17 100644 --- a/src/tests/packaging/test_capture_bundle.cpp +++ b/src/tests/packaging/test_capture_bundle.cpp @@ -32,6 +32,7 @@ int main(int argc, char** argv) { return 1; } +#if LIVEKIT_CAPTURE_ENABLED livekit::initialize(livekit::LogLevel::Warn); int status = 0; try { @@ -56,4 +57,7 @@ int main(int argc, char** argv) { } livekit::shutdown(); return status; +#else + return 0; +#endif } From f5ec27a48ef0ec438e04c3bb585367feb77cbbe4 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:12:57 -0700 Subject: [PATCH 23/29] Allow choosing specific backends --- .github/actions/build-sdk/action.yml | 14 +++--- .github/workflows/tests.yml | 2 +- AGENTS.md | 13 ++---- CMakeLists.txt | 44 +++++++++++++++++-- CMakePresets.json | 2 +- build.cmd | 21 ++++++--- build.h.in | 2 +- build.sh | 19 +++++--- docs/building.md | 20 ++++++--- include/livekit/capture_source.h | 5 +-- src/tests/integration/test_capture_source.cpp | 4 +- 11 files changed, 103 insertions(+), 43 deletions(-) diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index a0c911ef..ee54618b 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -19,7 +19,7 @@ inputs: description: Build script command, such as release-examples or debug-tests. required: true capture: - description: Whether to enable capture (ON or OFF). + description: Capture sources (ON/all, OFF/none, or gstreamer,pattern,clock). default: 'OFF' bundle-dir: description: Optional installation directory for an SDK bundle. @@ -45,9 +45,9 @@ runs: run: | args=(./build.sh "$SDK_PRESET") case "$SDK_CAPTURE" in - ON) args+=(--capture) ;; - OFF) args+=(--no-capture) ;; - *) echo "Invalid capture setting: $SDK_CAPTURE" >&2; exit 1 ;; + ON|all) args+=(--capture) ;; + OFF|none|"") args+=(--no-capture) ;; + *) args+=(--capture="$SDK_CAPTURE") ;; esac if [[ -n "$SDK_BUNDLE_DIR" ]]; then args+=(--bundle --prefix "$SDK_BUNDLE_DIR"); fi if [[ -n "$SDK_VERSION" ]]; then args+=(--version "$SDK_VERSION"); fi @@ -64,9 +64,9 @@ runs: run: | $buildArgs = @($env:SDK_PRESET) switch ($env:SDK_CAPTURE) { - 'ON' { $buildArgs += '--capture' } - 'OFF' { $buildArgs += '--no-capture' } - default { throw "Invalid capture setting: $env:SDK_CAPTURE" } + 'ON'; 'all' { $buildArgs += '--capture' } + 'OFF'; 'none'; '' { $buildArgs += '--no-capture' } + default { $buildArgs += "--capture=$($env:SDK_CAPTURE)" } } if ($env:SDK_BUNDLE_DIR) { $buildArgs += @('--bundle', '--prefix', $env:SDK_BUNDLE_DIR) } if ($env:SDK_VERSION) { $buildArgs += @('--version', $env:SDK_VERSION) } diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eeccad1e..86109318 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -537,7 +537,7 @@ jobs: - name: Configure (linux-debug-tests preset + coverage flags) run: | cmake --preset linux-debug-tests \ - -DLIVEKIT_BUILD_CAPTURE=ON \ + -DLIVEKIT_CAPTURE=all \ -DCMAKE_C_FLAGS="-Wno-deprecated-declarations --coverage" \ -DCMAKE_CXX_FLAGS="-Wno-deprecated-declarations --coverage" \ -DCMAKE_EXE_LINKER_FLAGS="--coverage" \ diff --git a/AGENTS.md b/AGENTS.md index 302d27b4..0932f7f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,15 +128,10 @@ To create an installable SDK bundle, use the same build command with `./build.sh release --bundle --prefix sdk-out/livekit-sdk`). On Windows, use `build.cmd release --bundle --prefix C:\path\to\livekit-sdk`. -The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated -`LIVEKIT_CAPTURE_ENABLED` macro is 0 or 1 and supports `#if` checks in consumers. - -Both build scripts accept `--capture` to enable GStreamer, pattern, and clock -sources, and `--no-capture` to disable them explicitly. Capture is disabled by -default for every preset. Capture-enabled builds need GStreamer development and -runtime libraries. Release workflows publish separate -`-capture` bundles and validate creation through the installed SDK using the standalone CTest project in -`src/tests/packaging/`. +Capture sources are selected with `LIVEKIT_CAPTURE` / `--capture[=list]`: +`none`, `all`, or a comma-separated subset of `gstreamer`, `pattern`, `clock`. +`LIVEKIT_CAPTURE_ENABLED` is 0 or 1. Release workflows publish separate +`-capture` bundles (all sources) and validate them via `src/tests/packaging/`. Rust FFI outputs live under `client-sdk-rust/target/cpp-/` to isolate Cargo feature configurations. Set `CARGO_TARGET_DIR` to override the root; diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d963cba..d5319a04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,7 +18,12 @@ include(warnings) option(LIVEKIT_BUILD_EXAMPLES "Build LiveKit examples" OFF) option(LIVEKIT_BUILD_TESTS "Build LiveKit tests" OFF) -option(LIVEKIT_BUILD_CAPTURE "Build the Rust FFI with the capture feature (livekit-capture sources; links system GStreamer)" OFF) +# Capture sources for the Rust FFI. Comma-separated subset of gstreamer, +# pattern, clock; or all / none / ON / OFF (aliases). Empty means none. +set(LIVEKIT_CAPTURE "" CACHE STRING + "Capture sources: none, all, or comma-separated gstreamer,pattern,clock") +set_property(CACHE LIVEKIT_CAPTURE PROPERTY STRINGS + "" none all gstreamer pattern clock ON OFF) # vcpkg is only used on Windows; Linux/macOS use system package managers if(WIN32) @@ -169,13 +174,44 @@ set(GENERATED_BUILD_H "${LIVEKIT_GENERATED_INCLUDE_DIR}/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. +# Map LIVEKIT_CAPTURE to livekit-ffi Cargo features (sorted for stable hashing). set(LIVEKIT_CARGO_FEATURES "") set(LIVEKIT_CAPTURE_ENABLED 0) -if(LIVEKIT_BUILD_CAPTURE) - set(LIVEKIT_CARGO_FEATURES "capture-gstreamer,capture-pattern,capture-clock") +string(STRIP "${LIVEKIT_CAPTURE}" _lk_capture) +string(TOLOWER "${_lk_capture}" _lk_capture_lower) +set(_lk_capture_tokens "") +if(_lk_capture_lower STREQUAL "" OR _lk_capture_lower STREQUAL "none" OR _lk_capture_lower STREQUAL "off") + # Capture disabled. +elseif(_lk_capture_lower STREQUAL "all" OR _lk_capture_lower STREQUAL "on") + set(_lk_capture_tokens "clock;gstreamer;pattern") +else() + string(REPLACE "," ";" _lk_capture_tokens "${_lk_capture_lower}") +endif() +set(_lk_capture_features "") +foreach(_token IN LISTS _lk_capture_tokens) + string(STRIP "${_token}" _token) + if(_token STREQUAL "") + continue() + endif() + if(_token STREQUAL "gstreamer") + list(APPEND _lk_capture_features "capture-gstreamer") + elseif(_token STREQUAL "pattern") + list(APPEND _lk_capture_features "capture-pattern") + elseif(_token STREQUAL "clock") + list(APPEND _lk_capture_features "capture-clock") + else() + message(FATAL_ERROR + "Unknown LIVEKIT_CAPTURE source '${_token}'. " + "Expected: gstreamer, pattern, clock, all, none, ON, or OFF.") + endif() +endforeach() +if(_lk_capture_features) + list(REMOVE_DUPLICATES _lk_capture_features) + list(SORT _lk_capture_features) + list(JOIN _lk_capture_features "," LIVEKIT_CARGO_FEATURES) set(LIVEKIT_CAPTURE_ENABLED 1) endif() +message(STATUS "LIVEKIT_CAPTURE='${LIVEKIT_CAPTURE}' -> cargo features: '${LIVEKIT_CARGO_FEATURES}'") set(GIT_COMMIT "unknown") set(RUST_GIT_COMMIT "unknown") diff --git a/CMakePresets.json b/CMakePresets.json index 9157e760..a300698b 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -10,7 +10,7 @@ "name": "sdk-defaults", "hidden": true, "cacheVariables": { - "LIVEKIT_BUILD_CAPTURE": "OFF" + "LIVEKIT_CAPTURE": "" } }, { diff --git a/build.cmd b/build.cmd index 1a60802f..3d071173 100644 --- a/build.cmd +++ b/build.cmd @@ -8,7 +8,7 @@ set "BUILD_TYPE=Release" set "PRESET=windows-release" set "LIVEKIT_VERSION=" set "CMAKE_EXTRA_ARGS=" -set "CAPTURE_OVERRIDE=OFF" +set "CAPTURE_OVERRIDE=none" set "BUILD_PARALLEL_JOBS=" set "DO_BUNDLE=" set "PREFIX=" @@ -90,13 +90,23 @@ if /I "%~1"=="--version" ( goto :get_version_value ) +set "ARG=%~1" +if /I "!ARG:~0,10!"=="--capture=" ( + set "CAPTURE_OVERRIDE=!ARG:~10!" + if "!CAPTURE_OVERRIDE!"=="" ( + echo ERROR: --capture= requires a value ^(gstreamer, pattern, clock, or all^) + exit /b 1 + ) + shift + goto parse_all +) if /I "%~1"=="--capture" ( - set "CAPTURE_OVERRIDE=ON" + set "CAPTURE_OVERRIDE=all" shift goto parse_all ) if /I "%~1"=="--no-capture" ( - set "CAPTURE_OVERRIDE=OFF" + set "CAPTURE_OVERRIDE=none" shift goto parse_all ) @@ -144,7 +154,7 @@ if defined LIVEKIT_VERSION ( set "CMAKE_EXTRA_ARGS=-DLIVEKIT_VERSION=%LIVEKIT_VERSION%" echo ==^> Injecting LIVEKIT_VERSION=%LIVEKIT_VERSION% ) -set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_BUILD_CAPTURE=%CAPTURE_OVERRIDE%" +set "CMAKE_EXTRA_ARGS=%CMAKE_EXTRA_ARGS% -DLIVEKIT_CAPTURE=%CAPTURE_OVERRIDE%" goto dispatch :dispatch @@ -228,7 +238,8 @@ echo clean-all Full clean (build dirs + local-install + Rust targets) echo help Show this help echo. echo Options (for debug / release): -echo --capture Enable all capture sources ^(requires GStreamer^) +echo --capture[=list] Enable capture sources ^(default: all^). +echo list: gstreamer, pattern, clock ^(comma-separated^) echo --no-capture Explicitly disable capture sources ^(the default^) echo --bundle Install the SDK bundle using cmake --install echo --prefix ^ Install prefix for --bundle ^(default: .\sdk-out\livekit-sdk^) diff --git a/build.h.in b/build.h.in index df106d4f..f4c508d3 100644 --- a/build.h.in +++ b/build.h.in @@ -8,7 +8,7 @@ #define LIVEKIT_BUILD_VERSION "@LIVEKIT_VERSION@" #define LIVEKIT_BUILD_FLAVOR "cpp" -/// @brief Whether the Rust FFI was built with capture-source support (0 or 1). +/// @brief Whether the Rust FFI was built with any capture source (0 or 1). #define LIVEKIT_CAPTURE_ENABLED @LIVEKIT_CAPTURE_ENABLED@ #ifdef DEBUG diff --git a/build.sh b/build.sh index 84220a4d..170b0f83 100755 --- a/build.sh +++ b/build.sh @@ -14,7 +14,7 @@ ARCHIVE_NAME="" GENERATOR="" MACOS_ARCH="" LIVEKIT_VERSION="" -CAPTURE_OVERRIDE="OFF" +CAPTURE_OVERRIDE="none" # Detect OS for preset selection detect_os() { @@ -47,7 +47,8 @@ Commands: help Show this help message Options (for debug / release): - --capture Enable all capture sources (requires GStreamer) + --capture[=list] Enable capture sources (default: all). + list: gstreamer, pattern, clock (comma-separated) --no-capture Explicitly disable capture sources (the default) --bundle Install the SDK bundle using 'cmake --install' --prefix Install prefix for --bundle @@ -79,11 +80,19 @@ parse_opts() { while [[ $# -gt 0 ]]; do case "$1" in --capture) - CAPTURE_OVERRIDE="ON" + CAPTURE_OVERRIDE="all" + shift + ;; + --capture=*) + CAPTURE_OVERRIDE="${1#*=}" + if [[ -z "${CAPTURE_OVERRIDE}" ]]; then + echo "ERROR: --capture= requires a value (gstreamer, pattern, clock, or all)" + exit 1 + fi shift ;; --no-capture) - CAPTURE_OVERRIDE="OFF" + CAPTURE_OVERRIDE="none" shift ;; --bundle) @@ -146,7 +155,7 @@ parse_opts() { configure() { echo "==> Configuring CMake (${BUILD_TYPE}) using preset ${PRESET}..." - local -a extra_args=("-DLIVEKIT_BUILD_CAPTURE=${CAPTURE_OVERRIDE}") + local -a extra_args=("-DLIVEKIT_CAPTURE=${CAPTURE_OVERRIDE}") if [[ -n "${LIVEKIT_VERSION}" ]]; then echo "==> Injecting LIVEKIT_VERSION=${LIVEKIT_VERSION}" extra_args+=("-DLIVEKIT_VERSION=${LIVEKIT_VERSION}") diff --git a/docs/building.md b/docs/building.md index 6d47b42f..7a14305c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -244,7 +244,7 @@ cmake --build build | `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 | -| `LIVEKIT_BUILD_CAPTURE` | OFF | Enable GStreamer, pattern, and clock capture sources in the Rust FFI | +| `LIVEKIT_CAPTURE` | empty (`none`) | Capture sources for the Rust FFI (`all`, `none`, or a comma-separated list; see [Capture sources](#capture-sources)) | ## Build output @@ -405,13 +405,23 @@ separately when it is no longer needed. ## Capture sources -Capture is disabled by default for every repository preset. Use `--capture` -with any build command to enable all three sources (GStreamer, GPU pattern, and -GPU wall clock), or `--no-capture` to disable them explicitly: +Capture is disabled by default. Select sources with `-DLIVEKIT_CAPTURE=` +or `--capture[=list]` / `--no-capture`. + +Available sources (comma-separated; each maps to `capture-*` on `livekit-ffi`): + +| Source | Description | +|--------|-------------| +| `gstreamer` | GStreamer pipelines (needs GStreamer at build and runtime) | +| `pattern` | GPU test pattern | +| `clock` | GPU wall clock | + +Special values: `none` / `OFF` / empty (disabled), `all` / `ON` (every source). +`LIVEKIT_CAPTURE_ENABLED` is 1 when any source is selected; installed +`share/livekit/build-info.json` also records `cargo_features`. ```bash ./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture -./build.sh debug-tests ``` On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. diff --git a/include/livekit/capture_source.h b/include/livekit/capture_source.h index 504d6536..e250668a 100644 --- a/include/livekit/capture_source.h +++ b/include/livekit/capture_source.h @@ -165,9 +165,8 @@ struct CaptureResult { /// a track from @ref CaptureSource::videoSource(), merge application options /// through @ref CaptureSource::publishOptions(), then call @ref CaptureSource::start(). /// -/// Requires a capture-enabled SDK build (`LIVEKIT_BUILD_CAPTURE`), with -/// `capture-gstreamer`, `capture-pattern`, and `capture-clock` Rust features. -/// Otherwise creation fails. +/// Requires a capture-enabled SDK build (`LIVEKIT_CAPTURE`). Creation fails +/// when the requested source was not compiled into the Rust FFI. /// /// @note Keep this object alive while the track is published. Destroying it /// stops a running capture. diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index 2ea974c9..83bc3d06 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -20,7 +20,7 @@ /// participant receives video through the SFU without per-frame FFI traffic. /// /// All of them require the Rust FFI built with capture-source support -/// (-DLIVEKIT_BUILD_CAPTURE=ON); otherwise they skip. +/// (-DLIVEKIT_CAPTURE=all); otherwise they skip. #include @@ -64,7 +64,7 @@ class CaptureSourceServerTest : public LiveKitTestBase, public ::testing::WithPa TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { #if !LIVEKIT_CAPTURE_ENABLED - GTEST_SKIP() << "livekit-ffi built without capture support; configure with -DLIVEKIT_BUILD_CAPTURE=ON"; + GTEST_SKIP() << "livekit-ffi built without capture support; configure with -DLIVEKIT_CAPTURE=all"; #endif failIfNotConfigured(); From fd8942d82ce857bbbbc47ced2f7b0914f8ed3093 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 13:24:04 -0600 Subject: [PATCH 24/29] --capture[=list] docs update --- docs/building.md | 37 +++++++++++++++++++++++++------------ docs/capture-sources.md | 7 +++++++ docs/testing.md | 6 ++++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/building.md b/docs/building.md index 7a14305c..5015cb7f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -27,11 +27,15 @@ CMake/vcpkg flows. ### Additional capture dependencies -Standard SDK builds do not require GStreamer. Capture-enabled builds -(`--capture`) require GStreamer development libraries at build time and -GStreamer libraries at runtime, including when using pattern or clock sources. -The SDK does not redistribute these libraries or plugins. Install the runtime -plugins required by your GStreamer pipeline too. +GStreamer is required only when the selected sources include `gstreamer`. +This includes `--capture` / `--capture=all` and release `-capture` bundles, +which enable all three sources. Standard SDK builds and builds selecting only +`pattern`, `clock`, or both do not require GStreamer. + +For builds that include `gstreamer`, install GStreamer development libraries +at build time and GStreamer libraries at runtime. The SDK does not redistribute +these libraries or plugins. Install the runtime plugins required by your +pipeline too. Pattern and clock sources need a compatible GPU adapter at runtime. **macOS:** Install both the official universal runtime and development packages from the [GStreamer downloads page](https://gstreamer.freedesktop.org/download/#macos). @@ -59,7 +63,8 @@ sudo apt install -y pkg-config libunwind-dev \ Add the installation's `bin` directory to `PATH` and its `lib/pkgconfig` directory to `PKG_CONFIG_PATH`. -Development files must be discoverable through `pkg-config`. You can verify +For GStreamer builds, development files must be discoverable through +`pkg-config`. You can verify that they are available with: ```bash @@ -398,11 +403,13 @@ The SDK places Rust FFI outputs in `client-sdk-rust/target/cpp-/`. Deep checkout paths can make WebRTC header paths exceed the Windows path limit. Set `CARGO_TARGET_DIR` to a short absolute path before building, for example `set CARGO_TARGET_DIR=C:\lk-rust` in Command Prompt. The SDK appends the -feature directory so capture-enabled and disabled outputs remain separate. +feature directory so different source selections have separate outputs. Windows CI uses this setting for builds, tests, and release bundles. `clean-all` removes the default Rust target directory; remove a custom root separately when it is no longer needed. + + ## Capture sources Capture is disabled by default. Select sources with `-DLIVEKIT_CAPTURE=` @@ -422,15 +429,21 @@ Special values: `none` / `OFF` / empty (disabled), `all` / `ON` (every source). ```bash ./build.sh release --capture --bundle --prefix sdk-out/livekit-sdk-capture +./build.sh release --capture=pattern,clock --bundle --prefix sdk-out/livekit-sdk-pixel +./build.sh release --capture=gstreamer --bundle --prefix sdk-out/livekit-sdk-gstreamer ``` On Windows, use `build.cmd release --capture --bundle --prefix sdk-out\livekit-sdk-capture`. -Custom CMake configurations can set `LIVEKIT_BUILD_CAPTURE=ON`. The build -enables `capture-gstreamer`, `capture-pattern`, and `capture-clock` in the -Rust FFI. +Windows also accepts source lists, for example `build.cmd release --capture=pattern,clock`. +Custom CMake configurations use `LIVEKIT_CAPTURE=all` or a source list such as +`LIVEKIT_CAPTURE=pattern,clock`. -The build scripts set `LIVEKIT_BUILD_CAPTURE`. The generated numeric +The build scripts set `LIVEKIT_CAPTURE`. The generated numeric `LIVEKIT_CAPTURE_ENABLED` macro (0 or 1) supports `#if` checks in consumers. +It indicates whether any source was compiled in; it does not identify which +sources are available. Check `cargo_features` in `share/livekit/build-info.json` +for the selected backends. Creating a source omitted from the SDK build fails +through the returned future with `CaptureSourceError`. Direct inclusion of `livekit/capture_source.h` fails at compile time in standard SDK builds. `livekit/livekit.h` includes capture declarations only when enabled. `share/livekit/build-info.json` in an installed bundle describes the same build @@ -445,7 +458,7 @@ See [Additional capture dependencies](#additional-capture-dependencies) before building or running a capture-enabled SDK. Rust artifacts are stored under `client-sdk-rust/target/cpp-/`, -with separate directories for capture-enabled and disabled builds. Generated +with separate directories for each normalized source selection. Generated `build.h` files live in each CMake build directory, so feature metadata follows the installed SDK instead of another configuration's most recent build. diff --git a/docs/capture-sources.md b/docs/capture-sources.md index e64555df..24c6fd7a 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -10,6 +10,13 @@ The SDK supports three source configurations: | `ClockVideoSourceConfig` | GPU-rendered local time with millisecond precision | Compatible GPU adapter | | `GstreamerVideoSourceConfig` | Pre-encoded video from a GStreamer pipeline | GStreamer runtime and the pipeline's plugins | +A custom SDK build can include all sources or a subset selected with +`--capture=pattern,clock` or `--capture=gstreamer`. Release `-capture` bundles +include all sources. `LIVEKIT_CAPTURE_ENABLED` indicates that at least one +source is enabled; creating a source omitted from the build reports +`CaptureSourceError` through the returned future. Pattern/clock-only builds do +not require GStreamer. + Native camera device capture and device enumeration are not part of this API. ## Build and release bundles diff --git a/docs/testing.md b/docs/testing.md index 519fe96e..e19c8938 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -106,8 +106,10 @@ labels and a 30-second timeout. It checks the bundle's capture metadata and creates a GStreamer VP8 source through the installed library when capture is enabled. Configuration checks that direct inclusion of `livekit/capture_source.h` succeeds for capture bundles and fails for standard bundles. Both variants must -still compile with `livekit/livekit.h`. It needs no LiveKit server; capture-enabled -runs require GStreamer and the pipeline's plugins. +still compile with `livekit/livekit.h`. It needs no LiveKit server. The current +capture-enabled packaging test exercises GStreamer, so it requires that backend +and the pipeline's plugins; it does not validate pattern/clock-only bundles or +prove that unselected sources were excluded. The consumer also checks `share/livekit/build-info.json`. Release jobs pass `LIVEKIT_EXPECT_VERSION` to check version metadata and the recorded Rust commit and FFI version through this same project. From 9d6b12155f6819630c5bb4badd6520c3161c857e Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 13:51:08 -0600 Subject: [PATCH 25/29] pipelines/testing for specific capture sources --- .../actions/test-capture-variant/action.yml | 124 ++++++++++++++++++ .../actions/test-capture-variants/action.yml | 80 +++++++++++ .github/actions/test-sdk-bundle/action.yml | 22 +++- .github/workflows/builds.yml | 11 +- .github/workflows/ci.yml | 4 + .github/workflows/make-release.yml | 9 +- .github/workflows/nightly.yml | 1 + .github/workflows/tests.yml | 69 +++++++++- docs/testing.md | 47 ++++++- src/tests/packaging/CMakeLists.txt | 55 +++++++- src/tests/packaging/test_capture_bundle.cpp | 92 +++++++++---- 11 files changed, 461 insertions(+), 53 deletions(-) create mode 100644 .github/actions/test-capture-variant/action.yml create mode 100644 .github/actions/test-capture-variants/action.yml diff --git a/.github/actions/test-capture-variant/action.yml b/.github/actions/test-capture-variant/action.yml new file mode 100644 index 00000000..4e7b7693 --- /dev/null +++ b/.github/actions/test-capture-variant/action.yml @@ -0,0 +1,124 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Test one selective capture bundle +description: Restore, build, verify, cache, and release one source variant's Cargo output. +inputs: + preset: + description: SDK build preset without tests or examples (debug or release). + default: release + sources: + description: Comma-separated capture sources for this variant. + required: true + verify-pixel-sources: + description: Require successful GPU source creation on a prepared runner. + default: 'false' + macos-arch: + description: Optional macOS architecture override. + default: '' +runs: + using: composite + steps: + - name: Identify feature-specific Cargo cache + id: variant + shell: bash + env: + VARIANT_SOURCES: ${{ inputs.sources }} + VARIANT_PRESET: ${{ inputs.preset }} + VARIANT_ARCH: ${{ inputs.macos-arch }} + VARIANT_CONFIG_HASH: ${{ hashFiles('CMakeLists.txt', 'build.sh', 'build.cmd', '.github/actions/setup-capture/action.yml', '.github/actions/test-capture-variant/action.yml') }} + run: | + set -euo pipefail + variant_python=python + if ! command -v "$variant_python" >/dev/null 2>&1; then variant_python=python3; fi + "$variant_python" - <<'PYCODE' + import hashlib + import os + import pathlib + import subprocess + import time + sources = sorted(set(os.environ["VARIANT_SOURCES"].split(","))) + if not sources or any(source not in {"clock", "gstreamer", "pattern"} for source in sources): + raise SystemExit("Invalid selective capture source set") + features = ",".join("capture-" + source for source in sources) + feature_hash = hashlib.sha256(features.encode()).hexdigest()[:16] + root = pathlib.Path(os.environ["CARGO_TARGET_DIR"]).resolve() + cargo_dir = root / ("cpp-" + feature_hash) + rust_sha = subprocess.check_output(["git", "-C", "client-sdk-rust", "rev-parse", "HEAD"], text=True).strip() + key = "-".join(["capture-variant", os.environ["RUNNER_OS"], os.environ.get("ImageOS", os.environ["RUNNER_OS"]), + os.environ["RUNNER_ARCH"], os.environ["VARIANT_ARCH"] or "native", + os.environ["VARIANT_PRESET"], feature_hash, + os.environ["VARIANT_CONFIG_HASH"], rust_sha]) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"cargo-dir={cargo_dir.as_posix()}\nkey={key}\n") + output.write(f"started-at={time.monotonic()}\n") + output.write("bundle-dir=sdk-out/capture-" + "-".join(sources) + "\n") + PYCODE + - name: Restore variant Cargo cache + id: cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.variant.outputs.cargo-dir }} + key: ${{ steps.variant.outputs.key }} + - name: Build variant + uses: ./.github/actions/build-sdk + with: + preset: ${{ inputs.preset }} + capture: ${{ inputs.sources }} + bundle-dir: ${{ steps.variant.outputs.bundle-dir }} + macos-arch: ${{ inputs.macos-arch }} + - name: Test installed variant + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: ${{ steps.variant.outputs.bundle-dir }} + expected-sources: ${{ inputs.sources }} + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + build-type: ${{ inputs.preset == 'debug' && 'Debug' || 'Release' }} + macos-arch: ${{ inputs.macos-arch }} + - name: Save verified variant Cargo cache + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.variant.outputs.cargo-dir }} + key: ${{ steps.variant.outputs.key }} + # The installed bundle owns copies of the libraries. Releasing the Rust + # build tree bounds disk use across the sequence of feature configurations. + - name: Release temporary Cargo output + if: always() && steps.variant.outputs.cargo-dir != '' + shell: bash + env: + VARIANT_CARGO_DIR: ${{ steps.variant.outputs.cargo-dir }} + VARIANT_SOURCES: ${{ inputs.sources }} + VARIANT_STARTED_AT: ${{ steps.variant.outputs.started-at }} + VARIANT_CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + run: | + set -euo pipefail + variant_python=python + if ! command -v "$variant_python" >/dev/null 2>&1; then variant_python=python3; fi + "$variant_python" - <<'PYCODE' + import os + import pathlib + import shutil + import time + path = pathlib.Path(os.environ["VARIANT_CARGO_DIR"]).resolve() + root = pathlib.Path(os.environ["CARGO_TARGET_DIR"]).resolve() + if path.parent != root or not path.name.startswith("cpp-"): + raise SystemExit("Refusing to remove a path outside the feature-specific Cargo root") + if path.exists(): + shutil.rmtree(path) + elapsed = time.monotonic() - float(os.environ["VARIANT_STARTED_AT"]) + cache = "hit" if os.environ["VARIANT_CACHE_HIT"] == "true" else "miss" + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write(f"Capture `{os.environ['VARIANT_SOURCES']}`: {elapsed:.0f}s; Cargo cache {cache}.\n\n") + PYCODE diff --git a/.github/actions/test-capture-variants/action.yml b/.github/actions/test-capture-variants/action.yml new file mode 100644 index 00000000..f2ba3c2e --- /dev/null +++ b/.github/actions/test-capture-variants/action.yml @@ -0,0 +1,80 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +name: Test selective capture bundles +description: Build and validate singleton capture sources, with optional pair combinations. +inputs: + preset: + description: SDK build preset without tests or examples (debug or release). + default: release + combinations: + description: Also validate each two-source combination. + default: 'false' + verify-pixel-sources: + description: Require successful GPU source creation on a prepared runner. + default: 'false' + macos-arch: + description: Optional macOS architecture override. + default: '' +runs: + using: composite + steps: + - name: Check pattern bundle + uses: ./.github/actions/test-capture-variant + with: + preset: ${{ inputs.preset }} + sources: 'pattern' + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + macos-arch: ${{ inputs.macos-arch }} + - name: Check clock bundle + uses: ./.github/actions/test-capture-variant + with: + preset: ${{ inputs.preset }} + sources: 'clock' + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + macos-arch: ${{ inputs.macos-arch }} + # Pixel-only variants run before installing GStreamer. + - name: Set up GStreamer dependencies + uses: ./.github/actions/setup-capture + - name: Check gstreamer bundle + uses: ./.github/actions/test-capture-variant + with: + preset: ${{ inputs.preset }} + sources: 'gstreamer' + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + macos-arch: ${{ inputs.macos-arch }} + - name: Check pattern,clock bundle + if: inputs.combinations == 'true' + uses: ./.github/actions/test-capture-variant + with: + preset: ${{ inputs.preset }} + sources: 'pattern,clock' + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + macos-arch: ${{ inputs.macos-arch }} + - name: Check gstreamer,pattern bundle + if: inputs.combinations == 'true' + uses: ./.github/actions/test-capture-variant + with: + preset: ${{ inputs.preset }} + sources: 'gstreamer,pattern' + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + macos-arch: ${{ inputs.macos-arch }} + - name: Check gstreamer,clock bundle + if: inputs.combinations == 'true' + uses: ./.github/actions/test-capture-variant + with: + preset: ${{ inputs.preset }} + sources: 'gstreamer,clock' + verify-pixel-sources: ${{ inputs.verify-pixel-sources }} + macos-arch: ${{ inputs.macos-arch }} diff --git a/.github/actions/test-sdk-bundle/action.yml b/.github/actions/test-sdk-bundle/action.yml index cf25f17b..f649847e 100644 --- a/.github/actions/test-sdk-bundle/action.yml +++ b/.github/actions/test-sdk-bundle/action.yml @@ -20,7 +20,13 @@ inputs: required: true capture: description: Expected capture support (ON or OFF). - required: true + default: 'ON' + expected-sources: + description: Expected source set (all, none, or comma-separated sources); empty derives from capture. + default: '' + verify-pixel-sources: + description: Require successful pattern/clock creation using a GPU or software adapter. + default: 'false' build-type: description: Consumer configuration (Debug or Release). default: Release @@ -39,17 +45,20 @@ runs: env: SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} SDK_CAPTURE: ${{ inputs.capture }} + SDK_EXPECTED_SOURCES: ${{ inputs.expected-sources }} + SDK_VERIFY_PIXEL_SOURCES: ${{ inputs.verify-pixel-sources }} SDK_BUILD_TYPE: ${{ inputs.build-type }} SDK_VERSION: ${{ inputs.version }} SDK_MACOS_ARCH: ${{ inputs.macos-arch }} run: | bundle="$GITHUB_WORKSPACE/$SDK_BUNDLE_DIR" - build_dir="build-packaging-$SDK_CAPTURE" + build_dir="${RUNNER_TEMP}/livekit-packaging-${SDK_EXPECTED_SOURCES:-$SDK_CAPTURE}" arch_args=() if [[ -n "$SDK_MACOS_ARCH" ]]; then arch_args+=(-DCMAKE_OSX_ARCHITECTURES="$SDK_MACOS_ARCH"); fi cmake -S src/tests/packaging -B "$build_dir" -G Ninja \ -DCMAKE_BUILD_TYPE="$SDK_BUILD_TYPE" -DCMAKE_PREFIX_PATH="$bundle" \ - -DLIVEKIT_EXPECT_CAPTURE="$SDK_CAPTURE" -DLIVEKIT_EXPECT_VERSION="$SDK_VERSION" "${arch_args[@]}" + -DLIVEKIT_EXPECT_CAPTURE="$SDK_CAPTURE" -DLIVEKIT_EXPECT_SOURCES="$SDK_EXPECTED_SOURCES" \ + -DLIVEKIT_VERIFY_PIXEL_SOURCES="$SDK_VERIFY_PIXEL_SOURCES" -DLIVEKIT_EXPECT_VERSION="$SDK_VERSION" "${arch_args[@]}" cmake --build "$build_dir" --config "$SDK_BUILD_TYPE" ctest --test-dir "$build_dir" -C "$SDK_BUILD_TYPE" --output-on-failure -L packaging - name: Test bundle (Windows) @@ -58,12 +67,15 @@ runs: env: SDK_BUNDLE_DIR: ${{ inputs.bundle-dir }} SDK_CAPTURE: ${{ inputs.capture }} + SDK_EXPECTED_SOURCES: ${{ inputs.expected-sources }} + SDK_VERIFY_PIXEL_SOURCES: ${{ inputs.verify-pixel-sources }} SDK_BUILD_TYPE: ${{ inputs.build-type }} SDK_VERSION: ${{ inputs.version }} run: | $bundle = Join-Path $env:GITHUB_WORKSPACE $env:SDK_BUNDLE_DIR - $buildDir = "build-packaging-$env:SDK_CAPTURE" - cmake -S src/tests/packaging -B "$buildDir" -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE="$env:SDK_CAPTURE" -DLIVEKIT_EXPECT_VERSION="$env:SDK_VERSION" + $selection = if ($env:SDK_EXPECTED_SOURCES) { $env:SDK_EXPECTED_SOURCES } else { $env:SDK_CAPTURE } + $buildDir = Join-Path $env:RUNNER_TEMP "livekit-packaging-$selection" + cmake -S src/tests/packaging -B "$buildDir" -DCMAKE_PREFIX_PATH="$bundle" -DLIVEKIT_EXPECT_CAPTURE="$env:SDK_CAPTURE" -DLIVEKIT_EXPECT_SOURCES="$env:SDK_EXPECTED_SOURCES" -DLIVEKIT_VERIFY_PIXEL_SOURCES="$env:SDK_VERIFY_PIXEL_SOURCES" -DLIVEKIT_EXPECT_VERSION="$env:SDK_VERSION" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } cmake --build "$buildDir" --config "$env:SDK_BUILD_TYPE" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index ac1dc9cb..0ef251ac 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -141,12 +141,11 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}- + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-release-standard-${{ hashFiles('CMakeLists.txt', 'build.sh', 'build.cmd', '.github/actions/setup-capture/action.yml', '.github/actions/test-capture-variants/action.yml') }}-${{ steps.rust_sha.outputs.sha }} - # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge - # artifacts in webrtc-sys are not compatible across those bumps. + # Restore exact keys only: webrtc-sys cxxbridge artifacts are not + # compatible across Rust submodule bumps. Avoid downloading a fallback + # cache that this cleanup would immediately discard. - name: Drop stale cargo target on submodule SHA cache miss if: steps.cache-cargo-target.outputs.cache-hit != 'true' shell: bash @@ -237,7 +236,7 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-release-standard-${{ hashFiles('CMakeLists.txt', 'build.sh', 'build.cmd', '.github/actions/setup-capture/action.yml', '.github/actions/test-capture-variants/action.yml') }}-${{ steps.rust_sha.outputs.sha }} - name: Save vcpkg binary cache if: runner.os == 'Windows' && steps.cache-vcpkg-binary.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd9ef1eb..896c69c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,8 @@ jobs: - .github/actions/cargo-target-root/** - .github/actions/smoke-examples/** - .github/actions/test-sdk-bundle/** + - .github/actions/test-capture-variants/** + - .github/actions/test-capture-variant/** - .github/actions/check-sdk-symbols/** - .github/scripts/check_no_private_symbols.py tests: @@ -79,6 +81,8 @@ jobs: - .github/actions/build-sdk/** - .github/actions/cargo-target-root/** - .github/actions/test-sdk-bundle/** + - .github/actions/test-capture-variants/** + - .github/actions/test-capture-variant/** docs: - README.md - include/** diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 64f1d084..3675b142 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -149,12 +149,11 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}- + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-release-standard-${{ hashFiles('CMakeLists.txt', 'build.sh', 'build.cmd', '.github/actions/setup-capture/action.yml', '.github/actions/test-capture-variants/action.yml') }}-${{ steps.rust_sha.outputs.sha }} - # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge - # artifacts in webrtc-sys are not compatible across those bumps. + # Restore exact keys only: webrtc-sys cxxbridge artifacts are not + # compatible across Rust submodule bumps. Avoid downloading a fallback + # cache that this cleanup would immediately discard. - name: Drop stale cargo target on submodule SHA cache miss if: steps.cache-cargo-target.outputs.cache-hit != 'true' shell: bash diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index daf4bb45..4b31c255 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -26,4 +26,5 @@ jobs: stress_timeout_minutes: 120 artifact_retention_days: 14 run_coverage: false + extended_capture_sources: true secrets: inherit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86109318..ddfb0402 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,11 @@ name: Tests on: workflow_call: inputs: + extended_capture_sources: + description: Test pair combinations and selective bundles on macOS/Windows as well as Linux. + required: false + type: boolean + default: false build_type: description: Debug or release test build. required: false @@ -56,6 +61,11 @@ on: default: true workflow_dispatch: inputs: + extended_capture_sources: + description: Test pair combinations and selective bundles on macOS/Windows as well as Linux. + required: false + type: boolean + default: false build_type: description: Debug or release test build. required: false @@ -145,12 +155,14 @@ jobs: e2e-testing: true - os: ubuntu-24.04 name: ubuntu-24.04-x64 + selective_capture: true e2e-testing: true - os: ubuntu-24.04-arm name: ubuntu-24.04-arm64 e2e-testing: true - os: macos-26-xlarge name: macos-arm64 + extended_capture: true e2e-testing: true - os: macos-26-large name: macos-x64 @@ -159,6 +171,7 @@ jobs: # Pinned to Windows 2022 for current VS 17 implementation - os: windows-2022 name: windows-x64 + extended_capture: true e2e-testing: true name: Test (${{ matrix.name }}) @@ -208,7 +221,21 @@ jobs: uses: ./.github/actions/setup-sdk-deps with: vcpkg-commit: ${{ env.VCPKG_GIT_COMMIT }} - linux-extra-packages: libunwind-dev libjpeg-dev gdb jq + linux-extra-packages: libunwind-dev libjpeg-dev gdb jq ${{ matrix.selective_capture && 'mesa-vulkan-drivers libvulkan1' || '' }} + + - name: Configure software Vulkan for capture source checks + if: matrix.selective_capture + shell: bash + run: | + set -euo pipefail + icds=(/usr/share/vulkan/icd.d/lvp_icd*.json) + test -f "${icds[0]}" + runtime_dir="$RUNNER_TEMP/capture-runtime" + mkdir -p "$runtime_dir" + chmod 700 "$runtime_dir" + echo "VK_ICD_FILENAMES=${icds[0]}" >> "$GITHUB_ENV" + echo "WGPU_BACKEND=vulkan" >> "$GITHUB_ENV" + echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -252,12 +279,11 @@ jobs: path: | client-sdk-rust/target/ ${{ env.CARGO_TARGET_DIR }} - key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}-${{ steps.rust_sha.outputs.sha }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ runner.os == 'macOS' && hashFiles('.github/actions/setup-capture/action.yml') || 'v3' }}- + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ inputs.build_type }}-standard-${{ hashFiles('CMakeLists.txt', 'build.sh', 'build.cmd', '.github/actions/setup-capture/action.yml', '.github/actions/test-capture-variants/action.yml') }}-${{ steps.rust_sha.outputs.sha }} - # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge - # artifacts in webrtc-sys are not compatible across those bumps. + # Restore exact keys only: webrtc-sys cxxbridge artifacts are not + # compatible across Rust submodule bumps. Avoid downloading a fallback + # cache that this cleanup would immediately discard. - name: Drop stale cargo target on submodule SHA cache miss if: steps.cache-cargo-target.outputs.cache-hit != 'true' shell: bash @@ -304,7 +330,17 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} build-type: ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} + - name: Test selective capture bundles + if: matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture) + uses: ./.github/actions/test-capture-variants + with: + preset: ${{ inputs.build_type }} + combinations: ${{ inputs.extended_capture_sources }} + verify-pixel-sources: ${{ matrix.selective_capture || runner.os == 'macOS' }} + macos-arch: ${{ matrix.macos_arch || '' }} + - name: Set up capture dependencies + if: ${{ !(matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture)) }} uses: ./.github/actions/setup-capture - name: Rebuild tests with capture @@ -312,7 +348,17 @@ jobs: with: preset: ${{ inputs.build_type }}-tests capture: 'ON' + bundle-dir: sdk-out/test-sdk-all + macos-arch: ${{ matrix.macos_arch || '' }} + + - name: Test installed all-sources SDK + uses: ./.github/actions/test-sdk-bundle + with: + bundle-dir: sdk-out/test-sdk-all + expected-sources: all + verify-pixel-sources: ${{ matrix.selective_capture || (inputs.extended_capture_sources && runner.os == 'macOS' && matrix.extended_capture) }} macos-arch: ${{ matrix.macos_arch || '' }} + build-type: ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} # ---------- Run unit tests ---------- - name: Run unit tests (Unix) @@ -430,6 +476,17 @@ jobs: shell: bash run: tail -n 200 "${{ steps.token_server.outputs.log-path }}" || true + # Cache the completed source set, not just the first variant. Keep PRs + # restore-only so their large Rust outputs do not evict shared main caches. + - name: Save cargo target after source and full-suite checks + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.cache-cargo-target.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + key: ${{ matrix.os }}-${{ matrix.name }}-cargo-target-${{ inputs.build_type }}-standard-${{ hashFiles('CMakeLists.txt', 'build.sh', 'build.cmd', '.github/actions/setup-capture/action.yml', '.github/actions/test-capture-variants/action.yml') }}-${{ steps.rust_sha.outputs.sha }} + # ---------- Upload results ---------- - name: Upload test results if: always() diff --git a/docs/testing.md b/docs/testing.md index e19c8938..51a77bf6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -102,14 +102,21 @@ export LIVEKIT_TOKEN_B="$(lk token create --api-key devkey --api-secret secret - `src/tests/packaging/` is a standalone CMake consumer of an installed SDK. CTest registers `InstalledSdk.Capture` with the `packaging` and `integration` -labels and a 30-second timeout. It checks the bundle's capture metadata and -creates a GStreamer VP8 source through the installed library when capture is -enabled. Configuration checks that direct inclusion of `livekit/capture_source.h` +labels and a 60-second timeout. It checks the bundle's exact `cargo_features` +against `LIVEKIT_EXPECT_SOURCES` (`all`, `none`, or a comma-separated source list). +If that setting is empty, `LIVEKIT_EXPECT_CAPTURE=ON` means all sources and `OFF` +means none. Explicit source selections determine the expected capture flag. +The test creates a GStreamer VP8 source when that source is selected. It also +requires each omitted backend to report the feature-disabled error. Configuration +checks that direct inclusion of `livekit/capture_source.h` succeeds for capture bundles and fails for standard bundles. Both variants must -still compile with `livekit/livekit.h`. It needs no LiveKit server. The current -capture-enabled packaging test exercises GStreamer, so it requires that backend -and the pipeline's plugins; it does not validate pattern/clock-only bundles or -prove that unselected sources were excluded. +still compile with `livekit/livekit.h`. It needs no LiveKit server. +GStreamer-enabled runs require the pipeline's plugins. Set +`LIVEKIT_VERIFY_PIXEL_SOURCES=ON` to also require successful pattern and clock +creation when selected. This needs a compatible GPU or software adapter; +creation failures fail the test. Without that option, an explicit missing-GPU-adapter error verifies that the +backend is available but reports that creation was not exercised. Other errors +still fail. Omitted pixel sources are always tested for rejection. The consumer also checks `share/livekit/build-info.json`. Release jobs pass `LIVEKIT_EXPECT_VERSION` to check version metadata and the recorded Rust commit and FFI version through this same project. @@ -132,6 +139,32 @@ capture-enabled CI release jobs, the expectation is explicitly `ON`; a disabled bundle cannot pass by rejecting source creation. On Windows, add the installed SDK's `bin` directory and GStreamer's `bin` directory to `PATH` before running CTest. +### Selective capture in CI + +The Ubuntu 24.04 x64 test job checks `none`, each singleton source, and `all`. +Singleton builds reuse the C++ build tree and do not compile the unit/integration +suites. Pattern and clock builds run before GStreamer installation. This runner +uses Mesa software Vulkan, and pixel-source creation must succeed. The full test +suite runs once after rebuilding with all sources. + +The reusable `test-capture-variants` action uses `build-sdk` and +`test-sdk-bundle`. The latter accepts `expected-sources` and +`verify-pixel-sources`, so build, test, and release workflows share one installed +consumer test. Nightly runs enable `extended_capture_sources`, adding the three +two-source combinations on Linux and selective bundles on macOS arm64 and +Windows x64. Windows validates exact metadata and source availability, allowing +an explicit missing-adapter error for enabled pixel sources. Successful pixel +creation is required on Linux and macOS. Manual test-workflow runs can enable +the same coverage. + +Cargo caches include the build configuration, build type, and source selection. +Each variant reports elapsed time and cache hits in the job summary. +Each selective variant has its own cache, saved after its installed-bundle check +passes on trusted main runs. The variant's temporary Cargo directory is then +removed to bound disk usage. Standard/all outputs use the existing shared cache, +saved after the full suite passes. Rust variants still compile separately on +cold runs even though C++ dependencies are reused. + ## Test coverage - **SDK initialization**: initialize / shutdown lifecycle. diff --git a/src/tests/packaging/CMakeLists.txt b/src/tests/packaging/CMakeLists.txt index fbf4ff50..7b47e293 100644 --- a/src/tests/packaging/CMakeLists.txt +++ b/src/tests/packaging/CMakeLists.txt @@ -22,6 +22,40 @@ set_property(CACHE LIVEKIT_EXPECT_CAPTURE PROPERTY STRINGS ON OFF) if(NOT LIVEKIT_EXPECT_CAPTURE STREQUAL "ON" AND NOT LIVEKIT_EXPECT_CAPTURE STREQUAL "OFF") message(FATAL_ERROR "LIVEKIT_EXPECT_CAPTURE must be ON or OFF") endif() +set(LIVEKIT_EXPECT_SOURCES "" CACHE STRING "Expected capture sources; empty derives all/none from LIVEKIT_EXPECT_CAPTURE") +option(LIVEKIT_VERIFY_PIXEL_SOURCES "Require successful GPU pattern and clock source creation" OFF) +if(LIVEKIT_EXPECT_SOURCES STREQUAL "") + if(LIVEKIT_EXPECT_CAPTURE STREQUAL "ON") + set(_source_selection "all") + else() + set(_source_selection "none") + endif() +else() + string(TOLOWER "${LIVEKIT_EXPECT_SOURCES}" _source_selection) +endif() +set(_expected_sources "") +if(_source_selection STREQUAL "all" OR _source_selection STREQUAL "on") + set(_expected_sources clock gstreamer pattern) +elseif(NOT _source_selection STREQUAL "none" AND NOT _source_selection STREQUAL "off") + string(REPLACE "," ";" _expected_sources "${_source_selection}") +endif() +set(_expected_features "") +foreach(_source IN LISTS _expected_sources) + if(NOT _source MATCHES "^(clock|gstreamer|pattern)$") + message(FATAL_ERROR "Unknown expected capture source: ${_source}") + endif() + list(APPEND _expected_features "capture-${_source}") +endforeach() +list(REMOVE_DUPLICATES _expected_sources) +list(REMOVE_DUPLICATES _expected_features) +list(SORT _expected_features) +list(JOIN _expected_features "," _expected_features_csv) +# Explicit source selections determine whether the public capture header is usable. +if(_expected_sources) + set(LIVEKIT_EXPECT_CAPTURE ON) +else() + set(LIVEKIT_EXPECT_CAPTURE OFF) +endif() set(LIVEKIT_EXPECT_VERSION "" CACHE STRING "Expected release version; empty skips release-specific checks") # Check the installed snapshot, not whichever variant the SDK build tree last held. @@ -41,6 +75,14 @@ foreach(_file IN ITEMS endforeach() file(READ "${_sdk_root}/share/livekit/build-info.json" _build_info) string(JSON _capture GET "${_build_info}" capture_enabled) +string(JSON _features GET "${_build_info}" cargo_features) +string(REPLACE "," ";" _installed_features "${_features}") +list(REMOVE_DUPLICATES _installed_features) +list(SORT _installed_features) +list(JOIN _installed_features "," _installed_features_csv) +if(NOT _installed_features_csv STREQUAL _expected_features_csv) + message(FATAL_ERROR "Installed cargo_features '${_features}' do not match expected '${_expected_features_csv}'") +endif() if(LIVEKIT_EXPECT_CAPTURE STREQUAL "ON") set(_expected_capture 1) else() @@ -86,6 +128,17 @@ endif() add_executable(livekit_capture_package_test test_capture_bundle.cpp) target_compile_features(livekit_capture_package_test PRIVATE cxx_std_17) +foreach(_source IN ITEMS clock gstreamer pattern) + string(TOUPPER "${_source}" _source_upper) + if(_source IN_LIST _expected_sources) + set(_enabled 1) + else() + set(_enabled 0) + endif() + target_compile_definitions(livekit_capture_package_test PRIVATE "LIVEKIT_EXPECT_${_source_upper}=${_enabled}") +endforeach() +target_compile_definitions(livekit_capture_package_test PRIVATE + LIVEKIT_VERIFY_PIXEL_SOURCES=$) target_link_libraries(livekit_capture_package_test PRIVATE LiveKit::livekit) # The installed macOS SDK uses @rpath for its FFI dependency. if(APPLE) @@ -97,5 +150,5 @@ add_test(NAME InstalledSdk.Capture COMMAND livekit_capture_package_test "--expect-capture=${LIVEKIT_EXPECT_CAPTURE}") set_tests_properties(InstalledSdk.Capture PROPERTIES LABELS "packaging;integration" - TIMEOUT 30 + TIMEOUT 60 ) diff --git a/src/tests/packaging/test_capture_bundle.cpp b/src/tests/packaging/test_capture_bundle.cpp index b9c46f17..84e9103b 100644 --- a/src/tests/packaging/test_capture_bundle.cpp +++ b/src/tests/packaging/test_capture_bundle.cpp @@ -16,10 +16,77 @@ #include +#include #include #include #include +#if LIVEKIT_CAPTURE_ENABLED +namespace { + +// Strict GPU checks require successful creation of enabled sources. Omitted +// backends must reject a valid configuration with the feature-disabled error. +template +bool checkSource(std::string_view name, const Config& config, bool expected, livekit::CaptureSourceKind kind) { + try { + auto future = livekit::CaptureSource::create(config); + if (future.wait_for(std::chrono::seconds(15)) != std::future_status::ready) { + std::cerr << name << ": source creation timed out\n"; + return false; + } + auto source = future.get(); + if (!expected || !source || source->kind() != kind || source->width() != 320 || source->height() != 240 || + !source->videoSource() || + (kind == livekit::CaptureSourceKind::Encoded && source->codec() != livekit::VideoCodec::VP8)) { + std::cerr << name << ": unexpected source availability or metadata\n"; + return false; + } + return true; + } catch (const livekit::CaptureSourceError& error) { + constexpr std::string_view disabled = "capture source is not enabled in this build"; + const std::string_view message(error.what()); + if (!expected && message.find(disabled) != std::string_view::npos) { + return true; + } + // Runners without an adapter can still prove that the selected backend + // reached its renderer. All other errors fail; strict GPU jobs also fail + // on a missing adapter. + if (expected && kind == livekit::CaptureSourceKind::Pixel && !LIVEKIT_VERIFY_PIXEL_SOURCES && + message.find("no compatible GPU adapter") != std::string_view::npos) { + std::cout << name << ": creation not exercised (no GPU adapter); backend availability verified\n"; + return true; + } + std::cerr << name << ": " << error.what() << '\n'; + return false; + } catch (const std::exception& error) { + std::cerr << name << ": " << error.what() << '\n'; + return false; + } +} + +bool checkSources() { + livekit::GstreamerVideoSourceConfig gstreamer; + gstreamer.pipeline = + "videotestsrc num-buffers=4 ! video/x-raw,width=320,height=240,framerate=30/1 ! " + "vp8enc deadline=1 ! appsink name=lk_appsink"; + bool success = + checkSource("gstreamer", gstreamer, LIVEKIT_EXPECT_GSTREAMER != 0, livekit::CaptureSourceKind::Encoded); + + livekit::PatternVideoSourceConfig pattern; + pattern.resolution = {320, 240}; + pattern.framerate_fps = 30; + success = checkSource("pattern", pattern, LIVEKIT_EXPECT_PATTERN != 0, livekit::CaptureSourceKind::Pixel) && success; + + livekit::ClockVideoSourceConfig clock; + clock.resolution = {320, 240}; + clock.framerate_fps = 30; + success = checkSource("clock", clock, LIVEKIT_EXPECT_CLOCK != 0, livekit::CaptureSourceKind::Pixel) && success; + return success; +} + +} // namespace +#endif + int main(int argc, char** argv) { if (argc != 2 || (std::string_view(argv[1]) != "--expect-capture=ON" && std::string_view(argv[1]) != "--expect-capture=OFF")) { @@ -31,32 +98,11 @@ int main(int argc, char** argv) { std::cerr << "Installed SDK capture metadata does not match the requested expectation\n"; return 1; } - #if LIVEKIT_CAPTURE_ENABLED livekit::initialize(livekit::LogLevel::Warn); - int status = 0; - try { - livekit::GstreamerVideoSourceConfig config; - config.pipeline = - "videotestsrc num-buffers=4 ! video/x-raw,width=320,height=240,framerate=30/1 ! " - "vp8enc deadline=1 ! appsink name=lk_appsink"; - auto source = livekit::CaptureSource::create(config).get(); - if (!expect_capture || !source || source->kind() != livekit::CaptureSourceKind::Encoded || source->width() != 320 || - source->height() != 240 || source->codec() != livekit::VideoCodec::VP8 || !source->videoSource()) { - std::cerr << "Installed capture SDK returned unexpected source metadata\n"; - status = 1; - } - } catch (const livekit::CaptureSourceError& error) { - if (expect_capture) { - std::cerr << "Installed capture SDK failed: " << error.what() << '\n'; - status = 1; - } - } catch (const std::exception& error) { - std::cerr << error.what() << '\n'; - status = 1; - } + const bool success = checkSources(); livekit::shutdown(); - return status; + return success ? 0 : 1; #else return 0; #endif From bff2394e8b14978b9565db652a82ddeeceb8d356 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 14:01:25 -0600 Subject: [PATCH 26/29] livekit/capture/ --- AGENTS.md | 1 + CMakeLists.txt | 9 +++++++++ docs/building.md | 6 ++++-- docs/capture-sources.md | 10 ++++++++++ docs/doxygen/Doxyfile | 2 +- docs/testing.md | 5 +++-- include/livekit/{ => capture}/capture_source.h | 0 include/livekit/livekit.h | 2 +- src/capture_source.cpp | 2 +- src/capture_source_internal.h | 2 +- src/ffi_client.cpp | 2 +- src/tests/integration/test_capture_source.cpp | 2 +- src/tests/packaging/CMakeLists.txt | 18 +++++++++++++++--- src/tests/unit/test_capture_source.cpp | 2 +- 14 files changed, 49 insertions(+), 14 deletions(-) rename include/livekit/{ => capture}/capture_source.h (100%) diff --git a/AGENTS.md b/AGENTS.md index 0932f7f6..cc4ce419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ Be sure to update the directory layout in this file if the directory layout chan | Path | Description | |------|-------------| | `include/livekit/` | Public API headers (what SDK consumers include) | +| `include/livekit/capture/` | Capture API headers; installed only for capture-enabled SDKs | | `src/` | Implementation files and internal-only headers (`ffi_client.h`, `lk_log.h`, etc.) | | `src/tests/` | Google Test integration and stress tests | | `examples/` | In-tree example applications | diff --git a/CMakeLists.txt b/CMakeLists.txt index d5319a04..76ac9b76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -799,7 +799,16 @@ endif() install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" PATTERN "build.h" EXCLUDE + PATTERN "capture" EXCLUDE ) +# Capture headers are a separate public API group, installed only when at least +# one capture backend is enabled in this SDK variant. +if(LIVEKIT_CAPTURE_ENABLED) + install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/livekit/capture" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/livekit" + FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" + ) +endif() install(FILES "${GENERATED_BUILD_H}" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/livekit") # ---- Create Config + Version + Targets for find_package(LiveKit CONFIG) ---- diff --git a/docs/building.md b/docs/building.md index 5015cb7f..a5bcdc7f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -444,8 +444,10 @@ It indicates whether any source was compiled in; it does not identify which sources are available. Check `cargo_features` in `share/livekit/build-info.json` for the selected backends. Creating a source omitted from the SDK build fails through the returned future with `CaptureSourceError`. -Direct inclusion of `livekit/capture_source.h` fails at compile time in standard -SDK builds. `livekit/livekit.h` includes capture declarations only when enabled. +Capture-enabled bundles install the public API under `include/livekit/capture/`; +standard bundles omit that directory. Use `` +for direct inclusion. The source-tree header also rejects use in a standard +build. `livekit/livekit.h` includes capture declarations only when enabled. `share/livekit/build-info.json` in an installed bundle describes the same build features; neither value tests whether a GPU or a requested GStreamer plugin is available at runtime. diff --git a/docs/capture-sources.md b/docs/capture-sources.md index 24c6fd7a..6177ffd5 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -24,6 +24,16 @@ Native camera device capture and device enumeration are not part of this API. For build flags, release bundle naming, GStreamer prerequisites, and runtime requirements, see [Building — Capture sources](building.md#capture-sources). +Capture-enabled bundles install the capture API in `include/livekit/capture/`: + +```cpp +#include +``` + +You can also include ``, which includes capture declarations +when capture is enabled. Standard SDK bundles do not contain the `capture/` +header directory. + ## Create and publish a source Dimensions must be positive. Pattern and clock frame rates must also be positive. diff --git a/docs/doxygen/Doxyfile b/docs/doxygen/Doxyfile index 718adf31..1491e984 100644 --- a/docs/doxygen/Doxyfile +++ b/docs/doxygen/Doxyfile @@ -1100,7 +1100,7 @@ FILE_PATTERNS = *.c \ # be searched for input files as well. # The default value is: NO. -RECURSIVE = NO +RECURSIVE = YES # The EXCLUDE tag can be used to specify files and/or directories that should be # excluded from the INPUT source files. This way you can easily exclude a diff --git a/docs/testing.md b/docs/testing.md index 51a77bf6..bc3ebc6a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -108,8 +108,9 @@ If that setting is empty, `LIVEKIT_EXPECT_CAPTURE=ON` means all sources and `OFF means none. Explicit source selections determine the expected capture flag. The test creates a GStreamer VP8 source when that source is selected. It also requires each omitted backend to report the feature-disabled error. Configuration -checks that direct inclusion of `livekit/capture_source.h` -succeeds for capture bundles and fails for standard bundles. Both variants must +checks that `include/livekit/capture/` is present only in capture bundles and +that direct inclusion of `livekit/capture/capture_source.h` succeeds only in +those bundles. The obsolete flat capture header must be absent. Both variants must still compile with `livekit/livekit.h`. It needs no LiveKit server. GStreamer-enabled runs require the pipeline's plugins. Set `LIVEKIT_VERIFY_PIXEL_SOURCES=ON` to also require successful pattern and clock diff --git a/include/livekit/capture_source.h b/include/livekit/capture/capture_source.h similarity index 100% rename from include/livekit/capture_source.h rename to include/livekit/capture/capture_source.h diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index e3756c34..ed9da5c2 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -22,7 +22,7 @@ #include "livekit/audio_stream.h" #include "livekit/build.h" #if LIVEKIT_CAPTURE_ENABLED -#include "livekit/capture_source.h" +#include "livekit/capture/capture_source.h" #endif #include "livekit/e2ee.h" #include "livekit/encoded_video_source.h" diff --git a/src/capture_source.cpp b/src/capture_source.cpp index dcf32a47..07abb135 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -14,7 +14,7 @@ * limitations under the License. */ -#include "livekit/capture_source.h" +#include "livekit/capture/capture_source.h" #include #include diff --git a/src/capture_source_internal.h b/src/capture_source_internal.h index 446f1534..08c56030 100644 --- a/src/capture_source_internal.h +++ b/src/capture_source_internal.h @@ -16,7 +16,7 @@ #pragma once -#include "livekit/capture_source.h" +#include "livekit/capture/capture_source.h" #include "livekit/visibility.h" namespace livekit { diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 1449b957..7678d63b 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -26,7 +26,7 @@ #include "data_track_proto_converter.h" #include "ffi.pb.h" #include "livekit/build.h" -#include "livekit/capture_source.h" +#include "livekit/capture/capture_source.h" #include "livekit/data_track_error.h" #include "livekit/ffi_handle.h" #include "livekit/room.h" diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index 83bc3d06..9acf5e44 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -22,7 +22,7 @@ /// All of them require the Rust FFI built with capture-source support /// (-DLIVEKIT_CAPTURE=all); otherwise they skip. -#include +#include #include #include diff --git a/src/tests/packaging/CMakeLists.txt b/src/tests/packaging/CMakeLists.txt index 7b47e293..393d9892 100644 --- a/src/tests/packaging/CMakeLists.txt +++ b/src/tests/packaging/CMakeLists.txt @@ -110,20 +110,32 @@ if(NOT LIVEKIT_EXPECT_VERSION STREQUAL "") endif() endif() +# Validate the public header layout as well as compile-time availability. +if(EXISTS "${_sdk_include_dir}/livekit/capture_source.h") + message(FATAL_ERROR "Installed SDK contains the obsolete flat capture header") +endif() +if(LIVEKIT_EXPECT_CAPTURE STREQUAL "ON") + if(NOT EXISTS "${_sdk_include_dir}/livekit/capture/capture_source.h") + message(FATAL_ERROR "Capture SDK is missing the capture public API directory") + endif() +elseif(EXISTS "${_sdk_include_dir}/livekit/capture") + message(FATAL_ERROR "Standard SDK must not install the capture public API directory") +endif() + # Direct capture includes must fail in standard bundles and work in capture bundles. include(CheckCXXSourceCompiles) set(CMAKE_REQUIRED_INCLUDES "${_sdk_include_dir}") set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) unset(_capture_header_compiles CACHE) -check_cxx_source_compiles("#include +check_cxx_source_compiles("#include int main() { return 0; }" _capture_header_compiles) unset(CMAKE_REQUIRED_INCLUDES) unset(CMAKE_TRY_COMPILE_TARGET_TYPE) if(LIVEKIT_EXPECT_CAPTURE STREQUAL "ON" AND NOT _capture_header_compiles) - message(FATAL_ERROR "Capture SDK must allow including livekit/capture_source.h") + message(FATAL_ERROR "Capture SDK must allow including livekit/capture/capture_source.h") elseif(LIVEKIT_EXPECT_CAPTURE STREQUAL "OFF" AND _capture_header_compiles) - message(FATAL_ERROR "Standard SDK must reject including livekit/capture_source.h") + message(FATAL_ERROR "Standard SDK must reject including livekit/capture/capture_source.h") endif() add_executable(livekit_capture_package_test test_capture_bundle.cpp) diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp index c46a2cdb..66543a99 100644 --- a/src/tests/unit/test_capture_source.cpp +++ b/src/tests/unit/test_capture_source.cpp @@ -15,7 +15,7 @@ */ #include -#include +#include #include #include From 34a525d4f9e2f33f86406dfb0d0725b101e38cf8 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 14 Oct 2026 21:19:28 -0600 Subject: [PATCH 27/29] syntax --- .github/actions/build-sdk/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index ee54618b..e74374f6 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -63,9 +63,9 @@ runs: SDK_VERSION: ${{ inputs.version }} run: | $buildArgs = @($env:SDK_PRESET) - switch ($env:SDK_CAPTURE) { - 'ON'; 'all' { $buildArgs += '--capture' } - 'OFF'; 'none'; '' { $buildArgs += '--no-capture' } + switch ([string]$env:SDK_CAPTURE) { + { $_ -in @('ON', 'all') } { $buildArgs += '--capture'; break } + { $_ -in @('OFF', 'none', '') } { $buildArgs += '--no-capture'; break } default { $buildArgs += "--capture=$($env:SDK_CAPTURE)" } } if ($env:SDK_BUNDLE_DIR) { $buildArgs += @('--bundle', '--prefix', $env:SDK_BUNDLE_DIR) } From 9d78b9078a7d8404ec0cdc8ffe50acc7b07f0b1a Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 16 Oct 2026 09:45:18 -0600 Subject: [PATCH 28/29] no capture on intel mac --- .github/workflows/builds.yml | 5 +++++ .github/workflows/make-release.yml | 7 +++++++ .github/workflows/tests.yml | 20 ++++++++++---------- docs/capture-sources.md | 2 ++ docs/testing.md | 3 +++ 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 0ef251ac..9c4f7e26 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -196,9 +196,11 @@ jobs: # Reconfigure the same CMake tree after the standard bundle and aliases # have been validated and uploaded. Keep both install prefixes separate. - name: Set up capture dependencies + if: matrix.name != 'macos-x64' uses: ./.github/actions/setup-capture - name: Build capture SDK + if: matrix.name != 'macos-x64' uses: ./.github/actions/build-sdk with: preset: release-examples @@ -207,11 +209,13 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} - name: Smoke test capture examples + if: matrix.name != 'macos-x64' uses: ./.github/actions/smoke-examples with: build-dir: ${{ env.BUILD_DIR }} - name: Test installed capture SDK + if: matrix.name != 'macos-x64' uses: ./.github/actions/test-sdk-bundle with: bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture @@ -219,6 +223,7 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} - name: Upload capture SDK bundle + if: matrix.name != 'macos-x64' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: livekit-sdk-${{ matrix.name }}-capture diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 3675b142..529c64c1 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -199,9 +199,11 @@ jobs: version: ${{ steps.version.outputs.version }} - name: Set up capture dependencies + if: matrix.name != 'macos-x64' uses: ./.github/actions/setup-capture - name: Rebuild with capture + if: matrix.name != 'macos-x64' uses: ./.github/actions/build-sdk with: preset: release-examples @@ -211,11 +213,13 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} - name: Symbol leak check (capture SDK) + if: matrix.name != 'macos-x64' uses: ./.github/actions/check-sdk-symbols with: bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} - name: Test installed capture SDK + if: matrix.name != 'macos-x64' uses: ./.github/actions/test-sdk-bundle with: bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} @@ -231,6 +235,7 @@ jobs: path: sdk-out/livekit-sdk-${{ matrix.name }}-${{ steps.version.outputs.version }} - name: Upload capture build artifact + if: matrix.name != 'macos-x64' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: livekit-sdk-${{ matrix.name }}-capture-${{ steps.version.outputs.version }} @@ -306,6 +311,8 @@ jobs: # Standard and capture-enabled Ubuntu and macOS bundles. for platform in ubuntu-22.04-x64 ubuntu-22.04-arm64 ubuntu-24.04-x64 ubuntu-24.04-arm64 macos-arm64 macos-x64; do for suffix in "" "-capture"; do + # Intel macOS ships only the standard SDK. + if [[ "$platform" == macos-x64 && "$suffix" == -capture ]]; then continue; fi dirName="livekit-sdk-${platform}${suffix}-${VERSION}" test -d "${dirName}" tar -czf "${{ github.workspace }}/release-assets/${dirName}.tar.gz" "${dirName}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ddfb0402..878b40df 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -312,8 +312,8 @@ jobs: sudo sysctl -w "kern.corefile=${RUNNER_TEMP}/livekit-core.%P" fi - # Validate the standard installed SDK, then enable capture in the same - # CMake tree. Compile and run the complete test suite once, with capture. + # Validate the standard SDK, then compile and run the suite once. + # Enable capture for supported platforms; Intel macOS stays standard. - name: Build standard SDK uses: ./.github/actions/build-sdk with: @@ -331,7 +331,7 @@ jobs: build-type: ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} - name: Test selective capture bundles - if: matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture) + if: matrix.name != 'macos-x64' && (matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture)) uses: ./.github/actions/test-capture-variants with: preset: ${{ inputs.build_type }} @@ -340,22 +340,22 @@ jobs: macos-arch: ${{ matrix.macos_arch || '' }} - name: Set up capture dependencies - if: ${{ !(matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture)) }} + if: ${{ matrix.name != 'macos-x64' && !(matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture)) }} uses: ./.github/actions/setup-capture - - name: Rebuild tests with capture + - name: Build SDK with tests uses: ./.github/actions/build-sdk with: preset: ${{ inputs.build_type }}-tests - capture: 'ON' - bundle-dir: sdk-out/test-sdk-all + capture: ${{ matrix.name == 'macos-x64' && 'OFF' || 'ON' }} + bundle-dir: sdk-out/test-sdk-tests macos-arch: ${{ matrix.macos_arch || '' }} - - name: Test installed all-sources SDK + - name: Test installed SDK used by tests uses: ./.github/actions/test-sdk-bundle with: - bundle-dir: sdk-out/test-sdk-all - expected-sources: all + bundle-dir: sdk-out/test-sdk-tests + expected-sources: ${{ matrix.name == 'macos-x64' && 'none' || 'all' }} verify-pixel-sources: ${{ matrix.selective_capture || (inputs.extended_capture_sources && runner.os == 'macOS' && matrix.extended_capture) }} macos-arch: ${{ matrix.macos_arch || '' }} build-type: ${{ inputs.build_type == 'debug' && 'Debug' || 'Release' }} diff --git a/docs/capture-sources.md b/docs/capture-sources.md index 6177ffd5..c82a58a4 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -1,5 +1,7 @@ # Capture sources +> **NOTE:** LiveKit Capture is not officially supported on Intel-based Macs. + `CaptureSource` connects a Rust video producer to a regular C++ `VideoSource`. The capture pump runs in Rust, so C++ does not send one FFI request per frame. The SDK supports three source configurations: diff --git a/docs/testing.md b/docs/testing.md index bc3ebc6a..c90788a0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -142,6 +142,9 @@ SDK's `bin` directory and GStreamer's `bin` directory to `PATH` before running C ### Selective capture in CI +Intel macOS CI runs the standard SDK tests only; capture builds and checks are +not supported on that platform. + The Ubuntu 24.04 x64 test job checks `none`, each singleton source, and `all`. Singleton builds reuse the C++ build tree and do not compile the unit/integration suites. Pattern and clock builds run before GStreamer installation. This runner From 64c57eca66d4c0c58e35ce678eb8c5fda0595836 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 16 Oct 2026 09:50:58 -0600 Subject: [PATCH 29/29] livekit::capture --- docs/capture-sources.md | 19 +++--- include/livekit/capture/capture_source.h | 11 +++- include/livekit/video_source.h | 11 +++- src/capture_source.cpp | 4 +- src/capture_source_internal.h | 10 ++-- src/ffi_client.cpp | 22 +++---- src/ffi_client.h | 4 +- src/tests/integration/test_capture_source.cpp | 22 +++---- src/tests/packaging/test_capture_bundle.cpp | 24 ++++---- src/tests/unit/test_capture_source.cpp | 59 ++++++++++--------- 10 files changed, 104 insertions(+), 82 deletions(-) diff --git a/docs/capture-sources.md b/docs/capture-sources.md index c82a58a4..4c0e5dba 100644 --- a/docs/capture-sources.md +++ b/docs/capture-sources.md @@ -2,7 +2,7 @@ > **NOTE:** LiveKit Capture is not officially supported on Intel-based Macs. -`CaptureSource` connects a Rust video producer to a regular C++ `VideoSource`. +`livekit::capture::CaptureSource` connects a Rust video producer to a regular C++ `VideoSource`. The capture pump runs in Rust, so C++ does not send one FFI request per frame. The SDK supports three source configurations: @@ -26,7 +26,8 @@ Native camera device capture and device enumeration are not part of this API. For build flags, release bundle naming, GStreamer prerequisites, and runtime requirements, see [Building — Capture sources](building.md#capture-sources). -Capture-enabled bundles install the capture API in `include/livekit/capture/`: +Capture types live in the `livekit::capture` namespace. Capture-enabled bundles +install the API in `include/livekit/capture/`: ```cpp #include @@ -50,8 +51,8 @@ continues, and the source is released when the callback arrives. #include // The SDK is initialized, and room is connected. -auto pending = livekit::CaptureSource::create( - livekit::PatternVideoSourceConfig{{1280, 720}, 30, livekit::Pattern::Gradient}); +auto pending = livekit::capture::CaptureSource::create( + livekit::capture::PatternVideoSourceConfig{{1280, 720}, 30, livekit::capture::Pattern::Gradient}); // Other application setup can run while the producer is being constructed. auto capture = pending.get(); // For a wall clock, use ClockVideoSourceConfig{{1280, 720}, 30} instead. @@ -63,7 +64,7 @@ if (!participant) { } participant->publishTrack(track, capture->publishOptions()); -capture->setOnFinishedCallback([](const livekit::CaptureResult& result) { +capture->setOnFinishedCallback([](const livekit::capture::CaptureResult& result) { // Runs on the FFI callback thread. Offload work that would block. }); if (!capture->start()) { @@ -81,12 +82,12 @@ Provide `appsink name=lk_appsink`, or leave exactly one encoded video source pad unlinked so the source can attach its own sink: ```cpp -livekit::GstreamerVideoSourceConfig config; +livekit::capture::GstreamerVideoSourceConfig config; config.pipeline = "videotestsrc ! video/x-raw,width=1280,height=720,framerate=30/1 ! " "vp8enc name=lk_encoder deadline=1 ! appsink name=lk_appsink"; -config.rate_control = livekit::GstreamerRateControl{ - "lk_encoder", "target-bitrate", livekit::GstreamerBitrateUnit::Bps}; -auto capture = livekit::CaptureSource::create(config).get(); +config.rate_control = livekit::capture::GstreamerRateControl{ + "lk_encoder", "target-bitrate", livekit::capture::GstreamerBitrateUnit::Bps}; +auto capture = livekit::capture::CaptureSource::create(config).get(); ``` The optional rate-control binding forwards WebRTC bitrate targets to the named diff --git a/include/livekit/capture/capture_source.h b/include/livekit/capture/capture_source.h index e250668a..4162f4b6 100644 --- a/include/livekit/capture/capture_source.h +++ b/include/livekit/capture/capture_source.h @@ -37,10 +37,17 @@ namespace livekit { +class FfiClient; + namespace proto { class OwnedCaptureSource; } // namespace proto +} // namespace livekit + +/// @brief Capture sources and their configuration types. +namespace livekit::capture { + /// @brief Error raised when capture source creation or control fails. class LIVEKIT_API CaptureSourceError : public std::runtime_error { public: @@ -269,7 +276,7 @@ class LIVEKIT_API CaptureSource { bool stop(); private: - friend class FfiClient; + friend class livekit::FfiClient; CaptureSource(); @@ -280,4 +287,4 @@ class LIVEKIT_API CaptureSource { std::unique_ptr impl_; }; -} // namespace livekit +} // namespace livekit::capture diff --git a/include/livekit/video_source.h b/include/livekit/video_source.h index fcba21b4..ea660ecc 100644 --- a/include/livekit/video_source.h +++ b/include/livekit/video_source.h @@ -25,6 +25,10 @@ namespace livekit { +namespace capture { +class CaptureSource; +} // namespace capture + class VideoFrame; class EncodedVideoSource; @@ -97,12 +101,15 @@ class LIVEKIT_API VideoSource { private: friend class EncodedVideoSource; - friend class CaptureSource; + friend class capture::CaptureSource; enum class SourceType { Native, Encoded }; VideoSource(int width, int height, SourceType source_type); - /// Adopt an existing FFI video source handle (used by CaptureSource). + /// @brief Adopt an existing FFI video source handle for capture sources. + /// @param handle Rust-owned video source handle. + /// @param width Frame width in pixels. + /// @param height Frame height in pixels. VideoSource(FfiHandle&& handle, int width, int height) noexcept : handle_(std::move(handle)), width_(width), height_(height) {} diff --git a/src/capture_source.cpp b/src/capture_source.cpp index 07abb135..43ddd9c7 100644 --- a/src/capture_source.cpp +++ b/src/capture_source.cpp @@ -26,7 +26,7 @@ #include "ffi_client.h" #include "lk_log.h" -namespace livekit { +namespace livekit::capture { struct CaptureSource::Impl { FfiHandle handle; @@ -271,4 +271,4 @@ bool controlCapture(std::uint64_t handle, bool start) { bool CaptureSource::start() { return controlCapture(impl_->handle.get(), true); } bool CaptureSource::stop() { return controlCapture(impl_->handle.get(), false); } -} // namespace livekit +} // namespace livekit::capture diff --git a/src/capture_source_internal.h b/src/capture_source_internal.h index 08c56030..bca578fc 100644 --- a/src/capture_source_internal.h +++ b/src/capture_source_internal.h @@ -19,11 +19,11 @@ #include "livekit/capture/capture_source.h" #include "livekit/visibility.h" -namespace livekit { - -namespace proto { +namespace livekit::proto { class NewCaptureSourceRequest; -} // namespace proto +} // namespace livekit::proto + +namespace livekit::capture { /// Serialize capture configurations for the FFI request. /// Internal test seam; not part of the public SDK API. @@ -31,4 +31,4 @@ LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const GstreamerVideo LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const PatternVideoSourceConfig& config); LIVEKIT_INTERNAL_API proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config); -} // namespace livekit +} // namespace livekit::capture diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 7678d63b..c638cdb3 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -165,8 +165,8 @@ 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. + // Delivered to the owning capture::CaptureSource's listener, not to a pending + // request; see capture::CaptureSource's capture-event handler. case E::kCaptureSourceEvent: case E::kLogs: case E::kPanic: @@ -718,37 +718,37 @@ std::future FfiClient::publishTrackAsync(std::uint return fut; } -std::future> FfiClient::newCaptureSourceAsync( +std::future> FfiClient::newCaptureSourceAsync( proto::NewCaptureSourceRequest request, const std::function& send) { // Generate client-side async_id first const AsyncId async_id = generateAsyncId(); // Register the async handler BEFORE sending the request - auto fut = registerAsync>( + 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 proto::FfiEvent& event, std::promise>& pr) { const auto& cb = event.new_capture_source(); try { if (cb.has_error()) { - throw CaptureSourceError(cb.error()); + throw capture::CaptureSourceError(cb.error()); } if (!cb.has_source()) { - throw CaptureSourceError("NewCaptureSourceCallback missing source"); + throw capture::CaptureSourceError("NewCaptureSourceCallback missing source"); } // Adopt the handles before satisfying the promise. If the caller has // discarded its future, destroying the pending promise releases the // wrapper and both handles after this callback completes. - pr.set_value(CaptureSource::fromOwned(cb.source())); + pr.set_value(capture::CaptureSource::fromOwned(cb.source())); } catch (const std::exception& error) { - pr.set_exception(std::make_exception_ptr(CaptureSourceError(error.what()))); + pr.set_exception(std::make_exception_ptr(capture::CaptureSourceError(error.what()))); } catch (...) { - pr.set_exception(std::make_exception_ptr(CaptureSourceError("Capture source construction failed"))); + pr.set_exception(std::make_exception_ptr(capture::CaptureSourceError("Capture source construction failed"))); } }, - std::make_exception_ptr(CaptureSourceError("Async operation cancelled"))); + std::make_exception_ptr(capture::CaptureSourceError("Async operation cancelled"))); request.set_request_async_id(async_id); proto::FfiRequest req; diff --git a/src/ffi_client.h b/src/ffi_client.h index 7ecdabf0..82953979 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -58,7 +58,9 @@ class DataStream; struct RoomOptions; struct TrackPublishOptions; +namespace capture { class CaptureSource; +} // namespace capture using FfiCallbackFn = void (*)(const uint8_t*, size_t); extern "C" void livekit_ffi_initialize(FfiCallbackFn cb, bool capture_logs, const char* sdk, const char* sdk_version); @@ -130,7 +132,7 @@ class LIVEKIT_INTERNAL_API FfiClient { // Capture APIs // Optional request transport permits deterministic callback tests without // starting a GPU renderer or GStreamer pipeline. - std::future> newCaptureSourceAsync( + std::future> newCaptureSourceAsync( proto::NewCaptureSourceRequest request, const std::function& send = {}); std::future setLocalMetadataAsync(std::uint64_t local_participant_handle, const std::string& metadata); diff --git a/src/tests/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp index 9acf5e44..7e127314 100644 --- a/src/tests/integration/test_capture_source.cpp +++ b/src/tests/integration/test_capture_source.cpp @@ -45,17 +45,17 @@ constexpr int kCaptureWidth = 1280; constexpr int kCaptureHeight = 720; constexpr std::uint32_t kCaptureFramerateFps = 30; -std::shared_ptr createCapture(bool clock) { +std::shared_ptr createCapture(bool clock) { if (clock) { - ClockVideoSourceConfig config; + capture::ClockVideoSourceConfig config; config.resolution = {kCaptureWidth, kCaptureHeight}; config.framerate_fps = kCaptureFramerateFps; - return CaptureSource::create(config).get(); + return capture::CaptureSource::create(config).get(); } - PatternVideoSourceConfig config; + capture::PatternVideoSourceConfig config; config.resolution = {kCaptureWidth, kCaptureHeight}; config.framerate_fps = kCaptureFramerateFps; - return CaptureSource::create(config).get(); + return capture::CaptureSource::create(config).get(); } } // namespace @@ -77,7 +77,7 @@ TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { ASSERT_NE(capture, nullptr); // The pixel source reports back the resolution it was configured with. - ASSERT_EQ(capture->kind(), CaptureSourceKind::Pixel); + ASSERT_EQ(capture->kind(), capture::CaptureSourceKind::Pixel); ASSERT_EQ(capture->width(), kCaptureWidth); ASSERT_EQ(capture->height(), kCaptureHeight); ASSERT_FALSE(capture->codec().has_value()); @@ -121,8 +121,8 @@ TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { ASSERT_NO_THROW(lockLocalParticipant(sender_room)->publishTrack(track, capture->publishOptions(app_options))); // Observe the capture's terminal event. - std::optional finished; - capture->setOnFinishedCallback([&mutex, &cv, &finished](const CaptureResult& result) { + std::optional finished; + capture->setOnFinishedCallback([&mutex, &cv, &finished](const capture::CaptureResult& result) { std::lock_guard lock(mutex); finished = result; cv.notify_all(); @@ -148,7 +148,7 @@ TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { } ASSERT_FALSE(finished->error.has_value()) << *finished->error; - EXPECT_EQ(finished->exit, CaptureExit::Stopped); + EXPECT_EQ(finished->exit, capture::CaptureExit::Stopped); EXPECT_GT(finished->frames_captured, 0u); const auto capture_seconds = std::chrono::duration(std::chrono::steady_clock::now() - capture_started).count(); @@ -162,6 +162,8 @@ TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { } INSTANTIATE_TEST_SUITE_P(PixelSources, CaptureSourceServerTest, ::testing::Bool(), - [](const ::testing::TestParamInfo& info) { return info.param ? "Clock" : "Pattern"; }); + [](const ::testing::TestParamInfo& info) { + return info.param ? "Clock" : "capture::Pattern"; + }); } // namespace livekit::test diff --git a/src/tests/packaging/test_capture_bundle.cpp b/src/tests/packaging/test_capture_bundle.cpp index 84e9103b..83eb7d66 100644 --- a/src/tests/packaging/test_capture_bundle.cpp +++ b/src/tests/packaging/test_capture_bundle.cpp @@ -27,9 +27,9 @@ namespace { // Strict GPU checks require successful creation of enabled sources. Omitted // backends must reject a valid configuration with the feature-disabled error. template -bool checkSource(std::string_view name, const Config& config, bool expected, livekit::CaptureSourceKind kind) { +bool checkSource(std::string_view name, const Config& config, bool expected, livekit::capture::CaptureSourceKind kind) { try { - auto future = livekit::CaptureSource::create(config); + auto future = livekit::capture::CaptureSource::create(config); if (future.wait_for(std::chrono::seconds(15)) != std::future_status::ready) { std::cerr << name << ": source creation timed out\n"; return false; @@ -37,12 +37,12 @@ bool checkSource(std::string_view name, const Config& config, bool expected, liv auto source = future.get(); if (!expected || !source || source->kind() != kind || source->width() != 320 || source->height() != 240 || !source->videoSource() || - (kind == livekit::CaptureSourceKind::Encoded && source->codec() != livekit::VideoCodec::VP8)) { + (kind == livekit::capture::CaptureSourceKind::Encoded && source->codec() != livekit::VideoCodec::VP8)) { std::cerr << name << ": unexpected source availability or metadata\n"; return false; } return true; - } catch (const livekit::CaptureSourceError& error) { + } catch (const livekit::capture::CaptureSourceError& error) { constexpr std::string_view disabled = "capture source is not enabled in this build"; const std::string_view message(error.what()); if (!expected && message.find(disabled) != std::string_view::npos) { @@ -51,7 +51,7 @@ bool checkSource(std::string_view name, const Config& config, bool expected, liv // Runners without an adapter can still prove that the selected backend // reached its renderer. All other errors fail; strict GPU jobs also fail // on a missing adapter. - if (expected && kind == livekit::CaptureSourceKind::Pixel && !LIVEKIT_VERIFY_PIXEL_SOURCES && + if (expected && kind == livekit::capture::CaptureSourceKind::Pixel && !LIVEKIT_VERIFY_PIXEL_SOURCES && message.find("no compatible GPU adapter") != std::string_view::npos) { std::cout << name << ": creation not exercised (no GPU adapter); backend availability verified\n"; return true; @@ -65,22 +65,24 @@ bool checkSource(std::string_view name, const Config& config, bool expected, liv } bool checkSources() { - livekit::GstreamerVideoSourceConfig gstreamer; + livekit::capture::GstreamerVideoSourceConfig gstreamer; gstreamer.pipeline = "videotestsrc num-buffers=4 ! video/x-raw,width=320,height=240,framerate=30/1 ! " "vp8enc deadline=1 ! appsink name=lk_appsink"; bool success = - checkSource("gstreamer", gstreamer, LIVEKIT_EXPECT_GSTREAMER != 0, livekit::CaptureSourceKind::Encoded); + checkSource("gstreamer", gstreamer, LIVEKIT_EXPECT_GSTREAMER != 0, livekit::capture::CaptureSourceKind::Encoded); - livekit::PatternVideoSourceConfig pattern; + livekit::capture::PatternVideoSourceConfig pattern; pattern.resolution = {320, 240}; pattern.framerate_fps = 30; - success = checkSource("pattern", pattern, LIVEKIT_EXPECT_PATTERN != 0, livekit::CaptureSourceKind::Pixel) && success; + success = checkSource("pattern", pattern, LIVEKIT_EXPECT_PATTERN != 0, livekit::capture::CaptureSourceKind::Pixel) && + success; - livekit::ClockVideoSourceConfig clock; + livekit::capture::ClockVideoSourceConfig clock; clock.resolution = {320, 240}; clock.framerate_fps = 30; - success = checkSource("clock", clock, LIVEKIT_EXPECT_CLOCK != 0, livekit::CaptureSourceKind::Pixel) && success; + success = + checkSource("clock", clock, LIVEKIT_EXPECT_CLOCK != 0, livekit::capture::CaptureSourceKind::Pixel) && success; return success; } diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp index 66543a99..e5f730ee 100644 --- a/src/tests/unit/test_capture_source.cpp +++ b/src/tests/unit/test_capture_source.cpp @@ -93,7 +93,7 @@ class CaptureSourceCallbackTest : public ::testing::Test { void SetUp() override { ASSERT_TRUE(FfiClient::instance().initialize(false)); } void TearDown() override { FfiClient::instance().shutdown(); } - std::future> beginCreation() { + std::future> beginCreation() { return FfiClient::instance().newCaptureSourceAsync({}, [this](const proto::FfiRequest& request) { async_id_ = request.new_capture_source().request_async_id(); proto::FfiResponse response; @@ -102,7 +102,7 @@ class CaptureSourceCallbackTest : public ::testing::Test { }); } - std::shared_ptr completeCreation(const proto::OwnedCaptureSource& owned) { + std::shared_ptr completeCreation(const proto::OwnedCaptureSource& owned) { auto future = beginCreation(); emitFfiEvent(creationEvent(async_id_, owned)); return future.get(); @@ -131,11 +131,11 @@ TEST_F(CaptureSourceCallbackTest, AbandonedFutureDoesNotWaitForCallbackAndReleas TEST_F(CaptureSourceCallbackTest, CallbackReturnsWrapperAndOwnsVideoSource) { auto source = completeCreation(makeOwned()); ASSERT_NE(source, nullptr); - EXPECT_EQ(source->kind(), CaptureSourceKind::Pixel); + EXPECT_EQ(source->kind(), capture::CaptureSourceKind::Pixel); EXPECT_EQ(source->width(), 1280); EXPECT_EQ(source->height(), 720); EXPECT_EQ(FfiClientTestAccess::listenerCount(), 1u); - std::weak_ptr weak_source = source; + std::weak_ptr weak_source = source; std::weak_ptr weak_video = source->videoSource(); source.reset(); EXPECT_TRUE(weak_source.expired()); @@ -165,7 +165,7 @@ TEST_F(CaptureSourceCallbackTest, AbandonedAndInvalidResultsReleaseBothRustHandl static_cast(video_guard.release()); emitFfiEvent(creationEvent(async_id_, owned)); if (invalid) { - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); } // A second drop must fail. If ownership leaked, this also cleans it up. EXPECT_FALSE(dropFfiHandle(capture.handle().id())); @@ -192,7 +192,7 @@ TEST_F(CaptureSourceCallbackTest, CreationErrorUsesCaptureSourceError) { event.mutable_new_capture_source()->set_async_id(async_id_); event.mutable_new_capture_source()->set_error("discovery failed"); emitFfiEvent(event); - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); } TEST_F(CaptureSourceCallbackTest, UnrelatedCallbackDoesNotCompleteFuture) { @@ -211,14 +211,14 @@ TEST_F(CaptureSourceCallbackTest, MissingSourceUsesCaptureSourceError) { proto::FfiEvent event; event.mutable_new_capture_source()->set_async_id(async_id_); emitFfiEvent(event); - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); } TEST_F(CaptureSourceCallbackTest, ShutdownCancelsPendingFutureWithCaptureSourceError) { auto future = beginCreation(); FfiClient::instance().shutdown(); EXPECT_EQ(future.wait_for(std::chrono::seconds(0)), std::future_status::ready); - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); } TEST_F(CaptureSourceCallbackTest, SendFailureRemovesPendingOperation) { @@ -300,7 +300,7 @@ TEST_F(CaptureSourceCallbackTest, RecommendationsCannotReplaceOtherApplicationOp } TEST(CaptureSourceProtoTest, ClockConfigurationUsesMainlineWireContract) { - ClockVideoSourceConfig config; + capture::ClockVideoSourceConfig config; config.resolution = {1920, 1080}; config.framerate_fps = 30; @@ -315,52 +315,53 @@ TEST(CaptureSourceProtoTest, ClockConfigurationUsesMainlineWireContract) { } TEST(CaptureSourceProtoTest, RejectsNonPositiveDimensionsBeforeUnsignedConversion) { - for (const CaptureResolution resolution : {CaptureResolution{0, 720}, CaptureResolution{-1, 720}, - CaptureResolution{1280, 0}, CaptureResolution{1280, -1}}) { - PatternVideoSourceConfig pattern; + for (const capture::CaptureResolution resolution : + {capture::CaptureResolution{0, 720}, capture::CaptureResolution{-1, 720}, capture::CaptureResolution{1280, 0}, + capture::CaptureResolution{1280, -1}}) { + capture::PatternVideoSourceConfig pattern; pattern.resolution = resolution; pattern.framerate_fps = 30; - EXPECT_THROW(toProto(pattern), CaptureSourceError); - ClockVideoSourceConfig clock; + EXPECT_THROW(toProto(pattern), capture::CaptureSourceError); + capture::ClockVideoSourceConfig clock; clock.resolution = resolution; clock.framerate_fps = 30; - EXPECT_THROW(toProto(clock), CaptureSourceError); - GstreamerVideoSourceConfig gstreamer; + EXPECT_THROW(toProto(clock), capture::CaptureSourceError); + capture::GstreamerVideoSourceConfig gstreamer; gstreamer.resolution = resolution; - EXPECT_THROW(toProto(gstreamer), CaptureSourceError); + EXPECT_THROW(toProto(gstreamer), capture::CaptureSourceError); } } TEST(CaptureSourceProtoTest, RejectsZeroFrameRate) { - PatternVideoSourceConfig pattern; + capture::PatternVideoSourceConfig pattern; pattern.resolution = {1280, 720}; - EXPECT_THROW(toProto(pattern), CaptureSourceError); - ClockVideoSourceConfig clock; + EXPECT_THROW(toProto(pattern), capture::CaptureSourceError); + capture::ClockVideoSourceConfig clock; clock.resolution = {1280, 720}; - EXPECT_THROW(toProto(clock), CaptureSourceError); + EXPECT_THROW(toProto(clock), capture::CaptureSourceError); } TEST(CaptureSourceProtoTest, ValidationErrorsAreDeliveredThroughFuture) { - std::future> future; - EXPECT_NO_THROW(future = CaptureSource::create(ClockVideoSourceConfig{})); + std::future> future; + EXPECT_NO_THROW(future = capture::CaptureSource::create(capture::ClockVideoSourceConfig{})); ASSERT_TRUE(future.valid()); - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); } TEST(CaptureSourceProtoTest, SendErrorsAreDeliveredThroughFuture) { FfiClient::instance().shutdown(); - ClockVideoSourceConfig config; + capture::ClockVideoSourceConfig config; config.resolution = {1280, 720}; config.framerate_fps = 30; - std::future> future; - EXPECT_NO_THROW(future = CaptureSource::create(config)); + std::future> future; + EXPECT_NO_THROW(future = capture::CaptureSource::create(config)); ASSERT_TRUE(future.valid()); - EXPECT_THROW(static_cast(future.get()), CaptureSourceError); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); EXPECT_EQ(FfiClientTestAccess::pendingCount(), 0u); } TEST(CaptureSourceProtoTest, GstreamerCanDiscoverResolution) { - GstreamerVideoSourceConfig config; + capture::GstreamerVideoSourceConfig config; config.pipeline = "videotestsrc ! vp8enc ! appsink name=lk_appsink"; const auto request = toProto(config); EXPECT_TRUE(request.IsInitialized());