diff --git a/.github/actions/install-desktop-deps/action.yml b/.github/actions/install-desktop-deps/action.yml index 726db4e24e8..a27009a8568 100644 --- a/.github/actions/install-desktop-deps/action.yml +++ b/.github/actions/install-desktop-deps/action.yml @@ -8,14 +8,40 @@ runs: shell: bash run: | sudo apt update - sudo apt install libwebkit2gtk-4.1-dev \ + sudo apt install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ build-essential \ + pkg-config \ curl \ wget \ file \ + clang \ + libclang-dev \ libxdo-dev \ libssl-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libpipewire-0.3-dev \ - ffmpeg clang libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libavdevice-dev pkg-config libasound2-dev + libspa-0.2-dev \ + libasound2-dev \ + libdbus-1-dev \ + libudev-dev \ + libx11-dev \ + libxrandr-dev \ + libxcb1-dev \ + libxcb-randr0-dev \ + libxcb-shm0-dev \ + libxcb-xfixes0-dev \ + libxfixes-dev \ + libwayland-dev \ + libxkbcommon-dev \ + ffmpeg \ + libavcodec-dev \ + libavformat-dev \ + libavutil-dev \ + libavfilter-dev \ + libavdevice-dev \ + libswscale-dev \ + libswresample-dev \ + patchelf \ + rpm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0c6810df7b8..9151fc46d86 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -184,6 +184,8 @@ jobs: runner: macos-latest-xlarge - target: x86_64-pc-windows-msvc runner: windows-2022 + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} @@ -194,17 +196,22 @@ jobs: with: ref: ${{ needs.draft.outputs.tag_name }} + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + - name: Create API Key File + if: ${{ runner.os == 'macOS' }} run: echo "${{ secrets.APPLE_API_KEY_FILE }}" > api.p8 - - uses: apple-actions/import-codesign-certs@v2 - if: ${{ matrix.settings.runner == 'macos-latest-xlarge' }} + - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2 + if: ${{ runner.os == 'macOS' }} with: p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - name: Verify certificate - if: ${{ matrix.settings.runner == 'macos-latest-xlarge' }} + if: ${{ runner.os == 'macOS' }} run: security find-identity -v -p codesigning ${{ runner.temp }}/build.keychain - name: Rust setup @@ -245,8 +252,8 @@ jobs: run: pnpm -w cap-setup env: RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_KEYCHAIN: ${{ runner.temp }}/build.keychain + APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }} + APPLE_KEYCHAIN: ${{ runner.os == 'macOS' && format('{0}/build.keychain', runner.temp) || '' }} - name: Build desktop binaries shell: bash @@ -260,14 +267,14 @@ jobs: CI: false GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # codesigning - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }} + APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} + APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }} # notarization - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ github.workspace }}/api.p8 - APPLE_KEYCHAIN: ${{ runner.temp }}/build.keychain + APPLE_API_ISSUER: ${{ runner.os == 'macOS' && secrets.APPLE_API_ISSUER || '' }} + APPLE_API_KEY: ${{ runner.os == 'macOS' && secrets.APPLE_API_KEY || '' }} + APPLE_API_KEY_PATH: ${{ runner.os == 'macOS' && format('{0}/api.p8', github.workspace) || '' }} + APPLE_KEYCHAIN: ${{ runner.os == 'macOS' && format('{0}/build.keychain', runner.temp) || '' }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} diff --git a/Cargo.lock b/Cargo.lock index 8ff0ce13714..cacc29748d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,16 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width 0.2.1", +] + [[package]] name = "anstream" version = "0.6.20" @@ -581,7 +591,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -605,7 +615,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 7.1.3", "num-rational", "v_frame", ] @@ -754,6 +764,29 @@ dependencies = [ "virtue", ] +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.106", + "which", +] + [[package]] name = "bindgen" version = "0.69.5" @@ -821,6 +854,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ + "annotate-snippets", "bitflags 2.9.4", "cexpr", "clang-sys", @@ -1039,7 +1073,7 @@ checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1121,6 +1155,7 @@ dependencies = [ "serde", "specta", "thiserror 1.0.69", + "v4l", "windows 0.60.0", "windows-core 0.60.1", "workspace-hack", @@ -1638,6 +1673,7 @@ name = "cap-recording" version = "0.1.0" dependencies = [ "anyhow", + "ashpd", "cap-audio", "cap-camera", "cap-camera-ffmpeg", @@ -1685,6 +1721,7 @@ dependencies = [ "objc2 0.6.2", "objc2-app-kit", "parking_lot", + "pipewire", "relative-path", "replace_with", "retry", @@ -1708,6 +1745,7 @@ dependencies = [ "tracing-subscriber", "windows 0.60.0", "workspace-hack", + "x11rb", ] [[package]] @@ -1922,7 +1960,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1943,7 +1981,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon 0.13.5", ] [[package]] @@ -2247,6 +2295,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "cookie_store" version = "0.21.1" @@ -3689,7 +3743,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3706,7 +3760,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3720,7 +3774,7 @@ dependencies = [ "gobject-sys", "libc", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3746,7 +3800,7 @@ dependencies = [ "gdk-sys", "glib-sys", "libc", - "system-deps", + "system-deps 6.2.2", "x11", ] @@ -3891,7 +3945,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", "winapi", ] @@ -3950,7 +4004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -4019,7 +4073,7 @@ checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -4120,7 +4174,7 @@ dependencies = [ "gobject-sys", "libc", "pango-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -4917,7 +4971,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -5230,6 +5284,33 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libspa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e" +dependencies = [ + "bitflags 2.9.4", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.2", + "system-deps 7.0.8", +] + +[[package]] +name = "libspa-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda" +dependencies = [ + "bindgen 0.72.1", + "cc", + "system-deps 7.0.8", +] + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -5947,6 +6028,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -6806,7 +6896,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -6877,6 +6967,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -7085,6 +7181,31 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38" +dependencies = [ + "bitflags 2.9.4", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.2", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc" +dependencies = [ + "bindgen 0.72.1", + "libspa-sys", + "system-deps 7.0.8", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -7658,7 +7779,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "simd_helpers", - "system-deps", + "system-deps 6.2.2", "thiserror 1.0.69", "v_frame", "wasm-bindgen", @@ -8375,6 +8496,7 @@ dependencies = [ "tracing", "windows 0.60.0", "workspace-hack", + "x11rb", ] [[package]] @@ -8804,9 +8926,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -9164,7 +9286,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -9239,7 +9361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" dependencies = [ "base64 0.13.1", - "nom", + "nom 7.1.3", "serde", "unicode-segmentation", ] @@ -9571,13 +9693,26 @@ version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "cfg-expr", + "cfg-expr 0.15.8", "heck 0.5.0", "pkg-config", "toml 0.8.2", "version-compare", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr 0.20.8", + "heck 0.5.0", + "pkg-config", + "toml 1.1.0+spec-1.1.0", + "version-compare", +] + [[package]] name = "tao" version = "0.34.3" @@ -9644,6 +9779,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tauri" version = "2.8.5" @@ -10619,13 +10760,28 @@ checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ "indexmap 2.11.4", "serde_core", - "serde_spanned 1.0.2", + "serde_spanned 1.1.0", "toml_datetime 0.7.2", "toml_parser", "toml_writer", "winnow 0.7.13", ] +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap 2.11.4", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -10644,6 +10800,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -10682,18 +10847,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 0.7.13", + "winnow 1.0.3", ] [[package]] name = "toml_writer" -version = "1.0.3" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" @@ -10917,7 +11082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" dependencies = [ "memchr", - "nom", + "nom 7.1.3", "once_cell", "petgraph", ] @@ -11284,6 +11449,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen 0.65.1", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -11613,7 +11798,7 @@ dependencies = [ "libc", "pkg-config", "soup3-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -12576,6 +12761,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "winreg" version = "0.50.0" @@ -12653,7 +12844,7 @@ dependencies = [ "log", "memchr", "miniz_oxide", - "nom", + "nom 7.1.3", "num-rational", "num-traits", "percent-encoding", diff --git a/apps/cli/src/export.rs b/apps/cli/src/export.rs index 470e1c7eae3..ec0702b26a2 100644 --- a/apps/cli/src/export.rs +++ b/apps/cli/src/export.rs @@ -1,6 +1,6 @@ use std::{ io::{Write, stdout}, - path::PathBuf, + path::{Path, PathBuf}, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -8,7 +8,7 @@ use std::{ }; use cap_export::{ExporterBase, make_cursor_only_project}; -use cap_project::{RecordingMeta, XY}; +use cap_project::{RecordingMeta, RecordingMetaInner, XY}; use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize}; use tracing::info; @@ -310,6 +310,20 @@ impl Export { let settings = self.resolve_settings()?; ensure_remuxed(self.project_path.clone()).await?; + let meta = RecordingMeta::load_for_project(&self.project_path) + .map_err(|e| format!("Failed to load recording meta: {e}"))?; + + if matches!(&meta.inner, RecordingMetaInner::Instant(_)) { + return export_instant_project( + self.project_path, + output, + &settings, + progress_json, + completion_json, + stdout, + ) + .await; + } let force_ffmpeg_decoder = self.force_ffmpeg_decoder || settings.force_ffmpeg_decoder(); let mut builder = ExporterBase::builder(self.project_path.clone()) @@ -320,8 +334,6 @@ impl Export { } if settings.cursor_only() { - let meta = RecordingMeta::load_for_project(&self.project_path) - .map_err(|e| format!("Failed to load recording meta: {e}"))?; builder = builder.with_config(make_cursor_only_project(meta.project_config())); } @@ -413,11 +425,157 @@ async fn ensure_remuxed(project_path: PathBuf) -> Result<(), String> { Ok(()) } +async fn prepare_instant_output(project_path: PathBuf) -> Result { + let output_path = project_path.join("content/output.mp4"); + let audio_dir = project_path.join("content/audio"); + if std::fs::metadata(&output_path) + .map(|metadata| metadata.len() > 0) + .unwrap_or(false) + && !audio_dir.exists() + { + return Ok(output_path); + } + + let display_dir = project_path.join("content/display"); + tokio::task::spawn_blocking(move || { + cap_recording::recovery::RecoveryManager::finalize_instant_output( + &display_dir, + &audio_dir, + &output_path, + ) + }) + .await + .map_err(|e| format!("instant export finalize task failed: {e}"))? + .map_err(|e| format!("Failed to finalize instant recording before export: {e}")) +} + +fn instant_export_settings_supported(settings: &CliExportSettings) -> bool { + match settings { + CliExportSettings::Mp4(settings) => { + settings.fps == 60 + && settings.resolution_base == XY::new(1920, 1080) + && matches!( + settings.compression, + cap_export::mp4::ExportCompression::Maximum + ) + && settings.custom_bpp.is_none() + && !settings.optimize_filesize + } + CliExportSettings::Gif(_) | CliExportSettings::Mov(_) => false, + } +} + +fn validate_instant_output_path(path: &Path) -> Result<(), String> { + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| !extension.eq_ignore_ascii_case("mp4")) + { + return Err("Instant recordings can only be exported as mp4 files".to_string()); + } + + Ok(()) +} + +fn copy_instant_output(source_path: &Path, output_path: PathBuf) -> Result { + validate_instant_output_path(&output_path)?; + + if source_path == output_path.as_path() { + return Ok(output_path); + } + + if output_path.exists() + && let (Ok(source), Ok(output)) = (source_path.canonicalize(), output_path.canonicalize()) + && source == output + { + return Ok(output_path); + } + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "Failed to create output directory {}: {e}", + parent.display() + ) + })?; + } + + std::fs::copy(source_path, &output_path).map_err(|e| { + format!( + "Failed to copy instant recording from {} to {}: {e}", + source_path.display(), + output_path.display() + ) + })?; + + Ok(output_path) +} + +async fn export_instant_project( + project_path: PathBuf, + output: Option, + settings: &CliExportSettings, + progress_json: bool, + completion_json: bool, + stdout: &Arc>, +) -> Result<(), String> { + if !instant_export_settings_supported(settings) { + return Err( + "Instant recordings are already finalized MP4 files; export supports copying them to an mp4 output path" + .to_string(), + ); + } + + let source_path = prepare_instant_output(project_path).await?; + let output_path = output.unwrap_or_else(|| source_path.clone()); + + if progress_json { + emit_export_message( + stdout, + &ExportProgressMessage::Progress { + rendered_count: 0, + total_frames: 1, + }, + )?; + } + + let output_path = copy_instant_output(&source_path, output_path)?; + + if progress_json { + emit_export_message( + stdout, + &ExportProgressMessage::Progress { + rendered_count: 1, + total_frames: 1, + }, + )?; + } + + if progress_json || completion_json { + emit_export_message( + stdout, + &ExportProgressMessage::Completed { path: &output_path }, + )?; + } else { + println!("Exported video to {}", output_path.display()); + } + + info!("Exported instant video to '{}'", output_path.display()); + + Ok(()) +} + /// Render a project to its default output path with default settings (mp4, 1080p60, Maximum). Used by /// `cap upload --export` to glue record -> export -> upload into one step. pub async fn export_project_default(project_path: PathBuf) -> Result { let settings = settings_from_flags(&ExportFlags::default())?; ensure_remuxed(project_path.clone()).await?; + let meta = RecordingMeta::load_for_project(&project_path) + .map_err(|e| format!("Failed to load recording meta: {e}"))?; + if matches!(&meta.inner, RecordingMetaInner::Instant(_)) { + return prepare_instant_output(project_path).await; + } + let exporter_base = ExporterBase::builder(project_path) .with_force_ffmpeg_decoder(settings.force_ffmpeg_decoder()) .build() diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index 5c2af69ed84..0081eae079a 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -172,12 +172,16 @@ fn build_report(project_path: &Path, meta: &RecordingMeta) -> ValidationReport { project_path.join("project-config.json"), )); - if let RecordingMetaInner::Studio(studio) = &meta.inner { - checks.extend(studio_checks(meta, studio)); + match &meta.inner { + RecordingMetaInner::Studio(studio) => { + checks.extend(studio_checks(meta, studio)); + checks.push(optional_check("output", meta.output_path())); + } + RecordingMetaInner::Instant(_) => { + checks.push(required_check("output", meta.output_path())); + } } - checks.push(optional_check("output", meta.output_path())); - let missing: Vec = checks .iter() .filter(|c| c.required && !c.exists) diff --git a/apps/cli/src/record.rs b/apps/cli/src/record.rs index a410e41b0ec..2ae52a02a81 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -1,10 +1,14 @@ +use cap_project::{ + InstantRecordingMeta, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, +}; use cap_recording::{ CameraFeed, MicrophoneFeed, feeds::{camera, microphone}, + instant_recording, screen_capture::ScreenCaptureTarget, - studio_recording::{self, ActorHandle, CompletedRecording}, + studio_recording::{self, ActorHandle as StudioActorHandle}, }; -use clap::Args; +use clap::{Args, ValueEnum}; use futures::FutureExt; use kameo::Actor as _; use scap_targets::{DisplayId, WindowId}; @@ -33,6 +37,9 @@ use crate::{ pub struct RecordParams { #[command(flatten)] target: RecordTargets, + /// Recording mode to use + #[arg(long, value_enum, default_value_t = RecordMode::Studio)] + mode: RecordMode, /// Capture from the camera with this device id (see `cap targets cameras`) #[arg(long)] camera: Option, @@ -77,6 +84,8 @@ impl RecordParams { args.push("--window".to_string()); args.push(id.to_string()); } + args.push("--mode".to_string()); + args.push(self.mode.to_string()); if let Some(camera) = &self.camera { args.push("--camera".to_string()); args.push(camera.clone()); @@ -104,6 +113,21 @@ impl RecordParams { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum RecordMode { + Studio, + Instant, +} + +impl std::fmt::Display for RecordMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Studio => f.write_str("studio"), + Self::Instant => f.write_str("instant"), + } + } +} + #[derive(Args)] pub struct RecordStart { #[command(flatten)] @@ -408,12 +432,15 @@ async fn session_worker(params: RecordParams, recording_id: &str) -> Result<(), let stop_path = session::stop_file(recording_id)?; let completed = finalize(actor, params.duration, false, Some(&stop_path)).await?; - let recording_meta_exists = completed.project_path.join("recording-meta.json").exists(); + let recording_meta_exists = completed + .project_path() + .join("recording-meta.json") + .exists(); session::write_session(&Session { recording_id: recording_id.to_string(), pid: std::process::id(), - path: completed.project_path, + path: completed.project_path().to_path_buf(), status: SessionStatus::Stopped, started_at, recording_meta_exists: Some(recording_meta_exists), @@ -599,13 +626,52 @@ pub fn status(format: OutputFormat) -> Result<(), String> { } } +enum ActorHandle { + Studio(StudioActorHandle), + Instant(instant_recording::ActorHandle), +} + +impl ActorHandle { + async fn stop(&self) -> Result { + match self { + Self::Studio(actor) => actor + .stop() + .await + .map(Box::new) + .map(CompletedRecording::Studio) + .map_err(|e| e.to_string()), + Self::Instant(actor) => actor + .stop() + .await + .map(CompletedRecording::Instant) + .map_err(|e| e.to_string()), + } + } +} + +enum CompletedRecording { + Studio(Box), + Instant(instant_recording::CompletedRecording), +} + +impl CompletedRecording { + fn project_path(&self) -> &Path { + match self { + Self::Studio(recording) => &recording.project_path, + Self::Instant(recording) => &recording.project_path, + } + } +} + async fn start_recording( params: &RecordParams, target: ScreenCaptureTarget, path: PathBuf, ) -> Result { - let mut builder = - studio_recording::Actor::builder(path, target).with_system_audio(params.system_audio); + let mut studio_builder = studio_recording::Actor::builder(path.clone(), target.clone()) + .with_system_audio(params.system_audio); + let mut instant_builder = + instant_recording::Actor::builder(path, target).with_system_audio(params.system_audio); let mut camera_active = false; // Feeds must be locked and attached before build(); the lock keeps the device open for the whole @@ -635,7 +701,9 @@ async fn start_recording( .ask(camera::Lock) .await .map_err(|e| format!("Failed to lock camera feed: {e}"))?; - builder = builder.with_camera_feed(Arc::new(lock)); + let lock = Arc::new(lock); + studio_builder = studio_builder.with_camera_feed(lock.clone()); + instant_builder = instant_builder.with_camera_feed(lock); camera_active = true; } @@ -665,28 +733,55 @@ async fn start_recording( .ask(microphone::Lock) .await .map_err(|e| format!("Failed to lock mic feed: {e}"))?; - builder = builder.with_mic_feed(Arc::new(lock)); - } - - // Reuse the desktop app's recording defaults rather than a CLI-specific config; `finalize` - // remuxes the resulting fragmented recording so the `.cap` stays directly exportable. - let builder = cap_recording::RecordingDefaults::default().apply_to_studio_builder( - builder, - camera_active, - params.fps, - ); - - builder - .build( - #[cfg(target_os = "macos")] - Some(cap_recording::SendableShareableContent::from( - cidre::sc::ShareableContent::current() - .await - .map_err(|e| format!("Failed to read shareable content: {e}"))?, - )), - ) - .await - .map_err(|e| e.to_string()) + let lock = Arc::new(lock); + studio_builder = studio_builder.with_mic_feed(lock.clone()); + instant_builder = instant_builder.with_mic_feed(lock); + } + + match params.mode { + RecordMode::Studio => { + let builder = cap_recording::RecordingDefaults::default().apply_to_studio_builder( + studio_builder, + camera_active, + params.fps, + ); + + builder + .build( + #[cfg(target_os = "macos")] + Some(cap_recording::SendableShareableContent::from( + cidre::sc::ShareableContent::current() + .await + .map_err(|e| format!("Failed to read shareable content: {e}"))?, + )), + ) + .await + .map(ActorHandle::Studio) + .map_err(|e| e.to_string()) + } + RecordMode::Instant => { + let mut builder = instant_builder; + builder = builder.with_max_output_size( + cap_recording::RecordingDefaults::default().instant_mode_max_resolution, + ); + if let Some(fps) = params.fps { + builder = builder.with_max_fps(fps); + } + + builder + .build( + #[cfg(target_os = "macos")] + Some(cap_recording::SendableShareableContent::from( + cidre::sc::ShareableContent::current() + .await + .map_err(|e| format!("Failed to read shareable content: {e}"))?, + )), + ) + .await + .map(ActorHandle::Instant) + .map_err(|e| e.to_string()) + } + } } /// Wait for the stop trigger, then finalize the recording. A panic between start and stop would @@ -720,30 +815,98 @@ async fn finalize( .map_err(|e| format!("recording panicked; finalize failed: {e}"))?, }; - remux_fragmented(completed).await + finalize_completed(completed).await } -/// Remux a freshly-stopped recording in place if it finalized as fragments (`NeedsRemux`). Reuses -/// the shared `RecoveryManager` the desktop uses; `recover` is synchronous and ffmpeg-heavy, so it -/// runs on a blocking thread. A no-op for recordings already stored as progressive mp4. -async fn remux_fragmented(completed: CompletedRecording) -> Result { - let project_path = completed.project_path.clone(); +async fn finalize_completed(completed: CompletedRecording) -> Result { + match &completed { + CompletedRecording::Studio(recording) => { + let project_path = recording.project_path.clone(); + tokio::task::spawn_blocking(move || { + cap_recording::recovery::RecoveryManager::remux_if_needed(&project_path) + }) + .await + .map_err(|e| format!("recording finalize task failed: {e}"))? + .map_err(|e| format!("Failed to remux recording: {e}"))?; + } + CompletedRecording::Instant(recording) => { + finalize_instant_output(recording.project_path.clone()).await?; + persist_instant_recording_meta(recording)?; + } + } + + Ok(completed) +} + +async fn finalize_instant_output(project_path: PathBuf) -> Result<(), String> { + let output_path = project_path.join("content/output.mp4"); + let audio_dir = project_path.join("content/audio"); + if std::fs::metadata(&output_path) + .map(|metadata| metadata.len() > 0) + .unwrap_or(false) + && !audio_dir.exists() + { + return Ok(()); + } + + let display_dir = project_path.join("content/display"); tokio::task::spawn_blocking(move || { - cap_recording::recovery::RecoveryManager::remux_if_needed(&project_path) + cap_recording::recovery::RecoveryManager::finalize_instant_output( + &display_dir, + &audio_dir, + &output_path, + ) }) .await - .map_err(|e| format!("recording finalize task failed: {e}"))? - .map_err(|e| format!("Failed to remux recording: {e}"))?; + .map_err(|e| format!("instant recording finalize task failed: {e}"))? + .map_err(|e| format!("Failed to finalize instant recording: {e}"))?; - Ok(completed) + Ok(()) +} + +fn persist_instant_recording_meta( + recording: &instant_recording::CompletedRecording, +) -> Result<(), String> { + let pretty_name = recording + .project_path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Cap Recording") + .to_string(); + let meta = match &recording.meta { + InstantRecordingMeta::Complete { .. } => recording.meta.clone(), + InstantRecordingMeta::InProgress { .. } => InstantRecordingMeta::Failed { + error: "instant recording stopped before completion".to_string(), + }, + InstantRecordingMeta::Failed { .. } => recording.meta.clone(), + }; + + RecordingMeta { + platform: Some(Platform::default()), + project_path: recording.project_path.clone(), + pretty_name, + sharing: None, + inner: RecordingMetaInner::Instant(meta), + upload: None, + } + .save_for_project() + .map_err(|e| format!("Failed to save instant recording meta: {e}"))?; + + ProjectConfiguration::default() + .write(&recording.project_path) + .map_err(|e| format!("Failed to save instant project config: {e}")) } fn emit_stopped(format: OutputFormat, completed: &CompletedRecording) -> Result<(), String> { - let recording_meta_exists = completed.project_path.join("recording-meta.json").exists(); + let recording_meta_exists = completed + .project_path() + .join("recording-meta.json") + .exists(); emit_record_event( format, &RecordEvent::Stopped { - path: &completed.project_path.display().to_string(), + path: &completed.project_path().display().to_string(), recording_meta_exists, }, ) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx index 81af1c1a60c..7a8d83b6d76 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx @@ -10,9 +10,9 @@ import { commands, type SystemDiagnostics } from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; import { Section, SettingsPageContent } from "./Setting"; -const getFeedbackOs = (): Extract => { +const getFeedbackOs = (): Extract => { const os = ostype(); - if (os === "macos" || os === "windows") return os; + if (os === "macos" || os === "windows" || os === "linux") return os; throw new Error(`Unsupported OS for feedback submission: ${os}`); }; @@ -148,7 +148,9 @@ export default function FeedbackTab() { ? (d.macosVersion as { displayName: string } | null) : "windowsVersion" in d ? (d.windowsVersion as { displayName: string } | null) - : null; + : "linuxVersion" in d + ? (d.linuxVersion as { displayName: string } | null) + : null; const captureSupported = "screenCaptureSupported" in d ? (d.screenCaptureSupported as boolean) diff --git a/apps/web/__tests__/unit/platform-downloads.test.ts b/apps/web/__tests__/unit/platform-downloads.test.ts new file mode 100644 index 00000000000..ce61853012b --- /dev/null +++ b/apps/web/__tests__/unit/platform-downloads.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + getDownloadButtonText, + getDownloadUrl, + getVersionText, +} from "@/utils/platform"; + +describe("download platform helpers", () => { + it("routes Linux users to the Linux AppImage download", () => { + expect(getDownloadUrl("linux", false)).toBe("/download/linux"); + expect(getDownloadButtonText("linux", false)).toBe("Download for free"); + expect(getVersionText("linux")).toBe("Linux x86_64 AppImage recommended"); + }); + + it("keeps existing macOS and Windows download routing", () => { + expect(getDownloadUrl("macos", false)).toBe("/download/apple-silicon"); + expect(getDownloadUrl("macos", true)).toBe("/download/apple-intel"); + expect(getDownloadUrl("windows", false)).toBe("/download/windows"); + }); +}); diff --git a/apps/web/__tests__/unit/releases-downloads.test.ts b/apps/web/__tests__/unit/releases-downloads.test.ts new file mode 100644 index 00000000000..1e08aaecf73 --- /dev/null +++ b/apps/web/__tests__/unit/releases-downloads.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + hasDownloads, + parseDownloadsFromBody, + releaseDownloadKeys, +} from "@/utils/releases"; + +describe("release downloads", () => { + it("parses Linux release download URLs from the release body", () => { + const downloads = parseDownloadsFromBody(` + + `); + + expect(downloads["linux-appimage"]).toBe( + "https://example.com/Cap.AppImage", + ); + expect(downloads["linux-deb"]).toBe("https://example.com/Cap.deb"); + expect(downloads["linux-rpm"]).toBe("https://example.com/Cap.rpm"); + expect(hasDownloads(downloads)).toBe(true); + }); + + it("treats Linux-only release metadata as downloadable", () => { + expect( + hasDownloads({ "linux-appimage": "https://example.com/Cap.AppImage" }), + ).toBe(true); + }); + + it("keeps the release download key list in platform order", () => { + expect(releaseDownloadKeys).toEqual([ + "macos-arm64", + "macos-x64", + "windows", + "linux-appimage", + "linux-deb", + "linux-rpm", + ]); + }); +}); diff --git a/apps/web/app/(site)/download/[platform]/route.ts b/apps/web/app/(site)/download/[platform]/route.ts index 3c30bd5ebc9..c5c1c897e3d 100644 --- a/apps/web/app/(site)/download/[platform]/route.ts +++ b/apps/web/app/(site)/download/[platform]/route.ts @@ -1,10 +1,8 @@ import { type NextRequest, NextResponse } from "next/server"; -import { getGitHubReleases } from "@/utils/releases"; +import { getGitHubReleases, type ReleaseDownloadKey } from "@/utils/releases"; export const runtime = "edge"; -type FallbackPlatform = "macos-arm64" | "macos-x64" | "windows"; - async function checkCrabNebulaDownload( url: string, ): Promise<{ ok: true; finalUrl: string } | { ok: false }> { @@ -26,19 +24,13 @@ async function checkCrabNebulaDownload( } async function getGitHubFallbackDownloadUrl( - platform: FallbackPlatform, + platform: ReleaseDownloadKey, ): Promise { try { const releases = await getGitHubReleases(); for (const release of releases) { - const url = - platform === "windows" - ? release.downloads.windows - : platform === "macos-arm64" - ? release.downloads["macos-arm64"] - : release.downloads["macos-x64"]; - + const url = release.downloads[platform]; if (url) return url; } } catch {} @@ -55,7 +47,7 @@ export async function GET( const downloadUrls: Record< string, - { url: string; fallback: FallbackPlatform } + { url: string; fallback: ReleaseDownloadKey } > = { "apple-intel": { url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/dmg-x86_64", @@ -93,6 +85,46 @@ export async function GET( url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/nsis-x86_64", fallback: "windows", }, + linux: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/appimage-x86_64", + fallback: "linux-appimage", + }, + "linux-appimage": { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/appimage-x86_64", + fallback: "linux-appimage", + }, + appimage: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/appimage-x86_64", + fallback: "linux-appimage", + }, + "linux-deb": { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/deb-x86_64", + fallback: "linux-deb", + }, + deb: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/deb-x86_64", + fallback: "linux-deb", + }, + debian: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/deb-x86_64", + fallback: "linux-deb", + }, + ubuntu: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/deb-x86_64", + fallback: "linux-deb", + }, + "linux-rpm": { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/rpm-x86_64", + fallback: "linux-rpm", + }, + rpm: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/rpm-x86_64", + fallback: "linux-rpm", + }, + fedora: { + url: "https://cdn.crabnebula.app/download/cap/cap/latest/platform/rpm-x86_64", + fallback: "linux-rpm", + }, }; const download = downloadUrls[platform]; diff --git a/apps/web/app/(site)/download/versions/page.tsx b/apps/web/app/(site)/download/versions/page.tsx index 17647dcc09b..6bce5d5241c 100644 --- a/apps/web/app/(site)/download/versions/page.tsx +++ b/apps/web/app/(site)/download/versions/page.tsx @@ -10,7 +10,8 @@ import { export const metadata: Metadata = { title: "All Versions — Cap", - description: "Download previous versions of Cap for macOS and Windows.", + description: + "Download previous versions of Cap for macOS, Windows, and Linux.", }; export const revalidate = 3600; @@ -46,6 +47,27 @@ function DownloadLinks({ Windows + + + Linux AppImage + + + + Debian/Ubuntu + + + + Fedora/RHEL + ); } @@ -83,13 +105,45 @@ function DownloadLinks({ Windows )} + {downloads["linux-appimage"] && ( + + + Linux AppImage + + )} + {downloads["linux-deb"] && ( + + + Debian/Ubuntu + + )} + {downloads["linux-rpm"] && ( + + + Fedora/RHEL + + )} ); } function AppleIcon() { return ( - + ); @@ -97,12 +151,30 @@ function AppleIcon() { function WindowsIcon() { return ( - + ); } +function LinuxIcon() { + return ( + + ); +} + function ReleaseRow({ release, isLatest, @@ -159,6 +231,7 @@ export default async function VersionsPage() { className="inline-flex items-center gap-1 text-sm text-gray-10 hover:text-gray-12" >

