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/build-sdk/action.yml b/.github/actions/build-sdk/action.yml new file mode 100644 index 00000000..e74374f6 --- /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: Capture sources (ON/all, OFF/none, or gstreamer,pattern,clock). + 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|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 + 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 ([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) } + 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-symbols/action.yml b/.github/actions/check-sdk-symbols/action.yml new file mode 100644 index 00000000..ecc6bbc9 --- /dev/null +++ b/.github/actions/check-sdk-symbols/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 symbols +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/setup-capture/action.yml b/.github/actions/setup-capture/action.yml new file mode 100644 index 00000000..1c64e6b7 --- /dev/null +++ b/.github/actions/setup-capture/action.yml @@ -0,0 +1,86 @@ +name: Set up capture dependencies +description: Install additional build and runtime dependencies for capture-enabled SDK builds. +runs: + using: composite + steps: + - name: Install GStreamer (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + # 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) + if: runner.os == 'macOS' + env: + GSTREAMER_MACOS_VERSION: "1.28.5" + GSTREAMER_MACOS_RUNTIME_SHA256: "0a8fc7a1cf8d7bac833ca0ebe2fd196a199c2465e810cd5b1e4b4f720c258f43" + GSTREAMER_MACOS_DEVEL_SHA256: "6f7b55e8fb86dcc615c9cae46b79b7785851e5c77f79a938648a81dfa2603729" + shell: bash + 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: + 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/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/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-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 new file mode 100644 index 00000000..f649847e --- /dev/null +++ b/.github/actions/test-sdk-bundle/action.yml @@ -0,0 +1,84 @@ +# 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). + 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 + 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_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="${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_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) + if: runner.os == 'Windows' + shell: pwsh + 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 + $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 } + $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/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..9c4f7e26 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,36 +32,25 @@ 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_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_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_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_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_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_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 --bundle --prefix sdk-out\livekit-sdk-windows-x64 - build_dir: build-release name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} + env: + BUILD_DIR: build-release steps: - name: Checkout (with submodules) @@ -72,6 +59,9 @@ jobs: submodules: recursive fetch-depth: 1 + - name: Set Cargo output root + uses: ./.github/actions/cargo-target-root + - name: Pull LFS files run: git lfs pull @@ -100,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) @@ -172,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 @@ -181,17 +138,18 @@ 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 }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target- + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + 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 - 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 @@ -204,17 +162,73 @@ 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 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 || '' }} - - name: Build (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: ${{ matrix.build_cmd }} + # ---------- 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 + 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 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: livekit-sdk-${{ matrix.name }} + 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 + 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 + capture: 'ON' + bundle-dir: sdk-out/livekit-sdk-${{ matrix.name }}-capture + 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 + capture: 'ON' + 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 + 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 @@ -224,8 +238,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-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' @@ -234,125 +250,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') }} - # ---------- 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 }}" - 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" - - # ---------- Upload artifacts ---------- - - name: Upload SDK bundle - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: livekit-sdk-${{ matrix.name }} - path: sdk-out/livekit-sdk-${{ matrix.name }} - retention-days: 7 - # ---------- Cleanup ---------- - name: Clean after build (best-effort) if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c20ff88..896c69c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,17 @@ jobs: - vcpkg.json - .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/** + - .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: - src/** - include/** @@ -65,6 +76,13 @@ 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/** + - .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 ca085dd9..529c64c1 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" @@ -26,26 +29,20 @@ 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 }} @@ -57,6 +54,9 @@ jobs: submodules: recursive fetch-depth: 0 + - name: Set Cargo output root + uses: ./.github/actions/cargo-target-root + # ---------- Extract version ---------- - name: Extract version id: version @@ -88,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" @@ -100,45 +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 base SDK dependencies + uses: ./.github/actions/setup-sdk-deps # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -171,23 +138,26 @@ 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 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 }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target- + path: | + client-sdk-rust/target/ + ${{ env.CARGO_TARGET_DIR }} + 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 - 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 @@ -199,68 +169,15 @@ 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: | - 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 - - if [[ "${{ matrix.name }}" == "macos-x64" ]]; then - ./build.sh release-examples --version "$version" --bundle --prefix "$bundleDir" --macos-arch x86_64 - else - ./build.sh release-examples --version "$version" --bundle --prefix "$bundleDir" - fi - - # List bundle contents - echo "Bundle contents:" - find "$bundleDir" -type f | head -120 - - - name: Validate bundle version metadata (Unix) - if: runner.os != 'Windows' - shell: bash - 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" - - # ---------- 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" } - .\build.cmd release-examples --version "$version" --bundle --prefix "$bundleDir" - - Write-Host "Bundle contents:" - Get-ChildItem -Recurse -File $bundleDir | Select-Object -First 200 | ForEach-Object { $_.FullName } + # ---------- 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 || '' }} # ---------- Verify exported ABI: no third-party symbol leaks ---------- - name: Setup Python @@ -268,75 +185,47 @@ jobs: with: python-version: '3.x' - - name: Symbol leak check (Unix) - 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 - 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}" + - name: Symbol leak check (standard SDK) + uses: ./.github/actions/check-sdk-symbols + 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: | - $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 - } - python .github/scripts/check_no_private_symbols.py "$lib" + - 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: 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 + 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) + 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: 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" - } + - 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 }} + capture: 'ON' + macos-arch: ${{ matrix.macos_arch || '' }} + version: ${{ steps.version.outputs.version }} # ---------- Upload artifact (raw directory, no pre-compression) ---------- - name: Upload build artifact @@ -345,6 +234,13 @@ 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 + if: matrix.name != 'macos-x64' + 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 @@ -412,23 +308,24 @@ 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 + # 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}" 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/.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 efeaf448..878b40df 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 @@ -118,10 +128,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 @@ -147,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 @@ -161,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 }}) @@ -175,6 +186,9 @@ jobs: submodules: recursive fetch-depth: 1 + - name: Set Cargo output root + uses: ./.github/actions/cargo-target-root + - name: Pull LFS files run: git lfs pull @@ -203,46 +217,25 @@ 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 }} + vcpkg-commit: ${{ env.VCPKG_GIT_COMMIT }} + linux-extra-packages: libunwind-dev libjpeg-dev gdb jq ${{ matrix.selective_capture && 'mesa-vulkan-drivers libvulkan1' || '' }} - # ---------- 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 \ - 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' + - name: Configure software Vulkan for capture source checks + if: matrix.selective_capture 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 + 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) @@ -275,23 +268,26 @@ 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 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 }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.name }}-cargo-target- + 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 }} - # 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 - 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 @@ -316,23 +312,53 @@ jobs: sudo sysctl -w "kern.corefile=${RUNNER_TEMP}/livekit-core.%P" fi - # ---------- Build (tests on, examples off) ---------- - - name: Build tests (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - chmod +x build.sh - build_cmd=(./build.sh "${{ inputs.build_type }}-tests") - if [[ -n "${{ matrix.macos_arch || '' }}" ]]; then - build_cmd+=(--macos-arch "${{ matrix.macos_arch }}") - fi - "${build_cmd[@]}" + # 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: + preset: ${{ inputs.build_type }} + capture: 'OFF' + bundle-dir: sdk-out/test-sdk-standard + macos-arch: ${{ matrix.macos_arch || '' }} - - name: Build tests (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: .\build.cmd ${{ inputs.build_type }}-tests + - 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 selective capture bundles + 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 }} + 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.name != 'macos-x64' && !(matrix.selective_capture || (inputs.extended_capture_sources && matrix.extended_capture)) }} + uses: ./.github/actions/setup-capture + + - name: Build SDK with tests + uses: ./.github/actions/build-sdk + with: + preset: ${{ inputs.build_type }}-tests + capture: ${{ matrix.name == 'macos-x64' && 'OFF' || 'ON' }} + bundle-dir: sdk-out/test-sdk-tests + macos-arch: ${{ matrix.macos_arch || '' }} + + - name: Test installed SDK used by tests + uses: ./.github/actions/test-sdk-bundle + with: + 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' }} # ---------- Run unit tests ---------- - name: Run unit tests (Unix) @@ -450,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() @@ -501,30 +538,17 @@ 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 - pip install --break-system-packages gcovr - - # 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 + - 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 # ---------- Rust toolchain ---------- - name: Install Rust (stable) @@ -570,6 +594,7 @@ jobs: - name: Configure (linux-debug-tests preset + coverage flags) run: | cmake --preset linux-debug-tests \ + -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 58985b83..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 | @@ -94,6 +95,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. | +| `src/tests/packaging/` | Standalone CTest integration tests consuming installed SDK bundles | +| `.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 @@ -126,6 +129,19 @@ 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`. +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; +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. + 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 +354,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 @@ -384,11 +401,18 @@ 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 - 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. + 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 1fa34d15..76ac9b76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,12 @@ include(warnings) option(LIVEKIT_BUILD_EXAMPLES "Build LiveKit examples" OFF) option(LIVEKIT_BUILD_TESTS "Build LiveKit tests" 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) @@ -162,11 +168,51 @@ 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) +# Map LIVEKIT_CAPTURE to livekit-ffi Cargo features (sorted for stable hashing). +set(LIVEKIT_CARGO_FEATURES "") +set(LIVEKIT_CAPTURE_ENABLED 0) +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") find_package(Git QUIET) @@ -225,10 +271,22 @@ 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) +# 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 "${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++. @@ -245,59 +303,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 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}") @@ -353,6 +369,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" @@ -378,9 +398,11 @@ add_custom_command( -DPROTOC_PATH=${Protobuf_PROTOC_EXECUTABLE} -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 ) @@ -396,6 +418,7 @@ include(uniffi_cpp) add_library(livekit SHARED src/audio_frame.cpp src/audio_processing_module.cpp + src/capture_source.cpp src/audio_source.cpp src/audio_stream.cpp src/data_track_frame.cpp @@ -480,6 +503,7 @@ set_source_files_properties( target_include_directories(livekit PUBLIC + $ $ $ PRIVATE @@ -520,28 +544,26 @@ 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") # 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" ) @@ -549,18 +571,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" ) @@ -588,12 +608,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" ) @@ -777,11 +795,21 @@ 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 + 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) ---- @@ -831,7 +859,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 @@ -843,7 +871,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 eaa05455..a300698b 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -6,6 +6,20 @@ "patch": 0 }, "configurePresets": [ + { + "name": "sdk-defaults", + "hidden": true, + "cacheVariables": { + "LIVEKIT_CAPTURE": "" + } + }, + { + "name": "tests", + "hidden": true, + "cacheVariables": { + "LIVEKIT_BUILD_TESTS": "ON" + } + }, { "name": "vcpkg", "hidden": true, @@ -23,14 +37,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" + "VCPKG_TARGET_TRIPLET": "x64-windows-static-md" } }, { @@ -45,7 +62,10 @@ "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" - } + }, + "inherits": [ + "sdk-defaults" + ] }, { "name": "macos-base", @@ -59,7 +79,10 @@ "type": "equals", "lhs": "${hostSystemName}", "rhs": "Darwin" - } + }, + "inherits": [ + "sdk-defaults" + ] }, { "name": "windows-release", @@ -209,144 +232,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": [ + "tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "windows-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "linux-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "ON", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-release", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "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": [ + "tests", + "macos-base" + ], "binaryDir": "${sourceDir}/build-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "LIVEKIT_BUILD_EXAMPLES": "OFF", - "LIVEKIT_BUILD_TESTS": "ON" + "LIVEKIT_BUILD_EXAMPLES": "OFF" } } ], diff --git a/build.cmd b/build.cmd index eb38aade..3d071173 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=none" set "BUILD_PARALLEL_JOBS=" set "DO_BUNDLE=" set "PREFIX=" @@ -89,6 +90,27 @@ 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=all" + shift + goto parse_all +) +if /I "%~1"=="--no-capture" ( + set "CAPTURE_OVERRIDE=none" + shift + goto parse_all +) + :: 3. Install the SDK bundle after a successful build. if /I "%~1"=="--bundle" ( set "DO_BUNDLE=1" @@ -132,6 +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_CAPTURE=%CAPTURE_OVERRIDE%" goto dispatch :dispatch @@ -215,6 +238,9 @@ echo clean-all Full clean (build dirs + local-install + Rust targets) echo help Show this help echo. echo Options (for debug / release): +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^) echo. diff --git a/build.h.in b/build.h.in index 68d8e814..f4c508d3 100644 --- a/build.h.in +++ b/build.h.in @@ -6,16 +6,19 @@ // Version injected by CMake (CI should set it). Defaults to 0.0.0 for dev builds. #define LIVEKIT_BUILD_VERSION "@LIVEKIT_VERSION@" -#define LIVEKIT_BUILD_FLAVOR "cpp" +#define LIVEKIT_BUILD_FLAVOR "cpp" + +/// @brief Whether the Rust FFI was built with any capture source (0 or 1). +#define LIVEKIT_CAPTURE_ENABLED @LIVEKIT_CAPTURE_ENABLED@ #ifdef DEBUG -#define LIVEKIT_BUILD_SUFFIX "-debug" +#define LIVEKIT_BUILD_SUFFIX "-debug" #else -#define LIVEKIT_BUILD_SUFFIX "-release" +#define LIVEKIT_BUILD_SUFFIX "-release" #endif // Follow params are auto generated by CMakeLists.txt -#define LIVEKIT_BUILD_DATE "@BUILD_DATE@" +#define LIVEKIT_BUILD_DATE "@BUILD_DATE@" #define LIVEKIT_BUILD_COMMIT "@GIT_COMMIT@" -#define LIVEKIT_BUILD_VERSION_FULL LIVEKIT_BUILD_VERSION LIVEKIT_BUILD_SUFFIX \ No newline at end of file +#define LIVEKIT_BUILD_VERSION_FULL LIVEKIT_BUILD_VERSION LIVEKIT_BUILD_SUFFIX diff --git a/build.sh b/build.sh index 48cb27f1..170b0f83 100755 --- a/build.sh +++ b/build.sh @@ -14,6 +14,7 @@ ARCHIVE_NAME="" GENERATOR="" MACOS_ARCH="" LIVEKIT_VERSION="" +CAPTURE_OVERRIDE="none" # Detect OS for preset selection detect_os() { @@ -46,6 +47,9 @@ Commands: help Show this help message Options (for debug / release): + --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 (default: ./sdk-out/livekit-sdk) @@ -75,6 +79,22 @@ parse_opts() { shift || true while [[ $# -gt 0 ]]; do case "$1" in + --capture) + 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="none" + shift + ;; --bundle) DO_BUNDLE="1" shift @@ -135,7 +155,7 @@ parse_opts() { configure() { echo "==> Configuring CMake (${BUILD_TYPE}) using preset ${PRESET}..." - local -a extra_args=() + 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/client-sdk-rust b/client-sdk-rust index a6f93a16..49b3ebe9 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit a6f93a16fa71fe811d040de175e070e233d9b1fe +Subproject commit 49b3ebe9ab1c574114b164868ad226cc26a9b1be 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..f5d5f1a5 100644 --- a/cmake/uniffi_cpp.cmake +++ b/cmake/uniffi_cpp.cmake @@ -26,23 +26,11 @@ 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" -) - file(GLOB_RECURSE LIVEKIT_UNIFFI_BINDGEN_SOURCES CONFIGURE_DEPENDS "${RUST_ROOT}/tools/bindgens/src/*.rs" "${RUST_ROOT}/tools/bindgens/Cargo.toml" @@ -58,9 +46,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 ${RUST_SOURCES} "${RUN_CARGO_SCRIPT}" COMMENT "Building unstripped livekit-ffi metadata library" VERBATIM ) @@ -95,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/README.md b/docs/README.md index d4f68c8b..17158b5b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,11 @@ 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. - [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..a5bcdc7f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -23,6 +23,54 @@ 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 + +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). +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`. + +For GStreamer builds, 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) @@ -201,6 +249,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_CAPTURE` | empty (`none`) | Capture sources for the Rust FFI (`all`, `none`, or a comma-separated list; see [Capture sources](#capture-sources)) | ## Build output @@ -348,6 +397,75 @@ 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 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=` +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 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`. +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_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`. +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. + +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. + +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 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. + +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..4c0e5dba --- /dev/null +++ b/docs/capture-sources.md @@ -0,0 +1,99 @@ +# Capture sources + +> **NOTE:** LiveKit Capture is not officially supported on Intel-based Macs. + +`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: + +| 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 | + +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 + +For build flags, release bundle naming, GStreamer prerequisites, and runtime +requirements, see [Building — Capture sources](building.md#capture-sources). + +Capture types live in the `livekit::capture` namespace. Capture-enabled bundles +install the 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. +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 + +// The SDK is initialized, and room is connected. +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. + +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::capture::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::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::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 +encoder property. Without it, the pipeline uses its configured fixed bitrate. +Use `capture->publishOptions()` when publishing: encoded sources require the +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/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 33919c0e..c90788a0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -98,6 +98,77 @@ 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 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 `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 +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. + +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. + +### 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 +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/include/livekit/capture/capture_source.h b/include/livekit/capture/capture_source.h new file mode 100644 index 00000000..4162f4b6 --- /dev/null +++ b/include/livekit/capture/capture_source.h @@ -0,0 +1,290 @@ +/* + * 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 "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 +#include +#include +#include +#include + +#include "livekit/room_event_types.h" +#include "livekit/video_source.h" +#include "livekit/visibility.h" + +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: + /// @brief Create a capture source error. + /// + /// @param message Human-readable error message. + explicit CaptureSourceError(const std::string& message) : std::runtime_error(message) {} +}; + +/// @brief Kind of media a capture source produces. +enum class CaptureSourceKind { + /// @brief Pixel frames, published through the WebRTC encoder. + Pixel = 0, + /// @brief Pre-encoded access units, published as passthrough. + Encoded = 1, +}; + +/// @brief Bitrate unit expected by a GStreamer encoder property. +enum class GstreamerBitrateUnit { + /// @brief Bits per second. + Bps = 0, + /// @brief Kilobits per second. + Kbps = 1, +}; + +/// @brief Video resolution in pixels. +struct CaptureResolution { + int width = 0; + int height = 0; +}; + +/// @brief Binding from WebRTC rate-control targets to a GStreamer encoder property. +struct GstreamerRateControl { + /// @brief Name of the encoder element in the pipeline (e.g. `lk_encoder`). + std::string element; + + /// @brief Bitrate property to set on the element (e.g. `bitrate` for x264enc, + /// `target-bitrate` for vp8enc/vp9enc). + std::string property; + + /// @brief Unit the property expects. + GstreamerBitrateUnit unit = GstreamerBitrateUnit::Bps; +}; + +/// @brief Configuration for encoded ingest from a GStreamer pipeline. +struct GstreamerVideoSourceConfig { + /// @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; + + /// @brief Codec expected from the pipeline; inferred from pipeline caps when + /// omitted. + std::optional codec; + + /// @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; + + /// @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; +}; + +/// @brief Test pattern rendered by the built-in pattern source. +enum class Pattern { + /// @brief Animated color gradient. + Gradient = 0, + /// @brief Bouncing LiveKit logo. + Logo = 1, +}; + +/// Configuration for the built-in test source rendering a @ref Pattern. +struct PatternVideoSourceConfig { + /// @brief Output resolution; both components must be positive. + CaptureResolution resolution; + + /// @brief Output frame rate in frames per second; must be non-zero. + std::uint32_t framerate_fps = 0; + + /// @brief Pattern to render. + Pattern pattern = Pattern::Gradient; +}; + +/// @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; + + /// @brief Output frame rate in frames per second; must be non-zero. + std::uint32_t framerate_fps = 0; +}; + +/// @brief Why a capture ended without error. +enum class CaptureExit { + /// Stopped via @ref CaptureSource::stop (or source destruction). + Stopped = 0, + /// @brief The producer reached the end of its stream. + EndOfStream = 1, +}; + +/// @brief Terminal result of a started capture. +struct CaptureResult { + /// @brief Error message when the capture failed; empty on success. + std::optional error; + + /// @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; +}; + +/// @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 +/// 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 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. +class LIVEKIT_API CaptureSource { +public: + /// @brief Terminal notification for a started capture, invoked exactly once on + /// the FFI event thread (like room delegate callbacks). + using FinishedCallback = std::function; + + /// @brief Create 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. + /// 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. + /// @throws CaptureSourceError When awaiting the future if creation fails. + static std::future> create(const GstreamerVideoSourceConfig& config); + + /// @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. + /// 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. + /// @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. + /// 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. + /// @throws CaptureSourceError When awaiting the future if creation fails. + static std::future> create(ClockVideoSourceConfig config); + + ~CaptureSource(); + + CaptureSource(const CaptureSource&) = delete; + CaptureSource& operator=(const CaptureSource&) = delete; + CaptureSource(CaptureSource&&) = delete; + CaptureSource& operator=(CaptureSource&&) = delete; + + /// @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; + + /// @brief Declared or discovered stream height. + /// @return Height in pixels. + int height() const noexcept; + + /// @brief Codec produced by the source; encoded sources only. + /// @return The codec, or no value for a pixel source. + std::optional codec() const noexcept; + + /// @brief RTC video source fed by this capture source; use it with + /// LocalVideoTrack::createLocalVideoTrack(). + /// @return The RTC video source. + std::shared_ptr videoSource() const noexcept; + + /// @brief Returns publish options for this track, applying application options. + /// + /// 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; + + /// @brief Set the terminal notification. Set this before @ref start(). + /// @param callback Terminal notification, called on the FFI callback thread. + void setOnFinishedCallback(FinishedCallback callback); + + /// @brief Start pumping frames into the RTC video source. + /// + /// @return @c true if the start request succeeds; otherwise @c false. + bool start(); + + /// @brief Signal a running capture to stop after the frame in flight. The + /// finished callback fires shortly after. Stopping an already-finished + /// capture is a no-op. + /// + /// @return @c true if the stop request succeeds; otherwise @c false. + bool stop(); + +private: + friend class livekit::FfiClient; + + CaptureSource(); + + /// @brief Builds the wrapper from the callback payload, adopting its handles. + static std::shared_ptr fromOwned(const proto::OwnedCaptureSource& owned); + + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace livekit::capture diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index 9b22b7e9..ed9da5c2 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -21,6 +21,9 @@ #include "livekit/audio_source.h" #include "livekit/audio_stream.h" #include "livekit/build.h" +#if LIVEKIT_CAPTURE_ENABLED +#include "livekit/capture/capture_source.h" +#endif #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..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,10 +101,18 @@ class LIVEKIT_API VideoSource { private: friend class EncodedVideoSource; + friend class capture::CaptureSource; enum class SourceType { Native, Encoded }; VideoSource(int width, int height, SourceType source_type); + /// @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) {} + FfiHandle handle_; // owned FFI handle int width_{0}; int height_{0}; diff --git a/scripts/generate-docs.sh b/scripts/generate-docs.sh index 60ba5588..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 new file mode 100644 index 00000000..43ddd9c7 --- /dev/null +++ b/src/capture_source.cpp @@ -0,0 +1,274 @@ +/* + * 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/capture_source.h" + +#include +#include +#include + +#include "capture.pb.h" +#include "capture_source_internal.h" +#include "ffi.pb.h" +#include "ffi_client.h" +#include "lk_log.h" + +namespace livekit::capture { + +struct CaptureSource::Impl { + FfiHandle handle; + CaptureSourceKind kind = CaptureSourceKind::Pixel; + int width = 0; + int height = 0; + std::optional codec; + std::shared_ptr video_source; + 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 { + +// Report validation and synchronous send failures through the same future as +// asynchronous creation errors. All public factories share this path. +template +std::future> createCaptureSource(const 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) { + if (resolution.width <= 0 || resolution.height <= 0) { + throw CaptureSourceError("Capture resolution dimensions must be positive"); + } +} + +void validatePixelConfig(const CaptureResolution& resolution, std::uint32_t framerate_fps) { + validateResolution(resolution); + if (framerate_fps == 0) { + throw CaptureSourceError("Capture frame rate must be positive"); + } +} + +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: + 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::future> CaptureSource::create(const GstreamerVideoSourceConfig& config) { + return createCaptureSource(config); +} + +proto::NewCaptureSourceRequest toProto(const GstreamerVideoSourceConfig& config) { + if (config.resolution) { + validateResolution(*config.resolution); + } + 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 request; +} + +std::future> CaptureSource::create(PatternVideoSourceConfig config) { + return createCaptureSource(config); +} + +proto::NewCaptureSourceRequest toProto(const PatternVideoSourceConfig& config) { + proto::NewCaptureSourceRequest request; + auto* pattern = request.mutable_pattern(); + copyPixelConfig(*pattern, config); + pattern->set_pattern(toProto(config.pattern)); + return request; +} + +std::future> CaptureSource::create(ClockVideoSourceConfig config) { + return createCaptureSource(config); +} + +proto::NewCaptureSourceRequest toProto(const ClockVideoSourceConfig& config) { + proto::NewCaptureSourceRequest request; + copyPixelConfig(*request.mutable_clock(), config); + return request; +} + +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 = 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()); + source->impl_->height = static_cast(info.resolution().height()); + if (info.has_codec()) { + source->impl_->codec = static_cast(info.codec()); + } + 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 + // 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->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->impl_->callback_mutex); + callback = raw->impl_->on_finished; + } + if (callback) { + callback(result); + } + }); + + return source; +} + +TrackPublishOptions CaptureSource::publishOptions(TrackPublishOptions options) const { + if (impl_->kind == CaptureSourceKind::Encoded) { + options.video_codec = impl_->codec; + options.video_encoder = VideoEncoderBackend::PreEncoded; + options.simulcast = false; + } + return options; +} + +CaptureSource::~CaptureSource() { + if (impl_->listener_id != 0) { + FfiClient::instance().removeListener(impl_->listener_id); + } + // Dropping the handle stops a running capture. +} + +void CaptureSource::setOnFinishedCallback(FinishedCallback callback) { + const std::scoped_lock lock(impl_->callback_mutex); + impl_->on_finished = std::move(callback); +} + +namespace { + +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 (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& error) { + LK_LOG_WARN("Capture {} request failed: {}", operation, error.what()); + return false; + } +} + +} // namespace + +bool CaptureSource::start() { return controlCapture(impl_->handle.get(), true); } +bool CaptureSource::stop() { return controlCapture(impl_->handle.get(), false); } + +} // namespace livekit::capture diff --git a/src/capture_source_internal.h b/src/capture_source_internal.h new file mode 100644 index 00000000..bca578fc --- /dev/null +++ b/src/capture_source_internal.h @@ -0,0 +1,34 @@ +/* + * 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 "livekit/capture/capture_source.h" +#include "livekit/visibility.h" + +namespace livekit::proto { +class NewCaptureSourceRequest; +} // namespace livekit::proto + +namespace livekit::capture { + +/// Serialize capture configurations for the FFI request. +/// Internal test seam; not part of the public SDK API. +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); + +} // namespace livekit::capture diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 6c924560..c638cdb3 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/capture_source.h" #include "livekit/data_track_error.h" #include "livekit/ffi_handle.h" #include "livekit/room.h" @@ -109,6 +110,8 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { return event.chat_message().async_id(); case E::kPerformRpc: return event.perform_rpc().async_id(); + case E::kNewCaptureSource: + return event.new_capture_source().async_id(); // low-level data stream callbacks case E::kSendStreamHeader: @@ -162,6 +165,9 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { case E::kTextStreamReaderEvent: case E::kDataTrackStreamEvent: case E::kRpcMethodInvocation: + // 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: case E::MESSAGE_NOT_SET: @@ -458,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)); @@ -710,6 +718,55 @@ std::future FfiClient::publishTrackAsync(std::uint return fut; } +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>( + 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(); + try { + if (cb.has_error()) { + throw capture::CaptureSourceError(cb.error()); + } + if (!cb.has_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(capture::CaptureSource::fromOwned(cb.source())); + } catch (const std::exception& error) { + pr.set_exception(std::make_exception_ptr(capture::CaptureSourceError(error.what()))); + } catch (...) { + pr.set_exception(std::make_exception_ptr(capture::CaptureSourceError("Capture source construction failed"))); + } + }, + std::make_exception_ptr(capture::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 = send ? send(req) : sendRequest(req); + if (!resp.has_new_capture_source()) { + logAndThrow("FfiResponse missing new_capture_source"); + } + } catch (...) { + cancelPendingByAsyncId(async_id); + throw; + } + + return fut; +} + std::future FfiClient::unpublishTrackAsync(std::uint64_t local_participant_handle, const std::string& track_sid, bool stop_on_unpublish) { // Generate client-side async_id first diff --git a/src/ffi_client.h b/src/ffi_client.h index c0835027..82953979 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" @@ -57,6 +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); @@ -65,6 +69,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 { @@ -118,6 +128,13 @@ 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 + // 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, @@ -158,6 +175,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 @@ -182,6 +201,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); } @@ -189,7 +209,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 @@ -219,7 +241,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/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 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/integration/test_capture_source.cpp b/src/tests/integration/test_capture_source.cpp new file mode 100644 index 00000000..7e127314 --- /dev/null +++ b/src/tests/integration/test_capture_source.cpp @@ -0,0 +1,169 @@ +/* + * 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). +/// +/// 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 capture-source support +/// (-DLIVEKIT_CAPTURE=all); otherwise they skip. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../common/test_common.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; + +std::shared_ptr createCapture(bool clock) { + if (clock) { + capture::ClockVideoSourceConfig config; + config.resolution = {kCaptureWidth, kCaptureHeight}; + config.framerate_fps = kCaptureFramerateFps; + return capture::CaptureSource::create(config).get(); + } + capture::PatternVideoSourceConfig config; + config.resolution = {kCaptureWidth, kCaptureHeight}; + config.framerate_fps = kCaptureFramerateFps; + return capture::CaptureSource::create(config).get(); +} + +} // namespace + +class CaptureSourceServerTest : public LiveKitTestBase, public ::testing::WithParamInterface {}; + +TEST_P(CaptureSourceServerTest, CaptureSourcePublishesFramesEndToEnd) { +#if !LIVEKIT_CAPTURE_ENABLED + GTEST_SKIP() << "livekit-ffi built without capture support; configure with -DLIVEKIT_CAPTURE=all"; +#endif + failIfNotConfigured(); + + 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 pixel source reports back the resolution it was configured with. + ASSERT_EQ(capture->kind(), capture::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 = "pixel-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 capture::CaptureResult& result) { + std::lock_guard lock(mutex); + finished = result; + 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"; + + { + 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 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, capture::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()) { + lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); + } +} + +INSTANTIATE_TEST_SUITE_P(PixelSources, CaptureSourceServerTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "Clock" : "capture::Pattern"; + }); + +} // namespace livekit::test diff --git a/src/tests/packaging/CMakeLists.txt b/src/tests/packaging/CMakeLists.txt new file mode 100644 index 00000000..393d9892 --- /dev/null +++ b/src/tests/packaging/CMakeLists.txt @@ -0,0 +1,166 @@ +# 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) +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) +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() +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. +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) +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() + 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() + +# 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 +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/capture_source.h") +elseif(LIVEKIT_EXPECT_CAPTURE STREQUAL "OFF" AND _capture_header_compiles) + message(FATAL_ERROR "Standard SDK must reject including livekit/capture/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) +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) + 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 60 +) diff --git a/src/tests/packaging/test_capture_bundle.cpp b/src/tests/packaging/test_capture_bundle.cpp new file mode 100644 index 00000000..83eb7d66 --- /dev/null +++ b/src/tests/packaging/test_capture_bundle.cpp @@ -0,0 +1,111 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include +#include + +#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::capture::CaptureSourceKind kind) { + try { + 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; + } + auto source = future.get(); + if (!expected || !source || source->kind() != kind || source->width() != 320 || source->height() != 240 || + !source->videoSource() || + (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::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) { + 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::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; + } + std::cerr << name << ": " << error.what() << '\n'; + return false; + } catch (const std::exception& error) { + std::cerr << name << ": " << error.what() << '\n'; + return false; + } +} + +bool checkSources() { + 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::capture::CaptureSourceKind::Encoded); + + livekit::capture::PatternVideoSourceConfig pattern; + pattern.resolution = {320, 240}; + pattern.framerate_fps = 30; + success = checkSource("pattern", pattern, LIVEKIT_EXPECT_PATTERN != 0, livekit::capture::CaptureSourceKind::Pixel) && + success; + + livekit::capture::ClockVideoSourceConfig clock; + clock.resolution = {320, 240}; + clock.framerate_fps = 30; + success = + checkSource("clock", clock, LIVEKIT_EXPECT_CLOCK != 0, livekit::capture::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")) { + 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_CAPTURE_ENABLED != 0) != expect_capture) { + std::cerr << "Installed SDK capture metadata does not match the requested expectation\n"; + return 1; + } +#if LIVEKIT_CAPTURE_ENABLED + livekit::initialize(livekit::LogLevel::Warn); + const bool success = checkSources(); + livekit::shutdown(); + return success ? 0 : 1; +#else + return 0; +#endif +} diff --git a/src/tests/unit/test_capture_source.cpp b/src/tests/unit/test_capture_source.cpp new file mode 100644 index 00000000..e5f730ee --- /dev/null +++ b/src/tests/unit/test_capture_source.cpp @@ -0,0 +1,372 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an “AS IS” BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include +#include + +#include "../common/ffi_utils.h" +#include "capture.pb.h" +#include "capture_source_internal.h" +#include "ffi_client.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(), 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_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()), capture::CaptureSourceError); + } + // A second drop must fail. If ownership leaked, this also cleans it up. + EXPECT_FALSE(dropFfiHandle(capture.handle().id())); + EXPECT_FALSE(dropFfiHandle(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()), capture::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()), 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()), capture::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) { + capture::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 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), capture::CaptureSourceError); + capture::ClockVideoSourceConfig clock; + clock.resolution = resolution; + clock.framerate_fps = 30; + EXPECT_THROW(toProto(clock), capture::CaptureSourceError); + capture::GstreamerVideoSourceConfig gstreamer; + gstreamer.resolution = resolution; + EXPECT_THROW(toProto(gstreamer), capture::CaptureSourceError); + } +} + +TEST(CaptureSourceProtoTest, RejectsZeroFrameRate) { + capture::PatternVideoSourceConfig pattern; + pattern.resolution = {1280, 720}; + EXPECT_THROW(toProto(pattern), capture::CaptureSourceError); + capture::ClockVideoSourceConfig clock; + clock.resolution = {1280, 720}; + EXPECT_THROW(toProto(clock), capture::CaptureSourceError); +} + +TEST(CaptureSourceProtoTest, ValidationErrorsAreDeliveredThroughFuture) { + std::future> future; + EXPECT_NO_THROW(future = capture::CaptureSource::create(capture::ClockVideoSourceConfig{})); + ASSERT_TRUE(future.valid()); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); +} + +TEST(CaptureSourceProtoTest, SendErrorsAreDeliveredThroughFuture) { + FfiClient::instance().shutdown(); + capture::ClockVideoSourceConfig config; + config.resolution = {1280, 720}; + config.framerate_fps = 30; + std::future> future; + EXPECT_NO_THROW(future = capture::CaptureSource::create(config)); + ASSERT_TRUE(future.valid()); + EXPECT_THROW(static_cast(future.get()), capture::CaptureSourceError); + EXPECT_EQ(FfiClientTestAccess::pendingCount(), 0u); +} + +TEST(CaptureSourceProtoTest, GstreamerCanDiscoverResolution) { + capture::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 +} // namespace livekit::test