Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdeab1aa3 | ||
|
|
072a4ab7ca | ||
|
|
d065ad7775 | ||
|
|
f1c31d6c90 | ||
|
|
178c400eeb | ||
|
|
dd016f7727 | ||
|
|
6db5964d06 | ||
|
|
0a7d62a807 | ||
|
|
7ac7e66ff1 | ||
|
|
f6cdef5dd2 | ||
|
|
36c13449dd | ||
|
|
2faddbd684 | ||
|
|
ec4a6ad74d | ||
|
|
c1cac1ba46 | ||
|
|
b51c64eee3 | ||
|
|
435ddf2b17 | ||
|
|
98fe898efb | ||
|
|
e20b2680cc | ||
|
|
795118c200 | ||
|
|
96590e9c4a | ||
|
|
dc9cbd6d84 | ||
|
|
afc54b363e | ||
|
|
06377de707 | ||
|
|
91f119e6b6 | ||
|
|
3588d5b212 | ||
|
|
9178a303ca | ||
|
|
444758f9ec | ||
|
|
263e7a964e | ||
|
|
f2eb7139da | ||
|
|
80b6abdb32 |
@@ -0,0 +1,6 @@
|
||||
# Development builds use the pinned OBS runtime assembled by scripts/obs/.
|
||||
# Packaged builds find the runtime relative to the executable instead.
|
||||
[env]
|
||||
CLIPBIN_OBS_RUNTIME_DIR = { value = "target/obs-runtime", relative = true }
|
||||
# Linux: link libobs from the bundled runtime rather than a system OBS.
|
||||
PKG_CONFIG_PATH = { value = "target/obs-runtime/linux-x86_64/lib/pkgconfig", relative = true }
|
||||
@@ -5,20 +5,58 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- name: Install native UI build dependencies
|
||||
- name: Install native build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev
|
||||
# GPUI, plus clang for the libobs bindings.
|
||||
sudo apt-get install -y build-essential pkg-config clang libfontconfig1-dev libfreetype6-dev \
|
||||
libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev
|
||||
# OBS 32.0.4 libobs and the bundled plugins (scripts/obs/build-linux.sh).
|
||||
sudo apt-get install -y cmake ninja-build extra-cmake-modules libavcodec-dev libavdevice-dev \
|
||||
libavfilter-dev libavformat-dev libavutil-dev libswresample-dev libswscale-dev libx264-dev \
|
||||
libcurl4-openssl-dev libmbedtls-dev libgl1-mesa-dev libglvnd-dev libgles2-mesa-dev libjansson-dev \
|
||||
libx11-dev libx11-xcb-dev libxcb-randr0-dev libxcb-shm0-dev libxcb-xinerama0-dev \
|
||||
libxcb-composite0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcomposite-dev libxinerama-dev \
|
||||
libxss-dev libpci-dev libpipewire-0.3-dev libpulse-dev libudev-dev libva-dev libdrm-dev \
|
||||
librist-dev libsrt-openssl-dev uthash-dev libsimde-dev nlohmann-json3-dev libffmpeg-nvenc-dev \
|
||||
uuid-dev libglib2.0-dev
|
||||
- name: Cache the OBS runtime
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: target/obs-runtime/linux-x86_64
|
||||
key: obs-runtime-linux-${{ hashFiles('scripts/obs/versions.env', 'scripts/obs/build-linux.sh') }}
|
||||
- name: Build the OBS runtime
|
||||
run: |
|
||||
if [ ! -f target/obs-runtime/linux-x86_64/VERSION ]; then
|
||||
scripts/obs/build-linux.sh
|
||||
fi
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Check Rust
|
||||
- name: Lint
|
||||
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
- name: Test
|
||||
run: cargo test --locked --workspace
|
||||
- name: Build
|
||||
run: cargo build --locked --workspace
|
||||
|
||||
windows-cross-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
components: clippy
|
||||
targets: x86_64-pc-windows-msvc
|
||||
# Type-checks the Windows code paths; nothing is linked or run. The GPUI
|
||||
# crate needs the MSVC C toolchain and is checked on Windows instead.
|
||||
- name: Lint Windows code paths
|
||||
run: >
|
||||
cargo clippy --locked --target x86_64-pc-windows-msvc
|
||||
-p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||
|
||||
@@ -60,6 +60,28 @@ dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -440,7 +462,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"pastey 0.1.1",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"v_frame",
|
||||
@@ -496,6 +518,26 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 2.1.3",
|
||||
"shlex 1.3.0",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -842,13 +884,62 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "clipbin-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ashpd 0.12.3",
|
||||
"async-io",
|
||||
"dirs 5.0.1",
|
||||
"futures-lite 2.6.1",
|
||||
"futures-util",
|
||||
"ksni",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tray-icon",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.55.0",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipbin-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clipbin-core",
|
||||
"clipbin-media",
|
||||
"clipbin-obs",
|
||||
"gpui",
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipbin-media"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clipbin-core",
|
||||
"cpal",
|
||||
"ffmpeg-next",
|
||||
"image",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipbin-obs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clipbin-core",
|
||||
"fs4",
|
||||
"libobs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipbin-probe"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clipbin-core",
|
||||
"clipbin-obs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -928,6 +1019,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "command-fds"
|
||||
version = "0.3.3"
|
||||
@@ -1138,6 +1239,20 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-text"
|
||||
version = "0.14.2"
|
||||
@@ -1161,6 +1276,39 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"block2",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"futures",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"portable-atomic",
|
||||
"pulseaudio",
|
||||
"web-sys",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1188,6 +1336,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -1264,6 +1421,12 @@ version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
@@ -1394,6 +1557,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dtor"
|
||||
version = "0.0.6"
|
||||
@@ -1450,7 +1619,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 1.1.7+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1473,6 +1642,17 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-primitive-derive"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1633,6 +1813,31 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-next"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"ffmpeg-sys-next",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sys-next"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b"
|
||||
dependencies = [
|
||||
"bindgen 0.72.1",
|
||||
"cc",
|
||||
"libc",
|
||||
"num_cpus",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -1806,6 +2011,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -2077,14 +2292,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"as-raw-xcb-connection",
|
||||
"ashpd 0.11.1",
|
||||
"async-task",
|
||||
"bindgen",
|
||||
"bindgen 0.71.1",
|
||||
"blade-graphics",
|
||||
"blade-macros",
|
||||
"blade-util",
|
||||
@@ -2160,7 +2373,7 @@ dependencies = [
|
||||
"wayland-protocols-plasma",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
"windows-registry 0.5.3",
|
||||
"x11-clipboard",
|
||||
"x11rb",
|
||||
@@ -2236,7 +2449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
"bindgen 0.71.1",
|
||||
"core-foundation 0.10.0",
|
||||
"core-video",
|
||||
"ctor",
|
||||
@@ -2827,6 +3040,64 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
@@ -2848,6 +3119,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -2858,6 +3139,24 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ksni"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b"
|
||||
dependencies = [
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"futures-channel",
|
||||
"futures-lite 2.6.1",
|
||||
"futures-util",
|
||||
"pastey 0.2.3",
|
||||
"serde",
|
||||
"task-local",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.11.3"
|
||||
@@ -2940,6 +3239,16 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libobs"
|
||||
version = "5.0.1+32.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8956a7534b34783f962fd8f9b16383bcf3a2b535629fd68628b205e849bd17"
|
||||
dependencies = [
|
||||
"bindgen 0.72.1",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
@@ -3059,6 +3368,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -3182,6 +3497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -3196,6 +3512,24 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3236,6 +3570,35 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -3421,6 +3784,28 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
@@ -3464,6 +3849,55 @@ dependencies = [
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-avf-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
@@ -3471,10 +3905,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3488,6 +3934,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -3671,6 +4119,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pathfinder_geometry"
|
||||
version = "0.5.1"
|
||||
@@ -3807,6 +4261,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "postage"
|
||||
version = "0.5.0"
|
||||
@@ -3944,6 +4404,22 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
|
||||
|
||||
[[package]]
|
||||
name = "pulseaudio"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d70623bd7967a9ca4c2ae0e807fc380b291f98480fc037042305ec643a4d3373"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
"enum-primitive-derive",
|
||||
"futures",
|
||||
"log",
|
||||
"mio",
|
||||
"num-traits",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
@@ -4357,6 +4833,14 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rig-pointer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wayland-client",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4857,6 +5341,16 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
@@ -5306,6 +5800,15 @@ dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "task-local"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5644,6 +6147,25 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -5890,6 +6412,12 @@ dependencies = [
|
||||
"sval_serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -6099,6 +6627,19 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols 0.32.13",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
@@ -6207,11 +6748,23 @@ version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6224,7 +6777,7 @@ dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
"windows 0.61.3",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6236,6 +6789,15 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
@@ -6261,6 +6823,19 @@ dependencies = [
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
@@ -6269,7 +6844,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
"windows-threading 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6338,6 +6924,16 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.4.0"
|
||||
@@ -6378,6 +6974,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.3.1"
|
||||
@@ -6396,6 +7001,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
@@ -6489,6 +7103,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -6645,6 +7268,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
[workspace]
|
||||
members = ["crates/clipbin-core", "crates/clipbin-desktop"]
|
||||
exclude = ["vendor/gpui"]
|
||||
members = [
|
||||
"crates/clipbin-core",
|
||||
"crates/clipbin-desktop",
|
||||
"crates/clipbin-media",
|
||||
"crates/clipbin-obs",
|
||||
"crates/clipbin-probe",
|
||||
"crates/rig-pointer",
|
||||
]
|
||||
default-members = ["crates/clipbin-desktop"]
|
||||
resolver = "3"
|
||||
|
||||
@@ -13,7 +21,12 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
clipbin-core = { path = "crates/clipbin-core" }
|
||||
clipbin-media = { path = "crates/clipbin-media" }
|
||||
clipbin-obs = { path = "crates/clipbin-obs" }
|
||||
gpui = "=0.2.2"
|
||||
# The tested OBS set: the libobs crate's bundled headers are OBS 32.0.4, matching
|
||||
# scripts/obs/versions.env. Update them together.
|
||||
libobs = "=5.0.1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -21,7 +34,15 @@ unsafe_code = "forbid"
|
||||
[profile.dev]
|
||||
debug = 1
|
||||
|
||||
# GPUI is far too slow to use unoptimised; optimise dependencies even in dev
|
||||
# builds (our own crates stay quick to rebuild).
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
|
||||
[patch.crates-io]
|
||||
gpui = { path = "vendor/gpui" }
|
||||
|
||||
@@ -1,47 +1,74 @@
|
||||
# Clipbin Desktop
|
||||
|
||||
An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust and GPUI provide the desktop interface; the recording engine will use libobs and official OBS capture components.
|
||||
An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust and GPUI provide the desktop interface; the recording engine uses libobs and official OBS capture components.
|
||||
|
||||
[SPEC.md](SPEC.md) defines the agreed v1 product requirements.
|
||||
|
||||
## Current state
|
||||
|
||||
This is the initial project scaffold. It opens a native GPUI window, displays the agreed recording defaults, and always reports **Not recording**.
|
||||
The recording foundation is being validated (see [docs/validation.md](docs/validation.md)). `clipbin-obs` records official OBS capture sources through one hardware encoder into keyframe-aligned segments and reports honest status; `clipbin-probe` exercises it. It passes the Linux headless rig and the hardware sign-off on CachyOS/Hyprland/RX 9070 XT; Windows runtime validation is still to do.
|
||||
|
||||
Capture, Steam detection, recording, playback, clipping, bookmarks, persistence, global hotkeys, autostart, and background recording are not implemented yet. Closing the window exits this initial shell. The full application will keep its recorder running independently of window closure, as specified in SPEC.md.
|
||||
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable.
|
||||
|
||||
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Linux capture delivery loss or Windows WASAPI device errors produce an “audio capture lost” warning while video continues.
|
||||
|
||||
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
crates/
|
||||
clipbin-core/ Settings and recorder state; independent of GPUI and native OBS.
|
||||
clipbin-core/ Settings, Steam detection and session storage; independent of GPUI and OBS.
|
||||
clipbin-media/ FFmpeg: stream-copy clip export, scaled video/audio playback and cached thumbnails.
|
||||
clipbin-obs/ Recording engine over the pinned libobs runtime; all unsafe code is in src/ffi.rs.
|
||||
clipbin-probe/ Validation probe for capture, encoders, segments and status.
|
||||
clipbin-desktop/ GPUI application and initial session-library view.
|
||||
.gitea/workflows/ Rust formatting and compilation checks.
|
||||
SPEC.md Product requirements and acceptance checks.
|
||||
scripts/obs/ Assemble the pinned OBS runtime for Linux (built from source) and Windows (official release).
|
||||
scripts/rig/ Headless Wayland/X11 desktop for capture tests on machines without a display.
|
||||
scripts/validate/ Validation suites: Linux rig, Linux hardware, Windows.
|
||||
docs/validation.md Validation status and findings.
|
||||
.gitea/workflows/ Formatting, lint, test and Windows cross-checks.
|
||||
SPEC.md Product requirements and acceptance checks.
|
||||
```
|
||||
|
||||
The core currently has no external dependencies. Keep capture and session operations out of the UI thread when the recording engine is added.
|
||||
Keep capture and session operations out of the UI thread. Core uses serde/JSON, dirs, tempfile, rustix on Linux and winreg/windows-sys on Windows. Raw OBS calls live in `clipbin-obs/src/ffi.rs`; raw Windows process calls live in the small `clipbin-core/src/windows_process.rs` bridge.
|
||||
|
||||
## Development
|
||||
|
||||
Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Cargo.lock pins transitive dependencies.
|
||||
Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Its vendored [background-loop patch](vendor/gpui/CLIPBIN.md) keeps one application alive while its windows close and reopen. Cargo.lock pins transitive dependencies.
|
||||
|
||||
Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux.
|
||||
|
||||
On Arch/CachyOS, install the native build dependencies:
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl
|
||||
sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl clang ffmpeg alsa-lib pipewire-alsa libpulse
|
||||
```
|
||||
|
||||
### OBS runtime
|
||||
|
||||
Clipbin bundles a pinned OBS **32.0.4** runtime (`scripts/obs/versions.env`) with only the official plugins it uses. The `libobs` crate's headers match that version; the engine refuses to start against any other.
|
||||
|
||||
- Linux: `scripts/obs/build-linux.sh` builds libobs and the plugins from source into `target/obs-runtime/linux-x86_64`. Arch/CachyOS build dependencies:
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed cmake ninja extra-cmake-modules ffmpeg x264 pipewire libpulse libdrm libva jansson curl libxcomposite libxinerama libxrandr wayland-protocols simde uthash nlohmann-json mbedtls3 pciutils srt librist ffnvcodec-headers amf-headers
|
||||
```
|
||||
|
||||
- Windows: `scripts/obs/assemble-windows.sh` (Git Bash) verifies and unpacks the official release zip into `target/obs-runtime/windows-x86_64`, copying files byte-for-byte so the Game Capture hook keeps its signature.
|
||||
|
||||
Development builds find the runtime through `.cargo/config.toml`; `clipbin-probe`'s build script places `obs-ffmpeg-mux` (and on Windows the OBS DLLs) next to the executable, where OBS looks for them.
|
||||
|
||||
On Debian/Ubuntu:
|
||||
|
||||
```sh
|
||||
sudo apt-get update
|
||||
sudo apt-get install build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev
|
||||
sudo apt-get install build-essential pkg-config libfontconfig1-dev libfreetype6-dev libwayland-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libssl-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libasound2-dev libpulse-dev pipewire-alsa
|
||||
```
|
||||
|
||||
For Windows, use the MSVC Rust toolchain and Visual Studio Build Tools with the Desktop development with C++ workload. Windows support needs validation on a Windows machine.
|
||||
Media dynamically links FFmpeg through `ffmpeg-next` 9. Linux uses system FFmpeg 8. On Windows, set `FFMPEG_DIR` to an FFmpeg **7.1 shared development** package with MSVC import libraries, matching the OBS-bundled `avcodec-61`, `avformat-61`, `avutil-59`, `swscale-8` and `swresample-5` DLLs. Keep those OBS DLLs alongside the desktop executable; do not mix FFmpeg major versions. Audio uses cpal (PulseAudio or ALSA/PipeWire on Linux, WASAPI on Windows).
|
||||
|
||||
For Windows, use the MSVC Rust toolchain and Visual Studio Build Tools with the Desktop development with C++ workload. Windows support needs validation on a Windows machine; see [docs/validation.md](docs/validation.md#windows).
|
||||
|
||||
Run the desktop shell from an active graphical session:
|
||||
|
||||
@@ -51,16 +78,24 @@ cargo run --locked -p clipbin-desktop
|
||||
|
||||
The desktop app needs a working Wayland or X11 session on Linux, or a Windows desktop. It does not start a web server. On this remote development machine, bind any future development server to its tailnet IP.
|
||||
|
||||
Use the Quit button or Ctrl+Q to quit.
|
||||
Use tray Quit or Ctrl+Q to quit. Closing the window keeps Clipbin in the background when a tray is available. Start on login is enabled by default and can be disabled in Settings. On Linux a session D-Bus and an SNI host (for example Waybar or KDE) provide the tray; bare Sway falls back to close=Quit.
|
||||
|
||||
Checks:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
cargo build --locked --workspace
|
||||
cargo test --locked --workspace
|
||||
# Windows code paths, from any platform (rustup target add x86_64-pc-windows-msvc):
|
||||
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Clips/bookmarks/cleanup validation: `scripts/validate/linux-clips.sh`. Native X11 hotkey validation: `scripts/validate/linux-hotkeys.sh`. Both run in the headless rig. Real Hyprland portal hotkeys and Windows runtime behaviour still need validation.
|
||||
|
||||
Background/tray/autostart validation: `scripts/validate/linux-background.sh` uses a minimal SNI host and activates the real D-Bus menu. Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Automatic desktop sessions: `scripts/validate/linux-session.sh` (fake Steam reaper, recording and library screenshots, close-window survival, game exit and explicit Quit). Playback fixtures: `scripts/validate/playback-fixture.sh`. Playback/audio e2e: `scripts/validate/linux-playback.sh [output-dir]` (moving preview, active recording, gaps, thumbnails, audio monitor, timing and CPU report). Fake Steam rigs must run serially because discovery reads the machine’s `/proc`. Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times.
|
||||
|
||||
Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms.
|
||||
|
||||
The core can also be checked without native UI dependencies:
|
||||
|
||||
```sh
|
||||
@@ -69,14 +104,12 @@ cargo check --locked -p clipbin-core
|
||||
|
||||
## First recording milestone
|
||||
|
||||
1. Validate official OBS capture and recording-health signals on CachyOS, Hyprland and the RX 9070 XT at 1440p60.
|
||||
2. Select and pin a tested OBS runtime, Rust bindings, and official plugin set together. Preserve the original signed Windows capture-hook binaries.
|
||||
3. Prove one continuous hardware encoder can produce short keyframe-aligned segments and remux clips across their boundaries while recording continues.
|
||||
1. Validate official OBS capture and recording-health signals on CachyOS, Hyprland and the RX 9070 XT at 1440p60. *Passed on CachyOS/Hyprland/RX 9070 XT (H.264, HEVC, AV1).*
|
||||
2. Select and pin a tested OBS runtime, Rust bindings, and official plugin set together. Preserve the original signed Windows capture-hook binaries. *Pinned to OBS 32.0.4 and libobs crate 5.0.1; Windows runtime unvalidated.*
|
||||
3. Prove one continuous hardware encoder can produce short keyframe-aligned segments and remux clips across their boundaries while recording continues. *Proven in the rig and on the RX 9070 XT with VA-API H.264, HEVC and AV1.*
|
||||
4. Integrate the recorder without blocking GPUI, then add native playback and seeking.
|
||||
|
||||
OBS is not linked or bundled in this scaffold. Adding an unused native dependency would not validate the required runtime/plugin compatibility. The runtime bundle and adapter belong to the first recording milestone.
|
||||
|
||||
The capture integration must use official OBS components: Windows Game Capture/Window Capture, X11 Xcomposite, and Wayland PipeWire/desktop-portal capture. Third-party game hooks are excluded.
|
||||
The capture integration uses official OBS components only: Windows Game Capture/Window Capture, X11 Xcomposite, and Wayland PipeWire/desktop-portal capture. Third-party game hooks are excluded.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ Build an open-source desktop game recorder inspired by Plays.tv, using Rust, GPU
|
||||
|
||||
## Recording defaults and audio
|
||||
|
||||
- Default video: 1080p at 60 FPS.
|
||||
- Resolution and frame rate are configurable, including the user's intended 1440p60 configuration.
|
||||
- Default video: the captured window's or monitor's own size (following its orientation) at 60 FPS. Decided 2026-10-08, replacing the original 1080p default.
|
||||
- A fixed resolution and the frame rate are configurable, including the user's intended 1440p60 configuration.
|
||||
- Recording writes footage only once capture provides a picture. Time spent waiting for a source (such as the screen-share picker) produces no footage. Decided 2026-10-08.
|
||||
- Hardware video codec choices: H.264, HEVC, and AV1. H.264 is the default.
|
||||
- Offer a codec as usable only when the runtime/device supports its hardware encoder. Failure to obtain a working encoder follows the honest-status and failure-alert rules.
|
||||
- Keep system audio enabled by default.
|
||||
|
||||
@@ -8,5 +8,26 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[dependencies]
|
||||
dirs = "5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ksni = { version = "0.3.6", default-features = false, features = ["async-io", "blocking"] }
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
ashpd = { version = "0.12.3", default-features = false, features = ["async-std"] }
|
||||
async-io = "2"
|
||||
futures-lite = "2"
|
||||
futures-util = "0.3"
|
||||
x11rb = { version = "0.13", features = ["xkb"] }
|
||||
rustix = { version = "1", features = ["process", "event"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tray-icon = { version = "0.26", default-features = false }
|
||||
winreg = "0.55"
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_Security", "Win32_System_LibraryLoader", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Validation helper: exits after both native global actions arrive.
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
fn main() {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let _hotkeys = clipbin_core::hotkeys::start(
|
||||
9,
|
||||
10,
|
||||
move |action, _| {
|
||||
sender.send(action).unwrap();
|
||||
},
|
||||
|status| println!("hotkeys={status}"),
|
||||
);
|
||||
let first = receiver
|
||||
.recv_timeout(Duration::from_secs(20))
|
||||
.expect("first action");
|
||||
let second = receiver
|
||||
.recv_timeout(Duration::from_secs(20))
|
||||
.expect("second action");
|
||||
assert!(matches!(
|
||||
(first, second),
|
||||
(
|
||||
clipbin_core::hotkeys::Action::Clip,
|
||||
clipbin_core::hotkeys::Action::Bookmark
|
||||
)
|
||||
));
|
||||
println!("PASS native clip and bookmark hotkeys");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Minimal SNI watcher/host for the headless validation rig (no desktop bar).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::sync::Mutex;
|
||||
struct Watcher(Mutex<Vec<String>>);
|
||||
#[zbus::interface(name = "org.kde.StatusNotifierWatcher")]
|
||||
impl Watcher {
|
||||
fn register_status_notifier_item(&self, service: String) {
|
||||
eprintln!("registered={service}");
|
||||
self.0.lock().unwrap().push(service);
|
||||
}
|
||||
fn register_status_notifier_host(&self, service: String) {
|
||||
let _ = service;
|
||||
}
|
||||
#[zbus(property)]
|
||||
fn is_status_notifier_host_registered(&self) -> bool {
|
||||
true
|
||||
}
|
||||
#[zbus(property)]
|
||||
fn protocol_version(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
#[zbus(property)]
|
||||
fn registered_status_notifier_items(&self) -> Vec<String> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
let _connection = zbus::blocking::connection::Builder::session()?
|
||||
.name("org.kde.StatusNotifierWatcher")?
|
||||
.serve_at("/StatusNotifierWatcher", Watcher(Mutex::new(Vec::new())))?
|
||||
.build()?;
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Reconcile the OS login entry with the saved setting.
|
||||
use std::{io, path::Path};
|
||||
|
||||
pub fn reconcile(enabled: bool) -> io::Result<()> {
|
||||
apply(enabled, &std::env::current_exe()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply(enabled: bool, executable: &Path) -> io::Result<()> {
|
||||
let path = dirs::config_dir()
|
||||
.ok_or_else(|| io::Error::other("No config directory"))?
|
||||
.join("autostart/clipbin.desktop");
|
||||
if enabled {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let executable = executable
|
||||
.to_str()
|
||||
.ok_or_else(|| io::Error::other("Executable path is not UTF-8"))?;
|
||||
// Desktop Entry Exec quoting includes reserved characters and doubles %.
|
||||
let quoted = executable
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('`', "\\`")
|
||||
.replace('$', "\\$")
|
||||
.replace('%', "%%");
|
||||
let escaped = quoted
|
||||
.replace('\\', "\\\\")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\t', "\\t");
|
||||
std::fs::write(
|
||||
path,
|
||||
format!(
|
||||
"[Desktop Entry]\nType=Application\nName=Clipbin\nExec=\"{escaped}\" --background\nTerminal=false\n"
|
||||
),
|
||||
)
|
||||
} else {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn apply(enabled: bool, executable: &Path) -> io::Result<()> {
|
||||
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
|
||||
let (key, _) = RegKey::predef(HKEY_CURRENT_USER)
|
||||
.create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run")?;
|
||||
if enabled {
|
||||
key.set_value(
|
||||
"Clipbin",
|
||||
&format!("\"{}\" --background", executable.display()),
|
||||
)
|
||||
} else {
|
||||
match key.delete_value("Clipbin") {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Background actions shared by the native tray and single-instance endpoint.
|
||||
use crate::RecordingStatus;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Action {
|
||||
Open,
|
||||
Clip,
|
||||
Bookmark,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct State {
|
||||
pub status: RecordingStatus,
|
||||
pub game: Option<String>,
|
||||
}
|
||||
impl State {
|
||||
pub fn tooltip(&self) -> String {
|
||||
let mut text = format!("Clipbin · {}", self.status.label());
|
||||
if let Some(game) = &self.game {
|
||||
text.push_str(&format!(" · {game}"));
|
||||
}
|
||||
match &self.status {
|
||||
RecordingStatus::Failed { reason } | RecordingStatus::Degraded { reason } => {
|
||||
text.push_str(&format!(" · {reason}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
text
|
||||
}
|
||||
pub fn colour(&self) -> [u8; 3] {
|
||||
match self.status {
|
||||
RecordingStatus::Recording => [239, 68, 68],
|
||||
RecordingStatus::Failed { .. } | RecordingStatus::Degraded { .. } => [245, 158, 11],
|
||||
_ => [168, 85, 247],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
use ksni::blocking::TrayMethods;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
struct Endpoint(Sender<Action>);
|
||||
#[zbus::interface(name = "dev.clipbin.Clipbin")]
|
||||
impl Endpoint {
|
||||
fn open(&self) {
|
||||
let _ = self.0.send(Action::Open);
|
||||
}
|
||||
}
|
||||
pub struct Instance {
|
||||
_connection: zbus::blocking::Connection,
|
||||
}
|
||||
impl Instance {
|
||||
pub fn acquire(actions: Sender<Action>) -> Result<Option<Self>, String> {
|
||||
let connection = zbus::blocking::connection::Builder::session()
|
||||
.map_err(|e| e.to_string())?
|
||||
.serve_at("/dev/clipbin/Clipbin", Endpoint(actions))
|
||||
.map_err(|e| e.to_string())?
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
match connection.request_name_with_flags(
|
||||
"dev.clipbin.Clipbin",
|
||||
zbus::fdo::RequestNameFlags::DoNotQueue.into(),
|
||||
) {
|
||||
Ok(zbus::fdo::RequestNameReply::PrimaryOwner) => Ok(Some(Self {
|
||||
_connection: connection,
|
||||
})),
|
||||
Err(zbus::Error::NameTaken) => {
|
||||
let proxy = zbus::blocking::Proxy::new(
|
||||
&connection,
|
||||
"dev.clipbin.Clipbin",
|
||||
"/dev/clipbin/Clipbin",
|
||||
"dev.clipbin.Clipbin",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
proxy
|
||||
.call::<_, _, ()>("Open", &())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error.to_string()),
|
||||
Ok(reply) => Err(format!("Unexpected D-Bus name response: {reply:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Item {
|
||||
state: State,
|
||||
actions: Sender<Action>,
|
||||
available: Arc<AtomicBool>,
|
||||
}
|
||||
impl ksni::Tray for Item {
|
||||
fn id(&self) -> String {
|
||||
"clipbin".into()
|
||||
}
|
||||
fn title(&self) -> String {
|
||||
self.state.tooltip()
|
||||
}
|
||||
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
|
||||
let [r, g, b] = self.state.colour();
|
||||
let mut data = Vec::with_capacity(32 * 32 * 4);
|
||||
for y in 0_i32..32 {
|
||||
for x in 0_i32..32 {
|
||||
data.extend_from_slice(&[
|
||||
if (x - 16).pow(2) + (y - 16).pow(2) < 196 {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
},
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
]);
|
||||
}
|
||||
}
|
||||
vec![ksni::Icon {
|
||||
width: 32,
|
||||
height: 32,
|
||||
data,
|
||||
}]
|
||||
}
|
||||
fn tool_tip(&self) -> ksni::ToolTip {
|
||||
ksni::ToolTip {
|
||||
title: self.state.tooltip(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn activate(&mut self, _: i32, _: i32) {
|
||||
let _ = self.actions.send(Action::Open);
|
||||
}
|
||||
fn watcher_offline(&self, _: ksni::OfflineReason) -> bool {
|
||||
self.available.store(false, Ordering::Relaxed);
|
||||
let _ = self.actions.send(Action::Open);
|
||||
true
|
||||
}
|
||||
fn watcher_online(&self) {
|
||||
self.available.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
[
|
||||
("Open Clipbin", Action::Open),
|
||||
("Save clip", Action::Clip),
|
||||
("Add bookmark", Action::Bookmark),
|
||||
("Quit", Action::Quit),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, action)| {
|
||||
ksni::menu::StandardItem {
|
||||
label: label.into(),
|
||||
activate: Box::new(move |item: &mut Self| {
|
||||
let _ = item.actions.send(action);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
pub struct Tray {
|
||||
handle: ksni::blocking::Handle<Item>,
|
||||
available: Arc<AtomicBool>,
|
||||
}
|
||||
impl Tray {
|
||||
pub fn start(actions: Sender<Action>) -> Result<Self, String> {
|
||||
let available = Arc::new(AtomicBool::new(true));
|
||||
let handle = Item {
|
||||
actions,
|
||||
available: available.clone(),
|
||||
state: State {
|
||||
status: RecordingStatus::Idle,
|
||||
game: None,
|
||||
},
|
||||
}
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self { handle, available })
|
||||
}
|
||||
pub fn available(&self) -> bool {
|
||||
self.available.load(Ordering::Relaxed) && !self.handle.is_closed()
|
||||
}
|
||||
pub fn update(&self, state: State) {
|
||||
self.handle.update(move |item| item.state = state);
|
||||
}
|
||||
}
|
||||
impl Drop for Tray {
|
||||
fn drop(&mut self) {
|
||||
self.handle.shutdown().wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
pub use crate::windows_background::{Instance, Tray};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::{Instance, Tray};
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Budget selection is pure; the recorder performs deletions and persists spans.
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Candidate {
|
||||
pub session: u64,
|
||||
pub index: usize,
|
||||
pub started: SystemTime,
|
||||
pub active: bool,
|
||||
pub start: Duration,
|
||||
pub bytes: u64,
|
||||
pub protected: bool,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
/// Touching endpoints do not overlap. Bookmark post-roll reaches ahead of the
|
||||
/// current session length, protecting each segment as it arrives.
|
||||
pub fn protected(
|
||||
segment: &crate::store::Segment,
|
||||
bookmarks: &[crate::library::Bookmark],
|
||||
ranges: &[(Duration, Duration)],
|
||||
) -> bool {
|
||||
let overlaps = |(start, end)| start < segment.end && end > segment.start;
|
||||
bookmarks
|
||||
.iter()
|
||||
.any(|b| b.protected && overlaps((b.at.saturating_sub(b.before), b.at + b.after)))
|
||||
|| ranges.iter().copied().any(overlaps)
|
||||
}
|
||||
|
||||
/// Incomplete files count towards usage but can never be removed.
|
||||
pub fn select(candidates: &[Candidate], budget: u64) -> Vec<(u64, usize)> {
|
||||
let mut usage: u64 = candidates
|
||||
.iter()
|
||||
.filter(|c| !c.protected)
|
||||
.map(|c| c.bytes)
|
||||
.sum();
|
||||
let mut eligible: Vec<_> = candidates
|
||||
.iter()
|
||||
.filter(|c| !c.protected && c.complete)
|
||||
.collect();
|
||||
eligible.sort_by_key(|c| (c.active, c.started, c.start, c.index));
|
||||
let mut selected = Vec::new();
|
||||
for c in eligible {
|
||||
if usage <= budget {
|
||||
break;
|
||||
}
|
||||
selected.push((c.session, c.index));
|
||||
usage = usage.saturating_sub(c.bytes);
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn candidate(id: u64, active: bool, protected: bool, complete: bool) -> Candidate {
|
||||
Candidate {
|
||||
session: id,
|
||||
index: 0,
|
||||
started: SystemTime::UNIX_EPOCH + Duration::from_secs(id),
|
||||
active,
|
||||
start: Duration::ZERO,
|
||||
bytes: 10,
|
||||
protected,
|
||||
complete,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn protection_covers_future_post_roll_and_export_but_not_touching_edges() {
|
||||
use crate::{library::Bookmark, store::Segment};
|
||||
let segment = Segment {
|
||||
path: "s.mkv".into(),
|
||||
start: Duration::from_secs(12),
|
||||
end: Duration::from_secs(14),
|
||||
complete: true,
|
||||
available: true,
|
||||
frame_start: 720,
|
||||
frames_per_second: 60,
|
||||
dropped_frames: 0,
|
||||
audio_start_samples: Vec::new(),
|
||||
};
|
||||
let mut bookmark = Bookmark {
|
||||
at: Duration::from_secs(10),
|
||||
before: Duration::from_secs(2),
|
||||
after: Duration::from_secs(4),
|
||||
protected: true,
|
||||
};
|
||||
assert!(protected(&segment, &[bookmark], &[]));
|
||||
bookmark.after = Duration::from_secs(2);
|
||||
assert!(!protected(&segment, &[bookmark], &[]));
|
||||
bookmark.after = Duration::from_secs(10);
|
||||
bookmark.protected = false;
|
||||
assert!(!protected(&segment, &[bookmark], &[]));
|
||||
assert!(protected(
|
||||
&segment,
|
||||
&[],
|
||||
&[(Duration::from_secs(13), Duration::from_secs(15))]
|
||||
));
|
||||
assert!(!protected(
|
||||
&segment,
|
||||
&[],
|
||||
&[(Duration::ZERO, Duration::from_secs(12))]
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn every_budget_and_protection_writer_combination_keeps_selection_safe() {
|
||||
for flags in 0..64 {
|
||||
let c: Vec<_> = (0..3)
|
||||
.map(|i| {
|
||||
candidate(
|
||||
i + 1,
|
||||
i == 0,
|
||||
flags & (1 << (i * 2)) != 0,
|
||||
flags & (1 << (i * 2 + 1)) != 0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for budget in 0..=30 {
|
||||
let chosen = select(&c, budget);
|
||||
for (id, _) in &chosen {
|
||||
let item = &c[(*id - 1) as usize];
|
||||
assert!(!item.protected && item.complete);
|
||||
}
|
||||
let usage: u64 = c
|
||||
.iter()
|
||||
.filter(|item| !item.protected)
|
||||
.map(|item| item.bytes)
|
||||
.sum();
|
||||
let remaining = usage - chosen.len() as u64 * 10;
|
||||
let writer: u64 = c
|
||||
.iter()
|
||||
.filter(|item| !item.protected && !item.complete)
|
||||
.map(|item| item.bytes)
|
||||
.sum();
|
||||
assert!(remaining <= budget.max(writer));
|
||||
if !chosen.is_empty() {
|
||||
assert!(remaining + 10 > budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn completed_sessions_precede_older_active_footage() {
|
||||
let c = [
|
||||
candidate(1, true, false, true),
|
||||
candidate(3, false, false, true),
|
||||
candidate(2, false, false, true),
|
||||
];
|
||||
assert_eq!(select(&c, 10), [(2, 0), (3, 0)]);
|
||||
}
|
||||
#[test]
|
||||
fn protected_bytes_are_excluded_and_writer_is_never_selected() {
|
||||
let c = [
|
||||
candidate(1, false, true, true),
|
||||
candidate(2, true, false, false),
|
||||
candidate(3, true, false, true),
|
||||
];
|
||||
assert_eq!(select(&c, 10), [(3, 0)]);
|
||||
assert_eq!(select(&c, 0), [(3, 0)]);
|
||||
assert!(select(&c, 20).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn oldest_segments_first_and_stop_at_budget() {
|
||||
let mut c = candidate(1, false, false, true);
|
||||
c.index = 1;
|
||||
c.start = Duration::from_secs(2);
|
||||
assert_eq!(select(&[c, candidate(1, false, false, true)], 10), [(1, 0)]);
|
||||
assert!(select(&[], 0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Saved MP4s and their source ranges. Clips are never part of automatic cleanup.
|
||||
use crate::{
|
||||
library::Clip,
|
||||
store::{Store, atomic_json},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, io, path::PathBuf, time::Duration};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedClip {
|
||||
pub clip: Clip,
|
||||
pub source_session: u64,
|
||||
pub start: Duration,
|
||||
pub end: Duration,
|
||||
pub separate_audio: bool,
|
||||
}
|
||||
/// Pending and failed exports retain their source leases across app restarts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingExport {
|
||||
pub session: u64,
|
||||
pub start: Duration,
|
||||
pub end: Duration,
|
||||
pub separate: bool,
|
||||
pub failed: bool,
|
||||
}
|
||||
impl Store {
|
||||
pub fn pending_exports(&self) -> io::Result<Vec<PendingExport>> {
|
||||
match fs::read(self.clips_directory().join("export-requests.json")) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
pub fn save_pending_exports(&self, exports: &[PendingExport]) -> io::Result<()> {
|
||||
atomic_json(
|
||||
&self.clips_directory().join("export-requests.json"),
|
||||
&exports,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clips_directory(&self) -> PathBuf {
|
||||
self.root.parent().unwrap_or(&self.root).join("clips")
|
||||
}
|
||||
pub fn clip_path(&self, id: u64) -> PathBuf {
|
||||
self.clips_directory().join(format!("{id}.mp4"))
|
||||
}
|
||||
pub fn save_clip(&self, clip: &SavedClip) -> io::Result<()> {
|
||||
atomic_json(
|
||||
&self
|
||||
.clips_directory()
|
||||
.join(format!("{}.json", clip.clip.id)),
|
||||
clip,
|
||||
)
|
||||
}
|
||||
pub fn clips(&self) -> io::Result<Vec<SavedClip>> {
|
||||
let entries = match fs::read_dir(self.clips_directory()) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut clips = vec![];
|
||||
for entry in entries {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_some_and(|e| e == "json")
|
||||
&& path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.is_some_and(|s| s.parse::<u64>().is_ok())
|
||||
{
|
||||
let saved: SavedClip =
|
||||
serde_json::from_slice(&fs::read(path)?).map_err(io::Error::other)?;
|
||||
if self.clip_path(saved.clip.id).is_file() {
|
||||
clips.push(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
clips.sort_by_key(|s| std::cmp::Reverse(s.clip.created));
|
||||
Ok(clips)
|
||||
}
|
||||
pub fn delete_clip(&self, id: u64) -> io::Result<()> {
|
||||
fs::remove_file(self.clip_path(id))?;
|
||||
fs::remove_file(self.clips_directory().join(format!("{id}.json")))
|
||||
}
|
||||
}
|
||||
/// Launch an OS association without shell interpolation, off the UI thread.
|
||||
pub fn open(path: PathBuf) -> io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
let status = std::process::Command::new("xdg-open").arg(path).status()?;
|
||||
#[cfg(windows)]
|
||||
let status = std::process::Command::new("explorer.exe")
|
||||
.arg(path)
|
||||
.status()?;
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
return Err(io::Error::other(
|
||||
"Opening files is unsupported on this platform",
|
||||
));
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"File opener exited with {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn pending_and_failed_export_leases_round_trip_without_becoming_clips() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Store {
|
||||
root: temp.path().join("sessions"),
|
||||
};
|
||||
let request = PendingExport {
|
||||
session: 42,
|
||||
start: Duration::from_secs(3),
|
||||
end: Duration::from_secs(5),
|
||||
separate: false,
|
||||
failed: true,
|
||||
};
|
||||
store.save_pending_exports(&[request]).unwrap();
|
||||
let requests = store.pending_exports().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].failed);
|
||||
assert_eq!(requests[0].session, 42);
|
||||
assert!(store.clips().unwrap().is_empty());
|
||||
store.save_pending_exports(&[]).unwrap();
|
||||
assert!(store.pending_exports().unwrap().is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn clips_require_a_published_mp4_and_survive_session_recovery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Store {
|
||||
root: temp.path().join("sessions"),
|
||||
};
|
||||
let saved = SavedClip {
|
||||
clip: Clip {
|
||||
id: 1,
|
||||
game: "Test".into(),
|
||||
created: std::time::SystemTime::UNIX_EPOCH,
|
||||
length: Duration::from_secs(2),
|
||||
size_bytes: 4,
|
||||
},
|
||||
source_session: 42,
|
||||
start: Duration::from_secs(3),
|
||||
end: Duration::from_secs(5),
|
||||
separate_audio: false,
|
||||
};
|
||||
store.save_clip(&saved).unwrap();
|
||||
assert!(store.clips().unwrap().is_empty());
|
||||
fs::write(store.clip_path(1), b"test").unwrap();
|
||||
store.recover().unwrap();
|
||||
assert_eq!(store.clips().unwrap()[0].source_session, 42);
|
||||
let library = store.library(1).unwrap();
|
||||
assert_eq!(library.storage.clip_bytes, 4);
|
||||
assert_eq!(library.storage.unprotected_bytes, 0);
|
||||
store.delete_clip(1).unwrap();
|
||||
assert!(store.clips().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Steam discovery is independent of both the interface and the recorder.
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Game {
|
||||
pub app_id: u32,
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub executable: String,
|
||||
}
|
||||
|
||||
/// `/proc` cmdlines are NUL-separated argv, not shell command strings.
|
||||
pub fn reaper_app_id(bytes: &[u8]) -> Option<u32> {
|
||||
let argv: Vec<_> = bytes
|
||||
.split(|b| *b == 0)
|
||||
.filter_map(|s| std::str::from_utf8(s).ok())
|
||||
.collect();
|
||||
if Path::new(argv.first()?).file_name()?.to_str()? != "reaper" || !argv.contains(&"SteamLaunch")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
argv.iter()
|
||||
.find_map(|s| s.strip_prefix("AppId=").and_then(parse_running_app_id))
|
||||
}
|
||||
pub fn parse_running_app_id(value: &str) -> Option<u32> {
|
||||
value.trim().parse::<u32>().ok().filter(|id| *id > 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VdfValue {
|
||||
Text(String),
|
||||
Object(Vec<(String, VdfValue)>),
|
||||
}
|
||||
impl VdfValue {
|
||||
pub fn get(&self, key: &str) -> Option<&Self> {
|
||||
match self {
|
||||
Self::Object(items) => items
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Text(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Steam's quoted strings, comments, and unquoted keys; unknown keys are kept.
|
||||
pub fn parse_vdf(input: &str) -> Option<VdfValue> {
|
||||
let mut tokens = vec![];
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
if c == '/' && chars.peek() == Some(&'/') {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '{' || c == '}' {
|
||||
tokens.push(c.to_string());
|
||||
continue;
|
||||
}
|
||||
let mut token = String::new();
|
||||
if c == '"' {
|
||||
let mut closed = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if c == '\\' {
|
||||
let next = chars.next()?;
|
||||
if next != '"' && next != '\\' {
|
||||
token.push('\\');
|
||||
}
|
||||
token.push(next);
|
||||
} else {
|
||||
token.push(c);
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
token.push(c);
|
||||
while chars
|
||||
.peek()
|
||||
.is_some_and(|c| !c.is_whitespace() && *c != '{' && *c != '}')
|
||||
{
|
||||
token.push(chars.next()?);
|
||||
}
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
fn object(tokens: &[String], at: &mut usize, nested: bool, depth: usize) -> Option<VdfValue> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
}
|
||||
let mut entries = vec![];
|
||||
while let Some(key) = tokens.get(*at) {
|
||||
if key == "}" {
|
||||
if !nested {
|
||||
return None;
|
||||
}
|
||||
*at += 1;
|
||||
return Some(VdfValue::Object(entries));
|
||||
}
|
||||
if key == "{" {
|
||||
return None;
|
||||
}
|
||||
*at += 1;
|
||||
let value = tokens.get(*at)?;
|
||||
*at += 1;
|
||||
let value = if value == "{" {
|
||||
object(tokens, at, true, depth + 1)?
|
||||
} else if value == "}" {
|
||||
return None;
|
||||
} else {
|
||||
VdfValue::Text(value.clone())
|
||||
};
|
||||
entries.push((key.clone(), value));
|
||||
}
|
||||
if nested {
|
||||
None
|
||||
} else {
|
||||
Some(VdfValue::Object(entries))
|
||||
}
|
||||
}
|
||||
object(&tokens, &mut 0, false, 0)
|
||||
}
|
||||
|
||||
fn steam_root() -> Option<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
|
||||
RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey("Software\\Valve\\Steam")
|
||||
.ok()?
|
||||
.get_value::<String, _>("SteamPath")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let home = dirs::home_dir()?;
|
||||
[
|
||||
home.join(".steam/steam"),
|
||||
home.join(".local/share/Steam"),
|
||||
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.exists())
|
||||
}
|
||||
}
|
||||
fn libraries(root: &Path) -> Vec<PathBuf> {
|
||||
let mut result = vec![root.to_path_buf()];
|
||||
if let Some(vdf) = fs::read_to_string(root.join("steamapps/libraryfolders.vdf"))
|
||||
.ok()
|
||||
.and_then(|s| parse_vdf(&s))
|
||||
&& let Some(VdfValue::Object(entries)) = vdf.get("libraryfolders")
|
||||
{
|
||||
for (key, value) in entries {
|
||||
if key.parse::<u32>().is_ok()
|
||||
&& let Some(path) = value
|
||||
.get("path")
|
||||
.and_then(VdfValue::text)
|
||||
.or_else(|| value.text())
|
||||
{
|
||||
result.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.sort();
|
||||
result.dedup();
|
||||
result
|
||||
}
|
||||
fn manifest(app_id: u32) -> Option<(String, PathBuf)> {
|
||||
for root in libraries(&steam_root()?) {
|
||||
let Some(vdf) =
|
||||
fs::read_to_string(root.join(format!("steamapps/appmanifest_{app_id}.acf")))
|
||||
.ok()
|
||||
.and_then(|s| parse_vdf(&s))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let app = vdf.get("AppState")?;
|
||||
let name = app.get("name")?.text()?.to_string();
|
||||
let install = root
|
||||
.join("steamapps/common")
|
||||
.join(app.get("installdir")?.text()?);
|
||||
return Some((name, install));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn discover() -> io::Result<Vec<Game>> {
|
||||
let mut games = vec![];
|
||||
for entry in fs::read_dir("/proc")?.flatten() {
|
||||
let Some(pid) = entry.file_name().to_str().and_then(|s| s.parse().ok()) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(bytes) = fs::read(entry.path().join("cmdline"))
|
||||
&& let Some(app_id) = reaper_app_id(&bytes)
|
||||
{
|
||||
games.push(Game {
|
||||
app_id,
|
||||
pid,
|
||||
name: manifest(app_id).map_or_else(|| format!("App {app_id}"), |m| m.0),
|
||||
executable: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
games.sort_by_key(|g| g.pid);
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct ExitWatch {
|
||||
fd: rustix::fd::OwnedFd,
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
impl ExitWatch {
|
||||
pub fn open(game: &Game) -> io::Result<Self> {
|
||||
let pid = rustix::process::Pid::from_raw(game.pid as i32)
|
||||
.ok_or_else(|| io::Error::other("Invalid process id"))?;
|
||||
let fd = rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty())?;
|
||||
// Check identity after opening: a recycled PID must not attach to another game.
|
||||
if reaper_app_id(&fs::read(format!("/proc/{}/cmdline", game.pid))?) != Some(game.app_id) {
|
||||
return Err(io::Error::other("Game exited during discovery"));
|
||||
}
|
||||
Ok(Self { fd })
|
||||
}
|
||||
pub fn exited(&mut self) -> io::Result<bool> {
|
||||
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||||
let mut fds = [PollFd::new(&self.fd, PollFlags::IN)];
|
||||
Ok(poll(
|
||||
&mut fds,
|
||||
Some(&Timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
}),
|
||||
)? > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn discover() -> io::Result<Vec<Game>> {
|
||||
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
|
||||
let steam = match RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Valve\\Steam") {
|
||||
Ok(key) => key,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let app_id = steam
|
||||
.get_value::<u32, _>("RunningAppID")
|
||||
.ok()
|
||||
.filter(|id| *id > 0)
|
||||
.or_else(|| {
|
||||
steam
|
||||
.get_value::<String, _>("RunningAppID")
|
||||
.ok()
|
||||
.and_then(|s| parse_running_app_id(&s))
|
||||
});
|
||||
let Some(app_id) = app_id else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if let Ok(app) = steam.open_subkey(format!("Apps\\{app_id}"))
|
||||
&& app.get_value::<u32, _>("Running").ok() == Some(0)
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let (name, directory) = manifest(app_id)
|
||||
.ok_or_else(|| io::Error::other(format!("Cannot locate installation for App {app_id}")))?;
|
||||
Ok(crate::windows_process::processes_in(&directory)?
|
||||
.into_iter()
|
||||
.map(|(pid, executable)| Game {
|
||||
app_id,
|
||||
pid,
|
||||
name: name.clone(),
|
||||
executable,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
pub struct ExitWatch {
|
||||
watch: crate::windows_process::ExitWatch,
|
||||
}
|
||||
#[cfg(windows)]
|
||||
impl ExitWatch {
|
||||
pub fn open(game: &Game) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
watch: crate::windows_process::ExitWatch::open(game.pid, &game.executable)?,
|
||||
})
|
||||
}
|
||||
pub fn exited(&mut self) -> io::Result<bool> {
|
||||
self.watch.exited()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cmdline_matches_only_the_steam_reaper() {
|
||||
assert_eq!(
|
||||
reaper_app_id(b"/usr/bin/reaper\0SteamLaunch\0AppId=480\0--\0sleep\0"),
|
||||
Some(480)
|
||||
);
|
||||
for bytes in [
|
||||
b"sleep\0SteamLaunch\0AppId=480\0".as_slice(),
|
||||
b"reaper\0AppId=480\0",
|
||||
b"reaper\0SteamLaunch\0AppId=bad\0",
|
||||
b"reaper\0SteamLaunch\0AppId=0\0",
|
||||
] {
|
||||
assert_eq!(reaper_app_id(bytes), None);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn registry_values_are_positive_app_ids() {
|
||||
assert_eq!(parse_running_app_id(" 480 "), Some(480));
|
||||
for value in ["0", "-1", "4294967296", "abc", ""] {
|
||||
assert_eq!(parse_running_app_id(value), None);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn manifests_and_library_folders_are_tolerant() {
|
||||
let app = parse_vdf("// comment\n\"AppState\" { appid 480 \"name\" \"Space \\\"War\\\"\" installdir Spacewar }").unwrap();
|
||||
assert_eq!(
|
||||
app.get("appstate").unwrap().get("name").unwrap().text(),
|
||||
Some("Space \"War\"")
|
||||
);
|
||||
let folders = parse_vdf(
|
||||
r#""libraryfolders" { "0" { "path" "C:\\Steam" "apps" { "480" "1" } } "1" "/games" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
folders
|
||||
.get("libraryfolders")
|
||||
.unwrap()
|
||||
.get("0")
|
||||
.unwrap()
|
||||
.get("path")
|
||||
.unwrap()
|
||||
.text(),
|
||||
Some("C:\\Steam")
|
||||
);
|
||||
assert!(parse_vdf("root { name \"unterminated").is_none());
|
||||
assert!(parse_vdf("root { name value").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! OS global actions; capture and UI work stay on their own threads.
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Action {
|
||||
Clip,
|
||||
Bookmark,
|
||||
}
|
||||
pub struct Hotkeys {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
impl Drop for Hotkeys {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
clip: u8,
|
||||
bookmark: u8,
|
||||
action: impl Fn(Action, Instant) + Send + Sync + 'static,
|
||||
status: impl Fn(String) + Send + Sync + 'static,
|
||||
) -> Hotkeys {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let flag = stop.clone();
|
||||
thread::spawn(move || {
|
||||
// Give a previous registration time to release its OS grabs on rebind.
|
||||
thread::sleep(std::time::Duration::from_millis(150));
|
||||
if flag.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if !(1..=12).contains(&clip) || !(1..=12).contains(&bookmark) || clip == bookmark {
|
||||
status("Invalid global bindings; use the Clip/Bookmark buttons".into());
|
||||
return;
|
||||
}
|
||||
let result = platform(clip, bookmark, action, &status, flag.clone());
|
||||
if let Err(e) = result
|
||||
&& !flag.load(Ordering::Relaxed)
|
||||
{
|
||||
status(format!(
|
||||
"Global hotkeys unavailable: {e}. Use Clip/Bookmark buttons."
|
||||
));
|
||||
}
|
||||
});
|
||||
Hotkeys { stop }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
#[cfg(windows)]
|
||||
use windows::platform;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform(
|
||||
clip: u8,
|
||||
bookmark: u8,
|
||||
action: impl Fn(Action, Instant) + Send + Sync + 'static,
|
||||
status: &(impl Fn(String) + Send + Sync + 'static),
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
async_io::block_on(wayland(clip, bookmark, action, status, &stop))
|
||||
} else {
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
protocol::{
|
||||
Event,
|
||||
xkb::{self, ConnectionExt as _},
|
||||
xproto::{ConnectionExt, GrabMode, ModMask},
|
||||
},
|
||||
};
|
||||
let (connection, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.xkb_use_extension(1, 0)
|
||||
.map_err(|e| e.to_string())?
|
||||
.reply()
|
||||
.map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.xkb_per_client_flags(
|
||||
xkb::ID::USE_CORE_KBD.into(),
|
||||
xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT,
|
||||
xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT,
|
||||
0u32.into(),
|
||||
0u32.into(),
|
||||
0u32.into(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?
|
||||
.reply()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let root = connection.setup().roots[screen].root;
|
||||
let setup = connection.setup();
|
||||
let keys = connection
|
||||
.get_keyboard_mapping(setup.min_keycode, setup.max_keycode - setup.min_keycode + 1)
|
||||
.map_err(|e| e.to_string())?
|
||||
.reply()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let lookup = |f: u8| {
|
||||
keys.keysyms
|
||||
.chunks(keys.keysyms_per_keycode as usize)
|
||||
.position(|row| row.contains(&(0xffbd + u32::from(f))))
|
||||
.map(|i| setup.min_keycode + i as u8)
|
||||
.ok_or("Function key missing in X11 map")
|
||||
};
|
||||
let codes = [lookup(clip)?, lookup(bookmark)?];
|
||||
// Ignore the common Caps/Num lock masks when grabbing.
|
||||
for code in codes {
|
||||
for locks in [0, 2, 16, 18] {
|
||||
connection
|
||||
.grab_key(
|
||||
false,
|
||||
root,
|
||||
ModMask::CONTROL | ModMask::SHIFT | ModMask::from(locks as u16),
|
||||
code,
|
||||
GrabMode::ASYNC,
|
||||
GrabMode::ASYNC,
|
||||
)
|
||||
.map_err(|e| e.to_string())?
|
||||
.check()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
connection.flush().map_err(|e| e.to_string())?;
|
||||
status(format!(
|
||||
"Clip: Ctrl+Shift+F{clip} · Bookmark: Ctrl+Shift+F{bookmark}"
|
||||
));
|
||||
let mut held = [false; 2];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match connection.poll_for_event().map_err(|e| e.to_string())? {
|
||||
Some(Event::KeyPress(key)) => {
|
||||
if let Some(index) = codes.iter().position(|code| *code == key.detail)
|
||||
&& !held[index]
|
||||
{
|
||||
held[index] = true;
|
||||
action(
|
||||
if index == 0 {
|
||||
Action::Clip
|
||||
} else {
|
||||
Action::Bookmark
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Event::KeyRelease(key)) => {
|
||||
if let Some(index) = codes.iter().position(|code| *code == key.detail) {
|
||||
held[index] = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn platform(
|
||||
_: u8,
|
||||
_: u8,
|
||||
_: impl Fn(Action, Instant) + Send + Sync + 'static,
|
||||
_: &(impl Fn(String) + Send + Sync + 'static),
|
||||
_: Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
Err("Unsupported OS".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn wayland(
|
||||
clip: u8,
|
||||
bookmark: u8,
|
||||
action: impl Fn(Action, Instant),
|
||||
status: &impl Fn(String),
|
||||
stop: &AtomicBool,
|
||||
) -> Result<(), String> {
|
||||
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
|
||||
use futures_util::StreamExt;
|
||||
let portal = GlobalShortcuts::new().await.map_err(|e| e.to_string())?;
|
||||
let session = portal.create_session().await.map_err(|e| e.to_string())?;
|
||||
let mut activated = portal
|
||||
.receive_activated()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// ashpd exposes the session handle through Serialize, not a public accessor.
|
||||
let session_handle = serde_json::to_value(&session).map_err(|e| e.to_string())?;
|
||||
let clip_trigger = format!("CTRL+SHIFT+F{clip}");
|
||||
let bookmark_trigger = format!("CTRL+SHIFT+F{bookmark}");
|
||||
let binding = async {
|
||||
portal
|
||||
.bind_shortcuts(
|
||||
&session,
|
||||
&[
|
||||
NewShortcut::new("clip", "Save recent Clipbin footage")
|
||||
.preferred_trigger(Some(clip_trigger.as_str())),
|
||||
NewShortcut::new("bookmark", "Protect a Clipbin bookmark")
|
||||
.preferred_trigger(Some(bookmark_trigger.as_str())),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.response()
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
let Some(bindings) = until_stopped(binding, stop).await else {
|
||||
return session.close().await.map_err(|e| e.to_string());
|
||||
};
|
||||
let bindings = match bindings {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = session.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
status(
|
||||
bindings
|
||||
.shortcuts()
|
||||
.iter()
|
||||
.map(|b| format!("{}: {}", b.id(), b.trigger_description()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
);
|
||||
while let Some(Some(signal)) = until_stopped(activated.next(), stop).await {
|
||||
if session_handle.as_str() != Some(signal.session_handle().as_str()) {
|
||||
continue;
|
||||
}
|
||||
let kind = match signal.shortcut_id() {
|
||||
"clip" => Action::Clip,
|
||||
"bookmark" => Action::Bookmark,
|
||||
_ => continue,
|
||||
};
|
||||
action(kind, Instant::now());
|
||||
}
|
||||
session.close().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn until_stopped<F: std::future::Future>(future: F, stop: &AtomicBool) -> Option<F::Output> {
|
||||
futures_lite::future::race(async { Some(future.await) }, async {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
async_io::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
None
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! RegisterHotKey and its thread-local message queue are confined to this bridge.
|
||||
#![allow(unsafe_code)]
|
||||
use super::*;
|
||||
use windows_sys::Win32::UI::{
|
||||
Input::KeyboardAndMouse::{
|
||||
MOD_CONTROL, MOD_NOREPEAT, MOD_SHIFT, RegisterHotKey, UnregisterHotKey, VK_F1,
|
||||
},
|
||||
WindowsAndMessaging::{MSG, PM_REMOVE, PeekMessageW, WM_HOTKEY},
|
||||
};
|
||||
|
||||
pub(super) fn platform(
|
||||
clip: u8,
|
||||
bookmark: u8,
|
||||
action: impl Fn(Action, Instant) + Send + Sync + 'static,
|
||||
status: &(impl Fn(String) + Send + Sync + 'static),
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
// No window handle: registration and message consumption belong to this thread.
|
||||
unsafe {
|
||||
for (id, key) in [(1, clip), (2, bookmark)] {
|
||||
if RegisterHotKey(
|
||||
std::ptr::null_mut(),
|
||||
id,
|
||||
MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT,
|
||||
u32::from(VK_F1) + u32::from(key) - 1,
|
||||
) == 0
|
||||
{
|
||||
let error = std::io::Error::last_os_error();
|
||||
UnregisterHotKey(std::ptr::null_mut(), 1);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
status(format!(
|
||||
"Clip: Ctrl+Shift+F{clip} · Bookmark: Ctrl+Shift+F{bookmark}"
|
||||
));
|
||||
let mut message: MSG = std::mem::zeroed();
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
|
||||
if message.message == WM_HOTKEY {
|
||||
match message.wParam {
|
||||
1 => action(Action::Clip, Instant::now()),
|
||||
2 => action(Action::Bookmark, Instant::now()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
UnregisterHotKey(std::ptr::null_mut(), 1);
|
||||
UnregisterHotKey(std::ptr::null_mut(), 2);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,27 @@
|
||||
//! Recorder settings and state shared with the desktop interface.
|
||||
|
||||
pub mod library;
|
||||
mod settings;
|
||||
mod status;
|
||||
|
||||
pub use library::Library;
|
||||
pub use settings::{Settings, VideoCodec};
|
||||
pub use status::RecordingStatus;
|
||||
|
||||
pub mod games;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_process;
|
||||
|
||||
pub mod segments;
|
||||
|
||||
pub mod cleanup;
|
||||
pub mod clips;
|
||||
|
||||
pub mod hotkeys;
|
||||
|
||||
pub mod autostart;
|
||||
pub mod background;
|
||||
#[cfg(windows)]
|
||||
mod windows_background;
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Recorded sessions, bookmarks and exported clips as the interface shows them.
|
||||
//!
|
||||
//! Loaded from the session store, or explicitly filled with `Library::sample()`
|
||||
//! for interface work.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Library {
|
||||
pub sessions: Vec<Session>,
|
||||
pub clips: Vec<Clip>,
|
||||
pub storage: StorageUsage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: u64,
|
||||
pub game: String,
|
||||
pub started: SystemTime,
|
||||
pub length: Duration,
|
||||
/// The session is still recording.
|
||||
pub active: bool,
|
||||
/// The session timeline: where footage exists and where it does not.
|
||||
pub spans: Vec<Span>,
|
||||
pub bookmarks: Vec<Bookmark>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Span {
|
||||
pub start: Duration,
|
||||
pub end: Duration,
|
||||
pub kind: SpanKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SpanKind {
|
||||
Footage,
|
||||
/// Capture was unavailable; nothing was recorded (SPEC: recovery must not
|
||||
/// imply the interrupted period was captured).
|
||||
Missing,
|
||||
/// Removed by storage cleanup.
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Bookmark {
|
||||
pub at: Duration,
|
||||
pub before: Duration,
|
||||
pub after: Duration,
|
||||
pub protected: bool,
|
||||
}
|
||||
|
||||
impl Bookmark {
|
||||
/// The protected range, clamped to the session.
|
||||
pub fn range(&self, session_length: Duration) -> (Duration, Duration) {
|
||||
(
|
||||
self.at.saturating_sub(self.before),
|
||||
(self.at + self.after).min(session_length),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Clip {
|
||||
pub id: u64,
|
||||
pub game: String,
|
||||
pub created: SystemTime,
|
||||
pub length: Duration,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// SPEC: the budget counts only unprotected session footage; protected
|
||||
/// bookmarks and exported clips are shown separately.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StorageUsage {
|
||||
pub budget_bytes: u64,
|
||||
pub unprotected_bytes: u64,
|
||||
pub protected_bytes: u64,
|
||||
pub clip_bytes: u64,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn protected_bookmarks(&self) -> usize {
|
||||
self.bookmarks
|
||||
.iter()
|
||||
.filter(|bookmark| bookmark.protected)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Time with no footage because capture was unavailable.
|
||||
pub fn missing_time(&self) -> Duration {
|
||||
self.time_in(SpanKind::Missing)
|
||||
}
|
||||
|
||||
/// Footage removed by storage cleanup.
|
||||
pub fn removed_time(&self) -> Duration {
|
||||
self.time_in(SpanKind::Removed)
|
||||
}
|
||||
|
||||
fn time_in(&self, kind: SpanKind) -> Duration {
|
||||
self.spans
|
||||
.iter()
|
||||
.filter(|span| span.kind == kind)
|
||||
.map(|span| span.end.saturating_sub(span.start))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip exports cut on keyframes: the requested range grows outward to the
|
||||
/// nearest keyframes so none of the selection is lost.
|
||||
pub fn export_range(
|
||||
start: Duration,
|
||||
end: Duration,
|
||||
keyframe_interval: Duration,
|
||||
session_length: Duration,
|
||||
) -> (Duration, Duration) {
|
||||
let interval = keyframe_interval.as_nanos().max(1);
|
||||
let duration = |nanos: u128| {
|
||||
Duration::new(
|
||||
(nanos / 1_000_000_000) as u64,
|
||||
(nanos % 1_000_000_000) as u32,
|
||||
)
|
||||
};
|
||||
let floor = |time: Duration| duration(time.as_nanos() / interval * interval);
|
||||
let ceil = |time: Duration| duration(time.as_nanos().div_ceil(interval) * interval);
|
||||
(
|
||||
floor(start).min(session_length),
|
||||
ceil(end).min(session_length),
|
||||
)
|
||||
}
|
||||
|
||||
/// `1:02:03` or `2:03`.
|
||||
pub fn format_clock(time: Duration) -> String {
|
||||
let seconds = time.as_secs();
|
||||
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
|
||||
if hours > 0 {
|
||||
format!("{hours}:{minutes:02}:{seconds:02}")
|
||||
} else {
|
||||
format!("{minutes}:{seconds:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a duration typed as `m:ss`, `h:mm:ss`, or plain seconds.
|
||||
pub fn parse_duration(text: &str) -> Option<Duration> {
|
||||
let parts: Vec<u64> = text
|
||||
.trim()
|
||||
.split(':')
|
||||
.map(|part| part.parse().ok())
|
||||
.collect::<Option<_>>()?;
|
||||
let (seconds, rest) = parts.split_last()?;
|
||||
if rest.len() > 2
|
||||
|| (!rest.is_empty() && *seconds >= 60)
|
||||
|| rest.iter().skip(1).any(|minutes| *minutes >= 60)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let seconds = rest.iter().fold(0, |total, part| total * 60 + part) * 60 + seconds;
|
||||
Some(Duration::from_secs(seconds))
|
||||
}
|
||||
|
||||
/// `1h 12m`, `12m`, `1m 34s`, or `45s`: seconds only for clip-sized lengths.
|
||||
pub fn format_length(time: Duration) -> String {
|
||||
let seconds = time.as_secs();
|
||||
match (seconds / 3600, seconds / 60 % 60, seconds % 60) {
|
||||
(0, 0, seconds) => format!("{seconds}s"),
|
||||
(0, minutes @ 1..10, seconds @ 1..) => format!("{minutes}m {seconds:02}s"),
|
||||
(0, minutes, _) => format!("{minutes}m"),
|
||||
(hours, minutes, _) => format!("{hours}h {minutes:02}m"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sizes in decimal units, as storage is sold: `12.4 GB`.
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = "B";
|
||||
for next in UNITS {
|
||||
if value < 1000.0 {
|
||||
break;
|
||||
}
|
||||
value /= 1000.0;
|
||||
unit = next;
|
||||
}
|
||||
if unit == "B" {
|
||||
format!("{bytes} B")
|
||||
} else {
|
||||
format!("{value:.1} {unit}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `just now`, `12 minutes ago`, `3 hours ago`, `2 days ago`.
|
||||
pub fn format_ago(then: SystemTime, now: SystemTime) -> String {
|
||||
let seconds = now.duration_since(then).unwrap_or_default().as_secs();
|
||||
let plural =
|
||||
|count: u64, unit: &str| format!("{count} {unit}{} ago", if count == 1 { "" } else { "s" });
|
||||
match seconds {
|
||||
0..60 => "just now".into(),
|
||||
60..3600 => plural(seconds / 60, "minute"),
|
||||
3600..86_400 => plural(seconds / 3600, "hour"),
|
||||
_ => plural(seconds / 86_400, "day"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn empty(budget_bytes: u64) -> Self {
|
||||
Self {
|
||||
sessions: Vec::new(),
|
||||
clips: Vec::new(),
|
||||
storage: StorageUsage {
|
||||
budget_bytes,
|
||||
unprotected_bytes: 0,
|
||||
protected_bytes: 0,
|
||||
clip_bytes: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self, id: u64) -> Option<&Session> {
|
||||
self.sessions.iter().find(|session| session.id == id)
|
||||
}
|
||||
|
||||
/// Example data for working on the interface before recordings are
|
||||
/// persisted. Never shown unless explicitly requested.
|
||||
pub fn sample(now: SystemTime) -> Self {
|
||||
let minutes = |m: u64| Duration::from_secs(m * 60);
|
||||
let seconds = Duration::from_secs;
|
||||
let ago = |m: u64| now - minutes(m);
|
||||
let footage = |start: Duration, end: Duration| Span {
|
||||
start,
|
||||
end,
|
||||
kind: SpanKind::Footage,
|
||||
};
|
||||
let bookmark = |at: Duration, protected| Bookmark {
|
||||
at,
|
||||
before: seconds(120),
|
||||
after: seconds(30),
|
||||
protected,
|
||||
};
|
||||
let session = |id, game: &str, started, length, active, spans, bookmarks| Session {
|
||||
id,
|
||||
game: game.into(),
|
||||
started,
|
||||
length,
|
||||
active,
|
||||
spans,
|
||||
bookmarks,
|
||||
};
|
||||
Self {
|
||||
sessions: vec![
|
||||
session(
|
||||
1,
|
||||
"Counter-Strike 2",
|
||||
ago(38),
|
||||
minutes(38),
|
||||
true,
|
||||
vec![footage(Duration::ZERO, minutes(38))],
|
||||
vec![bookmark(minutes(9), true), bookmark(minutes(31), true)],
|
||||
),
|
||||
session(
|
||||
2,
|
||||
"Deep Rock Galactic",
|
||||
ago(26 * 60),
|
||||
minutes(72),
|
||||
false,
|
||||
vec![
|
||||
footage(Duration::ZERO, minutes(41)),
|
||||
Span {
|
||||
start: minutes(41),
|
||||
end: minutes(43),
|
||||
kind: SpanKind::Missing,
|
||||
},
|
||||
footage(minutes(43), minutes(72)),
|
||||
],
|
||||
vec![
|
||||
bookmark(minutes(12), true),
|
||||
bookmark(minutes(40), true),
|
||||
bookmark(minutes(66), false),
|
||||
],
|
||||
),
|
||||
session(
|
||||
3,
|
||||
"Hades II",
|
||||
ago(2 * 24 * 60),
|
||||
minutes(124),
|
||||
false,
|
||||
vec![
|
||||
Span {
|
||||
start: Duration::ZERO,
|
||||
end: minutes(50),
|
||||
kind: SpanKind::Removed,
|
||||
},
|
||||
footage(minutes(50), minutes(124)),
|
||||
],
|
||||
vec![bookmark(minutes(98), true)],
|
||||
),
|
||||
session(
|
||||
4,
|
||||
"Rocket League",
|
||||
ago(4 * 24 * 60),
|
||||
minutes(47),
|
||||
false,
|
||||
vec![footage(Duration::ZERO, minutes(47))],
|
||||
Vec::new(),
|
||||
),
|
||||
session(
|
||||
5,
|
||||
"Elden Ring Nightreign",
|
||||
ago(6 * 24 * 60),
|
||||
minutes(96),
|
||||
false,
|
||||
vec![footage(Duration::ZERO, minutes(96))],
|
||||
vec![bookmark(minutes(55), true), bookmark(minutes(88), true)],
|
||||
),
|
||||
],
|
||||
clips: vec![
|
||||
Clip {
|
||||
id: 1,
|
||||
game: "Counter-Strike 2".into(),
|
||||
created: ago(12),
|
||||
length: seconds(120),
|
||||
size_bytes: 412_000_000,
|
||||
},
|
||||
Clip {
|
||||
id: 2,
|
||||
game: "Deep Rock Galactic".into(),
|
||||
created: ago(25 * 60),
|
||||
length: seconds(94),
|
||||
size_bytes: 318_000_000,
|
||||
},
|
||||
Clip {
|
||||
id: 3,
|
||||
game: "Hades II".into(),
|
||||
created: ago(2 * 24 * 60),
|
||||
length: seconds(38),
|
||||
size_bytes: 121_000_000,
|
||||
},
|
||||
],
|
||||
storage: StorageUsage {
|
||||
budget_bytes: 50_000_000_000,
|
||||
unprotected_bytes: 31_600_000_000,
|
||||
protected_bytes: 4_200_000_000,
|
||||
clip_bytes: 851_000_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() {
|
||||
let ms = Duration::from_millis;
|
||||
let second = Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
export_range(
|
||||
Duration::from_nanos(1_000_000_001),
|
||||
Duration::from_nanos(2_000_000_001),
|
||||
second,
|
||||
ms(60_000)
|
||||
),
|
||||
(second, Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(
|
||||
export_range(ms(10_400), ms(20_100), second, ms(60_000)),
|
||||
(ms(10_000), ms(21_000))
|
||||
);
|
||||
assert_eq!(
|
||||
export_range(ms(10_000), ms(20_000), second, ms(60_000)),
|
||||
(ms(10_000), ms(20_000))
|
||||
);
|
||||
assert_eq!(
|
||||
export_range(ms(55_000), ms(59_500), second, ms(59_500)),
|
||||
(ms(55_000), ms(59_500))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_protection_is_clamped_to_the_session() {
|
||||
let bookmark = Bookmark {
|
||||
at: Duration::from_secs(60),
|
||||
before: Duration::from_secs(120),
|
||||
after: Duration::from_secs(30),
|
||||
protected: true,
|
||||
};
|
||||
assert_eq!(
|
||||
bookmark.range(Duration::from_secs(80)),
|
||||
(Duration::ZERO, Duration::from_secs(80))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_are_typed_as_clock_times_or_seconds() {
|
||||
let seconds = Duration::from_secs;
|
||||
assert_eq!(parse_duration("2:00"), Some(seconds(120)));
|
||||
assert_eq!(parse_duration(" 90 "), Some(seconds(90)));
|
||||
assert_eq!(parse_duration("1:02:03"), Some(seconds(3723)));
|
||||
assert_eq!(parse_duration("0:30"), Some(seconds(30)));
|
||||
for invalid in ["", "1:75", "1:60:00", "a", "1::2", "1:2:3:4", "-5"] {
|
||||
assert_eq!(parse_duration(invalid), None, "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
assert_eq!(format_clock(Duration::from_secs(3723)), "1:02:03");
|
||||
assert_eq!(format_clock(Duration::from_secs(123)), "2:03");
|
||||
assert_eq!(format_length(Duration::from_secs(4320)), "1h 12m");
|
||||
assert_eq!(format_length(Duration::from_secs(45)), "45s");
|
||||
assert_eq!(format_length(Duration::from_secs(94)), "1m 34s");
|
||||
assert_eq!(format_length(Duration::from_secs(120)), "2m");
|
||||
assert_eq!(format_bytes(31_600_000_000), "31.6 GB");
|
||||
assert_eq!(format_bytes(999), "999 B");
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
assert_eq!(
|
||||
format_ago(now - Duration::from_secs(60), now),
|
||||
"1 minute ago"
|
||||
);
|
||||
assert_eq!(
|
||||
format_ago(now - Duration::from_secs(3 * 86_400), now),
|
||||
"3 days ago"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_removed_time_are_counted_separately() {
|
||||
let library = Library::sample(SystemTime::now());
|
||||
assert_eq!(
|
||||
library.session(2).unwrap().missing_time(),
|
||||
Duration::from_secs(120)
|
||||
);
|
||||
assert_eq!(
|
||||
library.session(3).unwrap().removed_time(),
|
||||
Duration::from_secs(50 * 60)
|
||||
);
|
||||
assert_eq!(library.session(3).unwrap().missing_time(), Duration::ZERO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A split racing with stop can leave only a Matroska header, with no footage.
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// The pinned muxer writes its first Cluster immediately after a small header.
|
||||
/// Inspect only that prefix, so checking a completed segment never reads its footage.
|
||||
pub fn has_cluster(path: &Path) -> io::Result<bool> {
|
||||
cluster_in(File::open(path)?)
|
||||
}
|
||||
fn cluster_in(reader: impl Read) -> io::Result<bool> {
|
||||
let mut prefix = Vec::new();
|
||||
reader.take(64 * 1024).read_to_end(&mut prefix)?;
|
||||
Ok(prefix.windows(4).any(|id| id == [0x1f, 0x43, 0xb6, 0x75]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn distinguishes_a_header_only_tail_from_media() {
|
||||
let header = [0x1a, 0x45, 0xdf, 0xa3, 0x80, 0x18, 0x53, 0x80, 0x67, 0xff];
|
||||
assert!(!cluster_in(header.as_slice()).unwrap());
|
||||
let mut media = header.to_vec();
|
||||
media.extend([0x1f, 0x43, 0xb6, 0x75, 0x84, 0xe7, 0x82, 0, 0]);
|
||||
assert!(cluster_in(media.as_slice()).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VideoCodec {
|
||||
#[default]
|
||||
H264,
|
||||
@@ -18,17 +19,23 @@ impl fmt::Display for VideoCodec {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Persisted recorder preferences from SPEC.md.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// A fixed recording size. `None` records at the captured window's or
|
||||
/// monitor's own size, following its orientation.
|
||||
pub portal_restore_token: Option<String>,
|
||||
pub resolution: Option<(u32, u32)>,
|
||||
pub frames_per_second: u32,
|
||||
pub codec: VideoCodec,
|
||||
pub system_audio_enabled: bool,
|
||||
pub microphone_enabled: bool,
|
||||
pub start_on_login: bool,
|
||||
pub clip_duration: Duration,
|
||||
pub export_separate_audio: bool,
|
||||
pub clip_hotkey: u8,
|
||||
pub bookmark_hotkey: u8,
|
||||
pub bookmark_before: Duration,
|
||||
pub bookmark_after: Duration,
|
||||
/// Protected bookmarks and exported clips are excluded from this limit.
|
||||
@@ -38,14 +45,17 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
portal_restore_token: None,
|
||||
resolution: None,
|
||||
frames_per_second: 60,
|
||||
codec: VideoCodec::H264,
|
||||
system_audio_enabled: true,
|
||||
microphone_enabled: false,
|
||||
start_on_login: true,
|
||||
clip_duration: Duration::from_secs(120),
|
||||
export_separate_audio: false,
|
||||
clip_hotkey: 9,
|
||||
bookmark_hotkey: 10,
|
||||
bookmark_before: Duration::from_secs(120),
|
||||
bookmark_after: Duration::from_secs(30),
|
||||
unprotected_session_limit_bytes: 50_000_000_000,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// The recorder will own these transitions once capture is implemented.
|
||||
/// The recorder's externally visible health.
|
||||
///
|
||||
/// A game being detected or permission being granted is insufficient to enter
|
||||
/// Recording: a usable capture source and successful output must be confirmed.
|
||||
@@ -9,6 +9,10 @@ pub enum RecordingStatus {
|
||||
WaitingForCapture,
|
||||
Starting,
|
||||
Recording,
|
||||
/// Video continues while a configured audio source is no longer delivering.
|
||||
Degraded {
|
||||
reason: String,
|
||||
},
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
@@ -21,6 +25,7 @@ impl RecordingStatus {
|
||||
Self::WaitingForCapture => "Waiting for capture",
|
||||
Self::Starting => "Starting",
|
||||
Self::Recording => "Recording",
|
||||
Self::Degraded { .. } => "Recording degraded",
|
||||
Self::Failed { .. } => "Recording failed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
//! Atomic session metadata; segment paths are relative to their session directory.
|
||||
use crate::{
|
||||
Library, Settings,
|
||||
library::{Session, Span, SpanKind},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub path: PathBuf,
|
||||
pub start: Duration,
|
||||
pub end: Duration,
|
||||
pub complete: bool,
|
||||
/// False for a header-only file left by a split racing with stop.
|
||||
#[serde(default = "available_default")]
|
||||
pub available: bool,
|
||||
#[serde(default)]
|
||||
pub frame_start: u64,
|
||||
#[serde(default = "default_fps")]
|
||||
pub frames_per_second: u32,
|
||||
/// Cumulative dropped frames in this run when the segment completed.
|
||||
#[serde(default)]
|
||||
pub dropped_frames: u64,
|
||||
/// Exact cumulative AAC samples before this file, independently per audio stream.
|
||||
#[serde(default)]
|
||||
pub audio_start_samples: Vec<u64>,
|
||||
}
|
||||
|
||||
fn available_default() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_fps() -> u32 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Metadata {
|
||||
pub session: Session,
|
||||
pub app_id: u32,
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
const METADATA_FILE: &str = "session.json";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
pub fn config_path() -> io::Result<PathBuf> {
|
||||
Ok(dirs::config_dir()
|
||||
.ok_or_else(|| io::Error::other("No configuration directory"))?
|
||||
.join("clipbin/settings.json"))
|
||||
}
|
||||
|
||||
pub fn load_settings() -> io::Result<Settings> {
|
||||
match fs::read(config_path()?) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Settings::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
pub fn save_settings(settings: &Settings) -> io::Result<()> {
|
||||
atomic_json(&config_path()?, settings)
|
||||
}
|
||||
|
||||
/// Same-directory replacement keeps readers and crash recovery away from partial JSON.
|
||||
pub fn atomic_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
|
||||
fs::create_dir_all(
|
||||
path.parent()
|
||||
.ok_or_else(|| io::Error::other("No parent directory"))?,
|
||||
)?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
|
||||
serde_json::to_writer_pretty(temporary.as_file_mut(), value).map_err(io::Error::other)?;
|
||||
temporary.as_file().sync_all()?;
|
||||
temporary.persist(path).map_err(|e| e.error)?;
|
||||
#[cfg(unix)]
|
||||
fs::File::open(path.parent().unwrap())?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Frame offsets never depend on container timestamps (each segment starts at zero).
|
||||
pub fn frame_offset(frames: u64, fps: u32) -> Duration {
|
||||
let fps = u64::from(fps.max(1));
|
||||
Duration::new(frames / fps, ((frames % fps) * 1_000_000_000 / fps) as u32)
|
||||
}
|
||||
|
||||
/// The keyframe triggering `file_changed` is counted already, but is the new
|
||||
/// segment's first frame. Zero identifies the initial `OutputStarted` segment.
|
||||
pub fn segment_offset(total_frames: u64, fps: u32) -> Duration {
|
||||
frame_offset(total_frames.saturating_sub(1), fps)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn discover() -> io::Result<Self> {
|
||||
let root = dirs::data_local_dir()
|
||||
.ok_or_else(|| io::Error::other("No data directory"))?
|
||||
.join(if cfg!(windows) { "Clipbin" } else { "clipbin" });
|
||||
Ok(Self {
|
||||
root: root.join("sessions"),
|
||||
})
|
||||
}
|
||||
pub fn directory(&self, id: u64) -> PathBuf {
|
||||
self.root.join(id.to_string())
|
||||
}
|
||||
pub fn save(&self, metadata: &Metadata) -> io::Result<()> {
|
||||
atomic_json(
|
||||
&self.directory(metadata.session.id).join(METADATA_FILE),
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
pub fn create(&self, app_id: u32, game: String) -> io::Result<Metadata> {
|
||||
let started = SystemTime::now();
|
||||
let mut id = started
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
while self.directory(id).exists() {
|
||||
id += 1;
|
||||
}
|
||||
let metadata = Metadata {
|
||||
session: Session {
|
||||
id,
|
||||
game,
|
||||
started,
|
||||
length: Duration::ZERO,
|
||||
active: true,
|
||||
spans: vec![],
|
||||
bookmarks: vec![],
|
||||
},
|
||||
app_id,
|
||||
segments: vec![],
|
||||
};
|
||||
self.save(&metadata)?;
|
||||
Ok(metadata)
|
||||
}
|
||||
pub fn read(&self) -> io::Result<Vec<Metadata>> {
|
||||
let entries = match fs::read_dir(&self.root) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut sessions = vec![];
|
||||
for entry in entries {
|
||||
let path = entry?.path().join(METADATA_FILE);
|
||||
if path.exists() {
|
||||
let bytes = fs::read(&path)?;
|
||||
sessions.push(
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|e| io::Error::other(format!("{}: {e}", path.display())))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
/// Active metadata cannot belong to a live recorder during initial startup.
|
||||
pub fn recover(&self) -> io::Result<()> {
|
||||
for mut metadata in self.read()? {
|
||||
if metadata.session.active {
|
||||
metadata.session.active = false;
|
||||
for segment in &mut metadata.segments {
|
||||
segment.complete = true;
|
||||
}
|
||||
self.save(&metadata)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// One session's metadata, with segment paths resolved against its directory.
|
||||
pub fn metadata(&self, id: u64) -> io::Result<Metadata> {
|
||||
let directory = self.directory(id);
|
||||
let mut metadata: Metadata =
|
||||
serde_json::from_slice(&fs::read(directory.join(METADATA_FILE))?)
|
||||
.map_err(io::Error::other)?;
|
||||
for segment in &mut metadata.segments {
|
||||
segment.path = directory.join(&segment.path);
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
pub fn library(&self, budget: u64) -> io::Result<Library> {
|
||||
self.library_with_protection(budget, &[])
|
||||
}
|
||||
pub fn library_with_protection(
|
||||
&self,
|
||||
budget: u64,
|
||||
ranges: &[(u64, Duration, Duration)],
|
||||
) -> io::Result<Library> {
|
||||
let mut library = Library::empty(budget);
|
||||
for metadata in self.read()? {
|
||||
let leased: Vec<_> = ranges
|
||||
.iter()
|
||||
.filter(|(id, _, _)| *id == metadata.session.id)
|
||||
.map(|(_, start, end)| (*start, *end))
|
||||
.collect();
|
||||
for segment in &metadata.segments {
|
||||
let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path))
|
||||
.map_or(0, |f| f.len());
|
||||
if crate::cleanup::protected(segment, &metadata.session.bookmarks, &leased) {
|
||||
library.storage.protected_bytes += bytes;
|
||||
} else {
|
||||
library.storage.unprotected_bytes += bytes;
|
||||
}
|
||||
}
|
||||
library.sessions.push(metadata.session);
|
||||
}
|
||||
for saved in self.clips()? {
|
||||
library.storage.clip_bytes += saved.clip.size_bytes;
|
||||
library.clips.push(saved.clip);
|
||||
}
|
||||
library
|
||||
.sessions
|
||||
.sort_by_key(|s| std::cmp::Reverse(s.started));
|
||||
Ok(library)
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in a segment file: the file and the offset into it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Located<'a> {
|
||||
pub index: usize,
|
||||
pub segment: &'a Segment,
|
||||
/// Time within the file. Each file's timestamps start at zero.
|
||||
pub offset: Duration,
|
||||
}
|
||||
|
||||
/// The part of one segment that falls inside a requested range.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Piece<'a> {
|
||||
pub index: usize,
|
||||
pub segment: &'a Segment,
|
||||
/// Range within the file, from its own zero.
|
||||
pub from: Duration,
|
||||
pub to: Duration,
|
||||
/// Where `from` sits on the session timeline.
|
||||
pub session_start: Duration,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
/// Segments that hold footage, in session order.
|
||||
fn playable(&self) -> impl Iterator<Item = (usize, &Segment)> {
|
||||
self.segments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, segment)| segment.available && segment.end > segment.start)
|
||||
}
|
||||
|
||||
/// Finds the footage at session time `time`. `None` inside gaps (missing or
|
||||
/// cleaned-up time) and beyond the last footage.
|
||||
pub fn locate(&self, time: Duration) -> Option<Located<'_>> {
|
||||
self.playable()
|
||||
.find(|(_, segment)| segment.start <= time && time < segment.end)
|
||||
.map(|(index, segment)| Located {
|
||||
index,
|
||||
segment,
|
||||
offset: time - segment.start,
|
||||
})
|
||||
}
|
||||
|
||||
/// The footage between `start` and `end`, as pieces of segment files in
|
||||
/// order. Gaps simply produce no pieces.
|
||||
pub fn covering(&self, start: Duration, end: Duration) -> Vec<Piece<'_>> {
|
||||
self.playable()
|
||||
.filter(|(_, segment)| segment.start < end && start < segment.end)
|
||||
.map(|(index, segment)| {
|
||||
let session_start = start.max(segment.start);
|
||||
Piece {
|
||||
index,
|
||||
segment,
|
||||
from: session_start - segment.start,
|
||||
to: end.min(segment.end) - segment.start,
|
||||
session_start,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keyframes are anchored to each capture run, which may start after a gap.
|
||||
pub fn export_range(&self, start: Duration, end: Duration) -> (Duration, Duration) {
|
||||
let anchor = |time| {
|
||||
self.segments
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.available && s.start <= time)
|
||||
.map_or(time, |segment| {
|
||||
segment.start.saturating_sub(frame_offset(
|
||||
segment.frame_start,
|
||||
segment.frames_per_second,
|
||||
))
|
||||
})
|
||||
};
|
||||
let a = anchor(start);
|
||||
let b = anchor(end.saturating_sub(Duration::from_nanos(1)));
|
||||
let (from, _) = crate::library::export_range(
|
||||
start.saturating_sub(a),
|
||||
start.saturating_sub(a),
|
||||
Duration::from_secs(1),
|
||||
self.session.length.max(end + Duration::from_secs(1)),
|
||||
);
|
||||
let (_, to) = crate::library::export_range(
|
||||
end.saturating_sub(b),
|
||||
end.saturating_sub(b),
|
||||
Duration::from_secs(1),
|
||||
self.session.length.max(end + Duration::from_secs(1)),
|
||||
);
|
||||
let start = (a + from).min(self.session.length);
|
||||
let end = if self.session.active {
|
||||
b + to
|
||||
} else {
|
||||
(b + to).min(self.session.length)
|
||||
};
|
||||
let pieces = self.covering(start, end);
|
||||
let start = pieces.first().map_or(start, |p| p.session_start);
|
||||
let end = if self.session.active {
|
||||
end
|
||||
} else {
|
||||
pieces.last().map_or(end, |p| p.segment.start + p.to)
|
||||
};
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// Split existing spans so deleted footage remains visible on the timeline.
|
||||
pub fn removed(&mut self, index: usize) {
|
||||
let segment = &mut self.segments[index];
|
||||
segment.available = false;
|
||||
let (start, end) = (segment.start, segment.end);
|
||||
let mut spans = Vec::new();
|
||||
for span in &self.session.spans {
|
||||
if span.kind != SpanKind::Footage || span.end <= start || span.start >= end {
|
||||
spans.push(*span);
|
||||
continue;
|
||||
}
|
||||
if span.start < start {
|
||||
spans.push(Span {
|
||||
end: start,
|
||||
..*span
|
||||
});
|
||||
}
|
||||
spans.push(Span {
|
||||
start: span.start.max(start),
|
||||
end: span.end.min(end),
|
||||
kind: SpanKind::Removed,
|
||||
});
|
||||
if span.end > end {
|
||||
spans.push(Span {
|
||||
start: end,
|
||||
..*span
|
||||
});
|
||||
}
|
||||
}
|
||||
self.session.spans = spans;
|
||||
}
|
||||
|
||||
pub fn update_run(
|
||||
&mut self,
|
||||
span_base: usize,
|
||||
footage_start: Option<Duration>,
|
||||
footage_end: Duration,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
self.session.spans.truncate(span_base);
|
||||
if let Some(start) = footage_start {
|
||||
let previous = self.session.spans.last().map_or(Duration::ZERO, |s| s.end);
|
||||
if start > previous {
|
||||
self.session.spans.push(Span {
|
||||
start: previous,
|
||||
end: start,
|
||||
kind: SpanKind::Missing,
|
||||
});
|
||||
}
|
||||
if footage_end > start {
|
||||
self.session.spans.push(Span {
|
||||
start,
|
||||
end: footage_end,
|
||||
kind: SpanKind::Footage,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let start = self.session.spans.last().map_or(Duration::ZERO, |s| s.end);
|
||||
if elapsed > start {
|
||||
self.session.spans.push(Span {
|
||||
start,
|
||||
end: elapsed,
|
||||
kind: SpanKind::Missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.session.length = elapsed.max(footage_end);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn segment(start: u64, end: u64, available: bool) -> Segment {
|
||||
Segment {
|
||||
path: format!("segment-{start}.mkv").into(),
|
||||
start: Duration::from_secs(start),
|
||||
end: Duration::from_secs(end),
|
||||
complete: true,
|
||||
available,
|
||||
frame_start: start * 60,
|
||||
frames_per_second: 60,
|
||||
dropped_frames: 0,
|
||||
audio_start_samples: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline(segments: Vec<Segment>) -> Metadata {
|
||||
Metadata {
|
||||
session: Session {
|
||||
id: 1,
|
||||
game: "Game".into(),
|
||||
started: UNIX_EPOCH,
|
||||
length: Duration::from_secs(10),
|
||||
active: false,
|
||||
spans: Vec::new(),
|
||||
bookmarks: Vec::new(),
|
||||
},
|
||||
app_id: 1,
|
||||
segments,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_metadata_defaults_audio_clocks_without_losing_bookmarks() {
|
||||
let m = timeline(vec![segment(0, 2, true)]);
|
||||
let mut json = serde_json::to_value(m).unwrap();
|
||||
json["segments"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("audio_start_samples");
|
||||
let restored: Metadata = serde_json::from_value(json).unwrap();
|
||||
assert!(restored.segments[0].audio_start_samples.is_empty());
|
||||
assert!(restored.segments[0].available);
|
||||
}
|
||||
#[test]
|
||||
fn export_keyframes_follow_run_anchor_and_outer_gaps_are_trimmed() {
|
||||
let mut metadata = timeline(vec![segment(2, 4, true), segment(4, 6, true)]);
|
||||
metadata.segments[0].frame_start = 0;
|
||||
metadata.segments[1].frame_start = 120;
|
||||
assert_eq!(
|
||||
metadata.export_range(Duration::from_millis(2400), Duration::from_millis(5100)),
|
||||
(Duration::from_secs(2), Duration::from_secs(6))
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.export_range(Duration::ZERO, Duration::from_secs(10)),
|
||||
(Duration::from_secs(2), Duration::from_secs(6))
|
||||
);
|
||||
metadata.session.active = true;
|
||||
assert_eq!(
|
||||
metadata
|
||||
.export_range(Duration::ZERO, Duration::from_secs(9))
|
||||
.1,
|
||||
Duration::from_secs(9)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn removal_splits_footage_preserving_missing_spans_and_is_idempotent() {
|
||||
let mut metadata = timeline(vec![segment(2, 4, true)]);
|
||||
metadata.session.spans = vec![
|
||||
Span {
|
||||
start: Duration::ZERO,
|
||||
end: Duration::from_secs(6),
|
||||
kind: SpanKind::Footage,
|
||||
},
|
||||
Span {
|
||||
start: Duration::from_secs(6),
|
||||
end: Duration::from_secs(8),
|
||||
kind: SpanKind::Missing,
|
||||
},
|
||||
];
|
||||
metadata.removed(0);
|
||||
assert_eq!(metadata.session.removed_time(), Duration::from_secs(2));
|
||||
assert_eq!(metadata.session.missing_time(), Duration::from_secs(2));
|
||||
assert!(!metadata.segments[0].available);
|
||||
let spans = metadata.session.spans.clone();
|
||||
metadata.removed(0);
|
||||
assert_eq!(metadata.session.spans, spans);
|
||||
assert!(metadata.locate(Duration::from_secs(3)).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn times_map_to_segment_offsets_and_gaps_have_no_footage() {
|
||||
// 0-2 s, 2-4 s, a gap, 6-8 s, and an unavailable tail at 8-10 s.
|
||||
let metadata = timeline(vec![
|
||||
segment(0, 2, true),
|
||||
segment(2, 4, true),
|
||||
segment(6, 8, true),
|
||||
segment(8, 10, false),
|
||||
]);
|
||||
let ms = Duration::from_millis;
|
||||
let at = metadata.locate(ms(2500)).unwrap();
|
||||
assert_eq!((at.index, at.offset), (1, ms(500)));
|
||||
assert_eq!(metadata.locate(ms(2000)).unwrap().index, 1);
|
||||
assert!(metadata.locate(ms(5000)).is_none());
|
||||
assert!(metadata.locate(ms(9000)).is_none());
|
||||
|
||||
let pieces = metadata.covering(ms(1500), ms(7000));
|
||||
let summary: Vec<_> = pieces
|
||||
.iter()
|
||||
.map(|piece| (piece.index, piece.from, piece.to, piece.session_start))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[
|
||||
(0, ms(1500), ms(2000), ms(1500)),
|
||||
(1, ms(0), ms(2000), ms(2000)),
|
||||
(2, ms(0), ms(1000), ms(6000)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn offsets_use_frames_without_accumulating_container_rounding() {
|
||||
assert_eq!(segment_offset(0, 60), Duration::ZERO);
|
||||
assert_eq!(segment_offset(121, 60), Duration::from_secs(2));
|
||||
assert_eq!(segment_offset(241, 60), Duration::from_secs(4));
|
||||
assert_eq!(segment_offset(361, 60), Duration::from_secs(6));
|
||||
assert_eq!(frame_offset(120, 60), Duration::from_secs(2));
|
||||
assert_eq!(frame_offset(121, 60), Duration::from_nanos(2_016_666_666));
|
||||
assert_eq!(frame_offset(216_000, 60), Duration::from_secs(3600));
|
||||
}
|
||||
#[test]
|
||||
fn recovery_preserves_footage_and_a_wall_clock_gap() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = Store {
|
||||
root: directory.path().into(),
|
||||
};
|
||||
let mut metadata = store.create(480, "Game".into()).unwrap();
|
||||
metadata.update_run(
|
||||
0,
|
||||
Some(Duration::from_secs(1)),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
let base = metadata.session.spans.len();
|
||||
metadata.update_run(base, None, Duration::ZERO, Duration::from_secs(8));
|
||||
assert_eq!(metadata.session.missing_time(), Duration::from_secs(4));
|
||||
metadata.update_run(
|
||||
base,
|
||||
Some(Duration::from_secs(10)),
|
||||
Duration::from_secs(12),
|
||||
Duration::from_secs(12),
|
||||
);
|
||||
assert_eq!(metadata.session.missing_time(), Duration::from_secs(6));
|
||||
assert_eq!(
|
||||
metadata.session.spans.last().unwrap().kind,
|
||||
SpanKind::Footage
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn atomic_writes_leave_readers_a_complete_document() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("settings.json");
|
||||
atomic_json(&path, &Settings::default()).unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
let reader = scope.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
serde_json::from_slice::<Settings>(&bytes).unwrap();
|
||||
}
|
||||
});
|
||||
for rate in [30, 60, 120] {
|
||||
atomic_json(
|
||||
&path,
|
||||
&Settings {
|
||||
frames_per_second: rate,
|
||||
..Settings::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
reader.join().unwrap();
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn round_trip_atomic_replace_and_crash_recovery() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"clipbin-store-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let store = Store { root };
|
||||
let mut metadata = store.create(480, "Test game".into()).unwrap();
|
||||
metadata.session.length = Duration::from_secs(7);
|
||||
metadata.segments.push(Segment {
|
||||
path: PathBuf::from("segment.mkv"),
|
||||
start: Duration::ZERO,
|
||||
end: Duration::from_secs(7),
|
||||
complete: false,
|
||||
available: true,
|
||||
frame_start: 0,
|
||||
frames_per_second: 60,
|
||||
dropped_frames: 0,
|
||||
audio_start_samples: Vec::new(),
|
||||
});
|
||||
fs::write(
|
||||
store.directory(metadata.session.id).join("segment.mkv"),
|
||||
[0u8; 13],
|
||||
)
|
||||
.unwrap();
|
||||
store.save(&metadata).unwrap();
|
||||
assert!(
|
||||
!store
|
||||
.directory(metadata.session.id)
|
||||
.join("session.json.tmp")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(store.read().unwrap()[0].session, metadata.session);
|
||||
fs::write(
|
||||
store.directory(metadata.session.id).join("interrupted.tmp"),
|
||||
b"{partial",
|
||||
)
|
||||
.unwrap();
|
||||
store.recover().unwrap();
|
||||
let library = store.library(100).unwrap();
|
||||
assert!(!library.sessions[0].active);
|
||||
assert_eq!(library.sessions[0].length, Duration::from_secs(7));
|
||||
assert_eq!(library.storage.unprotected_bytes, 13);
|
||||
assert!(store.read().unwrap()[0].segments[0].complete);
|
||||
fs::remove_dir_all(store.root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Win32 message pumps stay off GPUI's main thread.
|
||||
#![allow(unsafe_code)]
|
||||
use crate::background::{Action, State};
|
||||
use std::{
|
||||
ptr,
|
||||
sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, Sender},
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{
|
||||
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND, LPARAM, LRESULT, WPARAM,
|
||||
},
|
||||
System::{LibraryLoader::GetModuleHandleW, Threading::CreateMutexW},
|
||||
UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, FindWindowW, GetMessageW,
|
||||
PM_REMOVE, PeekMessageW, PostMessageW, PostQuitMessage, RegisterClassW, TranslateMessage,
|
||||
UnregisterClassW, WM_APP, WM_CLOSE, WM_DESTROY, WNDCLASSW,
|
||||
},
|
||||
};
|
||||
|
||||
fn wide(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().chain(Some(0)).collect()
|
||||
}
|
||||
static ACTIONS: OnceLock<Sender<Action>> = OnceLock::new();
|
||||
const OPEN: u32 = WM_APP + 1;
|
||||
unsafe extern "system" fn procedure(
|
||||
window: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if message == OPEN {
|
||||
if let Some(actions) = ACTIONS.get() {
|
||||
let _ = actions.send(Action::Open);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: this is a registered window procedure with Win32-provided arguments.
|
||||
unsafe {
|
||||
if message == WM_CLOSE {
|
||||
DestroyWindow(window);
|
||||
return 0;
|
||||
}
|
||||
if message == WM_DESTROY {
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
DefWindowProcW(window, message, wparam, lparam)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Instance {
|
||||
mutex: HANDLE,
|
||||
window: usize,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
impl Instance {
|
||||
pub fn acquire(actions: Sender<Action>) -> Result<Option<Self>, String> {
|
||||
let name = wide(&format!(
|
||||
"Local\\Clipbin-{}",
|
||||
std::env::var("USERNAME").unwrap_or_default()
|
||||
));
|
||||
let class = wide("ClipbinSingleInstance");
|
||||
// SAFETY: names are NUL-terminated, no security attributes or initial ownership.
|
||||
let mutex = unsafe { CreateMutexW(ptr::null(), 0, name.as_ptr()) };
|
||||
if mutex.is_null() {
|
||||
return Err(std::io::Error::last_os_error().to_string());
|
||||
}
|
||||
// SAFETY: GetLastError is read immediately after CreateMutexW.
|
||||
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
|
||||
// Startup can race the first instance's window registration.
|
||||
for _ in 0..100 {
|
||||
// SAFETY: class string is live; FindWindow and PostMessage accept borrowed handles.
|
||||
let window = unsafe { FindWindowW(class.as_ptr(), ptr::null()) };
|
||||
if !window.is_null() {
|
||||
// SAFETY: send a private message to the live endpoint window.
|
||||
let sent = unsafe { PostMessageW(window, OPEN, 0, 0) };
|
||||
let error = std::io::Error::last_os_error();
|
||||
// SAFETY: release this launch's mutex reference exactly once.
|
||||
unsafe {
|
||||
CloseHandle(mutex);
|
||||
}
|
||||
return if sent != 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(error.to_string())
|
||||
};
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// SAFETY: release our reference, leaving the first instance's mutex intact.
|
||||
unsafe {
|
||||
CloseHandle(mutex);
|
||||
}
|
||||
return Err("Running Clipbin did not expose its reopen endpoint".into());
|
||||
}
|
||||
let _ = ACTIONS.set(actions);
|
||||
let (ready, result) = mpsc::sync_channel(1);
|
||||
let worker = thread::spawn(move || {
|
||||
// SAFETY: window and message loop are owned by this thread; the class
|
||||
// and instance handle remain valid through DestroyWindow.
|
||||
unsafe {
|
||||
let instance = GetModuleHandleW(ptr::null());
|
||||
let definition = WNDCLASSW {
|
||||
lpfnWndProc: Some(procedure),
|
||||
hInstance: instance,
|
||||
lpszClassName: class.as_ptr(),
|
||||
..std::mem::zeroed()
|
||||
};
|
||||
RegisterClassW(&definition);
|
||||
let window = CreateWindowExW(
|
||||
0,
|
||||
class.as_ptr(),
|
||||
class.as_ptr(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
instance,
|
||||
ptr::null(),
|
||||
);
|
||||
let _ = ready.send(if window.is_null() {
|
||||
Err(std::io::Error::last_os_error().to_string())
|
||||
} else {
|
||||
Ok(window as usize)
|
||||
});
|
||||
if window.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut message = std::mem::zeroed();
|
||||
while GetMessageW(&mut message, ptr::null_mut(), 0, 0) > 0 {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
UnregisterClassW(class.as_ptr(), instance);
|
||||
}
|
||||
});
|
||||
match result.recv().map_err(|e| e.to_string())? {
|
||||
Ok(window) => Ok(Some(Self {
|
||||
mutex,
|
||||
window,
|
||||
worker: Some(worker),
|
||||
})),
|
||||
Err(error) => {
|
||||
// SAFETY: endpoint creation failed; release the acquired mutex.
|
||||
unsafe {
|
||||
CloseHandle(mutex);
|
||||
}
|
||||
let _ = worker.join();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the endpoint thread owns this HWND; posting close lets it clean up.
|
||||
unsafe {
|
||||
PostMessageW(self.window as HWND, WM_CLOSE, 0, 0);
|
||||
}
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
// SAFETY: release the mutex reference acquired above.
|
||||
unsafe {
|
||||
CloseHandle(self.mutex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Update(State),
|
||||
Stop,
|
||||
}
|
||||
pub struct Tray {
|
||||
commands: Sender<Command>,
|
||||
available: Arc<AtomicBool>,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
impl Tray {
|
||||
pub fn start(actions: Sender<Action>) -> Result<Self, String> {
|
||||
let (commands, receiver) = mpsc::channel();
|
||||
let (ready, result) = mpsc::sync_channel(1);
|
||||
let available = Arc::new(AtomicBool::new(true));
|
||||
let alive = available.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
use tray_icon::{
|
||||
Icon, TrayIconBuilder, TrayIconEvent,
|
||||
menu::{Menu, MenuEvent, MenuItem},
|
||||
};
|
||||
let run = || -> Result<(), String> {
|
||||
let menu = Menu::new();
|
||||
let entries: Vec<_> = [
|
||||
("Open Clipbin", Action::Open),
|
||||
("Save clip", Action::Clip),
|
||||
("Add bookmark", Action::Bookmark),
|
||||
("Quit", Action::Quit),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, action)| (MenuItem::new(label, true, None), action))
|
||||
.collect();
|
||||
for (item, _) in &entries {
|
||||
menu.append(item).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let icon = |state: &State| {
|
||||
let [r, g, b] = state.colour();
|
||||
Icon::from_rgba([r, g, b, 255].repeat(32 * 32), 32, 32)
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
let state = State {
|
||||
status: crate::RecordingStatus::Idle,
|
||||
game: None,
|
||||
};
|
||||
let tray = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu))
|
||||
.with_tooltip(state.tooltip())
|
||||
.with_icon(icon(&state)?)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
// SAFETY: the tray is created and dispatched on this thread.
|
||||
unsafe {
|
||||
let mut message = std::mem::zeroed();
|
||||
while PeekMessageW(&mut message, ptr::null_mut(), 0, 0, PM_REMOVE) != 0 {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
}
|
||||
while let Ok(event) = MenuEvent::receiver().try_recv() {
|
||||
if let Some((_, action)) =
|
||||
entries.iter().find(|(item, _)| item.id() == &event.id)
|
||||
{
|
||||
let _ = actions.send(*action);
|
||||
}
|
||||
}
|
||||
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
if matches!(event, TrayIconEvent::DoubleClick { .. }) {
|
||||
let _ = actions.send(Action::Open);
|
||||
}
|
||||
}
|
||||
match receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(Command::Update(state)) => {
|
||||
tray.set_tooltip(Some(state.tooltip()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
tray.set_icon(Some(icon(&state)?))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Command::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = run() {
|
||||
let _ = ready.send(Err(error.clone()));
|
||||
eprintln!("Windows tray unavailable: {error}");
|
||||
let _ = actions.send(Action::Open);
|
||||
}
|
||||
alive.store(false, Ordering::Relaxed);
|
||||
});
|
||||
result.recv().map_err(|e| e.to_string())??;
|
||||
Ok(Self {
|
||||
commands,
|
||||
available,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
pub fn available(&self) -> bool {
|
||||
self.available.load(Ordering::Relaxed)
|
||||
&& self
|
||||
.worker
|
||||
.as_ref()
|
||||
.is_some_and(|worker| !worker.is_finished())
|
||||
}
|
||||
pub fn update(&self, state: State) {
|
||||
let _ = self.commands.send(Command::Update(state));
|
||||
}
|
||||
}
|
||||
impl Drop for Tray {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commands.send(Command::Stop);
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Small Windows process bridge. The rest of the detection code stays safe Rust.
|
||||
#![allow(unsafe_code)]
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, Receiver},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT},
|
||||
System::{
|
||||
Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
TH32CS_SNAPPROCESS,
|
||||
},
|
||||
Threading::{
|
||||
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE,
|
||||
QueryFullProcessImageNameW, WaitForSingleObject,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// The numeric handle can be transferred to the waiting thread; Windows process
|
||||
// handles are valid across threads. Ownership still has exactly one Drop.
|
||||
struct Handle(usize);
|
||||
impl Handle {
|
||||
fn raw(&self) -> HANDLE {
|
||||
self.0 as HANDLE
|
||||
}
|
||||
fn process(pid: u32) -> io::Result<Self> {
|
||||
// SAFETY: valid access flags and PID; ownership of the result is taken here.
|
||||
let raw = unsafe {
|
||||
OpenProcess(
|
||||
PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
0,
|
||||
pid,
|
||||
)
|
||||
};
|
||||
if raw.is_null() {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(Self(raw as usize))
|
||||
}
|
||||
}
|
||||
fn path(&self) -> io::Result<String> {
|
||||
let mut buffer = vec![0u16; 32768];
|
||||
let mut length = buffer.len() as u32;
|
||||
// SAFETY: the handle is owned, and the writable buffer has the stated capacity.
|
||||
if unsafe { QueryFullProcessImageNameW(self.raw(), 0, buffer.as_mut_ptr(), &mut length) }
|
||||
== 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(String::from_utf16_lossy(&buffer[..length as usize]))
|
||||
}
|
||||
}
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: Handle owns a successful API result and closes it exactly once.
|
||||
unsafe {
|
||||
CloseHandle(self.raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn processes_in(directory: &Path) -> io::Result<Vec<(u32, String)>> {
|
||||
// SAFETY: flags request process enumeration; no caller pointers are involved.
|
||||
let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if raw == INVALID_HANDLE_VALUE {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let snapshot = Handle(raw as usize);
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let prefix = format!(
|
||||
"{}\\",
|
||||
directory.to_string_lossy().trim_end_matches(['\\', '/'])
|
||||
)
|
||||
.to_lowercase();
|
||||
let mut result = vec![];
|
||||
// SAFETY: snapshot remains live and entry is correctly sized and writable.
|
||||
let mut found = unsafe { Process32FirstW(snapshot.raw(), &mut entry) } != 0;
|
||||
while found {
|
||||
// Access-denied system processes and processes exiting during enumeration
|
||||
// are expected and cannot belong to the selected installation.
|
||||
if let Ok(process) = Handle::process(entry.th32ProcessID)
|
||||
&& let Ok(path) = process.path()
|
||||
&& path.to_lowercase().starts_with(&prefix)
|
||||
&& let Some(executable) = Path::new(&path).file_name().and_then(|s| s.to_str())
|
||||
{
|
||||
result.push((entry.th32ProcessID, executable.into()));
|
||||
}
|
||||
// SAFETY: as for Process32FirstW; snapshot and entry are still live.
|
||||
found = unsafe { Process32NextW(snapshot.raw(), &mut entry) } != 0;
|
||||
}
|
||||
result.sort_by_key(|(pid, _)| *pid);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub struct ExitWatch {
|
||||
events: Receiver<io::Result<()>>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl ExitWatch {
|
||||
pub fn open(pid: u32, executable: &str) -> io::Result<Self> {
|
||||
let handle = Handle::process(pid)?;
|
||||
if !Path::new(&handle.path()?)
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(executable))
|
||||
{
|
||||
return Err(io::Error::other("Game exited during discovery"));
|
||||
}
|
||||
let (sender, events) = mpsc::channel();
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let cancelled = cancel.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("clipbin-game-exit".into())
|
||||
.spawn(move || {
|
||||
while !cancelled.load(Ordering::Relaxed) {
|
||||
// SAFETY: the thread owns a live process handle with SYNCHRONIZE
|
||||
// access. A finite wait lets Drop cancel without leaking a waiter.
|
||||
let result = unsafe { WaitForSingleObject(handle.raw(), 250) };
|
||||
match result {
|
||||
WAIT_OBJECT_0 => {
|
||||
let _ = sender.send(Ok(()));
|
||||
break;
|
||||
}
|
||||
WAIT_TIMEOUT => {}
|
||||
_ => {
|
||||
let _ = sender.send(Err(io::Error::last_os_error()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
events,
|
||||
cancel,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
pub fn exited(&self) -> io::Result<bool> {
|
||||
match self.events.try_recv() {
|
||||
Ok(result) => result.map(|()| true),
|
||||
Err(mpsc::TryRecvError::Empty) => Ok(false),
|
||||
Err(mpsc::TryRecvError::Disconnected) => Ok(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for ExitWatch {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clipbin-core.workspace = true
|
||||
clipbin-media.workspace = true
|
||||
image = { version = "0.25.10", default-features = false }
|
||||
clipbin-obs.workspace = true
|
||||
gpui.workspace = true
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,39 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2023 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2025.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The MIT License (MIT) (for portions derived from Feather)
|
||||
|
||||
Copyright (c) 2013-2023 Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-app-window"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M10 4v4" />
|
||||
<path d="M2 8h20" />
|
||||
<path d="M6 4v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bookmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chevron-left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chevron-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-circle-dot"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-clapperboard"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" />
|
||||
<path d="m6.2 5.3 3.1 3.9" />
|
||||
<path d="m12.4 3.4 3.1 4" />
|
||||
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-clock"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 6v6l4 2" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-download"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 15V3" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
@@ -0,0 +1,22 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-film"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M7 3v18" />
|
||||
<path d="M3 7.5h4" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M3 16.5h4" />
|
||||
<path d="M17 3v18" />
|
||||
<path d="M17 7.5h4" />
|
||||
<path d="M17 16.5h4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-folder-open"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-gamepad-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="6" x2="10" y1="11" y2="11" />
|
||||
<line x1="8" x2="8" y1="9" y2="13" />
|
||||
<line x1="15" x2="15.01" y1="12" y2="12" />
|
||||
<line x1="18" x2="18.01" y1="10" y2="10" />
|
||||
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 761 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-hard-drive"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="22" x2="2" y1="12" y2="12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
<line x1="6" x2="6.01" y1="16" y2="16" />
|
||||
<line x1="10" x2="10.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-keyboard"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 8h.01" />
|
||||
<path d="M12 12h.01" />
|
||||
<path d="M14 8h.01" />
|
||||
<path d="M16 12h.01" />
|
||||
<path d="M18 8h.01" />
|
||||
<path d="M6 8h.01" />
|
||||
<path d="M7 16h10" />
|
||||
<path d="M8 12h.01" />
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-layout-grid"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-mic"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 19v3" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<rect x="9" y="2" width="6" height="13" rx="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-monitor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-pause"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||
<rect x="5" y="3" width="5" height="18" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-play"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-power"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2v10" />
|
||||
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-rotate-ccw"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-scissors"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M8.12 8.12 12 12" />
|
||||
<path d="M20 4 8.12 15.88" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M14.8 14.8 20 20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-settings"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-skip-back"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z" />
|
||||
<path d="M3 20V4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-skip-forward"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 4v16" />
|
||||
<path d="M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-triangle-alert"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-volume-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
||||
<path d="M16 9a5 5 0 0 1 0 6" />
|
||||
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.544.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-x"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
@@ -0,0 +1,56 @@
|
||||
//! Makes development builds run against the OBS runtime in target/obs-runtime.
|
||||
//!
|
||||
//! OBS starts `obs-ffmpeg-mux` from the running executable's directory, and on
|
||||
//! Windows the OBS DLLs must sit beside the executable too, so they are linked
|
||||
//! (Unix) or copied (Windows) into the Cargo profile directory. A symlink keeps
|
||||
//! the helper's `$ORIGIN/../lib` runpath pointing into the runtime. Packaged
|
||||
//! builds place the executable in the runtime's own bin directory instead (see
|
||||
//! clipbin_obs::RuntimeLayout).
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=CLIPBIN_OBS_RUNTIME_DIR");
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
|
||||
if target_os == "linux" {
|
||||
// Packaged layout: <root>/bin/<exe> loads <root>/lib/libobs.so.
|
||||
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN/../lib");
|
||||
}
|
||||
|
||||
let Some(runtime_dir) = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") else {
|
||||
return;
|
||||
};
|
||||
let runtime = PathBuf::from(runtime_dir).join(format!("{target_os}-{arch}"));
|
||||
// OUT_DIR is <profile>/build/<package>-<hash>/out.
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let profile_dir = out_dir.ancestors().nth(3).unwrap().to_path_buf();
|
||||
|
||||
let (bin_dir, lib_dir) = match target_os.as_str() {
|
||||
"windows" => (runtime.join("bin").join("64bit"), None),
|
||||
_ => (runtime.join("bin"), Some(runtime.join("lib"))),
|
||||
};
|
||||
if let Some(lib_dir) = lib_dir {
|
||||
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{}", lib_dir.display());
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(&bin_dir) else {
|
||||
println!(
|
||||
"cargo:warning=OBS runtime not found at {}; run scripts/obs/ to assemble it",
|
||||
runtime.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", bin_dir.display());
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let destination = profile_dir.join(entry.file_name());
|
||||
let _ = fs::remove_file(&destination);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&path, &destination).unwrap();
|
||||
#[cfg(not(unix))]
|
||||
fs::copy(&path, &destination).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! The root view: navigation, the shared state each screen reads, and the
|
||||
//! window frame (sidebar and main area).
|
||||
|
||||
use std::{
|
||||
cell::Cell,
|
||||
rc::Rc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use clipbin_core::{
|
||||
Library, RecordingStatus, Settings,
|
||||
library::{format_bytes, format_clock, parse_duration},
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Bounds, ClickEvent, Context, Entity, FontWeight, IntoElement, Pixels, Render,
|
||||
SharedString, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
components::{ButtonStyle, button, icon, meter},
|
||||
screens,
|
||||
text_field::{Committed, TextField},
|
||||
theme,
|
||||
timeline_view::TimelineView,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
Sessions,
|
||||
Session(u64),
|
||||
Clips,
|
||||
Settings,
|
||||
}
|
||||
|
||||
/// What the recorder is doing, as the sidebar shows it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Recorder {
|
||||
pub status: RecordingStatus,
|
||||
pub game: Option<String>,
|
||||
}
|
||||
|
||||
/// The open session's playhead and clip selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Editor {
|
||||
pub view: TimelineView,
|
||||
pub playhead: Duration,
|
||||
pub selection: (Duration, Duration),
|
||||
pub playing: bool,
|
||||
}
|
||||
|
||||
/// What a press on the timeline is dragging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Drag {
|
||||
Playhead,
|
||||
SelectionStart,
|
||||
SelectionEnd,
|
||||
}
|
||||
|
||||
pub struct ClipbinApp {
|
||||
pub route: Route,
|
||||
pub library: Library,
|
||||
pub settings: Settings,
|
||||
pub recorder: Recorder,
|
||||
pub editor: Editor,
|
||||
pub playback: crate::playback::Playback,
|
||||
/// A short message for actions that are not connected yet.
|
||||
pub notice: Option<SharedString>,
|
||||
pub drag: Option<Drag>,
|
||||
/// Where the timeline was last laid out, to turn pointer positions into times.
|
||||
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
pub fields: SettingsFields,
|
||||
pub service: crate::recorder_service::Client,
|
||||
pub hotkeys: String,
|
||||
pub failed_exports: usize,
|
||||
last_service_notice: Option<String>,
|
||||
}
|
||||
|
||||
/// The typed settings and the fields that edit them.
|
||||
pub struct SettingsFields {
|
||||
pub budget: Entity<TextField>,
|
||||
pub clip: Entity<TextField>,
|
||||
pub bookmark_before: Entity<TextField>,
|
||||
pub bookmark_after: Entity<TextField>,
|
||||
}
|
||||
|
||||
const GIGABYTE: u64 = 1_000_000_000;
|
||||
|
||||
fn digits(c: char) -> bool {
|
||||
c.is_ascii_digit()
|
||||
}
|
||||
|
||||
fn clock(c: char) -> bool {
|
||||
c.is_ascii_digit() || c == ':'
|
||||
}
|
||||
|
||||
/// Reads a duration within `min..=max`.
|
||||
fn duration_in(text: &str, min: u64, max: u64) -> Option<Duration> {
|
||||
parse_duration(text).filter(|time| (min..=max).contains(&time.as_secs()))
|
||||
}
|
||||
|
||||
impl SettingsFields {
|
||||
fn new(settings: &Settings, window: &mut Window, cx: &mut Context<ClipbinApp>) -> Self {
|
||||
let field = |value: String,
|
||||
accepts,
|
||||
apply: fn(&mut Settings, &str) -> bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ClipbinApp>| {
|
||||
let field = cx.new(|cx| TextField::new(value, accepts, window, cx));
|
||||
cx.subscribe_in(
|
||||
&field,
|
||||
window,
|
||||
move |app, _, Committed(text), window, cx| {
|
||||
if apply(&mut app.settings, text) {
|
||||
app.save_settings();
|
||||
app.fields.refresh(&app.settings, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
field
|
||||
};
|
||||
Self {
|
||||
budget: field(
|
||||
(settings.unprotected_session_limit_bytes / GIGABYTE).to_string(),
|
||||
digits,
|
||||
|settings, text| match text.parse::<u64>() {
|
||||
Ok(gigabytes @ 1..=10_000) => {
|
||||
settings.unprotected_session_limit_bytes = gigabytes * GIGABYTE;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
clip: field(
|
||||
format_clock(settings.clip_duration),
|
||||
clock,
|
||||
|settings, text| {
|
||||
duration_in(text, 5, 30 * 60)
|
||||
.map(|time| settings.clip_duration = time)
|
||||
.is_some()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
bookmark_before: field(
|
||||
format_clock(settings.bookmark_before),
|
||||
clock,
|
||||
|settings, text| {
|
||||
duration_in(text, 0, 30 * 60)
|
||||
.map(|time| settings.bookmark_before = time)
|
||||
.is_some()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
bookmark_after: field(
|
||||
format_clock(settings.bookmark_after),
|
||||
clock,
|
||||
|settings, text| {
|
||||
duration_in(text, 0, 10 * 60)
|
||||
.map(|time| settings.bookmark_after = time)
|
||||
.is_some()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows accepted values in their normal form (`90` becomes `1:30`).
|
||||
fn refresh(&self, settings: &Settings, window: &mut Window, cx: &mut Context<ClipbinApp>) {
|
||||
let values = [
|
||||
(
|
||||
&self.budget,
|
||||
(settings.unprotected_session_limit_bytes / GIGABYTE).to_string(),
|
||||
),
|
||||
(&self.clip, format_clock(settings.clip_duration)),
|
||||
(
|
||||
&self.bookmark_before,
|
||||
format_clock(settings.bookmark_before),
|
||||
),
|
||||
(&self.bookmark_after, format_clock(settings.bookmark_after)),
|
||||
];
|
||||
for (field, value) in values {
|
||||
field.update(cx, |field, cx| field.set_value(value, window, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipbinApp {
|
||||
pub fn new(
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
settings: Settings,
|
||||
service: crate::recorder_service::Client,
|
||||
) -> Self {
|
||||
let fields = SettingsFields::new(&settings, window, cx);
|
||||
// Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with
|
||||
// example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and
|
||||
// CLIPBIN_ROUTE the first screen, for screenshots.
|
||||
let sample = std::env::var_os("CLIPBIN_SAMPLE_LIBRARY").is_some();
|
||||
let library = if sample {
|
||||
Library::sample(SystemTime::now())
|
||||
} else {
|
||||
service.snapshot().library
|
||||
};
|
||||
let recorder = match (sample, std::env::var("CLIPBIN_SAMPLE_STATUS").as_deref()) {
|
||||
(_, Ok("failed")) => Recorder {
|
||||
status: RecordingStatus::Failed {
|
||||
reason: "The capture source stopped providing a picture.".into(),
|
||||
},
|
||||
game: Some("Counter-Strike 2".into()),
|
||||
},
|
||||
(_, Ok("waiting")) => Recorder {
|
||||
status: RecordingStatus::WaitingForCapture,
|
||||
game: Some("Counter-Strike 2".into()),
|
||||
},
|
||||
(true, _) => Recorder {
|
||||
status: RecordingStatus::Recording,
|
||||
game: Some("Counter-Strike 2".into()),
|
||||
},
|
||||
_ => Recorder {
|
||||
status: RecordingStatus::Idle,
|
||||
game: None,
|
||||
},
|
||||
};
|
||||
let mut app = Self {
|
||||
route: Route::Sessions,
|
||||
library,
|
||||
settings,
|
||||
recorder,
|
||||
editor: Editor {
|
||||
view: TimelineView::full(Duration::ZERO),
|
||||
playhead: Duration::ZERO,
|
||||
selection: (Duration::ZERO, Duration::ZERO),
|
||||
playing: false,
|
||||
},
|
||||
playback: crate::playback::Playback::default(),
|
||||
notice: None,
|
||||
drag: None,
|
||||
timeline_bounds: Rc::default(),
|
||||
fields,
|
||||
service,
|
||||
hotkeys: "Registering global hotkeys…".into(),
|
||||
failed_exports: 0,
|
||||
last_service_notice: None,
|
||||
};
|
||||
match std::env::var("CLIPBIN_ROUTE").as_deref() {
|
||||
Ok("clips") => app.route = Route::Clips,
|
||||
Ok("settings") => app.route = Route::Settings,
|
||||
Ok(route) if route.starts_with("session:") || route == "session" => {
|
||||
let id = route.trim_start_matches("session").trim_start_matches(':');
|
||||
if let Some(id) = id
|
||||
.parse()
|
||||
.ok()
|
||||
.or(app.library.sessions.first().map(|s| s.id))
|
||||
{
|
||||
app.open_session(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !sample {
|
||||
cx.spawn(async move |entity, cx| {
|
||||
loop {
|
||||
gpui::Timer::after(Duration::from_millis(250)).await;
|
||||
if entity
|
||||
.update(cx, |app, cx| {
|
||||
let snapshot = app.service.snapshot();
|
||||
if app.last_service_notice != snapshot.notice {
|
||||
app.notice = snapshot.notice.clone().map(Into::into);
|
||||
app.last_service_notice = snapshot.notice.clone();
|
||||
cx.notify();
|
||||
}
|
||||
if app.failed_exports != snapshot.failed_exports {
|
||||
app.failed_exports = snapshot.failed_exports;
|
||||
cx.notify();
|
||||
}
|
||||
if app.hotkeys != snapshot.hotkeys {
|
||||
app.hotkeys = snapshot.hotkeys.clone();
|
||||
cx.notify();
|
||||
}
|
||||
app.service.protect(match app.route {
|
||||
Route::Session(id) => {
|
||||
Some((id, app.editor.selection.0, app.editor.selection.1))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if app.recorder.status != snapshot.status
|
||||
|| app.recorder.game != snapshot.game
|
||||
|| app.library != snapshot.library
|
||||
|| app.settings.portal_restore_token != snapshot.restore_token
|
||||
{
|
||||
app.recorder.status = snapshot.status;
|
||||
app.recorder.game = snapshot.game;
|
||||
if let Route::Session(id) = app.route
|
||||
&& let Some(session) = snapshot.library.session(id)
|
||||
{
|
||||
app.editor.view.set_length(session.length);
|
||||
}
|
||||
app.library = snapshot.library;
|
||||
app.settings.portal_restore_token = snapshot.restore_token;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Self::start_playback_updates(window, cx);
|
||||
app
|
||||
}
|
||||
|
||||
pub fn save_settings(&self) {
|
||||
self.service.settings(self.settings.clone());
|
||||
}
|
||||
|
||||
pub fn navigate(&mut self, route: Route, cx: &mut Context<Self>) {
|
||||
self.notice = None;
|
||||
match route {
|
||||
Route::Session(id) => self.open_session(id),
|
||||
route => {
|
||||
self.playback.player.close();
|
||||
self.editor.playing = false;
|
||||
self.route = route;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Opens a session at its first bookmark (or its latest footage), with the
|
||||
/// default clip length selected up to that point.
|
||||
fn open_session(&mut self, id: u64) {
|
||||
let Some(session) = self.library.session(id) else {
|
||||
return;
|
||||
};
|
||||
let (playhead, selection) = match session.bookmarks.first() {
|
||||
Some(bookmark) => (bookmark.at, bookmark.range(session.length)),
|
||||
None => {
|
||||
let end = session.length;
|
||||
(end, (end.saturating_sub(self.settings.clip_duration), end))
|
||||
}
|
||||
};
|
||||
self.editor = Editor {
|
||||
view: TimelineView::full(session.length),
|
||||
playhead,
|
||||
selection,
|
||||
playing: false,
|
||||
};
|
||||
if let Ok(store) = clipbin_core::store::Store::discover() {
|
||||
let preview_time = playhead.min(
|
||||
session
|
||||
.length
|
||||
.saturating_sub(Duration::from_nanos(16_666_667)),
|
||||
);
|
||||
self.editor.playhead = preview_time;
|
||||
if session.bookmarks.is_empty() {
|
||||
self.playback.player.open_latest(store, id);
|
||||
} else {
|
||||
self.playback.player.open(store, id, preview_time);
|
||||
}
|
||||
}
|
||||
self.route = Route::Session(id);
|
||||
}
|
||||
|
||||
pub fn now(&self) -> SystemTime {
|
||||
SystemTime::now()
|
||||
}
|
||||
|
||||
fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let section = |route: Route| match (route, self.route) {
|
||||
(Route::Sessions, Route::Session(_)) => true,
|
||||
(route, current) => route == current,
|
||||
};
|
||||
let nav_item = |id: &'static str,
|
||||
route: Route,
|
||||
icon_name: &str,
|
||||
label: &'static str,
|
||||
count: Option<usize>| {
|
||||
let active = section(route);
|
||||
div()
|
||||
.id(id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.h_9()
|
||||
.px_3()
|
||||
.rounded_md()
|
||||
.text_sm()
|
||||
.cursor_pointer()
|
||||
.when(active, |item| {
|
||||
item.bg(theme::raised())
|
||||
.text_color(theme::foreground())
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
})
|
||||
.when(!active, |item| {
|
||||
item.text_color(theme::muted())
|
||||
.hover(|style| style.bg(theme::hover()).text_color(theme::text()))
|
||||
})
|
||||
.child(icon(icon_name).text_color(if active {
|
||||
theme::primary_light()
|
||||
} else {
|
||||
theme::subtle()
|
||||
}))
|
||||
.child(div().flex_1().child(label))
|
||||
.children(count.map(|count| {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.child(count.to_string())
|
||||
}))
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| this.navigate(route, cx)))
|
||||
};
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_shrink_0()
|
||||
.w(px(232.))
|
||||
.h_full()
|
||||
.p_3()
|
||||
.gap_6()
|
||||
.bg(theme::sidebar())
|
||||
.border_r_1()
|
||||
.border_color(theme::border())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2p5()
|
||||
.px_2()
|
||||
.pt_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_8()
|
||||
.rounded_lg()
|
||||
.bg(theme::primary())
|
||||
.child(icon("clapperboard").text_color(theme::foreground())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child("clipbin"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(nav_item(
|
||||
"nav-sessions",
|
||||
Route::Sessions,
|
||||
"layout-grid",
|
||||
"Sessions",
|
||||
Some(self.library.sessions.len()),
|
||||
))
|
||||
.child(nav_item(
|
||||
"nav-clips",
|
||||
Route::Clips,
|
||||
"film",
|
||||
"Clips",
|
||||
Some(self.library.clips.len()),
|
||||
))
|
||||
.child(nav_item(
|
||||
"nav-settings",
|
||||
Route::Settings,
|
||||
"settings",
|
||||
"Settings",
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
button(
|
||||
"save-recent",
|
||||
ButtonStyle::Secondary,
|
||||
Some("scissors"),
|
||||
Some("Clip"),
|
||||
)
|
||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.service.clip();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"add-bookmark",
|
||||
ButtonStyle::Secondary,
|
||||
Some("bookmark"),
|
||||
Some("Bookmark"),
|
||||
)
|
||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.service.bookmark();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(self.recorder_card())
|
||||
.child(self.storage_card())
|
||||
}
|
||||
|
||||
fn recorder_card(&self) -> impl IntoElement {
|
||||
let (colour, detail): (gpui::Hsla, String) = match &self.recorder.status {
|
||||
RecordingStatus::Recording => (
|
||||
theme::destructive().into(),
|
||||
self.recorder.game.clone().unwrap_or_default(),
|
||||
),
|
||||
RecordingStatus::Degraded { reason } => (theme::warning().into(), reason.clone()),
|
||||
RecordingStatus::Starting | RecordingStatus::WaitingForCapture => (
|
||||
theme::warning().into(),
|
||||
self.recorder.game.clone().unwrap_or_default(),
|
||||
),
|
||||
RecordingStatus::Failed { .. } => {
|
||||
(theme::destructive().into(), "Needs attention".into())
|
||||
}
|
||||
RecordingStatus::Idle => (theme::faint().into(), "Waiting for a game".into()),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme::border())
|
||||
.bg(theme::card())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().size_2().rounded_full().bg(colour))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child(self.recorder.status.label()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.pl_4()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.truncate()
|
||||
.child(detail),
|
||||
)
|
||||
}
|
||||
|
||||
fn storage_card(&self) -> impl IntoElement {
|
||||
let storage = self.library.storage;
|
||||
let budget = storage.budget_bytes.max(1) as f32;
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.pb_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_xs()
|
||||
.text_color(theme::muted())
|
||||
.child(icon("hard-drive").size_3p5())
|
||||
.child("Session footage"),
|
||||
)
|
||||
.child(meter(&[(
|
||||
storage.unprotected_bytes as f32 / budget,
|
||||
theme::primary().into(),
|
||||
)]))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_xs()
|
||||
.child(div().text_color(theme::text()).child(format!(
|
||||
"{} of {}",
|
||||
format_bytes(storage.unprotected_bytes),
|
||||
format_bytes(storage.budget_bytes)
|
||||
)))
|
||||
.child(div().text_color(theme::faint()).child(format!(
|
||||
"+ {} kept",
|
||||
format_bytes(storage.protected_bytes + storage.clip_bytes)
|
||||
))),
|
||||
)
|
||||
}
|
||||
|
||||
fn failure_banner(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let RecordingStatus::Failed { reason } = &self.recorder.status else {
|
||||
return None;
|
||||
};
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme::destructive_alpha(0.35))
|
||||
.bg(theme::destructive_alpha(0.08))
|
||||
.child(icon("triangle-alert").text_color(theme::destructive()))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child("Recording stopped"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::muted())
|
||||
.child(SharedString::from(reason.clone())),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
crate::components::button(
|
||||
"select-source",
|
||||
crate::components::ButtonStyle::Secondary,
|
||||
Some("monitor"),
|
||||
Some("Choose capture source"),
|
||||
)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.portal_restore_token = None;
|
||||
this.service.choose_source();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ClipbinApp {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Development aid: CLIPBIN_TRACE_FRAMES logs how long each frame takes
|
||||
// from building the view to the next frame starting.
|
||||
if std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some() {
|
||||
let started = std::time::Instant::now();
|
||||
let route = self.route;
|
||||
window.on_next_frame(move |_, _| eprintln!("frame {route:?} {:?}", started.elapsed()));
|
||||
}
|
||||
let screen = match self.route {
|
||||
Route::Sessions => screens::sessions::render(self, cx),
|
||||
Route::Session(id) => screens::editor::render(self, id, cx),
|
||||
Route::Clips => screens::clips::render(self, cx),
|
||||
Route::Settings => screens::settings::render(self, cx),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.bg(theme::background())
|
||||
.text_color(theme::text())
|
||||
.font_family(theme::FONT)
|
||||
.child(self.sidebar(cx))
|
||||
.child(
|
||||
div()
|
||||
.id("main")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.overflow_y_scroll()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.w_full()
|
||||
.max_w(px(1400.))
|
||||
.px_8()
|
||||
.py_8()
|
||||
.children(self.failure_banner(cx))
|
||||
.children(self.notice.clone().map(|notice| {
|
||||
div()
|
||||
.px_4()
|
||||
.py_2p5()
|
||||
.rounded_lg()
|
||||
.bg(theme::primary_alpha(0.1))
|
||||
.border_1()
|
||||
.border_color(theme::primary_alpha(0.3))
|
||||
.text_sm()
|
||||
.text_color(theme::text())
|
||||
.child(notice)
|
||||
}))
|
||||
.child(screen),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Fonts and icons compiled into the binary, so the interface needs no files
|
||||
//! beside the executable. Geist Mono is under the SIL OFL (assets/fonts/OFL.txt)
|
||||
//! and the icons are Lucide, under ISC (assets/icons/LICENSE).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
|
||||
macro_rules! embedded {
|
||||
($($path:literal),* $(,)?) => {
|
||||
&[$(($path, include_bytes!(concat!("../assets/", $path)))),*]
|
||||
};
|
||||
}
|
||||
|
||||
const FILES: &[(&str, &[u8])] = embedded![
|
||||
"icons/app-window.svg",
|
||||
"icons/arrow-left.svg",
|
||||
"icons/bookmark.svg",
|
||||
"icons/chevron-left.svg",
|
||||
"icons/chevron-right.svg",
|
||||
"icons/circle-dot.svg",
|
||||
"icons/clapperboard.svg",
|
||||
"icons/clock.svg",
|
||||
"icons/download.svg",
|
||||
"icons/film.svg",
|
||||
"icons/folder-open.svg",
|
||||
"icons/gamepad-2.svg",
|
||||
"icons/hard-drive.svg",
|
||||
"icons/keyboard.svg",
|
||||
"icons/layout-grid.svg",
|
||||
"icons/mic.svg",
|
||||
"icons/monitor.svg",
|
||||
"icons/pause.svg",
|
||||
"icons/play.svg",
|
||||
"icons/power.svg",
|
||||
"icons/rotate-ccw.svg",
|
||||
"icons/scissors.svg",
|
||||
"icons/settings.svg",
|
||||
"icons/skip-back.svg",
|
||||
"icons/skip-forward.svg",
|
||||
"icons/triangle-alert.svg",
|
||||
"icons/volume-2.svg",
|
||||
"icons/x.svg",
|
||||
];
|
||||
|
||||
const FONTS: &[&[u8]] = &[
|
||||
include_bytes!("../assets/fonts/GeistMono-Regular.ttf"),
|
||||
include_bytes!("../assets/fonts/GeistMono-Medium.ttf"),
|
||||
include_bytes!("../assets/fonts/GeistMono-SemiBold.ttf"),
|
||||
include_bytes!("../assets/fonts/GeistMono-Bold.ttf"),
|
||||
];
|
||||
|
||||
pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
|
||||
Ok(FILES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == path)
|
||||
.map(|(_, bytes)| Cow::Borrowed(*bytes)))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
Ok(FILES
|
||||
.iter()
|
||||
.filter(|(name, _)| name.starts_with(path))
|
||||
.map(|(name, _)| SharedString::from(*name))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_fonts(cx: &App) -> Result<()> {
|
||||
cx.text_system()
|
||||
.add_fonts(FONTS.iter().map(|font| Cow::Borrowed(*font)).collect())
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Small building blocks shared by the screens.
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Div, ElementId, FontWeight, Hsla, IntoElement, ParentElement, SharedString,
|
||||
Stateful, Styled, Svg, div, prelude::*, px, svg,
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
pub fn icon(name: &str) -> Svg {
|
||||
svg()
|
||||
.path(SharedString::from(format!("icons/{name}.svg")))
|
||||
.size_4()
|
||||
.flex_shrink_0()
|
||||
.text_color(theme::muted())
|
||||
}
|
||||
|
||||
/// Gives a box a fixed width-to-height ratio, like CSS `aspect-ratio`.
|
||||
pub fn aspect_ratio<E: Styled>(mut element: E, ratio: f32) -> E {
|
||||
element.style().aspect_ratio = Some(ratio);
|
||||
element
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonStyle {
|
||||
Primary,
|
||||
Secondary,
|
||||
Ghost,
|
||||
}
|
||||
|
||||
/// A clickable row with an optional icon and label.
|
||||
pub fn button(
|
||||
id: impl Into<ElementId>,
|
||||
style: ButtonStyle,
|
||||
icon_name: Option<&str>,
|
||||
label: Option<&str>,
|
||||
) -> Stateful<Div> {
|
||||
let (background, foreground, hover) = match style {
|
||||
ButtonStyle::Primary => (
|
||||
theme::primary().into(),
|
||||
theme::foreground(),
|
||||
theme::primary_alpha(0.85),
|
||||
),
|
||||
ButtonStyle::Secondary => (
|
||||
Hsla::from(theme::raised()),
|
||||
theme::text(),
|
||||
Hsla::from(theme::border()),
|
||||
),
|
||||
ButtonStyle::Ghost => (
|
||||
Hsla::transparent_black(),
|
||||
theme::muted(),
|
||||
Hsla::from(theme::hover()),
|
||||
),
|
||||
};
|
||||
let mut button = div()
|
||||
.id(id)
|
||||
.flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.h_8()
|
||||
.rounded_md()
|
||||
.bg(background)
|
||||
.text_color(foreground)
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.cursor_pointer()
|
||||
.hover(move |style| style.bg(hover));
|
||||
button = match label {
|
||||
Some(_) => button.px_3(),
|
||||
None => button.w_8(),
|
||||
};
|
||||
if let Some(name) = icon_name {
|
||||
button = button.child(icon(name).text_color(foreground));
|
||||
}
|
||||
if let Some(label) = label {
|
||||
button = button.child(SharedString::from(label.to_owned()));
|
||||
}
|
||||
button
|
||||
}
|
||||
|
||||
/// A small rounded label, as on video cards.
|
||||
pub fn chip(text: impl Into<SharedString>) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_sm()
|
||||
.bg(theme::overlay())
|
||||
.text_xs()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(theme::foreground())
|
||||
.child(text.into())
|
||||
}
|
||||
|
||||
/// A page title with a muted description and actions on the right.
|
||||
pub fn page_header(
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
actions: Vec<AnyElement>,
|
||||
) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_end()
|
||||
.justify_between()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_2xl()
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child(title.into()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme::subtle())
|
||||
.child(description.into()),
|
||||
),
|
||||
)
|
||||
.child(div().flex().gap_2().children(actions))
|
||||
}
|
||||
|
||||
/// A bordered panel with a small heading.
|
||||
pub fn panel(title: impl Into<SharedString>) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.p_4()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme::border())
|
||||
.bg(theme::card())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::subtle())
|
||||
.child(title.into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A label on the left and a value on the right.
|
||||
pub fn row(label: impl Into<SharedString>, value: impl IntoElement) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_4()
|
||||
.text_sm()
|
||||
.child(div().text_color(theme::muted()).child(label.into()))
|
||||
.child(value)
|
||||
}
|
||||
|
||||
/// A centred message for a screen with nothing to show yet.
|
||||
pub fn empty_state(
|
||||
icon_name: &str,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.py_24()
|
||||
.rounded_xl()
|
||||
.border_1()
|
||||
.border_color(theme::border())
|
||||
.border_dashed()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_12()
|
||||
.rounded_full()
|
||||
.bg(theme::surface())
|
||||
.child(icon(icon_name).size_5()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::text())
|
||||
.child(title.into()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(420.))
|
||||
.text_center()
|
||||
.text_sm()
|
||||
.text_color(theme::subtle())
|
||||
.child(description.into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A thin horizontal progress bar made of coloured parts.
|
||||
pub fn meter(parts: &[(f32, Hsla)]) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.h_1p5()
|
||||
.w_full()
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.bg(theme::raised())
|
||||
.children(parts.iter().map(|(fraction, colour)| {
|
||||
div()
|
||||
.h_full()
|
||||
.w(gpui::relative(fraction.clamp(0.0, 1.0)))
|
||||
.bg(*colour)
|
||||
}))
|
||||
}
|
||||
|
||||
/// `1 session`, `3 sessions`.
|
||||
pub fn count(n: usize, noun: &str) -> String {
|
||||
format!("{n} {noun}{}", if n == 1 { "" } else { "s" })
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! A bounded queue and one remux thread; source leases live on the recorder.
|
||||
use clipbin_core::{
|
||||
clips::{PendingExport, SavedClip},
|
||||
library::Clip,
|
||||
store::Store,
|
||||
};
|
||||
use std::{
|
||||
sync::mpsc::{self, Receiver},
|
||||
thread,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Request {
|
||||
pub session: u64,
|
||||
pub start: Duration,
|
||||
pub end: Duration,
|
||||
pub separate: bool,
|
||||
pub running: bool,
|
||||
pub failed: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Exports {
|
||||
pub requests: Vec<Request>,
|
||||
result: Option<Receiver<Result<String, String>>>,
|
||||
}
|
||||
impl Exports {
|
||||
pub fn load(store: &Store) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
requests: store
|
||||
.pending_exports()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|p| Request {
|
||||
session: p.session,
|
||||
start: p.start,
|
||||
end: p.end,
|
||||
separate: p.separate,
|
||||
running: false,
|
||||
failed: p.failed,
|
||||
})
|
||||
.collect(),
|
||||
result: None,
|
||||
})
|
||||
}
|
||||
pub fn save(&self, store: &Store) -> Result<(), String> {
|
||||
store
|
||||
.save_pending_exports(
|
||||
&self
|
||||
.requests
|
||||
.iter()
|
||||
.map(|r| PendingExport {
|
||||
session: r.session,
|
||||
start: r.start,
|
||||
end: r.end,
|
||||
separate: r.separate,
|
||||
failed: r.failed,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
&mut self,
|
||||
store: &Store,
|
||||
session: u64,
|
||||
start: Duration,
|
||||
end: Duration,
|
||||
separate: bool,
|
||||
) -> Result<(), String> {
|
||||
if end <= start {
|
||||
return Err("Select a non-empty range".into());
|
||||
}
|
||||
if self.requests.len() >= 8 {
|
||||
return Err("Export queue is full; finish or release existing requests".into());
|
||||
}
|
||||
self.requests.push(Request {
|
||||
session,
|
||||
start,
|
||||
end,
|
||||
separate,
|
||||
running: false,
|
||||
failed: false,
|
||||
});
|
||||
if let Err(e) = self.save(store) {
|
||||
self.requests.pop();
|
||||
return Err(format!("Could not persist export request: {e}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn poll(&mut self, store: &Store) -> Option<String> {
|
||||
if let Some(receiver) = &self.result {
|
||||
match receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
let index = self.requests.iter().position(|r| r.running).unwrap();
|
||||
self.result = None;
|
||||
let notice = match result {
|
||||
Ok(message) => {
|
||||
self.requests.remove(index);
|
||||
message
|
||||
}
|
||||
Err(error) => {
|
||||
self.requests[index].running = false;
|
||||
self.requests[index].failed = true;
|
||||
format!("Export failed: {error}. Source range remains protected.")
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.save(store) {
|
||||
return Some(format!("{notice}; could not persist export queue: {e}"));
|
||||
}
|
||||
return Some(notice);
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.result = None;
|
||||
if let Some(r) = self.requests.iter_mut().find(|r| r.running) {
|
||||
r.running = false;
|
||||
r.failed = true;
|
||||
}
|
||||
let _ = self.save(store);
|
||||
return Some("Export worker stopped; source range remains protected".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for request in &mut self.requests {
|
||||
if request.failed {
|
||||
continue;
|
||||
}
|
||||
let metadata = match store.metadata(request.session) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
request.failed = true;
|
||||
let message = format!("Export failed: {e}");
|
||||
let _ = self.save(store);
|
||||
return Some(message);
|
||||
}
|
||||
};
|
||||
let available_end = metadata
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|s| s.complete && s.available)
|
||||
.map(|s| s.end)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
let (start, end) = metadata.export_range(request.start, request.end);
|
||||
if metadata.session.active && available_end < end {
|
||||
continue;
|
||||
}
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let store = store.clone();
|
||||
let separate = request.separate;
|
||||
match thread::Builder::new()
|
||||
.name("clipbin-export".into())
|
||||
.spawn(move || {
|
||||
let result = (|| {
|
||||
std::fs::create_dir_all(store.clips_directory())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let created = SystemTime::now();
|
||||
let id = created
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
let path = store.clip_path(id);
|
||||
clipbin_media::export::export(&metadata, start, end, separate, &path)?;
|
||||
let size_bytes = std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
|
||||
let saved = SavedClip {
|
||||
clip: Clip {
|
||||
id,
|
||||
game: metadata.session.game.clone(),
|
||||
created,
|
||||
length: end.saturating_sub(start),
|
||||
size_bytes,
|
||||
},
|
||||
source_session: metadata.session.id,
|
||||
start,
|
||||
end,
|
||||
separate_audio: separate,
|
||||
};
|
||||
if let Err(e) = store.save_clip(&saved) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
Ok(format!(
|
||||
"Saved clip · {} · {} – {}",
|
||||
clipbin_core::library::format_length(saved.clip.length),
|
||||
clipbin_core::library::format_clock(start),
|
||||
clipbin_core::library::format_clock(end)
|
||||
))
|
||||
})();
|
||||
let _ = sender.send(result);
|
||||
}) {
|
||||
Ok(_) => {
|
||||
request.running = true;
|
||||
self.result = Some(receiver);
|
||||
}
|
||||
Err(e) => {
|
||||
request.failed = true;
|
||||
return Some(format!("Could not start export: {e}"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn busy(&self) -> bool {
|
||||
self.result.is_some() || self.requests.iter().any(|r| !r.failed)
|
||||
}
|
||||
}
|
||||
@@ -3,46 +3,177 @@ use gpui::{
|
||||
actions, px, size,
|
||||
};
|
||||
|
||||
mod view;
|
||||
use clipbin_core::background::{Action, Instance, Tray};
|
||||
use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
mod app;
|
||||
mod assets;
|
||||
mod components;
|
||||
mod exports;
|
||||
mod playback;
|
||||
mod recorder_service;
|
||||
mod screens;
|
||||
mod storage;
|
||||
mod text_field;
|
||||
mod theme;
|
||||
mod timeline_view;
|
||||
|
||||
actions!(clipbin, [Quit]);
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
cx.quit();
|
||||
}
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
cx.on_action(quit);
|
||||
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||
|
||||
// This initial shell has no background recorder yet.
|
||||
cx.on_window_closed(|cx| {
|
||||
if cx.windows().is_empty() {
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx);
|
||||
let result = cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("Clipbin".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(|_| view::ClipbinView::default()),
|
||||
);
|
||||
|
||||
if let Err(error) = result {
|
||||
eprintln!("Could not open Clipbin: {error}");
|
||||
cx.quit();
|
||||
let (settings, initial_error) = match clipbin_core::store::load_settings() {
|
||||
Ok(settings) => (settings, None),
|
||||
Err(error) => (
|
||||
clipbin_core::Settings::default(),
|
||||
Some(format!("Could not load settings: {error}")),
|
||||
),
|
||||
};
|
||||
let (actions, receiver) = std::sync::mpsc::channel();
|
||||
let _instance = match Instance::acquire(actions.clone()) {
|
||||
Ok(Some(instance)) => instance,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
eprintln!("Could not establish single instance: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tray = match Tray::start(actions) {
|
||||
Ok(tray) => Some(tray),
|
||||
Err(error) => {
|
||||
eprintln!("Tray unavailable; closing the window will quit: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let initial_error = clipbin_core::autostart::reconcile(settings.start_on_login)
|
||||
.err()
|
||||
.map(|e| format!("Could not configure start on login: {e}"))
|
||||
.or(initial_error);
|
||||
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
|
||||
let interface_service = service.clone();
|
||||
let tray = tray.map(Arc::new);
|
||||
let background = std::env::args().any(|arg| arg == "--background");
|
||||
Application::new()
|
||||
.with_assets(assets::Assets)
|
||||
.run(move |cx: &mut App| {
|
||||
cx.set_quit_on_last_window_close(false);
|
||||
if let Err(error) = assets::load_fonts(cx) {
|
||||
eprintln!("Could not load the interface font: {error}");
|
||||
}
|
||||
let quitter = interface_service.clone();
|
||||
cx.on_action(move |_: &Quit, _| quitter.quit());
|
||||
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||
cx.set_menus(vec![gpui::Menu {
|
||||
name: "Clipbin".into(),
|
||||
items: vec![gpui::MenuItem::action("Quit Clipbin", Quit)],
|
||||
}]);
|
||||
text_field::bind_keys(cx);
|
||||
playback::bind_keys(cx);
|
||||
let ui = Rc::new(RefCell::new(Interface {
|
||||
window: None,
|
||||
route: app::Route::Sessions,
|
||||
}));
|
||||
if !background || tray.is_none() {
|
||||
open_window(cx, &interface_service, &ui, &tray);
|
||||
}
|
||||
cx.spawn(async move |cx| {
|
||||
let mut last_state = None;
|
||||
loop {
|
||||
gpui::Timer::after(Duration::from_millis(100)).await;
|
||||
let state = interface_service.background_state();
|
||||
if last_state.as_ref() != Some(&state) {
|
||||
if let Some(tray) = &tray {
|
||||
tray.update(state.clone());
|
||||
}
|
||||
last_state = Some(state);
|
||||
}
|
||||
while let Ok(action) = receiver.try_recv() {
|
||||
match action {
|
||||
Action::Open => {
|
||||
let _ =
|
||||
cx.update(|cx| open_window(cx, &interface_service, &ui, &tray));
|
||||
}
|
||||
Action::Clip => interface_service.clip(),
|
||||
Action::Bookmark => interface_service.bookmark(),
|
||||
Action::Quit => interface_service.quit(),
|
||||
}
|
||||
}
|
||||
if interface_service.exited() {
|
||||
let _ = cx.update(|cx| cx.quit());
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
service.quit();
|
||||
let _ = worker.join();
|
||||
}
|
||||
|
||||
cx.activate(true);
|
||||
struct Interface {
|
||||
window: Option<gpui::WindowHandle<app::ClipbinApp>>,
|
||||
route: app::Route,
|
||||
}
|
||||
|
||||
fn open_window(
|
||||
cx: &mut App,
|
||||
service: &recorder_service::Client,
|
||||
ui: &Rc<RefCell<Interface>>,
|
||||
tray: &Option<Arc<Tray>>,
|
||||
) {
|
||||
let current = ui.borrow().window;
|
||||
if let Some(window) = current
|
||||
&& window
|
||||
.update(cx, |_, window, _| window.activate_window())
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let settings = clipbin_core::store::load_settings().unwrap_or_default();
|
||||
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
||||
let window_service = service.clone();
|
||||
let result = cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
window_min_size: Some(size(px(1040.), px(640.))),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("Clipbin".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
app_id: Some("dev.clipbin.Clipbin".into()),
|
||||
..Default::default()
|
||||
},
|
||||
move |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service)),
|
||||
);
|
||||
let window = match result {
|
||||
Ok(window) => window,
|
||||
Err(error) => {
|
||||
eprintln!("Could not open Clipbin: {error}");
|
||||
service.quit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let route = ui.borrow().route;
|
||||
ui.borrow_mut().window = Some(window);
|
||||
let close_service = service.clone();
|
||||
let close_tray = tray.clone();
|
||||
let close_ui = ui.clone();
|
||||
let _ = window.update(cx, |app, window, cx| {
|
||||
if route != app::Route::Sessions {
|
||||
app.navigate(route, cx);
|
||||
}
|
||||
let entity = cx.entity();
|
||||
window.on_window_should_close(cx, move |_, cx| {
|
||||
if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
|
||||
close_service.quit();
|
||||
return false;
|
||||
}
|
||||
let mut ui = close_ui.borrow_mut();
|
||||
ui.route = entity.read(cx).route;
|
||||
ui.window = None;
|
||||
eprintln!("Window closed: Clipbin is running in the tray");
|
||||
true
|
||||
});
|
||||
});
|
||||
playback::bind_actions(window, cx);
|
||||
cx.activate(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! GPUI image upload and controls; file I/O and decoding belong to the player.
|
||||
use crate::app::{ClipbinApp, Route};
|
||||
use clipbin_core::store::Store;
|
||||
use clipbin_media::{
|
||||
player::{Picture, Player},
|
||||
thumbnail::Thumbnails,
|
||||
};
|
||||
use gpui::{Context, RenderImage};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
pub struct Playback {
|
||||
pub player: Player,
|
||||
pub image: Option<Arc<RenderImage>>,
|
||||
pub message: Option<String>,
|
||||
revision: u64,
|
||||
last_frame: Option<Arc<clipbin_media::decode::Frame>>,
|
||||
pub muted: bool,
|
||||
pub volume: f32,
|
||||
pub audio_error: Option<String>,
|
||||
pub preview_height: f32,
|
||||
pub thumbnails: HashMap<u64, PathBuf>,
|
||||
pub clip_thumbnails: HashMap<u64, PathBuf>,
|
||||
}
|
||||
impl Default for Playback {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
player: Player::new(),
|
||||
image: None,
|
||||
message: Some("Loading footage…".into()),
|
||||
revision: 0,
|
||||
last_frame: None,
|
||||
muted: false,
|
||||
volume: 1.,
|
||||
audio_error: None,
|
||||
preview_height: 416.,
|
||||
thumbnails: HashMap::new(),
|
||||
clip_thumbnails: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ClipbinApp {
|
||||
pub fn seek_playback(&mut self, time: Duration) {
|
||||
self.editor.playhead = time;
|
||||
self.playback.player.seek(time);
|
||||
}
|
||||
pub fn toggle_playback(&mut self) {
|
||||
if let Route::Session(id) = self.route {
|
||||
if self
|
||||
.library
|
||||
.session(id)
|
||||
.is_some_and(|session| self.editor.playhead >= session.length)
|
||||
{
|
||||
self.seek_playback(Duration::ZERO);
|
||||
}
|
||||
self.editor.playing = !self.editor.playing;
|
||||
self.playback.player.play(self.editor.playing);
|
||||
}
|
||||
}
|
||||
pub fn start_playback_updates(window: &gpui::Window, cx: &mut Context<Self>) {
|
||||
cx.spawn_in(window, async |entity, cx| {
|
||||
loop {
|
||||
gpui::Timer::after(Duration::from_millis(16)).await;
|
||||
if entity
|
||||
.update_in(cx, |app, window, cx| {
|
||||
if !matches!(app.route, Route::Session(_)) {
|
||||
if let Some(image) = app.playback.image.take() {
|
||||
let _ = window.drop_image(image);
|
||||
}
|
||||
app.playback.last_frame = None;
|
||||
return;
|
||||
}
|
||||
let snapshot = app.playback.player.snapshot();
|
||||
if snapshot.revision == app.playback.revision {
|
||||
return;
|
||||
}
|
||||
app.playback.revision = snapshot.revision;
|
||||
app.editor.playhead = snapshot.time;
|
||||
app.editor.playing = snapshot.playing;
|
||||
app.playback.audio_error = snapshot.audio_error;
|
||||
match snapshot.picture {
|
||||
Picture::Frame(frame) => {
|
||||
// Swscale already produced GPUI-native BGRA off the UI thread.
|
||||
if app
|
||||
.playback
|
||||
.last_frame
|
||||
.as_ref()
|
||||
.is_none_or(|last| !Arc::ptr_eq(last, &frame))
|
||||
{
|
||||
let upload_started = std::time::Instant::now();
|
||||
let bytes = frame.bgra.clone();
|
||||
if let Some(buffer) =
|
||||
image::RgbaImage::from_raw(frame.width, frame.height, bytes)
|
||||
{
|
||||
let image =
|
||||
Arc::new(RenderImage::new(vec![image::Frame::new(
|
||||
buffer,
|
||||
)]));
|
||||
if let Some(old) = app.playback.image.replace(image) {
|
||||
let _ = window.drop_image(old);
|
||||
}
|
||||
if std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some() {
|
||||
eprintln!(
|
||||
"playback_ui upload_ms={:.3} width={} height={}",
|
||||
upload_started.elapsed().as_secs_f64() * 1000.,
|
||||
frame.width,
|
||||
frame.height
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
app.playback.last_frame = Some(frame);
|
||||
app.playback.message = None;
|
||||
}
|
||||
Picture::Loading => {
|
||||
if let Some(old) = app.playback.image.take() {
|
||||
let _ = window.drop_image(old);
|
||||
}
|
||||
app.playback.last_frame = None;
|
||||
app.playback.message = Some("Loading footage…".into());
|
||||
}
|
||||
Picture::Gap => {
|
||||
if let Some(old) = app.playback.image.take() {
|
||||
let _ = window.drop_image(old);
|
||||
}
|
||||
app.playback.last_frame = None;
|
||||
app.playback.message =
|
||||
Some("No footage (not captured / cleaned up)".into());
|
||||
}
|
||||
Picture::Error(error) => {
|
||||
if let Some(old) = app.playback.image.take() {
|
||||
let _ = window.drop_image(old);
|
||||
}
|
||||
app.playback.last_frame = None;
|
||||
app.playback.message = Some(error);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
// Discovery and thumbnail work never touch the UI thread's filesystem.
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
let thumbnails = Thumbnails::new();
|
||||
loop {
|
||||
let mut ready = HashMap::new();
|
||||
let mut clips = HashMap::new();
|
||||
if let Ok(store) = Store::discover()
|
||||
&& let Ok(metadata) = store.read()
|
||||
{
|
||||
for metadata in metadata {
|
||||
if let Some(segment) = metadata
|
||||
.segments
|
||||
.iter()
|
||||
.find(|segment| segment.complete && segment.available)
|
||||
&& let Some(path) = thumbnails
|
||||
.request(&store.directory(metadata.session.id).join(&segment.path))
|
||||
{
|
||||
ready.insert(metadata.session.id, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(store) = Store::discover()
|
||||
&& let Some(root) = store.root.parent()
|
||||
&& let Ok(entries) = std::fs::read_dir(root.join("clips"))
|
||||
{
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|extension| extension == "mp4")
|
||||
&& let Some(id) = path
|
||||
.file_stem()
|
||||
.and_then(|id| id.to_str())
|
||||
.and_then(|id| id.parse().ok())
|
||||
&& let Some(thumbnail) = thumbnails.request(&path)
|
||||
{
|
||||
clips.insert(id, thumbnail);
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
sender.try_send((ready, clips)),
|
||||
Err(std::sync::mpsc::TrySendError::Disconnected(_))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
cx.spawn(async move |entity, cx| {
|
||||
loop {
|
||||
gpui::Timer::after(Duration::from_millis(250)).await;
|
||||
let ready = receiver.try_recv().ok();
|
||||
if entity
|
||||
.update(cx, |app, cx| {
|
||||
if let Some((sessions, clips)) = ready
|
||||
&& (app.playback.thumbnails != sessions
|
||||
|| app.playback.clip_thumbnails != clips)
|
||||
{
|
||||
app.playback.thumbnails = sessions;
|
||||
app.playback.clip_thumbnails = clips;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
gpui::actions!(playback, [TogglePlayback, StepBack, StepForward]);
|
||||
pub fn bind_keys(cx: &mut gpui::App) {
|
||||
cx.bind_keys([
|
||||
gpui::KeyBinding::new("space", TogglePlayback, None),
|
||||
gpui::KeyBinding::new("k", TogglePlayback, None),
|
||||
gpui::KeyBinding::new("left", StepBack, None),
|
||||
gpui::KeyBinding::new("j", StepBack, None),
|
||||
gpui::KeyBinding::new("right", StepForward, None),
|
||||
gpui::KeyBinding::new("l", StepForward, None),
|
||||
]);
|
||||
cx.bind_keys(
|
||||
["space", "k", "j", "l"]
|
||||
.map(|key| gpui::KeyBinding::new(key, gpui::NoAction {}, Some("TextField"))),
|
||||
);
|
||||
}
|
||||
/// Window actions also work when a mouse click leaves no focused GPUI element.
|
||||
/// Text-field contexts keep their own typing and arrow bindings.
|
||||
pub fn bind_actions(window: gpui::WindowHandle<ClipbinApp>, cx: &mut gpui::App) {
|
||||
// The event dispatcher already borrows the window; update its entity directly.
|
||||
let editor = window.entity(cx).expect("editor window").downgrade();
|
||||
let toggle = editor.clone();
|
||||
cx.on_action(move |_: &TogglePlayback, cx| {
|
||||
let _ = toggle.update(cx, |app, cx| {
|
||||
app.toggle_playback();
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
let back = editor.clone();
|
||||
cx.on_action(move |_: &StepBack, cx| {
|
||||
let _ = back.update(cx, |app, cx| {
|
||||
app.step_playback(false);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
cx.on_action(move |_: &StepForward, cx| {
|
||||
let _ = editor.update(cx, |app, cx| {
|
||||
app.step_playback(true);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
impl ClipbinApp {
|
||||
pub fn step_playback(&mut self, forward: bool) {
|
||||
if !matches!(self.route, Route::Session(_)) {
|
||||
return;
|
||||
}
|
||||
self.editor.playing = false;
|
||||
self.playback.player.play(false);
|
||||
let snapshot = self.playback.player.snapshot();
|
||||
let step = Duration::from_secs_f64(1. / snapshot.frames_per_second.max(1) as f64);
|
||||
let time = snapshot.video_time.unwrap_or(self.editor.playhead);
|
||||
// Round towards the selected frame when nanoseconds cannot represent a frame period.
|
||||
let at = if forward {
|
||||
time + step
|
||||
} else {
|
||||
time.saturating_sub(step)
|
||||
} + Duration::from_nanos(2);
|
||||
self.seek_playback(at);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
//! One worker owns OBS; the interface sends commands and reads cheap snapshots.
|
||||
use clipbin_core::{
|
||||
Library, RecordingStatus, Settings,
|
||||
games::{self, ExitWatch, Game},
|
||||
library::{Bookmark, Span, SpanKind},
|
||||
store::{self, Metadata, Segment, Store, frame_offset, segment_offset},
|
||||
};
|
||||
use clipbin_obs::{
|
||||
Engine, RecorderEvent, Recording, RecordingConfig, RuntimeLayout, StatusTracker, StopReason,
|
||||
capture,
|
||||
};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
mpsc::{self, Receiver, Sender},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Snapshot {
|
||||
pub status: RecordingStatus,
|
||||
pub game: Option<String>,
|
||||
pub library: Library,
|
||||
pub restore_token: Option<String>,
|
||||
pub exited: bool,
|
||||
pub notice: Option<String>,
|
||||
pub metadata: Vec<Metadata>,
|
||||
pub hotkeys: String,
|
||||
pub failed_exports: usize,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
commands: Sender<Command>,
|
||||
snapshot: Arc<Mutex<Snapshot>>,
|
||||
}
|
||||
enum Command {
|
||||
Settings(Settings),
|
||||
ChooseSource,
|
||||
Quit,
|
||||
Clip(Instant),
|
||||
Bookmark(Instant),
|
||||
Export(u64, Duration, Duration),
|
||||
Release(u64, usize),
|
||||
Protect(crate::storage::Editing),
|
||||
DeleteClip(u64),
|
||||
Open(Option<u64>),
|
||||
RetryExports,
|
||||
ReleaseExports,
|
||||
}
|
||||
impl Client {
|
||||
pub fn start(
|
||||
settings: Settings,
|
||||
initial_error: Option<String>,
|
||||
) -> (Self, thread::JoinHandle<()>) {
|
||||
let (commands, receiver) = mpsc::channel();
|
||||
let snapshot = Arc::new(Mutex::new(Snapshot {
|
||||
status: initial_error.map_or(RecordingStatus::Idle, |reason| RecordingStatus::Failed {
|
||||
reason,
|
||||
}),
|
||||
game: None,
|
||||
library: Library::empty(settings.unprotected_session_limit_bytes),
|
||||
restore_token: settings.portal_restore_token.clone(),
|
||||
exited: false,
|
||||
notice: None,
|
||||
metadata: Vec::new(),
|
||||
hotkeys: "Registering global hotkeys…".into(),
|
||||
failed_exports: 0,
|
||||
}));
|
||||
let state = snapshot.clone();
|
||||
let action_sender = commands.clone();
|
||||
let worker = thread::Builder::new()
|
||||
.name("clipbin-recorder".into())
|
||||
.spawn(move || {
|
||||
if let Err(error) = worker(settings, receiver, &state, action_sender) {
|
||||
state.lock().unwrap().status = RecordingStatus::Failed { reason: error };
|
||||
}
|
||||
})
|
||||
.expect("start recorder worker");
|
||||
let client = Self { commands, snapshot };
|
||||
(client, worker)
|
||||
}
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
self.snapshot.lock().unwrap().clone()
|
||||
}
|
||||
pub fn background_state(&self) -> clipbin_core::background::State {
|
||||
let snapshot = self.snapshot.lock().unwrap();
|
||||
clipbin_core::background::State {
|
||||
status: snapshot.status.clone(),
|
||||
game: snapshot.game.clone(),
|
||||
}
|
||||
}
|
||||
pub fn exited(&self) -> bool {
|
||||
self.snapshot.lock().unwrap().exited
|
||||
}
|
||||
pub fn settings(&self, settings: Settings) {
|
||||
let _ = self.commands.send(Command::Settings(settings));
|
||||
}
|
||||
pub fn clip(&self) {
|
||||
let _ = self.commands.send(Command::Clip(Instant::now()));
|
||||
}
|
||||
pub fn bookmark(&self) {
|
||||
let _ = self.commands.send(Command::Bookmark(Instant::now()));
|
||||
}
|
||||
pub fn export(&self, id: u64, start: Duration, end: Duration) {
|
||||
let _ = self.commands.send(Command::Export(id, start, end));
|
||||
}
|
||||
pub fn release(&self, id: u64, index: usize) {
|
||||
let _ = self.commands.send(Command::Release(id, index));
|
||||
}
|
||||
pub fn protect(&self, editing: crate::storage::Editing) {
|
||||
let _ = self.commands.send(Command::Protect(editing));
|
||||
}
|
||||
pub fn delete_clip(&self, id: u64) {
|
||||
let _ = self.commands.send(Command::DeleteClip(id));
|
||||
}
|
||||
pub fn open_clip(&self, id: Option<u64>) {
|
||||
let _ = self.commands.send(Command::Open(id));
|
||||
}
|
||||
pub fn export_range(
|
||||
&self,
|
||||
id: u64,
|
||||
start: Duration,
|
||||
end: Duration,
|
||||
) -> Option<(Duration, Duration)> {
|
||||
self.snapshot
|
||||
.lock()
|
||||
.unwrap()
|
||||
.metadata
|
||||
.iter()
|
||||
.find(|m| m.session.id == id)
|
||||
.map(|m| m.export_range(start, end))
|
||||
}
|
||||
pub fn retry_exports(&self) {
|
||||
let _ = self.commands.send(Command::RetryExports);
|
||||
}
|
||||
pub fn release_exports(&self) {
|
||||
let _ = self.commands.send(Command::ReleaseExports);
|
||||
}
|
||||
pub fn choose_source(&self) {
|
||||
let _ = self.commands.send(Command::ChooseSource);
|
||||
}
|
||||
pub fn quit(&self) {
|
||||
if self.commands.send(Command::Quit).is_err() {
|
||||
self.snapshot.lock().unwrap().exited = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Active {
|
||||
game: Game,
|
||||
watch: ExitWatch,
|
||||
metadata: Metadata,
|
||||
started: Instant,
|
||||
recording: Option<Recording>,
|
||||
tracker: StatusTracker,
|
||||
run: u32,
|
||||
run_base: Option<Duration>,
|
||||
span_base: usize,
|
||||
span_start: Duration,
|
||||
last_frames: u64,
|
||||
last_dropped: u64,
|
||||
audio_samples: Vec<u64>,
|
||||
fps: u32,
|
||||
stopping: bool,
|
||||
ending: bool,
|
||||
retry_at: Instant,
|
||||
retries: u32,
|
||||
}
|
||||
impl Active {
|
||||
fn start_run(
|
||||
&mut self,
|
||||
engine: &Engine,
|
||||
settings: &Settings,
|
||||
store: &Store,
|
||||
) -> Result<(), String> {
|
||||
let encoder = engine
|
||||
.hardware_encoders(settings.codec)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("No hardware {} encoder", settings.codec))?;
|
||||
#[cfg(windows)]
|
||||
let target = capture::CaptureTarget::Game {
|
||||
window: capture::WindowMatch {
|
||||
obs_window: format!("::{}", self.game.executable),
|
||||
restore_token: None,
|
||||
},
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let target = capture::CaptureTarget::Monitor {
|
||||
monitor: capture::MonitorMatch {
|
||||
obs_monitor: String::new(),
|
||||
restore_token: settings.portal_restore_token.clone(),
|
||||
},
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let video_source = target.video_source();
|
||||
#[cfg(not(windows))]
|
||||
let video_source = target.video_source(
|
||||
capture::LinuxSession::detect().ok_or("No Wayland or X11 display for capture")?,
|
||||
);
|
||||
self.fps = settings.frames_per_second;
|
||||
self.run += 1;
|
||||
self.run_base = None;
|
||||
self.span_base = self.metadata.session.spans.len();
|
||||
self.span_start = self
|
||||
.metadata
|
||||
.session
|
||||
.spans
|
||||
.last()
|
||||
.map_or(Duration::ZERO, |s| s.end);
|
||||
self.last_frames = 0;
|
||||
self.audio_samples.clear();
|
||||
self.last_dropped = 0;
|
||||
self.tracker = StatusTracker::new(
|
||||
Instant::now(),
|
||||
StatusTracker::DEFAULT_MIN_FREE_BYTES,
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
self.recording = Some(
|
||||
engine
|
||||
.start_recording(RecordingConfig {
|
||||
frames_per_second: Some(settings.frames_per_second),
|
||||
video_source: Some(video_source),
|
||||
system_audio: settings
|
||||
.system_audio_enabled
|
||||
.then(capture::system_audio_source),
|
||||
microphone: settings.microphone_enabled.then(capture::microphone_source),
|
||||
resolution: settings.resolution,
|
||||
video_encoder: encoder,
|
||||
video_bitrate_kbps: 12_000,
|
||||
keyframe_interval_seconds: 1,
|
||||
segment_seconds: 2,
|
||||
segment_dir: store
|
||||
.directory(self.metadata.session.id)
|
||||
.join(format!("run-{:03}", self.run)),
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
self.stopping = false;
|
||||
Ok(())
|
||||
}
|
||||
fn update_timeline(&mut self) {
|
||||
let end = self.run_base.map_or(Duration::ZERO, |base| {
|
||||
base + frame_offset(self.last_frames, self.fps)
|
||||
});
|
||||
self.metadata
|
||||
.update_run(self.span_base, self.run_base, end, self.started.elapsed());
|
||||
if let Some(segment) = self.metadata.segments.last_mut()
|
||||
&& !segment.complete
|
||||
{
|
||||
segment.end = end.max(segment.start);
|
||||
}
|
||||
for index in 0..self.metadata.segments.len() {
|
||||
if !self.metadata.segments[index].available {
|
||||
self.metadata.removed(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn finish_run(&mut self, store: &Store) -> Result<(), String> {
|
||||
let mut empty_tail = false;
|
||||
if let Some(segment) = self.metadata.segments.last_mut()
|
||||
&& !segment.complete
|
||||
{
|
||||
let path = store
|
||||
.directory(self.metadata.session.id)
|
||||
.join(&segment.path);
|
||||
if !clipbin_core::segments::has_cluster(&path).map_err(|e| e.to_string())? {
|
||||
eprintln!(
|
||||
"Warning: {} has no media cluster; its tail is Missing",
|
||||
path.display()
|
||||
);
|
||||
segment.available = false;
|
||||
segment.end = segment.start;
|
||||
self.last_frames = segment.frame_start;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => empty_tail = true,
|
||||
Err(error) => {
|
||||
eprintln!("Could not remove empty segment: {error}; marked unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if empty_tail {
|
||||
self.metadata.segments.pop();
|
||||
}
|
||||
self.update_timeline();
|
||||
if let Some(segment) = self.metadata.segments.last_mut()
|
||||
&& !segment.complete
|
||||
{
|
||||
if segment.audio_start_samples.is_empty() && segment.frame_start == 0 {
|
||||
let path = store
|
||||
.directory(self.metadata.session.id)
|
||||
.join(&segment.path);
|
||||
if let Ok(samples) = clipbin_media::export::audio_samples(&path) {
|
||||
segment.audio_start_samples = vec![0; samples.len()];
|
||||
}
|
||||
}
|
||||
segment.complete = true;
|
||||
segment.dropped_frames = self.last_dropped;
|
||||
if self.last_dropped > 0 {
|
||||
eprintln!(
|
||||
"Warning: {} frames dropped in run {}; segment offsets may drift",
|
||||
self.last_dropped, self.run
|
||||
);
|
||||
}
|
||||
}
|
||||
self.span_base = self.metadata.session.spans.len();
|
||||
self.span_start = self
|
||||
.metadata
|
||||
.session
|
||||
.spans
|
||||
.last()
|
||||
.map_or(Duration::ZERO, |s| s.end);
|
||||
self.run_base = None;
|
||||
self.recording = None;
|
||||
self.stopping = false;
|
||||
self.retry_at = Instant::now() + Duration::from_secs(5);
|
||||
Ok(())
|
||||
}
|
||||
fn finish_session(&mut self) {
|
||||
self.metadata.session.length = self.started.elapsed().max(self.metadata.session.length);
|
||||
let end = self
|
||||
.metadata
|
||||
.session
|
||||
.spans
|
||||
.last()
|
||||
.map_or(Duration::ZERO, |s| s.end);
|
||||
if self.metadata.session.length > end {
|
||||
self.metadata.session.spans.push(Span {
|
||||
start: end,
|
||||
end: self.metadata.session.length,
|
||||
kind: SpanKind::Missing,
|
||||
});
|
||||
}
|
||||
self.metadata.session.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(snapshot: &Arc<Mutex<Snapshot>>, reason: impl ToString) {
|
||||
snapshot.lock().unwrap().status = RecordingStatus::Failed {
|
||||
reason: reason.to_string(),
|
||||
};
|
||||
}
|
||||
fn worker(
|
||||
mut settings: Settings,
|
||||
commands: Receiver<Command>,
|
||||
snapshot: &Arc<Mutex<Snapshot>>,
|
||||
action_sender: Sender<Command>,
|
||||
) -> Result<(), String> {
|
||||
let store = Store::discover().map_err(|e| e.to_string())?;
|
||||
store.recover().map_err(|e| e.to_string())?;
|
||||
if let Err(e) = crate::storage::index_legacy_audio(&store) {
|
||||
snapshot.lock().unwrap().notice =
|
||||
Some(format!("Could not recover exact audio clocks: {e}"));
|
||||
}
|
||||
snapshot.lock().unwrap().library = store
|
||||
.library(settings.unprotected_session_limit_bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// An absent runtime is an interface-visible error; keep accepting commands.
|
||||
let engine = RuntimeLayout::discover()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|layout| {
|
||||
Engine::start(&layout, settings.frames_per_second).map_err(|e| e.to_string())
|
||||
});
|
||||
let engine = match engine {
|
||||
Ok(engine) => Some(engine),
|
||||
Err(e) => {
|
||||
fail(snapshot, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
let register = |settings: &Settings| {
|
||||
let sender = action_sender.clone();
|
||||
let state = snapshot.clone();
|
||||
clipbin_core::hotkeys::start(
|
||||
settings.clip_hotkey,
|
||||
settings.bookmark_hotkey,
|
||||
move |action, pressed| {
|
||||
let _ = sender.send(match action {
|
||||
clipbin_core::hotkeys::Action::Clip => Command::Clip(pressed),
|
||||
clipbin_core::hotkeys::Action::Bookmark => Command::Bookmark(pressed),
|
||||
});
|
||||
},
|
||||
move |message| {
|
||||
state.lock().unwrap().hotkeys = message;
|
||||
},
|
||||
)
|
||||
};
|
||||
let mut hotkeys = Some(register(&settings));
|
||||
let mut active: Option<Active> = None;
|
||||
let mut quitting = false;
|
||||
let mut next_scan = Instant::now();
|
||||
let mut next_poll = Instant::now();
|
||||
let mut next_save = Instant::now();
|
||||
let mut previous_status = None;
|
||||
let mut exports = crate::exports::Exports::load(&store)?;
|
||||
let mut editing = None;
|
||||
let mut next_cleanup = Instant::now();
|
||||
loop {
|
||||
match commands.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(Command::Quit) => {
|
||||
quitting = true;
|
||||
if let Some(a) = &mut active {
|
||||
a.ending = true;
|
||||
if !a.stopping
|
||||
&& let Some(r) = &a.recording
|
||||
{
|
||||
r.stop();
|
||||
a.stopping = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Command::Settings(new)) => {
|
||||
if new.clip_hotkey != settings.clip_hotkey
|
||||
|| new.bookmark_hotkey != settings.bookmark_hotkey
|
||||
{
|
||||
hotkeys.take();
|
||||
hotkeys = Some(register(&new));
|
||||
}
|
||||
settings = new;
|
||||
snapshot.lock().unwrap().library.storage.budget_bytes =
|
||||
settings.unprotected_session_limit_bytes;
|
||||
if let Err(e) = store::save_settings(&settings) {
|
||||
fail(snapshot, format!("Could not save settings: {e}"));
|
||||
}
|
||||
if let Err(e) = clipbin_core::autostart::reconcile(settings.start_on_login) {
|
||||
snapshot.lock().unwrap().notice =
|
||||
Some(format!("Could not configure start on login: {e}"));
|
||||
}
|
||||
snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone();
|
||||
}
|
||||
Ok(Command::Clip(pressed)) => {
|
||||
let result = if let Some(a) = &active {
|
||||
let end = pressed.saturating_duration_since(a.started);
|
||||
exports.request(
|
||||
&store,
|
||||
a.metadata.session.id,
|
||||
end.saturating_sub(settings.clip_duration),
|
||||
end,
|
||||
settings.export_separate_audio,
|
||||
)
|
||||
} else {
|
||||
Err("No active session to clip".into())
|
||||
};
|
||||
snapshot.lock().unwrap().notice = Some(result.err().unwrap_or_else(|| {
|
||||
"Clip queued at press time; waiting for the segment to finish".into()
|
||||
}));
|
||||
}
|
||||
Ok(Command::Bookmark(pressed)) => {
|
||||
if let Some(a) = &mut active {
|
||||
a.metadata.session.bookmarks.push(Bookmark {
|
||||
at: pressed.saturating_duration_since(a.started),
|
||||
before: settings.bookmark_before,
|
||||
after: settings.bookmark_after,
|
||||
protected: true,
|
||||
});
|
||||
store.save(&a.metadata).map_err(|e| e.to_string())?;
|
||||
snapshot.lock().unwrap().notice = Some("Bookmark saved and protected".into());
|
||||
next_save = Instant::now();
|
||||
} else {
|
||||
snapshot.lock().unwrap().notice = Some("No active session to bookmark".into());
|
||||
}
|
||||
}
|
||||
Ok(Command::Export(id, start, end)) => {
|
||||
snapshot.lock().unwrap().notice = Some(
|
||||
exports
|
||||
.request(&store, id, start, end, settings.export_separate_audio)
|
||||
.err()
|
||||
.unwrap_or_else(|| "Export queued; source range protected".into()),
|
||||
);
|
||||
}
|
||||
Ok(Command::Release(id, index)) => {
|
||||
let mut m = store
|
||||
.read()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|m| m.session.id == id);
|
||||
if let Some(m) = &mut m {
|
||||
if let Some(b) = m.session.bookmarks.get_mut(index) {
|
||||
b.protected = false;
|
||||
}
|
||||
store.save(m).map_err(|e| e.to_string())?;
|
||||
if let Some(a) = &mut active
|
||||
&& a.metadata.session.id == id
|
||||
{
|
||||
a.metadata.session.bookmarks = m.session.bookmarks.clone();
|
||||
}
|
||||
}
|
||||
next_cleanup = Instant::now();
|
||||
}
|
||||
Ok(Command::Protect(range)) => editing = range,
|
||||
Ok(Command::DeleteClip(id)) => {
|
||||
if let Err(e) = store.delete_clip(id) {
|
||||
snapshot.lock().unwrap().notice = Some(format!("Could not delete clip: {e}"));
|
||||
}
|
||||
next_cleanup = Instant::now();
|
||||
}
|
||||
Ok(Command::Open(id)) => {
|
||||
let path = id.map_or_else(|| store.clips_directory(), |id| store.clip_path(id));
|
||||
let state = snapshot.clone();
|
||||
thread::spawn(move || {
|
||||
let result = if id.is_none() {
|
||||
std::fs::create_dir_all(&path)
|
||||
.and_then(|()| clipbin_core::clips::open(path))
|
||||
} else {
|
||||
clipbin_core::clips::open(path)
|
||||
};
|
||||
if let Err(e) = result {
|
||||
state.lock().unwrap().notice = Some(format!("Could not open: {e}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Command::RetryExports) => {
|
||||
for r in &mut exports.requests {
|
||||
r.failed = false;
|
||||
}
|
||||
snapshot.lock().unwrap().notice = Some(exports.save(&store).err().map_or_else(
|
||||
|| "Retrying failed exports".into(),
|
||||
|e| format!("Could not persist export queue: {e}"),
|
||||
));
|
||||
}
|
||||
Ok(Command::ReleaseExports) => {
|
||||
let previous = exports.requests.clone();
|
||||
exports.requests.retain(|r| !r.failed);
|
||||
snapshot.lock().unwrap().notice =
|
||||
Some("Failed export source protection released".into());
|
||||
next_cleanup = Instant::now();
|
||||
if let Err(e) = exports.save(&store) {
|
||||
exports.requests = previous;
|
||||
snapshot.lock().unwrap().notice =
|
||||
Some(format!("Could not persist released protection: {e}"));
|
||||
}
|
||||
}
|
||||
Ok(Command::ChooseSource) => {
|
||||
settings.portal_restore_token = None;
|
||||
if let Err(e) = store::save_settings(&settings) {
|
||||
fail(snapshot, e);
|
||||
}
|
||||
snapshot.lock().unwrap().restore_token = None;
|
||||
if let Some(a) = &mut active {
|
||||
a.retries = 0;
|
||||
a.retry_at = Instant::now();
|
||||
if !a.stopping
|
||||
&& let Some(r) = &a.recording
|
||||
{
|
||||
r.stop();
|
||||
a.stopping = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if quitting && active.is_none() && !exports.busy() {
|
||||
break;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if active.is_none() && !quitting && now >= next_scan {
|
||||
next_scan = now + Duration::from_secs(1);
|
||||
match games::discover() {
|
||||
Ok(games) => {
|
||||
if let Some(game) = games.into_iter().next()
|
||||
&& let Some(engine) = &engine
|
||||
{
|
||||
match ExitWatch::open(&game).and_then(|watch| {
|
||||
store
|
||||
.create(game.app_id, game.name.clone())
|
||||
.map(|metadata| (watch, metadata))
|
||||
}) {
|
||||
Ok((watch, metadata)) => {
|
||||
let mut a = Active {
|
||||
game,
|
||||
watch,
|
||||
metadata,
|
||||
started: now,
|
||||
recording: None,
|
||||
tracker: StatusTracker::new(now, 0, Duration::from_secs(2)),
|
||||
run: 0,
|
||||
run_base: None,
|
||||
span_base: 0,
|
||||
span_start: Duration::ZERO,
|
||||
last_frames: 0,
|
||||
last_dropped: 0,
|
||||
audio_samples: Vec::new(),
|
||||
fps: settings.frames_per_second,
|
||||
stopping: false,
|
||||
ending: false,
|
||||
retry_at: now + Duration::from_secs(5),
|
||||
retries: 0,
|
||||
};
|
||||
snapshot.lock().unwrap().game = Some(a.game.name.clone());
|
||||
match a.start_run(engine, &settings, &store) {
|
||||
Ok(()) => {
|
||||
let mut state = snapshot.lock().unwrap();
|
||||
if !matches!(state.status, RecordingStatus::Failed { .. }) {
|
||||
state.status = RecordingStatus::Starting;
|
||||
}
|
||||
}
|
||||
Err(e) => fail(snapshot, e),
|
||||
}
|
||||
active = Some(a);
|
||||
}
|
||||
Err(e) => fail(snapshot, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => fail(snapshot, e),
|
||||
}
|
||||
}
|
||||
if let Some(a) = &mut active {
|
||||
// Process exit has priority over health: closing a game is normal.
|
||||
match a.watch.exited() {
|
||||
Ok(true) => a.ending = true,
|
||||
Err(e) => {
|
||||
fail(snapshot, format!("Could not watch game exit: {e}"));
|
||||
a.ending = true;
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
if a.ending
|
||||
&& !a.stopping
|
||||
&& let Some(r) = &a.recording
|
||||
{
|
||||
r.stop();
|
||||
a.stopping = true;
|
||||
}
|
||||
if !a.ending
|
||||
&& a.recording.is_none()
|
||||
&& a.retries < 3
|
||||
&& now >= a.retry_at
|
||||
&& let Some(engine) = &engine
|
||||
{
|
||||
a.retries += 1;
|
||||
a.retry_at = now + Duration::from_secs(5);
|
||||
if let Err(e) = a.start_run(engine, &settings, &store) {
|
||||
fail(snapshot, e);
|
||||
}
|
||||
}
|
||||
let mut stopped = false;
|
||||
if let Some(r) = &a.recording {
|
||||
if now >= next_poll {
|
||||
next_poll = now + Duration::from_secs(1);
|
||||
match r.poll() {
|
||||
Ok(health) => {
|
||||
a.last_frames = health.total_frames;
|
||||
a.last_dropped = health.dropped_frames;
|
||||
if health.output_active && a.run_base.is_none() {
|
||||
a.run_base = Some(
|
||||
a.started
|
||||
.elapsed()
|
||||
.saturating_sub(frame_offset(health.total_frames, a.fps)),
|
||||
);
|
||||
}
|
||||
if !a.ending && !a.stopping {
|
||||
let status = a.tracker.sample(&health, now).clone();
|
||||
if matches!(
|
||||
status,
|
||||
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
|
||||
) {
|
||||
a.retries = 0;
|
||||
}
|
||||
// Keep a failure visible throughout recovery until footage is proven.
|
||||
if matches!(
|
||||
status,
|
||||
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
|
||||
) || !matches!(
|
||||
snapshot.lock().unwrap().status,
|
||||
RecordingStatus::Failed { .. }
|
||||
) {
|
||||
snapshot.lock().unwrap().status = status.clone();
|
||||
}
|
||||
if matches!(status, RecordingStatus::Failed { .. }) {
|
||||
r.stop();
|
||||
a.stopping = true;
|
||||
}
|
||||
}
|
||||
if let Some(token) = r.restore_token()
|
||||
&& settings.portal_restore_token.as_ref() != Some(&token)
|
||||
{
|
||||
settings.portal_restore_token = Some(token.clone());
|
||||
snapshot.lock().unwrap().restore_token = Some(token);
|
||||
if let Err(e) = store::save_settings(&settings) {
|
||||
fail(snapshot, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
fail(snapshot, e);
|
||||
if !a.stopping {
|
||||
r.stop();
|
||||
a.stopping = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for event in r.events().try_iter() {
|
||||
match event {
|
||||
RecorderEvent::SegmentStarted {
|
||||
path,
|
||||
total_frames,
|
||||
dropped_frames,
|
||||
} => {
|
||||
let base = *a.run_base.get_or_insert_with(|| {
|
||||
a.started
|
||||
.elapsed()
|
||||
.saturating_sub(frame_offset(a.last_frames, a.fps))
|
||||
});
|
||||
let offset = base + segment_offset(total_frames, a.fps);
|
||||
if let Some(previous) = a.metadata.segments.last_mut()
|
||||
&& !previous.complete
|
||||
{
|
||||
let path =
|
||||
store.directory(a.metadata.session.id).join(&previous.path);
|
||||
match clipbin_media::export::audio_samples(&path) {
|
||||
Ok(samples) => {
|
||||
if a.audio_samples.is_empty() {
|
||||
if !previous.audio_start_samples.is_empty() {
|
||||
a.audio_samples =
|
||||
previous.audio_start_samples.clone();
|
||||
} else if previous.frame_start == 0 {
|
||||
a.audio_samples = vec![0; samples.len()];
|
||||
}
|
||||
}
|
||||
previous.audio_start_samples = a.audio_samples.clone();
|
||||
for (total, count) in
|
||||
a.audio_samples.iter_mut().zip(samples)
|
||||
{
|
||||
*total += count;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Could not index segment audio: {e}");
|
||||
a.audio_samples.clear();
|
||||
}
|
||||
}
|
||||
previous.end = offset;
|
||||
previous.complete = true;
|
||||
previous.dropped_frames = dropped_frames;
|
||||
if dropped_frames > 0 {
|
||||
eprintln!(
|
||||
"Warning: {dropped_frames} frames dropped in run {}; segment offsets may drift",
|
||||
a.run
|
||||
);
|
||||
}
|
||||
}
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let relative = path
|
||||
.strip_prefix(store.directory(a.metadata.session.id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_path_buf();
|
||||
a.metadata.segments.push(Segment {
|
||||
path: relative,
|
||||
start: offset,
|
||||
end: offset,
|
||||
complete: false,
|
||||
available: true,
|
||||
frame_start: total_frames.saturating_sub(1),
|
||||
frames_per_second: a.fps,
|
||||
dropped_frames: 0,
|
||||
audio_start_samples: a.audio_samples.clone(),
|
||||
});
|
||||
}
|
||||
RecorderEvent::OutputStopped { code, last_error } => {
|
||||
if !a.stopping && !a.ending {
|
||||
fail(snapshot, "The recording output stopped unexpectedly");
|
||||
}
|
||||
if code != 0 {
|
||||
snapshot.lock().unwrap().status = a
|
||||
.tracker
|
||||
.stopped(StopReason::from_code(code), last_error.as_deref())
|
||||
.clone();
|
||||
}
|
||||
// Read final cumulative frame count after output finalisation.
|
||||
if let Ok(health) = r.poll() {
|
||||
a.last_frames = health.total_frames;
|
||||
a.last_dropped = health.dropped_frames;
|
||||
}
|
||||
stopped = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stopped {
|
||||
a.finish_run(&store)?;
|
||||
}
|
||||
a.update_timeline();
|
||||
if now >= next_save || stopped || a.ending {
|
||||
next_save = now + Duration::from_secs(1);
|
||||
if a.ending && a.recording.is_none() {
|
||||
a.finish_session();
|
||||
store.save(&a.metadata).map_err(|e| e.to_string())?;
|
||||
let mut state = snapshot.lock().unwrap();
|
||||
if !matches!(state.status, RecordingStatus::Failed { .. }) {
|
||||
state.status = RecordingStatus::Idle;
|
||||
}
|
||||
drop(state);
|
||||
snapshot.lock().unwrap().game = None;
|
||||
active = None;
|
||||
next_cleanup = now;
|
||||
} else {
|
||||
store.save(&a.metadata).map_err(|e| e.to_string())?;
|
||||
}
|
||||
match store.library_with_protection(
|
||||
settings.unprotected_session_limit_bytes,
|
||||
&crate::storage::ranges(&exports, editing),
|
||||
) {
|
||||
Ok(library) => {
|
||||
let metadata = store.read().map_err(|e| e.to_string())?;
|
||||
let mut state = snapshot.lock().unwrap();
|
||||
state.library = library;
|
||||
state.metadata = metadata;
|
||||
}
|
||||
Err(e) => fail(snapshot, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(message) = exports.poll(&store) {
|
||||
eprintln!("export_status={message}");
|
||||
snapshot.lock().unwrap().notice = Some(message);
|
||||
next_cleanup = now;
|
||||
}
|
||||
if now >= next_cleanup {
|
||||
next_cleanup = now + Duration::from_secs(3);
|
||||
if let Some(a) = &mut active {
|
||||
store.save(&a.metadata).map_err(|e| e.to_string())?;
|
||||
}
|
||||
match crate::storage::cleanup(
|
||||
&store,
|
||||
settings.unprotected_session_limit_bytes,
|
||||
&exports,
|
||||
editing,
|
||||
) {
|
||||
Ok(metadata) => {
|
||||
if let Some(a) = &mut active
|
||||
&& let Some(m) = metadata
|
||||
.iter()
|
||||
.find(|m| m.session.id == a.metadata.session.id)
|
||||
{
|
||||
a.metadata = m.clone();
|
||||
a.span_base = a
|
||||
.metadata
|
||||
.session
|
||||
.spans
|
||||
.iter()
|
||||
.position(|s| s.start >= a.span_start)
|
||||
.unwrap_or(a.metadata.session.spans.len());
|
||||
}
|
||||
snapshot.lock().unwrap().metadata = metadata;
|
||||
}
|
||||
Err(e) => snapshot.lock().unwrap().notice = Some(format!("Cleanup failed: {e}")),
|
||||
}
|
||||
match store.library_with_protection(
|
||||
settings.unprotected_session_limit_bytes,
|
||||
&crate::storage::ranges(&exports, editing),
|
||||
) {
|
||||
Ok(library) => snapshot.lock().unwrap().library = library,
|
||||
Err(e) => fail(snapshot, e),
|
||||
}
|
||||
}
|
||||
snapshot.lock().unwrap().failed_exports =
|
||||
exports.requests.iter().filter(|r| r.failed).count();
|
||||
let status = snapshot.lock().unwrap().status.clone();
|
||||
if previous_status.as_ref() != Some(&status) {
|
||||
eprintln!("recorder_status={status:?}");
|
||||
previous_status = Some(status);
|
||||
}
|
||||
}
|
||||
drop(active);
|
||||
drop(engine);
|
||||
eprintln!("Recorder stopped; exiting");
|
||||
snapshot.lock().unwrap().exited = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Exported clips: local MP4 files that never expire automatically.
|
||||
|
||||
use clipbin_core::library::{format_ago, format_bytes, format_length};
|
||||
use gpui::{
|
||||
AnyElement, ClickEvent, Context, FontWeight, IntoElement, Pixels, canvas, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::ClipbinApp,
|
||||
components::{ButtonStyle, button, chip, count, empty_state, page_header},
|
||||
screens::{corner, thumbnail},
|
||||
theme,
|
||||
};
|
||||
|
||||
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
let clips = &app.library.clips;
|
||||
let total: u64 = clips.iter().map(|clip| clip.size_bytes).sum();
|
||||
let header = page_header(
|
||||
"Clips",
|
||||
if clips.is_empty() {
|
||||
"Clips you save are kept until you delete them.".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{} · {} · kept until you delete them",
|
||||
count(clips.len(), "clip"),
|
||||
format_bytes(total)
|
||||
)
|
||||
},
|
||||
vec![
|
||||
button(
|
||||
"open-clips-folder",
|
||||
ButtonStyle::Secondary,
|
||||
Some("folder-open"),
|
||||
Some("Open folder"),
|
||||
)
|
||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.service.open_clip(None);
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element(),
|
||||
],
|
||||
);
|
||||
|
||||
let failed_actions = (app.failed_exports > 0).then(|| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(format!(
|
||||
"{} failed exports · source footage kept",
|
||||
app.failed_exports
|
||||
))
|
||||
.child(
|
||||
button("retry-exports", ButtonStyle::Secondary, None, Some("Retry")).on_click(
|
||||
cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.service.retry_exports();
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"release-exports",
|
||||
ButtonStyle::Ghost,
|
||||
None,
|
||||
Some("Release protection"),
|
||||
)
|
||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.service.release_exports();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
});
|
||||
if clips.is_empty() {
|
||||
return div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(header)
|
||||
.children(failed_actions)
|
||||
.child(empty_state(
|
||||
"scissors",
|
||||
"No clips yet",
|
||||
"Press the clip hotkey in game to save the last two minutes, or select a range on a session's timeline.",
|
||||
))
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let now = app.now();
|
||||
let cards = clips.iter().map(|clip| {
|
||||
let id = clip.id;
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.gap_3()
|
||||
.child(
|
||||
thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child(
|
||||
corner()
|
||||
.bottom_2p5()
|
||||
.right_2p5()
|
||||
.child(chip(format_length(clip.length))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child(label(clip.game.clone(), px(24.))),
|
||||
)
|
||||
.child(div().text_xs().text_color(theme::subtle()).child(label(
|
||||
format!(
|
||||
"{} · {}",
|
||||
format_ago(clip.created, now),
|
||||
format_bytes(clip.size_bytes)
|
||||
),
|
||||
px(18.),
|
||||
))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.flex_shrink_0()
|
||||
.child(
|
||||
button(("open-clip", id), ButtonStyle::Ghost, Some("play"), None)
|
||||
.flex_shrink_0()
|
||||
.rounded_full()
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
app.service.open_clip(Some(id));
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
("delete-clip", id),
|
||||
ButtonStyle::Ghost,
|
||||
None,
|
||||
Some("Delete"),
|
||||
)
|
||||
.flex_shrink_0()
|
||||
.on_click(cx.listener(
|
||||
move |app, _: &ClickEvent, _, cx| {
|
||||
app.service.delete_clip(id);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(header)
|
||||
.children(failed_actions)
|
||||
.child(
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(3)
|
||||
.gap_x_4()
|
||||
.gap_y_8()
|
||||
.children(cards),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
// GPUI 0.2.2 caches nowrap widths and mutates text runs during remeasurement.
|
||||
// Shape once at the final card bounds to retain both ellipsis and font styling.
|
||||
fn label(text: String, height: Pixels) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
let style = window.text_style();
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
let line_height = style
|
||||
.line_height
|
||||
.to_pixels(font_size.into(), window.rem_size());
|
||||
let text: gpui::SharedString = text.replace(['\n', '\r'], " ").into();
|
||||
let mut runs = vec![style.to_run(text.len())];
|
||||
let text = cx
|
||||
.text_system()
|
||||
.line_wrapper(style.font(), font_size)
|
||||
.truncate_line(text, bounds.size.width, "…", &mut runs);
|
||||
(
|
||||
window
|
||||
.text_system()
|
||||
.shape_line(text, font_size, &runs, None),
|
||||
line_height,
|
||||
)
|
||||
},
|
||||
|bounds, (line, line_height), window, cx| {
|
||||
let _ = line.paint(bounds.origin, line_height, window, cx);
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.h(height)
|
||||
.overflow_hidden()
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! A session's timeline: preview, bookmarks, and choosing a clip to export.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use clipbin_core::library::{
|
||||
Session, SpanKind, export_range, format_ago, format_clock, format_length,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, ImageSource, IntoElement,
|
||||
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, Pixels, Point,
|
||||
ScrollWheelEvent, canvas, div, img, pattern_slash, prelude::*, px, relative,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{ClipbinApp, Drag, Route},
|
||||
components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row},
|
||||
theme,
|
||||
timeline_view::TimelineView,
|
||||
};
|
||||
|
||||
/// Keyframes come every second (clipbin-obs records with keyint_sec = 1).
|
||||
const KEYFRAME_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// Selection handles respond within this distance of the pointer.
|
||||
const HANDLE_REACH: f32 = 10.;
|
||||
/// Pixels per scroll-wheel line, for wheels that report lines.
|
||||
const SCROLL_LINE: f32 = 40.;
|
||||
/// How strongly each scrolled pixel zooms the timeline.
|
||||
const ZOOM_PER_PIXEL: f32 = 0.0025;
|
||||
|
||||
pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
let Some(session) = app.library.session(id) else {
|
||||
return div()
|
||||
.child("This session no longer exists.")
|
||||
.into_any_element();
|
||||
};
|
||||
let editor = app.editor;
|
||||
let length = session.length;
|
||||
let (start, end) = editor.selection;
|
||||
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(
|
||||
button("back", ButtonStyle::Ghost, Some("arrow-left"), None).on_click(
|
||||
cx.listener(|app, _: &ClickEvent, _, cx| app.navigate(Route::Sessions, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xl()
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child(session.game.clone())
|
||||
.when(session.active, |title| {
|
||||
title.child(chip("REC").bg(theme::destructive()))
|
||||
}),
|
||||
)
|
||||
.child(div().text_xs().text_color(theme::subtle()).child(format!(
|
||||
"{} · {}",
|
||||
format_ago(session.started, app.now()),
|
||||
format_length(length)
|
||||
))),
|
||||
);
|
||||
|
||||
let preview = aspect_ratio(div(), 16. / 9.)
|
||||
.h(px(app.playback.preview_height))
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.rounded_lg()
|
||||
.bg(gpui::black())
|
||||
.children(app.playback.image.clone().map(|image| {
|
||||
img(ImageSource::Render(image))
|
||||
.absolute()
|
||||
.size_full()
|
||||
.object_fit(ObjectFit::Contain)
|
||||
}))
|
||||
.children(app.playback.message.clone().map(|message| {
|
||||
div()
|
||||
.absolute()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_color(theme::muted())
|
||||
.child(message)
|
||||
}))
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let player = cx.entity().downgrade();
|
||||
move |bounds, window, cx| {
|
||||
let scale = window.scale_factor();
|
||||
let size = (
|
||||
(f32::from(bounds.size.width) * scale) as u32,
|
||||
(f32::from(bounds.size.height) * scale) as u32,
|
||||
);
|
||||
let _ = player.update(cx, |app, cx| {
|
||||
app.playback.player.resize(size);
|
||||
let height = f32::from(bounds.size.width) * 9. / 16.;
|
||||
if (app.playback.preview_height - height).abs() > 1. {
|
||||
app.playback.preview_height = height;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_3()
|
||||
.right_3()
|
||||
.child(chip(format_clock(editor.playhead))),
|
||||
);
|
||||
|
||||
let previous_bookmark = session
|
||||
.bookmarks
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.at < editor.playhead)
|
||||
.map(|b| b.at);
|
||||
let next_bookmark = session
|
||||
.bookmarks
|
||||
.iter()
|
||||
.find(|b| b.at > editor.playhead)
|
||||
.map(|b| b.at);
|
||||
let transport = div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
button(
|
||||
"play",
|
||||
ButtonStyle::Secondary,
|
||||
Some(if editor.playing { "pause" } else { "play" }),
|
||||
None,
|
||||
)
|
||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.toggle_playback();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"previous-bookmark",
|
||||
ButtonStyle::Ghost,
|
||||
Some("skip-back"),
|
||||
None,
|
||||
)
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
if let Some(at) = previous_bookmark {
|
||||
app.seek_playback(at);
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"next-bookmark",
|
||||
ButtonStyle::Ghost,
|
||||
Some("skip-forward"),
|
||||
None,
|
||||
)
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
if let Some(at) = next_bookmark {
|
||||
app.seek_playback(at);
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_1p5()
|
||||
.ml_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(theme::foreground())
|
||||
.child(format_clock(editor.playhead)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(theme::faint())
|
||||
.child(format!("/ {}", format_clock(length))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
button(
|
||||
"mute",
|
||||
ButtonStyle::Ghost,
|
||||
Some(if app.playback.muted {
|
||||
"volume-x"
|
||||
} else {
|
||||
"volume-2"
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||
app.playback.muted = !app.playback.muted;
|
||||
app.playback
|
||||
.player
|
||||
.audio(app.playback.muted, app.playback.volume);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
button("volume-down", ButtonStyle::Ghost, None, Some("−")).on_click(cx.listener(
|
||||
|app, _: &ClickEvent, _, cx| {
|
||||
app.playback.volume = (app.playback.volume - 0.1).max(0.);
|
||||
app.playback
|
||||
.player
|
||||
.audio(app.playback.muted, app.playback.volume);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.child(format!("{:.0}%", app.playback.volume * 100.)),
|
||||
)
|
||||
.child(
|
||||
button("volume-up", ButtonStyle::Ghost, None, Some("+")).on_click(cx.listener(
|
||||
|app, _: &ClickEvent, _, cx| {
|
||||
app.playback.volume = (app.playback.volume + 0.1).min(1.);
|
||||
app.playback
|
||||
.player
|
||||
.audio(app.playback.muted, app.playback.volume);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.child(legend(theme::footage().into(), "Footage"))
|
||||
.child(legend(theme::warning().into(), "Bookmark"))
|
||||
.child(legend(theme::primary().into(), "Clip"))
|
||||
.child(legend(theme::destructive_alpha(0.6), "Missing"))
|
||||
.child(legend(theme::faint().into(), "Removed")),
|
||||
);
|
||||
|
||||
let left = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_3()
|
||||
.child(preview)
|
||||
.child(transport)
|
||||
.children(
|
||||
app.playback
|
||||
.audio_error
|
||||
.clone()
|
||||
.map(|error| div().text_xs().text_color(theme::warning()).child(error)),
|
||||
)
|
||||
.child(timeline(app, session, cx));
|
||||
|
||||
let (export_start, export_end) = app
|
||||
.service
|
||||
.export_range(id, start, end)
|
||||
.unwrap_or_else(|| export_range(start, end, KEYFRAME_INTERVAL, length));
|
||||
let clip_panel = panel("CLIP")
|
||||
.child(row("Start", value(format_clock(start))))
|
||||
.child(row("End", value(format_clock(end))))
|
||||
.child(row(
|
||||
"Length",
|
||||
value(format_length(end.saturating_sub(start))),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_2p5()
|
||||
.rounded_md()
|
||||
.bg(theme::surface())
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_color(theme::muted())
|
||||
.child("Exported on keyframes"),
|
||||
)
|
||||
.child(div().text_color(theme::text()).child(format!(
|
||||
"{} – {} · {}",
|
||||
format_clock(export_start),
|
||||
format_clock(export_end),
|
||||
format_length(export_end.saturating_sub(export_start))
|
||||
))),
|
||||
)
|
||||
.child(row(
|
||||
"Audio",
|
||||
value(if app.settings.export_separate_audio {
|
||||
"System + mic, if recorded".to_owned()
|
||||
} else {
|
||||
"Combined source audio".to_owned()
|
||||
}),
|
||||
))
|
||||
.child(
|
||||
div().flex().gap_2().pt_1().child(
|
||||
button(
|
||||
"export",
|
||||
ButtonStyle::Primary,
|
||||
Some("scissors"),
|
||||
Some("Export clip"),
|
||||
)
|
||||
.flex_1()
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
app.service.export(id, start, end);
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
let bookmarks_panel = panel(format!("BOOKMARKS · {}", session.bookmarks.len())).children(
|
||||
if session.bookmarks.is_empty() {
|
||||
vec![
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.child("Press the bookmark hotkey while playing to keep a moment.")
|
||||
.into_any_element(),
|
||||
]
|
||||
} else {
|
||||
session
|
||||
.bookmarks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, bookmark)| {
|
||||
let range = bookmark.range(length);
|
||||
let selected = editor.selection == range;
|
||||
let at = bookmark.at;
|
||||
div()
|
||||
.id(("bookmark", index))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2p5()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded_md()
|
||||
.cursor_pointer()
|
||||
.when(selected, |item| item.bg(theme::primary_alpha(0.15)))
|
||||
.hover(|style| style.bg(theme::hover()))
|
||||
.child(icon("bookmark").size_3p5().text_color(theme::warning()))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_sm()
|
||||
.text_color(theme::text())
|
||||
.child(format_clock(bookmark.at)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(if bookmark.protected {
|
||||
theme::success()
|
||||
} else {
|
||||
theme::subtle()
|
||||
})
|
||||
.child(if bookmark.protected {
|
||||
"Kept"
|
||||
} else {
|
||||
"Released"
|
||||
}),
|
||||
)
|
||||
.when(bookmark.protected, |row| {
|
||||
row.child(
|
||||
button(
|
||||
("release-bookmark", index),
|
||||
ButtonStyle::Ghost,
|
||||
None,
|
||||
Some("Release"),
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
move |app, _: &ClickEvent, _, cx| {
|
||||
app.service.release(id, index);
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
})
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
app.seek_playback(at);
|
||||
app.editor.selection = range;
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
);
|
||||
|
||||
let session_panel = panel("SESSION")
|
||||
.child(row("Length", value(format_length(length))))
|
||||
.child(row(
|
||||
"Not captured",
|
||||
value(match session.missing_time() {
|
||||
Duration::ZERO => "None".to_owned(),
|
||||
missing => format_length(missing),
|
||||
}),
|
||||
))
|
||||
.child(row(
|
||||
"Cleaned up",
|
||||
value(match session.removed_time() {
|
||||
Duration::ZERO => "None".to_owned(),
|
||||
removed => format_length(removed),
|
||||
}),
|
||||
))
|
||||
.child(row(
|
||||
"Kept by bookmarks",
|
||||
value(format!(
|
||||
"{} of {}",
|
||||
session.protected_bookmarks(),
|
||||
session.bookmarks.len()
|
||||
)),
|
||||
));
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_5()
|
||||
.child(header)
|
||||
.child(
|
||||
div().flex().items_start().gap_6().child(left).child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_shrink_0()
|
||||
.w(px(300.))
|
||||
.gap_4()
|
||||
.child(clip_panel)
|
||||
.child(bookmarks_panel)
|
||||
.child(session_panel),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn value(text: String) -> impl IntoElement {
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(theme::foreground())
|
||||
.child(text)
|
||||
}
|
||||
|
||||
fn legend(colour: Hsla, label: &'static str) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(div().size_2().rounded_sm().bg(colour))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
/// Places an element across the timeline from `from` to `to`, clipped to the
|
||||
/// visible view. `None` when it is entirely out of view.
|
||||
fn placed(view: &TimelineView, from: Duration, to: Duration) -> Option<(f32, f32)> {
|
||||
let left = view.position(from).max(0.);
|
||||
let right = view.position(to).min(1.);
|
||||
(right > left).then_some((left, right - left))
|
||||
}
|
||||
|
||||
fn position_in(position: Point<Pixels>, bounds: Bounds<Pixels>) -> f32 {
|
||||
(position.x - bounds.left()) / bounds.size.width
|
||||
}
|
||||
|
||||
/// The session timeline: footage and gaps, protected bookmark ranges, the
|
||||
/// clip selection with its handles, and the playhead. Click to seek; drag the
|
||||
/// selection's edges to change the clip; scroll to zoom around the pointer,
|
||||
/// and scroll sideways (or hold Shift) to pan.
|
||||
fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -> impl IntoElement {
|
||||
let length = session.length;
|
||||
let view = app.editor.view;
|
||||
let (start, end) = app.editor.selection;
|
||||
let bounds = app.timeline_bounds.clone();
|
||||
let at = |time: Duration| relative(view.position(time));
|
||||
|
||||
let spans = session.spans.iter().filter_map(|span| {
|
||||
let (left, width) = placed(&view, span.start, span.end)?;
|
||||
let piece = div()
|
||||
.absolute()
|
||||
.top(px(30.))
|
||||
.h(px(8.))
|
||||
.left(relative(left))
|
||||
.w(relative(width));
|
||||
Some(match span.kind {
|
||||
SpanKind::Footage => piece.bg(theme::footage()),
|
||||
SpanKind::Missing => piece.bg(pattern_slash(theme::destructive_alpha(0.7), 2., 4.)),
|
||||
SpanKind::Removed => piece.bg(pattern_slash(Hsla::from(theme::faint()), 2., 4.)),
|
||||
})
|
||||
});
|
||||
|
||||
let protected = session
|
||||
.bookmarks
|
||||
.iter()
|
||||
.filter(|b| b.protected)
|
||||
.filter_map(|bookmark| {
|
||||
let (from, to) = bookmark.range(length);
|
||||
let (left, width) = placed(&view, from, to)?;
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(26.))
|
||||
.h_4()
|
||||
.left(relative(left))
|
||||
.w(relative(width))
|
||||
.rounded_sm()
|
||||
.bg(Hsla::from(theme::warning()).opacity(0.12)),
|
||||
)
|
||||
});
|
||||
|
||||
let markers = session
|
||||
.bookmarks
|
||||
.iter()
|
||||
.filter(|bookmark| (0. ..=1.).contains(&view.position(bookmark.at)))
|
||||
.map(|bookmark| {
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(4.))
|
||||
.left(at(bookmark.at))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.ml(px(-6.))
|
||||
.child(icon("bookmark").size_3().text_color(theme::warning()))
|
||||
.child(
|
||||
div()
|
||||
.w(px(1.))
|
||||
.h(px(34.))
|
||||
.bg(Hsla::from(theme::warning()).opacity(0.6)),
|
||||
)
|
||||
});
|
||||
|
||||
let handle = || div().w(px(4.)).h_5().rounded_sm().bg(theme::foreground());
|
||||
let selection = div()
|
||||
.absolute()
|
||||
.top(px(22.))
|
||||
.h_6()
|
||||
.left(at(start))
|
||||
.w(relative(view.position(end) - view.position(start)))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(theme::primary())
|
||||
.bg(theme::primary_alpha(0.35))
|
||||
.child(handle().ml(px(-2.)))
|
||||
.child(handle().mr(px(-2.)));
|
||||
|
||||
let playhead = div()
|
||||
.absolute()
|
||||
.top(px(14.))
|
||||
.bottom(px(10.))
|
||||
.left(at(app.editor.playhead))
|
||||
.w(px(2.))
|
||||
.ml(px(-1.))
|
||||
.rounded_full()
|
||||
.bg(theme::foreground());
|
||||
|
||||
let ticks = (0..=4).map(|quarter| {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::faint())
|
||||
.child(format_clock(view.time_at(quarter as f32 / 4.)))
|
||||
});
|
||||
|
||||
let on_down_bounds = bounds.clone();
|
||||
let on_move_bounds = bounds.clone();
|
||||
let on_scroll_bounds = bounds.clone();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id("timeline")
|
||||
.relative()
|
||||
.h(px(64.))
|
||||
.px_3()
|
||||
.rounded_lg()
|
||||
.bg(theme::surface())
|
||||
.border_1()
|
||||
.border_color(theme::border())
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
canvas(
|
||||
move |area, _, _| {
|
||||
if bounds.get() != Some(area)
|
||||
&& std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some()
|
||||
{
|
||||
eprintln!(
|
||||
"playback_timeline x={} y={} width={} height={}",
|
||||
f32::from(area.left()),
|
||||
f32::from(area.top()),
|
||||
f32::from(area.size.width),
|
||||
f32::from(area.size.height)
|
||||
);
|
||||
}
|
||||
bounds.set(Some(area));
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(30.))
|
||||
.h(px(8.))
|
||||
.w_full()
|
||||
.rounded_full()
|
||||
.bg(theme::raised()),
|
||||
)
|
||||
.children(spans)
|
||||
.children(protected)
|
||||
.child(selection)
|
||||
.children(markers)
|
||||
.child(playhead),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |app, event: &MouseDownEvent, _, cx| {
|
||||
let Some(area) = on_down_bounds.get() else {
|
||||
return;
|
||||
};
|
||||
let view = app.editor.view;
|
||||
let time = view.time_at(position_in(event.position, area));
|
||||
let (start, end) = app.editor.selection;
|
||||
let reach = (view.end() - view.start())
|
||||
.mul_f32(HANDLE_REACH / f32::from(area.size.width));
|
||||
let near = |edge: Duration| time.abs_diff(edge) <= reach;
|
||||
app.drag = Some(if near(start) {
|
||||
Drag::SelectionStart
|
||||
} else if near(end) {
|
||||
Drag::SelectionEnd
|
||||
} else {
|
||||
app.seek_playback(time);
|
||||
Drag::Playhead
|
||||
});
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(cx.listener(move |app, event: &MouseMoveEvent, _, cx| {
|
||||
let (Some(drag), Some(area)) = (app.drag, on_move_bounds.get()) else {
|
||||
return;
|
||||
};
|
||||
if event.pressed_button != Some(MouseButton::Left) {
|
||||
app.drag = None;
|
||||
return;
|
||||
}
|
||||
let time = app.editor.view.time_at(position_in(event.position, area));
|
||||
let (start, end) = app.editor.selection;
|
||||
let minimum = Duration::from_secs(1);
|
||||
match drag {
|
||||
Drag::Playhead => app.seek_playback(time),
|
||||
Drag::SelectionStart => {
|
||||
app.editor.selection.0 = time.min(end.saturating_sub(minimum))
|
||||
}
|
||||
Drag::SelectionEnd => {
|
||||
app.editor.selection.1 = time.max(start + minimum).min(length)
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|app, _: &MouseUpEvent, _, cx| {
|
||||
app.drag = None;
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.on_scroll_wheel(cx.listener(move |app, event: &ScrollWheelEvent, _, cx| {
|
||||
let Some(area) = on_scroll_bounds.get() else {
|
||||
return;
|
||||
};
|
||||
let delta = event.delta.pixel_delta(px(SCROLL_LINE));
|
||||
let width = f32::from(area.size.width);
|
||||
let (dx, dy) = (f32::from(delta.x), f32::from(delta.y));
|
||||
if event.modifiers.shift || dx.abs() > dy.abs() {
|
||||
let sideways = if dx.abs() > dy.abs() { dx } else { dy };
|
||||
app.editor.view.pan_by(-sideways / width);
|
||||
} else {
|
||||
// Scrolling up (positive) zooms in around the pointer.
|
||||
app.editor.view.zoom_by(
|
||||
(-dy * ZOOM_PER_PIXEL).exp(),
|
||||
position_in(event.position, area),
|
||||
);
|
||||
}
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.child(div().flex_1().flex().justify_between().children(ticks))
|
||||
.when(view.is_zoomed(), |row| {
|
||||
row.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.child(format!("{:.1}×", view.zoom()))
|
||||
.child(
|
||||
div()
|
||||
.id("zoom-fit")
|
||||
.px_1p5()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.text_color(theme::primary_light())
|
||||
.hover(|style| style.bg(theme::hover()))
|
||||
.child("Fit")
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
app.editor.view = TimelineView::full(length);
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!view.is_zoomed(), |row| {
|
||||
row.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::faint())
|
||||
.child("Scroll to zoom"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
pub mod clips;
|
||||
pub mod editor;
|
||||
pub mod sessions;
|
||||
pub mod settings;
|
||||
|
||||
use gpui::{Div, FontWeight, Hsla, div, hsla, linear_color_stop, linear_gradient, prelude::*};
|
||||
|
||||
use crate::{components::aspect_ratio, theme};
|
||||
|
||||
/// A 16:9 thumbnail. Until frames are decoded, each game gets its own colour
|
||||
/// with its initials, so cards stay recognisable.
|
||||
pub fn thumbnail(game: &str, path: Option<&std::path::PathBuf>) -> Div {
|
||||
if let Some(path) = path {
|
||||
return aspect_ratio(div(), 16. / 9.)
|
||||
.relative()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.rounded_xl()
|
||||
.child(
|
||||
gpui::img(path.clone())
|
||||
.absolute()
|
||||
.size_full()
|
||||
.object_fit(gpui::ObjectFit::Cover),
|
||||
);
|
||||
}
|
||||
let tint = game_tint(game);
|
||||
let initials: String = game
|
||||
.split_whitespace()
|
||||
.filter_map(|word| word.chars().next())
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.take(2)
|
||||
.collect();
|
||||
aspect_ratio(backdrop(game), 16. / 9.)
|
||||
.relative()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.rounded_xl()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_3xl()
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.text_color(tint(0.75, 0.22))
|
||||
.child(initials),
|
||||
)
|
||||
}
|
||||
|
||||
/// The game's colour as a gradient fill.
|
||||
pub fn backdrop(game: &str) -> Div {
|
||||
let tint = game_tint(game);
|
||||
div().bg(linear_gradient(
|
||||
135.,
|
||||
linear_color_stop(tint(0.22, 1.), 0.),
|
||||
linear_color_stop(tint(0.07, 1.), 1.),
|
||||
))
|
||||
}
|
||||
|
||||
fn game_tint(game: &str) -> impl Fn(f32, f32) -> Hsla {
|
||||
let hue = game
|
||||
.bytes()
|
||||
.fold(7u32, |hash, byte| hash.wrapping_mul(31) + byte as u32)
|
||||
% 360;
|
||||
move |lightness, alpha| hsla(hue as f32 / 360., 0.45, lightness, alpha)
|
||||
}
|
||||
|
||||
/// Pads a thumbnail's overlay chips into its corners.
|
||||
pub fn corner() -> Div {
|
||||
div().absolute().flex().gap_1p5()
|
||||
}
|
||||
|
||||
pub fn muted_line(text: String) -> Div {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.truncate()
|
||||
.child(text)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! The session library: every recorded session, newest first.
|
||||
|
||||
use clipbin_core::library::{format_ago, format_bytes, format_length};
|
||||
use gpui::{AnyElement, ClickEvent, Context, FontWeight, IntoElement, div, prelude::*};
|
||||
|
||||
use crate::{
|
||||
app::{ClipbinApp, Route},
|
||||
components::{chip, count, empty_state, icon, page_header},
|
||||
screens::{corner, muted_line, thumbnail},
|
||||
theme,
|
||||
};
|
||||
|
||||
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
let library = &app.library;
|
||||
let now = app.now();
|
||||
let description = if library.sessions.is_empty() {
|
||||
"Sessions start automatically when you launch a Steam game.".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{} · {} of footage within your {} budget",
|
||||
count(library.sessions.len(), "session"),
|
||||
format_bytes(library.storage.unprotected_bytes),
|
||||
format_bytes(library.storage.budget_bytes)
|
||||
)
|
||||
};
|
||||
let header = page_header("Sessions", description, Vec::new());
|
||||
|
||||
if library.sessions.is_empty() {
|
||||
return div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(header)
|
||||
.child(empty_state(
|
||||
"gamepad-2",
|
||||
"No sessions yet",
|
||||
"Launch a Steam game and Clipbin records it in the background. Sessions and bookmarked moments appear here.",
|
||||
))
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let cards =
|
||||
library.sessions.iter().map(|session| {
|
||||
let id = session.id;
|
||||
let bookmarks = session.bookmarks.len();
|
||||
let detail = format!(
|
||||
"{} · {}{}",
|
||||
format_ago(session.started, now),
|
||||
format_length(session.length),
|
||||
match bookmarks {
|
||||
0 => String::new(),
|
||||
1 => " · 1 bookmark".into(),
|
||||
count => format!(" · {count} bookmarks"),
|
||||
}
|
||||
);
|
||||
div()
|
||||
.id(("session", id))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.cursor_pointer()
|
||||
.group("card")
|
||||
.child(
|
||||
thumbnail(&session.game, app.playback.thumbnails.get(&session.id))
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.group_hover("card", |style| {
|
||||
style.border_color(theme::primary_alpha(0.6))
|
||||
})
|
||||
.when(session.active, |thumb| {
|
||||
thumb.child(
|
||||
corner().top_2p5().left_2p5().child(
|
||||
chip("REC").bg(theme::destructive()).child(
|
||||
div().size_1p5().rounded_full().bg(theme::foreground()),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
corner()
|
||||
.bottom_2p5()
|
||||
.right_2p5()
|
||||
.when(bookmarks > 0, |corner| {
|
||||
corner.child(chip(bookmarks.to_string()).child(
|
||||
icon("bookmark").size_3().text_color(theme::warning()),
|
||||
))
|
||||
})
|
||||
.child(chip(format_length(session.length))),
|
||||
)
|
||||
.child(
|
||||
corner()
|
||||
.bottom_2p5()
|
||||
.left_2p5()
|
||||
.children(gap_chip(session.missing_time(), "missing"))
|
||||
.children(gap_chip(session.removed_time(), "cleaned up")),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.px_1()
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::foreground())
|
||||
.truncate()
|
||||
.child(session.game.clone()),
|
||||
)
|
||||
.child(muted_line(detail)),
|
||||
)
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
app.navigate(Route::Session(id), cx)
|
||||
}))
|
||||
});
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(header)
|
||||
.child(
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(3)
|
||||
.gap_x_4()
|
||||
.gap_y_8()
|
||||
.children(cards),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn gap_chip(time: std::time::Duration, what: &str) -> Option<gpui::Div> {
|
||||
(time > std::time::Duration::ZERO)
|
||||
.then(|| chip(format!("{} {what}", format_length(time))).text_color(theme::muted()))
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Recording, audio, clip and storage settings. Changes apply to this run;
|
||||
//! saving them comes with settings persistence.
|
||||
|
||||
use clipbin_core::{Settings, VideoCodec, library::format_bytes};
|
||||
use gpui::{
|
||||
AnyElement, ClickEvent, Context, Div, ElementId, Entity, FontWeight, IntoElement, SharedString,
|
||||
div, prelude::*, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::ClipbinApp,
|
||||
components::{icon, page_header},
|
||||
text_field::TextField,
|
||||
theme,
|
||||
};
|
||||
|
||||
type Update = fn(&mut Settings);
|
||||
|
||||
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
||||
let settings = &app.settings;
|
||||
|
||||
let resolution = segmented(
|
||||
"resolution",
|
||||
cx,
|
||||
&[
|
||||
(
|
||||
"Match window",
|
||||
settings.resolution.is_none(),
|
||||
|s: &mut Settings| s.resolution = None,
|
||||
),
|
||||
("1080p", settings.resolution == Some((1920, 1080)), |s| {
|
||||
s.resolution = Some((1920, 1080))
|
||||
}),
|
||||
("1440p", settings.resolution == Some((2560, 1440)), |s| {
|
||||
s.resolution = Some((2560, 1440))
|
||||
}),
|
||||
],
|
||||
);
|
||||
let frame_rate = segmented(
|
||||
"fps",
|
||||
cx,
|
||||
&[
|
||||
(
|
||||
"30",
|
||||
settings.frames_per_second == 30,
|
||||
|s: &mut Settings| s.frames_per_second = 30,
|
||||
),
|
||||
("60", settings.frames_per_second == 60, |s| {
|
||||
s.frames_per_second = 60
|
||||
}),
|
||||
("120", settings.frames_per_second == 120, |s| {
|
||||
s.frames_per_second = 120
|
||||
}),
|
||||
],
|
||||
);
|
||||
let codec = segmented(
|
||||
"codec",
|
||||
cx,
|
||||
&[
|
||||
(
|
||||
"H.264",
|
||||
settings.codec == VideoCodec::H264,
|
||||
|s: &mut Settings| s.codec = VideoCodec::H264,
|
||||
),
|
||||
("HEVC", settings.codec == VideoCodec::Hevc, |s| {
|
||||
s.codec = VideoCodec::Hevc
|
||||
}),
|
||||
("AV1", settings.codec == VideoCodec::Av1, |s| {
|
||||
s.codec = VideoCodec::Av1
|
||||
}),
|
||||
],
|
||||
);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.max_w(px(820.))
|
||||
.child(page_header(
|
||||
"Settings",
|
||||
"Settings are saved automatically. Recording changes apply to the next capture run.",
|
||||
Vec::new(),
|
||||
))
|
||||
.child(
|
||||
section("monitor", "Recording")
|
||||
.child(setting("Resolution", "Match window records at the game's own size and orientation.", resolution))
|
||||
.child(setting("Frame rate", "Frames per second.", frame_rate))
|
||||
.child(setting("Video codec", "Hardware encoding only. Unsupported codecs are disabled once encoders are checked.", codec))
|
||||
.child(setting(
|
||||
"Start on login",
|
||||
"Record games without opening Clipbin first.",
|
||||
toggle("login", settings.start_on_login, |s| s.start_on_login = !s.start_on_login, cx),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
section("volume-2", "Audio")
|
||||
.child(setting("Export separate tracks", "Keep system and microphone tracks for editing; otherwise copy the combined track.",toggle("export-audio", settings.export_separate_audio, |s| s.export_separate_audio = !s.export_separate_audio,cx)))
|
||||
.child(setting(
|
||||
"Game audio",
|
||||
"Everything your speakers play.",
|
||||
toggle("system-audio", settings.system_audio_enabled, |s| s.system_audio_enabled = !s.system_audio_enabled, cx),
|
||||
))
|
||||
.child(setting(
|
||||
"Microphone",
|
||||
"Recorded on its own track, plus a mixed track for playback.",
|
||||
toggle("microphone", settings.microphone_enabled, |s| s.microphone_enabled = !s.microphone_enabled, cx),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
section("bookmark", "Clips and bookmarks")
|
||||
.child(setting(
|
||||
"Clip hotkey saves",
|
||||
"How far back the clip hotkey reaches, as m:ss. Between 0:05 and 30:00.",
|
||||
field(&app.fields.clip, "back"),
|
||||
))
|
||||
.child(setting(
|
||||
"Bookmarks keep",
|
||||
"Footage protected around each bookmark until you release it, as m:ss.",
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(labelled("Before", &app.fields.bookmark_before))
|
||||
.child(labelled("After", &app.fields.bookmark_after)),
|
||||
))
|
||||
.child(setting("Clip binding", "Ctrl+Shift plus this function key. Changes apply immediately.", segmented("clip-binding",cx,&[("F9",settings.clip_hotkey==9,|s|s.clip_hotkey=9),("F11",settings.clip_hotkey==11,|s|s.clip_hotkey=11)])))
|
||||
.child(setting("Bookmark binding", "Ctrl+Shift plus this function key. Portal desktops may override it.", segmented("bookmark-binding",cx,&[("F10",settings.bookmark_hotkey==10,|s|s.bookmark_hotkey=10),("F12",settings.bookmark_hotkey==12,|s|s.bookmark_hotkey=12)])))
|
||||
.child(setting("Hotkeys", "Ctrl+Shift+F9: clip · Ctrl+Shift+F10: bookmark. Wayland's portal handles approval and rebinding.", value(app.hotkeys.clone()))),
|
||||
)
|
||||
.child(
|
||||
section("hard-drive", "Storage")
|
||||
.child(setting(
|
||||
"Session footage budget",
|
||||
"Oldest unprotected footage is removed beyond this. Bookmarks and clips don't count.",
|
||||
field(&app.fields.budget, "GB"),
|
||||
))
|
||||
.child(setting(
|
||||
"In use",
|
||||
"Session footage, then footage kept by bookmarks and clips.",
|
||||
value(format!(
|
||||
"{} · {} kept",
|
||||
format_bytes(app.library.storage.unprotected_bytes),
|
||||
format_bytes(app.library.storage.protected_bytes + app.library.storage.clip_bytes)
|
||||
)),
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(icon_name: &str, title: &'static str) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme::border())
|
||||
.bg(theme::card())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_5()
|
||||
.py_3()
|
||||
.child(icon(icon_name).text_color(theme::primary_light()))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme::foreground())
|
||||
.child(title),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn setting(title: &'static str, description: &'static str, control: impl IntoElement) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_6()
|
||||
.px_5()
|
||||
.py_3p5()
|
||||
.border_t_1()
|
||||
.border_color(theme::border())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(div().text_sm().text_color(theme::text()).child(title))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::subtle())
|
||||
.child(description),
|
||||
),
|
||||
)
|
||||
.child(control)
|
||||
}
|
||||
|
||||
/// A text field with a unit or label after it.
|
||||
fn field(field: &Entity<TextField>, suffix: &'static str) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(field.clone())
|
||||
.child(div().text_xs().text_color(theme::subtle()).child(suffix))
|
||||
}
|
||||
|
||||
/// A text field with a label before it.
|
||||
fn labelled(label: &'static str, field: &Entity<TextField>) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().text_xs().text_color(theme::subtle()).child(label))
|
||||
.child(field.clone())
|
||||
}
|
||||
|
||||
fn value(text: String) -> Div {
|
||||
div().text_sm().text_color(theme::muted()).child(text)
|
||||
}
|
||||
|
||||
fn segmented(
|
||||
id: &'static str,
|
||||
cx: &mut Context<ClipbinApp>,
|
||||
options: &[(&'static str, bool, Update)],
|
||||
) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.p_0p5()
|
||||
.rounded_md()
|
||||
.bg(theme::surface())
|
||||
.border_1()
|
||||
.border_color(theme::border())
|
||||
.children(
|
||||
options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (label, selected, update))| {
|
||||
let update = *update;
|
||||
div()
|
||||
.id(ElementId::Name(SharedString::from(format!("{id}-{index}"))))
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.text_xs()
|
||||
.cursor_pointer()
|
||||
.when(*selected, |option| {
|
||||
option
|
||||
.bg(theme::primary())
|
||||
.text_color(theme::foreground())
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
})
|
||||
.when(!*selected, |option| {
|
||||
option
|
||||
.text_color(theme::muted())
|
||||
.hover(|style| style.text_color(theme::foreground()))
|
||||
})
|
||||
.child(*label)
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
update(&mut app.settings);
|
||||
app.save_settings();
|
||||
cx.notify();
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle(
|
||||
id: &'static str,
|
||||
on: bool,
|
||||
update: Update,
|
||||
cx: &mut Context<ClipbinApp>,
|
||||
) -> impl IntoElement {
|
||||
div()
|
||||
.id(id)
|
||||
.flex()
|
||||
.items_center()
|
||||
.w(px(36.))
|
||||
.h(px(20.))
|
||||
.p(px(2.))
|
||||
.rounded_full()
|
||||
.cursor_pointer()
|
||||
.bg(if on {
|
||||
theme::primary()
|
||||
} else {
|
||||
theme::raised()
|
||||
})
|
||||
.when(on, |track| track.justify_end())
|
||||
.child(div().size_4().rounded_full().bg(theme::foreground()))
|
||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||
update(&mut app.settings);
|
||||
app.save_settings();
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Cleanup runs only on the recorder, where requests and the current file are known.
|
||||
use crate::exports::Exports;
|
||||
use clipbin_core::{
|
||||
cleanup::{Candidate, select},
|
||||
store::{Metadata, Store},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
pub type Editing = Option<(u64, Duration, Duration)>;
|
||||
pub fn ranges(exports: &Exports, editing: Editing) -> Vec<(u64, Duration, Duration)> {
|
||||
let mut ranges: Vec<_> = exports
|
||||
.requests
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.session,
|
||||
r.start.saturating_sub(Duration::from_secs(1)),
|
||||
r.end + Duration::from_secs(1),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if let Some((id, start, end)) = editing {
|
||||
ranges.push((
|
||||
id,
|
||||
start.saturating_sub(Duration::from_secs(1)),
|
||||
end + Duration::from_secs(1),
|
||||
));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
pub fn cleanup(
|
||||
store: &Store,
|
||||
budget: u64,
|
||||
exports: &Exports,
|
||||
editing: Editing,
|
||||
) -> Result<Vec<Metadata>, String> {
|
||||
index_legacy_audio(store)?;
|
||||
let mut metadata = store.read().map_err(|e| e.to_string())?;
|
||||
let mut candidates = Vec::new();
|
||||
let all_ranges = ranges(exports, editing);
|
||||
for m in &metadata {
|
||||
let ranges: Vec<_> = all_ranges
|
||||
.iter()
|
||||
.filter(|(id, _, _)| *id == m.session.id)
|
||||
.map(|(_, start, end)| (*start, *end))
|
||||
.collect();
|
||||
for (index, s) in m.segments.iter().enumerate().filter(|(_, s)| s.available) {
|
||||
let protected = clipbin_core::cleanup::protected(s, &m.session.bookmarks, &ranges);
|
||||
candidates.push(Candidate {
|
||||
session: m.session.id,
|
||||
index,
|
||||
started: m.session.started,
|
||||
active: m.session.active,
|
||||
start: s.start,
|
||||
bytes: std::fs::metadata(store.directory(m.session.id).join(&s.path))
|
||||
.map_or(0, |f| f.len()),
|
||||
protected,
|
||||
complete: s.complete,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (id, index) in select(&candidates, budget) {
|
||||
let m = metadata.iter_mut().find(|m| m.session.id == id).unwrap();
|
||||
let path = store.directory(id).join(&m.segments[index].path);
|
||||
// Persist unavailability first: a crash must never advertise a deleted file.
|
||||
let previous = m.clone();
|
||||
m.removed(index);
|
||||
store.save(m).map_err(|e| e.to_string())?;
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
*m = previous;
|
||||
store.save(m).map_err(|e| e.to_string())?;
|
||||
return Err(format!("Could not clean {}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Upgrade old sessions before cleanup can discard the files needed to recover
|
||||
/// their separate audio clocks. New recordings index one closed file per split.
|
||||
pub fn index_legacy_audio(store: &Store) -> Result<(), String> {
|
||||
for mut m in store.read().map_err(|e| e.to_string())? {
|
||||
if !m
|
||||
.segments
|
||||
.iter()
|
||||
.any(|s| s.complete && s.available && s.audio_start_samples.is_empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut totals = Vec::new();
|
||||
for s in &mut m.segments {
|
||||
if s.frame_start == 0 {
|
||||
totals.clear();
|
||||
}
|
||||
if !s.audio_start_samples.is_empty() {
|
||||
totals = s.audio_start_samples.clone();
|
||||
}
|
||||
if !s.complete {
|
||||
if !totals.is_empty() {
|
||||
s.audio_start_samples = totals.clone();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !s.available {
|
||||
totals.clear();
|
||||
continue;
|
||||
}
|
||||
let samples =
|
||||
clipbin_media::export::audio_samples(&store.directory(m.session.id).join(&s.path))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if totals.is_empty() {
|
||||
if s.frame_start != 0 {
|
||||
return Err("Earlier audio clock data is missing".into());
|
||||
}
|
||||
totals = vec![0; samples.len()];
|
||||
}
|
||||
s.audio_start_samples = totals.clone();
|
||||
for (total, count) in totals.iter_mut().zip(samples) {
|
||||
*total += count;
|
||||
}
|
||||
}
|
||||
store.save(&m).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! A single-line text field for short values such as sizes and durations.
|
||||
//!
|
||||
//! Adapted from GPUI's `examples/input.rs` (Apache-2.0), restricted to the
|
||||
//! characters a field accepts so cursor movement can work on bytes.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ClipboardItem, Context, CursorStyle, ElementId, ElementInputHandler, Entity,
|
||||
EntityInputHandler, EventEmitter, FocusHandle, Focusable, GlobalElementId, KeyBinding,
|
||||
LayoutId, MouseButton, MouseDownEvent, PaintQuad, Pixels, Point, ShapedLine, SharedString,
|
||||
Style, TextRun, UTF16Selection, Window, actions, div, fill, point, prelude::*, px, relative,
|
||||
size,
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
actions!(
|
||||
text_field,
|
||||
[
|
||||
Backspace, Delete, Left, Right, SelectAll, Home, End, Paste, Copy, Cut, Commit, Cancel
|
||||
]
|
||||
);
|
||||
|
||||
const CONTEXT: &str = "TextField";
|
||||
|
||||
pub fn bind_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
|
||||
KeyBinding::new("delete", Delete, Some(CONTEXT)),
|
||||
KeyBinding::new("left", Left, Some(CONTEXT)),
|
||||
KeyBinding::new("right", Right, Some(CONTEXT)),
|
||||
KeyBinding::new("home", Home, Some(CONTEXT)),
|
||||
KeyBinding::new("end", End, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-a", SelectAll, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-v", Paste, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-c", Copy, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-x", Cut, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Commit, Some(CONTEXT)),
|
||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Sent when the user confirms a value with Enter or by leaving the field.
|
||||
pub struct Committed(pub String);
|
||||
|
||||
pub struct TextField {
|
||||
focus_handle: FocusHandle,
|
||||
content: String,
|
||||
/// The value shown when not editing; Escape and rejected values return to it.
|
||||
value: String,
|
||||
accepts: fn(char) -> bool,
|
||||
selected_range: Range<usize>,
|
||||
last_layout: Option<ShapedLine>,
|
||||
last_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<Committed> for TextField {}
|
||||
|
||||
impl TextField {
|
||||
pub fn new(
|
||||
value: impl Into<String>,
|
||||
accepts: fn(char) -> bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
cx.on_blur(&focus_handle, window, |field, _, cx| {
|
||||
field.commit(&Commit, cx)
|
||||
})
|
||||
.detach();
|
||||
// Focusing a field selects its value, ready to be replaced.
|
||||
cx.on_focus(&focus_handle, window, |field, _, cx| {
|
||||
field.selected_range = 0..field.content.len();
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
let value = value.into();
|
||||
Self {
|
||||
focus_handle,
|
||||
content: value.clone(),
|
||||
value,
|
||||
accepts,
|
||||
selected_range: 0..0,
|
||||
last_layout: None,
|
||||
last_bounds: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a new value, unless the user has unsaved edits in the field.
|
||||
pub fn set_value(&mut self, value: String, window: &Window, cx: &mut Context<Self>) {
|
||||
let editing = self.focus_handle.is_focused(window) && self.content != self.value;
|
||||
if editing || value == self.value {
|
||||
return;
|
||||
}
|
||||
self.content = value.clone();
|
||||
self.value = value;
|
||||
self.selected_range = 0..0;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn commit(&mut self, _: &Commit, cx: &mut Context<Self>) {
|
||||
if self.content != self.value {
|
||||
cx.emit(Committed(self.content.clone()));
|
||||
}
|
||||
// The owner calls set_value with the accepted value; otherwise this
|
||||
// restores the previous one.
|
||||
self.content = self.value.clone();
|
||||
self.selected_range = self.content.len()..self.content.len();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.content = self.value.clone();
|
||||
self.selected_range = 0..0;
|
||||
window.blur();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let offset = match self.selected_range.is_empty() {
|
||||
true => self.selected_range.start.saturating_sub(1),
|
||||
false => self.selected_range.start,
|
||||
};
|
||||
self.move_to(offset, cx);
|
||||
}
|
||||
|
||||
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let offset = match self.selected_range.is_empty() {
|
||||
true => (self.selected_range.end + 1).min(self.content.len()),
|
||||
false => self.selected_range.end,
|
||||
};
|
||||
self.move_to(offset, cx);
|
||||
}
|
||||
|
||||
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(0, cx);
|
||||
}
|
||||
|
||||
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(self.content.len(), cx);
|
||||
}
|
||||
|
||||
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_range = 0..self.content.len();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_range.is_empty() {
|
||||
self.selected_range.start = self.selected_range.start.saturating_sub(1);
|
||||
}
|
||||
self.replace_text_in_range(None, "", window, cx);
|
||||
}
|
||||
|
||||
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_range.is_empty() {
|
||||
self.selected_range.end = (self.selected_range.end + 1).min(self.content.len());
|
||||
}
|
||||
self.replace_text_in_range(None, "", window, cx);
|
||||
}
|
||||
|
||||
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||
self.replace_text_in_range(None, &text, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.selected_range.is_empty() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
self.content[self.selected_range.clone()].to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copy(&Copy, window, cx);
|
||||
self.replace_text_in_range(None, "", window, cx);
|
||||
}
|
||||
|
||||
fn on_mouse_down(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.focus_handle.is_focused(window) {
|
||||
self.move_to(self.index_for_position(event.position), cx);
|
||||
} else {
|
||||
window.focus(&self.focus_handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||
self.selected_range = offset..offset;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn index_for_position(&self, position: Point<Pixels>) -> usize {
|
||||
match (&self.last_bounds, &self.last_layout) {
|
||||
(Some(bounds), Some(line)) => line.closest_index_for_x(position.x - bounds.left()),
|
||||
_ => self.content.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every accepted character is ASCII, so UTF-16 and byte offsets coincide.
|
||||
impl EntityInputHandler for TextField {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
actual_range: &mut Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = range.start.min(self.content.len())..range.end.min(self.content.len());
|
||||
actual_range.replace(range.clone());
|
||||
Some(self.content[range].to_owned())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
Some(UTF16Selection {
|
||||
range: self.selected_range.clone(),
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let range = range.unwrap_or(self.selected_range.clone());
|
||||
let range = range.start.min(self.content.len())..range.end.min(self.content.len());
|
||||
let accepted: String = new_text.chars().filter(|c| (self.accepts)(*c)).collect();
|
||||
self.content.replace_range(range.clone(), &accepted);
|
||||
let cursor = range.start + accepted.len();
|
||||
self.selected_range = cursor..cursor;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
_: Option<Range<usize>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.replace_text_in_range(range, new_text, window, cx);
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let line = self.last_layout.as_ref()?;
|
||||
Some(Bounds::from_corners(
|
||||
point(bounds.left() + line.x_for_index(range.start), bounds.top()),
|
||||
point(bounds.left() + line.x_for_index(range.end), bounds.bottom()),
|
||||
))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
point: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let local = self.last_bounds?.localize(&point)?;
|
||||
self.last_layout.as_ref()?.index_for_x(local.x)
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for TextField {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextField {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focused = self.focus_handle.is_focused(window);
|
||||
div()
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.cursor(CursorStyle::IBeam)
|
||||
.on_action(cx.listener(Self::backspace))
|
||||
.on_action(cx.listener(Self::delete))
|
||||
.on_action(cx.listener(Self::left))
|
||||
.on_action(cx.listener(Self::right))
|
||||
.on_action(cx.listener(Self::home))
|
||||
.on_action(cx.listener(Self::end))
|
||||
.on_action(cx.listener(Self::select_all))
|
||||
.on_action(cx.listener(Self::paste))
|
||||
.on_action(cx.listener(Self::copy))
|
||||
.on_action(cx.listener(Self::cut))
|
||||
.on_action(cx.listener(|field, action, _, cx| field.commit(action, cx)))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
|
||||
.w(px(76.))
|
||||
.h_8()
|
||||
.px_2p5()
|
||||
.flex()
|
||||
.items_center()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(if focused {
|
||||
theme::primary()
|
||||
} else {
|
||||
theme::border()
|
||||
})
|
||||
.bg(theme::surface())
|
||||
.text_sm()
|
||||
.text_color(theme::foreground())
|
||||
.child(TextLine { field: cx.entity() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out and paints the field's text, selection and cursor.
|
||||
struct TextLine {
|
||||
field: Entity<TextField>,
|
||||
}
|
||||
|
||||
struct Painted {
|
||||
line: ShapedLine,
|
||||
cursor: Option<PaintQuad>,
|
||||
selection: Option<PaintQuad>,
|
||||
}
|
||||
|
||||
impl IntoElement for TextLine {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TextLine {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Option<Painted>;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, ()) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = window.line_height().into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut (),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Painted> {
|
||||
let field = self.field.read(cx);
|
||||
let style = window.text_style();
|
||||
let text = SharedString::from(field.content.clone());
|
||||
let run = TextRun {
|
||||
len: text.len(),
|
||||
font: style.font(),
|
||||
color: style.color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
let line = window
|
||||
.text_system()
|
||||
.shape_line(text, font_size, &[run], None);
|
||||
let range = field.selected_range.clone();
|
||||
let (cursor, selection) = if range.is_empty() {
|
||||
let x = bounds.left() + line.x_for_index(range.start);
|
||||
(
|
||||
Some(fill(
|
||||
Bounds::new(point(x, bounds.top()), size(px(1.5), bounds.size.height)),
|
||||
theme::primary_light(),
|
||||
)),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
None,
|
||||
Some(fill(
|
||||
Bounds::from_corners(
|
||||
point(bounds.left() + line.x_for_index(range.start), bounds.top()),
|
||||
point(bounds.left() + line.x_for_index(range.end), bounds.bottom()),
|
||||
),
|
||||
theme::primary_alpha(0.4),
|
||||
)),
|
||||
)
|
||||
};
|
||||
Some(Painted {
|
||||
line,
|
||||
cursor,
|
||||
selection,
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut (),
|
||||
painted: &mut Option<Painted>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(painted) = painted.take() else {
|
||||
return;
|
||||
};
|
||||
let focus_handle = self.field.read(cx).focus_handle.clone();
|
||||
window.handle_input(
|
||||
&focus_handle,
|
||||
ElementInputHandler::new(bounds, self.field.clone()),
|
||||
cx,
|
||||
);
|
||||
let focused = focus_handle.is_focused(window);
|
||||
if focused && let Some(selection) = painted.selection {
|
||||
window.paint_quad(selection);
|
||||
}
|
||||
let _ = painted
|
||||
.line
|
||||
.paint(bounds.origin, window.line_height(), window, cx);
|
||||
if focused && let Some(cursor) = painted.cursor {
|
||||
window.paint_quad(cursor);
|
||||
}
|
||||
self.field.update(cx, |field, _| {
|
||||
field.last_layout = Some(painted.line);
|
||||
field.last_bounds = Some(bounds);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Colours and type from clipbin's web interface (resources/css/app.css):
|
||||
//! a zinc dark theme with a purple accent, set entirely in Geist Mono.
|
||||
|
||||
use gpui::{Hsla, Rgba, rgb, rgba};
|
||||
|
||||
pub const FONT: &str = "Geist Mono";
|
||||
|
||||
pub fn background() -> Rgba {
|
||||
rgb(0x09090b)
|
||||
}
|
||||
pub fn sidebar() -> Rgba {
|
||||
rgb(0x0c0c0f)
|
||||
}
|
||||
pub fn card() -> Rgba {
|
||||
rgb(0x0f0f12)
|
||||
}
|
||||
pub fn surface() -> Rgba {
|
||||
rgb(0x18181b)
|
||||
}
|
||||
pub fn raised() -> Rgba {
|
||||
rgb(0x232326)
|
||||
}
|
||||
pub fn border() -> Rgba {
|
||||
rgb(0x27272a)
|
||||
}
|
||||
pub fn footage() -> Rgba {
|
||||
rgb(0x52525b)
|
||||
}
|
||||
pub fn foreground() -> Rgba {
|
||||
rgb(0xfafafa)
|
||||
}
|
||||
pub fn text() -> Rgba {
|
||||
rgb(0xe4e4e7)
|
||||
}
|
||||
pub fn muted() -> Rgba {
|
||||
rgb(0xa1a1aa)
|
||||
}
|
||||
pub fn subtle() -> Rgba {
|
||||
rgb(0x71717a)
|
||||
}
|
||||
pub fn faint() -> Rgba {
|
||||
rgb(0x52525b)
|
||||
}
|
||||
pub fn primary() -> Rgba {
|
||||
rgb(0x7d22d2)
|
||||
}
|
||||
pub fn primary_light() -> Rgba {
|
||||
rgb(0xb57cf0)
|
||||
}
|
||||
pub fn primary_alpha(alpha: f32) -> Hsla {
|
||||
let mut colour: Hsla = primary().into();
|
||||
colour.a = alpha;
|
||||
colour
|
||||
}
|
||||
pub fn destructive() -> Rgba {
|
||||
rgb(0xef4444)
|
||||
}
|
||||
pub fn destructive_alpha(alpha: f32) -> Hsla {
|
||||
let mut colour: Hsla = destructive().into();
|
||||
colour.a = alpha;
|
||||
colour
|
||||
}
|
||||
pub fn warning() -> Rgba {
|
||||
rgb(0xfbbf24)
|
||||
}
|
||||
pub fn success() -> Rgba {
|
||||
rgb(0x4ade80)
|
||||
}
|
||||
pub fn overlay() -> Rgba {
|
||||
rgba(0x000000b3)
|
||||
}
|
||||
pub fn hover() -> Rgba {
|
||||
rgba(0xffffff14)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! The visible part of a session timeline, zoomed and panned with the wheel.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The narrowest window the timeline zooms to.
|
||||
const MIN_SPAN: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimelineView {
|
||||
start: Duration,
|
||||
span: Duration,
|
||||
length: Duration,
|
||||
}
|
||||
|
||||
impl TimelineView {
|
||||
pub fn full(length: Duration) -> Self {
|
||||
Self {
|
||||
start: Duration::ZERO,
|
||||
span: length.max(Duration::from_secs(1)),
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
/// New footage extends a full view without moving a user's zoomed range.
|
||||
pub fn set_length(&mut self, length: Duration) {
|
||||
let full = !self.is_zoomed();
|
||||
self.length = length;
|
||||
if full {
|
||||
self.span = length.max(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self) -> Duration {
|
||||
self.start
|
||||
}
|
||||
|
||||
pub fn end(&self) -> Duration {
|
||||
self.start + self.span
|
||||
}
|
||||
|
||||
/// How many times closer than the whole session the view is.
|
||||
pub fn zoom(&self) -> f32 {
|
||||
self.length.as_secs_f32() / self.span.as_secs_f32()
|
||||
}
|
||||
|
||||
pub fn is_zoomed(&self) -> bool {
|
||||
self.span < self.length
|
||||
}
|
||||
|
||||
/// Where `time` sits across the view: 0 at its left edge, 1 at its right.
|
||||
/// Times outside the view fall outside 0..=1.
|
||||
pub fn position(&self, time: Duration) -> f32 {
|
||||
(time.as_secs_f32() - self.start.as_secs_f32()) / self.span.as_secs_f32()
|
||||
}
|
||||
|
||||
/// The time at a position across the view, kept within the session.
|
||||
pub fn time_at(&self, position: f32) -> Duration {
|
||||
(self.start + self.span.mul_f32(position.clamp(0., 1.))).min(self.length)
|
||||
}
|
||||
|
||||
/// Scales the visible span by `factor` (below 1 zooms in), keeping the
|
||||
/// time under `anchor` (a position across the view) where it is.
|
||||
pub fn zoom_by(&mut self, factor: f32, anchor: f32) {
|
||||
let anchor = anchor.clamp(0., 1.);
|
||||
let anchor_time = self.start.as_secs_f32() + self.span.as_secs_f32() * anchor;
|
||||
let span = (self.span.as_secs_f32() * factor).clamp(
|
||||
MIN_SPAN.min(self.length).as_secs_f32(),
|
||||
self.length.as_secs_f32(),
|
||||
);
|
||||
self.span = Duration::from_secs_f32(span);
|
||||
self.set_start(anchor_time - span * anchor);
|
||||
}
|
||||
|
||||
/// Moves the view by a fraction of its width (positive moves later).
|
||||
pub fn pan_by(&mut self, fraction: f32) {
|
||||
self.set_start(self.start.as_secs_f32() + self.span.as_secs_f32() * fraction);
|
||||
}
|
||||
|
||||
fn set_start(&mut self, start: f32) {
|
||||
let latest = self.length.saturating_sub(self.span).as_secs_f32();
|
||||
self.start = Duration::from_secs_f32(start.clamp(0., latest));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HOUR: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[test]
|
||||
fn new_footage_extends_the_full_view_and_preserves_a_zoomed_view() {
|
||||
let mut view = TimelineView::full(Duration::from_secs(20));
|
||||
view.set_length(Duration::from_secs(40));
|
||||
assert_eq!(view.end(), Duration::from_secs(40));
|
||||
view.zoom_by(0.5, 0.5);
|
||||
let range = (view.start(), view.end());
|
||||
view.set_length(Duration::from_secs(60));
|
||||
assert_eq!((view.start(), view.end()), range);
|
||||
assert_eq!(view.time_at(1.0), range.1);
|
||||
}
|
||||
#[test]
|
||||
fn zooming_keeps_the_time_under_the_pointer() {
|
||||
let mut view = TimelineView::full(HOUR);
|
||||
let anchor = 0.25;
|
||||
let before = view.time_at(anchor);
|
||||
view.zoom_by(0.5, anchor);
|
||||
assert_eq!(view.zoom(), 2.);
|
||||
assert!(view.time_at(anchor).abs_diff(before) < Duration::from_millis(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_stays_within_the_session() {
|
||||
let mut view = TimelineView::full(HOUR);
|
||||
view.zoom_by(0.1, 1.);
|
||||
assert_eq!(view.end(), HOUR);
|
||||
view.pan_by(5.);
|
||||
assert_eq!(view.end(), HOUR);
|
||||
view.pan_by(-50.);
|
||||
assert_eq!(view.start(), Duration::ZERO);
|
||||
view.zoom_by(100., 0.5);
|
||||
assert!(!view.is_zoomed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_stops_at_ten_seconds() {
|
||||
let mut view = TimelineView::full(HOUR);
|
||||
view.zoom_by(0.000_001, 0.5);
|
||||
assert_eq!(view.end() - view.start(), MIN_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_outside_the_view_fall_outside_its_edges() {
|
||||
let mut view = TimelineView::full(HOUR);
|
||||
view.zoom_by(0.5, 1.);
|
||||
assert!(view.position(Duration::ZERO) < 0.);
|
||||
assert_eq!(view.position(HOUR), 1.);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use clipbin_core::{RecordingStatus, Settings};
|
||||
use gpui::{Context, IntoElement, Render, Window, div, prelude::*, rgb};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClipbinView {
|
||||
settings: Settings,
|
||||
status: RecordingStatus,
|
||||
}
|
||||
|
||||
impl Render for ClipbinView {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = &self.settings;
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.p_8()
|
||||
.bg(rgb(0x101318))
|
||||
.text_color(rgb(0xe6e9ef))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(div().text_3xl().child("Clipbin"))
|
||||
.child(
|
||||
div()
|
||||
.id("quit")
|
||||
.px_4()
|
||||
.py_2()
|
||||
.rounded_md()
|
||||
.bg(rgb(0x232a35))
|
||||
.cursor_pointer()
|
||||
.on_click(|_, _, cx| cx.quit())
|
||||
.child("Quit"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_4()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(rgb(0x343d4c))
|
||||
.child(div().text_lg().child(self.status.label()))
|
||||
.child(
|
||||
div()
|
||||
.text_color(rgb(0xa9b2c2))
|
||||
.child("Capture is not available in this build."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.rounded_lg()
|
||||
.bg(rgb(0x181d25))
|
||||
.child(div().text_2xl().child("No sessions yet"))
|
||||
.child(
|
||||
div().text_color(rgb(0xa9b2c2)).child(
|
||||
"Your recorded sessions and bookmarked moments will appear here.",
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(rgb(0xa9b2c2))
|
||||
.child(format!(
|
||||
"Recording defaults: {} × {} · {} FPS · Hardware {}",
|
||||
settings.width, settings.height, settings.frames_per_second, settings.codec,
|
||||
))
|
||||
.child(format!(
|
||||
"Session budget: {} GB of unprotected footage · Clip: {} seconds",
|
||||
settings.unprotected_session_limit_bytes / 1_000_000_000,
|
||||
settings.clip_duration.as_secs(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "clipbin-media"
|
||||
description = "Reading recorded segments: clip export (remux) and playback decoding, via FFmpeg."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clipbin-core.workspace = true
|
||||
ffmpeg-next = { version = "9", default-features = false, features = ["codec", "format", "software-scaling", "software-resampling"] }
|
||||
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
||||
cpal = { version = "0.18.2", features = ["pulseaudio"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rustix = { version = "1", features = ["process", "thread"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Threading"] }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Validation/repair helper: sessions-dir session-id start-ms end-ms output.mp4.
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
if args.len() != 5 {
|
||||
return Err("Usage: export sessions-dir session-id start-ms end-ms output.mp4".into());
|
||||
}
|
||||
let store = clipbin_core::store::Store {
|
||||
root: PathBuf::from(&args[0]),
|
||||
};
|
||||
let metadata = store.metadata(args[1].parse()?)?;
|
||||
let (start, end) = metadata.export_range(
|
||||
Duration::from_millis(args[2].parse()?),
|
||||
Duration::from_millis(args[3].parse()?),
|
||||
);
|
||||
clipbin_media::export::export(&metadata, start, end, false, &PathBuf::from(&args[4]))?;
|
||||
println!(
|
||||
"Exported {} – {}",
|
||||
clipbin_core::library::format_clock(start),
|
||||
clipbin_core::library::format_clock(end)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Headless timing check against real segments; run inside the rig for device audio.
|
||||
use clipbin_core::store::Store;
|
||||
use clipbin_media::player::{Picture, Player};
|
||||
use std::{
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
fn main() {
|
||||
let directory = std::env::args().nth(1).expect("session root");
|
||||
let id = std::env::args()
|
||||
.nth(2)
|
||||
.unwrap_or_else(|| "1".into())
|
||||
.parse()
|
||||
.unwrap();
|
||||
let player = Player::new();
|
||||
player.resize((800, 450));
|
||||
player.open(
|
||||
Store {
|
||||
root: directory.into(),
|
||||
},
|
||||
id,
|
||||
Duration::ZERO,
|
||||
);
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
player.play(true);
|
||||
let begin = Instant::now();
|
||||
let mut last = None;
|
||||
let mut frames = 0;
|
||||
for _ in 0..700 {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
let snapshot = player.snapshot();
|
||||
assert!(snapshot.audio_error.is_none(), "{:?}", snapshot.audio_error);
|
||||
if let Picture::Frame(frame) = snapshot.picture {
|
||||
if let Some(video_time) = snapshot.video_time {
|
||||
assert!(
|
||||
video_time.abs_diff(snapshot.time) <= Duration::from_millis(20),
|
||||
"video out of sync: {video_time:?} vs {:?}",
|
||||
snapshot.time
|
||||
);
|
||||
}
|
||||
if last
|
||||
.as_ref()
|
||||
.is_none_or(|previous| !std::sync::Arc::ptr_eq(previous, &frame))
|
||||
{
|
||||
frames += 1;
|
||||
}
|
||||
last = Some(frame);
|
||||
}
|
||||
}
|
||||
let snapshot = player.snapshot();
|
||||
println!(
|
||||
"playback_check wall={:.3} session={:.3} displayed_frames={frames}",
|
||||
begin.elapsed().as_secs_f64(),
|
||||
snapshot.time.as_secs_f64()
|
||||
);
|
||||
assert!(
|
||||
snapshot.time >= Duration::from_secs(6),
|
||||
"audio master clock stalled: {snapshot:?}"
|
||||
);
|
||||
player.audio(false, 0.5);
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
player.audio(true, 0.5);
|
||||
let before = snapshot.time;
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
assert!(player.snapshot().time > before, "muted wall clock stalled");
|
||||
player.close();
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Combined-track decoding and bounded device output. No decoder runs in a callback.
|
||||
use crate::ffmpeg::{self, format, media, software::resampling, util::frame::Audio as AudioFrame};
|
||||
use clipbin_core::store::Metadata;
|
||||
use cpal::{
|
||||
FromSample, SizedSample,
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct Block {
|
||||
time: Duration,
|
||||
samples: Vec<f32>,
|
||||
consumed: usize,
|
||||
}
|
||||
struct Buffer {
|
||||
blocks: VecDeque<Block>,
|
||||
queued: usize,
|
||||
playing: bool,
|
||||
volume: f32,
|
||||
// The time of the first sample actually scheduled for the device.
|
||||
anchors: VecDeque<(Duration, Instant, Duration)>,
|
||||
error: Option<String>,
|
||||
}
|
||||
impl Buffer {
|
||||
fn time(&mut self, now: Instant) -> Option<Duration> {
|
||||
while self.anchors.len() > 1 && self.anchors[1].1 <= now {
|
||||
self.anchors.pop_front();
|
||||
}
|
||||
self.anchors.front().and_then(|(time, begins, span)| {
|
||||
(now <= *begins + *span + Duration::from_millis(100))
|
||||
.then(|| *time + now.saturating_duration_since(*begins).min(*span))
|
||||
})
|
||||
}
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: cpal::Stream,
|
||||
buffer: Arc<Mutex<Buffer>>,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
decoder: Option<(PathBuf, AudioDecoder)>,
|
||||
next: Duration,
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(host) = cpal::host_from_id(cpal::HostId::PulseAudio) {
|
||||
match Self::with_host(host) {
|
||||
Ok(audio) => return Ok(audio),
|
||||
Err(error) => eprintln!("PulseAudio output unavailable: {error}; trying ALSA"),
|
||||
}
|
||||
}
|
||||
Self::with_host(cpal::default_host())
|
||||
}
|
||||
fn with_host(host: cpal::Host) -> Result<Self, String> {
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or("No audio output device")?;
|
||||
let supported = device.default_output_config().map_err(|e| e.to_string())?;
|
||||
let format = supported.sample_format();
|
||||
let config: cpal::StreamConfig = supported.into();
|
||||
let rate = config.sample_rate;
|
||||
let channels = config.channels;
|
||||
let buffer = Arc::new(Mutex::new(Buffer {
|
||||
blocks: VecDeque::new(),
|
||||
queued: 0,
|
||||
playing: false,
|
||||
volume: 1.,
|
||||
anchors: VecDeque::new(),
|
||||
error: None,
|
||||
}));
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::F32 => output::<f32>(&device, config, buffer.clone()),
|
||||
cpal::SampleFormat::I16 => output::<i16>(&device, config, buffer.clone()),
|
||||
cpal::SampleFormat::U16 => output::<u16>(&device, config, buffer.clone()),
|
||||
cpal::SampleFormat::I32 => output::<i32>(&device, config, buffer.clone()),
|
||||
cpal::SampleFormat::F64 => output::<f64>(&device, config, buffer.clone()),
|
||||
_ => return Err(format!("Unsupported audio device format: {format}")),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
eprintln!("playback_audio rate={rate} channels={channels} format={format}");
|
||||
Ok(Self {
|
||||
_stream: stream,
|
||||
buffer,
|
||||
rate,
|
||||
channels,
|
||||
decoder: None,
|
||||
next: Duration::ZERO,
|
||||
})
|
||||
}
|
||||
pub fn reset(&mut self, time: Duration) {
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
buffer.blocks.clear();
|
||||
buffer.queued = 0;
|
||||
buffer.anchors.clear();
|
||||
self.decoder = None;
|
||||
self.next = time;
|
||||
}
|
||||
pub fn controls(&self, playing: bool, volume: f32) {
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
buffer.playing = playing;
|
||||
buffer.volume = volume.clamp(0., 1.);
|
||||
if !playing {
|
||||
buffer.anchors.clear();
|
||||
}
|
||||
}
|
||||
/// Device time, corrected for callback-to-DAC latency; bounded by scheduled samples.
|
||||
pub fn time(&self, now: Instant) -> Option<Duration> {
|
||||
self.buffer.lock().unwrap().time(now)
|
||||
}
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.buffer.lock().unwrap().error.clone()
|
||||
}
|
||||
pub fn catch_up(&mut self, time: Duration) {
|
||||
if self.next + Duration::from_millis(50) < time && self.buffer.lock().unwrap().queued == 0 {
|
||||
self.reset(time);
|
||||
}
|
||||
}
|
||||
pub fn fill(&mut self, metadata: &Metadata) -> Result<(), ffmpeg::Error> {
|
||||
// At most 250 ms buffered. All allocations/decoding happen on the player thread.
|
||||
let limit = self.rate as usize * self.channels as usize / 4;
|
||||
for _ in 0..16 {
|
||||
if self.buffer.lock().unwrap().queued >= limit {
|
||||
break;
|
||||
}
|
||||
let Some(at) = metadata.locate(self.next).filter(|at| at.segment.complete) else {
|
||||
break;
|
||||
};
|
||||
if self
|
||||
.decoder
|
||||
.as_ref()
|
||||
.is_none_or(|(path, _)| path != &at.segment.path)
|
||||
{
|
||||
self.decoder = Some((
|
||||
at.segment.path.clone(),
|
||||
AudioDecoder::open(&at.segment.path, self.rate, self.channels, at.offset)?,
|
||||
));
|
||||
}
|
||||
let (_, decoder) = self.decoder.as_mut().unwrap();
|
||||
let Some((offset, mut samples)) = decoder.next()? else {
|
||||
self.next = at.segment.end;
|
||||
self.decoder = None;
|
||||
continue;
|
||||
};
|
||||
let begin = at.segment.start + offset;
|
||||
let skip = ((self.next.saturating_sub(begin).as_secs_f64() * self.rate as f64).round()
|
||||
as usize)
|
||||
.min(samples.len() / self.channels as usize);
|
||||
samples.drain(..skip * self.channels as usize);
|
||||
let time = begin + Duration::from_secs_f64(skip as f64 / self.rate as f64);
|
||||
let keep = ((at.segment.end.saturating_sub(time).as_secs_f64() * self.rate as f64)
|
||||
.round() as usize)
|
||||
* self.channels as usize;
|
||||
samples.truncate(keep);
|
||||
if samples.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.next = time
|
||||
+ Duration::from_secs_f64(
|
||||
(samples.len() / self.channels as usize) as f64 / self.rate as f64,
|
||||
);
|
||||
if at.segment.end.saturating_sub(self.next)
|
||||
< Duration::from_nanos(1_000_000_000 / self.rate as u64)
|
||||
{
|
||||
self.next = at.segment.end;
|
||||
}
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
buffer.queued += samples.len();
|
||||
buffer.blocks.push_back(Block {
|
||||
time,
|
||||
samples,
|
||||
consumed: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn output<T: SizedSample + FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: cpal::StreamConfig,
|
||||
buffer: Arc<Mutex<Buffer>>,
|
||||
) -> Result<cpal::Stream, cpal::Error> {
|
||||
let errors = buffer.clone();
|
||||
let rate = config.sample_rate;
|
||||
let channels = config.channels as usize;
|
||||
device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], info: &cpal::OutputCallbackInfo| {
|
||||
// try_lock guarantees UI/decoder contention cannot block the device callback.
|
||||
data.fill(T::from_sample(0.));
|
||||
let Ok(mut buffer) = buffer.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if !buffer.playing {
|
||||
return;
|
||||
}
|
||||
let latency = info
|
||||
.timestamp()
|
||||
.playback
|
||||
.duration_since(info.timestamp().callback);
|
||||
let mut scheduled = None;
|
||||
for (frame_number, output) in data.chunks_exact_mut(channels).enumerate() {
|
||||
let volume = buffer.volume;
|
||||
let Some(block) = buffer.blocks.front_mut() else {
|
||||
break;
|
||||
};
|
||||
let time = block.time
|
||||
+ Duration::from_secs_f64((block.consumed / channels) as f64 / rate as f64);
|
||||
scheduled.get_or_insert((
|
||||
time,
|
||||
Instant::now()
|
||||
+ latency
|
||||
+ Duration::from_secs_f64(frame_number as f64 / rate as f64),
|
||||
Duration::ZERO,
|
||||
));
|
||||
for sample in output {
|
||||
*sample =
|
||||
T::from_sample((block.samples[block.consumed] * volume).clamp(-1., 1.));
|
||||
block.consumed += 1;
|
||||
}
|
||||
let done = block.consumed == block.samples.len();
|
||||
buffer.queued -= channels;
|
||||
if let Some((_, _, span)) = scheduled.as_mut() {
|
||||
*span += Duration::from_secs_f64(1. / rate as f64);
|
||||
}
|
||||
if done {
|
||||
buffer.blocks.pop_front();
|
||||
}
|
||||
}
|
||||
if let Some(scheduled) = scheduled {
|
||||
if buffer.anchors.len() == 32 {
|
||||
buffer.anchors.pop_front();
|
||||
}
|
||||
buffer.anchors.push_back(scheduled);
|
||||
}
|
||||
},
|
||||
move |error| {
|
||||
if let Ok(mut buffer) = errors.lock() {
|
||||
buffer.error = Some(format!("Audio output failed: {error}"));
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
struct AudioDecoder {
|
||||
input: format::context::Input,
|
||||
decoder: ffmpeg::decoder::Audio,
|
||||
resampler: resampling::Context,
|
||||
stream: usize,
|
||||
time_base: f64,
|
||||
origin: i64,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
eof: bool,
|
||||
next_time: Duration,
|
||||
initialised_time: bool,
|
||||
drained: bool,
|
||||
}
|
||||
impl AudioDecoder {
|
||||
fn open(path: &Path, rate: u32, channels: u16, seek: Duration) -> Result<Self, ffmpeg::Error> {
|
||||
crate::init();
|
||||
let mut input = format::input(path)?;
|
||||
// OBS writes system, mic, combined, in that order; a single track is already mixed.
|
||||
let tracks: Vec<_> = input
|
||||
.streams()
|
||||
.filter(|stream| stream.parameters().medium() == media::Type::Audio)
|
||||
.collect();
|
||||
let track = if tracks.len() >= 3 {
|
||||
&tracks[2]
|
||||
} else {
|
||||
tracks.first().ok_or(ffmpeg::Error::StreamNotFound)?
|
||||
};
|
||||
let stream = track.index();
|
||||
let time_base = f64::from(track.time_base());
|
||||
let origin = track.start_time().max(0);
|
||||
let decoder = ffmpeg::codec::context::Context::from_parameters(track.parameters())?
|
||||
.decoder()
|
||||
.audio()?;
|
||||
let layout = if decoder.channel_layout().channels() == 0 {
|
||||
ffmpeg::ChannelLayout::default(decoder.channels() as i32)
|
||||
} else {
|
||||
decoder.channel_layout()
|
||||
};
|
||||
let resampler = resampling::Context::get(
|
||||
decoder.format(),
|
||||
layout,
|
||||
decoder.rate(),
|
||||
format::Sample::F32(format::sample::Type::Packed),
|
||||
ffmpeg::ChannelLayout::default(channels as i32),
|
||||
rate,
|
||||
)?;
|
||||
if seek > Duration::ZERO {
|
||||
let timestamp = (seek.as_secs_f64() * 1_000_000.) as i64;
|
||||
input.seek(timestamp, ..timestamp)?;
|
||||
}
|
||||
Ok(Self {
|
||||
input,
|
||||
decoder,
|
||||
resampler,
|
||||
stream,
|
||||
time_base,
|
||||
origin,
|
||||
rate,
|
||||
channels,
|
||||
eof: false,
|
||||
next_time: seek,
|
||||
initialised_time: false,
|
||||
drained: false,
|
||||
})
|
||||
}
|
||||
fn next(&mut self) -> Result<Option<(Duration, Vec<f32>)>, ffmpeg::Error> {
|
||||
loop {
|
||||
let mut frame = AudioFrame::empty();
|
||||
match self.decoder.receive_frame(&mut frame) {
|
||||
Ok(()) => {
|
||||
let timestamp = frame.timestamp().map_or(self.next_time, |pts| {
|
||||
Duration::from_secs_f64((pts - self.origin).max(0) as f64 * self.time_base)
|
||||
});
|
||||
let time = if self.initialised_time {
|
||||
self.next_time
|
||||
} else {
|
||||
timestamp
|
||||
};
|
||||
self.initialised_time = true;
|
||||
// Allocate enough output for upsampling; ffmpeg-next's empty output uses input count.
|
||||
let count = ((frame.samples() as u64 * self.rate as u64)
|
||||
.div_ceil(frame.rate() as u64)
|
||||
+ 256) as usize;
|
||||
let mut converted = AudioFrame::new(
|
||||
format::Sample::F32(format::sample::Type::Packed),
|
||||
count,
|
||||
ffmpeg::ChannelLayout::default(self.channels as i32),
|
||||
);
|
||||
converted.set_rate(self.rate);
|
||||
self.resampler.run(&frame, &mut converted)?;
|
||||
let samples = converted.plane::<f32>(0).to_vec();
|
||||
self.next_time = time
|
||||
+ Duration::from_secs_f64(converted.samples() as f64 / self.rate as f64);
|
||||
return Ok(Some((time, samples)));
|
||||
}
|
||||
Err(ffmpeg::Error::Eof) => {
|
||||
if self.drained {
|
||||
return Ok(None);
|
||||
}
|
||||
self.drained = true;
|
||||
let mut converted = AudioFrame::new(
|
||||
format::Sample::F32(format::sample::Type::Packed),
|
||||
256,
|
||||
ffmpeg::ChannelLayout::default(self.channels as i32),
|
||||
);
|
||||
converted.set_rate(self.rate);
|
||||
self.resampler.flush(&mut converted)?;
|
||||
return if converted.samples() == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some((self.next_time, converted.plane::<f32>(0).to_vec())))
|
||||
};
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::error::EAGAIN => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut packet = ffmpeg::Packet::empty();
|
||||
loop {
|
||||
match packet.read(&mut self.input) {
|
||||
Ok(()) if packet.stream() == self.stream => {
|
||||
self.decoder.send_packet(&packet)?;
|
||||
break;
|
||||
}
|
||||
Ok(()) => {}
|
||||
Err(ffmpeg::Error::Eof) => {
|
||||
self.eof = true;
|
||||
self.decoder.send_eof()?;
|
||||
break;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn device_clock_waits_for_latency_and_stops_at_scheduled_samples() {
|
||||
let now = Instant::now();
|
||||
let mut buffer = Buffer {
|
||||
blocks: VecDeque::new(),
|
||||
queued: 0,
|
||||
playing: true,
|
||||
volume: 1.,
|
||||
anchors: VecDeque::new(),
|
||||
error: None,
|
||||
};
|
||||
buffer.anchors.push_back((
|
||||
Duration::from_secs(2),
|
||||
now + Duration::from_millis(100),
|
||||
Duration::from_millis(50),
|
||||
));
|
||||
buffer.anchors.push_back((
|
||||
Duration::from_millis(2050),
|
||||
now + Duration::from_millis(150),
|
||||
Duration::from_millis(50),
|
||||
));
|
||||
assert_eq!(buffer.time(now), Some(Duration::from_secs(2)));
|
||||
assert_eq!(
|
||||
buffer.time(now + Duration::from_millis(125)),
|
||||
Some(Duration::from_millis(2025))
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.time(now + Duration::from_millis(200)),
|
||||
Some(Duration::from_millis(2100))
|
||||
);
|
||||
assert_eq!(buffer.anchors.len(), 1);
|
||||
assert_eq!(buffer.time(now + Duration::from_secs(5)), None);
|
||||
}
|
||||
#[test]
|
||||
fn resampling_selects_combined_track_and_preserves_its_tone() {
|
||||
let Some(directory) = std::env::var_os("CLIPBIN_TEST_MEDIA_DIR") else {
|
||||
return;
|
||||
};
|
||||
let mut decoder = AudioDecoder::open(
|
||||
&PathBuf::from(directory).join("combined.mkv"),
|
||||
44_100,
|
||||
1,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let mut samples = Vec::new();
|
||||
while let Some((_, block)) = decoder.next().unwrap() {
|
||||
samples.extend(block);
|
||||
}
|
||||
assert!(samples.len() > 80_000 && samples.len() < 95_000);
|
||||
let crossings = samples
|
||||
.windows(2)
|
||||
.filter(|pair| pair[0] <= 0. && pair[1] > 0.)
|
||||
.count();
|
||||
let hz = crossings as f64 / (samples.len() as f64 / 44_100.);
|
||||
assert!(
|
||||
(435. ..445.).contains(&hz),
|
||||
"combined track was not the 440 Hz tone: {hz}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Single-threaded file decoding. Scale only frames which will be displayed.
|
||||
use crate::ffmpeg::{self, codec, format, media, software::scaling, util::frame::video::Video};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Frame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Packed BGRA, without FFmpeg's row padding.
|
||||
pub bgra: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct Decoder {
|
||||
input: format::context::Input,
|
||||
decoder: ffmpeg::decoder::Video,
|
||||
stream: usize,
|
||||
time_base: f64,
|
||||
origin: i64,
|
||||
fps: u32,
|
||||
next_index: u64,
|
||||
eof: bool,
|
||||
scaler: Option<(u32, u32, format::Pixel, u32, u32, scaling::context::Context)>,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn open(path: &Path, fps: u32) -> Result<Self, ffmpeg::Error> {
|
||||
crate::init();
|
||||
let input = format::input(path)?;
|
||||
let video = input
|
||||
.streams()
|
||||
.best(media::Type::Video)
|
||||
.ok_or(ffmpeg::Error::StreamNotFound)?;
|
||||
let stream = video.index();
|
||||
let time_base = f64::from(video.time_base());
|
||||
let origin = video.start_time().max(0);
|
||||
let mut context = codec::context::Context::from_parameters(video.parameters())?;
|
||||
// Never let FFmpeg create a pool competing with the recorder.
|
||||
context.set_threading(codec::threading::Config::count(1));
|
||||
let decoder = context.decoder().video()?;
|
||||
Ok(Self {
|
||||
input,
|
||||
decoder,
|
||||
stream,
|
||||
time_base,
|
||||
origin,
|
||||
fps: fps.max(1),
|
||||
next_index: 0,
|
||||
eof: false,
|
||||
scaler: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reuse swscale's setup while the preview dimensions stay unchanged.
|
||||
pub fn scale(&mut self, video: &Video, target: (u32, u32)) -> Result<Frame, ffmpeg::Error> {
|
||||
let (width, height) = fitted_size((video.width(), video.height()), target);
|
||||
if self
|
||||
.scaler
|
||||
.as_ref()
|
||||
.is_none_or(|(w, h, pixel, source_w, source_h, _)| {
|
||||
(*w, *h) != (width, height)
|
||||
|| *pixel != video.format()
|
||||
|| (*source_w, *source_h) != (video.width(), video.height())
|
||||
})
|
||||
{
|
||||
self.scaler = Some((
|
||||
width,
|
||||
height,
|
||||
video.format(),
|
||||
video.width(),
|
||||
video.height(),
|
||||
scaling::context::Context::get(
|
||||
video.format(),
|
||||
video.width(),
|
||||
video.height(),
|
||||
format::Pixel::BGRA,
|
||||
width,
|
||||
height,
|
||||
scaling::flag::Flags::FAST_BILINEAR,
|
||||
)?,
|
||||
));
|
||||
}
|
||||
let (_, _, _, _, _, scaler) = self.scaler.as_mut().unwrap();
|
||||
packed_frame(scaler, video, width, height)
|
||||
}
|
||||
|
||||
/// FFmpeg seeks backwards to a keyframe; callers decode forward by frame index.
|
||||
pub fn seek(&mut self, time: Duration) -> Result<(), ffmpeg::Error> {
|
||||
let timestamp = (time.as_secs_f64() * 1_000_000.
|
||||
+ self.origin as f64 * self.time_base * 1_000_000.) as i64;
|
||||
self.input.seek(timestamp, ..timestamp)?;
|
||||
self.decoder.flush();
|
||||
self.next_index = frame_index(time, self.fps);
|
||||
self.eof = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn next_frame(&mut self) -> Result<Option<(u64, Video)>, ffmpeg::Error> {
|
||||
loop {
|
||||
let mut frame = Video::empty();
|
||||
match self.decoder.receive_frame(&mut frame) {
|
||||
Ok(()) => {
|
||||
let index = frame.timestamp().map_or(self.next_index, |pts| {
|
||||
(((pts - self.origin).max(0) as f64 * self.time_base * self.fps as f64)
|
||||
.round()) as u64
|
||||
});
|
||||
self.next_index = index + 1;
|
||||
return Ok(Some((index, frame)));
|
||||
}
|
||||
Err(ffmpeg::Error::Eof) => return Ok(None),
|
||||
Err(ffmpeg::Error::Other { errno }) if errno == ffmpeg::error::EAGAIN => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut packet = ffmpeg::Packet::empty();
|
||||
loop {
|
||||
match packet.read(&mut self.input) {
|
||||
Ok(()) if packet.stream() == self.stream => {
|
||||
self.decoder.send_packet(&packet)?;
|
||||
break;
|
||||
}
|
||||
Ok(()) => {}
|
||||
Err(ffmpeg::Error::Eof) => {
|
||||
self.eof = true;
|
||||
self.decoder.send_eof()?;
|
||||
break;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_index(time: Duration, fps: u32) -> u64 {
|
||||
(time.as_nanos() * u128::from(fps.max(1)) / 1_000_000_000) as u64
|
||||
}
|
||||
|
||||
/// Preserve aspect ratio and cap allocation even on a very large display.
|
||||
pub fn fitted_size(source: (u32, u32), target: (u32, u32)) -> (u32, u32) {
|
||||
let ratio = (target.0.clamp(1, 1920) as f64 / source.0.max(1) as f64)
|
||||
.min(target.1.clamp(1, 1080) as f64 / source.1.max(1) as f64)
|
||||
.min(1.);
|
||||
(
|
||||
(source.0 as f64 * ratio).round().max(1.) as u32,
|
||||
(source.1 as f64 * ratio).round().max(1.) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn scale(video: &Video, target: (u32, u32)) -> Result<Frame, ffmpeg::Error> {
|
||||
let (width, height) = fitted_size((video.width(), video.height()), target);
|
||||
let mut scaler = scaling::context::Context::get(
|
||||
video.format(),
|
||||
video.width(),
|
||||
video.height(),
|
||||
format::Pixel::BGRA,
|
||||
width,
|
||||
height,
|
||||
scaling::flag::Flags::FAST_BILINEAR,
|
||||
)?;
|
||||
packed_frame(&mut scaler, video, width, height)
|
||||
}
|
||||
|
||||
fn packed_frame(
|
||||
scaler: &mut scaling::context::Context,
|
||||
video: &Video,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Frame, ffmpeg::Error> {
|
||||
let mut output = Video::empty();
|
||||
scaler.run(video, &mut output)?;
|
||||
let mut bgra = Vec::with_capacity(width as usize * height as usize * 4);
|
||||
for row in 0..height as usize {
|
||||
let start = row * output.stride(0);
|
||||
bgra.extend_from_slice(&output.data(0)[start..start + width as usize * 4]);
|
||||
}
|
||||
Ok(Frame {
|
||||
width,
|
||||
height,
|
||||
bgra,
|
||||
})
|
||||
}
|
||||
|
||||
/// A thumbnail from the first decoded frame of any MP4/MKV path.
|
||||
pub fn first_frame(path: &Path, target: (u32, u32)) -> Result<Frame, ffmpeg::Error> {
|
||||
let mut decoder = Decoder::open(path, 60)?;
|
||||
let (_, frame) = decoder.next_frame()?.ok_or(ffmpeg::Error::Eof)?;
|
||||
scale(&frame, target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn frame_exact_seek_and_bounded_aspect_ratio() {
|
||||
assert_eq!(frame_index(Duration::from_millis(1999), 60), 119);
|
||||
assert_eq!(frame_index(Duration::from_secs(2), 60), 120);
|
||||
assert_eq!(fitted_size((2560, 1440), (800, 450)), (800, 450));
|
||||
assert_eq!(fitted_size((1440, 2560), (800, 450)), (253, 450));
|
||||
assert_eq!(fitted_size((8000, 8000), (8000, 8000)), (1080, 1080));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Stream-copy export. Container timestamps restart independently at every split;
|
||||
//! video frames and AAC samples therefore have separate exact clocks.
|
||||
use crate::{
|
||||
ffmpeg::{self, Rational, Rescale, codec, encoder, format, media},
|
||||
init,
|
||||
};
|
||||
use clipbin_core::store::Metadata;
|
||||
use std::{fs, path::Path, time::Duration};
|
||||
|
||||
/// Writes and closes MP4 under a temporary name before publishing it.
|
||||
/// Missing/Removed footage is skipped, retaining a visible timestamp jump.
|
||||
pub fn export(
|
||||
metadata: &Metadata,
|
||||
start: Duration,
|
||||
end: Duration,
|
||||
separate_audio: bool,
|
||||
destination: &Path,
|
||||
) -> Result<(), String> {
|
||||
init();
|
||||
let (start, end) = metadata.export_range(start, end);
|
||||
let pieces = metadata.covering(start, end);
|
||||
if pieces.is_empty() {
|
||||
return Err("No retained footage in this range".into());
|
||||
}
|
||||
if pieces.iter().any(|p| p.segment.dropped_frames > 0) {
|
||||
return Err("This range has dropped frames; exact export placement is unvalidated".into());
|
||||
}
|
||||
if pieces
|
||||
.iter()
|
||||
.any(|p| p.segment.frames_per_second != pieces[0].segment.frames_per_second)
|
||||
{
|
||||
return Err("Export ranges spanning a frame-rate change are unsupported".into());
|
||||
}
|
||||
if pieces.iter().any(|p| !p.segment.complete) {
|
||||
return Err("Footage is still being written".into());
|
||||
}
|
||||
let temporary = destination.with_extension("partial.mp4");
|
||||
let result = remux(metadata, start, end, separate_audio, &temporary);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
fs::File::open(&temporary)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|e| e.to_string())?;
|
||||
fs::rename(&temporary, destination).map_err(|e| e.to_string())
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
Err(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts AAC samples in a finalised file, without decoding or rounded durations.
|
||||
pub fn audio_samples(path: &Path) -> Result<Vec<u64>, ffmpeg::Error> {
|
||||
init();
|
||||
let mut input = format::input(path)?;
|
||||
let audio: Vec<_> = input
|
||||
.streams()
|
||||
.filter(|s| s.parameters().medium() == media::Type::Audio)
|
||||
.map(|s| s.index())
|
||||
.collect();
|
||||
let mut samples = vec![0; audio.len()];
|
||||
loop {
|
||||
let mut packet = ffmpeg::Packet::empty();
|
||||
match packet.read(&mut input) {
|
||||
Ok(()) => {}
|
||||
Err(ffmpeg::Error::Eof) => break,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
if let Some(index) = audio.iter().position(|s| *s == packet.stream()) {
|
||||
samples[index] += 1024;
|
||||
}
|
||||
}
|
||||
Ok(samples)
|
||||
}
|
||||
fn audio_start(
|
||||
metadata: &Metadata,
|
||||
index: usize,
|
||||
tracks: usize,
|
||||
) -> Result<Vec<u64>, ffmpeg::Error> {
|
||||
let segment = &metadata.segments[index];
|
||||
if segment.audio_start_samples.len() == tracks {
|
||||
return Ok(segment.audio_start_samples.clone());
|
||||
}
|
||||
if segment.frame_start == 0 {
|
||||
return Ok(vec![0; tracks]);
|
||||
}
|
||||
// Old metadata can be reconstructed while its preceding files still exist.
|
||||
let mut samples = vec![0; tracks];
|
||||
for previous in metadata.segments[..index].iter().rev() {
|
||||
if !previous.available || previous.path.parent() != segment.path.parent() {
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
let counts = audio_samples(&previous.path)?;
|
||||
if counts.len() != tracks {
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
for (sum, count) in samples.iter_mut().zip(counts) {
|
||||
*sum += count;
|
||||
}
|
||||
if previous.audio_start_samples.len() == tracks {
|
||||
for (sum, start) in samples.iter_mut().zip(&previous.audio_start_samples) {
|
||||
*sum += start;
|
||||
}
|
||||
return Ok(samples);
|
||||
}
|
||||
if previous.frame_start == 0 {
|
||||
return Ok(samples);
|
||||
}
|
||||
}
|
||||
Err(ffmpeg::Error::InvalidData)
|
||||
}
|
||||
fn signed_ticks(time: Duration, origin: Duration, rate: i32) -> i64 {
|
||||
if time >= origin {
|
||||
ticks(time - origin, rate)
|
||||
} else {
|
||||
-ticks(origin - time, rate)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticks(time: Duration, rate: i32) -> i64 {
|
||||
((time.as_nanos() * rate as u128 + 500_000_000) / 1_000_000_000) as i64
|
||||
}
|
||||
fn remux(
|
||||
metadata: &Metadata,
|
||||
start: Duration,
|
||||
end: Duration,
|
||||
separate: bool,
|
||||
path: &Path,
|
||||
) -> Result<(), ffmpeg::Error> {
|
||||
let pieces = metadata.covering(start, end);
|
||||
let first = format::input(&pieces[0].segment.path)?;
|
||||
let audio: Vec<_> = first
|
||||
.streams()
|
||||
.filter(|s| s.parameters().medium() == media::Type::Audio)
|
||||
.map(|s| s.index())
|
||||
.collect();
|
||||
// OBS writes system, mic, combined in this order; one track is already mixed.
|
||||
let selected_audio: Vec<_> = if audio.len() == 3 {
|
||||
if separate {
|
||||
audio[..2].to_vec()
|
||||
} else {
|
||||
vec![audio[2]]
|
||||
}
|
||||
} else {
|
||||
audio.clone()
|
||||
};
|
||||
let mut output = format::output(path)?;
|
||||
let mut mapping = vec![None; first.nb_streams() as usize];
|
||||
let mut clocks = vec![(0, 0); first.nb_streams() as usize];
|
||||
for stream in first.streams() {
|
||||
let video = stream.parameters().medium() == media::Type::Video;
|
||||
if !video && !selected_audio.contains(&stream.index()) {
|
||||
continue;
|
||||
}
|
||||
let rate = if video {
|
||||
pieces[0].segment.frames_per_second as i32
|
||||
} else {
|
||||
codec::context::Context::from_parameters(stream.parameters())?
|
||||
.decoder()
|
||||
.audio()?
|
||||
.rate() as i32
|
||||
};
|
||||
let mut target = output.add_stream(encoder::find(codec::Id::None))?;
|
||||
target.set_parameters(stream.parameters());
|
||||
target.set_time_base(Rational(1, rate));
|
||||
mapping[stream.index()] = Some((target.index(), video, rate));
|
||||
clocks[stream.index()] = (0, if video { 1 } else { 1024 });
|
||||
}
|
||||
output.write_header()?;
|
||||
let mut previous_end = None;
|
||||
let mut wrote_video = false;
|
||||
for piece in pieces {
|
||||
let adjacent = previous_end == Some(piece.segment.start);
|
||||
let mut input = format::input(&piece.segment.path)?;
|
||||
if input.nb_streams() != first.nb_streams() {
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
for stream in input.streams() {
|
||||
let original = first.stream(stream.index()).unwrap();
|
||||
if stream.parameters().id() != original.parameters().id() {
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
if stream.parameters().medium() == media::Type::Video {
|
||||
let current = codec::context::Context::from_parameters(stream.parameters())?
|
||||
.decoder()
|
||||
.video()?;
|
||||
let initial = codec::context::Context::from_parameters(original.parameters())?
|
||||
.decoder()
|
||||
.video()?;
|
||||
if (current.width(), current.height()) != (initial.width(), initial.height()) {
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut counts = vec![0i64; mapping.len()];
|
||||
let mut first_pts = vec![None; mapping.len()];
|
||||
let mut bases = vec![0i64; mapping.len()];
|
||||
let origins = audio_start(metadata, piece.index, audio.len())?;
|
||||
let run_base = piece
|
||||
.segment
|
||||
.start
|
||||
.saturating_sub(clipbin_core::store::frame_offset(
|
||||
piece.segment.frame_start,
|
||||
piece.segment.frames_per_second,
|
||||
));
|
||||
for (index, mapped) in mapping.iter().enumerate() {
|
||||
if let Some((_, video, rate)) = mapped {
|
||||
bases[index] = if *video {
|
||||
if adjacent {
|
||||
clocks[index].0
|
||||
} else {
|
||||
signed_ticks(piece.segment.start, start, *rate)
|
||||
}
|
||||
} else {
|
||||
let ordinal = audio.iter().position(|i| *i == index).unwrap();
|
||||
signed_ticks(run_base, start, *rate) + origins[ordinal] as i64
|
||||
};
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let mut packet = ffmpeg::Packet::empty();
|
||||
match packet.read(&mut input) {
|
||||
Ok(()) => {}
|
||||
Err(ffmpeg::Error::Eof) => break,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let index = packet.stream();
|
||||
let stream = input.stream(index).ok_or(ffmpeg::Error::StreamNotFound)?;
|
||||
let Some((target, video, rate)) = mapping[index] else {
|
||||
continue;
|
||||
};
|
||||
let step = clocks[index].1;
|
||||
let local = if video {
|
||||
let pts = packet.pts().ok_or(ffmpeg::Error::InvalidData)?;
|
||||
let origin = *first_pts[index].get_or_insert(pts);
|
||||
(pts - origin).rescale(stream.time_base(), Rational(1, rate))
|
||||
} else {
|
||||
counts[index] * step
|
||||
};
|
||||
counts[index] += 1;
|
||||
let from = ticks(piece.from, rate);
|
||||
let to = ticks(piece.to, rate);
|
||||
let pts = bases[index] + local;
|
||||
if video && (local + step <= from || local >= to) {
|
||||
continue;
|
||||
}
|
||||
if !video && pts >= ticks(end - start, rate) {
|
||||
continue;
|
||||
}
|
||||
if pts < 0 {
|
||||
continue;
|
||||
}
|
||||
let tb = Rational(1, rate);
|
||||
let out_tb = output.stream(target).unwrap().time_base();
|
||||
let reorder = packet.pts().unwrap_or(0) - packet.dts().unwrap_or(0);
|
||||
let delay = reorder.rescale(stream.time_base(), tb);
|
||||
packet.set_pts(Some(pts));
|
||||
packet.set_dts(Some(pts - delay));
|
||||
packet.set_duration(step);
|
||||
packet.rescale_ts(tb, out_tb);
|
||||
packet.set_position(-1);
|
||||
packet.set_stream(target);
|
||||
packet.write_interleaved(&mut output)?;
|
||||
wrote_video |= video;
|
||||
}
|
||||
for (index, mapped) in mapping.iter().enumerate() {
|
||||
if let Some((_, true, rate)) = mapped
|
||||
&& counts[index] != ticks(piece.segment.end - piece.segment.start, *rate)
|
||||
{
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
}
|
||||
for index in 0..mapping.len() {
|
||||
clocks[index].0 = bases[index] + counts[index] * clocks[index].1;
|
||||
}
|
||||
previous_end = Some(piece.segment.end);
|
||||
}
|
||||
if !wrote_video {
|
||||
return Err(ffmpeg::Error::InvalidData);
|
||||
}
|
||||
output.write_trailer()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Reading recorded segments with FFmpeg: clip export (remuxing without
|
||||
//! re-encoding) and decoding for playback.
|
||||
//!
|
||||
//! FFmpeg is linked dynamically: the system's libraries on Linux (the OBS
|
||||
//! runtime already depends on them) and, on Windows, the FFmpeg DLLs shipped
|
||||
//! in the official OBS release that scripts/obs/assemble-windows.sh bundles.
|
||||
|
||||
use std::{path::Path, sync::Once, time::Duration};
|
||||
|
||||
pub use ffmpeg_next as ffmpeg;
|
||||
|
||||
/// Initialises FFmpeg once per process.
|
||||
pub fn init() {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let _ = ffmpeg::init();
|
||||
ffmpeg::log::set_level(ffmpeg::log::Level::Error);
|
||||
});
|
||||
}
|
||||
|
||||
/// Basic facts about a segment file.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Probe {
|
||||
pub duration: Duration,
|
||||
pub video_codec: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub audio_tracks: usize,
|
||||
}
|
||||
|
||||
pub fn probe(path: &Path) -> Result<Probe, ffmpeg::Error> {
|
||||
init();
|
||||
let input = ffmpeg::format::input(path)?;
|
||||
let video = input
|
||||
.streams()
|
||||
.best(ffmpeg::media::Type::Video)
|
||||
.ok_or(ffmpeg::Error::StreamNotFound)?;
|
||||
let decoder = ffmpeg::codec::context::Context::from_parameters(video.parameters())?
|
||||
.decoder()
|
||||
.video()?;
|
||||
let micros = input.duration().max(0) as u64;
|
||||
Ok(Probe {
|
||||
duration: Duration::from_micros(micros),
|
||||
video_codec: decoder.id().name().to_owned(),
|
||||
width: decoder.width(),
|
||||
height: decoder.height(),
|
||||
audio_tracks: input
|
||||
.streams()
|
||||
.filter(|stream| stream.parameters().medium() == ffmpeg::media::Type::Audio)
|
||||
.count(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Set CLIPBIN_TEST_SEGMENT to a recorded segment to run this.
|
||||
#[test]
|
||||
fn probes_a_recorded_segment() {
|
||||
let Some(path) = std::env::var_os("CLIPBIN_TEST_SEGMENT") else {
|
||||
return;
|
||||
};
|
||||
let probe = probe(Path::new(&path)).unwrap();
|
||||
assert!(probe.duration > Duration::ZERO);
|
||||
assert!(probe.width > 0 && probe.height > 0);
|
||||
println!("{probe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod audio;
|
||||
pub mod decode;
|
||||
pub mod export;
|
||||
pub mod player;
|
||||
pub mod thumbnail;
|
||||
|
||||
mod priority;
|
||||
#[cfg(windows)]
|
||||
#[allow(unsafe_code)]
|
||||
mod windows_priority;
|
||||
@@ -0,0 +1,459 @@
|
||||
//! On-demand background playback with a one-frame mailbox and coalesced controls.
|
||||
use crate::decode::{self, Decoder, Frame};
|
||||
use clipbin_core::store::{Metadata, Store};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Condvar, Mutex},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Picture {
|
||||
Loading,
|
||||
Frame(Arc<Frame>),
|
||||
Gap,
|
||||
Error(String),
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub time: Duration,
|
||||
pub playing: bool,
|
||||
pub picture: Picture,
|
||||
pub revision: u64,
|
||||
pub audio_error: Option<String>,
|
||||
pub video_time: Option<Duration>,
|
||||
pub frames_per_second: u32,
|
||||
}
|
||||
struct Controls {
|
||||
session: Option<(Store, u64)>,
|
||||
seek: Option<Duration>,
|
||||
latest: bool,
|
||||
playing: bool,
|
||||
size: (u32, u32),
|
||||
generation: u64,
|
||||
muted: bool,
|
||||
volume: f32,
|
||||
quit: bool,
|
||||
snapshot: Snapshot,
|
||||
}
|
||||
struct Shared {
|
||||
state: Mutex<Controls>,
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
impl Default for Player {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl Player {
|
||||
pub fn new() -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
state: Mutex::new(Controls {
|
||||
session: None,
|
||||
seek: None,
|
||||
latest: false,
|
||||
playing: false,
|
||||
size: (800, 450),
|
||||
generation: 0,
|
||||
muted: false,
|
||||
volume: 1.,
|
||||
quit: false,
|
||||
snapshot: Snapshot {
|
||||
time: Duration::ZERO,
|
||||
playing: false,
|
||||
picture: Picture::Loading,
|
||||
revision: 0,
|
||||
audio_error: None,
|
||||
video_time: None,
|
||||
frames_per_second: 60,
|
||||
},
|
||||
}),
|
||||
wake: Condvar::new(),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
thread::Builder::new()
|
||||
.name("clipbin-player".into())
|
||||
.spawn(move || run(worker))
|
||||
.expect("player thread");
|
||||
Self { shared }
|
||||
}
|
||||
pub fn open(&self, store: Store, id: u64, time: Duration) {
|
||||
self.open_session(store, id, time, false);
|
||||
}
|
||||
pub fn open_latest(&self, store: Store, id: u64) {
|
||||
self.open_session(store, id, Duration::ZERO, true);
|
||||
}
|
||||
fn open_session(&self, store: Store, id: u64, time: Duration, latest: bool) {
|
||||
let mut state = self.shared.state.lock().unwrap();
|
||||
state.session = Some((store, id));
|
||||
state.latest = latest;
|
||||
state.seek = Some(time);
|
||||
state.playing = false;
|
||||
state.generation += 1;
|
||||
state.snapshot.picture = Picture::Loading;
|
||||
state.snapshot.revision += 1;
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
pub fn close(&self) {
|
||||
let mut state = self.shared.state.lock().unwrap();
|
||||
state.session = None;
|
||||
state.playing = false;
|
||||
state.generation += 1;
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
pub fn seek(&self, time: Duration) {
|
||||
let mut state = self.shared.state.lock().unwrap();
|
||||
state.seek = Some(time);
|
||||
state.snapshot.time = time;
|
||||
state.snapshot.picture = Picture::Loading;
|
||||
state.snapshot.revision += 1;
|
||||
state.generation += 1;
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
pub fn play(&self, playing: bool) {
|
||||
self.shared.state.lock().unwrap().playing = playing;
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
pub fn audio(&self, muted: bool, volume: f32) {
|
||||
let mut state = self.shared.state.lock().unwrap();
|
||||
if state.muted != muted {
|
||||
state.seek = Some(state.snapshot.time);
|
||||
state.generation += 1;
|
||||
}
|
||||
state.muted = muted;
|
||||
state.volume = volume.clamp(0., 1.);
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
pub fn resize(&self, size: (u32, u32)) {
|
||||
let mut state = self.shared.state.lock().unwrap();
|
||||
if state.size != size {
|
||||
state.size = size;
|
||||
state.generation += 1;
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
}
|
||||
pub fn snapshot(&self) -> Snapshot {
|
||||
self.shared.state.lock().unwrap().snapshot.clone()
|
||||
}
|
||||
}
|
||||
impl Drop for Player {
|
||||
fn drop(&mut self) {
|
||||
self.shared.state.lock().unwrap().quit = true;
|
||||
self.shared.wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic session clock: decoding delays never accumulate into playback time.
|
||||
#[derive(Debug)]
|
||||
pub struct Clock {
|
||||
base: Duration,
|
||||
started: Instant,
|
||||
playing: bool,
|
||||
}
|
||||
impl Clock {
|
||||
pub fn new(now: Instant) -> Self {
|
||||
Self {
|
||||
base: Duration::ZERO,
|
||||
started: now,
|
||||
playing: false,
|
||||
}
|
||||
}
|
||||
pub fn time(&self, now: Instant) -> Duration {
|
||||
self.base
|
||||
+ if self.playing {
|
||||
now.saturating_duration_since(self.started)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, time: Duration, playing: bool, now: Instant) {
|
||||
self.base = time;
|
||||
self.started = now;
|
||||
self.playing = playing;
|
||||
}
|
||||
}
|
||||
|
||||
fn run(shared: Arc<Shared>) {
|
||||
crate::priority::lower(false);
|
||||
let mut clock = Clock::new(Instant::now());
|
||||
let mut generation = u64::MAX;
|
||||
let mut metadata: Option<Metadata> = None;
|
||||
let mut refreshed = Instant::now();
|
||||
let mut decoder: Option<(PathBuf, Decoder, u64)> = None;
|
||||
let mut audio: Option<crate::audio::Audio> = None;
|
||||
let mut audio_error = None;
|
||||
let mut audio_attempted = false;
|
||||
loop {
|
||||
let (session, seek, playing, size, requested, muted, volume, latest) = {
|
||||
let mut state = shared.state.lock().unwrap();
|
||||
if state.quit {
|
||||
break;
|
||||
}
|
||||
(
|
||||
state.session.clone(),
|
||||
state.seek.take(),
|
||||
state.playing,
|
||||
state.size,
|
||||
state.generation,
|
||||
state.muted,
|
||||
state.volume,
|
||||
std::mem::take(&mut state.latest),
|
||||
)
|
||||
};
|
||||
let changed = requested != generation;
|
||||
if changed {
|
||||
generation = requested;
|
||||
decoder = None;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if changed || now.duration_since(refreshed) >= Duration::from_millis(250) {
|
||||
metadata = session
|
||||
.as_ref()
|
||||
.and_then(|(store, id)| store.metadata(*id).ok());
|
||||
refreshed = now;
|
||||
}
|
||||
let seek = if latest {
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| {
|
||||
metadata
|
||||
.segments
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|segment| {
|
||||
segment.complete && segment.available && segment.end > segment.start
|
||||
})
|
||||
.map(|segment| {
|
||||
segment.end.saturating_sub(Duration::from_secs_f64(
|
||||
1. / segment.frames_per_second.max(1) as f64,
|
||||
))
|
||||
})
|
||||
})
|
||||
.or(Some(Duration::ZERO))
|
||||
} else {
|
||||
seek.map(|time| {
|
||||
metadata
|
||||
.as_ref()
|
||||
.map_or(time, |metadata| time.min(metadata.session.length))
|
||||
})
|
||||
};
|
||||
let transport_changed = playing != clock.playing;
|
||||
if seek.is_some() || transport_changed {
|
||||
clock.set(seek.unwrap_or_else(|| clock.time(now)), playing, now);
|
||||
}
|
||||
if session.is_none() {
|
||||
audio = None;
|
||||
audio_attempted = false;
|
||||
audio_error = None;
|
||||
}
|
||||
if session.is_some() && !audio_attempted && !muted {
|
||||
audio_attempted = true;
|
||||
match crate::audio::Audio::new() {
|
||||
Ok(output) => audio = Some(output),
|
||||
Err(error) => audio_error = Some(error),
|
||||
}
|
||||
}
|
||||
if let Some(audio) = audio.as_mut() {
|
||||
if changed || seek.is_some() || transport_changed {
|
||||
audio.reset(clock.time(now));
|
||||
}
|
||||
audio.controls(playing && !muted, volume);
|
||||
}
|
||||
let Some(metadata) = metadata.as_ref().filter(|_| session.is_some()) else {
|
||||
wait(&shared, Duration::from_millis(100));
|
||||
continue;
|
||||
};
|
||||
let mut time = clock.time(now).min(metadata.session.length);
|
||||
if playing
|
||||
&& !muted
|
||||
&& let Some(audio) = audio.as_mut()
|
||||
{
|
||||
if let Some(device_time) = audio.time(now)
|
||||
&& metadata
|
||||
.locate(device_time)
|
||||
.is_some_and(|at| at.segment.complete)
|
||||
{
|
||||
time = device_time.min(metadata.session.length);
|
||||
clock.set(time, true, now);
|
||||
}
|
||||
audio.catch_up(time);
|
||||
if let Err(error) = audio.fill(metadata) {
|
||||
audio_error = Some(format!("Cannot play audio: {error}"));
|
||||
}
|
||||
if let Some(error) = audio.error() {
|
||||
audio_error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
if time >= metadata.session.length && playing {
|
||||
clock.set(time, false, now);
|
||||
shared.state.lock().unwrap().playing = false;
|
||||
}
|
||||
// Completed files only: active files may still lack seekable cues.
|
||||
let located = metadata.locate(time).filter(|at| at.segment.complete);
|
||||
let start = located
|
||||
.as_ref()
|
||||
.map_or(Duration::ZERO, |at| at.segment.start);
|
||||
let frames_per_second = located
|
||||
.as_ref()
|
||||
.map_or(60, |at| at.segment.frames_per_second);
|
||||
let mut picture = Picture::Gap;
|
||||
let mut presented = false;
|
||||
let started = Instant::now();
|
||||
if let Some(at) = located {
|
||||
let target = decode::frame_index(at.offset, at.segment.frames_per_second);
|
||||
let result = (|| {
|
||||
if decoder.as_ref().is_none_or(|(path, _, index)| {
|
||||
path != &at.segment.path
|
||||
|| target < *index
|
||||
|| target > index.saturating_add(120)
|
||||
}) {
|
||||
let mut opened = Decoder::open(&at.segment.path, at.segment.frames_per_second)?;
|
||||
if at.offset > Duration::ZERO {
|
||||
opened.seek(at.offset)?;
|
||||
}
|
||||
decoder = Some((at.segment.path.clone(), opened, u64::MAX));
|
||||
}
|
||||
let (_, active, last) = decoder.as_mut().unwrap();
|
||||
if *last == target && !changed {
|
||||
return Ok(None);
|
||||
}
|
||||
loop {
|
||||
// A drag or navigation cancels decode-forward work between frames.
|
||||
let state = shared.state.lock().unwrap();
|
||||
if state.quit || state.generation != generation {
|
||||
return Ok(None);
|
||||
}
|
||||
drop(state);
|
||||
let Some((index, frame)) = active.next_frame()? else {
|
||||
return Ok(Some(Picture::Gap));
|
||||
};
|
||||
if index >= target {
|
||||
*last = index;
|
||||
return active
|
||||
.scale(&frame, size)
|
||||
.map(|frame| Some(Picture::Frame(Arc::new(frame))));
|
||||
}
|
||||
}
|
||||
})();
|
||||
match result {
|
||||
Ok(Some(frame)) => {
|
||||
presented = matches!(frame, Picture::Frame(_));
|
||||
picture = frame;
|
||||
}
|
||||
Ok(None) => picture = shared.state.lock().unwrap().snapshot.picture.clone(),
|
||||
Err(error) => {
|
||||
picture = Picture::Error(format!("Cannot read footage: {error}"));
|
||||
decoder = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
decoder = None;
|
||||
}
|
||||
let mut state = shared.state.lock().unwrap();
|
||||
if state.generation == generation {
|
||||
let updated = time != state.snapshot.time
|
||||
|| clock.playing != state.snapshot.playing
|
||||
|| presented
|
||||
|| std::mem::discriminant(&picture)
|
||||
!= std::mem::discriminant(&state.snapshot.picture)
|
||||
|| audio_error != state.snapshot.audio_error;
|
||||
let revision = state.snapshot.revision + u64::from(updated);
|
||||
let video_time = matches!(picture, Picture::Frame(_))
|
||||
.then(|| {
|
||||
decoder.as_ref().map(|(_, _, index)| {
|
||||
start + clipbin_core::store::frame_offset(*index, frames_per_second)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
state.snapshot = Snapshot {
|
||||
time,
|
||||
playing: clock.playing,
|
||||
picture,
|
||||
revision,
|
||||
audio_error: audio_error.clone(),
|
||||
video_time,
|
||||
frames_per_second,
|
||||
};
|
||||
if std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some() && (playing || changed) {
|
||||
eprintln!(
|
||||
"playback time={:.3} decode_ms={:.3} generation={generation} presented={presented} state={} audio_time={:.3} video_time={:.3}",
|
||||
time.as_secs_f64(),
|
||||
started.elapsed().as_secs_f64() * 1000.,
|
||||
match &state.snapshot.picture {
|
||||
Picture::Frame(_) => "frame",
|
||||
Picture::Gap => "gap",
|
||||
Picture::Loading => "loading",
|
||||
Picture::Error(_) => "error",
|
||||
},
|
||||
audio
|
||||
.as_ref()
|
||||
.and_then(|audio| audio.time(Instant::now()))
|
||||
.map_or(-1., |time| time.as_secs_f64()),
|
||||
video_time.map_or(-1., |time| time.as_secs_f64())
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
wait(
|
||||
&shared,
|
||||
if clock.playing {
|
||||
Duration::from_millis(4)
|
||||
} else {
|
||||
Duration::from_millis(100)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
fn wait(shared: &Shared, timeout: Duration) {
|
||||
let state = shared.state.lock().unwrap();
|
||||
if !state.quit && state.seek.is_none() {
|
||||
drop(shared.wake.wait_timeout(state, timeout).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_decoder_stall_skips_overdue_frames_without_shifting_the_clock() {
|
||||
let now = Instant::now();
|
||||
let mut clock = Clock::new(now);
|
||||
clock.set(Duration::ZERO, true, now);
|
||||
let first_target = decode::frame_index(clock.time(now + Duration::from_millis(16)), 60);
|
||||
let after_stall = decode::frame_index(clock.time(now + Duration::from_millis(250)), 60);
|
||||
assert_eq!(first_target, 0);
|
||||
assert_eq!(after_stall, 15);
|
||||
// Scaling only the target discards the intervening frames; their decoding
|
||||
// cost must never be added to the clock's next presentation deadline.
|
||||
assert_eq!(
|
||||
decode::frame_index(clock.time(now + Duration::from_secs(1)), 60),
|
||||
60
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn clock_pauses_and_seek_does_not_accumulate_decode_delay() {
|
||||
let now = Instant::now();
|
||||
let mut clock = Clock::new(now);
|
||||
clock.set(Duration::from_secs(2), true, now);
|
||||
assert_eq!(
|
||||
clock.time(now + Duration::from_secs(3)),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
clock.set(Duration::from_secs(5), false, now);
|
||||
assert_eq!(
|
||||
clock.time(now + Duration::from_secs(100)),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
clock.set(Duration::from_millis(1500), true, now);
|
||||
assert_eq!(
|
||||
decode::frame_index(clock.time(now + Duration::from_millis(100)), 60),
|
||||
96
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Browsing runs below the recorder's normal scheduling priority.
|
||||
pub fn lower(thumbnail: bool) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(error) = rustix::process::setpriority_process(
|
||||
Some(rustix::thread::gettid()),
|
||||
if thumbnail { 10 } else { 3 },
|
||||
) {
|
||||
eprintln!("Cannot lower browsing thread priority: {error}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
crate::windows_priority::lower(thumbnail);
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let _ = thumbnail;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! One background worker and a bounded queue for persistent PNG thumbnails.
|
||||
use crate::decode;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Deterministic cache path beside the source; exported MP4s use the same API.
|
||||
pub fn cache_path(path: &Path) -> PathBuf {
|
||||
path.with_extension("thumbnail.png")
|
||||
}
|
||||
|
||||
/// Decode any MP4/MKV and atomically publish a small thumbnail.
|
||||
pub fn generate(path: &Path) -> Result<PathBuf, String> {
|
||||
let cache = cache_path(path);
|
||||
if cache.is_file() {
|
||||
return Ok(cache);
|
||||
}
|
||||
let mut frame = decode::first_frame(path, (320, 180)).map_err(|error| error.to_string())?;
|
||||
let temporary = cache.with_extension("png.part");
|
||||
for pixel in frame.bgra.as_chunks_mut::<4>().0 {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
image::save_buffer_with_format(
|
||||
&temporary,
|
||||
&frame.bgra,
|
||||
frame.width,
|
||||
frame.height,
|
||||
image::ColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
std::fs::rename(&temporary, &cache).map_err(|error| error.to_string())?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
pub struct Thumbnails {
|
||||
sender: mpsc::SyncSender<PathBuf>,
|
||||
pending: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
}
|
||||
impl Default for Thumbnails {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl Thumbnails {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = mpsc::sync_channel::<PathBuf>(16);
|
||||
let pending = Arc::new(Mutex::new(HashSet::new()));
|
||||
let work = pending.clone();
|
||||
thread::Builder::new()
|
||||
.name("clipbin-thumbnails".into())
|
||||
.spawn(move || {
|
||||
crate::priority::lower(true);
|
||||
for path in receiver {
|
||||
// One decoder thread, one frame, and a duty-cycle cap leave capture priority.
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
if let Err(error) = generate(&path) {
|
||||
eprintln!("thumbnail {}: {error}", path.display());
|
||||
}
|
||||
work.lock().unwrap().remove(&path);
|
||||
}
|
||||
})
|
||||
.expect("thumbnail thread");
|
||||
Self { sender, pending }
|
||||
}
|
||||
pub fn request(&self, path: &Path) -> Option<PathBuf> {
|
||||
let cache = cache_path(path);
|
||||
if cache.is_file() {
|
||||
return Some(cache);
|
||||
}
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if pending.insert(path.to_owned()) && self.sender.try_send(path.to_owned()).is_err() {
|
||||
pending.remove(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The only raw Windows calls in the media crate; no handles escape this bridge.
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_BELOW_NORMAL, THREAD_PRIORITY_LOWEST,
|
||||
};
|
||||
|
||||
pub fn lower(thumbnail: bool) {
|
||||
let priority = if thumbnail {
|
||||
THREAD_PRIORITY_LOWEST
|
||||
} else {
|
||||
THREAD_PRIORITY_BELOW_NORMAL
|
||||
};
|
||||
// GetCurrentThread returns a borrowed pseudo-handle, valid for this thread.
|
||||
if unsafe { SetThreadPriority(GetCurrentThread(), priority) } == 0 {
|
||||
eprintln!(
|
||||
"Cannot lower browsing thread priority: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Run through scripts/validate/playback-fixture.sh for real FFmpeg seek coverage.
|
||||
use clipbin_core::store::Store;
|
||||
use clipbin_media::{
|
||||
decode::{Decoder, scale},
|
||||
player::{Picture, Player},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn fixture() -> Option<PathBuf> {
|
||||
std::env::var_os("CLIPBIN_TEST_MEDIA_DIR").map(PathBuf::from)
|
||||
}
|
||||
#[test]
|
||||
fn seeking_matches_sequential_frames_in_each_independent_segment() {
|
||||
let Some(directory) = fixture() else {
|
||||
return;
|
||||
};
|
||||
for segment in 0..3 {
|
||||
let path = directory.join(format!("1/segment-{segment:03}.mkv"));
|
||||
let mut sequential = Decoder::open(&path, 60).unwrap();
|
||||
let mut expected = Vec::new();
|
||||
while let Some((index, frame)) = sequential.next_frame().unwrap() {
|
||||
expected.push((index, scale(&frame, (160, 90)).unwrap().bgra));
|
||||
}
|
||||
assert_eq!(expected.len(), 120);
|
||||
for target in [0, 1, 59, 60, 61, 119] {
|
||||
let mut decoder = Decoder::open(&path, 60).unwrap();
|
||||
decoder
|
||||
.seek(Duration::from_secs_f64(target as f64 / 60.))
|
||||
.unwrap();
|
||||
loop {
|
||||
let (index, frame) = decoder.next_frame().unwrap().unwrap();
|
||||
if index >= target {
|
||||
assert_eq!(index, target);
|
||||
assert_eq!(
|
||||
scale(&frame, (160, 90)).unwrap().bgra,
|
||||
expected[target as usize].1
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn wait(player: &Player, at: Duration) -> clipbin_media::player::Snapshot {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let snapshot = player.snapshot();
|
||||
if snapshot.time == at && !matches!(snapshot.picture, Picture::Loading) {
|
||||
return snapshot;
|
||||
}
|
||||
assert!(Instant::now() < until, "seek timed out: {snapshot:?}");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn player_crosses_boundaries_and_gaps_and_discards_superseded_seeks() {
|
||||
let Some(directory) = fixture() else {
|
||||
return;
|
||||
};
|
||||
let player = Player::new();
|
||||
player.audio(true, 1.);
|
||||
player.open(Store { root: directory }, 1, Duration::ZERO);
|
||||
assert!(matches!(
|
||||
wait(&player, Duration::ZERO).picture,
|
||||
Picture::Frame(_)
|
||||
));
|
||||
for millis in [1999, 2000, 2500, 4000, 5999, 6000, 7000] {
|
||||
let at = Duration::from_millis(millis);
|
||||
player.seek(at);
|
||||
let snapshot = wait(&player, at);
|
||||
assert_eq!(
|
||||
matches!(snapshot.picture, Picture::Gap),
|
||||
(4000..6000).contains(&millis),
|
||||
"at {millis}"
|
||||
);
|
||||
}
|
||||
for i in 0..50 {
|
||||
player.seek(Duration::from_millis(i * 40));
|
||||
}
|
||||
player.seek(Duration::from_millis(6500));
|
||||
assert!(matches!(
|
||||
wait(&player, Duration::from_millis(6500)).picture,
|
||||
Picture::Frame(_)
|
||||
));
|
||||
player.seek(Duration::from_millis(1900));
|
||||
wait(&player, Duration::from_millis(1900));
|
||||
player.play(true);
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
player.play(false);
|
||||
let snapshot = player.snapshot();
|
||||
assert!((Duration::from_millis(2250)..Duration::from_millis(2500)).contains(&snapshot.time));
|
||||
assert!(matches!(snapshot.picture, Picture::Frame(_)));
|
||||
assert!(snapshot.video_time.unwrap().abs_diff(snapshot.time) < Duration::from_millis(20));
|
||||
player.seek(Duration::from_millis(3900));
|
||||
wait(&player, Duration::from_millis(3900));
|
||||
player.play(true);
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
let snapshot = player.snapshot();
|
||||
assert!(snapshot.time >= Duration::from_millis(4200));
|
||||
assert!(matches!(snapshot.picture, Picture::Gap));
|
||||
player.close();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "clipbin-obs"
|
||||
description = "Clipbin's recording engine adapter over the pinned libobs runtime."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clipbin-core.workspace = true
|
||||
fs4 = { version = "1.1", default-features = false, features = ["sync"] }
|
||||
libobs.workspace = true
|
||||
|
||||
# Raw libobs calls are confined to src/ffi.rs, which opts back in to unsafe code.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
@@ -0,0 +1,208 @@
|
||||
/// What the user chose to record. Switching from a window to a monitor always
|
||||
/// needs the user's choice; the recorder never broadens the target by itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CaptureTarget {
|
||||
/// Windows: Game Capture hooked into one game window, the default for games.
|
||||
/// Elsewhere this is the same as `Window`.
|
||||
Game { window: WindowMatch },
|
||||
/// A single window: Windows Window Capture, X11 Xcomposite, or a
|
||||
/// PipeWire window stream selected through the desktop portal on Wayland.
|
||||
Window { window: WindowMatch },
|
||||
/// A whole monitor, including whatever is shown during alt-tab.
|
||||
Monitor { monitor: MonitorMatch },
|
||||
}
|
||||
|
||||
/// Identifies a window. On Windows this is OBS's `title:class:executable`
|
||||
/// string; on X11 it is the Xcomposite window id; on Wayland the portal picks
|
||||
/// the window, so only `restore_token` matters.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct WindowMatch {
|
||||
pub obs_window: String,
|
||||
pub restore_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MonitorMatch {
|
||||
/// Windows monitor device id, or X11 screen number; unused on Wayland.
|
||||
pub obs_monitor: String,
|
||||
pub restore_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SettingValue {
|
||||
String(String),
|
||||
Int(i64),
|
||||
Bool(bool),
|
||||
}
|
||||
|
||||
/// An OBS source id with its settings.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SourceSpec {
|
||||
pub id: &'static str,
|
||||
pub settings: Vec<(&'static str, SettingValue)>,
|
||||
}
|
||||
|
||||
impl SourceSpec {
|
||||
fn new(id: &'static str) -> Self {
|
||||
Self {
|
||||
id,
|
||||
settings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with(mut self, key: &'static str, value: SettingValue) -> Self {
|
||||
self.settings.push((key, value));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Which Linux display server the recorder is running under.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LinuxSession {
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
impl LinuxSession {
|
||||
pub fn detect() -> Option<Self> {
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
Some(Self::Wayland)
|
||||
} else if std::env::var_os("DISPLAY").is_some() {
|
||||
Some(Self::X11)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptureTarget {
|
||||
#[cfg(windows)]
|
||||
pub fn video_source(&self) -> SourceSpec {
|
||||
// libobs/util/windows/window-helpers.h: CLASS, TITLE, EXE.
|
||||
const WINDOW_PRIORITY_EXE: i64 = 2;
|
||||
use SettingValue::*;
|
||||
match self {
|
||||
Self::Game { window } => SourceSpec::new("game_capture")
|
||||
.with("capture_mode", String("window".into()))
|
||||
.with("window", String(window.obs_window.clone()))
|
||||
// Match the executable first: survives title changes.
|
||||
.with("priority", Int(WINDOW_PRIORITY_EXE))
|
||||
.with("capture_cursor", Bool(false)),
|
||||
// Other Game Capture settings, including its anti-cheat
|
||||
// compatibility hook, keep OBS's defaults.
|
||||
Self::Window { window } => SourceSpec::new("window_capture")
|
||||
.with("window", String(window.obs_window.clone()))
|
||||
.with("priority", Int(WINDOW_PRIORITY_EXE))
|
||||
.with("cursor", Bool(false)),
|
||||
Self::Monitor { monitor } => SourceSpec::new("monitor_capture")
|
||||
.with("monitor_id", String(monitor.obs_monitor.clone()))
|
||||
.with("capture_cursor", Bool(true)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn video_source(&self, session: LinuxSession) -> SourceSpec {
|
||||
use SettingValue::*;
|
||||
match (session, self) {
|
||||
(LinuxSession::Wayland, Self::Game { window } | Self::Window { window }) => {
|
||||
portal_source(
|
||||
"pipewire-window-capture-source",
|
||||
window.restore_token.as_deref(),
|
||||
)
|
||||
}
|
||||
(LinuxSession::Wayland, Self::Monitor { monitor }) => portal_source(
|
||||
"pipewire-screen-capture-source",
|
||||
monitor.restore_token.as_deref(),
|
||||
),
|
||||
(LinuxSession::X11, Self::Game { window } | Self::Window { window }) => {
|
||||
SourceSpec::new("xcomposite_input")
|
||||
.with("capture_window", String(window.obs_window.clone()))
|
||||
.with("show_cursor", Bool(false))
|
||||
}
|
||||
(LinuxSession::X11, Self::Monitor { monitor }) => SourceSpec::new("xshm_input_v2")
|
||||
.with("screen", Int(monitor.obs_monitor.parse().unwrap_or(0)))
|
||||
.with("show_cursor", Bool(true)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_token(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Game { window } | Self::Window { window } => window.restore_token.as_deref(),
|
||||
Self::Monitor { monitor } => monitor.restore_token.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn portal_source(id: &'static str, restore_token: Option<&str>) -> SourceSpec {
|
||||
let spec = SourceSpec::new(id).with("ShowCursor", SettingValue::Bool(true));
|
||||
match restore_token {
|
||||
// Lets the portal restore the earlier selection without asking again.
|
||||
Some(token) => spec.with("RestoreToken", SettingValue::String(token.to_owned())),
|
||||
None => spec,
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop audio and, when opted in, the default microphone.
|
||||
pub fn system_audio_source() -> SourceSpec {
|
||||
#[cfg(windows)]
|
||||
let id = "wasapi_output_capture";
|
||||
#[cfg(not(windows))]
|
||||
let id = "pulse_output_capture";
|
||||
SourceSpec::new(id).with("device_id", SettingValue::String("default".into()))
|
||||
}
|
||||
|
||||
pub fn microphone_source() -> SourceSpec {
|
||||
#[cfg(windows)]
|
||||
let id = "wasapi_input_capture";
|
||||
#[cfg(not(windows))]
|
||||
let id = "pulse_input_capture";
|
||||
SourceSpec::new(id).with("device_id", SettingValue::String("default".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn wayland_monitor_uses_the_portal_and_restores_the_selection() {
|
||||
let target = CaptureTarget::Monitor {
|
||||
monitor: MonitorMatch {
|
||||
obs_monitor: String::new(),
|
||||
restore_token: Some("tok".into()),
|
||||
},
|
||||
};
|
||||
let spec = target.video_source(LinuxSession::Wayland);
|
||||
assert_eq!(spec.id, "pipewire-screen-capture-source");
|
||||
assert!(
|
||||
spec.settings
|
||||
.contains(&("RestoreToken", SettingValue::String("tok".into())))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn game_capture_is_window_capture_off_windows() {
|
||||
let window = WindowMatch::default();
|
||||
let game = CaptureTarget::Game {
|
||||
window: window.clone(),
|
||||
};
|
||||
let plain = CaptureTarget::Window { window };
|
||||
for session in [LinuxSession::Wayland, LinuxSession::X11] {
|
||||
assert_eq!(game.video_source(session), plain.video_source(session));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn game_target_uses_official_game_capture() {
|
||||
let target = CaptureTarget::Game {
|
||||
window: WindowMatch {
|
||||
obs_window: "Game:GameClass:game.exe".into(),
|
||||
restore_token: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(target.video_source().id, "game_capture");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use clipbin_core::VideoCodec;
|
||||
|
||||
/// Official OBS hardware encoders for each codec, most preferred first.
|
||||
///
|
||||
/// Texture-based encoders come first because they take frames straight from
|
||||
/// the GPU; their counterparts copy each frame through system memory. Software
|
||||
/// encoders are deliberately absent: a codec without a working hardware
|
||||
/// encoder is unavailable, never silently software-encoded.
|
||||
pub fn hardware_candidates(codec: VideoCodec) -> &'static [&'static str] {
|
||||
#[cfg(windows)]
|
||||
return match codec {
|
||||
VideoCodec::H264 => &[
|
||||
"obs_nvenc_h264_tex",
|
||||
"h264_texture_amf",
|
||||
"obs_qsv11_v2",
|
||||
"obs_nvenc_h264_soft",
|
||||
"obs_qsv11_soft_v2",
|
||||
"h264_fallback_amf",
|
||||
],
|
||||
VideoCodec::Hevc => &[
|
||||
"obs_nvenc_hevc_tex",
|
||||
"h265_texture_amf",
|
||||
"obs_qsv11_hevc",
|
||||
"obs_nvenc_hevc_soft",
|
||||
"obs_qsv11_hevc_soft",
|
||||
"h265_fallback_amf",
|
||||
],
|
||||
VideoCodec::Av1 => &[
|
||||
"obs_nvenc_av1_tex",
|
||||
"av1_texture_amf",
|
||||
"obs_qsv11_av1",
|
||||
"obs_nvenc_av1_soft",
|
||||
"obs_qsv11_av1_soft",
|
||||
"av1_fallback_amf",
|
||||
],
|
||||
};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
return match codec {
|
||||
VideoCodec::H264 => &[
|
||||
"obs_nvenc_h264_tex",
|
||||
"ffmpeg_vaapi_tex",
|
||||
"obs_nvenc_h264_soft",
|
||||
"ffmpeg_vaapi",
|
||||
],
|
||||
VideoCodec::Hevc => &[
|
||||
"obs_nvenc_hevc_tex",
|
||||
"hevc_ffmpeg_vaapi_tex",
|
||||
"obs_nvenc_hevc_soft",
|
||||
"hevc_ffmpeg_vaapi",
|
||||
],
|
||||
VideoCodec::Av1 => &[
|
||||
"obs_nvenc_av1_tex",
|
||||
"av1_ffmpeg_vaapi_tex",
|
||||
"obs_nvenc_av1_soft",
|
||||
"av1_ffmpeg_vaapi",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// Registered encoders for `codec`, in preference order.
|
||||
///
|
||||
/// Registration only means the plugin loaded. Whether the device can actually
|
||||
/// encode is proven by starting an output; see `Recorder`.
|
||||
pub fn registered_candidates<'a>(
|
||||
codec: VideoCodec,
|
||||
registered: &[impl AsRef<str>],
|
||||
) -> Vec<&'a str> {
|
||||
hardware_candidates(codec)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| registered.iter().any(|r| r.as_ref() == *id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub const ALL_CODECS: [VideoCodec; 3] = [VideoCodec::H264, VideoCodec::Hevc, VideoCodec::Av1];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn candidates_keep_preference_order_and_skip_unregistered() {
|
||||
let first = hardware_candidates(VideoCodec::Hevc)[0];
|
||||
let third = hardware_candidates(VideoCodec::Hevc)[2];
|
||||
let registered = [third, "obs_x264", first];
|
||||
assert_eq!(
|
||||
registered_candidates(VideoCodec::Hevc, ®istered),
|
||||
[first, third]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_encoders_are_never_candidates() {
|
||||
for codec in ALL_CODECS {
|
||||
for id in hardware_candidates(codec) {
|
||||
assert!(
|
||||
![
|
||||
"obs_x264",
|
||||
"ffmpeg_svt_av1",
|
||||
"ffmpeg_aom_av1",
|
||||
"ffmpeg_openh264"
|
||||
]
|
||||
.contains(id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Clipbin's recording engine on top of the pinned libobs runtime.
|
||||
//!
|
||||
//! Capture uses only official OBS sources: Game Capture and Window Capture on
|
||||
//! Windows, Xcomposite on X11, and PipeWire through the desktop portal on
|
||||
//! Wayland. See SPEC.md.
|
||||
|
||||
pub mod capture;
|
||||
pub mod encoders;
|
||||
mod ffi;
|
||||
mod recorder;
|
||||
pub mod runtime;
|
||||
mod thread;
|
||||
|
||||
pub use recorder::{
|
||||
Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig,
|
||||
SEGMENT_EXTENSION, StatusTracker, StopReason,
|
||||
};
|
||||
pub use runtime::{RuntimeError, RuntimeLayout};
|
||||
@@ -0,0 +1,733 @@
|
||||
use std::{
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
mpsc::{self, Receiver, Sender},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use clipbin_core::{RecordingStatus, VideoCodec};
|
||||
|
||||
use crate::{
|
||||
RuntimeLayout,
|
||||
capture::{SettingValue, SourceSpec},
|
||||
encoders::registered_candidates,
|
||||
ffi::{self, CaptureSpec, OutputSpec, Pipeline, Session, StartupSpec},
|
||||
runtime::PLUGINS,
|
||||
thread::ObsThread,
|
||||
};
|
||||
|
||||
/// Segments are Matroska: a segment cut short by a crash stays readable, and
|
||||
/// export remuxes to MP4 without re-encoding.
|
||||
pub const SEGMENT_EXTENSION: &str = "mkv";
|
||||
|
||||
/// The single libobs instance. Dropping it shuts OBS down.
|
||||
pub struct Engine {
|
||||
obs: Arc<ObsThread>,
|
||||
session: Option<Session>,
|
||||
frames_per_second: u32,
|
||||
#[cfg(target_os = "linux")]
|
||||
_glib: ffi::GlibLoop,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// Starts libobs. Each recording sets its own size once capture has a picture.
|
||||
pub fn start(layout: &RuntimeLayout, frames_per_second: u32) -> Result<Self, EngineError> {
|
||||
let path = |path: PathBuf| path.to_string_lossy().into_owned();
|
||||
let spec = StartupSpec {
|
||||
libobs_data_dir: path(layout.libobs_data_dir()),
|
||||
plugins: PLUGINS
|
||||
.iter()
|
||||
.map(|plugin| {
|
||||
(
|
||||
path(layout.plugin_library(plugin)),
|
||||
path(layout.plugin_data_dir(plugin)),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
// A placeholder canvas until a recording knows its source's size.
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frames_per_second,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
let glib = ffi::GlibLoop::start();
|
||||
let obs = Arc::new(ObsThread::spawn());
|
||||
let session = obs
|
||||
.run(move || ffi::startup(&spec))
|
||||
.map_err(EngineError::Startup)?;
|
||||
Ok(Self {
|
||||
obs,
|
||||
session: Some(session),
|
||||
frames_per_second,
|
||||
#[cfg(target_os = "linux")]
|
||||
_glib: glib,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn obs_version(&self) -> &str {
|
||||
&self.session().version
|
||||
}
|
||||
|
||||
/// Bundled plugins that declined to initialise, such as NVENC without an
|
||||
/// NVIDIA GPU. Their encoders are simply not registered.
|
||||
pub fn plugins_failed(&self) -> &[String] {
|
||||
&self.session().plugins_failed
|
||||
}
|
||||
|
||||
fn session(&self) -> &Session {
|
||||
self.session.as_ref().expect("the session lives until drop")
|
||||
}
|
||||
|
||||
/// Registered video encoders as `(id, codec)` pairs, such as `("ffmpeg_vaapi_tex", "h264")`.
|
||||
pub fn registered_video_encoders(&self) -> Vec<(String, String)> {
|
||||
self.obs.run(ffi::registered_video_encoders)
|
||||
}
|
||||
|
||||
/// Registered hardware encoders for `codec`, most preferred first.
|
||||
pub fn hardware_encoders(&self, codec: VideoCodec) -> Vec<String> {
|
||||
let registered: Vec<String> = self
|
||||
.registered_video_encoders()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
registered_candidates(codec, ®istered)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Starts capture. The segment output starts once the source has a
|
||||
/// picture (see `Recording::poll`), so nothing is written before then.
|
||||
pub fn start_recording(&self, config: RecordingConfig) -> Result<Recording, EngineError> {
|
||||
std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?;
|
||||
let (sender, events) = mpsc::channel();
|
||||
let current_segment = Arc::new(Mutex::new(None));
|
||||
let waits_for_picture = config.video_source.is_some();
|
||||
let capture = CaptureSpec {
|
||||
video_source: config.video_source,
|
||||
system_audio: config.system_audio,
|
||||
microphone: config.microphone,
|
||||
};
|
||||
let output = OutputSpec {
|
||||
frames_per_second: config.frames_per_second.unwrap_or(self.frames_per_second),
|
||||
resolution: config.resolution,
|
||||
video_encoder: config.video_encoder,
|
||||
video_encoder_settings: vec![
|
||||
("rate_control", SettingValue::String("CBR".into())),
|
||||
("bitrate", SettingValue::Int(config.video_bitrate_kbps)),
|
||||
(
|
||||
"keyint_sec",
|
||||
SettingValue::Int(config.keyframe_interval_seconds),
|
||||
),
|
||||
],
|
||||
audio_bitrate_kbps: 160,
|
||||
segment_dir: forward_slashes(&config.segment_dir),
|
||||
segment_extension: SEGMENT_EXTENSION,
|
||||
segment_seconds: config.segment_seconds,
|
||||
events: sender.clone(),
|
||||
current_segment: Arc::clone(¤t_segment),
|
||||
};
|
||||
let pipeline = self
|
||||
.obs
|
||||
.run(move || Pipeline::build(&capture, output).map(Arc::new))
|
||||
.map_err(EngineError::Pipeline)?;
|
||||
Ok(Recording {
|
||||
obs: Arc::clone(&self.obs),
|
||||
pipeline: Some(pipeline),
|
||||
events,
|
||||
sender,
|
||||
waits_for_picture,
|
||||
current_segment,
|
||||
written: Mutex::default(),
|
||||
segment_dir: config.segment_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Engine {
|
||||
fn drop(&mut self) {
|
||||
if let Some(session) = self.session.take() {
|
||||
self.obs.run(move || ffi::shutdown(session));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths in OBS settings use forward slashes on every platform.
|
||||
fn forward_slashes(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
pub struct RecordingConfig {
|
||||
/// Override the engine startup rate for this recording.
|
||||
pub frames_per_second: Option<u32>,
|
||||
pub video_source: Option<SourceSpec>,
|
||||
pub system_audio: Option<SourceSpec>,
|
||||
pub microphone: Option<SourceSpec>,
|
||||
/// The recording size; `None` follows the captured window or monitor.
|
||||
pub resolution: Option<(u32, u32)>,
|
||||
pub video_encoder: String,
|
||||
pub video_bitrate_kbps: i64,
|
||||
pub keyframe_interval_seconds: i64,
|
||||
/// Segments split at the first keyframe after this many seconds.
|
||||
pub segment_seconds: i64,
|
||||
pub segment_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// An active recording. Dropping it stops the output and releases its OBS
|
||||
/// objects on the OBS thread. Drop recordings before their `Engine`.
|
||||
pub struct Recording {
|
||||
obs: Arc<ObsThread>,
|
||||
pipeline: Option<Arc<Pipeline>>,
|
||||
events: Receiver<RecorderEvent>,
|
||||
sender: Sender<RecorderEvent>,
|
||||
/// Without a video source (encoder checks) there is no picture to wait for.
|
||||
waits_for_picture: bool,
|
||||
current_segment: Arc<Mutex<Option<String>>>,
|
||||
written: Mutex<Written>,
|
||||
segment_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Bytes on disk across this recording's segments.
|
||||
#[derive(Default)]
|
||||
struct Written {
|
||||
current: Option<String>,
|
||||
finished_bytes: u64,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
fn with_pipeline<T: Send + 'static>(
|
||||
&self,
|
||||
operation: impl FnOnce(&Pipeline) -> T + Send + 'static,
|
||||
) -> T {
|
||||
let pipeline = Arc::clone(
|
||||
self.pipeline
|
||||
.as_ref()
|
||||
.expect("the pipeline lives until drop"),
|
||||
);
|
||||
self.obs.run(move || operation(&pipeline))
|
||||
}
|
||||
|
||||
pub fn events(&self) -> &Receiver<RecorderEvent> {
|
||||
&self.events
|
||||
}
|
||||
|
||||
/// Samples the recording, starting the segment output the first time the
|
||||
/// source has a picture. Call it regularly (the probe uses once a second).
|
||||
///
|
||||
/// Health combines OBS's counters with what reached disk: OBS counts bytes
|
||||
/// handed to its muxer process, which keep rising even if that process
|
||||
/// cannot write, so only growth on disk proves footage is saved.
|
||||
pub fn poll(&self) -> Result<Health, EngineError> {
|
||||
let mut health = self.with_pipeline(Pipeline::health);
|
||||
if (health.has_picture() || !self.waits_for_picture)
|
||||
&& let Some((width, height)) = self
|
||||
.with_pipeline(Pipeline::start_output)
|
||||
.map_err(EngineError::Start)?
|
||||
{
|
||||
let _ = self
|
||||
.sender
|
||||
.send(RecorderEvent::OutputSized { width, height });
|
||||
}
|
||||
health.disk_available_bytes = fs4::available_space(&self.segment_dir).ok();
|
||||
let current = self
|
||||
.current_segment
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|path| path.clone());
|
||||
let size = |path: &str| std::fs::metadata(path).map_or(0, |metadata| metadata.len());
|
||||
if let Ok(mut written) = self.written.lock() {
|
||||
if written.current != current {
|
||||
// The previous segment is complete; count its final size.
|
||||
if let Some(finished) = written.current.take() {
|
||||
written.finished_bytes += size(&finished);
|
||||
}
|
||||
written.current = current;
|
||||
}
|
||||
health.written_bytes =
|
||||
written.finished_bytes + written.current.as_deref().map_or(0, size);
|
||||
}
|
||||
Ok(health)
|
||||
}
|
||||
|
||||
pub fn restore_token(&self) -> Option<String> {
|
||||
self.with_pipeline(Pipeline::restore_token)
|
||||
}
|
||||
|
||||
/// Requests a stop; `OutputStopped` follows once the last segment is
|
||||
/// finalised, or at once if the output never started.
|
||||
pub fn stop(&self) {
|
||||
if !self.with_pipeline(Pipeline::stop) {
|
||||
let _ = self.sender.send(RecorderEvent::OutputStopped {
|
||||
code: libobs::OBS_OUTPUT_SUCCESS.into(),
|
||||
last_error: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Recording {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pipeline) = self.pipeline.take() {
|
||||
// OBS objects must be released on the OBS thread.
|
||||
self.obs.run(move || drop(pipeline));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RecorderEvent {
|
||||
/// Capture has a picture and the output was created at this size.
|
||||
OutputSized { width: u32, height: u32 },
|
||||
/// OBS reports the output as started. Not yet proof that footage is written.
|
||||
OutputStarted,
|
||||
/// The muxer opened a new segment file; the previous one is complete.
|
||||
SegmentStarted {
|
||||
path: String,
|
||||
total_frames: u64,
|
||||
dropped_frames: u64,
|
||||
},
|
||||
OutputStopped {
|
||||
code: i64,
|
||||
last_error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Counters for honest recording status: OBS accepting a start request is not
|
||||
/// enough, frames must reach disk and the source must have a picture.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Health {
|
||||
pub source_width: u32,
|
||||
pub source_height: u32,
|
||||
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||
/// which report loss through their size, and before the portal answers.
|
||||
pub pipewire: Option<PipeWireState>,
|
||||
/// A configured capture source lost delivery (Linux) or reported a device error (Windows).
|
||||
pub audio_lost: bool,
|
||||
pub output_active: bool,
|
||||
pub total_frames: u64,
|
||||
pub dropped_frames: u64,
|
||||
pub total_bytes: u64,
|
||||
pub lagged_frames: u64,
|
||||
/// Bytes on disk across all of this recording's segments.
|
||||
pub written_bytes: u64,
|
||||
/// Free space on the recording volume, if it could be read.
|
||||
pub disk_available_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PipeWireState {
|
||||
Streaming = 1,
|
||||
/// The stream exists but is not delivering frames.
|
||||
Stopped = 2,
|
||||
/// The portal request was denied, cancelled by the user, or failed.
|
||||
Refused = 3,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
/// Whether the capture source is delivering a picture.
|
||||
pub fn has_picture(&self) -> bool {
|
||||
self.source_width > 0
|
||||
&& self.source_height > 0
|
||||
&& matches!(self.pipewire, None | Some(PipeWireState::Streaming))
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the status shown to the user from health samples and output events.
|
||||
///
|
||||
/// Recording is reported only while the source has a picture and encoded data
|
||||
/// keeps reaching the muxer. A stall after recording began is a failure, not a
|
||||
/// silent pause.
|
||||
#[derive(Debug)]
|
||||
pub struct StatusTracker {
|
||||
status: RecordingStatus,
|
||||
min_free_bytes: u64,
|
||||
stall_limit: Duration,
|
||||
last_written: u64,
|
||||
last_progress: Instant,
|
||||
}
|
||||
|
||||
impl StatusTracker {
|
||||
/// Free space below which recording stops, so the last segment can still
|
||||
/// be finalised rather than failing mid-write.
|
||||
pub const DEFAULT_MIN_FREE_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// Matroska output can stay buffered until its segment closes, so footage
|
||||
/// may appear on disk only once per segment. A stall is declared after two
|
||||
/// segment lengths without growth.
|
||||
pub fn new(now: Instant, min_free_bytes: u64, segment_length: Duration) -> Self {
|
||||
Self {
|
||||
status: RecordingStatus::Starting,
|
||||
min_free_bytes,
|
||||
stall_limit: (segment_length * 2 + Duration::from_secs(1)).max(Duration::from_secs(3)),
|
||||
last_written: 0,
|
||||
last_progress: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// How long footage may stop reaching disk before recording counts as failed.
|
||||
pub fn stall_limit(&self) -> Duration {
|
||||
self.stall_limit
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &RecordingStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
pub fn sample(&mut self, health: &Health, now: Instant) -> &RecordingStatus {
|
||||
if matches!(self.status, RecordingStatus::Failed { .. }) {
|
||||
return &self.status;
|
||||
}
|
||||
let progressed = health.written_bytes > self.last_written;
|
||||
if progressed {
|
||||
self.last_written = health.written_bytes;
|
||||
self.last_progress = now;
|
||||
}
|
||||
if health.pipewire == Some(PipeWireState::Refused) {
|
||||
self.status = RecordingStatus::Failed {
|
||||
reason: "screen capture was not allowed: the source selection was denied, cancelled or failed"
|
||||
.into(),
|
||||
};
|
||||
return &self.status;
|
||||
}
|
||||
let has_picture = health.has_picture();
|
||||
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
|
||||
if let Some(available) = health.disk_available_bytes
|
||||
&& available < self.min_free_bytes
|
||||
{
|
||||
self.status = RecordingStatus::Failed {
|
||||
reason: format!(
|
||||
"not enough disk space: {} MB free, {} MB needed",
|
||||
available / 1_000_000,
|
||||
self.min_free_bytes / 1_000_000
|
||||
),
|
||||
};
|
||||
return &self.status;
|
||||
}
|
||||
|
||||
self.status = match (&self.status, has_picture, progressed || !stalled) {
|
||||
(_, true, true)
|
||||
if health.output_active && health.total_frames > 0 && self.last_written > 0 =>
|
||||
{
|
||||
if health.audio_lost {
|
||||
RecordingStatus::Degraded {
|
||||
reason: "audio capture lost".into(),
|
||||
}
|
||||
} else {
|
||||
RecordingStatus::Recording
|
||||
}
|
||||
}
|
||||
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
|
||||
RecordingStatus::Failed {
|
||||
reason: "the capture source stopped providing a picture".into(),
|
||||
}
|
||||
}
|
||||
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, true, false) => {
|
||||
RecordingStatus::Failed {
|
||||
reason: format!(
|
||||
"no footage was written for {} seconds",
|
||||
self.stall_limit.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
(_, false, _) => RecordingStatus::WaitingForCapture,
|
||||
(status, _, _) => status.clone(),
|
||||
};
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// A failure stays visible after the output stops cleanly; it clears only
|
||||
/// when a new recording starts.
|
||||
pub fn stopped(&mut self, reason: StopReason, last_error: Option<&str>) -> &RecordingStatus {
|
||||
self.status = match reason {
|
||||
StopReason::Success if matches!(self.status, RecordingStatus::Failed { .. }) => {
|
||||
return &self.status;
|
||||
}
|
||||
StopReason::Success => RecordingStatus::Idle,
|
||||
reason => RecordingStatus::Failed {
|
||||
reason: match last_error {
|
||||
Some(error) => format!("{reason:?}: {error}"),
|
||||
None => format!("{reason:?}"),
|
||||
},
|
||||
},
|
||||
};
|
||||
&self.status
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an output stopped, from `obs-output.h`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StopReason {
|
||||
Success,
|
||||
BadPath,
|
||||
Unsupported,
|
||||
NoSpace,
|
||||
EncodeError,
|
||||
Disconnected,
|
||||
Error,
|
||||
Other(i64),
|
||||
}
|
||||
|
||||
impl StopReason {
|
||||
pub fn from_code(code: i64) -> Self {
|
||||
match code as i32 {
|
||||
code if code == libobs::OBS_OUTPUT_SUCCESS as i32 => Self::Success,
|
||||
libobs::OBS_OUTPUT_BAD_PATH => Self::BadPath,
|
||||
libobs::OBS_OUTPUT_UNSUPPORTED => Self::Unsupported,
|
||||
libobs::OBS_OUTPUT_NO_SPACE => Self::NoSpace,
|
||||
libobs::OBS_OUTPUT_ENCODE_ERROR => Self::EncodeError,
|
||||
libobs::OBS_OUTPUT_DISCONNECTED => Self::Disconnected,
|
||||
libobs::OBS_OUTPUT_ERROR => Self::Error,
|
||||
_ => Self::Other(code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
Startup(String),
|
||||
Io(std::io::Error),
|
||||
Pipeline(String),
|
||||
Start(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Startup(error) => write!(formatter, "OBS did not start: {error}"),
|
||||
Self::Io(error) => write!(formatter, "{error}"),
|
||||
Self::Pipeline(error) => write!(formatter, "could not set up recording: {error}"),
|
||||
Self::Start(error) => write!(formatter, "recording did not start: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SEGMENT: Duration = Duration::from_secs(2);
|
||||
|
||||
fn healthy(bytes: u64) -> Health {
|
||||
Health {
|
||||
source_width: 1920,
|
||||
source_height: 1080,
|
||||
pipewire: None,
|
||||
output_active: true,
|
||||
total_frames: bytes / 1000,
|
||||
dropped_frames: 0,
|
||||
total_bytes: bytes,
|
||||
lagged_frames: 0,
|
||||
written_bytes: bytes,
|
||||
audio_lost: false,
|
||||
disk_available_bytes: Some(u64::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_loss_degrades_and_recovers_without_masking_video_loss() {
|
||||
let now = Instant::now();
|
||||
let mut tracker = StatusTracker::new(now, 0, SEGMENT);
|
||||
let mut health = healthy(10_000);
|
||||
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
|
||||
health.audio_lost = true;
|
||||
assert!(matches!(
|
||||
tracker.sample(&health, now),
|
||||
RecordingStatus::Degraded { .. }
|
||||
));
|
||||
health.audio_lost = false;
|
||||
assert_eq!(tracker.sample(&health, now), &RecordingStatus::Recording);
|
||||
health.audio_lost = true;
|
||||
tracker.sample(&health, now);
|
||||
health.source_width = 0;
|
||||
assert!(matches!(
|
||||
tracker.sample(&health, now),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_disk_space_fails_before_the_disk_is_full() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 1000, SEGMENT);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(5000), start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
let low = Health {
|
||||
disk_available_bytes: Some(999),
|
||||
..healthy(9000)
|
||||
};
|
||||
assert!(
|
||||
matches!(tracker.sample(&low, start), RecordingStatus::Failed { reason } if reason.contains("disk space"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_must_reach_disk_not_just_the_muxer() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let unwritten = Health {
|
||||
written_bytes: 0,
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_ne!(
|
||||
tracker.sample(&unwritten, start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(5000), start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
// OBS keeps counting, but the file on disk stopped growing.
|
||||
let stuck = Health {
|
||||
total_bytes: 90_000,
|
||||
..healthy(5000)
|
||||
};
|
||||
let later = start + tracker.stall_limit();
|
||||
assert!(matches!(
|
||||
tracker.sample(&stuck, later),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_needs_a_picture_and_written_footage() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let no_picture = Health {
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.sample(&no_picture, start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(0), start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(5000), start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stall_or_lost_source_fails_instead_of_pausing() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
tracker.sample(&healthy(5000), start);
|
||||
let later = start + tracker.stall_limit();
|
||||
assert!(matches!(
|
||||
tracker.sample(&healthy(5000), later),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
tracker.sample(&healthy(5000), start);
|
||||
let lost = Health {
|
||||
source_width: 0,
|
||||
..healthy(6000)
|
||||
};
|
||||
assert!(matches!(
|
||||
tracker.sample(&lost, start),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
// Failure persists until a new recording starts.
|
||||
assert!(matches!(
|
||||
tracker.sample(&healthy(9000), start),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_pipewire_stream_is_a_lost_source_despite_its_size() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let streaming = Health {
|
||||
pipewire: Some(PipeWireState::Streaming),
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.sample(&streaming, start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
let paused = Health {
|
||||
pipewire: Some(PipeWireState::Stopped),
|
||||
..healthy(9000)
|
||||
};
|
||||
assert!(matches!(
|
||||
tracker.sample(&paused, start),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_portal_request_fails_while_waiting_for_capture() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let waiting = Health {
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.sample(&waiting, start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
let refused = Health {
|
||||
pipewire: Some(PipeWireState::Refused),
|
||||
..waiting
|
||||
};
|
||||
assert!(matches!(
|
||||
tracker.sample(&refused, start),
|
||||
RecordingStatus::Failed { reason } if reason.contains("not allowed")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_errors_fail_and_clean_stops_go_idle() {
|
||||
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
||||
assert!(matches!(
|
||||
tracker.stopped(StopReason::NoSpace, Some("disk full")),
|
||||
RecordingStatus::Failed { reason } if reason.contains("disk full")
|
||||
));
|
||||
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
||||
assert_eq!(
|
||||
tracker.stopped(StopReason::Success, None),
|
||||
&RecordingStatus::Idle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_after_a_failure_keeps_the_failure() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
tracker.sample(&healthy(5000), start);
|
||||
let lost = Health {
|
||||
source_width: 0,
|
||||
..healthy(6000)
|
||||
};
|
||||
tracker.sample(&lost, start);
|
||||
assert!(matches!(
|
||||
tracker.stopped(StopReason::Success, None),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_codes_map_to_reasons() {
|
||||
assert_eq!(StopReason::from_code(0), StopReason::Success);
|
||||
assert_eq!(
|
||||
StopReason::from_code(libobs::OBS_OUTPUT_NO_SPACE.into()),
|
||||
StopReason::NoSpace
|
||||
);
|
||||
assert_eq!(StopReason::from_code(-99), StopReason::Other(-99));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use std::{
|
||||
env, fmt, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Where the bundled OBS runtime's data and plugins live.
|
||||
///
|
||||
/// Packaged builds keep OBS's own install layout and place the Clipbin
|
||||
/// executable where OBS expects its front end, because OBS finds helper
|
||||
/// executables such as `obs-ffmpeg-mux` next to the running executable:
|
||||
///
|
||||
/// * Linux: `<root>/bin/clipbin`, `<root>/lib/obs-plugins`, `<root>/share/obs`.
|
||||
/// * Windows: `<root>/bin/64bit/clipbin.exe`, `<root>/obs-plugins/64bit`, `<root>/data`.
|
||||
///
|
||||
/// Development builds use `CLIPBIN_OBS_RUNTIME_DIR` (see `.cargo/config.toml`),
|
||||
/// the directory holding `<os>-<arch>` runtimes assembled by `scripts/obs/`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeLayout {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl RuntimeLayout {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Finds the runtime for this executable, preferring the development override.
|
||||
pub fn discover() -> Result<Self, RuntimeError> {
|
||||
// Debug builds also remember the development runtime from build time,
|
||||
// so binaries in target/ work when started outside Cargo.
|
||||
let dev_dir = env::var_os("CLIPBIN_OBS_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
cfg!(debug_assertions)
|
||||
.then(|| option_env!("CLIPBIN_OBS_RUNTIME_DIR").map(PathBuf::from))?
|
||||
});
|
||||
let layout = match dev_dir {
|
||||
Some(dir) => Self::new(dir.join(platform_dir())),
|
||||
None => {
|
||||
let exe = env::current_exe().map_err(RuntimeError::Executable)?;
|
||||
Self::from_executable(&exe).ok_or_else(|| RuntimeError::Layout(exe.clone()))?
|
||||
}
|
||||
};
|
||||
layout.check()?;
|
||||
Ok(layout)
|
||||
}
|
||||
|
||||
fn from_executable(exe: &Path) -> Option<Self> {
|
||||
let bin_dir = exe.parent()?;
|
||||
let root = if cfg!(windows) {
|
||||
bin_dir.parent()?.parent()?
|
||||
} else {
|
||||
bin_dir.parent()?
|
||||
};
|
||||
Some(Self::new(root))
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn libobs_data_dir(&self) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
self.root.join("data").join("libobs")
|
||||
} else {
|
||||
self.root.join("share").join("obs").join("libobs")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin_bin_dir(&self) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
self.root.join("obs-plugins").join("64bit")
|
||||
} else {
|
||||
self.root.join("lib").join("obs-plugins")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin_data_dir(&self, plugin: &str) -> PathBuf {
|
||||
let base = if cfg!(windows) {
|
||||
self.root.join("data").join("obs-plugins")
|
||||
} else {
|
||||
self.root.join("share").join("obs").join("obs-plugins")
|
||||
};
|
||||
base.join(plugin)
|
||||
}
|
||||
|
||||
pub fn plugin_library(&self, plugin: &str) -> PathBuf {
|
||||
let extension = if cfg!(windows) { "dll" } else { "so" };
|
||||
self.plugin_bin_dir().join(format!("{plugin}.{extension}"))
|
||||
}
|
||||
|
||||
/// The OBS version recorded when the runtime was assembled.
|
||||
pub fn version(&self) -> Option<String> {
|
||||
let manifest = std::fs::read_to_string(self.root.join("VERSION")).ok()?;
|
||||
manifest
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("obs="))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), RuntimeError> {
|
||||
if !self.libobs_data_dir().is_dir() {
|
||||
return Err(RuntimeError::Missing(self.libobs_data_dir()));
|
||||
}
|
||||
for plugin in PLUGINS {
|
||||
if !self.plugin_library(plugin).is_file() {
|
||||
return Err(RuntimeError::Missing(self.plugin_library(plugin)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The official OBS plugins Clipbin loads, and the only ones it ships.
|
||||
/// Keep in sync with the allowlists in scripts/obs/.
|
||||
#[cfg(windows)]
|
||||
pub const PLUGINS: &[&str] = &[
|
||||
"win-capture",
|
||||
"win-wasapi",
|
||||
"obs-ffmpeg",
|
||||
"obs-nvenc",
|
||||
"obs-qsv11",
|
||||
];
|
||||
#[cfg(not(windows))]
|
||||
pub const PLUGINS: &[&str] = &[
|
||||
"linux-capture",
|
||||
"linux-pipewire",
|
||||
"linux-pulseaudio",
|
||||
"obs-ffmpeg",
|
||||
"obs-nvenc",
|
||||
];
|
||||
|
||||
/// The runtime directory name scripts/obs/ assembles for this platform, such as
|
||||
/// `linux-x86_64` or `windows-x86_64`.
|
||||
pub fn platform_dir() -> String {
|
||||
format!("{}-{}", env::consts::OS, env::consts::ARCH)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RuntimeError {
|
||||
Executable(io::Error),
|
||||
Layout(PathBuf),
|
||||
Missing(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for RuntimeError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Executable(error) => write!(formatter, "cannot locate the executable: {error}"),
|
||||
Self::Layout(exe) => write!(
|
||||
formatter,
|
||||
"{} is not inside an OBS runtime layout",
|
||||
exe.display()
|
||||
),
|
||||
Self::Missing(dir) => write!(
|
||||
formatter,
|
||||
"the OBS runtime is incomplete: {} is missing (run scripts/obs/ to assemble it)",
|
||||
dir.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packaged_layout_is_relative_to_the_executable() {
|
||||
let (exe, root) = if cfg!(windows) {
|
||||
(r"C:\Clipbin\bin\64bit\clipbin.exe", r"C:\Clipbin")
|
||||
} else {
|
||||
("/opt/clipbin/bin/clipbin", "/opt/clipbin")
|
||||
};
|
||||
let layout = RuntimeLayout::from_executable(Path::new(exe)).unwrap();
|
||||
assert_eq!(layout.root(), Path::new(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_paths_follow_the_obs_install_layout() {
|
||||
let layout = RuntimeLayout::new("rt");
|
||||
if cfg!(windows) {
|
||||
assert_eq!(layout.plugin_bin_dir(), Path::new(r"rt\obs-plugins\64bit"));
|
||||
assert_eq!(layout.libobs_data_dir(), Path::new(r"rt\data\libobs"));
|
||||
} else {
|
||||
assert_eq!(layout.plugin_bin_dir(), Path::new("rt/lib/obs-plugins"));
|
||||
assert_eq!(layout.libobs_data_dir(), Path::new("rt/share/obs/libobs"));
|
||||
assert_eq!(
|
||||
layout.plugin_library("obs-ffmpeg"),
|
||||
Path::new("rt/lib/obs-plugins/obs-ffmpeg.so")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.plugin_data_dir("obs-ffmpeg"),
|
||||
Path::new("rt/share/obs/obs-plugins/obs-ffmpeg")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::{
|
||||
sync::mpsc::{self, Sender},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
type Job = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// The one thread that makes libobs calls. libobs is not thread-safe for most
|
||||
/// calls, so every OBS operation is sent here and run in order.
|
||||
pub(crate) struct ObsThread {
|
||||
jobs: Option<Sender<Job>>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ObsThread {
|
||||
pub(crate) fn spawn() -> Self {
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
let handle = thread::Builder::new()
|
||||
.name("obs".into())
|
||||
.spawn(move || {
|
||||
for job in receiver {
|
||||
job();
|
||||
}
|
||||
})
|
||||
.expect("the OBS thread can be spawned");
|
||||
Self {
|
||||
jobs: Some(jobs),
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `job` on the OBS thread and waits for its result.
|
||||
pub(crate) fn run<T: Send + 'static>(&self, job: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (result, receiver) = mpsc::sync_channel(1);
|
||||
self.jobs
|
||||
.as_ref()
|
||||
.expect("the OBS thread runs until drop")
|
||||
.send(Box::new(move || {
|
||||
let _ = result.send(job());
|
||||
}))
|
||||
.expect("the OBS thread is running");
|
||||
receiver.recv().expect("OBS jobs run to completion")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ObsThread {
|
||||
fn drop(&mut self) {
|
||||
drop(self.jobs.take());
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jobs_run_on_one_thread_in_order() {
|
||||
let obs = ObsThread::spawn();
|
||||
let first = obs.run(|| thread::current().id());
|
||||
let second = obs.run(|| thread::current().id());
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(first, thread::current().id());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "clipbin-probe"
|
||||
description = "Validation probe for Clipbin's OBS capture, encoder and segment pipeline."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clipbin-core.workspace = true
|
||||
clipbin-obs.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Makes development builds run against the OBS runtime in target/obs-runtime.
|
||||
//!
|
||||
//! OBS starts `obs-ffmpeg-mux` from the running executable's directory, and on
|
||||
//! Windows the OBS DLLs must sit beside the executable too, so they are linked
|
||||
//! (Unix) or copied (Windows) into the Cargo profile directory. A symlink keeps
|
||||
//! the helper's `$ORIGIN/../lib` runpath pointing into the runtime. Packaged
|
||||
//! builds place the executable in the runtime's own bin directory instead (see
|
||||
//! clipbin_obs::RuntimeLayout).
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=CLIPBIN_OBS_RUNTIME_DIR");
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();
|
||||
|
||||
if target_os == "linux" {
|
||||
// Packaged layout: <root>/bin/<exe> loads <root>/lib/libobs.so.
|
||||
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN/../lib");
|
||||
}
|
||||
|
||||
let Some(runtime_dir) = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") else {
|
||||
return;
|
||||
};
|
||||
let runtime = PathBuf::from(runtime_dir).join(format!("{target_os}-{arch}"));
|
||||
// OUT_DIR is <profile>/build/<package>-<hash>/out.
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let profile_dir = out_dir.ancestors().nth(3).unwrap().to_path_buf();
|
||||
|
||||
let (bin_dir, lib_dir) = match target_os.as_str() {
|
||||
"windows" => (runtime.join("bin").join("64bit"), None),
|
||||
_ => (runtime.join("bin"), Some(runtime.join("lib"))),
|
||||
};
|
||||
if let Some(lib_dir) = lib_dir {
|
||||
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{}", lib_dir.display());
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(&bin_dir) else {
|
||||
println!(
|
||||
"cargo:warning=OBS runtime not found at {}; run scripts/obs/ to assemble it",
|
||||
runtime.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", bin_dir.display());
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let destination = profile_dir.join(entry.file_name());
|
||||
let _ = fs::remove_file(&destination);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&path, &destination).unwrap();
|
||||
#[cfg(not(unix))]
|
||||
fs::copy(&path, &destination).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Validates the recording foundation on real hardware before the UI is built:
|
||||
//! the pinned OBS runtime, hardware encoders, official capture sources,
|
||||
//! keyframe-aligned segments and honest status. Prints `key=value` lines that
|
||||
//! scripts/validate/ checks.
|
||||
//!
|
||||
//! clipbin-probe encoders [--verify] [--out DIR]
|
||||
//! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW]
|
||||
//! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID]
|
||||
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
||||
//! [--width W --height H] [--fps F] [--no-audio] [--mic]
|
||||
//! [--min-free-mb MB] [--out DIR]
|
||||
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--expect-motion] [--out DIR]
|
||||
//!
|
||||
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
|
||||
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
||||
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode},
|
||||
sync::mpsc::RecvTimeoutError,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use clipbin_core::{RecordingStatus, VideoCodec};
|
||||
use clipbin_obs::{
|
||||
Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason,
|
||||
capture::{self, CaptureTarget, MonitorMatch, WindowMatch},
|
||||
encoders::ALL_CODECS,
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = env::args().skip(1).collect();
|
||||
let command = if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
args.remove(0)
|
||||
};
|
||||
let options = match Options::parse(&args) {
|
||||
Ok(options) => options,
|
||||
Err(error) => return usage(&error),
|
||||
};
|
||||
let result = match command.as_str() {
|
||||
"encoders" => encoders(&options),
|
||||
"record" => record(&options),
|
||||
"check" => check(&options),
|
||||
_ => return usage("expected a command"),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::FAILURE,
|
||||
Err(error) => {
|
||||
println!("error={error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage(error: &str) -> ExitCode {
|
||||
eprintln!("{error}\nusage: clipbin-probe encoders|record|check [options] (see src/main.rs)");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
|
||||
struct Options {
|
||||
verify: bool,
|
||||
expect_motion: bool,
|
||||
target: String,
|
||||
window: String,
|
||||
restore_token: Option<String>,
|
||||
codec: VideoCodec,
|
||||
encoder: Option<String>,
|
||||
seconds: u64,
|
||||
segment_seconds: i64,
|
||||
keyint: i64,
|
||||
bitrate: i64,
|
||||
/// The recording size; by default it follows the captured source.
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
frames_per_second: u32,
|
||||
audio: bool,
|
||||
mic: bool,
|
||||
min_free_mb: u64,
|
||||
out: PathBuf,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut options = Self {
|
||||
verify: false,
|
||||
expect_motion: false,
|
||||
target: "monitor".into(),
|
||||
window: String::new(),
|
||||
restore_token: None,
|
||||
codec: VideoCodec::H264,
|
||||
encoder: None,
|
||||
seconds: 20,
|
||||
segment_seconds: 2,
|
||||
keyint: 1,
|
||||
bitrate: 20_000,
|
||||
width: None,
|
||||
height: None,
|
||||
frames_per_second: 60,
|
||||
audio: true,
|
||||
mic: false,
|
||||
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
|
||||
out: env::temp_dir().join("clipbin-probe"),
|
||||
};
|
||||
let mut args = args.iter();
|
||||
while let Some(flag) = args.next() {
|
||||
let mut value = || args.next().cloned().ok_or(format!("{flag} needs a value"));
|
||||
let number = |value: String| {
|
||||
value
|
||||
.parse::<i64>()
|
||||
.map_err(|_| format!("{flag}: not a number"))
|
||||
};
|
||||
match flag.as_str() {
|
||||
"--verify" => options.verify = true,
|
||||
"--expect-motion" => options.expect_motion = true,
|
||||
"--target" => options.target = value()?,
|
||||
"--window" => options.window = value()?,
|
||||
"--restore-token" => options.restore_token = Some(value()?),
|
||||
"--codec" => options.codec = parse_codec(&value()?)?,
|
||||
"--encoder" => options.encoder = Some(value()?),
|
||||
"--seconds" => options.seconds = number(value()?)? as u64,
|
||||
"--segment-seconds" => options.segment_seconds = number(value()?)?,
|
||||
"--keyint" => options.keyint = number(value()?)?,
|
||||
"--bitrate" => options.bitrate = number(value()?)?,
|
||||
"--width" => options.width = Some(number(value()?)? as u32),
|
||||
"--height" => options.height = Some(number(value()?)? as u32),
|
||||
"--fps" => options.frames_per_second = number(value()?)? as u32,
|
||||
"--no-audio" => options.audio = false,
|
||||
"--mic" => options.mic = true,
|
||||
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
|
||||
"--out" => options.out = value()?.into(),
|
||||
_ => return Err(format!("unknown option {flag}")),
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn resolution(&self) -> Result<Option<(u32, u32)>, String> {
|
||||
match (self.width, self.height) {
|
||||
(Some(width), Some(height)) => Ok(Some((width, height))),
|
||||
(None, None) => Ok(None),
|
||||
_ => Err("--width and --height go together".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_target(&self) -> Result<CaptureTarget, String> {
|
||||
let window = WindowMatch {
|
||||
obs_window: self.window.clone(),
|
||||
restore_token: self.restore_token.clone(),
|
||||
};
|
||||
match self.target.as_str() {
|
||||
"game" => Ok(CaptureTarget::Game { window }),
|
||||
"window" => Ok(CaptureTarget::Window { window }),
|
||||
"monitor" => Ok(CaptureTarget::Monitor {
|
||||
monitor: MonitorMatch {
|
||||
obs_monitor: self.window.clone(),
|
||||
restore_token: self.restore_token.clone(),
|
||||
},
|
||||
}),
|
||||
other => Err(format!("unknown target {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_codec(value: &str) -> Result<VideoCodec, String> {
|
||||
match value {
|
||||
"h264" => Ok(VideoCodec::H264),
|
||||
"hevc" => Ok(VideoCodec::Hevc),
|
||||
"av1" => Ok(VideoCodec::Av1),
|
||||
other => Err(format!("unknown codec {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_key(codec: VideoCodec) -> &'static str {
|
||||
match codec {
|
||||
VideoCodec::H264 => "h264",
|
||||
VideoCodec::Hevc => "hevc",
|
||||
VideoCodec::Av1 => "av1",
|
||||
}
|
||||
}
|
||||
|
||||
fn start_engine(options: &Options) -> Result<Engine, Box<dyn std::error::Error>> {
|
||||
let layout = RuntimeLayout::discover()?;
|
||||
println!("runtime={}", layout.root().display());
|
||||
println!(
|
||||
"runtime_obs_version={}",
|
||||
layout.version().unwrap_or_default()
|
||||
);
|
||||
let engine = Engine::start(&layout, options.frames_per_second)?;
|
||||
println!("obs_version={}", engine.obs_version());
|
||||
for plugin in engine.plugins_failed() {
|
||||
println!("plugin_not_initialised={plugin}");
|
||||
}
|
||||
println!("platform={}", clipbin_obs::runtime::platform_dir());
|
||||
#[cfg(not(windows))]
|
||||
println!("session={:?}", capture::LinuxSession::detect());
|
||||
Ok(engine)
|
||||
}
|
||||
|
||||
/// Lists hardware encoders per codec. With `--verify`, proves each codec by
|
||||
/// recording a few seconds with its preferred encoder and checking frames land
|
||||
/// on disk: a registered encoder can still fail on the actual device.
|
||||
fn encoders(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let engine = start_engine(options)?;
|
||||
for (id, codec) in engine.registered_video_encoders() {
|
||||
println!("registered_encoder={id} codec={codec}");
|
||||
}
|
||||
let mut all_ok = true;
|
||||
for codec in ALL_CODECS {
|
||||
let key = codec_key(codec);
|
||||
let candidates = engine.hardware_encoders(codec);
|
||||
println!("candidates.{key}={}", candidates.join(","));
|
||||
if !options.verify {
|
||||
continue;
|
||||
}
|
||||
let mut usable = None;
|
||||
for encoder in &candidates {
|
||||
let dir = options.out.join(format!("verify-{key}-{encoder}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let config = RecordingConfig {
|
||||
frames_per_second: None,
|
||||
video_source: None,
|
||||
system_audio: None,
|
||||
microphone: None,
|
||||
resolution: Some(options.resolution()?.unwrap_or((1920, 1080))),
|
||||
video_encoder: encoder.clone(),
|
||||
video_bitrate_kbps: options.bitrate,
|
||||
keyframe_interval_seconds: options.keyint,
|
||||
segment_seconds: options.segment_seconds,
|
||||
segment_dir: dir,
|
||||
};
|
||||
match verify_encoder(&engine, config) {
|
||||
Ok(frames) => {
|
||||
println!("verify.{key}.{encoder}=ok frames={frames}");
|
||||
usable = Some(encoder.clone());
|
||||
break;
|
||||
}
|
||||
Err(error) => println!("verify.{key}.{encoder}=failed {error}"),
|
||||
}
|
||||
}
|
||||
match usable {
|
||||
Some(encoder) => println!("usable.{key}={encoder}"),
|
||||
None => {
|
||||
println!("usable.{key}=none");
|
||||
all_ok &= codec != VideoCodec::H264;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all_ok)
|
||||
}
|
||||
|
||||
fn verify_encoder(engine: &Engine, config: RecordingConfig) -> Result<u64, String> {
|
||||
let recording = engine
|
||||
.start_recording(config)
|
||||
.map_err(|error| error.to_string())?;
|
||||
recording.poll().map_err(|error| error.to_string())?;
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
let health = recording.poll().map_err(|error| error.to_string())?;
|
||||
recording.stop();
|
||||
let stop = wait_for_stop(&recording, Duration::from_secs(10));
|
||||
match stop {
|
||||
Some((StopReason::Success, _)) if health.total_frames > 0 && health.total_bytes > 0 => {
|
||||
Ok(health.total_frames)
|
||||
}
|
||||
Some((reason, error)) => Err(format!(
|
||||
"stopped={reason:?} frames={} bytes={} error={}",
|
||||
health.total_frames,
|
||||
health.total_bytes,
|
||||
error.unwrap_or_default()
|
||||
)),
|
||||
None => Err("the output did not stop".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_stop(
|
||||
recording: &clipbin_obs::Recording,
|
||||
timeout: Duration,
|
||||
) -> Option<(StopReason, Option<String>)> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
|
||||
match recording.events().recv_timeout(remaining) {
|
||||
Ok(RecorderEvent::OutputStopped { code, last_error }) => {
|
||||
return Some((StopReason::from_code(code), last_error));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Records from a real capture source, printing every status change and
|
||||
/// segment so scripts/validate/ can check timing, keyframes and continuity.
|
||||
fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let engine = start_engine(options)?;
|
||||
let target = options.capture_target()?;
|
||||
#[cfg(windows)]
|
||||
let video_source = target.video_source();
|
||||
#[cfg(not(windows))]
|
||||
let video_source = target.video_source(
|
||||
capture::LinuxSession::detect()
|
||||
.ok_or("no Wayland or X11 session (WAYLAND_DISPLAY/DISPLAY unset)")?,
|
||||
);
|
||||
println!("video_source={}", video_source.id);
|
||||
|
||||
let encoder = match &options.encoder {
|
||||
Some(encoder) => encoder.clone(),
|
||||
None => engine
|
||||
.hardware_encoders(options.codec)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("no hardware {} encoder is registered", options.codec))?,
|
||||
};
|
||||
println!("encoder={encoder}");
|
||||
|
||||
let dir = options.out.join("session");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
println!("segment_dir={}", dir.display());
|
||||
let config = RecordingConfig {
|
||||
frames_per_second: None,
|
||||
video_source: Some(video_source),
|
||||
system_audio: options.audio.then(capture::system_audio_source),
|
||||
microphone: options.mic.then(capture::microphone_source),
|
||||
resolution: options.resolution()?,
|
||||
video_encoder: encoder,
|
||||
video_bitrate_kbps: options.bitrate,
|
||||
keyframe_interval_seconds: options.keyint,
|
||||
segment_seconds: options.segment_seconds,
|
||||
segment_dir: dir,
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let elapsed = || format!("{:.3}", started.elapsed().as_secs_f64());
|
||||
let recording = engine.start_recording(config)?;
|
||||
let mut tracker = StatusTracker::new(
|
||||
started,
|
||||
options.min_free_mb * 1_000_000,
|
||||
Duration::from_secs(options.segment_seconds as u64),
|
||||
);
|
||||
let mut status = RecordingStatus::Starting;
|
||||
let mut recorded = false;
|
||||
println!("t={} status={}", elapsed(), status.label());
|
||||
|
||||
let stop_at = started + Duration::from_secs(options.seconds);
|
||||
let mut next_sample = started;
|
||||
let mut stopping = false;
|
||||
let mut outcome = None;
|
||||
while outcome.is_none() {
|
||||
let now = Instant::now();
|
||||
if now >= next_sample {
|
||||
next_sample += Duration::from_secs(1);
|
||||
let health = recording.poll()?;
|
||||
println!(
|
||||
"t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
||||
elapsed(),
|
||||
health.source_width,
|
||||
health.source_height,
|
||||
health.pipewire,
|
||||
health.output_active,
|
||||
health.total_frames,
|
||||
health.dropped_frames,
|
||||
health.lagged_frames,
|
||||
health.total_bytes,
|
||||
health.written_bytes
|
||||
);
|
||||
report_status(&mut status, tracker.sample(&health, now), &elapsed());
|
||||
recorded |= matches!(
|
||||
status,
|
||||
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
|
||||
);
|
||||
}
|
||||
if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) {
|
||||
stopping = true;
|
||||
println!("t={} stop_requested", elapsed());
|
||||
recording.stop();
|
||||
}
|
||||
match recording.events().recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(RecorderEvent::OutputSized { width, height }) => {
|
||||
println!("t={} recording_size={width}x{height}", elapsed())
|
||||
}
|
||||
Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()),
|
||||
Ok(RecorderEvent::SegmentStarted { path, .. }) => {
|
||||
println!("t={} segment_started={path}", elapsed())
|
||||
}
|
||||
Ok(RecorderEvent::OutputStopped { code, last_error }) => {
|
||||
let reason = StopReason::from_code(code);
|
||||
println!(
|
||||
"t={} output_stopped={reason:?} error={}",
|
||||
elapsed(),
|
||||
last_error.clone().unwrap_or_default()
|
||||
);
|
||||
report_status(
|
||||
&mut status,
|
||||
tracker.stopped(reason, last_error.as_deref()),
|
||||
&elapsed(),
|
||||
);
|
||||
outcome = Some(reason);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(token) = recording.restore_token() {
|
||||
println!("restore_token={token}");
|
||||
}
|
||||
println!("recorded={recorded}");
|
||||
Ok(recorded
|
||||
&& outcome == Some(StopReason::Success)
|
||||
&& !matches!(status, RecordingStatus::Failed { .. }))
|
||||
}
|
||||
|
||||
fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed: &str) {
|
||||
if shown != current {
|
||||
*shown = current.clone();
|
||||
match current {
|
||||
RecordingStatus::Degraded { reason } => {
|
||||
println!("t={elapsed} status=degraded reason={reason}")
|
||||
}
|
||||
RecordingStatus::Failed { reason } => {
|
||||
println!("t={elapsed} status=failed reason={reason}")
|
||||
}
|
||||
status => println!("t={elapsed} status={}", status.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the recorded segments: each starts with a keyframe in the chosen
|
||||
/// codec and lasts about one segment length, and consecutive segments remux
|
||||
/// into one MP4 across their boundaries without re-encoding and decode cleanly.
|
||||
fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let dir = options.out.join("session");
|
||||
let mut segments: Vec<PathBuf> = std::fs::read_dir(&dir)?
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext == clipbin_obs::SEGMENT_EXTENSION)
|
||||
})
|
||||
.collect();
|
||||
segments.sort();
|
||||
println!("segments={}", segments.len());
|
||||
if segments.len() < 3 {
|
||||
println!("check=failed need at least three segments");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let expected_codec = codec_key(options.codec);
|
||||
let mut ok = true;
|
||||
let last = segments.len() - 1;
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let name = segment.file_name().unwrap_or_default().to_string_lossy();
|
||||
let codec = ffprobe(
|
||||
segment,
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
],
|
||||
)?;
|
||||
let audio_tracks = ffprobe(
|
||||
segment,
|
||||
&["-select_streams", "a", "-show_entries", "stream=index"],
|
||||
)?
|
||||
.lines()
|
||||
.count();
|
||||
let duration: f64 = ffprobe(segment, &["-show_entries", "format=duration"])?
|
||||
.parse()
|
||||
.unwrap_or(0.0);
|
||||
let first_flags = ffprobe(
|
||||
segment,
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-read_intervals",
|
||||
"%+#1",
|
||||
"-show_entries",
|
||||
"packet=flags",
|
||||
],
|
||||
)?;
|
||||
let starts_on_keyframe = first_flags.starts_with('K');
|
||||
// The final segment is cut short by the stop request.
|
||||
let full_length = index == last || duration >= options.segment_seconds as f64 * 0.9;
|
||||
let segment_ok =
|
||||
codec == expected_codec && starts_on_keyframe && full_length && audio_tracks > 0;
|
||||
ok &= segment_ok;
|
||||
println!(
|
||||
"segment={name} ok={segment_ok} codec={codec} keyframe_start={starts_on_keyframe} duration={duration:.3} audio_tracks={audio_tracks}"
|
||||
);
|
||||
}
|
||||
|
||||
// A black or frozen capture still produces valid segments. When the
|
||||
// captured content was moving, some segment must show it (capture may
|
||||
// start late, after the user answers the picker).
|
||||
if options.expect_motion {
|
||||
let mut most = (0, None);
|
||||
for segment in &segments {
|
||||
let frames = distinct_frames(segment)?;
|
||||
if frames > most.0 {
|
||||
most = (frames, Some(segment));
|
||||
}
|
||||
}
|
||||
let moving = most.0 > 1;
|
||||
ok &= moving;
|
||||
println!(
|
||||
"motion ok={moving} distinct_frames={} segment={}",
|
||||
most.0,
|
||||
most.1
|
||||
.map_or(String::new(), |segment| segment.display().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// A clip spanning the boundaries between the first three segments.
|
||||
let list = options.out.join("concat.txt");
|
||||
let entries: String = segments[..3]
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
format!(
|
||||
"file '{}'\n",
|
||||
segment.to_string_lossy().replace('\'', "'\\''")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(&list, entries)?;
|
||||
let clip = options.out.join("cross-boundary-clip.mp4");
|
||||
let remux = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i"])
|
||||
.arg(&list)
|
||||
.args(["-map", "0", "-c", "copy", "-movflags", "+faststart"])
|
||||
.arg(&clip)
|
||||
.status()?;
|
||||
let clip_codec = ffprobe(
|
||||
&clip,
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
],
|
||||
)?;
|
||||
let clip_duration: f64 = ffprobe(&clip, &["-show_entries", "format=duration"])?
|
||||
.parse()
|
||||
.unwrap_or(0.0);
|
||||
let decode = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-xerror", "-i"])
|
||||
.arg(&clip)
|
||||
.args(["-f", "null", "-"])
|
||||
.output()?;
|
||||
let decode_errors = String::from_utf8_lossy(&decode.stderr).lines().count();
|
||||
let clip_ok = remux.success()
|
||||
&& clip_codec == expected_codec
|
||||
&& decode.status.success()
|
||||
&& decode_errors == 0;
|
||||
ok &= clip_ok;
|
||||
println!(
|
||||
"clip ok={clip_ok} codec={clip_codec} duration={clip_duration:.3} decode_errors={decode_errors} path={}",
|
||||
clip.display()
|
||||
);
|
||||
println!("check={}", if ok { "ok" } else { "failed" });
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// Frames in a segment's video that differ from the previous one.
|
||||
fn distinct_frames(segment: &Path) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(segment)
|
||||
.args([
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-vf",
|
||||
"mpdecimate=max=0",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.args(["-progress", "pipe:1"])
|
||||
.output()?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("frame="))
|
||||
.filter_map(|frames| frames.parse().ok())
|
||||
.next_back()
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn ffprobe(path: &Path, query: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("ffprobe")
|
||||
.args(["-v", "error"])
|
||||
.args(query)
|
||||
.args(["-of", "default=noprint_wrappers=1:nokey=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|error| format!("could not run ffprobe (is FFmpeg installed?): {error}"))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||