- Download previous versions of Cap for macOS and Windows. + Download previous versions of Cap for macOS, Windows, and Linux.

diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 1d2fd4c2757..694119771b9 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -183,6 +183,7 @@ const diagnosticsSchema = z.object({ }) .optional(), macosVersion: z.object({ displayName: z.string() }).optional(), + linuxVersion: z.object({ displayName: z.string() }).optional(), gpuInfo: z .object({ vendor: z.string(), @@ -241,6 +242,8 @@ function formatDiagnosticsForDiscord( lines.push(`**OS:** ${sys.windowsVersion.displayName}`); } else if (sys?.macosVersion?.displayName) { lines.push(`**OS:** ${sys.macosVersion.displayName}`); + } else if (sys?.linuxVersion?.displayName) { + lines.push(`**OS:** ${sys.linuxVersion.displayName}`); } if (sys?.gpuInfo) { @@ -404,7 +407,7 @@ app.post( "form", z.object({ feedback: z.string(), - os: z.union([z.literal("macos"), z.literal("windows")]).optional(), + os: z.enum(["macos", "windows", "linux"]).optional(), version: z.string().optional(), }), ), diff --git a/apps/web/app/api/download/route.ts b/apps/web/app/api/download/route.ts index dd7dd3a1112..d510c223058 100644 --- a/apps/web/app/api/download/route.ts +++ b/apps/web/app/api/download/route.ts @@ -3,18 +3,32 @@ import { type NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; export async function GET(request: NextRequest) { - const userAgent = request.headers.get("user-agent") || ""; + const userAgent = request.headers.get("user-agent")?.toLowerCase() || ""; + const clientPlatform = + request.headers + .get("sec-ch-ua-platform") + ?.replaceAll('"', "") + .toLowerCase() || ""; let platform = "apple-silicon"; - if (userAgent.includes("Windows")) { + if (clientPlatform.includes("windows") || userAgent.includes("windows")) { platform = "windows"; - } else if (userAgent.includes("Mac")) { - if (userAgent.includes("Intel")) { + } else if (clientPlatform.includes("macos") || userAgent.includes("mac")) { + if ( + userAgent.includes("intel") || + userAgent.includes("x86_64") || + userAgent.includes("amd64") + ) { platform = "apple-intel"; } else { platform = "apple-silicon"; } + } else if ( + clientPlatform.includes("linux") || + (userAgent.includes("linux") && !userAgent.includes("android")) + ) { + platform = "linux"; } return NextResponse.redirect(new URL(`/download/${platform}`, request.url)); diff --git a/apps/web/app/install-cli.sh/route.ts b/apps/web/app/install-cli.sh/route.ts index c2134a70bde..8082677ee3a 100644 --- a/apps/web/app/install-cli.sh/route.ts +++ b/apps/web/app/install-cli.sh/route.ts @@ -1,8 +1,10 @@ const capAppPathParameter = "$" + "{CAP_APP_PATH:-}"; const capCliInstallDirParameter = "$" + "{CAP_CLI_INSTALL_DIR:-}"; const capDesktopInstallDirParameter = "$" + "{CAP_DESKTOP_INSTALL_DIR:-}"; +const capDesktopInstallFormatParameter = "$" + "{CAP_DESKTOP_INSTALL_FORMAT:-}"; const capDesktopForceInstallParameter = "$" + "{CAP_DESKTOP_FORCE_INSTALL:-}"; const homeParameter = "$" + "{HOME:-}"; +const xdgDataHomeParameter = "$" + "{XDG_DATA_HOME:-}"; const capNoModifyPathParameter = "$" + "{CAP_NO_MODIFY_PATH:-}"; const shellParameter = "$" + "{SHELL:-/bin/sh}"; const tmpDirParameter = "$" + "{TMPDIR:-/tmp}"; @@ -13,15 +15,21 @@ set -eu APP_PATH="${capAppPathParameter}" CLI_INSTALL_DIR_OVERRIDE="${capCliInstallDirParameter}" DESKTOP_INSTALL_DIR_OVERRIDE="${capDesktopInstallDirParameter}" +DESKTOP_INSTALL_FORMAT="${capDesktopInstallFormatParameter}" DESKTOP_FORCE_INSTALL="${capDesktopForceInstallParameter}" HOME_DIR="${homeParameter}" +XDG_DATA_HOME_DIR="${xdgDataHomeParameter}" TMP_BASE="${tmpDirParameter}" TMP_ROOT="" MOUNT_DIR="" APP_TMP="" +APPIMAGE_PATH="" +APPDIR_PATH="" +CLI_TARGET="" MOUNTED=0 +OS_NAME="$(uname -s)" -find_cap_app() { +find_cap_app_macos() { for candidate in "/Applications/Cap.app" "$HOME_DIR/Applications/Cap.app"; do if [ -d "$candidate" ]; then APP_PATH="$candidate" @@ -46,12 +54,182 @@ cleanup_desktop_install() { fi } -install_cap_desktop() { - if [ "$(uname -s)" != "Darwin" ]; then - echo "Cap Desktop auto-install is only supported on macOS. Install Cap from https://cap.so/download, then run this script again." >&2 +linux_app_data_dir() { + if [ -n "$DESKTOP_INSTALL_DIR_OVERRIDE" ]; then + printf '%s\n' "$DESKTOP_INSTALL_DIR_OVERRIDE" + elif [ -n "$XDG_DATA_HOME_DIR" ]; then + printf '%s\n' "$XDG_DATA_HOME_DIR/cap" + else + printf '%s\n' "$HOME_DIR/.local/share/cap" + fi +} + +linux_require_supported_arch() { + case "$(uname -m)" in + x86_64|amd64) + ;; + *) + echo "Cap for Linux currently ships x86_64 desktop builds. Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; + esac +} + +run_as_root() { + if [ "$(id -u)" = "0" ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + echo "Installing this package requires root. Re-run with sudo installed, or use CAP_DESKTOP_INSTALL_FORMAT=appimage." >&2 + exit 1 + fi +} + +find_cli_in_dir() { + root="$1" + if [ ! -d "$root" ]; then + return 1 + fi + + for dir in "$root" "$root/usr/bin" "$root/usr/lib/cap" "$root/usr/lib/Cap" "$root/usr/lib64/cap" "$root/opt/Cap" "$root/resources" "$root/Contents/MacOS"; do + for name in cap-cli cap-cli-x86_64-unknown-linux-gnu cap-cli-aarch64-unknown-linux-gnu; do + candidate="$dir/$name" + if [ -x "$candidate" ]; then + CLI_TARGET="$candidate" + return 0 + fi + done + done + + candidate="$(find "$root" -type f \( -name "cap-cli" -o -name "cap-cli-*linux*" \) -perm -111 2>/dev/null | head -n 1 || true)" + if [ -n "$candidate" ]; then + CLI_TARGET="$candidate" + return 0 + fi + + return 1 +} + +prepare_appimage_cli() { + if [ -z "$APPIMAGE_PATH" ] || [ ! -f "$APPIMAGE_PATH" ]; then + return 1 + fi + + if [ -z "$APPDIR_PATH" ]; then + APPDIR_PATH="$(linux_app_data_dir)/Cap.AppDir" + fi + + if [ -d "$APPDIR_PATH" ] && find_cli_in_dir "$APPDIR_PATH"; then + if [ ! "$APPIMAGE_PATH" -nt "$CLI_TARGET" ]; then + return 0 + fi + fi + + chmod +x "$APPIMAGE_PATH" + TMP_ROOT="$(mktemp -d "$TMP_BASE/cap-cli-install.XXXXXX")" + EXTRACT_DIR="$TMP_ROOT/appimage" + APP_TMP="$APPDIR_PATH.installing" + mkdir -p "$EXTRACT_DIR" "$(dirname "$APPDIR_PATH")" + + trap cleanup_desktop_install EXIT HUP INT TERM + + echo "Extracting Cap Desktop AppImage..." + if ! (cd "$EXTRACT_DIR" && "$APPIMAGE_PATH" --appimage-extract >/dev/null); then + echo "Could not extract Cap Desktop AppImage." >&2 + exit 1 + fi + + if [ ! -d "$EXTRACT_DIR/squashfs-root" ]; then + echo "Cap Desktop AppImage extraction did not produce an AppDir." >&2 exit 1 fi + rm -rf "$APP_TMP" + mv "$EXTRACT_DIR/squashfs-root" "$APP_TMP" + rm -rf "$APPDIR_PATH" + mv "$APP_TMP" "$APPDIR_PATH" + APP_TMP="" + rm -rf "$TMP_ROOT" + TMP_ROOT="" + trap - EXIT HUP INT TERM + + find_cli_in_dir "$APPDIR_PATH" +} + +find_linux_system_cli() { + for root in /usr/bin /usr/local/bin /usr/lib/cap /usr/lib/Cap /usr/lib64/cap /opt/Cap /opt/cap /usr/share/cap /usr/share/Cap; do + if find_cli_in_dir "$root"; then + return 0 + fi + done + + candidate="$(find /usr/lib /usr/lib64 /opt -type f \( -name "cap-cli" -o -name "cap-cli-*linux*" \) -perm -111 2>/dev/null | head -n 1 || true)" + if [ -n "$candidate" ]; then + CLI_TARGET="$candidate" + return 0 + fi + + return 1 +} + +find_linux_cli_target() { + if [ -n "$APP_PATH" ]; then + if [ -d "$APP_PATH" ]; then + if find_cli_in_dir "$APP_PATH"; then + return 0 + fi + elif [ -f "$APP_PATH" ]; then + case "$APP_PATH" in + *.AppImage|*.appimage) + APPIMAGE_PATH="$APP_PATH" + APPDIR_PATH="$(dirname "$APP_PATH")/Cap.AppDir" + if prepare_appimage_cli; then + return 0 + fi + ;; + *) + if [ -x "$APP_PATH" ]; then + case "$(basename "$APP_PATH")" in + cap-cli|cap-cli-*linux*) + CLI_TARGET="$APP_PATH" + return 0 + ;; + esac + fi + + if find_cli_in_dir "$(dirname "$APP_PATH")"; then + return 0 + fi + ;; + esac + fi + fi + + if find_linux_system_cli; then + return 0 + fi + + DATA_DIR="$(linux_app_data_dir)" + APPDIR_PATH="$DATA_DIR/Cap.AppDir" + if [ -d "$APPDIR_PATH" ] && find_cli_in_dir "$APPDIR_PATH"; then + return 0 + fi + + for candidate in "$DATA_DIR/Cap.AppImage" "$HOME_DIR/Applications/Cap.AppImage" "$HOME_DIR/Cap.AppImage"; do + if [ -f "$candidate" ]; then + APPIMAGE_PATH="$candidate" + APPDIR_PATH="$(dirname "$candidate")/Cap.AppDir" + if prepare_appimage_cli; then + return 0 + fi + fi + done + + return 1 +} + +install_cap_desktop_macos() { case "$(uname -m)" in arm64|aarch64) DOWNLOAD_URL="https://cap.so/download/apple-silicon" ;; x86_64|amd64) DOWNLOAD_URL="https://cap.so/download/apple-intel" ;; @@ -111,37 +289,171 @@ install_cap_desktop() { trap - EXIT HUP INT TERM } -if [ -z "$HOME_DIR" ]; then - echo "Could not determine home directory. Set HOME, then run this script again." >&2 - exit 1 -fi +install_cap_desktop_linux_appimage() { + linux_require_supported_arch + APP_DIR="$(linux_app_data_dir)" + APPIMAGE_PATH="$APP_DIR/Cap.AppImage" + APPDIR_PATH="$APP_DIR/Cap.AppDir" + mkdir -p "$APP_DIR" -if [ -z "$APP_PATH" ]; then - if ! find_cap_app; then - install_cap_desktop + echo "Downloading Cap Desktop AppImage..." + curl --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 -fL "https://cap.so/download/linux-appimage" -o "$APPIMAGE_PATH" + chmod +x "$APPIMAGE_PATH" + + if ! prepare_appimage_cli; then + echo "This Cap Desktop AppImage does not include the CLI." >&2 + exit 1 fi -elif [ ! -d "$APP_PATH" ]; then - echo "Cap Desktop was not found at $APP_PATH." >&2 - exit 1 -fi +} -if [ -n "$DESKTOP_FORCE_INSTALL" ]; then - install_cap_desktop -fi +install_cap_desktop_linux_deb() { + linux_require_supported_arch + TMP_ROOT="$(mktemp -d "$TMP_BASE/cap-cli-install.XXXXXX")" + DEB_PATH="$TMP_ROOT/Cap.deb" + trap cleanup_desktop_install EXIT HUP INT TERM + + echo "Downloading Cap Desktop Debian package..." + curl --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 -fL "https://cap.so/download/linux-deb" -o "$DEB_PATH" + + if command -v apt-get >/dev/null 2>&1; then + run_as_root apt-get install -y "$DEB_PATH" + elif command -v dpkg >/dev/null 2>&1; then + run_as_root dpkg -i "$DEB_PATH" + else + echo "Could not find apt-get or dpkg. Use CAP_DESKTOP_INSTALL_FORMAT=appimage on this system." >&2 + exit 1 + fi -CLI_TARGET="$APP_PATH/Contents/MacOS/cap-cli" + rm -rf "$TMP_ROOT" + TMP_ROOT="" + trap - EXIT HUP INT TERM -if [ ! -x "$CLI_TARGET" ]; then - echo "This Cap Desktop install does not include the CLI. Reinstalling Cap Desktop..." - install_cap_desktop - CLI_TARGET="$APP_PATH/Contents/MacOS/cap-cli" + if ! find_linux_cli_target; then + echo "This Cap Desktop package does not include the CLI." >&2 + exit 1 + fi +} + +install_cap_desktop_linux_rpm() { + linux_require_supported_arch + TMP_ROOT="$(mktemp -d "$TMP_BASE/cap-cli-install.XXXXXX")" + RPM_PATH="$TMP_ROOT/Cap.rpm" + trap cleanup_desktop_install EXIT HUP INT TERM + + echo "Downloading Cap Desktop RPM package..." + curl --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 -fL "https://cap.so/download/linux-rpm" -o "$RPM_PATH" + + if command -v dnf >/dev/null 2>&1; then + run_as_root dnf install -y "$RPM_PATH" + elif command -v yum >/dev/null 2>&1; then + run_as_root yum install -y "$RPM_PATH" + elif command -v zypper >/dev/null 2>&1; then + run_as_root zypper --non-interactive install "$RPM_PATH" + elif command -v rpm >/dev/null 2>&1; then + run_as_root rpm -Uvh "$RPM_PATH" + else + echo "Could not find dnf, yum, zypper, or rpm. Use CAP_DESKTOP_INSTALL_FORMAT=appimage on this system." >&2 + exit 1 + fi + + rm -rf "$TMP_ROOT" + TMP_ROOT="" + trap - EXIT HUP INT TERM + + if ! find_linux_cli_target; then + echo "This Cap Desktop package does not include the CLI." >&2 + exit 1 + fi +} + +install_cap_desktop_linux() { + FORMAT="$(printf '%s' "$DESKTOP_INSTALL_FORMAT" | tr '[:upper:]' '[:lower:]')" + if [ -z "$FORMAT" ]; then + FORMAT="appimage" + fi + + case "$FORMAT" in + appimage|app-image|linux) + install_cap_desktop_linux_appimage + ;; + deb|debian|ubuntu) + install_cap_desktop_linux_deb + ;; + rpm|fedora|rhel|redhat) + install_cap_desktop_linux_rpm + ;; + *) + echo "Unsupported Linux install format: $DESKTOP_INSTALL_FORMAT. Use appimage, deb, or rpm." >&2 + exit 1 + ;; + esac +} + +install_cap_desktop() { + case "$OS_NAME" in + Darwin) + install_cap_desktop_macos + ;; + Linux) + install_cap_desktop_linux + ;; + *) + echo "Cap Desktop auto-install is only supported on macOS and Linux. Install Cap from https://cap.so/download, then run this script again." >&2 + exit 1 + ;; + esac +} + +resolve_cli_target() { + case "$OS_NAME" in + Darwin) + if [ -z "$APP_PATH" ]; then + if ! find_cap_app_macos; then + install_cap_desktop + fi + elif [ ! -d "$APP_PATH" ]; then + echo "Cap Desktop was not found at $APP_PATH." >&2 + exit 1 + fi + + if [ -n "$DESKTOP_FORCE_INSTALL" ]; then + install_cap_desktop + fi + + CLI_TARGET="$APP_PATH/Contents/MacOS/cap-cli" + + if [ ! -x "$CLI_TARGET" ]; then + echo "This Cap Desktop install does not include the CLI. Reinstalling Cap Desktop..." + install_cap_desktop + CLI_TARGET="$APP_PATH/Contents/MacOS/cap-cli" + fi + ;; + Linux) + if [ -n "$DESKTOP_FORCE_INSTALL" ]; then + install_cap_desktop + elif ! find_linux_cli_target; then + install_cap_desktop + fi + ;; + *) + echo "Unsupported operating system: $OS_NAME" >&2 + exit 1 + ;; + esac if [ ! -x "$CLI_TARGET" ]; then echo "This Cap Desktop install does not include the CLI." >&2 exit 1 fi +} + +if [ -z "$HOME_DIR" ]; then + echo "Could not determine home directory. Set HOME, then run this script again." >&2 + exit 1 fi +resolve_cli_target + if [ -n "$CLI_INSTALL_DIR_OVERRIDE" ]; then INSTALL_DIR="$CLI_INSTALL_DIR_OVERRIDE" elif case ":$PATH:" in *:"$HOME_DIR/.local/bin":*) true ;; *) false ;; esac; then @@ -162,7 +474,7 @@ if [ -e "$SHIM_PATH" ] || [ -L "$SHIM_PATH" ]; then EXISTING_TARGET="$(readlink "$SHIM_PATH" || true)" case "$EXISTING_TARGET" in - "$CLI_TARGET"|*/Cap.app/Contents/MacOS/cap-cli) + "$CLI_TARGET"|*/Cap.app/Contents/MacOS/cap-cli|*/cap-cli|*/cap-cli-*linux*) ;; *) echo "$SHIM_PATH already exists and is not managed by Cap. Remove it or set CAP_CLI_INSTALL_DIR, then run this script again." >&2 diff --git a/apps/web/components/pages/DownloadPage.tsx b/apps/web/components/pages/DownloadPage.tsx index 5b6a77b210b..779944816ce 100644 --- a/apps/web/components/pages/DownloadPage.tsx +++ b/apps/web/components/pages/DownloadPage.tsx @@ -50,8 +50,8 @@ export const DownloadPage = () => { Download Cap

- The quickest way to share your screen. Pin to your dock and record in - seconds. + The quickest way to share your screen. Pin to your dock or taskbar and + record in seconds.

@@ -143,6 +143,45 @@ export const DownloadPage = () => { Windows (Beta) )} + {platform !== "linux" && ( + + trackDownloadClick("other_option_linux", "/download/linux") + } + className="text-sm transition-all text-gray-10 hover:underline" + > + Linux AppImage + + )} + {platform === "linux" && ( + <> + + trackDownloadClick( + "other_option_linux_deb", + "/download/linux-deb", + ) + } + className="text-sm transition-all text-gray-10 hover:underline" + > + Debian/Ubuntu + + + trackDownloadClick( + "other_option_linux_rpm", + "/download/linux-rpm", + ) + } + className="text-sm transition-all text-gray-10 hover:underline" + > + Fedora/RHEL + + + )} {platform === "macos" && isIntel && ( { if (platform === "windows") { return ( - + ); } else if (platform === "macos") { return ( - + ); + } else if (platform === "linux") { + return ( + + ); } else { return null; } @@ -54,6 +78,8 @@ export const getVersionText = (platform: string | null): React.ReactNode => { return "macOS 13.1+ recommended"; } else if (platform === "windows") { return "Windows 10+ recommended"; + } else if (platform === "linux") { + return "Linux x86_64 AppImage recommended"; } else { return "macOS 13.1+ recommended"; } @@ -70,6 +96,7 @@ export const PlatformIcons: React.FC = ({ diff --git a/apps/web/utils/releases.ts b/apps/web/utils/releases.ts index c681c94096d..0b829b4814a 100644 --- a/apps/web/utils/releases.ts +++ b/apps/web/utils/releases.ts @@ -2,8 +2,22 @@ export interface ReleaseDownloads { "macos-arm64"?: string; "macos-x64"?: string; windows?: string; + "linux-appimage"?: string; + "linux-deb"?: string; + "linux-rpm"?: string; } +export type ReleaseDownloadKey = keyof ReleaseDownloads; + +export const releaseDownloadKeys = [ + "macos-arm64", + "macos-x64", + "windows", + "linux-appimage", + "linux-deb", + "linux-rpm", +] satisfies ReleaseDownloadKey[]; + export interface Release { version: string; tagName: string; @@ -23,18 +37,23 @@ interface GitHubRelease { prerelease: boolean; } -function parseDownloadsFromBody(body: string): ReleaseDownloads { +export function parseDownloadsFromBody(body: string): ReleaseDownloads { const downloads: ReleaseDownloads = {}; const jsonMatch = body.match(//); if (jsonMatch?.[1]) { try { - const parsed = JSON.parse(jsonMatch[1]); - if (parsed["macos-arm64"]) - downloads["macos-arm64"] = parsed["macos-arm64"]; - if (parsed["macos-x64"]) downloads["macos-x64"] = parsed["macos-x64"]; - if (parsed.windows) downloads.windows = parsed.windows; + const parsed: unknown = JSON.parse(jsonMatch[1]); + if (!parsed || typeof parsed !== "object") return downloads; + + const values = parsed as Partial>; + for (const key of releaseDownloadKeys) { + const value = values[key]; + if (typeof value === "string" && value.length > 0) { + downloads[key] = value; + } + } } catch {} } @@ -79,9 +98,5 @@ export async function getGitHubReleases(): Promise { } export function hasDownloads(downloads: ReleaseDownloads): boolean { - return !!( - downloads["macos-arm64"] || - downloads["macos-x64"] || - downloads.windows - ); + return releaseDownloadKeys.some((key) => !!downloads[key]); } diff --git a/crates/camera-ffmpeg/src/lib.rs b/crates/camera-ffmpeg/src/lib.rs index c8221916641..3ec20823a6b 100644 --- a/crates/camera-ffmpeg/src/lib.rs +++ b/crates/camera-ffmpeg/src/lib.rs @@ -8,6 +8,11 @@ mod windows; #[cfg(windows)] pub use windows::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + pub trait CapturedFrameExt { /// Creates an ffmpeg video frame from the native frame. /// Only size, format, and data are set. diff --git a/crates/camera-ffmpeg/src/linux.rs b/crates/camera-ffmpeg/src/linux.rs new file mode 100644 index 00000000000..89e42e2a85b --- /dev/null +++ b/crates/camera-ffmpeg/src/linux.rs @@ -0,0 +1,249 @@ +use cap_camera::{CapturedFrame, NativeFrameFormat}; +use ffmpeg::{Packet, format::Pixel, frame::Video as FFVideo}; +use std::cell::RefCell; + +use crate::CapturedFrameExt; + +#[derive(thiserror::Error, Debug)] +pub enum AsFFmpegError { + #[error("Unsupported Linux camera pixel format '{0}'")] + UnsupportedFormat(String), + #[error( + "Invalid camera frame data for {format}: expected at least {expected} bytes, found {actual}" + )] + InvalidFrameData { + format: String, + expected: usize, + actual: usize, + }, + #[error("MJPEG decode error: {0}")] + MjpegDecodeError(String), + #[error("Invalid camera frame stride for {format}: {stride}")] + InvalidFrameStride { format: String, stride: usize }, +} + +struct MjpegDecoder { + decoder: ffmpeg::codec::decoder::Video, +} + +impl MjpegDecoder { + fn new() -> Result { + let codec = ffmpeg::codec::decoder::find(ffmpeg::codec::Id::MJPEG) + .ok_or_else(|| AsFFmpegError::MjpegDecodeError("MJPEG codec not found".to_string()))?; + + let decoder_context = ffmpeg::codec::context::Context::new_with_codec(codec); + let decoder = decoder_context.decoder().video().map_err(|e| { + AsFFmpegError::MjpegDecodeError(format!("Failed to create decoder: {e}")) + })?; + + Ok(Self { decoder }) + } + + fn decode(&mut self, bytes: &[u8]) -> Result { + let packet = Packet::copy(bytes); + self.decoder + .send_packet(&packet) + .map_err(|e| AsFFmpegError::MjpegDecodeError(format!("Failed to send packet: {e}")))?; + + let mut decoded_frame = FFVideo::empty(); + self.decoder + .receive_frame(&mut decoded_frame) + .map_err(|e| { + AsFFmpegError::MjpegDecodeError(format!("Failed to receive frame: {e}")) + })?; + + Ok(decoded_frame) + } +} + +thread_local! { + static MJPEG_DECODER: RefCell> = const { RefCell::new(None) }; +} + +fn decode_mjpeg(bytes: &[u8]) -> Result { + MJPEG_DECODER.with(|decoder_cell| { + let mut decoder_opt = decoder_cell.borrow_mut(); + + if decoder_opt.is_none() { + *decoder_opt = Some(MjpegDecoder::new()?); + } + + decoder_opt.as_mut().unwrap().decode(bytes) + }) +} + +impl CapturedFrameExt for CapturedFrame { + fn as_ffmpeg(&self) -> Result { + let native = self.native(); + let format = native.format; + let bytes = native.bytes.as_slice(); + + match fourcc_str(format.fourcc).as_str() { + "YUYV" => copy_packed(bytes, format, Pixel::YUYV422, 2), + "UYVY" => copy_packed(bytes, format, Pixel::UYVY422, 2), + "RGB3" => copy_packed(bytes, format, Pixel::RGB24, 3), + "BGR3" => copy_packed(bytes, format, Pixel::BGR24, 3), + "NV12" => copy_nv12(bytes, format), + "YU12" => copy_yuv420(bytes, format, false), + "YV12" => copy_yuv420(bytes, format, true), + "MJPG" | "JPEG" => decode_mjpeg(bytes), + other => Err(AsFFmpegError::UnsupportedFormat(other.to_string())), + } + } +} + +fn copy_packed( + bytes: &[u8], + format: NativeFrameFormat, + pixel: Pixel, + bytes_per_pixel: usize, +) -> Result { + let width = format.width as usize; + let height = format.height as usize; + let row_width = width * bytes_per_pixel; + let source_stride = format.stride.max(row_width); + require_len(bytes, source_stride * height, format)?; + + let mut frame = FFVideo::new(pixel, format.width, format.height); + let dest_stride = frame.stride(0); + let dest = frame.data_mut(0); + + for y in 0..height { + let source_start = y * source_stride; + let source_end = source_start + row_width; + let dest_start = y * dest_stride; + dest[dest_start..dest_start + row_width].copy_from_slice(&bytes[source_start..source_end]); + } + + Ok(frame) +} + +fn copy_nv12(bytes: &[u8], format: NativeFrameFormat) -> Result { + let width = format.width as usize; + let height = format.height as usize; + let source_stride = format.stride.max(width); + let y_size = source_stride * height; + let uv_height = height / 2; + require_len(bytes, y_size + source_stride * uv_height, format)?; + + let mut frame = FFVideo::new(Pixel::NV12, format.width, format.height); + let y_dest_stride = frame.stride(0); + copy_plane( + bytes, + 0, + source_stride, + frame.data_mut(0), + y_dest_stride, + width, + height, + ); + let uv_dest_stride = frame.stride(1); + copy_plane( + bytes, + y_size, + source_stride, + frame.data_mut(1), + uv_dest_stride, + width, + uv_height, + ); + + Ok(frame) +} + +fn copy_yuv420( + bytes: &[u8], + format: NativeFrameFormat, + v_before_u: bool, +) -> Result { + let width = format.width as usize; + let height = format.height as usize; + let y_stride = format.stride.max(width); + let chroma_width = width / 2; + let chroma_height = height / 2; + if y_stride % 2 != 0 { + return Err(AsFFmpegError::InvalidFrameStride { + format: fourcc_str(format.fourcc), + stride: y_stride, + }); + } + let chroma_stride = y_stride / 2; + let y_size = y_stride * height; + let chroma_size = chroma_stride * chroma_height; + require_len(bytes, y_size + chroma_size * 2, format)?; + + let mut frame = FFVideo::new(Pixel::YUV420P, format.width, format.height); + let y_dest_stride = frame.stride(0); + copy_plane( + bytes, + 0, + y_stride, + frame.data_mut(0), + y_dest_stride, + width, + height, + ); + + let first_chroma_plane = if v_before_u { 2 } else { 1 }; + let second_chroma_plane = if v_before_u { 1 } else { 2 }; + + let first_chroma_stride = frame.stride(first_chroma_plane); + copy_plane( + bytes, + y_size, + chroma_stride, + frame.data_mut(first_chroma_plane), + first_chroma_stride, + chroma_width, + chroma_height, + ); + let second_chroma_stride = frame.stride(second_chroma_plane); + copy_plane( + bytes, + y_size + chroma_size, + chroma_stride, + frame.data_mut(second_chroma_plane), + second_chroma_stride, + chroma_width, + chroma_height, + ); + + Ok(frame) +} + +fn copy_plane( + source: &[u8], + source_offset: usize, + source_stride: usize, + dest: &mut [u8], + dest_stride: usize, + row_width: usize, + height: usize, +) { + for y in 0..height { + let source_start = source_offset + y * source_stride; + let dest_start = y * dest_stride; + dest[dest_start..dest_start + row_width] + .copy_from_slice(&source[source_start..source_start + row_width]); + } +} + +fn require_len( + bytes: &[u8], + expected: usize, + format: NativeFrameFormat, +) -> Result<(), AsFFmpegError> { + if bytes.len() < expected { + return Err(AsFFmpegError::InvalidFrameData { + format: fourcc_str(format.fourcc), + expected, + actual: bytes.len(), + }); + } + + Ok(()) +} + +fn fourcc_str(fourcc: [u8; 4]) -> String { + String::from_utf8_lossy(&fourcc).to_string() +} diff --git a/crates/camera/Cargo.toml b/crates/camera/Cargo.toml index 5e63811c5e6..e678b535bc1 100644 --- a/crates/camera/Cargo.toml +++ b/crates/camera/Cargo.toml @@ -20,6 +20,9 @@ windows = { workspace = true } windows-core = { workspace = true } cap-camera-windows = { path = "../camera-windows" } +[target.'cfg(target_os = "linux")'.dependencies] +v4l = "0.14.0" + [dev-dependencies] inquire = "0.7.5" diff --git a/crates/camera/src/lib.rs b/crates/camera/src/lib.rs index cec0e04961a..2c5f09a1715 100644 --- a/crates/camera/src/lib.rs +++ b/crates/camera/src/lib.rs @@ -1,5 +1,3 @@ -#![cfg(any(windows, target_os = "macos"))] - use std::{ fmt::{Debug, Display}, ops::Deref, @@ -16,6 +14,11 @@ mod windows; #[cfg(windows)] use windows::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "specta", derive(specta::Type))] @@ -105,6 +108,10 @@ impl Debug for Format { VideoFormatInner::MediaFoundation(_) => &"MediaFoundation", } } + #[cfg(target_os = "linux")] + { + &"Linux" + } }) .finish() } @@ -185,6 +192,9 @@ pub enum StartCapturingError { #[cfg(windows)] #[error("{0}")] Native(windows_core::Error), + #[cfg(target_os = "linux")] + #[error("{0}")] + Native(String), } #[derive(Debug)] @@ -209,13 +219,15 @@ impl CameraInfo { pub fn start_capturing( &self, format: Format, - callback: impl FnMut(CapturedFrame) + 'static, + callback: impl FnMut(CapturedFrame) + Send + 'static, ) -> Result { Ok(CaptureHandle { #[cfg(target_os = "macos")] native: Some(start_capturing_impl(self, format, Box::new(callback))?), #[cfg(windows)] native: Some(start_capturing_impl(self, format, Box::new(callback))?), + #[cfg(target_os = "linux")] + native: Some(start_capturing_impl(self, format, Box::new(callback))?), }) } } diff --git a/crates/camera/src/linux.rs b/crates/camera/src/linux.rs new file mode 100644 index 00000000000..4b6a52999ad --- /dev/null +++ b/crates/camera/src/linux.rs @@ -0,0 +1,393 @@ +use crate::{CameraInfo, CapturedFrame, Format, FormatInfo, StartCapturingError}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; +use v4l::{ + Device, FourCC, + buffer::Type, + capability::Flags as CapabilityFlags, + format::Format as V4lFormat, + frameinterval::{FrameInterval, FrameIntervalEnum}, + framesize::{FrameSize, FrameSizeEnum}, + io::{mmap::Stream as MmapStream, traits::CaptureStream}, + video::{Capture, capture::Parameters as CaptureParameters}, +}; + +const PREFERRED_FOURCCS: &[([u8; 4], u32)] = &[ + (*b"YUYV", 0), + (*b"UYVY", 1), + (*b"NV12", 2), + (*b"RGB3", 3), + (*b"BGR3", 4), + (*b"YU12", 5), + (*b"YV12", 6), + (*b"MJPG", 7), + (*b"JPEG", 8), +]; + +const PREFERRED_STEPWISE_SIZES: &[(u32, u32)] = &[ + (3840, 2160), + (2560, 1440), + (1920, 1080), + (1280, 720), + (1024, 768), + (800, 600), + (640, 480), + (320, 240), +]; + +const PREFERRED_STEPWISE_FPS: &[u32] = &[60, 30, 24, 15]; + +#[derive(Clone, Debug)] +pub struct NativeFormat { + pub fourcc: [u8; 4], + pub width: u32, + pub height: u32, + pub frame_rate: f32, + pub interval: v4l::Fraction, +} + +#[derive(Clone, Copy, Debug)] +pub struct NativeFrameFormat { + pub fourcc: [u8; 4], + pub width: u32, + pub height: u32, + pub stride: usize, +} + +#[derive(Debug)] +pub struct NativeCapturedFrame { + pub bytes: Vec, + pub format: NativeFrameFormat, +} + +pub struct NativeCaptureHandle { + stop_tx: Option>, + thread: Option>, +} + +impl NativeCaptureHandle { + pub fn stop_capturing(mut self) -> Result<(), String> { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| "Linux camera capture thread panicked".to_string())?; + } + + Ok(()) + } +} + +impl Drop for NativeCaptureHandle { + fn drop(&mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + } +} + +pub fn list_cameras_impl() -> impl Iterator { + video_device_paths() + .into_iter() + .filter_map(|path| { + let device = Device::with_path(&path).ok()?; + let caps = device.query_caps().ok()?; + let is_capture = caps + .capabilities + .contains(CapabilityFlags::VIDEO_CAPTURE | CapabilityFlags::STREAMING); + + if !is_capture { + return None; + } + + Some(CameraInfo { + device_id: path.display().to_string(), + model_id: None, + display_name: if caps.card.is_empty() { + path.display().to_string() + } else { + caps.card + }, + }) + }) + .collect::>() + .into_iter() +} + +impl CameraInfo { + pub fn formats_impl(&self) -> Option> { + let device = open_device(self).ok()?; + let mut formats = Vec::new(); + + for desc in device.enum_formats().ok()? { + if fourcc_rank(desc.fourcc.repr).is_none() { + continue; + } + + for (width, height) in frame_sizes(&device, desc.fourcc) { + for (frame_rate, interval) in frame_rates(&device, desc.fourcc, width, height) { + formats.push(Format { + info: FormatInfo { + width, + height, + frame_rate, + }, + native: NativeFormat { + fourcc: desc.fourcc.repr, + width, + height, + frame_rate, + interval, + }, + }); + } + } + } + + formats.sort_by(|a, b| { + fourcc_rank(a.native.fourcc) + .unwrap_or(u32::MAX) + .cmp(&fourcc_rank(b.native.fourcc).unwrap_or(u32::MAX)) + .then((b.width() * b.height()).cmp(&(a.width() * a.height()))) + .then( + (b.frame_rate() * 100.0) + .round() + .total_cmp(&(a.frame_rate() * 100.0).round()), + ) + }); + formats.dedup_by(|a, b| { + a.native.fourcc == b.native.fourcc + && a.width() == b.width() + && a.height() == b.height() + && (a.frame_rate() - b.frame_rate()).abs() < 0.01 + }); + + Some(formats) + } +} + +pub fn start_capturing_impl( + camera: &CameraInfo, + format: Format, + mut callback: Box, +) -> Result { + let device = open_device(camera)?; + let requested = V4lFormat::new( + format.native().width, + format.native().height, + FourCC::new(&format.native().fourcc), + ); + let active_format = device + .set_format(&requested) + .map_err(|e| StartCapturingError::Native(e.to_string()))?; + let _ = device.set_params(&CaptureParameters::new(format.native().interval)); + + let frame_format = NativeFrameFormat { + fourcc: active_format.fourcc.repr, + width: active_format.width, + height: active_format.height, + stride: active_format.stride as usize, + }; + + let (stop_tx, stop_rx) = mpsc::sync_channel(1); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let started = Instant::now(); + + let thread = thread::spawn(move || { + let mut stream = match MmapStream::with_buffers(&device, Type::VideoCapture, 4) { + Ok(stream) => { + let _ = ready_tx.send(Ok(())); + stream + } + Err(e) => { + let _ = ready_tx.send(Err(e.to_string())); + return; + } + }; + + loop { + if stop_rx.try_recv().is_ok() { + break; + } + + match stream.next() { + Ok((bytes, meta)) => { + let used = meta.bytesused as usize; + let bytes = bytes.get(..used).unwrap_or(bytes); + callback(CapturedFrame { + native: NativeCapturedFrame { + bytes: bytes.to_vec(), + format: frame_format, + }, + timestamp: started.elapsed(), + }); + } + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + thread::sleep(Duration::from_millis(5)); + } + Err(_) => { + thread::sleep(Duration::from_millis(20)); + } + } + } + }); + + match ready_rx.recv_timeout(Duration::from_secs(2)) { + Ok(Ok(())) => Ok(NativeCaptureHandle { + stop_tx: Some(stop_tx), + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(StartCapturingError::Native(error)) + } + Err(error) => Err(StartCapturingError::Native(error.to_string())), + } +} + +fn video_device_paths() -> Vec { + let mut paths = fs::read_dir("/dev") + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(Result::ok)) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.strip_prefix("video") + .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit())) + }) + }) + .collect::>(); + + paths.sort_by_key(|path| video_index(path).unwrap_or(u32::MAX)); + paths +} + +fn video_index(path: &Path) -> Option { + path.file_name()? + .to_str()? + .strip_prefix("video")? + .parse() + .ok() +} + +fn open_device(camera: &CameraInfo) -> Result { + Device::with_path(camera.device_id()).map_err(|e| StartCapturingError::Native(e.to_string())) +} + +fn fourcc_rank(fourcc: [u8; 4]) -> Option { + PREFERRED_FOURCCS + .iter() + .find_map(|(candidate, rank)| (*candidate == fourcc).then_some(*rank)) +} + +fn frame_sizes(device: &Device, fourcc: FourCC) -> Vec<(u32, u32)> { + let mut sizes = device + .enum_framesizes(fourcc) + .unwrap_or_default() + .into_iter() + .flat_map(frame_size_candidates) + .collect::>(); + + sizes.sort_unstable(); + sizes.dedup(); + sizes +} + +fn frame_size_candidates(size: FrameSize) -> Vec<(u32, u32)> { + match size.size { + FrameSizeEnum::Discrete(discrete) => vec![(discrete.width, discrete.height)], + FrameSizeEnum::Stepwise(stepwise) => { + let mut sizes = PREFERRED_STEPWISE_SIZES + .iter() + .copied() + .filter(|(width, height)| { + in_stepwise_range( + *width, + stepwise.min_width, + stepwise.max_width, + stepwise.step_width, + ) && in_stepwise_range( + *height, + stepwise.min_height, + stepwise.max_height, + stepwise.step_height, + ) + }) + .collect::>(); + + if sizes.is_empty() { + sizes.push((stepwise.max_width, stepwise.max_height)); + } + + sizes + } + } +} + +fn in_stepwise_range(value: u32, min: u32, max: u32, step: u32) -> bool { + value >= min && value <= max && (step == 0 || (value - min).is_multiple_of(step)) +} + +fn frame_rates( + device: &Device, + fourcc: FourCC, + width: u32, + height: u32, +) -> Vec<(f32, v4l::Fraction)> { + let mut rates = device + .enum_frameintervals(fourcc, width, height) + .unwrap_or_default() + .into_iter() + .flat_map(frame_rate_candidates) + .collect::>(); + + if rates.is_empty() { + rates.push((30.0, v4l::Fraction::new(1, 30))); + } + + rates.sort_by(|a, b| b.0.total_cmp(&a.0)); + rates.dedup_by(|a, b| (a.0 - b.0).abs() < 0.01); + rates +} + +fn frame_rate_candidates(interval: FrameInterval) -> Vec<(f32, v4l::Fraction)> { + match interval.interval { + FrameIntervalEnum::Discrete(fraction) => fps_from_fraction(fraction) + .map(|fps| vec![(fps, fraction)]) + .unwrap_or_default(), + FrameIntervalEnum::Stepwise(stepwise) => { + let min_fps = fps_from_fraction(stepwise.max).unwrap_or(0.0); + let max_fps = fps_from_fraction(stepwise.min).unwrap_or(f32::MAX); + + PREFERRED_STEPWISE_FPS + .iter() + .copied() + .filter(|fps| { + let fps = *fps as f32; + fps >= min_fps && fps <= max_fps + }) + .map(|fps| (fps as f32, v4l::Fraction::new(1, fps))) + .collect() + } + } +} + +fn fps_from_fraction(fraction: v4l::Fraction) -> Option { + (fraction.numerator != 0).then_some(fraction.denominator as f32 / fraction.numerator as f32) +} diff --git a/crates/cli-install/src/lib.rs b/crates/cli-install/src/lib.rs index 3ab7e00028a..3b86484c1c3 100644 --- a/crates/cli-install/src/lib.rs +++ b/crates/cli-install/src/lib.rs @@ -6,10 +6,10 @@ //! current executable, which is the bundled CLI for both callers. use serde::Serialize; +#[cfg(unix)] +use std::ffi::OsStr; use std::{ - env, - ffi::OsStr, - fs, + env, fs, path::{Path, PathBuf}, }; @@ -177,6 +177,7 @@ fn current_target_triple() -> Option<&'static str> { } } +#[cfg(unix)] fn cli_binary_file_name_is_cap_managed(name: &OsStr) -> bool { if name == CLI_BINARY_NAME { return true; diff --git a/crates/cursor-capture/src/position.rs b/crates/cursor-capture/src/position.rs index bb4ea757198..fe537d46eb0 100644 --- a/crates/cursor-capture/src/position.rs +++ b/crates/cursor-capture/src/position.rs @@ -55,6 +55,17 @@ impl RelativeCursorPosition { display, }) } + + #[cfg(target_os = "linux")] + { + let physical_bounds = display.raw_handle().physical_bounds()?; + + Some(Self { + x: raw.x - physical_bounds.position().x() as i32, + y: raw.y - physical_bounds.position().y() as i32, + display, + }) + } } pub fn display(&self) -> &Display { @@ -97,6 +108,24 @@ impl RelativeCursorPosition { display: self.display, }) } + + #[cfg(target_os = "linux")] + { + let bounds = self.display().raw_handle().physical_bounds()?; + let size = bounds.size(); + + Some(NormalizedCursorPosition { + x: self.x as f64 / size.width(), + y: self.y as f64 / size.height(), + crop: CursorCropBounds { + x: 0.0, + y: 0.0, + width: size.width(), + height: size.height(), + }, + display: self.display, + }) + } } } @@ -140,6 +169,16 @@ impl CursorCropBounds { } } + #[cfg(target_os = "linux")] + pub fn new_linux(bounds: PhysicalBounds) -> Self { + Self { + x: bounds.position().x(), + y: bounds.position().y(), + width: bounds.size().width(), + height: bounds.size().height(), + } + } + pub fn x(&self) -> f64 { self.x } diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 15db6a1724f..0ec6690cce2 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -451,7 +451,11 @@ fn open_video_encoder_inner( input_config.pixel_format } else { needs_pixel_conversion = true; - ffmpeg::format::Pixel::NV12 + if codec.name() == "libx264" { + ffmpeg::format::Pixel::YUV420P + } else { + ffmpeg::format::Pixel::NV12 + } }; debug!( @@ -539,44 +543,47 @@ fn open_video_encoder_inner( None }; - let mut encoder_ctx = context::Context::new_with_codec(codec); - let thread_count = thread::available_parallelism() .map(|v| v.get()) .unwrap_or(1); - encoder_ctx.set_threading(Config::count(thread_count)); - let mut encoder = encoder_ctx.encoder().video()?; - - encoder.set_width(output_width); - encoder.set_height(output_height); - encoder.set_format(output_format); - encoder.set_time_base(input_config.time_base); - encoder.set_frame_rate(Some(input_config.frame_rate)); - encoder.set_colorspace(color::Space::BT709); - encoder.set_color_range(color::Range::MPEG); - unsafe { - (*encoder.as_mut_ptr()).color_primaries = ffmpeg::ffi::AVColorPrimaries::AVCOL_PRI_BT709; - (*encoder.as_mut_ptr()).color_trc = - ffmpeg::ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709; - if global_header { - (*encoder.as_mut_ptr()).flags |= ffmpeg::ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32; + + let encoder = { + let mut encoder_ctx = context::Context::new_with_codec(codec); + encoder_ctx.set_threading(Config::count(thread_count)); + let mut encoder = encoder_ctx.encoder().video()?; + + encoder.set_width(output_width); + encoder.set_height(output_height); + encoder.set_format(output_format); + encoder.set_time_base(input_config.time_base); + encoder.set_frame_rate(Some(input_config.frame_rate)); + encoder.set_colorspace(color::Space::BT709); + encoder.set_color_range(color::Range::MPEG); + unsafe { + (*encoder.as_mut_ptr()).color_primaries = + ffmpeg::ffi::AVColorPrimaries::AVCOL_PRI_BT709; + (*encoder.as_mut_ptr()).color_trc = + ffmpeg::ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709; + if global_header { + (*encoder.as_mut_ptr()).flags |= ffmpeg::ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32; + } } - } - if crf.is_some() { - encoder.set_bit_rate(0); - } else { - let bitrate = get_bitrate( - output_width, - output_height, - input_config.frame_rate.0 as f32 / input_config.frame_rate.1 as f32, - bpp, - ); - encoder.set_bit_rate(bitrate); - encoder.set_max_bit_rate(bitrate * 3 / 2); - } + if crf.is_some() { + encoder.set_bit_rate(0); + } else { + let bitrate = get_bitrate( + output_width, + output_height, + input_config.frame_rate.0 as f32 / input_config.frame_rate.1 as f32, + bpp, + ); + encoder.set_bit_rate(bitrate); + encoder.set_max_bit_rate(bitrate * 3 / 2); + } - let encoder = encoder.open_with(encoder_options)?; + encoder.open_as_with(codec, encoder_options)? + }; let converted_frame_pool = if converter.is_some() { Some(frame::Video::new( @@ -743,18 +750,29 @@ impl H264Encoder { } } +#[cfg(any(target_os = "macos", target_os = "windows"))] const VIDEOTOOLBOX_4K_MAX_FPS: f64 = 55.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const VIDEOTOOLBOX_1080P_MAX_FPS: f64 = 190.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const NVENC_4K_MAX_FPS: f64 = 120.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const NVENC_1080P_MAX_FPS: f64 = 500.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const QSV_4K_MAX_FPS: f64 = 90.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const QSV_1080P_MAX_FPS: f64 = 300.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const AMF_4K_MAX_FPS: f64 = 100.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const AMF_1080P_MAX_FPS: f64 = 350.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const PIXELS_4K: f64 = 3840.0 * 2160.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] const PIXELS_1080P: f64 = 1920.0 * 1080.0; +#[cfg(any(target_os = "macos", target_os = "windows"))] fn estimate_hw_encoder_max_fps(encoder_name: &str, width: u32, height: u32) -> f64 { let pixels = (width as f64) * (height as f64); @@ -785,10 +803,13 @@ fn requires_software_encoder(config: &VideoInfo, preset: H264Preset, is_export: return true; } - let fps = config.frame_rate.numerator() as f64 / config.frame_rate.denominator().max(1) as f64; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let _ = config; #[cfg(target_os = "macos")] { + let fps = + config.frame_rate.numerator() as f64 / config.frame_rate.denominator().max(1) as f64; let max_hw_fps = estimate_hw_encoder_max_fps("h264_videotoolbox", config.width, config.height); let headroom_factor = 0.9; @@ -808,6 +829,8 @@ fn requires_software_encoder(config: &VideoInfo, preset: H264Preset, is_export: { use cap_frame_converter::{GpuVendor, detect_primary_gpu}; + let fps = + config.frame_rate.numerator() as f64 / config.frame_rate.denominator().max(1) as f64; let encoder_name = match detect_primary_gpu().map(|info| info.vendor) { Some(GpuVendor::Nvidia) => "h264_nvenc", Some(GpuVendor::Amd) => "h264_amf", @@ -1049,7 +1072,6 @@ fn get_codec_and_options( options.set("preset", realtime_preset); options.set("tune", "zerolatency"); } - options.set("vsync", "1"); options.set("g", &keyframe_interval_str); options.set("keyint_min", &keyframe_interval_str); } diff --git a/crates/frame-converter/src/lib.rs b/crates/frame-converter/src/lib.rs index 572a449c8b8..cbe4c581072 100644 --- a/crates/frame-converter/src/lib.rs +++ b/crates/frame-converter/src/lib.rs @@ -185,7 +185,10 @@ pub fn create_converter_with_details( config.output_height ); + #[cfg(any(target_os = "macos", target_os = "windows"))] let mut fallback_reasons: Vec = Vec::new(); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let fallback_reasons: Vec = Vec::new(); #[cfg(target_os = "macos")] { diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 33360361ec9..35efde97b70 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -69,6 +69,7 @@ pub struct SharingMeta { pub enum Platform { MacOS, Windows, + Linux, } impl Default for Platform { @@ -76,8 +77,11 @@ impl Default for Platform { #[cfg(windows)] return Self::Windows; - #[cfg(not(windows))] + #[cfg(target_os = "macos")] return Self::MacOS; + + #[cfg(target_os = "linux")] + return Self::Linux; } } diff --git a/crates/recording/Cargo.toml b/crates/recording/Cargo.toml index 46a619920b8..e00167142b5 100644 --- a/crates/recording/Cargo.toml +++ b/crates/recording/Cargo.toml @@ -97,6 +97,11 @@ cap-camera-windows = { path = "../camera-windows" } scap-direct3d = { path = "../scap-direct3d" } scap-cpal = { path = "../scap-cpal" } +[target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.11.0", default-features = false, features = ["tokio"] } +pipewire = "0.10.0" +x11rb = { version = "0.13.2", features = ["xfixes"] } + [dev-dependencies] tempfile = "3.20.0" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index aa7847aa261..d21c01d1167 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -9,7 +9,10 @@ use crate::output_pipeline::{MacOSFragmentedM4SMuxer, MacOSFragmentedM4SMuxerCon #[cfg(windows)] use crate::output_pipeline::{WindowsFragmentedM4SMuxer, WindowsFragmentedM4SMuxerConfig}; use anyhow::anyhow; +#[cfg(any(target_os = "macos", windows))] use cap_enc_ffmpeg::h264::H264EncoderBuilder; +#[cfg(target_os = "linux")] +use cap_enc_ffmpeg::h264::H264Preset; #[cfg(windows)] use cap_enc_ffmpeg::h264::H264Preset; use cap_enc_ffmpeg::segmented_stream::SegmentCompletedEvent; @@ -353,12 +356,69 @@ impl MakeCapturePipeline for screen_capture::Direct3DCapture { } } +#[cfg(target_os = "linux")] +impl MakeCapturePipeline for screen_capture::X11Capture { + async fn make_studio_mode_pipeline( + screen_capture: screen_capture::VideoSourceConfig, + output_path: PathBuf, + start_time: Timestamps, + _fragmented: bool, + _use_oop_muxer: bool, + shared_pause_state: Option, + output_size: Option<(u32, u32)>, + quality: StudioQuality, + ) -> anyhow::Result { + let fragments_dir = output_path + .parent() + .map(|p| p.join("display")) + .unwrap_or_else(|| output_path.with_file_name("display")); + + let ultra = quality == StudioQuality::Ultra; + OutputPipeline::builder(fragments_dir) + .with_video::(screen_capture) + .with_timestamps(start_time) + .build::(crate::ffmpeg::SegmentedVideoMuxerConfig { + segment_duration: std::time::Duration::from_secs(2), + preset: if ultra { + H264Preset::Medium + } else { + H264Preset::Ultrafast + }, + output_size, + shared_pause_state, + }) + .await + } + + async fn make_instant_segmented_video_pipeline( + screen_capture: screen_capture::VideoSourceConfig, + segments_dir: PathBuf, + output_size: (u32, u32), + start_time: Timestamps, + _segment_tx: Option>, + ) -> anyhow::Result { + OutputPipeline::builder(segments_dir) + .with_video::(screen_capture) + .with_timestamps(start_time) + .build::(crate::ffmpeg::SegmentedVideoMuxerConfig { + segment_duration: std::time::Duration::from_secs(2), + preset: H264Preset::Ultrafast, + output_size: Some(output_size), + shared_pause_state: None, + }) + .await + } +} + #[cfg(target_os = "macos")] pub type ScreenCaptureMethod = screen_capture::CMSampleBufferCapture; #[cfg(windows)] pub type ScreenCaptureMethod = screen_capture::Direct3DCapture; +#[cfg(target_os = "linux")] +pub type ScreenCaptureMethod = screen_capture::X11Capture; + pub fn target_to_display_and_crop( target: &ScreenCaptureTarget, ) -> anyhow::Result<(scap_targets::Display, Option)> { @@ -412,6 +472,26 @@ pub fn target_to_display_and_crop( raw_window_bounds.size(), )) } + + #[cfg(target_os = "linux")] + { + let raw_display_position = display + .raw_handle() + .physical_position() + .ok_or_else(|| anyhow!("No display bounds"))?; + let raw_window_bounds = window + .raw_handle() + .physical_bounds() + .ok_or_else(|| anyhow!("No window bounds"))?; + + Some(PhysicalBounds::new( + PhysicalPosition::new( + raw_window_bounds.position().x() - raw_display_position.x(), + raw_window_bounds.position().y() - raw_display_position.y(), + ), + raw_window_bounds.size(), + )) + } } ScreenCaptureTarget::Area { bounds: relative_bounds, @@ -445,6 +525,30 @@ pub fn target_to_display_and_crop( ), )) } + + #[cfg(target_os = "linux")] + { + let raw_display_size = display + .physical_size() + .ok_or_else(|| anyhow!("No display bounds"))?; + let logical_display_size = display + .logical_size() + .ok_or_else(|| anyhow!("No display logical size"))?; + Some(PhysicalBounds::new( + PhysicalPosition::new( + (relative_bounds.position().x() / logical_display_size.width()) + * raw_display_size.width(), + (relative_bounds.position().y() / logical_display_size.height()) + * raw_display_size.height(), + ), + PhysicalSize::new( + (relative_bounds.size().width() / logical_display_size.width()) + * raw_display_size.width(), + (relative_bounds.size().height() / logical_display_size.height()) + * raw_display_size.height(), + ), + )) + } } ScreenCaptureTarget::CameraOnly => { return Err(anyhow!("Camera-only target has no display")); diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index c6ffaa63e47..48f3ef05953 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -34,6 +34,8 @@ pub struct CursorActorResponse { pub struct CursorActor { stop: Option, + stop_wakeup: Option>, + thread: Option>, pub rx: Shared>, } @@ -45,11 +47,28 @@ pub struct IncrementalCaptureOutputs { impl CursorActor { pub fn stop(&mut self) { drop(self.stop.take()); + if let Some(stop_wakeup) = self.stop_wakeup.take() { + let _ = stop_wakeup.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } } } const CURSOR_FLUSH_INTERVAL_SECS: u64 = 5; +#[cfg(target_os = "linux")] +fn prefers_wayland_portal_cursor() -> bool { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + return false; + } + + std::env::var_os("DISPLAY").is_none() + || std::env::var("XDG_SESSION_TYPE") + .is_ok_and(|session| session.eq_ignore_ascii_case("wayland")) +} + fn flush_cursor_data(output_path: &Path, moves: &[CursorMoveEvent], clicks: &[CursorClickEvent]) { let events = CursorEvents { clicks: clicks.to_vec(), @@ -196,18 +215,35 @@ pub fn spawn_cursor_recorder( start_time: Timestamps, incremental_outputs: IncrementalCaptureOutputs, ) -> CursorActor { - use cap_utils::spawn_actor; + #[cfg(target_os = "linux")] + if prefers_wayland_portal_cursor() { + let (tx, rx) = oneshot::channel(); + let _ = tx.send(CursorActorResponse { + cursors: prev_cursors, + next_cursor_id, + moves: vec![], + clicks: vec![], + keyboard_presses: vec![], + }); + return CursorActor { + stop: None, + stop_wakeup: None, + thread: None, + rx: rx.shared(), + }; + } + use device_query::{DeviceQuery, DeviceState}; - use futures::future::Either; use sha2::{Digest, Sha256}; - use std::{pin::pin, time::Duration}; + use std::time::Duration; use tracing::{error, info}; let stop_token = CancellationToken::new(); let (tx, rx) = oneshot::channel(); + let (stop_wakeup_tx, stop_wakeup_rx) = std::sync::mpsc::channel(); let stop_token_child = stop_token.child_token(); - spawn_actor(async move { + let thread = std::thread::spawn(move || { let device_state = DeviceState::new(); let mut last_mouse_state = device_state.get_mouse(); let mut last_keys: Vec = device_state.get_keys(); @@ -229,12 +265,16 @@ pub fn spawn_cursor_recorder( let mut last_cursor_id: Option = None; loop { - let sleep = tokio::time::sleep(Duration::from_millis(16)); - let Either::Right(_) = - futures::future::select(pin!(stop_token_child.cancelled()), pin!(sleep)).await - else { + if stop_token_child.is_cancelled() { break; - }; + } + + if stop_wakeup_rx + .recv_timeout(Duration::from_millis(16)) + .is_ok() + { + break; + } let elapsed = start_time.instant().elapsed().as_secs_f64() * 1000.0; let mouse_state = device_state.get_mouse(); @@ -386,6 +426,8 @@ pub fn spawn_cursor_recorder( CursorActor { stop: Some(stop_token.drop_guard()), + stop_wakeup: Some(stop_wakeup_tx), + thread: Some(thread), rx: rx.shared(), } } @@ -425,6 +467,96 @@ fn get_cursor_data() -> Option { }) } +#[cfg(target_os = "linux")] +fn get_cursor_data() -> Option { + get_x11_cursor_data().or_else(fallback_cursor_data) +} + +#[cfg(target_os = "linux")] +fn get_x11_cursor_data() -> Option { + use x11rb::protocol::xfixes::ConnectionExt as _; + + let (conn, _) = x11rb::connect(None).ok()?; + conn.xfixes_query_version(5, 0).ok()?.reply().ok()?; + let cursor = conn.xfixes_get_cursor_image().ok()?.reply().ok()?; + + let width = u32::from(cursor.width); + let height = u32::from(cursor.height); + if width == 0 || height == 0 { + return None; + } + + let pixel_count = usize::from(cursor.width).checked_mul(usize::from(cursor.height))?; + if cursor.cursor_image.len() != pixel_count { + return None; + } + + let mut rgba = Vec::with_capacity(pixel_count.checked_mul(4)?); + for pixel in cursor.cursor_image { + rgba.push(((pixel >> 16) & 0xff) as u8); + rgba.push(((pixel >> 8) & 0xff) as u8); + rgba.push((pixel & 0xff) as u8); + rgba.push(((pixel >> 24) & 0xff) as u8); + } + + let image = image::RgbaImage::from_raw(width, height, rgba)?; + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, image::ImageFormat::Png) + .ok()?; + + Some(CursorData { + image: bytes.into_inner(), + hotspot: XY::new( + f64::from(cursor.xhot) / f64::from(width), + f64::from(cursor.yhot) / f64::from(height), + ), + shape: None, + }) +} + +#[cfg(target_os = "linux")] +fn fallback_cursor_data() -> Option { + use std::sync::OnceLock; + + static CURSOR_PNG: OnceLock> = OnceLock::new(); + + let image = CURSOR_PNG.get_or_init(linux_cursor_png).clone(); + if image.is_empty() { + return None; + } + + Some(CursorData { + image, + hotspot: XY::new(0.0, 0.0), + shape: None, + }) +} + +#[cfg(target_os = "linux")] +fn linux_cursor_png() -> Vec { + let mut image = image::RgbaImage::new(24, 24); + for y in 0..18 { + for x in 0..=y.min(10) { + image.put_pixel(x, y, image::Rgba([0, 0, 0, 255])); + } + } + for y in 2..15 { + for x in 1..=y.min(8) { + image.put_pixel(x, y, image::Rgba([255, 255, 255, 255])); + } + } + + let mut bytes = std::io::Cursor::new(Vec::new()); + if image::DynamicImage::ImageRgba8(image) + .write_to(&mut bytes, image::ImageFormat::Png) + .is_err() + { + return Vec::new(); + } + bytes.into_inner() +} + #[cfg(windows)] fn get_cursor_data() -> Option { use windows::Win32::Foundation::{HWND, POINT}; diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index ee6bc98deb1..d9ba332092f 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -1,7 +1,9 @@ use cap_camera::CameraInfo; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] use cap_camera_ffmpeg::*; use cap_fail::fail_err; use cap_media_info::VideoInfo; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] use cap_timestamp::Timestamp; use futures::{ FutureExt, @@ -9,8 +11,9 @@ use futures::{ }; use kameo::prelude::*; use replace_with::replace_with_or_abort; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] +use std::cmp::Ordering; use std::{ - cmp::Ordering, ops::Deref, sync::{ Arc, Weak, @@ -562,6 +565,7 @@ pub enum SetInputError { Initialisation, } +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] fn find_camera(selected_camera: &DeviceOrModelID) -> Option { cap_camera::list_cameras().find(|c| match selected_camera { DeviceOrModelID::DeviceID(device_id) => c.device_id() == device_id, @@ -575,13 +579,20 @@ struct SetupCameraResult { video_info: VideoInfo, } +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] static CAMERA_CALLBACK_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] const TARGET_CAMERA_WIDTH: u32 = 1280; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] const TARGET_CAMERA_HEIGHT: u32 = 720; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] const TARGET_CAMERA_FRAME_RATE: f32 = 30.0; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] const PREFERRED_CAMERA_FRAME_RATE: f32 = 29.0; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] const MIN_CAMERA_FRAME_RATE: f32 = 24.0; +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] fn select_preferred_camera_format( formats: &[cap_camera::Format], settings: CameraDeviceSettings, @@ -626,6 +637,7 @@ fn select_preferred_camera_format( matches.into_iter().next() } +#[cfg(any(target_os = "macos", windows, target_os = "linux"))] fn select_camera_format( camera: &cap_camera::CameraInfo, settings: Option, @@ -819,7 +831,7 @@ async fn setup_camera( }) } -#[cfg(not(target_os = "macos"))] +#[cfg(windows)] async fn setup_camera( id: &DeviceOrModelID, settings: Option, @@ -924,6 +936,73 @@ async fn setup_camera( }) } +#[cfg(target_os = "linux")] +async fn setup_camera( + id: &DeviceOrModelID, + settings: Option, + recipient: Recipient, + _native_recipient: Recipient, + _native_sender_count: Arc, +) -> Result { + let camera = find_camera(id).ok_or(SetInputError::DeviceNotFound)?; + let format = select_camera_format(&camera, settings)?; + let frame_rate = format.frame_rate().round().max(1.0) as u32; + + let (ready_tx, ready_rx) = oneshot::channel(); + let mut ready_signal = Some(ready_tx); + + let capture_handle = camera + .start_capturing(format.clone(), move |frame| { + let callback_num = + CAMERA_CALLBACK_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + let timestamp = Timestamp::Instant(std::time::Instant::now()); + + let Ok(mut ff_frame) = frame.as_ffmpeg() else { + return; + }; + + ff_frame.set_pts(Some(frame.timestamp.as_micros() as i64)); + + if let Some(signal) = ready_signal.take() { + let video_info = VideoInfo::from_raw_ffmpeg( + ff_frame.format(), + ff_frame.width(), + ff_frame.height(), + frame_rate, + ); + + let _ = signal.send(video_info); + } + + let send_result = recipient + .tell(NewFrame(FFmpegVideoFrame { + inner: ff_frame, + timestamp, + })) + .try_send(); + + if send_result.is_err() && callback_num.is_multiple_of(30) { + tracing::warn!( + "Camera callback: failed to send frame {} to actor (mailbox full?)", + callback_num + ); + } + }) + .map_err(|e| SetInputError::StartCapturing(e.to_string()))?; + + let video_info = tokio::time::timeout(CAMERA_INIT_TIMEOUT, ready_rx) + .await + .map_err(|e| SetInputError::Timeout(e.to_string()))? + .map_err(|_| SetInputError::Initialisation)?; + + Ok(SetupCameraResult { + handle: capture_handle, + camera_info: camera, + video_info, + }) +} + impl Message for CameraFeed { type Reply = Result>, SetInputError>; diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 7f96464e6e7..6ab8850c203 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -414,6 +414,7 @@ pub struct ActorBuilder { mic_feed: Option>, camera_feed: Option>, max_output_size: Option, + max_fps: u32, #[cfg(target_os = "macos")] excluded_windows: Vec, } @@ -427,6 +428,7 @@ impl ActorBuilder { mic_feed: None, camera_feed: None, max_output_size: None, + max_fps: crate::defaults::DEFAULT_INSTANT_MODE_FPS, #[cfg(target_os = "macos")] excluded_windows: Vec::new(), } @@ -455,6 +457,11 @@ impl ActorBuilder { self } + pub fn with_max_fps(mut self, max_fps: u32) -> Self { + self.max_fps = max_fps.clamp(1, 120); + self + } + #[cfg(target_os = "macos")] pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { self.excluded_windows = excluded_windows; @@ -478,6 +485,7 @@ impl ActorBuilder { excluded_windows: self.excluded_windows, }, self.max_output_size, + self.max_fps, ) .await } @@ -488,6 +496,7 @@ pub async fn spawn_instant_recording_actor( recording_dir: PathBuf, inputs: RecordingBaseInputs, max_output_size: Option, + max_fps: u32, ) -> anyhow::Result { ensure_dir(&recording_dir)?; @@ -502,58 +511,101 @@ pub async fn spawn_instant_recording_actor( let (mut pipeline, video_info) = match inputs.capture_target { ScreenCaptureTarget::CameraOnly => { - let camera_feed = inputs.camera_feed.clone().ok_or_else(|| { - anyhow::anyhow!( - "Camera-only recording requires a camera, but no camera is currently available. \ - Please select a camera in the recording settings before starting. \ - If you have already selected a camera, it may have been disconnected or \ - failed to initialize. Try reconnecting your camera or selecting a different one." - ) - })?; - - let output_path = content_dir.join("output.mp4"); + #[cfg(target_os = "linux")] + { + let camera_feed = inputs.camera_feed.clone().ok_or_else(|| { + anyhow::anyhow!( + "Camera-only recording requires a camera, but no camera is currently available. \ + Please select a camera in the recording settings before starting. \ + If you have already selected a camera, it may have been disconnected or \ + failed to initialize. Try reconnecting your camera or selecting a different one." + ) + })?; + + let output_path = content_dir.join("output.mp4"); + + let mut builder = OutputPipeline::builder(output_path.clone()) + .with_video::(camera_feed.clone()) + .with_timestamps(timestamps); + + if let Some(mic_feed) = inputs.mic_feed.clone() { + builder = builder.with_audio_source::(mic_feed); + } - let mut builder = OutputPipeline::builder(output_path.clone()) - .with_video::(camera_feed.clone()) - .with_timestamps(timestamps); + let cam_pipeline = builder + .build::(()) + .await + .context("camera-only pipeline setup")?; - if let Some(mic_feed) = inputs.mic_feed.clone() { - builder = builder.with_audio_source::(mic_feed); - } - - #[cfg(target_os = "macos")] - let cam_pipeline = builder - .build::( - output_pipeline::AVFoundationCameraMuxerConfig { - instant_mode: true, - ..Default::default() + let video_info = *camera_feed.video_info(); + ( + Pipeline { + video: cam_pipeline, + audio: None, + video_info, + segments_dir: content_dir.clone(), + segment_rx: None, }, + video_info, ) - .await - .context("camera-only pipeline setup")?; + } - #[cfg(windows)] - let cam_pipeline = builder - .build::( - output_pipeline::WindowsCameraMuxerConfig { - encoder_preferences: crate::capture_pipeline::EncoderPreferences::default(), - ..Default::default() - }, - ) - .await - .context("camera-only pipeline setup")?; + #[cfg(any(target_os = "macos", windows))] + { + let camera_feed = inputs.camera_feed.clone().ok_or_else(|| { + anyhow::anyhow!( + "Camera-only recording requires a camera, but no camera is currently available. \ + Please select a camera in the recording settings before starting. \ + If you have already selected a camera, it may have been disconnected or \ + failed to initialize. Try reconnecting your camera or selecting a different one." + ) + })?; + + let output_path = content_dir.join("output.mp4"); + + let mut builder = OutputPipeline::builder(output_path.clone()) + .with_video::(camera_feed.clone()) + .with_timestamps(timestamps); + + if let Some(mic_feed) = inputs.mic_feed.clone() { + builder = builder.with_audio_source::(mic_feed); + } - let video_info = *camera_feed.video_info(); - ( - Pipeline { - video: cam_pipeline, - audio: None, + #[cfg(target_os = "macos")] + let cam_pipeline = builder + .build::( + output_pipeline::AVFoundationCameraMuxerConfig { + instant_mode: true, + ..Default::default() + }, + ) + .await + .context("camera-only pipeline setup")?; + + #[cfg(windows)] + let cam_pipeline = builder + .build::( + output_pipeline::WindowsCameraMuxerConfig { + encoder_preferences: + crate::capture_pipeline::EncoderPreferences::default(), + ..Default::default() + }, + ) + .await + .context("camera-only pipeline setup")?; + + let video_info = *camera_feed.video_info(); + ( + Pipeline { + video: cam_pipeline, + audio: None, + video_info, + segments_dir: content_dir.clone(), + segment_rx: None, + }, video_info, - segments_dir: content_dir.clone(), - segment_rx: None, - }, - video_info, - ) + ) + } } _ => { #[cfg(windows)] @@ -566,10 +618,14 @@ pub async fn spawn_instant_recording_actor( display, crop_bounds, true, - crate::defaults::DEFAULT_INSTANT_MODE_FPS, + max_fps, None, timestamps.system_time(), inputs.capture_system_audio, + #[cfg(target_os = "linux")] + crate::sources::screen_capture::LinuxCaptureSource::from_target( + &inputs.capture_target, + ), #[cfg(windows)] d3d_device, #[cfg(target_os = "macos")] diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 302770f9e2c..28a8850666d 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -262,12 +262,14 @@ pub(crate) fn trim_audio_frame_front( Some(new_frame) } +#[cfg(any(test, target_os = "macos", windows))] pub(crate) enum BlockingThreadFinish { Clean, Failed(anyhow::Error), TimedOut(anyhow::Error), } +#[cfg(any(test, target_os = "macos", windows))] fn join_blocking_thread( handle: std::thread::JoinHandle>, label: &str, @@ -279,6 +281,7 @@ fn join_blocking_thread( } } +#[cfg(any(test, target_os = "macos", windows))] pub(crate) fn spawn_blocking_thread_timeout_cleanup( handle: std::thread::JoinHandle>, label: &str, @@ -555,6 +558,7 @@ pub(crate) fn send_with_stall_budget_flume( } } +#[cfg(any(test, target_os = "macos", windows))] pub(crate) fn wait_for_blocking_thread_finish( handle: std::thread::JoinHandle>, timeout: Duration, @@ -582,6 +586,7 @@ pub(crate) fn wait_for_blocking_thread_finish( } } +#[cfg(any(target_os = "macos", windows))] pub(crate) fn combine_finish_errors( primary: anyhow::Error, secondary: anyhow::Error, diff --git a/crates/recording/src/output_pipeline/mod.rs b/crates/recording/src/output_pipeline/mod.rs index dcf9924bcb6..bd6450ba048 100644 --- a/crates/recording/src/output_pipeline/mod.rs +++ b/crates/recording/src/output_pipeline/mod.rs @@ -12,6 +12,20 @@ pub mod oop_muxer; pub use async_camera::*; pub use core::*; pub use ffmpeg::*; + +#[cfg(target_os = "linux")] +#[derive(Clone)] +pub struct NativeCameraFrame { + pub timestamp: cap_timestamp::Timestamp, +} + +#[cfg(target_os = "linux")] +impl VideoFrame for NativeCameraFrame { + fn timestamp(&self) -> cap_timestamp::Timestamp { + self.timestamp + } +} + #[cfg(target_os = "macos")] pub use macos_fragmented_m4s::*; #[cfg(target_os = "macos")] diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index d257a9d259e..eb40ff3fbc2 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -6,8 +6,8 @@ use std::{ use cap_enc_ffmpeg::fragmented_mp4::tail_is_complete; use cap_enc_ffmpeg::remux::{ concatenate_audio_to_ogg, concatenate_m4s_segments_with_init, concatenate_video_fragments, - get_media_duration, get_video_fps, probe_media_valid, probe_video_can_decode, - probe_video_seek_points, remux_file, + get_media_duration, get_video_fps, merge_video_audio, probe_media_valid, + probe_video_can_decode, probe_video_seek_points, remux_file, }; use cap_project::{ AudioMeta, Cursors, MultipleSegment, MultipleSegments, ProjectConfiguration, RecordingMeta, @@ -59,6 +59,8 @@ pub enum RecoveryError { VideoConcat(cap_enc_ffmpeg::remux::RemuxError), #[error("Failed to concatenate audio fragments: {0}")] AudioConcat(cap_enc_ffmpeg::remux::RemuxError), + #[error("Failed to merge media streams: {0}")] + MediaMerge(cap_enc_ffmpeg::remux::RemuxError), #[error("Failed to serialize meta: {0}")] Serialize(#[from] serde_json::Error), #[error("No recoverable segments found")] @@ -987,6 +989,55 @@ impl RecoveryManager { Self::finalize_to_progressive_mp4_with_health(fragmented_dir, output, None) } + pub fn finalize_instant_output( + display_dir: &Path, + audio_dir: &Path, + output: &Path, + ) -> Result { + if !audio_dir.exists() { + return Self::finalize_to_progressive_mp4(display_dir, output); + } + + Self::rescue_pending_tmp_fragments(audio_dir, None); + let audio_info = Self::find_complete_fragments_with_init(audio_dir); + if audio_info.fragments.is_empty() { + return Self::finalize_to_progressive_mp4(display_dir, output); + } + + let parent = output.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let stem = output + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("instant"); + let video_output = parent.join(format!("{stem}.video.mp4")); + let audio_output = parent.join(format!("{stem}.audio.mp4")); + let merged_output = parent.join(format!("{stem}.merged.mp4")); + + let result = (|| { + Self::finalize_to_progressive_mp4(display_dir, &video_output)?; + Self::finalize_audio_fragments_to_progressive_mp4( + &audio_info.fragments, + audio_info.init_segment.as_deref(), + &audio_output, + "audio", + )?; + merge_video_audio(&video_output, &audio_output, &merged_output) + .map_err(RecoveryError::MediaMerge)?; + Self::validate_required_video(&merged_output, "display")?; + replace_file(&merged_output, output)?; + Ok(output.to_path_buf()) + })(); + + for path in [&video_output, &audio_output, &merged_output] { + if path.exists() && path != output { + let _ = std::fs::remove_file(path); + } + } + + result + } + pub fn finalize_to_progressive_mp4_with_health( fragmented_dir: &Path, output: &Path, @@ -1100,6 +1151,36 @@ impl RecoveryManager { Ok(()) } + fn finalize_audio_fragments_to_progressive_mp4( + fragments: &[PathBuf], + init_segment: Option<&Path>, + output: &Path, + label: &str, + ) -> Result<(), RecoveryError> { + if fragments.is_empty() { + return Err(RecoveryError::NoRecoverableSegments); + } + + if let Some(init_path) = init_segment { + info!( + "Concatenating {} M4S {label} segments with init to {:?}", + fragments.len(), + output + ); + concatenate_m4s_segments_with_init(init_path, fragments, output) + .map_err(RecoveryError::AudioConcat)?; + } else { + info!( + "Concatenating {} {label} fragments to {:?}", + fragments.len(), + output + ); + concatenate_video_fragments(fragments, output).map_err(RecoveryError::AudioConcat)?; + } + + Ok(()) + } + fn validate_required_video(path: &Path, label: &str) -> Result<(), RecoveryError> { info!("Validating recovered {} video: {:?}", label, path); diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index a468967a76f..362e1441c1c 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -1,24 +1,31 @@ use crate::sources::screen_capture::ScreenCaptureTarget; +#[cfg(target_os = "linux")] +use crate::sources::screen_capture::{X11InputConfig, frame_from_x11_packet, open_x11_input}; #[cfg(target_os = "macos")] use anyhow::Context; +#[cfg(target_os = "linux")] +use anyhow::Context; use anyhow::anyhow; use image::{DynamicImage, RgbImage, RgbaImage}; #[cfg(target_os = "macos")] use scap_ffmpeg::AsFFmpeg; +#[cfg(not(target_os = "linux"))] use std::sync::{Arc, Mutex}; +#[cfg(not(target_os = "linux"))] use std::time::Duration; +#[cfg(not(target_os = "linux"))] use tokio::sync::oneshot; +#[cfg(not(target_os = "linux"))] use tracing::debug; #[cfg(target_os = "macos")] use tracing::error; #[cfg(target_os = "macos")] use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -#[cfg(target_os = "macos")] -use scap_screencapturekit::{Capturer, StreamCfgBuilder}; - #[cfg(target_os = "windows")] use scap_direct3d::{Capturer, Frame, NewCapturerError, PixelFormat, Settings}; +#[cfg(target_os = "macos")] +use scap_screencapturekit::{Capturer, StreamCfgBuilder}; #[cfg(target_os = "windows")] use std::sync::OnceLock; #[cfg(target_os = "windows")] @@ -44,6 +51,7 @@ fn unsupported_error() -> anyhow::Error { anyhow!(WINDOWS_CAPTURE_UNSUPPORTED) } +#[cfg(not(target_os = "linux"))] #[derive(Clone, Copy)] enum ChannelOrder { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -51,6 +59,7 @@ enum ChannelOrder { Bgra, } +#[cfg(not(target_os = "linux"))] fn rgb_from_rgba( data: &[u8], width: usize, @@ -96,6 +105,7 @@ fn rgb_from_rgba( RgbImage::from_raw(width as u32, height as u32, rgb) } +#[cfg(not(target_os = "linux"))] fn rgba_from_raw( data: &[u8], width: usize, @@ -807,6 +817,13 @@ fn try_fast_capture(target: &ScreenCaptureTarget) -> Option { Some(image) } +#[cfg(target_os = "linux")] +pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { + let image = capture_screenshot_x11(&target).await?; + Ok(finalize_screenshot(image, &target)) +} + +#[cfg(not(target_os = "linux"))] pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { #[cfg(target_os = "macos")] { @@ -1091,6 +1108,7 @@ fn finalize_screenshot(image: RgbImage, target: &ScreenCaptureTarget) -> Dynamic } } +#[cfg(not(target_os = "linux"))] fn crop_area_if_needed( image: RgbImage, target: &ScreenCaptureTarget, @@ -1101,6 +1119,9 @@ fn crop_area_if_needed( return Ok(image); } + #[cfg(target_os = "linux")] + let _ = screen; + #[cfg(target_os = "macos")] let scale = { let display = scap_targets::Display::from_id(screen) @@ -1117,6 +1138,9 @@ fn crop_area_if_needed( physical_width / logical_width }; + #[cfg(target_os = "linux")] + let scale = 1.0; + let x = (bounds.position().x() * scale) as u32; let y = (bounds.position().y() * scale) as u32; let width = (bounds.size().width() * scale) as u32; @@ -1141,7 +1165,7 @@ fn crop_area_if_needed( Ok(image) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn convert_ffmpeg_frame_to_image(frame: &ffmpeg::frame::Video) -> anyhow::Result { let mut scaler = ffmpeg::software::scaling::context::Context::get( frame.format(), @@ -1185,3 +1209,101 @@ fn convert_ffmpeg_frame_to_image(frame: &ffmpeg::frame::Video) -> anyhow::Result RgbImage::from_raw(width as u32, height as u32, img_buffer) .ok_or_else(|| anyhow!("Failed to create image buffer")) } + +#[cfg(target_os = "linux")] +async fn capture_screenshot_x11(target: &ScreenCaptureTarget) -> anyhow::Result { + let target = target.clone(); + tokio::task::spawn_blocking(move || capture_screenshot_x11_blocking(&target)) + .await + .context("Linux screenshot task failed")? +} + +#[cfg(target_os = "linux")] +fn capture_screenshot_x11_blocking(target: &ScreenCaptureTarget) -> anyhow::Result { + let (display_name, x, y, width, height) = linux_capture_geometry(target)?; + let config = X11InputConfig { + display_name, + x, + y, + width, + height, + fps: 1, + show_cursor: false, + }; + let mut input = open_x11_input(&config)?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or_else(|| anyhow!("x11grab did not expose a video stream"))?; + let stream_index = stream.index(); + + for (stream, packet) in input.packets() { + if stream.index() != stream_index { + continue; + } + let frame = frame_from_x11_packet(&packet, config.width, config.height)?; + return convert_ffmpeg_frame_to_image(&frame); + } + + Err(anyhow!("x11grab ended before producing a screenshot frame")) +} + +#[cfg(target_os = "linux")] +fn linux_capture_geometry( + target: &ScreenCaptureTarget, +) -> anyhow::Result<(String, i32, i32, u32, u32)> { + let display_name = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string()); + match target { + ScreenCaptureTarget::Display { id } => { + let display = + scap_targets::Display::from_id(id).ok_or_else(|| anyhow!("Display not found"))?; + let position = display + .raw_handle() + .physical_position() + .ok_or_else(|| anyhow!("Display position unavailable"))?; + let size = display + .physical_size() + .ok_or_else(|| anyhow!("Display size unavailable"))?; + Ok(( + display_name, + position.x() as i32, + position.y() as i32, + size.width().max(1.0) as u32, + size.height().max(1.0) as u32, + )) + } + ScreenCaptureTarget::Window { id } => { + let window = + scap_targets::Window::from_id(id).ok_or_else(|| anyhow!("Window not found"))?; + let bounds = window + .raw_handle() + .physical_bounds() + .ok_or_else(|| anyhow!("Window bounds unavailable"))?; + Ok(( + display_name, + bounds.position().x() as i32, + bounds.position().y() as i32, + bounds.size().width().max(1.0) as u32, + bounds.size().height().max(1.0) as u32, + )) + } + ScreenCaptureTarget::Area { screen, bounds } => { + let display = scap_targets::Display::from_id(screen) + .ok_or_else(|| anyhow!("Display not found"))?; + let position = display + .raw_handle() + .physical_position() + .ok_or_else(|| anyhow!("Display position unavailable"))?; + Ok(( + display_name, + position.x() as i32 + bounds.position().x().max(0.0) as i32, + position.y() as i32 + bounds.position().y().max(0.0) as i32, + bounds.size().width().max(1.0) as u32, + bounds.size().height().max(1.0) as u32, + )) + } + ScreenCaptureTarget::CameraOnly => { + Err(anyhow!("Camera-only not supported for screenshots")) + } + } +} diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs new file mode 100644 index 00000000000..ed702dabafd --- /dev/null +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -0,0 +1,1193 @@ +use super::*; +use crate::feeds::microphone::{self, MicrophoneFeed, MicrophoneFeedLock}; +use crate::ffmpeg::FFmpegVideoFrame; +use crate::output_pipeline::{ + self, AudioFrame, AudioSource, SetupCtx, StallSendOutcome, VideoSource as OutputVideoSource, + send_with_stall_budget_futures, +}; +use anyhow::{Context as _, anyhow, bail}; +use ashpd::desktop::{ + PersistMode, Session, + screencast::{CursorMode, Screencast, SourceType, Stream as PortalStream}, +}; +use cap_timestamp::Timestamp; +use ffmpeg::codec::packet::Packet; +use futures::channel::mpsc; +use kameo::Actor as _; +use pipewire as pw; +use pw::{properties::properties, spa}; +use std::{ + ffi::CString, + os::fd::OwnedFd, + process::Command, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug)] +pub struct X11Capture; + +impl ScreenCaptureFormat for X11Capture { + type VideoFormat = ffmpeg::frame::Video; + + fn pixel_format() -> ffmpeg::format::Pixel { + ffmpeg::format::Pixel::BGRA + } + + fn audio_info() -> AudioInfo { + AudioInfo::new( + ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed), + 48_000, + 2, + ) + .expect("static F32/48kHz/stereo audio config") + } +} + +pub struct VideoSourceConfig { + video_info: VideoInfo, + input: LinuxInputConfig, +} + +enum LinuxInputConfig { + X11(X11InputConfig), + Wayland(WaylandInputConfig), +} + +pub(crate) struct X11InputConfig { + pub display_name: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub fps: u32, + pub show_cursor: bool, +} + +struct WaylandInputConfig { + fd: OwnedFd, + node_id: u32, + fps: u32, + crop_bounds: Option, + portal_session: WaylandPortalSession, +} + +struct WaylandPortalSession { + _proxy: Screencast<'static>, + _session: Session<'static, Screencast<'static>>, +} + +pub struct VideoSource { + info: VideoInfo, + stop_token: CancellationToken, +} + +impl ScreenCaptureConfig { + pub async fn to_sources( + &self, + ) -> anyhow::Result<(VideoSourceConfig, Option)> { + let system_audio = if self.system_audio { + Some(create_system_audio_source_config().await?) + } else { + None + }; + + if prefers_wayland_portal() { + match create_wayland_source_config(self).await { + Ok((video_info, input)) => { + return Ok(( + VideoSourceConfig { + video_info, + input: LinuxInputConfig::Wayland(input), + }, + system_audio, + )); + } + Err(error) if std::env::var_os("DISPLAY").is_some() => { + tracing::warn!( + error = %error, + "Wayland portal capture failed; falling back to X11 capture" + ); + } + Err(error) => return Err(error), + } + } + + let display = + Display::from_id(&self.config.display).ok_or_else(|| anyhow!("Display not found"))?; + let display_position = display + .raw_handle() + .physical_position() + .ok_or_else(|| anyhow!("Display position unavailable"))?; + let display_size = display + .physical_size() + .ok_or_else(|| anyhow!("Display size unavailable"))?; + + let (x, y, width, height) = if let Some(crop) = self.config.crop_bounds { + let position = crop.position(); + let size = crop.size(); + ( + display_position.x() as i32 + position.x().max(0.0) as i32, + display_position.y() as i32 + position.y().max(0.0) as i32, + size.width().max(2.0) as u32, + size.height().max(2.0) as u32, + ) + } else { + ( + display_position.x() as i32, + display_position.y() as i32, + display_size.width().max(2.0) as u32, + display_size.height().max(2.0) as u32, + ) + }; + + Ok(( + VideoSourceConfig { + video_info: self.video_info, + input: LinuxInputConfig::X11(X11InputConfig { + display_name: std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string()), + x, + y, + width: ensure_even(width), + height: ensure_even(height), + fps: self.config.fps, + show_cursor: self.config.show_cursor, + }), + }, + system_audio, + )) + } +} + +impl OutputVideoSource for VideoSource { + type Config = VideoSourceConfig; + type Frame = FFmpegVideoFrame; + + async fn setup( + config: Self::Config, + video_tx: mpsc::Sender, + ctx: &mut SetupCtx, + ) -> anyhow::Result + where + Self: Sized, + { + let stop_token = ctx.stop_token(); + let health_tx = ctx.health_tx().clone(); + let info = config.video_info; + match config.input { + LinuxInputConfig::X11(input) => { + ctx.tasks().spawn_thread("x11grab-capture-thread", { + let stop_token = stop_token.clone(); + move || capture_x11(info, input, video_tx, stop_token, health_tx) + }); + } + LinuxInputConfig::Wayland(input) => { + ctx.tasks() + .spawn_thread("wayland-pipewire-capture-thread", { + let stop_token = stop_token.clone(); + move || capture_wayland(info, input, video_tx, stop_token, health_tx) + }); + } + } + + Ok(Self { info, stop_token }) + } + + fn video_info(&self) -> VideoInfo { + self.info + } + + fn stop(&mut self) -> futures::future::BoxFuture<'_, anyhow::Result<()>> { + self.stop_token.cancel(); + futures::FutureExt::boxed(async { Ok(()) }) + } +} + +struct WaylandPortalCapture { + stream: PortalStream, + fd: OwnedFd, + portal_session: WaylandPortalSession, +} + +struct PipewireCaptureState { + format: spa::param::video::VideoInfoRaw, + scaler: Option, + video_info: VideoInfo, + crop_bounds: Option, + video_tx: mpsc::Sender, + health_tx: output_pipeline::HealthSender, + stop_requested: Arc, + fatal_error: Arc>>, + sent: Arc, + dropped: Arc, +} + +impl PipewireCaptureState { + fn set_fatal_error(&self, error: impl Into) { + let mut fatal_error = self.fatal_error.lock(); + if fatal_error.is_none() { + *fatal_error = Some(error.into()); + } + } +} + +async fn create_wayland_source_config( + config: &ScreenCaptureConfig, +) -> anyhow::Result<(VideoInfo, WaylandInputConfig)> { + let portal = open_wayland_portal(config.config.linux_source, config.config.show_cursor).await?; + let crop_bounds = match config.config.linux_source { + LinuxCaptureSource::Area => config.config.crop_bounds, + LinuxCaptureSource::Display | LinuxCaptureSource::Window => None, + }; + let video_info = wayland_video_info(&portal.stream, config.video_info, crop_bounds); + + Ok(( + video_info, + WaylandInputConfig { + fd: portal.fd, + node_id: portal.stream.pipe_wire_node_id(), + fps: config.config.fps, + crop_bounds, + portal_session: portal.portal_session, + }, + )) +} + +async fn open_wayland_portal( + source: LinuxCaptureSource, + show_cursor: bool, +) -> anyhow::Result { + let proxy: Screencast<'static> = Screencast::new() + .await + .context("connect to XDG Desktop Portal ScreenCast")?; + let session = proxy + .create_session() + .await + .context("create XDG Desktop Portal ScreenCast session")?; + let cursor_mode = if show_cursor { + CursorMode::Embedded + } else { + CursorMode::Hidden + }; + + proxy + .select_sources( + &session, + cursor_mode, + wayland_source_type(source), + false, + None, + PersistMode::DoNot, + ) + .await + .context("select Wayland screen capture source")?; + + let response = proxy + .start(&session, None) + .await + .context("start Wayland screen capture portal request")? + .response() + .context("Wayland screen capture portal request was cancelled")?; + let stream = response + .streams() + .first() + .cloned() + .ok_or_else(|| anyhow!("Wayland screen capture portal did not return a stream"))?; + let fd = proxy + .open_pipe_wire_remote(&session) + .await + .context("open PipeWire remote for Wayland screen capture")?; + + Ok(WaylandPortalCapture { + stream, + fd, + portal_session: WaylandPortalSession { + _proxy: proxy, + _session: session, + }, + }) +} + +fn prefers_wayland_portal() -> bool { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + return false; + } + + std::env::var_os("DISPLAY").is_none() + || std::env::var("XDG_SESSION_TYPE") + .is_ok_and(|session| session.eq_ignore_ascii_case("wayland")) +} + +fn wayland_source_type(source: LinuxCaptureSource) -> ashpd::enumflags2::BitFlags { + match source { + LinuxCaptureSource::Window => SourceType::Window.into(), + LinuxCaptureSource::Display | LinuxCaptureSource::Area => SourceType::Monitor.into(), + } +} + +fn wayland_video_info( + stream: &PortalStream, + fallback: VideoInfo, + crop_bounds: Option, +) -> VideoInfo { + if crop_bounds.is_some() { + return fallback; + } + + let Some((width, height)) = stream.size() else { + return fallback; + }; + if width <= 0 || height <= 0 { + return fallback; + } + + VideoInfo::from_raw_ffmpeg( + fallback.pixel_format, + ensure_even(width as u32), + ensure_even(height as u32), + fallback.fps(), + ) +} + +fn capture_wayland( + video_info: VideoInfo, + input: WaylandInputConfig, + video_tx: mpsc::Sender, + stop_token: CancellationToken, + health_tx: output_pipeline::HealthSender, +) -> anyhow::Result<()> { + let _portal_session = input.portal_session; + let stop_requested = Arc::new(AtomicBool::new(false)); + let fatal_error = Arc::new(parking_lot::Mutex::new(None)); + let sent = Arc::new(AtomicU64::new(0)); + let dropped = Arc::new(AtomicU64::new(0)); + let started = Instant::now(); + + let thread_loop = unsafe { pw::thread_loop::ThreadLoopBox::new(Some("cap-wayland"), None) } + .context("create PipeWire thread loop")?; + let context = pw::context::ContextBox::new(thread_loop.loop_(), None) + .context("create PipeWire context")?; + let core = context + .connect_fd(input.fd, None) + .context("connect to PipeWire remote")?; + + let state = PipewireCaptureState { + format: Default::default(), + scaler: None, + video_info, + crop_bounds: input.crop_bounds, + video_tx, + health_tx, + stop_requested: stop_requested.clone(), + fatal_error: fatal_error.clone(), + sent: sent.clone(), + dropped: dropped.clone(), + }; + + let stream = pw::stream::StreamBox::new( + &core, + "cap-wayland-screen", + properties! { + *pw::keys::MEDIA_TYPE => "Video", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Screen", + }, + ) + .context("create PipeWire screen capture stream")?; + + let _listener = stream + .add_local_listener_with_user_data(state) + .state_changed(|_, state, _, new| { + if let pw::stream::StreamState::Error(error) = new { + state.set_fatal_error(format!("PipeWire screen capture stream failed: {error}")); + } + }) + .param_changed(|_, state, id, param| { + if let Err(error) = update_pipewire_format(state, id, param) { + state.set_fatal_error(error.to_string()); + } + }) + .process(|stream, state| { + if state.stop_requested.load(Ordering::Relaxed) { + return; + } + + match process_pipewire_frame(stream, state) { + Ok(Some(StallSendOutcome::Sent)) => { + state.sent.fetch_add(1, Ordering::Relaxed); + } + Ok(Some(StallSendOutcome::StalledAndDropped { .. })) => { + state.dropped.fetch_add(1, Ordering::Relaxed); + } + Ok(Some(StallSendOutcome::Disconnected)) => { + state.stop_requested.store(true, Ordering::Relaxed); + } + Ok(None) => {} + Err(error) => state.set_fatal_error(error.to_string()), + } + }) + .register() + .context("register PipeWire stream listener")?; + + let param_bytes = pipewire_format_param(input.fps)?; + let mut params = [spa::pod::Pod::from_bytes(¶m_bytes) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?]; + + stream + .connect( + spa::utils::Direction::Input, + Some(input.node_id), + pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, + &mut params, + ) + .context("connect PipeWire stream to portal node")?; + + thread_loop.start(); + + while !stop_token.is_cancelled() && !stop_requested.load(Ordering::Relaxed) { + if fatal_error.lock().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + stop_requested.store(true, Ordering::Relaxed); + thread_loop.stop(); + + let error = fatal_error.lock().take(); + tracing::info!( + sent = sent.load(Ordering::Relaxed), + dropped = dropped.load(Ordering::Relaxed), + elapsed_ms = started.elapsed().as_millis() as u64, + "Linux Wayland PipeWire capture stopped" + ); + + if let Some(error) = error { + Err(anyhow!(error)) + } else { + Ok(()) + } +} + +fn update_pipewire_format( + state: &mut PipewireCaptureState, + id: u32, + param: Option<&spa::pod::Pod>, +) -> anyhow::Result<()> { + let Some(param) = param else { + return Ok(()); + }; + if id != spa::param::ParamType::Format.as_raw() { + return Ok(()); + } + + let (media_type, media_subtype) = + spa::param::format_utils::parse_format(param).context("parse PipeWire stream format")?; + if media_type != spa::param::format::MediaType::Video + || media_subtype != spa::param::format::MediaSubtype::Raw + { + return Ok(()); + } + + let mut format = spa::param::video::VideoInfoRaw::default(); + format + .parse(param) + .context("parse PipeWire raw video format")?; + pipewire_pixel_format(format.format()).ok_or_else(|| { + anyhow!( + "Unsupported PipeWire screen capture pixel format: {:?}", + format.format() + ) + })?; + state.format = format; + + Ok(()) +} + +fn process_pipewire_frame( + stream: &pw::stream::Stream, + state: &mut PipewireCaptureState, +) -> anyhow::Result> { + let Some(mut buffer) = stream.dequeue_buffer() else { + return Ok(None); + }; + let datas = buffer.datas_mut(); + if datas.is_empty() { + return Ok(None); + } + + let raw_frame = frame_from_pipewire_data(&mut datas[0], state.format, state.crop_bounds)?; + if state.scaler.is_none() { + state.scaler = Some(FrameScaler::new( + raw_frame.format(), + raw_frame.width(), + raw_frame.height(), + state.video_info, + )?); + } + let frame = state + .scaler + .as_mut() + .expect("PipeWire frame scaler initialized") + .scale(&raw_frame, state.video_info)?; + let timestamp = Timestamp::Instant(Instant::now()); + + Ok(Some(send_with_stall_budget_futures( + &mut state.video_tx, + FFmpegVideoFrame { + inner: frame, + timestamp, + }, + "linux-wayland-video", + &state.health_tx, + ))) +} + +fn frame_from_pipewire_data( + data: &mut spa::buffer::Data, + format: spa::param::video::VideoInfoRaw, + crop_bounds: Option, +) -> anyhow::Result { + let (pixel_format, bytes_per_pixel) = + pipewire_pixel_format(format.format()).ok_or_else(|| { + anyhow!( + "Unsupported PipeWire screen capture pixel format: {:?}", + format.format() + ) + })?; + let size = format.size(); + let source_width = size.width as usize; + let source_height = size.height as usize; + if source_width == 0 || source_height == 0 { + bail!("PipeWire screen capture stream did not provide frame dimensions"); + } + + let chunk_flags = data.chunk().flags(); + let chunk_stride = data.chunk().stride(); + let chunk_offset = data.chunk().offset(); + let chunk_size = data.chunk().size(); + if chunk_flags.contains(spa::buffer::ChunkFlags::CORRUPTED) { + bail!("PipeWire screen capture frame was marked corrupted"); + } + if chunk_stride < 0 { + bail!("PipeWire screen capture frame used a negative stride"); + } + + let source_stride = if chunk_stride > 0 { + chunk_stride as usize + } else { + source_width * bytes_per_pixel + }; + let (crop_x, crop_y, crop_width, crop_height) = + pipewire_crop(source_width, source_height, crop_bounds)?; + let source = data + .data() + .ok_or_else(|| anyhow!("PipeWire screen capture buffer was not memory-mapped"))?; + let offset = chunk_offset as usize; + let source_limit = if chunk_size > 0 { + offset + .checked_add(chunk_size as usize) + .map(|limit| limit.min(source.len())) + .ok_or_else(|| anyhow!("PipeWire screen capture frame size overflowed"))? + } else { + source.len() + }; + let row_bytes = crop_width * bytes_per_pixel; + + let mut frame = ffmpeg::frame::Video::new(pixel_format, crop_width as u32, crop_height as u32); + let target_stride = frame.stride(0); + if target_stride < row_bytes { + bail!( + "PipeWire target frame stride was too small: {} for {}x{}", + target_stride, + crop_width, + crop_height + ); + } + + for y in 0..crop_height { + let source_start = offset + (crop_y + y) * source_stride + crop_x * bytes_per_pixel; + let source_end = source_start + row_bytes; + if source_end > source_limit { + bail!( + "PipeWire screen capture frame was too small: {} bytes for {}x{}", + source.len(), + source_width, + source_height + ); + } + + let target_start = y * target_stride; + frame.data_mut(0)[target_start..target_start + row_bytes] + .copy_from_slice(&source[source_start..source_end]); + } + + Ok(frame) +} + +fn pipewire_crop( + source_width: usize, + source_height: usize, + crop_bounds: Option, +) -> anyhow::Result<(usize, usize, usize, usize)> { + let Some(crop_bounds) = crop_bounds else { + return Ok((0, 0, source_width, source_height)); + }; + + let crop_x = crop_bounds.position().x().max(0.0).floor() as usize; + let crop_y = crop_bounds.position().y().max(0.0).floor() as usize; + if crop_x >= source_width || crop_y >= source_height { + bail!("Wayland capture crop is outside the PipeWire stream bounds"); + } + + let crop_width = + (crop_bounds.size().width().max(1.0).floor() as usize).min(source_width - crop_x); + let crop_height = + (crop_bounds.size().height().max(1.0).floor() as usize).min(source_height - crop_y); + + Ok((crop_x, crop_y, crop_width, crop_height)) +} + +fn pipewire_pixel_format( + format: spa::param::video::VideoFormat, +) -> Option<(ffmpeg::format::Pixel, usize)> { + let pixel = if format == spa::param::video::VideoFormat::RGBx { + ffmpeg::format::Pixel::RGBZ + } else if format == spa::param::video::VideoFormat::BGRx { + ffmpeg::format::Pixel::BGRZ + } else if format == spa::param::video::VideoFormat::xRGB { + ffmpeg::format::Pixel::ZRGB + } else if format == spa::param::video::VideoFormat::xBGR { + ffmpeg::format::Pixel::ZBGR + } else if format == spa::param::video::VideoFormat::RGBA { + ffmpeg::format::Pixel::RGBA + } else if format == spa::param::video::VideoFormat::BGRA { + ffmpeg::format::Pixel::BGRA + } else if format == spa::param::video::VideoFormat::ARGB { + ffmpeg::format::Pixel::ARGB + } else if format == spa::param::video::VideoFormat::ABGR { + ffmpeg::format::Pixel::ABGR + } else if format == spa::param::video::VideoFormat::RGB { + return Some((ffmpeg::format::Pixel::RGB24, 3)); + } else if format == spa::param::video::VideoFormat::BGR { + return Some((ffmpeg::format::Pixel::BGR24, 3)); + } else { + return None; + }; + + Some((pixel, 4)) +} + +fn pipewire_format_param(fps: u32) -> anyhow::Result> { + let fps = fps.max(1); + let obj = spa::pod::object!( + spa::utils::SpaTypes::ObjectParamFormat, + spa::param::ParamType::EnumFormat, + spa::pod::property!( + spa::param::format::FormatProperties::MediaType, + Id, + spa::param::format::MediaType::Video + ), + spa::pod::property!( + spa::param::format::FormatProperties::MediaSubtype, + Id, + spa::param::format::MediaSubtype::Raw + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFormat, + Choice, + Enum, + Id, + spa::param::video::VideoFormat::BGRx, + spa::param::video::VideoFormat::BGRx, + spa::param::video::VideoFormat::BGRA, + spa::param::video::VideoFormat::RGBx, + spa::param::video::VideoFormat::RGBA, + spa::param::video::VideoFormat::RGB, + spa::param::video::VideoFormat::BGR + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoSize, + Choice, + Range, + Rectangle, + spa::utils::Rectangle { + width: 1920, + height: 1080 + }, + spa::utils::Rectangle { + width: 1, + height: 1 + }, + spa::utils::Rectangle { + width: 8192, + height: 8192 + } + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFramerate, + Choice, + Range, + Fraction, + spa::utils::Fraction { num: fps, denom: 1 }, + spa::utils::Fraction { num: 0, denom: 1 }, + spa::utils::Fraction { + num: 1000, + denom: 1 + } + ) + ); + + Ok(spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .map_err(|error| anyhow!("serialize PipeWire format parameter: {error:?}"))? + .0 + .into_inner()) +} + +pub struct SystemAudioSourceConfig { + feed_lock: Arc, + device_name: String, + restore_source: Option, +} + +pub struct SystemAudioSource { + inner: crate::sources::Microphone, + restore_source: Option, +} + +impl AudioSource for SystemAudioSource { + type Config = SystemAudioSourceConfig; + + fn setup( + config: Self::Config, + tx: mpsc::Sender, + ctx: &mut SetupCtx, + ) -> impl std::future::Future> + Send + 'static + where + Self: Sized, + { + let device_name = config.device_name.clone(); + let restore_source = config.restore_source; + let setup = ::setup(config.feed_lock, tx, ctx); + async move { + let inner = setup + .await + .with_context(|| format!("set up Linux system audio source '{device_name}'"))?; + + Ok(Self { + inner, + restore_source, + }) + } + } + + fn audio_info(&self) -> AudioInfo { + self.inner.audio_info() + } + + fn stop(&mut self) -> impl std::future::Future> + Send { + let restore_source = self.restore_source.take(); + let stop = self.inner.stop(); + async move { + let result = stop.await; + if let Some(source) = restore_source { + restore_pactl_default_source(&source); + } + result + } + } +} + +async fn create_system_audio_source_config() -> anyhow::Result { + let selected = select_system_audio_monitor()?; + + let (error_tx, _error_rx) = flume::bounded(16); + let feed = MicrophoneFeed::spawn(MicrophoneFeed::new(error_tx)); + feed.ask(microphone::SetInput { + label: selected.device_name.clone(), + settings: None, + }) + .await + .map_err(|e| anyhow!("Failed to set Linux system audio input: {e}"))? + .await + .with_context(|| { + format!( + "Linux system audio input '{}' failed to connect", + selected.device_name + ) + })?; + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let lock = feed + .ask(microphone::Lock) + .await + .map_err(|e| anyhow!("Failed to lock Linux system audio input: {e}"))?; + + Ok(SystemAudioSourceConfig { + feed_lock: Arc::new(lock), + device_name: selected.device_name, + restore_source: selected.restore_source, + }) +} + +impl Drop for SystemAudioSource { + fn drop(&mut self) { + if let Some(source) = self.restore_source.take() { + restore_pactl_default_source(&source); + } + } +} + +struct SelectedSystemAudioInput { + device_name: String, + restore_source: Option, +} + +fn select_system_audio_monitor() -> anyhow::Result { + let devices = MicrophoneFeed::list(); + let available = devices.keys().cloned().collect::>(); + + let mut candidates = devices + .iter() + .filter_map(|(name, device)| { + system_audio_device_rank(&name).map(|rank| (rank, name, device)) + }) + .collect::>(); + + candidates.sort_by_key(|(rank, name, _)| (*rank, name.to_ascii_lowercase())); + + if let Some((_, name, _)) = candidates.into_iter().next() { + return Ok(SelectedSystemAudioInput { + device_name: name.to_string(), + restore_source: None, + }); + } + + if let Some(selected) = select_pactl_monitor_source(&available)? { + return Ok(selected); + } + + Err(anyhow!( + "No PulseAudio/PipeWire monitor input was found for Linux system audio. \ + Available input devices: {available:?}. Select a monitor source with --mic, or enable a monitor source in your audio server." + )) +} + +fn system_audio_device_rank(name: &str) -> Option { + let name = name.to_ascii_lowercase(); + if name.contains("monitor") { + Some(0) + } else if name.contains("what u hear") || name.contains("stereo mix") { + Some(1) + } else if name.contains("loopback") || (name.contains("output") && name.contains("sink")) { + Some(2) + } else { + None + } +} + +fn select_pactl_monitor_source( + available_devices: &[String], +) -> anyhow::Result> { + let Some(device_name) = pulse_cpal_device_name(available_devices) else { + return Ok(None); + }; + + let output = match Command::new("pactl") + .args(["list", "short", "sources"]) + .output() + { + Ok(output) if output.status.success() => output, + _ => return Ok(None), + }; + + let sources = String::from_utf8_lossy(&output.stdout); + let mut monitor_sources = sources + .lines() + .filter_map(|line| line.split_whitespace().nth(1)) + .filter_map(|name| pactl_monitor_rank(name).map(|rank| (rank, name.to_string()))) + .collect::>(); + monitor_sources.sort_by_key(|(rank, name)| (*rank, name.to_ascii_lowercase())); + + let Some((_, source)) = monitor_sources.into_iter().next() else { + return Ok(None); + }; + + let previous_source = pactl_default_source(); + let restore_source = if previous_source.as_deref() == Some(source.as_str()) { + None + } else { + set_pactl_default_source(&source)?; + previous_source + }; + + Ok(Some(SelectedSystemAudioInput { + device_name, + restore_source, + })) +} + +fn pulse_cpal_device_name(available_devices: &[String]) -> Option { + available_devices + .iter() + .find(|name| name.eq_ignore_ascii_case("pulse")) + .or_else(|| { + available_devices + .iter() + .find(|name| name.to_ascii_lowercase().contains("pulse")) + }) + .or_else(|| { + available_devices + .iter() + .find(|name| name.eq_ignore_ascii_case("default")) + }) + .cloned() +} + +fn pactl_monitor_rank(name: &str) -> Option { + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".monitor") { + Some(0) + } else if lower.contains("monitor") { + Some(1) + } else { + None + } +} + +fn pactl_default_source() -> Option { + let output = Command::new("pactl") + .arg("get-default-source") + .output() + .ok()?; + + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|source| !source.is_empty()) +} + +fn set_pactl_default_source(source: &str) -> anyhow::Result<()> { + let status = Command::new("pactl") + .args(["set-default-source", source]) + .status() + .context("run pactl set-default-source")?; + + status + .success() + .then_some(()) + .ok_or_else(|| anyhow!("pactl set-default-source '{source}' failed")) +} + +fn restore_pactl_default_source(source: &str) { + if let Err(error) = set_pactl_default_source(source) { + tracing::warn!( + source, + error = %error, + "Failed to restore PulseAudio/PipeWire default source after Linux system audio capture" + ); + } +} + +struct FrameScaler { + context: ffmpeg::software::scaling::Context, + source_format: ffmpeg::format::Pixel, + source_width: u32, + source_height: u32, +} + +impl FrameScaler { + fn new( + source_format: ffmpeg::format::Pixel, + source_width: u32, + source_height: u32, + output: VideoInfo, + ) -> anyhow::Result { + let context = ffmpeg::software::scaling::Context::get( + source_format, + source_width, + source_height, + output.pixel_format, + output.width, + output.height, + ffmpeg::software::scaling::Flags::BILINEAR, + )?; + + Ok(Self { + context, + source_format, + source_width, + source_height, + }) + } + + fn matches(&self, frame: &ffmpeg::frame::Video) -> bool { + self.source_format == frame.format() + && self.source_width == frame.width() + && self.source_height == frame.height() + } + + fn scale( + &mut self, + frame: &ffmpeg::frame::Video, + output: VideoInfo, + ) -> anyhow::Result { + if frame.format() == output.pixel_format + && frame.width() == output.width + && frame.height() == output.height + { + return Ok(frame.clone()); + } + + if !self.matches(frame) { + *self = Self::new(frame.format(), frame.width(), frame.height(), output)?; + } + + let mut scaled = ffmpeg::frame::Video::empty(); + self.context.run(frame, &mut scaled)?; + scaled.set_pts(frame.pts()); + Ok(scaled) + } +} + +fn capture_x11( + video_info: VideoInfo, + input_config: X11InputConfig, + mut video_tx: mpsc::Sender, + stop_token: CancellationToken, + health_tx: output_pipeline::HealthSender, +) -> anyhow::Result<()> { + let mut input = open_x11_input(&input_config)?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or_else(|| anyhow!("x11grab did not expose a video stream"))?; + let stream_index = stream.index(); + let mut scaler = FrameScaler::new( + video_info.pixel_format, + video_info.width, + video_info.height, + video_info, + )?; + let started = Instant::now(); + let mut sent = 0u64; + let mut dropped = 0u64; + + for (stream, packet) in input.packets() { + if stop_token.is_cancelled() { + break; + } + if stream.index() != stream_index { + continue; + } + + let raw_frame = frame_from_x11_packet(&packet, input_config.width, input_config.height)?; + let frame = scaler.scale(&raw_frame, video_info)?; + let timestamp = Timestamp::Instant(Instant::now()); + match send_with_stall_budget_futures( + &mut video_tx, + FFmpegVideoFrame { + inner: frame, + timestamp, + }, + "linux-screen-video", + &health_tx, + ) { + StallSendOutcome::Sent => sent += 1, + StallSendOutcome::StalledAndDropped { .. } => dropped += 1, + StallSendOutcome::Disconnected => return Ok(()), + } + } + + tracing::info!( + sent, + dropped, + elapsed_ms = started.elapsed().as_millis() as u64, + "Linux X11 capture stopped" + ); + + Ok(()) +} + +pub(crate) fn frame_from_x11_packet( + packet: &Packet, + width: u32, + height: u32, +) -> anyhow::Result { + let data = packet + .data() + .ok_or_else(|| anyhow!("x11grab packet did not contain frame data"))?; + let source_stride = width as usize * 4; + let expected_len = source_stride * height as usize; + if data.len() < expected_len { + bail!( + "x11grab packet was too small: {} bytes for {}x{}", + data.len(), + width, + height + ); + } + + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRZ, width, height); + let target_stride = frame.stride(0); + if target_stride < source_stride { + bail!( + "x11grab frame stride was too small: {} for {}x{}", + target_stride, + width, + height + ); + } + + for y in 0..height as usize { + let source_start = y * source_stride; + let target_start = y * target_stride; + let target_end = target_start + source_stride; + frame.data_mut(0)[target_start..target_end] + .copy_from_slice(&data[source_start..source_start + source_stride]); + } + + frame.set_pts(packet.pts()); + Ok(frame) +} + +pub(crate) fn open_x11_input( + config: &X11InputConfig, +) -> anyhow::Result { + ffmpeg::init().context("initialize FFmpeg")?; + + let format_name = CString::new("x11grab")?; + let input_format = unsafe { + let ptr = ffmpeg::ffi::av_find_input_format(format_name.as_ptr()); + if ptr.is_null() { + bail!("FFmpeg was built without x11grab input support"); + } + ffmpeg::format::Input::wrap(ptr as *mut _) + }; + + let source = format!("{}+{},{}", config.display_name, config.x, config.y); + let mut options = ffmpeg::Dictionary::new(); + options.set("framerate", &config.fps.to_string()); + options.set("video_size", &format!("{}x{}", config.width, config.height)); + options.set("draw_mouse", if config.show_cursor { "1" } else { "0" }); + + Ok( + ffmpeg::format::open_with(&source, &ffmpeg::Format::Input(input_format), options) + .with_context(|| { + format!( + "open x11grab source {source} at {}x{}", + config.width, config.height + ) + })? + .input(), + ) +} diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index ef7bd644fbd..566431e246a 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -18,6 +18,11 @@ mod macos; #[cfg(target_os = "macos")] pub use macos::*; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + pub struct StopCapturing; #[derive(Debug, Clone, thiserror::Error)] @@ -65,6 +70,25 @@ pub enum ScreenCaptureTarget { CameraOnly, } +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug)] +pub enum LinuxCaptureSource { + Display, + Window, + Area, +} + +#[cfg(target_os = "linux")] +impl LinuxCaptureSource { + pub fn from_target(target: &ScreenCaptureTarget) -> Self { + match target { + ScreenCaptureTarget::Window { .. } => Self::Window, + ScreenCaptureTarget::Area { .. } => Self::Area, + ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::CameraOnly => Self::Display, + } + } +} + impl ScreenCaptureTarget { pub fn display(&self) -> Option { match self { @@ -104,6 +128,16 @@ impl ScreenCaptureTarget { display.raw_handle().physical_size()?, ))); } + + #[cfg(target_os = "linux")] + #[allow(clippy::needless_return)] + { + let display = self.display()?; + return Some(CursorCropBounds::new_linux(PhysicalBounds::new( + PhysicalPosition::new(0.0, 0.0), + display.raw_handle().physical_size()?, + ))); + } } Self::Window { id } => { let window = Window::from_id(id)?; @@ -141,6 +175,24 @@ impl ScreenCaptureTarget { ), ))); } + + #[cfg(target_os = "linux")] + #[allow(clippy::needless_return)] + { + let display_bounds = self.display()?.raw_handle().physical_bounds()?; + let window_bounds = window.raw_handle().physical_bounds()?; + + return Some(CursorCropBounds::new_linux(PhysicalBounds::new( + PhysicalPosition::new( + window_bounds.position().x() - display_bounds.position().x(), + window_bounds.position().y() - display_bounds.position().y(), + ), + PhysicalSize::new( + window_bounds.size().width(), + window_bounds.size().height(), + ), + ))); + } } Self::Area { bounds, .. } => { #[cfg(target_os = "macos")] @@ -169,6 +221,15 @@ impl ScreenCaptureTarget { ), ))); } + + #[cfg(target_os = "linux")] + #[allow(clippy::needless_return)] + { + return Some(CursorCropBounds::new_linux(PhysicalBounds::new( + PhysicalPosition::new(bounds.position().x(), bounds.position().y()), + PhysicalSize::new(bounds.size().width(), bounds.size().height()), + ))); + } } Self::CameraOnly => None, } @@ -301,6 +362,8 @@ pub struct Config { crop_bounds: Option, fps: u32, show_cursor: bool, + #[cfg(target_os = "linux")] + linux_source: LinuxCaptureSource, } #[cfg(target_os = "macos")] @@ -309,6 +372,9 @@ pub type CropBounds = LogicalBounds; #[cfg(windows)] pub type CropBounds = PhysicalBounds; +#[cfg(target_os = "linux")] +pub type CropBounds = PhysicalBounds; + impl Config { pub fn fps(&self) -> u32 { self.fps @@ -335,6 +401,7 @@ impl ScreenCaptureConfig { max_capture_size: Option<(u32, u32)>, start_time: SystemTime, system_audio: bool, + #[cfg(target_os = "linux")] linux_source: LinuxCaptureSource, #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: SendableShareableContent, #[cfg(target_os = "macos")] excluded_windows: Vec, @@ -360,6 +427,11 @@ impl ScreenCaptureConfig { { crop_bounds.map(|b| b.size().map(|v| (v / 2.0).floor() * 2.0)) } + + #[cfg(target_os = "linux")] + { + crop_bounds.map(|b| b.size().map(|v| (v / 2.0).floor() * 2.0)) + } } .or_else(|| display.physical_size()) .ok_or(ScreenCaptureInitError::NoBounds)?; @@ -371,6 +443,8 @@ impl ScreenCaptureConfig { crop_bounds, fps, show_cursor, + #[cfg(target_os = "linux")] + linux_source, }, video_info: VideoInfo::from_raw_ffmpeg( TCaptureFormat::pixel_format(), diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index f8b975efb63..b3d50f167e1 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1373,16 +1373,6 @@ async fn create_segment_pipeline( #[cfg(not(target_os = "macos"))] let segment_fragmented = fragmented; - #[cfg(target_os = "macos")] - let shared_pause_state = if segment_fragmented { - Some(SharedPauseState::new(Arc::new( - std::sync::atomic::AtomicBool::new(false), - ))) - } else { - None - }; - - #[cfg(windows)] let shared_pause_state = if segment_fragmented { Some(SharedPauseState::new(Arc::new( std::sync::atomic::AtomicBool::new(false), @@ -1397,37 +1387,81 @@ async fn create_segment_pipeline( ); let (screen, system_audio, cursor_display) = if camera_only { - let camera_feed = base_inputs.camera_feed.clone().ok_or_else(|| { - anyhow!( - "Camera-only recording requires a camera, but no camera is currently available. \ - Please select a camera in the recording settings before starting. \ - If you have already selected a camera, it may have been disconnected or \ - failed to initialize. Try reconnecting your camera or selecting a different one." - ) - })?; - - #[cfg(target_os = "macos")] - let screen = OutputPipeline::builder(screen_output_path.clone()) - .with_video::(camera_feed.clone()) - .with_timestamps(start_time) - .build::(AVFoundationCameraMuxerConfig::default()) - .instrument(error_span!("screen-out")) - .await - .context("camera-only screen pipeline setup")?; + #[cfg(target_os = "linux")] + { + let camera_feed = base_inputs.camera_feed.clone().ok_or_else(|| { + anyhow!( + "Camera-only recording requires a camera, but no camera is currently available. \ + Please select a camera in the recording settings before starting. \ + If you have already selected a camera, it may have been disconnected or \ + failed to initialize. Try reconnecting your camera or selecting a different one." + ) + })?; - #[cfg(windows)] - let screen = OutputPipeline::builder(screen_output_path.clone()) - .with_video::(camera_feed.clone()) - .with_timestamps(start_time) - .build::(WindowsCameraMuxerConfig { - encoder_preferences: encoder_preferences.clone(), - ..Default::default() - }) - .instrument(error_span!("screen-out")) - .await + let builder = if segment_fragmented { + OutputPipeline::builder(dir.join("display")) + } else { + OutputPipeline::builder(screen_output_path.clone()) + } + .with_video::(camera_feed) + .with_timestamps(start_time); + + let screen = if segment_fragmented { + builder + .build::( + crate::ffmpeg::SegmentedVideoMuxerConfig { + segment_duration: Duration::from_secs(2), + shared_pause_state: shared_pause_state.clone(), + ..Default::default() + }, + ) + .instrument(error_span!("screen-out")) + .await + } else { + builder + .build::(()) + .instrument(error_span!("screen-out")) + .await + } .context("camera-only screen pipeline setup")?; - (screen, None, None) + (screen, None, None) + } + + #[cfg(any(target_os = "macos", windows))] + { + let camera_feed = base_inputs.camera_feed.clone().ok_or_else(|| { + anyhow!( + "Camera-only recording requires a camera, but no camera is currently available. \ + Please select a camera in the recording settings before starting. \ + If you have already selected a camera, it may have been disconnected or \ + failed to initialize. Try reconnecting your camera or selecting a different one." + ) + })?; + + #[cfg(target_os = "macos")] + let screen = OutputPipeline::builder(screen_output_path.clone()) + .with_video::(camera_feed.clone()) + .with_timestamps(start_time) + .build::(AVFoundationCameraMuxerConfig::default()) + .instrument(error_span!("screen-out")) + .await + .context("camera-only screen pipeline setup")?; + + #[cfg(windows)] + let screen = OutputPipeline::builder(screen_output_path.clone()) + .with_video::(camera_feed.clone()) + .with_timestamps(start_time) + .build::(WindowsCameraMuxerConfig { + encoder_preferences: encoder_preferences.clone(), + ..Default::default() + }) + .instrument(error_span!("screen-out")) + .await + .context("camera-only screen pipeline setup")?; + + (screen, None, None) + } } else { let capture_target = base_inputs.capture_target.clone(); @@ -1452,6 +1486,8 @@ async fn create_segment_pipeline( max_capture_size, start_time.system_time(), base_inputs.capture_system_audio, + #[cfg(target_os = "linux")] + sources::screen_capture::LinuxCaptureSource::from_target(&capture_target), #[cfg(windows)] d3d_device, #[cfg(target_os = "macos")] @@ -1555,6 +1591,36 @@ async fn create_segment_pipeline( None }; + #[cfg(target_os = "linux")] + let camera = if camera_only { + None + } else if let Some(camera_feed) = base_inputs.camera_feed { + let pipeline = if segment_fragmented { + OutputPipeline::builder(dir.join("camera")) + .with_video::(camera_feed) + .with_timestamps(start_time) + .build::( + crate::ffmpeg::SegmentedVideoMuxerConfig { + segment_duration: Duration::from_secs(2), + shared_pause_state: shared_pause_state.clone(), + ..Default::default() + }, + ) + .instrument(error_span!("camera-out")) + .await + } else { + OutputPipeline::builder(dir.join("camera.mp4")) + .with_video::(camera_feed) + .with_timestamps(start_time) + .build::(()) + .instrument(error_span!("camera-out")) + .await + }; + Some(pipeline.context("camera pipeline setup")?) + } else { + None + }; + let microphone = if let Some(mic_feed) = base_inputs.mic_feed { let pipeline = if segment_fragmented { let output_path = dir.join("audio-input.m4a"); diff --git a/crates/rendering/src/decoder/ffmpeg.rs b/crates/rendering/src/decoder/ffmpeg.rs index 291fdd0ee9c..1e26eeb25dc 100644 --- a/crates/rendering/src/decoder/ffmpeg.rs +++ b/crates/rendering/src/decoder/ffmpeg.rs @@ -1,6 +1,8 @@ #![allow(dead_code)] -use ffmpeg::{format, frame, sys::AVHWDeviceType}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use ffmpeg::sys::AVHWDeviceType; +use ffmpeg::{format, frame}; use std::{ cell::RefCell, collections::BTreeMap, diff --git a/crates/scap-ffmpeg/src/lib.rs b/crates/scap-ffmpeg/src/lib.rs index 93beea30862..5b081601117 100644 --- a/crates/scap-ffmpeg/src/lib.rs +++ b/crates/scap-ffmpeg/src/lib.rs @@ -8,6 +8,12 @@ mod direct3d; #[cfg(windows)] pub use direct3d::*; +#[cfg(target_os = "linux")] +#[derive(Debug)] +pub enum AsFFmpegError { + Unsupported, +} + mod cpal; pub use cpal::*; diff --git a/crates/scap-targets/Cargo.toml b/crates/scap-targets/Cargo.toml index 8bf81d2030e..0baa4fd2d82 100644 --- a/crates/scap-targets/Cargo.toml +++ b/crates/scap-targets/Cargo.toml @@ -36,3 +36,6 @@ windows = { workspace = true, features = [ "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", ] } + +[target.'cfg(target_os = "linux")'.dependencies] +x11rb = { version = "0.13.2", features = ["randr"] } diff --git a/crates/scap-targets/src/lib.rs b/crates/scap-targets/src/lib.rs index be57e607369..7a74229058a 100644 --- a/crates/scap-targets/src/lib.rs +++ b/crates/scap-targets/src/lib.rs @@ -195,6 +195,20 @@ impl Window { ), )) } + + #[cfg(target_os = "linux")] + { + let display_bounds = display.raw_handle().logical_bounds()?; + let window_bounds = self.raw_handle().logical_bounds()?; + + Some(LogicalBounds::new( + LogicalPosition::new( + window_bounds.position().x() - display_bounds.position().x(), + window_bounds.position().y() - display_bounds.position().y(), + ), + window_bounds.size(), + )) + } } } diff --git a/crates/scap-targets/src/platform/linux.rs b/crates/scap-targets/src/platform/linux.rs new file mode 100644 index 00000000000..566eb63083a --- /dev/null +++ b/crates/scap-targets/src/platform/linux.rs @@ -0,0 +1,455 @@ +use std::{env, fs, str::FromStr}; + +use x11rb::{ + connection::Connection, + protocol::{ + randr::ConnectionExt as RandrConnectionExt, + xproto::{Atom, AtomEnum, ConnectionExt as XprotoConnectionExt, Window}, + }, + rust_connection::RustConnection, +}; + +use crate::bounds::{ + LogicalBounds, LogicalPosition, LogicalSize, PhysicalBounds, PhysicalPosition, PhysicalSize, +}; + +#[derive(Clone, Copy)] +pub struct DisplayImpl { + id: u32, + x: i32, + y: i32, + width: u32, + height: u32, + refresh_rate: f64, +} + +impl DisplayImpl { + pub fn primary() -> Self { + Self::list().into_iter().next().unwrap_or(Self { + id: 0, + x: 0, + y: 0, + width: 0, + height: 0, + refresh_rate: 60.0, + }) + } + + pub fn list() -> Vec { + let Ok((conn, screen_num)) = x11_connection() else { + return wayland_displays(); + }; + let screen = &conn.setup().roots[screen_num]; + let root = screen.root; + + let monitors = conn + .randr_get_monitors(root, true) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .map(|reply| { + reply + .monitors + .into_iter() + .enumerate() + .filter(|(_, monitor)| monitor.width > 0 && monitor.height > 0) + .map(|(index, monitor)| Self { + id: index as u32, + x: monitor.x.into(), + y: monitor.y.into(), + width: monitor.width.into(), + height: monitor.height.into(), + refresh_rate: 60.0, + }) + .collect::>() + }) + .unwrap_or_default(); + + if !monitors.is_empty() { + return monitors; + } + + vec![Self { + id: 0, + x: 0, + y: 0, + width: screen.width_in_pixels.into(), + height: screen.height_in_pixels.into(), + refresh_rate: 60.0, + }] + } + + pub fn raw_id(&self) -> DisplayIdImpl { + DisplayIdImpl(self.id) + } + + pub fn from_id(id: String) -> Option { + let parsed = id.parse::().ok()?; + Self::list() + .into_iter() + .find(|display| display.id == parsed) + } + + pub fn logical_size(&self) -> Option { + Some(LogicalSize::new(self.width.into(), self.height.into())) + } + + pub fn logical_bounds(&self) -> Option { + Some(LogicalBounds::new( + LogicalPosition::new(self.x.into(), self.y.into()), + self.logical_size()?, + )) + } + + pub fn physical_bounds(&self) -> Option { + Some(PhysicalBounds::new( + PhysicalPosition::new(self.x.into(), self.y.into()), + self.physical_size()?, + )) + } + + pub fn physical_position(&self) -> Option { + Some(PhysicalPosition::new(self.x.into(), self.y.into())) + } + + pub fn physical_size(&self) -> Option { + Some(PhysicalSize::new(self.width.into(), self.height.into())) + } + + pub fn refresh_rate(&self) -> f64 { + self.refresh_rate + } + + pub fn name(&self) -> Option { + Some(format!("Display {}", self.id + 1)) + } + + pub fn get_containing_cursor() -> Option { + let cursor = get_cursor_position()?; + Self::list().into_iter().find(|display| { + display + .physical_bounds() + .is_some_and(|bounds| bounds.contains_point(cursor)) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DisplayIdImpl(u32); + +impl std::fmt::Display for DisplayIdImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for DisplayIdImpl { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(Self) + .map_err(|e| format!("invalid X11 display id: {e}")) + } +} + +#[derive(Clone, Copy)] +pub struct WindowImpl(Window); + +impl WindowImpl { + pub fn list() -> Vec { + let Ok((conn, screen_num)) = x11_connection() else { + return wayland_windows(); + }; + let screen = &conn.setup().roots[screen_num]; + let root = screen.root; + let windows = filter_window_candidates(client_list(&conn, root)); + + if windows.is_empty() { + filter_window_candidates(query_window_tree(&conn, root)) + } else { + windows + } + } + + pub fn list_containing_cursor() -> Vec { + let Some(cursor) = get_cursor_position() else { + return Vec::new(); + }; + let mut windows = Self::list() + .into_iter() + .filter(|window| { + window + .physical_bounds() + .is_some_and(|bounds| bounds.contains_point(cursor)) + }) + .collect::>(); + windows.reverse(); + windows + } + + pub fn get_topmost_at_cursor() -> Option { + Self::list_containing_cursor().into_iter().next() + } + + pub fn id(&self) -> WindowIdImpl { + WindowIdImpl(self.0) + } + + pub fn physical_size(&self) -> Option { + Some(self.physical_bounds()?.size()) + } + + pub fn logical_size(&self) -> Option { + let size = self.physical_size()?; + Some(LogicalSize::new(size.width(), size.height())) + } + + pub fn physical_bounds(&self) -> Option { + if is_wayland_portal_window(self.0) { + return Some(DisplayImpl::primary().physical_bounds()?); + } + + let (conn, screen_num) = x11_connection().ok()?; + let root = conn.setup().roots[screen_num].root; + let geometry = conn.get_geometry(self.0).ok()?.reply().ok()?; + let translated = conn + .translate_coordinates(self.0, root, 0, 0) + .ok()? + .reply() + .ok()?; + + Some(PhysicalBounds::new( + PhysicalPosition::new(translated.dst_x.into(), translated.dst_y.into()), + PhysicalSize::new(geometry.width.into(), geometry.height.into()), + )) + } + + pub fn logical_bounds(&self) -> Option { + let bounds = self.physical_bounds()?; + Some(LogicalBounds::new( + LogicalPosition::new(bounds.position().x(), bounds.position().y()), + LogicalSize::new(bounds.size().width(), bounds.size().height()), + )) + } + + pub fn owner_name(&self) -> Option { + if is_wayland_portal_window(self.0) { + return Some("Wayland Portal".to_string()); + } + + let (conn, _) = x11_connection().ok()?; + window_pid(&conn, self.0) + .and_then(process_name) + .or_else(|| window_property_string(&conn, self.0, "WM_CLASS")) + } + + pub fn app_icon(&self) -> Option> { + None + } + + pub fn display(&self) -> Option { + let bounds = self.physical_bounds()?; + let center = PhysicalPosition::new( + bounds.position().x() + bounds.size().width() / 2.0, + bounds.position().y() + bounds.size().height() / 2.0, + ); + + DisplayImpl::list().into_iter().find(|display| { + display + .physical_bounds() + .is_some_and(|display_bounds| display_bounds.contains_point(center)) + }) + } + + pub fn name(&self) -> Option { + if is_wayland_portal_window(self.0) { + return Some("Select window when recording".to_string()); + } + + let (conn, _) = x11_connection().ok()?; + window_property_string(&conn, self.0, "_NET_WM_NAME") + .or_else(|| window_property_string(&conn, self.0, "WM_NAME")) + } + + pub fn level(&self) -> Option { + Some(0) + } +} + +fn filter_window_candidates(windows: Vec) -> Vec { + windows + .into_iter() + .map(WindowImpl) + .filter(|window| { + window + .physical_size() + .is_some_and(|size| size.width() > 0.0 && size.height() > 0.0) + && window.display().is_some() + && window.name().is_some_and(|name| !name.is_empty()) + }) + .collect() +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowIdImpl(Window); + +impl std::fmt::Display for WindowIdImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for WindowIdImpl { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(Self) + .map_err(|e| format!("invalid X11 window id: {e}")) + } +} + +fn x11_connection() -> Result<(RustConnection, usize), x11rb::errors::ConnectError> { + x11rb::connect(None) +} + +fn wayland_displays() -> Vec { + if env::var_os("WAYLAND_DISPLAY").is_none() { + return Vec::new(); + } + + let (width, height) = wayland_display_size(); + vec![DisplayImpl { + id: 0, + x: 0, + y: 0, + width, + height, + refresh_rate: 60.0, + }] +} + +fn wayland_windows() -> Vec { + if wayland_displays().is_empty() { + Vec::new() + } else { + vec![WindowImpl(0)] + } +} + +fn wayland_display_size() -> (u32, u32) { + let width = env::var("CAP_WAYLAND_OUTPUT_WIDTH") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(1920); + let height = env::var("CAP_WAYLAND_OUTPUT_HEIGHT") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(1080); + + (width, height) +} + +fn is_wayland_portal_window(window: Window) -> bool { + window == 0 && x11_connection().is_err() && env::var_os("WAYLAND_DISPLAY").is_some() +} + +fn intern_atom(conn: &RustConnection, name: &str) -> Option { + conn.intern_atom(false, name.as_bytes()) + .ok()? + .reply() + .ok() + .map(|reply| reply.atom) +} + +fn client_list(conn: &RustConnection, root: Window) -> Vec { + ["_NET_CLIENT_LIST_STACKING", "_NET_CLIENT_LIST"] + .into_iter() + .find_map(|name| { + let atom = intern_atom(conn, name)?; + let reply = conn + .get_property(false, root, atom, AtomEnum::WINDOW, 0, u32::MAX) + .ok()? + .reply() + .ok()?; + reply + .value32() + .map(|values| values.collect::>()) + .filter(|windows| !windows.is_empty()) + }) + .unwrap_or_else(|| query_window_tree(conn, root)) +} + +fn query_window_tree(conn: &RustConnection, root: Window) -> Vec { + let mut windows = Vec::new(); + let mut stack = conn + .query_tree(root) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .map(|reply| reply.children) + .unwrap_or_default(); + + while let Some(window) = stack.pop() { + windows.push(window); + if let Ok(cookie) = conn.query_tree(window) + && let Ok(reply) = cookie.reply() + { + stack.extend(reply.children); + } + } + + windows +} + +fn window_property_string( + conn: &RustConnection, + window: Window, + property_name: &str, +) -> Option { + let property = intern_atom(conn, property_name)?; + let reply = conn + .get_property(false, window, property, AtomEnum::ANY, 0, 1024) + .ok()? + .reply() + .ok()?; + if reply.value.is_empty() { + return None; + } + let nul = reply + .value + .iter() + .position(|b| *b == 0) + .unwrap_or(reply.value.len()); + String::from_utf8(reply.value[..nul].to_vec()) + .ok() + .filter(|value| !value.is_empty()) +} + +fn window_pid(conn: &RustConnection, window: Window) -> Option { + let property = intern_atom(conn, "_NET_WM_PID")?; + let reply = conn + .get_property(false, window, property, AtomEnum::CARDINAL, 0, 1) + .ok()? + .reply() + .ok()?; + reply.value32()?.next() +} + +fn process_name(pid: u32) -> Option { + fs::read_to_string(format!("/proc/{pid}/comm")) + .ok() + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) +} + +fn get_cursor_position() -> Option { + let (conn, screen_num) = x11_connection().ok()?; + let root = conn.setup().roots[screen_num].root; + let reply = conn.query_pointer(root).ok()?.reply().ok()?; + Some(PhysicalPosition::new( + reply.root_x.into(), + reply.root_y.into(), + )) +} diff --git a/crates/scap-targets/src/platform/mod.rs b/crates/scap-targets/src/platform/mod.rs index 07dff2afeef..4387a8453c5 100644 --- a/crates/scap-targets/src/platform/mod.rs +++ b/crates/scap-targets/src/platform/mod.rs @@ -7,3 +7,8 @@ pub use macos::*; mod win; #[cfg(windows)] pub use win::*; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; diff --git a/crates/timestamp/src/lib.rs b/crates/timestamp/src/lib.rs index 9b537fdfeb1..f3f5e7f5e8b 100644 --- a/crates/timestamp/src/lib.rs +++ b/crates/timestamp/src/lib.rs @@ -82,6 +82,11 @@ impl Timestamp { { Self::MachAbsoluteTime(MachAbsoluteTimestamp::from_cpal(instant)) } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = instant; + Self::Instant(Instant::now()) + } } } diff --git a/packages/web-api-contract-effect/src/index.ts b/packages/web-api-contract-effect/src/index.ts index dca827a1d1d..b2b6d7f4637 100644 --- a/packages/web-api-contract-effect/src/index.ts +++ b/packages/web-api-contract-effect/src/index.ts @@ -15,7 +15,7 @@ const TranscriptionStatus = Schema.Literal( "SKIPPED", "NO_AUDIO", ); -const OSType = Schema.Literal("macos", "windows"); +const OSType = Schema.Literal("macos", "windows", "linux"); const LicenseType = Schema.Literal("yearly", "lifetime"); const MessageResponse = Schema.Struct({ diff --git a/packages/web-api-contract/src/desktop.ts b/packages/web-api-contract/src/desktop.ts index 9d4edf756c0..2a0b96b529b 100644 --- a/packages/web-api-contract/src/desktop.ts +++ b/packages/web-api-contract/src/desktop.ts @@ -126,7 +126,7 @@ const protectedContract = c.router( contentType: "application/x-www-form-urlencoded", body: z.object({ feedback: z.string(), - os: z.union([z.literal("macos"), z.literal("windows")]), + os: z.enum(["macos", "windows", "linux"]), version: z.string(), }), responses: { diff --git a/scripts/setup.js b/scripts/setup.js index 38ef03271e6..cbdffd941ea 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -20,14 +20,14 @@ const arch = process.env.RUST_TARGET_TRIPLE?.split("-")[0] ?? (process.arch === "arm64" ? "aarch64" : "x86_64"); -const BASE_CARGO_TOML = `[env] +const FFMPEG_CARGO_ENV = `[env] FFMPEG_DIR = { relative = true, force = true, value = "target/native-deps" } `; async function main() { await fs.mkdir(targetDir, { recursive: true }); - let cargoConfigContents = BASE_CARGO_TOML; + let cargoConfigContents = ""; let cargoBuildContents = ""; const sccachePath = await findExecutable("sccache"); const useSccache = env.CAP_USE_SCCACHE === "1"; @@ -43,6 +43,8 @@ async function main() { ); if (process.platform === "darwin") { + cargoConfigContents += FFMPEG_CARGO_ENV; + const NATIVE_DEPS_VERSION = "v0.25"; const NATIVE_DEPS_URL = `https://github.com/spacedriveapp/native-deps/releases/download/${NATIVE_DEPS_VERSION}`; @@ -120,6 +122,8 @@ async function main() { onnxRuntimePath, )}" }\n`; } else if (process.platform === "win32") { + cargoConfigContents += FFMPEG_CARGO_ENV; + await ensureMsvcVersion(); const FFMPEG_VERSION = "7.1";