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 |
@@ -60,6 +60,28 @@ dependencies = [
|
|||||||
"equator",
|
"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]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@@ -440,7 +462,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"num-rational",
|
"num-rational",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pastey",
|
"pastey 0.1.1",
|
||||||
"rayon",
|
"rayon",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"v_frame",
|
"v_frame",
|
||||||
@@ -862,13 +884,45 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "clipbin-core"
|
name = "clipbin-core"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "clipbin-desktop"
|
name = "clipbin-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clipbin-core",
|
"clipbin-core",
|
||||||
|
"clipbin-media",
|
||||||
|
"clipbin-obs",
|
||||||
"gpui",
|
"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]]
|
[[package]]
|
||||||
@@ -965,6 +1019,16 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
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]]
|
[[package]]
|
||||||
name = "command-fds"
|
name = "command-fds"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1175,6 +1239,20 @@ dependencies = [
|
|||||||
"libm",
|
"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]]
|
[[package]]
|
||||||
name = "cosmic-text"
|
name = "cosmic-text"
|
||||||
version = "0.14.2"
|
version = "0.14.2"
|
||||||
@@ -1198,6 +1276,39 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1225,6 +1336,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.8"
|
version = "0.8.8"
|
||||||
@@ -1301,6 +1421,12 @@ version = "0.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1431,6 +1557,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dpi"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dtor"
|
name = "dtor"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -1487,7 +1619,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.7+spec-1.1.0",
|
"toml 1.1.7+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.56.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1510,6 +1642,17 @@ version = "1.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
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]]
|
[[package]]
|
||||||
name = "enumflags2"
|
name = "enumflags2"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
@@ -1670,6 +1813,31 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -2124,8 +2292,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui"
|
name = "gpui"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"as-raw-xcb-connection",
|
"as-raw-xcb-connection",
|
||||||
@@ -2207,7 +2373,7 @@ dependencies = [
|
|||||||
"wayland-protocols-plasma",
|
"wayland-protocols-plasma",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-numerics",
|
"windows-numerics 0.2.0",
|
||||||
"windows-registry 0.5.3",
|
"windows-registry 0.5.3",
|
||||||
"x11-clipboard",
|
"x11-clipboard",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
@@ -2874,6 +3040,64 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "jobserver"
|
name = "jobserver"
|
||||||
version = "0.1.35"
|
version = "0.1.35"
|
||||||
@@ -2895,6 +3119,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "khronos-egl"
|
name = "khronos-egl"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -2905,6 +3139,24 @@ dependencies = [
|
|||||||
"libloading",
|
"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]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -3116,6 +3368,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -3239,6 +3497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -3253,6 +3512,24 @@ dependencies = [
|
|||||||
"pxfm",
|
"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]]
|
[[package]]
|
||||||
name = "multiversion_no_op"
|
name = "multiversion_no_op"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -3293,6 +3570,35 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -3478,6 +3784,28 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "objc"
|
name = "objc"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
@@ -3521,6 +3849,55 @@ dependencies = [
|
|||||||
"objc2-quartz-core",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-core-foundation"
|
name = "objc2-core-foundation"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3528,10 +3905,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -3545,6 +3934,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -3728,6 +4119,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathfinder_geometry"
|
name = "pathfinder_geometry"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3864,6 +4261,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
|
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "postage"
|
name = "postage"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4001,6 +4404,22 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
|
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]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.30"
|
version = "0.1.30"
|
||||||
@@ -4414,6 +4833,14 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rig-pointer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -4914,6 +5341,16 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
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]]
|
[[package]]
|
||||||
name = "simd_helpers"
|
name = "simd_helpers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -5363,6 +5800,15 @@ dependencies = [
|
|||||||
"objc",
|
"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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5701,6 +6147,25 @@ dependencies = [
|
|||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -5947,6 +6412,12 @@ dependencies = [
|
|||||||
"sval_serde",
|
"sval_serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -6156,6 +6627,19 @@ dependencies = [
|
|||||||
"wayland-scanner",
|
"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]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.11"
|
version = "0.31.11"
|
||||||
@@ -6264,11 +6748,23 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
@@ -6281,7 +6777,7 @@ dependencies = [
|
|||||||
"rayon",
|
"rayon",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6293,6 +6789,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.57.0"
|
version = "0.57.0"
|
||||||
@@ -6318,6 +6823,19 @@ dependencies = [
|
|||||||
"windows-strings 0.4.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-future"
|
name = "windows-future"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -6326,7 +6844,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
@@ -6395,6 +6924,16 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -6435,6 +6974,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -6453,6 +7001,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
@@ -6546,6 +7103,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -6702,6 +7268,16 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.56.0"
|
version = "0.56.0"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
|
exclude = ["vendor/gpui"]
|
||||||
members = [
|
members = [
|
||||||
"crates/clipbin-core",
|
"crates/clipbin-core",
|
||||||
"crates/clipbin-desktop",
|
"crates/clipbin-desktop",
|
||||||
|
"crates/clipbin-media",
|
||||||
"crates/clipbin-obs",
|
"crates/clipbin-obs",
|
||||||
"crates/clipbin-probe",
|
"crates/clipbin-probe",
|
||||||
|
"crates/rig-pointer",
|
||||||
]
|
]
|
||||||
default-members = ["crates/clipbin-desktop"]
|
default-members = ["crates/clipbin-desktop"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
@@ -18,6 +21,7 @@ publish = false
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clipbin-core = { path = "crates/clipbin-core" }
|
clipbin-core = { path = "crates/clipbin-core" }
|
||||||
|
clipbin-media = { path = "crates/clipbin-media" }
|
||||||
clipbin-obs = { path = "crates/clipbin-obs" }
|
clipbin-obs = { path = "crates/clipbin-obs" }
|
||||||
gpui = "=0.2.2"
|
gpui = "=0.2.2"
|
||||||
# The tested OBS set: the libobs crate's bundled headers are OBS 32.0.4, matching
|
# The tested OBS set: the libobs crate's bundled headers are OBS 32.0.4, matching
|
||||||
@@ -30,7 +34,15 @@ unsafe_code = "forbid"
|
|||||||
[profile.dev]
|
[profile.dev]
|
||||||
debug = 1
|
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]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
strip = "debuginfo"
|
strip = "debuginfo"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
gpui = { path = "vendor/gpui" }
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ An open-source game recorder for Windows and Linux, inspired by Plays.tv. Rust a
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
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; hardware sign-off on Hyprland and all Windows validation are still to do.
|
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.
|
||||||
|
|
||||||
The desktop app is still the initial shell: it opens a GPUI window, displays the agreed recording defaults, and always reports **Not recording**. Steam detection, the recorder in the app, playback, clipping, bookmarks, persistence, global hotkeys, autostart, and background recording are not implemented yet.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -16,7 +18,8 @@ Windows is a first-class target. Every change must keep the Windows code paths b
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
crates/
|
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-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-probe/ Validation probe for capture, encoders, segments and status.
|
||||||
clipbin-desktop/ GPUI application and initial session-library view.
|
clipbin-desktop/ GPUI application and initial session-library view.
|
||||||
@@ -28,18 +31,18 @@ docs/validation.md Validation status and findings.
|
|||||||
SPEC.md Product requirements and acceptance checks.
|
SPEC.md Product requirements and acceptance checks.
|
||||||
```
|
```
|
||||||
|
|
||||||
The core has no external dependencies. Keep capture and session operations out of the UI thread.
|
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
|
## 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.
|
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:
|
On Arch/CachyOS, install the native build dependencies:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl clang
|
sudo pacman -S --needed base-devel pkgconf fontconfig freetype2 wayland libxcb libxkbcommon libxkbcommon-x11 openssl clang ffmpeg alsa-lib pipewire-alsa libpulse
|
||||||
```
|
```
|
||||||
|
|
||||||
### OBS runtime
|
### OBS runtime
|
||||||
@@ -60,9 +63,11 @@ On Debian/Ubuntu:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt-get update
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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).
|
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:
|
Run the desktop shell from an active graphical session:
|
||||||
@@ -73,7 +78,7 @@ 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.
|
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:
|
Checks:
|
||||||
|
|
||||||
@@ -85,7 +90,11 @@ cargo test --locked --workspace
|
|||||||
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`.
|
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:
|
The core can also be checked without native UI dependencies:
|
||||||
|
|
||||||
@@ -95,9 +104,9 @@ cargo check --locked -p clipbin-core
|
|||||||
|
|
||||||
## First recording milestone
|
## First recording milestone
|
||||||
|
|
||||||
1. Validate official OBS capture and recording-health signals on CachyOS, Hyprland and the RX 9070 XT at 1440p60. *Rig validated; hardware sign-off pending.*
|
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.*
|
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 with VA-API H.264 and HEVC.*
|
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.
|
4. Integrate the recorder without blocking GPUI, then add native playback and seeking.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ Build an open-source desktop game recorder inspired by Plays.tv, using Rust, GPU
|
|||||||
|
|
||||||
## Recording defaults and audio
|
## Recording defaults and audio
|
||||||
|
|
||||||
- Default video: 1080p at 60 FPS.
|
- 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.
|
||||||
- Resolution and frame rate are configurable, including the user's intended 1440p60 configuration.
|
- 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.
|
- 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.
|
- 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.
|
- Keep system audio enabled by default.
|
||||||
|
|||||||
@@ -8,5 +8,26 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints.rust]
|
||||||
workspace = true
|
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.
|
//! Recorder settings and state shared with the desktop interface.
|
||||||
|
|
||||||
|
pub mod library;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
|
pub use library::Library;
|
||||||
pub use settings::{Settings, VideoCodec};
|
pub use settings::{Settings, VideoCodec};
|
||||||
pub use status::RecordingStatus;
|
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};
|
use std::{fmt, time::Duration};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum VideoCodec {
|
pub enum VideoCodec {
|
||||||
#[default]
|
#[default]
|
||||||
H264,
|
H264,
|
||||||
@@ -18,17 +19,23 @@ impl fmt::Display for VideoCodec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
|
/// Persisted recorder preferences from SPEC.md.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub width: u32,
|
/// A fixed recording size. `None` records at the captured window's or
|
||||||
pub height: u32,
|
/// monitor's own size, following its orientation.
|
||||||
|
pub portal_restore_token: Option<String>,
|
||||||
|
pub resolution: Option<(u32, u32)>,
|
||||||
pub frames_per_second: u32,
|
pub frames_per_second: u32,
|
||||||
pub codec: VideoCodec,
|
pub codec: VideoCodec,
|
||||||
pub system_audio_enabled: bool,
|
pub system_audio_enabled: bool,
|
||||||
pub microphone_enabled: bool,
|
pub microphone_enabled: bool,
|
||||||
pub start_on_login: bool,
|
pub start_on_login: bool,
|
||||||
pub clip_duration: Duration,
|
pub clip_duration: Duration,
|
||||||
|
pub export_separate_audio: bool,
|
||||||
|
pub clip_hotkey: u8,
|
||||||
|
pub bookmark_hotkey: u8,
|
||||||
pub bookmark_before: Duration,
|
pub bookmark_before: Duration,
|
||||||
pub bookmark_after: Duration,
|
pub bookmark_after: Duration,
|
||||||
/// Protected bookmarks and exported clips are excluded from this limit.
|
/// Protected bookmarks and exported clips are excluded from this limit.
|
||||||
@@ -38,14 +45,17 @@ pub struct Settings {
|
|||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
width: 1920,
|
portal_restore_token: None,
|
||||||
height: 1080,
|
resolution: None,
|
||||||
frames_per_second: 60,
|
frames_per_second: 60,
|
||||||
codec: VideoCodec::H264,
|
codec: VideoCodec::H264,
|
||||||
system_audio_enabled: true,
|
system_audio_enabled: true,
|
||||||
microphone_enabled: false,
|
microphone_enabled: false,
|
||||||
start_on_login: true,
|
start_on_login: true,
|
||||||
clip_duration: Duration::from_secs(120),
|
clip_duration: Duration::from_secs(120),
|
||||||
|
export_separate_audio: false,
|
||||||
|
clip_hotkey: 9,
|
||||||
|
bookmark_hotkey: 10,
|
||||||
bookmark_before: Duration::from_secs(120),
|
bookmark_before: Duration::from_secs(120),
|
||||||
bookmark_after: Duration::from_secs(30),
|
bookmark_after: Duration::from_secs(30),
|
||||||
unprotected_session_limit_bytes: 50_000_000_000,
|
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
|
/// A game being detected or permission being granted is insufficient to enter
|
||||||
/// Recording: a usable capture source and successful output must be confirmed.
|
/// Recording: a usable capture source and successful output must be confirmed.
|
||||||
@@ -9,6 +9,10 @@ pub enum RecordingStatus {
|
|||||||
WaitingForCapture,
|
WaitingForCapture,
|
||||||
Starting,
|
Starting,
|
||||||
Recording,
|
Recording,
|
||||||
|
/// Video continues while a configured audio source is no longer delivering.
|
||||||
|
Degraded {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
Failed {
|
Failed {
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
@@ -21,6 +25,7 @@ impl RecordingStatus {
|
|||||||
Self::WaitingForCapture => "Waiting for capture",
|
Self::WaitingForCapture => "Waiting for capture",
|
||||||
Self::Starting => "Starting",
|
Self::Starting => "Starting",
|
||||||
Self::Recording => "Recording",
|
Self::Recording => "Recording",
|
||||||
|
Self::Degraded { .. } => "Recording degraded",
|
||||||
Self::Failed { .. } => "Recording failed",
|
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]
|
[dependencies]
|
||||||
clipbin-core.workspace = true
|
clipbin-core.workspace = true
|
||||||
|
clipbin-media.workspace = true
|
||||||
|
image = { version = "0.25.10", default-features = false }
|
||||||
|
clipbin-obs.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
|
||||||
[lints]
|
[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,
|
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]);
|
actions!(clipbin, [Quit]);
|
||||||
|
|
||||||
fn quit(_: &Quit, cx: &mut App) {
|
|
||||||
cx.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
Application::new().run(|cx: &mut App| {
|
let (settings, initial_error) = match clipbin_core::store::load_settings() {
|
||||||
cx.on_action(quit);
|
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.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||||
|
cx.set_menus(vec![gpui::Menu {
|
||||||
// This initial shell has no background recorder yet.
|
name: "Clipbin".into(),
|
||||||
cx.on_window_closed(|cx| {
|
items: vec![gpui::MenuItem::action("Quit Clipbin", Quit)],
|
||||||
if cx.windows().is_empty() {
|
}]);
|
||||||
cx.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();
|
.detach();
|
||||||
|
});
|
||||||
|
service.quit();
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx);
|
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(
|
let result = cx.open_window(
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
window_min_size: Some(size(px(1040.), px(640.))),
|
||||||
titlebar: Some(TitlebarOptions {
|
titlebar: Some(TitlebarOptions {
|
||||||
title: Some("Clipbin".into()),
|
title: Some("Clipbin".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
app_id: Some("dev.clipbin.Clipbin".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|_, cx| cx.new(|_| view::ClipbinView::default()),
|
move |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service)),
|
||||||
);
|
);
|
||||||
|
let window = match result {
|
||||||
if let Err(error) = result {
|
Ok(window) => window,
|
||||||
|
Err(error) => {
|
||||||
eprintln!("Could not open Clipbin: {error}");
|
eprintln!("Could not open Clipbin: {error}");
|
||||||
cx.quit();
|
service.quit();
|
||||||
return;
|
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);
|
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();
|
||||||
|
}
|
||||||
@@ -13,13 +13,14 @@ use std::{
|
|||||||
atomic::{AtomicU8, AtomicU64, Ordering},
|
atomic::{AtomicU8, AtomicU64, Ordering},
|
||||||
mpsc::Sender,
|
mpsc::Sender,
|
||||||
},
|
},
|
||||||
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
use libobs as sys;
|
use libobs as sys;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
capture::{SettingValue, SourceSpec},
|
capture::{SettingValue, SourceSpec},
|
||||||
recorder::{Health, RecorderEvent},
|
recorder::{Health, PipeWireState, RecorderEvent},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn cstring(value: &str) -> CString {
|
fn cstring(value: &str) -> CString {
|
||||||
@@ -149,36 +150,49 @@ unsafe extern "C" fn log_to_stderr(
|
|||||||
_ => "debug",
|
_ => "debug",
|
||||||
};
|
};
|
||||||
if let Some(text) = owned(message.array) {
|
if let Some(text) = owned(message.array) {
|
||||||
if let Some(state) = pipewire_stream_state(&text) {
|
if let Some(state) = pipewire_state(&text) {
|
||||||
let code = if state == "streaming" {
|
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
|
||||||
STREAMING
|
|
||||||
} else {
|
|
||||||
NOT_STREAMING
|
|
||||||
};
|
|
||||||
PIPEWIRE_STREAM.store(code, Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
record_wasapi_event(&text);
|
||||||
eprintln!("obs {label}: {text}");
|
eprintln!("obs {label}: {text}");
|
||||||
}
|
}
|
||||||
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
|
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
|
||||||
unsafe { sys::bfree(message.array.cast()) };
|
unsafe { sys::bfree(message.array.cast()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The state of the PipeWire capture stream, as last logged by OBS.
|
/// The PipeWire capture state, as last logged by OBS (0 = nothing logged).
|
||||||
///
|
///
|
||||||
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
|
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
|
||||||
/// its last frame after the stream stops, and exposes the stream state only
|
/// its last frame after its stream stops, and reports portal refusals and
|
||||||
/// through this log line. Clipbin records one capture source at a time.
|
/// stream states only in its log (linux-pipewire's screencast-portal.c and
|
||||||
static PIPEWIRE_STREAM: AtomicU8 = AtomicU8::new(NO_PIPEWIRE_STREAM);
|
/// pipewire.c). Clipbin records one capture source at a time.
|
||||||
const NO_PIPEWIRE_STREAM: u8 = 0;
|
static PIPEWIRE_STATE: AtomicU8 = AtomicU8::new(0);
|
||||||
const STREAMING: u8 = 1;
|
|
||||||
const NOT_STREAMING: u8 = 2;
|
|
||||||
|
|
||||||
/// Parses `[pipewire] Stream 0x... state: "streaming" (error: none)` from
|
fn pipewire_state(message: &str) -> Option<PipeWireState> {
|
||||||
/// linux-pipewire's on_state_changed_cb.
|
if let Some(rest) = message.strip_prefix("[pipewire] Stream ") {
|
||||||
fn pipewire_stream_state(message: &str) -> Option<&str> {
|
// `[pipewire] Stream 0x... state: "streaming" (error: none)`
|
||||||
let rest = message.strip_prefix("[pipewire] Stream ")?;
|
let state = rest.split_once(" state: \"")?.1.split_once('"')?.0;
|
||||||
let state = rest.split_once(" state: \"")?.1;
|
return Some(if state == "streaming" {
|
||||||
state.split_once('"').map(|(state, _)| state)
|
PipeWireState::Streaming
|
||||||
|
} else {
|
||||||
|
PipeWireState::Stopped
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The user denied or cancelled the portal request, or it failed.
|
||||||
|
let refused = message.starts_with("[pipewire] Error ")
|
||||||
|
|| (message.starts_with("[pipewire] Failed to ")
|
||||||
|
&& message.ends_with("denied or cancelled by user"));
|
||||||
|
refused.then_some(PipeWireState::Refused)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_pipewire_state() -> Option<PipeWireState> {
|
||||||
|
match PIPEWIRE_STATE.load(Ordering::Relaxed) {
|
||||||
|
state if state == PipeWireState::Streaming as u8 => Some(PipeWireState::Streaming),
|
||||||
|
state if state == PipeWireState::Stopped as u8 => Some(PipeWireState::Stopped),
|
||||||
|
state if state == PipeWireState::Refused as u8 => Some(PipeWireState::Refused),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
||||||
@@ -213,28 +227,9 @@ pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
|||||||
return Err("libobs could not set up audio".into());
|
return Err("libobs could not set up audio".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let graphics_module = cstring(if cfg!(windows) {
|
if let Err(error) = reset_video((spec.width, spec.height), spec.frames_per_second) {
|
||||||
"libobs-d3d11"
|
|
||||||
} else {
|
|
||||||
"libobs-opengl"
|
|
||||||
});
|
|
||||||
let mut video: sys::obs_video_info = std::mem::zeroed();
|
|
||||||
video.graphics_module = graphics_module.as_ptr();
|
|
||||||
video.fps_num = spec.frames_per_second;
|
|
||||||
video.fps_den = 1;
|
|
||||||
video.base_width = spec.width;
|
|
||||||
video.base_height = spec.height;
|
|
||||||
video.output_width = spec.width;
|
|
||||||
video.output_height = spec.height;
|
|
||||||
video.output_format = sys::video_format_VIDEO_FORMAT_NV12;
|
|
||||||
video.gpu_conversion = true;
|
|
||||||
video.colorspace = sys::video_colorspace_VIDEO_CS_709;
|
|
||||||
video.range = sys::video_range_type_VIDEO_RANGE_DEFAULT;
|
|
||||||
video.scale_type = sys::obs_scale_type_OBS_SCALE_BICUBIC;
|
|
||||||
let code = sys::obs_reset_video(&mut video);
|
|
||||||
if code != sys::OBS_VIDEO_SUCCESS as i32 {
|
|
||||||
sys::obs_shutdown();
|
sys::obs_shutdown();
|
||||||
return Err(format!("libobs could not start video (code {code})"));
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open exactly the bundled plugins. obs_load_all_modules would also
|
// Open exactly the bundled plugins. obs_load_all_modules would also
|
||||||
@@ -269,6 +264,39 @@ pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the canvas and output size. Fails while an output is active.
|
||||||
|
fn reset_video((width, height): (u32, u32), frames_per_second: u32) -> Result<(), String> {
|
||||||
|
let graphics_module = cstring(if cfg!(windows) {
|
||||||
|
"libobs-d3d11"
|
||||||
|
} else {
|
||||||
|
"libobs-opengl"
|
||||||
|
});
|
||||||
|
// SAFETY: runs on the OBS thread; obs_video_info is plain data.
|
||||||
|
unsafe {
|
||||||
|
let mut video: sys::obs_video_info = std::mem::zeroed();
|
||||||
|
video.graphics_module = graphics_module.as_ptr();
|
||||||
|
video.fps_num = frames_per_second;
|
||||||
|
video.fps_den = 1;
|
||||||
|
video.base_width = width;
|
||||||
|
video.base_height = height;
|
||||||
|
video.output_width = width;
|
||||||
|
video.output_height = height;
|
||||||
|
video.output_format = sys::video_format_VIDEO_FORMAT_NV12;
|
||||||
|
video.gpu_conversion = true;
|
||||||
|
video.colorspace = sys::video_colorspace_VIDEO_CS_709;
|
||||||
|
video.range = sys::video_range_type_VIDEO_RANGE_DEFAULT;
|
||||||
|
video.scale_type = sys::obs_scale_type_OBS_SCALE_BICUBIC;
|
||||||
|
let code = sys::obs_reset_video(&mut video);
|
||||||
|
if code == sys::OBS_VIDEO_SUCCESS as i32 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"libobs could not set up {width}x{height} video (code {code})"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn shutdown(session: Session) {
|
pub(crate) fn shutdown(session: Session) {
|
||||||
// SAFETY: runs on the OBS thread after every OBS object was released.
|
// SAFETY: runs on the OBS thread after every OBS object was released.
|
||||||
// Released sources are destroyed on a libobs task thread, and destroying
|
// Released sources are destroyed on a libobs task thread, and destroying
|
||||||
@@ -434,17 +462,24 @@ pub(crate) fn registered_video_encoders() -> Vec<(String, String)> {
|
|||||||
encoders
|
encoders
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PipelineSpec {
|
pub(crate) struct CaptureSpec {
|
||||||
pub canvas: (u32, u32),
|
|
||||||
pub video_source: Option<SourceSpec>,
|
pub video_source: Option<SourceSpec>,
|
||||||
pub system_audio: Option<SourceSpec>,
|
pub system_audio: Option<SourceSpec>,
|
||||||
pub microphone: Option<SourceSpec>,
|
pub microphone: Option<SourceSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct OutputSpec {
|
||||||
|
pub frames_per_second: u32,
|
||||||
|
/// The recording size; `None` follows the captured source.
|
||||||
|
pub resolution: Option<(u32, u32)>,
|
||||||
pub video_encoder: String,
|
pub video_encoder: String,
|
||||||
pub video_encoder_settings: Vec<(&'static str, SettingValue)>,
|
pub video_encoder_settings: Vec<(&'static str, SettingValue)>,
|
||||||
pub audio_bitrate_kbps: i64,
|
pub audio_bitrate_kbps: i64,
|
||||||
pub segment_dir: String,
|
pub segment_dir: String,
|
||||||
pub segment_extension: &'static str,
|
pub segment_extension: &'static str,
|
||||||
pub segment_seconds: i64,
|
pub segment_seconds: i64,
|
||||||
|
pub events: Sender<RecorderEvent>,
|
||||||
|
pub current_segment: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OBS output channels, following the OBS front end's convention.
|
/// OBS output channels, following the OBS front end's convention.
|
||||||
@@ -457,6 +492,25 @@ pub(crate) const SYSTEM_TRACK: usize = 0;
|
|||||||
pub(crate) const MICROPHONE_TRACK: usize = 1;
|
pub(crate) const MICROPHONE_TRACK: usize = 1;
|
||||||
pub(crate) const COMBINED_TRACK: usize = 2;
|
pub(crate) const COMBINED_TRACK: usize = 2;
|
||||||
|
|
||||||
|
/// With the microphone on, system and microphone audio get their own tracks
|
||||||
|
/// for editing plus a combined track for playback. With only system audio,
|
||||||
|
/// its single track is already the combined mix. The FFmpeg muxer needs at
|
||||||
|
/// least one audio track, so recording without audio writes a silent one.
|
||||||
|
fn audio_tracks(microphone: bool) -> &'static [usize] {
|
||||||
|
if microphone {
|
||||||
|
&[SYSTEM_TRACK, MICROPHONE_TRACK, COMBINED_TRACK]
|
||||||
|
} else {
|
||||||
|
&[SYSTEM_TRACK]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recording size for a captured picture: the configured resolution, or
|
||||||
|
/// the source's own size. NV12 encoding needs even dimensions.
|
||||||
|
pub(crate) fn canvas_size(resolution: Option<(u32, u32)>, source: (u32, u32)) -> (u32, u32) {
|
||||||
|
let (width, height) = resolution.unwrap_or(source);
|
||||||
|
((width & !1).max(2), (height & !1).max(2))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn segment_stem(index: u64) -> String {
|
pub(crate) fn segment_stem(index: u64) -> String {
|
||||||
format!("segment-{index:06}")
|
format!("segment-{index:06}")
|
||||||
}
|
}
|
||||||
@@ -470,11 +524,15 @@ struct SignalContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SignalContext {
|
impl SignalContext {
|
||||||
fn segment_started(&self, path: String) {
|
fn segment_started(&self, path: String, total_frames: u64, dropped_frames: u64) {
|
||||||
if let Ok(mut current) = self.current_segment.lock() {
|
if let Ok(mut current) = self.current_segment.lock() {
|
||||||
*current = Some(path.clone());
|
*current = Some(path.clone());
|
||||||
}
|
}
|
||||||
self.send(RecorderEvent::SegmentStarted { path });
|
self.send(RecorderEvent::SegmentStarted {
|
||||||
|
path,
|
||||||
|
total_frames,
|
||||||
|
dropped_frames,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send(&self, event: RecorderEvent) {
|
fn send(&self, event: RecorderEvent) {
|
||||||
@@ -494,11 +552,11 @@ const SIGNALS: [(
|
|||||||
];
|
];
|
||||||
|
|
||||||
unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) {
|
unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) {
|
||||||
// SAFETY: data is the SignalContext registered in Pipeline::build, which
|
// SAFETY: data is the SignalContext registered in Output::build, which
|
||||||
// outlives the connection.
|
// outlives the connection.
|
||||||
let context = unsafe { &*(data as *const SignalContext) };
|
let context = unsafe { &*(data as *const SignalContext) };
|
||||||
context.send(RecorderEvent::OutputStarted);
|
context.send(RecorderEvent::OutputStarted);
|
||||||
context.segment_started(context.first_segment.clone());
|
context.segment_started(context.first_segment.clone(), 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe extern "C" fn on_stop(data: *mut c_void, calldata: *mut sys::calldata_t) {
|
unsafe extern "C" fn on_stop(data: *mut c_void, calldata: *mut sys::calldata_t) {
|
||||||
@@ -537,7 +595,17 @@ unsafe extern "C" fn on_file_changed(data: *mut c_void, calldata: *mut sys::call
|
|||||||
let settings = Data(unsafe { sys::obs_output_get_settings(context.output) });
|
let settings = Data(unsafe { sys::obs_output_get_settings(context.output) });
|
||||||
settings.set("format", &SettingValue::String(segment_stem(index + 1)));
|
settings.set("format", &SettingValue::String(segment_stem(index + 1)));
|
||||||
}
|
}
|
||||||
context.segment_started(path);
|
// SAFETY: the output outlives its signal connections.
|
||||||
|
let total_frames = unsafe { sys::obs_output_get_total_frames(context.output) };
|
||||||
|
// The split-triggering keyframe is already counted here, but belongs to
|
||||||
|
// the new file. Consumers subtract one for placement; retain the raw count.
|
||||||
|
// SAFETY: as above, the output outlives this callback.
|
||||||
|
let dropped_frames = unsafe { sys::obs_output_get_frames_dropped(context.output) };
|
||||||
|
context.segment_started(
|
||||||
|
path,
|
||||||
|
total_frames.max(0) as u64,
|
||||||
|
dropped_frames.max(0) as u64,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
||||||
@@ -546,16 +614,122 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
|||||||
stem.strip_prefix("segment-")?.parse().ok()
|
stem.strip_prefix("segment-")?.parse().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live OBS objects for one recording.
|
#[cfg(windows)]
|
||||||
|
static AUDIO_FAULTS: Mutex<Vec<(String, std::sync::Weak<std::sync::atomic::AtomicBool>)>> =
|
||||||
|
Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn wasapi_event(message: &str) -> Option<(&str, bool)> {
|
||||||
|
let (message, source) = message.rsplit_once(" (source: ")?;
|
||||||
|
let source = source.strip_suffix(')')?;
|
||||||
|
if !source.starts_with("clipbin-") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if message.starts_with("WASAPI: Device '") && message.contains(" initialized") {
|
||||||
|
Some((source, false))
|
||||||
|
} else if (message.starts_with("WASAPI: Device '") && message.contains(" failed to start"))
|
||||||
|
|| (message.starts_with("Device '") && message.contains(" invalidated. Retrying"))
|
||||||
|
{
|
||||||
|
Some((source, true))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn record_wasapi_event(message: &str) {
|
||||||
|
if let Some((source, failed)) = wasapi_event(message)
|
||||||
|
&& let Ok(faults) = AUDIO_FAULTS.lock()
|
||||||
|
&& let Some(flag) = faults
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == source)
|
||||||
|
.and_then(|(_, flag)| flag.upgrade())
|
||||||
|
{
|
||||||
|
flag.store(failed, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AudioClock {
|
||||||
|
started: Instant,
|
||||||
|
last_millis: AtomicU64,
|
||||||
|
#[cfg(windows)]
|
||||||
|
source_name: String,
|
||||||
|
#[cfg(windows)]
|
||||||
|
failed: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
}
|
||||||
|
impl AudioClock {
|
||||||
|
fn new(source_name: &str) -> Self {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let failed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
#[cfg(windows)]
|
||||||
|
AUDIO_FAULTS
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((source_name.into(), Arc::downgrade(&failed)));
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = source_name;
|
||||||
|
Self {
|
||||||
|
started: Instant::now(),
|
||||||
|
last_millis: AtomicU64::new(0),
|
||||||
|
#[cfg(windows)]
|
||||||
|
source_name: source_name.into(),
|
||||||
|
#[cfg(windows)]
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn lost(&self) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
self.failed.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
(self.started.elapsed().as_millis() as u64)
|
||||||
|
.saturating_sub(self.last_millis.load(Ordering::Relaxed))
|
||||||
|
>= 5_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl Drop for AudioClock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut faults) = AUDIO_FAULTS.lock() {
|
||||||
|
faults.retain(|(name, _)| name != &self.source_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn audio_captured(
|
||||||
|
param: *mut c_void,
|
||||||
|
_: *mut sys::obs_source_t,
|
||||||
|
_: *const sys::audio_data,
|
||||||
|
_: bool,
|
||||||
|
) {
|
||||||
|
// SAFETY: each callback holds the stable AudioClock allocation until removal;
|
||||||
|
// OBS serialises removal against callbacks with source->audio_cb_mutex.
|
||||||
|
let clock = unsafe { &*param.cast::<AudioClock>() };
|
||||||
|
#[cfg(windows)]
|
||||||
|
clock.failed.store(false, Ordering::Relaxed);
|
||||||
|
clock.last_millis.store(
|
||||||
|
clock.started.elapsed().as_millis() as u64,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live OBS objects for one recording. Capture starts at once; encoders
|
||||||
|
/// and the segment output are created only when the source has a picture,
|
||||||
|
/// so nothing is written while the user picks a source or capture is down.
|
||||||
pub(crate) struct Pipeline {
|
pub(crate) struct Pipeline {
|
||||||
|
name_prefix: String,
|
||||||
scene: *mut sys::obs_scene_t,
|
scene: *mut sys::obs_scene_t,
|
||||||
|
scene_item: *mut sys::obs_sceneitem_t,
|
||||||
/// Each source with the output channel it is bound to, if any.
|
/// Each source with the output channel it is bound to, if any.
|
||||||
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
|
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
|
||||||
video_source: *mut sys::obs_source_t,
|
video_source: *mut sys::obs_source_t,
|
||||||
video_encoder: *mut sys::obs_encoder_t,
|
microphone: bool,
|
||||||
audio_encoders: Vec<*mut sys::obs_encoder_t>,
|
audio_clocks: Vec<(*mut sys::obs_source_t, Box<AudioClock>)>,
|
||||||
output: *mut sys::obs_output_t,
|
pending: Mutex<Option<OutputSpec>>,
|
||||||
signals: Option<Box<SignalContext>>,
|
output: Mutex<Option<Output>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: the pointers are only dereferenced on the OBS thread (see module docs).
|
// SAFETY: the pointers are only dereferenced on the OBS thread (see module docs).
|
||||||
@@ -563,16 +737,12 @@ unsafe impl Send for Pipeline {}
|
|||||||
unsafe impl Sync for Pipeline {}
|
unsafe impl Sync for Pipeline {}
|
||||||
|
|
||||||
impl Pipeline {
|
impl Pipeline {
|
||||||
pub(crate) fn build(
|
pub(crate) fn build(capture: &CaptureSpec, output: OutputSpec) -> Result<Self, String> {
|
||||||
spec: &PipelineSpec,
|
PIPEWIRE_STATE.store(0, Ordering::Relaxed);
|
||||||
events: Sender<RecorderEvent>,
|
|
||||||
current_segment: Arc<Mutex<Option<String>>>,
|
|
||||||
) -> Result<Self, String> {
|
|
||||||
PIPEWIRE_STREAM.store(NO_PIPEWIRE_STREAM, Ordering::Relaxed);
|
|
||||||
// OBS releases objects asynchronously, so names must not be reused.
|
// OBS releases objects asynchronously, so names must not be reused.
|
||||||
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
|
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
|
||||||
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed);
|
let name_prefix = format!("clipbin-{}", RECORDINGS.fetch_add(1, Ordering::Relaxed));
|
||||||
let name = |what: &str| cstring(&format!("clipbin-{id}-{what}"));
|
let name = |what: &str| cstring(&format!("{name_prefix}-{what}"));
|
||||||
|
|
||||||
// SAFETY: runs on the OBS thread. Each object is stored in `pipeline`
|
// SAFETY: runs on the OBS thread. Each object is stored in `pipeline`
|
||||||
// as soon as it exists, so Drop releases it if a later step fails.
|
// as soon as it exists, so Drop releases it if a later step fails.
|
||||||
@@ -580,45 +750,43 @@ impl Pipeline {
|
|||||||
let mut pipeline = Self {
|
let mut pipeline = Self {
|
||||||
// Private: internal to Clipbin, not registered with the main canvas.
|
// Private: internal to Clipbin, not registered with the main canvas.
|
||||||
scene: sys::obs_scene_create_private(name("scene").as_ptr()),
|
scene: sys::obs_scene_create_private(name("scene").as_ptr()),
|
||||||
|
scene_item: ptr::null_mut(),
|
||||||
sources: Vec::new(),
|
sources: Vec::new(),
|
||||||
video_source: ptr::null_mut(),
|
video_source: ptr::null_mut(),
|
||||||
video_encoder: ptr::null_mut(),
|
microphone: capture.microphone.is_some(),
|
||||||
audio_encoders: Vec::new(),
|
audio_clocks: Vec::new(),
|
||||||
output: ptr::null_mut(),
|
pending: Mutex::new(None),
|
||||||
signals: None,
|
output: Mutex::new(None),
|
||||||
|
name_prefix: name_prefix.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(source) = &spec.video_source {
|
if let Some(source) = &capture.video_source {
|
||||||
pipeline.video_source = create_source(source, &name("video"))?;
|
pipeline.video_source = create_source(source, &name("video"))?;
|
||||||
pipeline.sources.push((None, pipeline.video_source));
|
pipeline.sources.push((None, pipeline.video_source));
|
||||||
let item = sys::obs_scene_add(pipeline.scene, pipeline.video_source);
|
pipeline.scene_item = sys::obs_scene_add(pipeline.scene, pipeline.video_source);
|
||||||
sys::obs_sceneitem_set_bounds_type(
|
sys::obs_sceneitem_set_bounds_type(
|
||||||
item,
|
pipeline.scene_item,
|
||||||
sys::obs_bounds_type_OBS_BOUNDS_SCALE_INNER,
|
sys::obs_bounds_type_OBS_BOUNDS_SCALE_INNER,
|
||||||
);
|
);
|
||||||
let mut bounds: sys::vec2 = std::mem::zeroed();
|
|
||||||
bounds.__bindgen_anon_1.__bindgen_anon_1.x = spec.canvas.0 as f32;
|
|
||||||
bounds.__bindgen_anon_1.__bindgen_anon_1.y = spec.canvas.1 as f32;
|
|
||||||
sys::obs_sceneitem_set_bounds(item, &bounds);
|
|
||||||
}
|
}
|
||||||
sys::obs_set_output_source(VIDEO_CHANNEL, sys::obs_scene_get_source(pipeline.scene));
|
sys::obs_set_output_source(VIDEO_CHANNEL, sys::obs_scene_get_source(pipeline.scene));
|
||||||
|
|
||||||
// With the microphone on, system and microphone audio get their
|
let tracks = audio_tracks(pipeline.microphone);
|
||||||
// own tracks for editing plus a combined track for playback. With
|
|
||||||
// only system audio, its single track is already the combined mix.
|
|
||||||
// The FFmpeg muxer needs at least one audio track, so recording
|
|
||||||
// without audio sources still writes a silent one.
|
|
||||||
let tracks: &[usize] = match &spec.microphone {
|
|
||||||
Some(_) => &[SYSTEM_TRACK, MICROPHONE_TRACK, COMBINED_TRACK],
|
|
||||||
None => &[SYSTEM_TRACK],
|
|
||||||
};
|
|
||||||
for (source_spec, channel, track) in [
|
for (source_spec, channel, track) in [
|
||||||
(&spec.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK),
|
(&capture.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK),
|
||||||
(&spec.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
|
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
|
||||||
] {
|
] {
|
||||||
if let Some(source_spec) = source_spec {
|
if let Some(source_spec) = source_spec {
|
||||||
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
|
let source_name = format!("{name_prefix}-audio-{channel}");
|
||||||
|
let mut clock = Box::new(AudioClock::new(&source_name));
|
||||||
|
let source = create_source(source_spec, &cstring(&source_name))?;
|
||||||
pipeline.sources.push((Some(channel), source));
|
pipeline.sources.push((Some(channel), source));
|
||||||
|
sys::obs_source_add_audio_capture_callback(
|
||||||
|
source,
|
||||||
|
Some(audio_captured),
|
||||||
|
(&mut *clock as *mut AudioClock).cast(),
|
||||||
|
);
|
||||||
|
pipeline.audio_clocks.push((source, clock));
|
||||||
let mixers = tracks
|
let mixers = tracks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| **t == track || **t == COMBINED_TRACK)
|
.filter(|t| **t == track || **t == COMBINED_TRACK)
|
||||||
@@ -628,139 +796,95 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let encoder_settings = Data::new(&spec.video_encoder_settings);
|
*pipeline.pending.get_mut().expect("not shared yet") = Some(output);
|
||||||
pipeline.video_encoder = sys::obs_video_encoder_create(
|
|
||||||
cstring(&spec.video_encoder).as_ptr(),
|
|
||||||
name("video-encoder").as_ptr(),
|
|
||||||
encoder_settings.0,
|
|
||||||
ptr::null_mut(),
|
|
||||||
);
|
|
||||||
if pipeline.video_encoder.is_null() {
|
|
||||||
return Err(format!(
|
|
||||||
"could not create video encoder {}",
|
|
||||||
spec.video_encoder
|
|
||||||
));
|
|
||||||
}
|
|
||||||
sys::obs_encoder_set_video(pipeline.video_encoder, sys::obs_get_video());
|
|
||||||
|
|
||||||
for &track in tracks {
|
|
||||||
let settings =
|
|
||||||
Data::new(&[("bitrate", SettingValue::Int(spec.audio_bitrate_kbps))]);
|
|
||||||
let encoder = sys::obs_audio_encoder_create(
|
|
||||||
cstring("ffmpeg_aac").as_ptr(),
|
|
||||||
name(&format!("audio-encoder-{track}")).as_ptr(),
|
|
||||||
settings.0,
|
|
||||||
track,
|
|
||||||
ptr::null_mut(),
|
|
||||||
);
|
|
||||||
if encoder.is_null() {
|
|
||||||
return Err("could not create the AAC audio encoder".into());
|
|
||||||
}
|
|
||||||
pipeline.audio_encoders.push(encoder);
|
|
||||||
sys::obs_encoder_set_audio(encoder, sys::obs_get_audio());
|
|
||||||
}
|
|
||||||
|
|
||||||
let first_segment = format!(
|
|
||||||
"{}/{}.{}",
|
|
||||||
spec.segment_dir,
|
|
||||||
segment_stem(0),
|
|
||||||
spec.segment_extension
|
|
||||||
);
|
|
||||||
let output_settings = Data::new(&[
|
|
||||||
("path", SettingValue::String(first_segment.clone())),
|
|
||||||
("directory", SettingValue::String(spec.segment_dir.clone())),
|
|
||||||
("format", SettingValue::String(segment_stem(1))),
|
|
||||||
(
|
|
||||||
"extension",
|
|
||||||
SettingValue::String(spec.segment_extension.into()),
|
|
||||||
),
|
|
||||||
("split_file", SettingValue::Bool(true)),
|
|
||||||
("max_time_sec", SettingValue::Int(spec.segment_seconds)),
|
|
||||||
("max_size_mb", SettingValue::Int(0)),
|
|
||||||
("allow_overwrite", SettingValue::Bool(false)),
|
|
||||||
]);
|
|
||||||
pipeline.output = sys::obs_output_create(
|
|
||||||
cstring("ffmpeg_muxer").as_ptr(),
|
|
||||||
name("segments").as_ptr(),
|
|
||||||
output_settings.0,
|
|
||||||
ptr::null_mut(),
|
|
||||||
);
|
|
||||||
if pipeline.output.is_null() {
|
|
||||||
return Err("could not create the FFmpeg muxer output".into());
|
|
||||||
}
|
|
||||||
sys::obs_output_set_video_encoder(pipeline.output, pipeline.video_encoder);
|
|
||||||
for (index, encoder) in pipeline.audio_encoders.iter().enumerate() {
|
|
||||||
sys::obs_output_set_audio_encoder(pipeline.output, *encoder, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
let signals = Box::new(SignalContext {
|
|
||||||
events: Mutex::new(events),
|
|
||||||
output: pipeline.output,
|
|
||||||
first_segment,
|
|
||||||
current_segment,
|
|
||||||
});
|
|
||||||
let handler = sys::obs_output_get_signal_handler(pipeline.output);
|
|
||||||
for (name, callback) in SIGNALS {
|
|
||||||
sys::signal_handler_connect(
|
|
||||||
handler,
|
|
||||||
cstring(name).as_ptr(),
|
|
||||||
Some(callback),
|
|
||||||
(&*signals as *const SignalContext).cast_mut().cast(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
pipeline.signals = Some(signals);
|
|
||||||
|
|
||||||
Ok(pipeline)
|
Ok(pipeline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts writing segments. A `true` result only means OBS accepted the
|
fn source_size(&self) -> (u32, u32) {
|
||||||
/// request; recording is confirmed by `OutputStarted` and written frames.
|
if self.video_source.is_null() {
|
||||||
pub(crate) fn start(&self) -> Result<(), String> {
|
return (0, 0);
|
||||||
// SAFETY: output is live for the Pipeline's lifetime.
|
}
|
||||||
|
// SAFETY: video_source is live for the Pipeline's lifetime.
|
||||||
unsafe {
|
unsafe {
|
||||||
if sys::obs_output_start(self.output) {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(owned(sys::obs_output_get_last_error(self.output))
|
|
||||||
.unwrap_or_else(|| "the output did not start".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn stop(&self) {
|
|
||||||
// SAFETY: output is live; stopping an inactive output is a no-op.
|
|
||||||
unsafe { sys::obs_output_stop(self.output) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OBS's view of the recording; `Recording::health` adds what reached disk.
|
|
||||||
pub(crate) fn health(&self) -> Health {
|
|
||||||
// SAFETY: all objects are live for the Pipeline's lifetime.
|
|
||||||
unsafe {
|
|
||||||
let (width, height) = if self.video_source.is_null() {
|
|
||||||
(0, 0)
|
|
||||||
} else {
|
|
||||||
(
|
(
|
||||||
sys::obs_source_get_width(self.video_source),
|
sys::obs_source_get_width(self.video_source),
|
||||||
sys::obs_source_get_height(self.video_source),
|
sys::obs_source_get_height(self.video_source),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the segment output if it is not running yet. `Ok(None)` means it
|
||||||
|
/// was already started; `Ok(Some(size))` reports the recording size chosen.
|
||||||
|
pub(crate) fn start_output(&self) -> Result<Option<(u32, u32)>, String> {
|
||||||
|
let mut output = self.output.lock().map_err(|_| "recording state poisoned")?;
|
||||||
|
let Some(spec) = self
|
||||||
|
.pending
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "recording state poisoned")?
|
||||||
|
.take()
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let source_streaming = match PIPEWIRE_STREAM.load(Ordering::Relaxed) {
|
let canvas = canvas_size(spec.resolution, self.source_size());
|
||||||
NO_PIPEWIRE_STREAM => None,
|
reset_video(canvas, spec.frames_per_second)?;
|
||||||
state => Some(state == STREAMING),
|
if !self.scene_item.is_null() {
|
||||||
};
|
// SAFETY: the scene item belongs to the live scene.
|
||||||
|
unsafe {
|
||||||
|
let mut bounds: sys::vec2 = std::mem::zeroed();
|
||||||
|
bounds.__bindgen_anon_1.__bindgen_anon_1.x = canvas.0 as f32;
|
||||||
|
bounds.__bindgen_anon_1.__bindgen_anon_1.y = canvas.1 as f32;
|
||||||
|
sys::obs_sceneitem_set_bounds(self.scene_item, &bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let started = Output::build(&self.name_prefix, audio_tracks(self.microphone), spec)?;
|
||||||
|
started.start()?;
|
||||||
|
*output = Some(started);
|
||||||
|
Ok(Some(canvas))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the output. Returns false if it never started.
|
||||||
|
pub(crate) fn stop(&self) -> bool {
|
||||||
|
self.pending.lock().ok().map(|mut pending| pending.take());
|
||||||
|
match self.output.lock().ok().as_deref() {
|
||||||
|
Some(Some(output)) => {
|
||||||
|
// SAFETY: output is live; stopping an inactive output is a no-op.
|
||||||
|
unsafe { sys::obs_output_stop(output.output) };
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OBS's view of the recording; `Recording::poll` adds what reached disk.
|
||||||
|
pub(crate) fn health(&self) -> Health {
|
||||||
|
let (source_width, source_height) = self.source_size();
|
||||||
|
let output = self.output.lock().ok();
|
||||||
|
let output = output.as_deref().and_then(Option::as_ref);
|
||||||
|
// SAFETY: the output, when present, is live for the Pipeline's lifetime.
|
||||||
|
let counters = output.map(|output| unsafe {
|
||||||
|
(
|
||||||
|
sys::obs_output_active(output.output),
|
||||||
|
sys::obs_output_get_total_frames(output.output).max(0) as u64,
|
||||||
|
sys::obs_output_get_frames_dropped(output.output).max(0) as u64,
|
||||||
|
sys::obs_output_get_total_bytes(output.output),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let (output_active, total_frames, dropped_frames, total_bytes) =
|
||||||
|
counters.unwrap_or((false, 0, 0, 0));
|
||||||
Health {
|
Health {
|
||||||
source_width: width,
|
source_width,
|
||||||
source_height: height,
|
source_height,
|
||||||
source_streaming,
|
pipewire: load_pipewire_state(),
|
||||||
output_active: sys::obs_output_active(self.output),
|
output_active,
|
||||||
total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64,
|
total_frames,
|
||||||
dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64,
|
dropped_frames,
|
||||||
total_bytes: sys::obs_output_get_total_bytes(self.output),
|
total_bytes,
|
||||||
lagged_frames: sys::obs_get_lagged_frames() as u64,
|
// SAFETY: a global counter.
|
||||||
|
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
|
||||||
written_bytes: 0,
|
written_bytes: 0,
|
||||||
disk_available_bytes: None,
|
disk_available_bytes: None,
|
||||||
}
|
audio_lost: self.audio_clocks.iter().any(|(_, clock)| clock.lost()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,9 +909,153 @@ impl Pipeline {
|
|||||||
|
|
||||||
impl Drop for Pipeline {
|
impl Drop for Pipeline {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// The output goes first: it holds the encoders reading the scene.
|
||||||
|
drop(self.output.get_mut().ok().and_then(Option::take));
|
||||||
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
|
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
|
||||||
// Releases in reverse order of creation, after detaching callbacks;
|
unsafe {
|
||||||
// a partially built Pipeline has null or empty fields for later steps.
|
for (source, clock) in &mut self.audio_clocks {
|
||||||
|
sys::obs_source_remove_audio_capture_callback(
|
||||||
|
*source,
|
||||||
|
Some(audio_captured),
|
||||||
|
(&mut **clock as *mut AudioClock).cast(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
|
||||||
|
for (channel, source) in &self.sources {
|
||||||
|
if let Some(channel) = channel {
|
||||||
|
sys::obs_set_output_source(*channel, ptr::null_mut());
|
||||||
|
}
|
||||||
|
sys::obs_source_release(*source);
|
||||||
|
}
|
||||||
|
sys::obs_scene_release(self.scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoders and the FFmpeg muxer writing keyframe-aligned segments.
|
||||||
|
struct Output {
|
||||||
|
video_encoder: *mut sys::obs_encoder_t,
|
||||||
|
audio_encoders: Vec<*mut sys::obs_encoder_t>,
|
||||||
|
output: *mut sys::obs_output_t,
|
||||||
|
signals: Option<Box<SignalContext>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Output {
|
||||||
|
fn build(name_prefix: &str, tracks: &[usize], spec: OutputSpec) -> Result<Self, String> {
|
||||||
|
let name = |what: &str| cstring(&format!("{name_prefix}-{what}"));
|
||||||
|
// SAFETY: runs on the OBS thread. Each object is stored in `built` as
|
||||||
|
// soon as it exists, so Drop releases it if a later step fails.
|
||||||
|
unsafe {
|
||||||
|
let mut built = Self {
|
||||||
|
video_encoder: ptr::null_mut(),
|
||||||
|
audio_encoders: Vec::new(),
|
||||||
|
output: ptr::null_mut(),
|
||||||
|
signals: None,
|
||||||
|
};
|
||||||
|
let encoder_settings = Data::new(&spec.video_encoder_settings);
|
||||||
|
built.video_encoder = sys::obs_video_encoder_create(
|
||||||
|
cstring(&spec.video_encoder).as_ptr(),
|
||||||
|
name("video-encoder").as_ptr(),
|
||||||
|
encoder_settings.0,
|
||||||
|
ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if built.video_encoder.is_null() {
|
||||||
|
return Err(format!(
|
||||||
|
"could not create video encoder {}",
|
||||||
|
spec.video_encoder
|
||||||
|
));
|
||||||
|
}
|
||||||
|
sys::obs_encoder_set_video(built.video_encoder, sys::obs_get_video());
|
||||||
|
|
||||||
|
for &track in tracks {
|
||||||
|
let settings =
|
||||||
|
Data::new(&[("bitrate", SettingValue::Int(spec.audio_bitrate_kbps))]);
|
||||||
|
let encoder = sys::obs_audio_encoder_create(
|
||||||
|
cstring("ffmpeg_aac").as_ptr(),
|
||||||
|
name(&format!("audio-encoder-{track}")).as_ptr(),
|
||||||
|
settings.0,
|
||||||
|
track,
|
||||||
|
ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if encoder.is_null() {
|
||||||
|
return Err("could not create the AAC audio encoder".into());
|
||||||
|
}
|
||||||
|
built.audio_encoders.push(encoder);
|
||||||
|
sys::obs_encoder_set_audio(encoder, sys::obs_get_audio());
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_segment = format!(
|
||||||
|
"{}/{}.{}",
|
||||||
|
spec.segment_dir,
|
||||||
|
segment_stem(0),
|
||||||
|
spec.segment_extension
|
||||||
|
);
|
||||||
|
let output_settings = Data::new(&[
|
||||||
|
("path", SettingValue::String(first_segment.clone())),
|
||||||
|
("directory", SettingValue::String(spec.segment_dir.clone())),
|
||||||
|
("format", SettingValue::String(segment_stem(1))),
|
||||||
|
(
|
||||||
|
"extension",
|
||||||
|
SettingValue::String(spec.segment_extension.into()),
|
||||||
|
),
|
||||||
|
("split_file", SettingValue::Bool(true)),
|
||||||
|
("max_time_sec", SettingValue::Int(spec.segment_seconds)),
|
||||||
|
("max_size_mb", SettingValue::Int(0)),
|
||||||
|
("allow_overwrite", SettingValue::Bool(false)),
|
||||||
|
]);
|
||||||
|
built.output = sys::obs_output_create(
|
||||||
|
cstring("ffmpeg_muxer").as_ptr(),
|
||||||
|
name("segments").as_ptr(),
|
||||||
|
output_settings.0,
|
||||||
|
ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if built.output.is_null() {
|
||||||
|
return Err("could not create the FFmpeg muxer output".into());
|
||||||
|
}
|
||||||
|
sys::obs_output_set_video_encoder(built.output, built.video_encoder);
|
||||||
|
for (index, encoder) in built.audio_encoders.iter().enumerate() {
|
||||||
|
sys::obs_output_set_audio_encoder(built.output, *encoder, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
let signals = Box::new(SignalContext {
|
||||||
|
events: Mutex::new(spec.events),
|
||||||
|
output: built.output,
|
||||||
|
first_segment,
|
||||||
|
current_segment: spec.current_segment,
|
||||||
|
});
|
||||||
|
let handler = sys::obs_output_get_signal_handler(built.output);
|
||||||
|
for (name, callback) in SIGNALS {
|
||||||
|
sys::signal_handler_connect(
|
||||||
|
handler,
|
||||||
|
cstring(name).as_ptr(),
|
||||||
|
Some(callback),
|
||||||
|
(&*signals as *const SignalContext).cast_mut().cast(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
built.signals = Some(signals);
|
||||||
|
Ok(built)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `true` result only means OBS accepted the request; recording is
|
||||||
|
/// confirmed by `OutputStarted` and footage growing on disk.
|
||||||
|
fn start(&self) -> Result<(), String> {
|
||||||
|
// SAFETY: output is live.
|
||||||
|
unsafe {
|
||||||
|
if sys::obs_output_start(self.output) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(owned(sys::obs_output_get_last_error(self.output))
|
||||||
|
.unwrap_or_else(|| "the output did not start".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Output {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: runs on the OBS thread; detaches callbacks before releasing.
|
||||||
|
// A partially built Output has null or empty fields for later steps.
|
||||||
unsafe {
|
unsafe {
|
||||||
if !self.output.is_null() {
|
if !self.output.is_null() {
|
||||||
sys::obs_output_stop(self.output);
|
sys::obs_output_stop(self.output);
|
||||||
@@ -810,14 +1078,6 @@ impl Drop for Pipeline {
|
|||||||
for encoder in &self.audio_encoders {
|
for encoder in &self.audio_encoders {
|
||||||
sys::obs_encoder_release(*encoder);
|
sys::obs_encoder_release(*encoder);
|
||||||
}
|
}
|
||||||
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
|
|
||||||
for (channel, source) in &self.sources {
|
|
||||||
if let Some(channel) = channel {
|
|
||||||
sys::obs_set_output_source(*channel, ptr::null_mut());
|
|
||||||
}
|
|
||||||
sys::obs_source_release(*source);
|
|
||||||
}
|
|
||||||
sys::obs_scene_release(self.scene);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,13 +1112,58 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipewire_stream_state_is_read_from_the_plugin_log() {
|
fn wasapi_events_are_scoped_and_distinguish_failure_from_silence() {
|
||||||
let line = r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#;
|
|
||||||
assert_eq!(pipewire_stream_state(line), Some("paused"));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pipewire_stream_state("[pipewire] Screencast session created"),
|
wasapi_event(
|
||||||
|
"WASAPI: Device 'Speakers' [48000 Hz] initialized (source: clipbin-7-audio-1)"
|
||||||
|
),
|
||||||
|
Some(("clipbin-7-audio-1", false))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event("Device 'Speakers' invalidated. Retrying (source: clipbin-7-audio-1)"),
|
||||||
|
Some(("clipbin-7-audio-1", true))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event("WASAPI: Device 'default' failed to start (source: clipbin-7-audio-3)"),
|
||||||
|
Some(("clipbin-7-audio-3", true))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event("Device 'Speakers' invalidated. Retrying (source: another-app)"),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
|
assert_eq!(wasapi_event("WASAPI: Device 'Speakers' Terminated"), None);
|
||||||
|
assert_eq!(wasapi_event("WASAPI: Default output device changed"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipewire_state_is_read_from_the_plugin_log() {
|
||||||
|
let state = |line| pipewire_state(line);
|
||||||
|
assert_eq!(
|
||||||
|
state(r#"[pipewire] Stream 0x7fe128037440 state: "streaming" (error: none)"#),
|
||||||
|
Some(PipeWireState::Streaming)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state(r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#),
|
||||||
|
Some(PipeWireState::Stopped)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state("[pipewire] Failed to select source, denied or cancelled by user"),
|
||||||
|
Some(PipeWireState::Refused)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state("[pipewire] Error selecting screencast source: Timeout"),
|
||||||
|
Some(PipeWireState::Refused)
|
||||||
|
);
|
||||||
|
assert_eq!(state("[pipewire] Screencast session created"), None);
|
||||||
|
// OBS cancelling its own pending request at teardown is not a refusal.
|
||||||
|
assert_eq!(state("[portals] Request cancelled"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recording_follows_the_source_unless_configured() {
|
||||||
|
assert_eq!(canvas_size(None, (1440, 2560)), (1440, 2560));
|
||||||
|
assert_eq!(canvas_size(None, (1417, 2511)), (1416, 2510));
|
||||||
|
assert_eq!(canvas_size(Some((1920, 1080)), (1440, 2560)), (1920, 1080));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub mod runtime;
|
|||||||
mod thread;
|
mod thread;
|
||||||
|
|
||||||
pub use recorder::{
|
pub use recorder::{
|
||||||
Engine, EngineError, Health, RecorderEvent, Recording, RecordingConfig, SEGMENT_EXTENSION,
|
Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig,
|
||||||
StatusTracker, StopReason, VideoFormat,
|
SEGMENT_EXTENSION, StatusTracker, StopReason,
|
||||||
};
|
};
|
||||||
pub use runtime::{RuntimeError, RuntimeLayout};
|
pub use runtime::{RuntimeError, RuntimeLayout};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{
|
sync::{
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
mpsc::{self, Receiver},
|
mpsc::{self, Receiver, Sender},
|
||||||
},
|
},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
RuntimeLayout,
|
RuntimeLayout,
|
||||||
capture::{SettingValue, SourceSpec},
|
capture::{SettingValue, SourceSpec},
|
||||||
encoders::registered_candidates,
|
encoders::registered_candidates,
|
||||||
ffi::{self, Pipeline, PipelineSpec, Session, StartupSpec},
|
ffi::{self, CaptureSpec, OutputSpec, Pipeline, Session, StartupSpec},
|
||||||
runtime::PLUGINS,
|
runtime::PLUGINS,
|
||||||
thread::ObsThread,
|
thread::ObsThread,
|
||||||
};
|
};
|
||||||
@@ -23,24 +23,18 @@ use crate::{
|
|||||||
/// export remuxes to MP4 without re-encoding.
|
/// export remuxes to MP4 without re-encoding.
|
||||||
pub const SEGMENT_EXTENSION: &str = "mkv";
|
pub const SEGMENT_EXTENSION: &str = "mkv";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct VideoFormat {
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub frames_per_second: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The single libobs instance. Dropping it shuts OBS down.
|
/// The single libobs instance. Dropping it shuts OBS down.
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
obs: Arc<ObsThread>,
|
obs: Arc<ObsThread>,
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
format: VideoFormat,
|
frames_per_second: u32,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
_glib: ffi::GlibLoop,
|
_glib: ffi::GlibLoop,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
pub fn start(layout: &RuntimeLayout, format: VideoFormat) -> Result<Self, EngineError> {
|
/// 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 path = |path: PathBuf| path.to_string_lossy().into_owned();
|
||||||
let spec = StartupSpec {
|
let spec = StartupSpec {
|
||||||
libobs_data_dir: path(layout.libobs_data_dir()),
|
libobs_data_dir: path(layout.libobs_data_dir()),
|
||||||
@@ -53,9 +47,10 @@ impl Engine {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
width: format.width,
|
// A placeholder canvas until a recording knows its source's size.
|
||||||
height: format.height,
|
width: 1920,
|
||||||
frames_per_second: format.frames_per_second,
|
height: 1080,
|
||||||
|
frames_per_second,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let glib = ffi::GlibLoop::start();
|
let glib = ffi::GlibLoop::start();
|
||||||
@@ -66,7 +61,7 @@ impl Engine {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
obs,
|
obs,
|
||||||
session: Some(session),
|
session: Some(session),
|
||||||
format,
|
frames_per_second,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
_glib: glib,
|
_glib: glib,
|
||||||
})
|
})
|
||||||
@@ -104,13 +99,21 @@ impl Engine {
|
|||||||
.collect()
|
.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> {
|
pub fn start_recording(&self, config: RecordingConfig) -> Result<Recording, EngineError> {
|
||||||
std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?;
|
std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?;
|
||||||
let spec = PipelineSpec {
|
let (sender, events) = mpsc::channel();
|
||||||
canvas: (self.format.width, self.format.height),
|
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,
|
video_source: config.video_source,
|
||||||
system_audio: config.system_audio,
|
system_audio: config.system_audio,
|
||||||
microphone: config.microphone,
|
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: config.video_encoder,
|
||||||
video_encoder_settings: vec![
|
video_encoder_settings: vec![
|
||||||
("rate_control", SettingValue::String("CBR".into())),
|
("rate_control", SettingValue::String("CBR".into())),
|
||||||
@@ -124,26 +127,23 @@ impl Engine {
|
|||||||
segment_dir: forward_slashes(&config.segment_dir),
|
segment_dir: forward_slashes(&config.segment_dir),
|
||||||
segment_extension: SEGMENT_EXTENSION,
|
segment_extension: SEGMENT_EXTENSION,
|
||||||
segment_seconds: config.segment_seconds,
|
segment_seconds: config.segment_seconds,
|
||||||
|
events: sender.clone(),
|
||||||
|
current_segment: Arc::clone(¤t_segment),
|
||||||
};
|
};
|
||||||
let (sender, events) = mpsc::channel();
|
|
||||||
let current_segment = Arc::new(Mutex::new(None));
|
|
||||||
let segment = Arc::clone(¤t_segment);
|
|
||||||
let pipeline = self
|
let pipeline = self
|
||||||
.obs
|
.obs
|
||||||
.run(move || Pipeline::build(&spec, sender, segment).map(Arc::new))
|
.run(move || Pipeline::build(&capture, output).map(Arc::new))
|
||||||
.map_err(EngineError::Pipeline)?;
|
.map_err(EngineError::Pipeline)?;
|
||||||
let recording = Recording {
|
Ok(Recording {
|
||||||
obs: Arc::clone(&self.obs),
|
obs: Arc::clone(&self.obs),
|
||||||
pipeline: Some(pipeline),
|
pipeline: Some(pipeline),
|
||||||
events,
|
events,
|
||||||
|
sender,
|
||||||
|
waits_for_picture,
|
||||||
current_segment,
|
current_segment,
|
||||||
written: Mutex::default(),
|
written: Mutex::default(),
|
||||||
segment_dir: config.segment_dir,
|
segment_dir: config.segment_dir,
|
||||||
};
|
})
|
||||||
recording
|
|
||||||
.with_pipeline(|pipeline| pipeline.start())
|
|
||||||
.map_err(EngineError::Start)?;
|
|
||||||
Ok(recording)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,9 +161,13 @@ fn forward_slashes(path: &Path) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct RecordingConfig {
|
pub struct RecordingConfig {
|
||||||
|
/// Override the engine startup rate for this recording.
|
||||||
|
pub frames_per_second: Option<u32>,
|
||||||
pub video_source: Option<SourceSpec>,
|
pub video_source: Option<SourceSpec>,
|
||||||
pub system_audio: Option<SourceSpec>,
|
pub system_audio: Option<SourceSpec>,
|
||||||
pub microphone: 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_encoder: String,
|
||||||
pub video_bitrate_kbps: i64,
|
pub video_bitrate_kbps: i64,
|
||||||
pub keyframe_interval_seconds: i64,
|
pub keyframe_interval_seconds: i64,
|
||||||
@@ -178,6 +182,9 @@ pub struct Recording {
|
|||||||
obs: Arc<ObsThread>,
|
obs: Arc<ObsThread>,
|
||||||
pipeline: Option<Arc<Pipeline>>,
|
pipeline: Option<Arc<Pipeline>>,
|
||||||
events: Receiver<RecorderEvent>,
|
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>>>,
|
current_segment: Arc<Mutex<Option<String>>>,
|
||||||
written: Mutex<Written>,
|
written: Mutex<Written>,
|
||||||
segment_dir: PathBuf,
|
segment_dir: PathBuf,
|
||||||
@@ -207,11 +214,23 @@ impl Recording {
|
|||||||
&self.events
|
&self.events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OBS's counters plus what reached disk. OBS counts bytes handed to its
|
/// Samples the recording, starting the segment output the first time the
|
||||||
/// muxer process, which keeps rising even if that process cannot write, so
|
/// source has a picture. Call it regularly (the probe uses once a second).
|
||||||
/// only growth on disk proves footage is saved.
|
///
|
||||||
pub fn health(&self) -> Health {
|
/// 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);
|
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();
|
health.disk_available_bytes = fs4::available_space(&self.segment_dir).ok();
|
||||||
let current = self
|
let current = self
|
||||||
.current_segment
|
.current_segment
|
||||||
@@ -230,16 +249,22 @@ impl Recording {
|
|||||||
health.written_bytes =
|
health.written_bytes =
|
||||||
written.finished_bytes + written.current.as_deref().map_or(0, size);
|
written.finished_bytes + written.current.as_deref().map_or(0, size);
|
||||||
}
|
}
|
||||||
health
|
Ok(health)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_token(&self) -> Option<String> {
|
pub fn restore_token(&self) -> Option<String> {
|
||||||
self.with_pipeline(Pipeline::restore_token)
|
self.with_pipeline(Pipeline::restore_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests a stop; `OutputStopped` follows once the last segment is finalised.
|
/// Requests a stop; `OutputStopped` follows once the last segment is
|
||||||
|
/// finalised, or at once if the output never started.
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
self.with_pipeline(Pipeline::stop)
|
if !self.with_pipeline(Pipeline::stop) {
|
||||||
|
let _ = self.sender.send(RecorderEvent::OutputStopped {
|
||||||
|
code: libobs::OBS_OUTPUT_SUCCESS.into(),
|
||||||
|
last_error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,10 +279,16 @@ impl Drop for Recording {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RecorderEvent {
|
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.
|
/// OBS reports the output as started. Not yet proof that footage is written.
|
||||||
OutputStarted,
|
OutputStarted,
|
||||||
/// The muxer opened a new segment file; the previous one is complete.
|
/// The muxer opened a new segment file; the previous one is complete.
|
||||||
SegmentStarted { path: String },
|
SegmentStarted {
|
||||||
|
path: String,
|
||||||
|
total_frames: u64,
|
||||||
|
dropped_frames: u64,
|
||||||
|
},
|
||||||
OutputStopped {
|
OutputStopped {
|
||||||
code: i64,
|
code: i64,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
@@ -270,9 +301,11 @@ pub enum RecorderEvent {
|
|||||||
pub struct Health {
|
pub struct Health {
|
||||||
pub source_width: u32,
|
pub source_width: u32,
|
||||||
pub source_height: u32,
|
pub source_height: u32,
|
||||||
/// For PipeWire capture, whether its stream is delivering frames. `None`
|
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||||
/// for sources that report loss through their size instead.
|
/// which report loss through their size, and before the portal answers.
|
||||||
pub source_streaming: Option<bool>,
|
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 output_active: bool,
|
||||||
pub total_frames: u64,
|
pub total_frames: u64,
|
||||||
pub dropped_frames: u64,
|
pub dropped_frames: u64,
|
||||||
@@ -284,6 +317,24 @@ pub struct Health {
|
|||||||
pub disk_available_bytes: Option<u64>,
|
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.
|
/// 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
|
/// Recording is reported only while the source has a picture and encoded data
|
||||||
@@ -334,9 +385,14 @@ impl StatusTracker {
|
|||||||
self.last_written = health.written_bytes;
|
self.last_written = health.written_bytes;
|
||||||
self.last_progress = now;
|
self.last_progress = now;
|
||||||
}
|
}
|
||||||
let has_picture = health.source_width > 0
|
if health.pipewire == Some(PipeWireState::Refused) {
|
||||||
&& health.source_height > 0
|
self.status = RecordingStatus::Failed {
|
||||||
&& health.source_streaming != Some(false);
|
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;
|
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
|
||||||
if let Some(available) = health.disk_available_bytes
|
if let Some(available) = health.disk_available_bytes
|
||||||
&& available < self.min_free_bytes
|
&& available < self.min_free_bytes
|
||||||
@@ -355,17 +411,27 @@ impl StatusTracker {
|
|||||||
(_, true, true)
|
(_, true, true)
|
||||||
if health.output_active && health.total_frames > 0 && self.last_written > 0 =>
|
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::Recording, false, _) => RecordingStatus::Failed {
|
}
|
||||||
|
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, false, _) => {
|
||||||
|
RecordingStatus::Failed {
|
||||||
reason: "the capture source stopped providing a picture".into(),
|
reason: "the capture source stopped providing a picture".into(),
|
||||||
},
|
}
|
||||||
(RecordingStatus::Recording, true, false) => RecordingStatus::Failed {
|
}
|
||||||
|
(RecordingStatus::Recording | RecordingStatus::Degraded { .. }, true, false) => {
|
||||||
|
RecordingStatus::Failed {
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"no footage was written for {} seconds",
|
"no footage was written for {} seconds",
|
||||||
self.stall_limit.as_secs()
|
self.stall_limit.as_secs()
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
(_, false, _) => RecordingStatus::WaitingForCapture,
|
(_, false, _) => RecordingStatus::WaitingForCapture,
|
||||||
(status, _, _) => status.clone(),
|
(status, _, _) => status.clone(),
|
||||||
};
|
};
|
||||||
@@ -450,17 +516,40 @@ mod tests {
|
|||||||
Health {
|
Health {
|
||||||
source_width: 1920,
|
source_width: 1920,
|
||||||
source_height: 1080,
|
source_height: 1080,
|
||||||
source_streaming: None,
|
pipewire: None,
|
||||||
output_active: true,
|
output_active: true,
|
||||||
total_frames: bytes / 1000,
|
total_frames: bytes / 1000,
|
||||||
dropped_frames: 0,
|
dropped_frames: 0,
|
||||||
total_bytes: bytes,
|
total_bytes: bytes,
|
||||||
lagged_frames: 0,
|
lagged_frames: 0,
|
||||||
written_bytes: bytes,
|
written_bytes: bytes,
|
||||||
|
audio_lost: false,
|
||||||
disk_available_bytes: Some(u64::MAX),
|
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]
|
#[test]
|
||||||
fn low_disk_space_fails_before_the_disk_is_full() {
|
fn low_disk_space_fails_before_the_disk_is_full() {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -562,7 +651,7 @@ mod tests {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||||
let streaming = Health {
|
let streaming = Health {
|
||||||
source_streaming: Some(true),
|
pipewire: Some(PipeWireState::Streaming),
|
||||||
..healthy(5000)
|
..healthy(5000)
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -570,7 +659,7 @@ mod tests {
|
|||||||
&RecordingStatus::Recording
|
&RecordingStatus::Recording
|
||||||
);
|
);
|
||||||
let paused = Health {
|
let paused = Health {
|
||||||
source_streaming: Some(false),
|
pipewire: Some(PipeWireState::Stopped),
|
||||||
..healthy(9000)
|
..healthy(9000)
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -579,6 +668,29 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn output_errors_fail_and_clean_stops_go_idle() {
|
fn output_errors_fail_and_clean_stops_go_idle() {
|
||||||
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
//! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW]
|
//! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW]
|
||||||
//! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID]
|
//! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID]
|
||||||
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
||||||
//! [--width W --height H --fps F] [--no-audio] [--mic]
|
//! [--width W --height H] [--fps F] [--no-audio] [--mic]
|
||||||
//! [--min-free-mb MB] [--out DIR]
|
//! [--min-free-mb MB] [--out DIR]
|
||||||
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--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
|
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
|
||||||
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
||||||
@@ -24,7 +24,7 @@ use std::{
|
|||||||
|
|
||||||
use clipbin_core::{RecordingStatus, VideoCodec};
|
use clipbin_core::{RecordingStatus, VideoCodec};
|
||||||
use clipbin_obs::{
|
use clipbin_obs::{
|
||||||
Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, VideoFormat,
|
Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason,
|
||||||
capture::{self, CaptureTarget, MonitorMatch, WindowMatch},
|
capture::{self, CaptureTarget, MonitorMatch, WindowMatch},
|
||||||
encoders::ALL_CODECS,
|
encoders::ALL_CODECS,
|
||||||
};
|
};
|
||||||
@@ -63,6 +63,7 @@ fn usage(error: &str) -> ExitCode {
|
|||||||
|
|
||||||
struct Options {
|
struct Options {
|
||||||
verify: bool,
|
verify: bool,
|
||||||
|
expect_motion: bool,
|
||||||
target: String,
|
target: String,
|
||||||
window: String,
|
window: String,
|
||||||
restore_token: Option<String>,
|
restore_token: Option<String>,
|
||||||
@@ -72,7 +73,10 @@ struct Options {
|
|||||||
segment_seconds: i64,
|
segment_seconds: i64,
|
||||||
keyint: i64,
|
keyint: i64,
|
||||||
bitrate: i64,
|
bitrate: i64,
|
||||||
format: VideoFormat,
|
/// The recording size; by default it follows the captured source.
|
||||||
|
width: Option<u32>,
|
||||||
|
height: Option<u32>,
|
||||||
|
frames_per_second: u32,
|
||||||
audio: bool,
|
audio: bool,
|
||||||
mic: bool,
|
mic: bool,
|
||||||
min_free_mb: u64,
|
min_free_mb: u64,
|
||||||
@@ -83,6 +87,7 @@ impl Options {
|
|||||||
fn parse(args: &[String]) -> Result<Self, String> {
|
fn parse(args: &[String]) -> Result<Self, String> {
|
||||||
let mut options = Self {
|
let mut options = Self {
|
||||||
verify: false,
|
verify: false,
|
||||||
|
expect_motion: false,
|
||||||
target: "monitor".into(),
|
target: "monitor".into(),
|
||||||
window: String::new(),
|
window: String::new(),
|
||||||
restore_token: None,
|
restore_token: None,
|
||||||
@@ -92,11 +97,9 @@ impl Options {
|
|||||||
segment_seconds: 2,
|
segment_seconds: 2,
|
||||||
keyint: 1,
|
keyint: 1,
|
||||||
bitrate: 20_000,
|
bitrate: 20_000,
|
||||||
format: VideoFormat {
|
width: None,
|
||||||
width: 1920,
|
height: None,
|
||||||
height: 1080,
|
|
||||||
frames_per_second: 60,
|
frames_per_second: 60,
|
||||||
},
|
|
||||||
audio: true,
|
audio: true,
|
||||||
mic: false,
|
mic: false,
|
||||||
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
|
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
|
||||||
@@ -112,6 +115,7 @@ impl Options {
|
|||||||
};
|
};
|
||||||
match flag.as_str() {
|
match flag.as_str() {
|
||||||
"--verify" => options.verify = true,
|
"--verify" => options.verify = true,
|
||||||
|
"--expect-motion" => options.expect_motion = true,
|
||||||
"--target" => options.target = value()?,
|
"--target" => options.target = value()?,
|
||||||
"--window" => options.window = value()?,
|
"--window" => options.window = value()?,
|
||||||
"--restore-token" => options.restore_token = Some(value()?),
|
"--restore-token" => options.restore_token = Some(value()?),
|
||||||
@@ -121,9 +125,9 @@ impl Options {
|
|||||||
"--segment-seconds" => options.segment_seconds = number(value()?)?,
|
"--segment-seconds" => options.segment_seconds = number(value()?)?,
|
||||||
"--keyint" => options.keyint = number(value()?)?,
|
"--keyint" => options.keyint = number(value()?)?,
|
||||||
"--bitrate" => options.bitrate = number(value()?)?,
|
"--bitrate" => options.bitrate = number(value()?)?,
|
||||||
"--width" => options.format.width = number(value()?)? as u32,
|
"--width" => options.width = Some(number(value()?)? as u32),
|
||||||
"--height" => options.format.height = number(value()?)? as u32,
|
"--height" => options.height = Some(number(value()?)? as u32),
|
||||||
"--fps" => options.format.frames_per_second = number(value()?)? as u32,
|
"--fps" => options.frames_per_second = number(value()?)? as u32,
|
||||||
"--no-audio" => options.audio = false,
|
"--no-audio" => options.audio = false,
|
||||||
"--mic" => options.mic = true,
|
"--mic" => options.mic = true,
|
||||||
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
|
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
|
||||||
@@ -134,6 +138,14 @@ impl Options {
|
|||||||
Ok(options)
|
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> {
|
fn capture_target(&self) -> Result<CaptureTarget, String> {
|
||||||
let window = WindowMatch {
|
let window = WindowMatch {
|
||||||
obs_window: self.window.clone(),
|
obs_window: self.window.clone(),
|
||||||
@@ -177,7 +189,7 @@ fn start_engine(options: &Options) -> Result<Engine, Box<dyn std::error::Error>>
|
|||||||
"runtime_obs_version={}",
|
"runtime_obs_version={}",
|
||||||
layout.version().unwrap_or_default()
|
layout.version().unwrap_or_default()
|
||||||
);
|
);
|
||||||
let engine = Engine::start(&layout, options.format)?;
|
let engine = Engine::start(&layout, options.frames_per_second)?;
|
||||||
println!("obs_version={}", engine.obs_version());
|
println!("obs_version={}", engine.obs_version());
|
||||||
for plugin in engine.plugins_failed() {
|
for plugin in engine.plugins_failed() {
|
||||||
println!("plugin_not_initialised={plugin}");
|
println!("plugin_not_initialised={plugin}");
|
||||||
@@ -209,9 +221,11 @@ fn encoders(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
let dir = options.out.join(format!("verify-{key}-{encoder}"));
|
let dir = options.out.join(format!("verify-{key}-{encoder}"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
let config = RecordingConfig {
|
let config = RecordingConfig {
|
||||||
|
frames_per_second: None,
|
||||||
video_source: None,
|
video_source: None,
|
||||||
system_audio: None,
|
system_audio: None,
|
||||||
microphone: None,
|
microphone: None,
|
||||||
|
resolution: Some(options.resolution()?.unwrap_or((1920, 1080))),
|
||||||
video_encoder: encoder.clone(),
|
video_encoder: encoder.clone(),
|
||||||
video_bitrate_kbps: options.bitrate,
|
video_bitrate_kbps: options.bitrate,
|
||||||
keyframe_interval_seconds: options.keyint,
|
keyframe_interval_seconds: options.keyint,
|
||||||
@@ -242,8 +256,9 @@ fn verify_encoder(engine: &Engine, config: RecordingConfig) -> Result<u64, Strin
|
|||||||
let recording = engine
|
let recording = engine
|
||||||
.start_recording(config)
|
.start_recording(config)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
recording.poll().map_err(|error| error.to_string())?;
|
||||||
std::thread::sleep(Duration::from_secs(3));
|
std::thread::sleep(Duration::from_secs(3));
|
||||||
let health = recording.health();
|
let health = recording.poll().map_err(|error| error.to_string())?;
|
||||||
recording.stop();
|
recording.stop();
|
||||||
let stop = wait_for_stop(&recording, Duration::from_secs(10));
|
let stop = wait_for_stop(&recording, Duration::from_secs(10));
|
||||||
match stop {
|
match stop {
|
||||||
@@ -305,9 +320,11 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
println!("segment_dir={}", dir.display());
|
println!("segment_dir={}", dir.display());
|
||||||
let config = RecordingConfig {
|
let config = RecordingConfig {
|
||||||
|
frames_per_second: None,
|
||||||
video_source: Some(video_source),
|
video_source: Some(video_source),
|
||||||
system_audio: options.audio.then(capture::system_audio_source),
|
system_audio: options.audio.then(capture::system_audio_source),
|
||||||
microphone: options.mic.then(capture::microphone_source),
|
microphone: options.mic.then(capture::microphone_source),
|
||||||
|
resolution: options.resolution()?,
|
||||||
video_encoder: encoder,
|
video_encoder: encoder,
|
||||||
video_bitrate_kbps: options.bitrate,
|
video_bitrate_kbps: options.bitrate,
|
||||||
keyframe_interval_seconds: options.keyint,
|
keyframe_interval_seconds: options.keyint,
|
||||||
@@ -335,13 +352,13 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now >= next_sample {
|
if now >= next_sample {
|
||||||
next_sample += Duration::from_secs(1);
|
next_sample += Duration::from_secs(1);
|
||||||
let health = recording.health();
|
let health = recording.poll()?;
|
||||||
println!(
|
println!(
|
||||||
"t={} health source={}x{} streaming={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
"t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
||||||
elapsed(),
|
elapsed(),
|
||||||
health.source_width,
|
health.source_width,
|
||||||
health.source_height,
|
health.source_height,
|
||||||
health.source_streaming,
|
health.pipewire,
|
||||||
health.output_active,
|
health.output_active,
|
||||||
health.total_frames,
|
health.total_frames,
|
||||||
health.dropped_frames,
|
health.dropped_frames,
|
||||||
@@ -350,7 +367,10 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
health.written_bytes
|
health.written_bytes
|
||||||
);
|
);
|
||||||
report_status(&mut status, tracker.sample(&health, now), &elapsed());
|
report_status(&mut status, tracker.sample(&health, now), &elapsed());
|
||||||
recorded |= status == RecordingStatus::Recording;
|
recorded |= matches!(
|
||||||
|
status,
|
||||||
|
RecordingStatus::Recording | RecordingStatus::Degraded { .. }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) {
|
if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) {
|
||||||
stopping = true;
|
stopping = true;
|
||||||
@@ -358,8 +378,11 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
recording.stop();
|
recording.stop();
|
||||||
}
|
}
|
||||||
match recording.events().recv_timeout(Duration::from_millis(100)) {
|
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::OutputStarted) => println!("t={} output_started", elapsed()),
|
||||||
Ok(RecorderEvent::SegmentStarted { path }) => {
|
Ok(RecorderEvent::SegmentStarted { path, .. }) => {
|
||||||
println!("t={} segment_started={path}", elapsed())
|
println!("t={} segment_started={path}", elapsed())
|
||||||
}
|
}
|
||||||
Ok(RecorderEvent::OutputStopped { code, last_error }) => {
|
Ok(RecorderEvent::OutputStopped { code, last_error }) => {
|
||||||
@@ -394,6 +417,9 @@ fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed
|
|||||||
if shown != current {
|
if shown != current {
|
||||||
*shown = current.clone();
|
*shown = current.clone();
|
||||||
match current {
|
match current {
|
||||||
|
RecordingStatus::Degraded { reason } => {
|
||||||
|
println!("t={elapsed} status=degraded reason={reason}")
|
||||||
|
}
|
||||||
RecordingStatus::Failed { reason } => {
|
RecordingStatus::Failed { reason } => {
|
||||||
println!("t={elapsed} status=failed reason={reason}")
|
println!("t={elapsed} status=failed reason={reason}")
|
||||||
}
|
}
|
||||||
@@ -466,6 +492,27 @@ fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// A clip spanning the boundaries between the first three segments.
|
||||||
let list = options.out.join("concat.txt");
|
let list = options.out.join("concat.txt");
|
||||||
let entries: String = segments[..3]
|
let entries: String = segments[..3]
|
||||||
@@ -516,6 +563,30 @@ fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
|||||||
Ok(ok)
|
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>> {
|
fn ffprobe(path: &Path, query: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let output = Command::new("ffprobe")
|
let output = Command::new("ffprobe")
|
||||||
.args(["-v", "error"])
|
.args(["-v", "error"])
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "rig-pointer"
|
||||||
|
description = "Development tool: drives a virtual pointer in the headless test rig."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wayland-client = "0.31"
|
||||||
|
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! Drives a virtual pointer on a wlroots compositor (the headless rig's Sway),
|
||||||
|
//! which has no input devices of its own. Reads one command per line on stdin:
|
||||||
|
//!
|
||||||
|
//! move <x> <y> move to output coordinates
|
||||||
|
//! click press and release the left button
|
||||||
|
//! down / up press or release the left button
|
||||||
|
//! scroll <dy> scroll vertically (negative scrolls up), in wheel steps
|
||||||
|
//! sleep <seconds>
|
||||||
|
//!
|
||||||
|
//! echo -e "move 100 200\nclick" | rig-pointer <output-width> <output-height>
|
||||||
|
|
||||||
|
use std::{io::BufRead, thread, time::Duration};
|
||||||
|
|
||||||
|
use wayland_client::{
|
||||||
|
Connection, Dispatch, QueueHandle,
|
||||||
|
globals::{GlobalListContents, registry_queue_init},
|
||||||
|
protocol::{wl_pointer, wl_registry, wl_seat::WlSeat},
|
||||||
|
};
|
||||||
|
use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||||
|
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||||
|
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUTTON_LEFT: u32 = 0x110;
|
||||||
|
|
||||||
|
struct State;
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &wl_registry::WlRegistry,
|
||||||
|
_: wl_registry::Event,
|
||||||
|
_: &GlobalListContents,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! ignore_events {
|
||||||
|
($($proxy:ty),*) => {$(
|
||||||
|
impl Dispatch<$proxy, ()> for State {
|
||||||
|
fn event(_: &mut Self, _: &$proxy, _: <$proxy as wayland_client::Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
ignore_events!(WlSeat, ZwlrVirtualPointerManagerV1, ZwlrVirtualPointerV1);
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args: Vec<u32> = std::env::args()
|
||||||
|
.skip(1)
|
||||||
|
.map(|arg| arg.parse())
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
let [width, height] = args[..] else {
|
||||||
|
return Err("usage: rig-pointer <output-width> <output-height> < commands".into());
|
||||||
|
};
|
||||||
|
let connection = Connection::connect_to_env()?;
|
||||||
|
let (globals, mut queue) = registry_queue_init::<State>(&connection)?;
|
||||||
|
let handle = queue.handle();
|
||||||
|
let seat: WlSeat = globals.bind(&handle, 1..=7, ())?;
|
||||||
|
let manager: ZwlrVirtualPointerManagerV1 = globals.bind(&handle, 1..=2, ())?;
|
||||||
|
let pointer = manager.create_virtual_pointer(Some(&seat), &handle, ());
|
||||||
|
queue.roundtrip(&mut State)?;
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let time = || start.elapsed().as_millis() as u32;
|
||||||
|
for line in std::io::stdin().lock().lines() {
|
||||||
|
let line = line?;
|
||||||
|
let words: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
let button = |state| {
|
||||||
|
pointer.button(time(), BUTTON_LEFT, state);
|
||||||
|
pointer.frame();
|
||||||
|
};
|
||||||
|
match words[..] {
|
||||||
|
["move", x, y] => {
|
||||||
|
pointer.motion_absolute(time(), x.parse()?, y.parse()?, width, height);
|
||||||
|
pointer.frame();
|
||||||
|
}
|
||||||
|
["down"] => button(wl_pointer::ButtonState::Pressed),
|
||||||
|
["up"] => button(wl_pointer::ButtonState::Released),
|
||||||
|
["click"] => {
|
||||||
|
button(wl_pointer::ButtonState::Pressed);
|
||||||
|
button(wl_pointer::ButtonState::Released);
|
||||||
|
}
|
||||||
|
["scroll", steps] => {
|
||||||
|
let steps: i32 = steps.parse()?;
|
||||||
|
pointer.axis_source(wl_pointer::AxisSource::Wheel);
|
||||||
|
pointer.axis_discrete(
|
||||||
|
time(),
|
||||||
|
wl_pointer::Axis::VerticalScroll,
|
||||||
|
15. * steps as f64,
|
||||||
|
steps,
|
||||||
|
);
|
||||||
|
pointer.frame();
|
||||||
|
}
|
||||||
|
["sleep", seconds] => thread::sleep(Duration::from_secs_f64(seconds.parse()?)),
|
||||||
|
[] => continue,
|
||||||
|
_ => return Err(format!("unknown command: {line}").into()),
|
||||||
|
}
|
||||||
|
queue.roundtrip(&mut State)?;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -8,8 +8,9 @@ pass/fail results.
|
|||||||
|
|
||||||
| Where | Script | Status |
|
| Where | Script | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) |
|
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 12 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 |
|
||||||
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Not run yet |
|
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) |
|
||||||
|
| Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen and active Quit |
|
||||||
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
||||||
|
|
||||||
## Probe commands
|
## Probe commands
|
||||||
@@ -45,7 +46,11 @@ The suite covers:
|
|||||||
3. X11 window capture (Xcomposite).
|
3. X11 window capture (Xcomposite).
|
||||||
4. Failures that must end in `status=failed`: PipeWire stream lost, captured
|
4. Failures that must end in `status=failed`: PipeWire stream lost, captured
|
||||||
X11 window closed, segment directory unwritable, free space below the margin.
|
X11 window closed, segment directory unwritable, free space below the margin.
|
||||||
5. A portal that grants no usable source never reports `Recording`.
|
5. A portal that refuses the request fails with an actionable reason and
|
||||||
|
never reports `Recording`.
|
||||||
|
|
||||||
|
Segment checks run with `--expect-motion`, so black or frozen footage (which
|
||||||
|
still makes valid segments) fails.
|
||||||
|
|
||||||
## Linux hardware sign-off
|
## Linux hardware sign-off
|
||||||
|
|
||||||
@@ -55,6 +60,49 @@ shows the portal picker; later runs pass the restore token and ask whether the
|
|||||||
picker stayed away), records a window, and asks you to close a window
|
picker stayed away), records a window, and asks you to close a window
|
||||||
mid-recording. Send back `report.txt` from the output directory.
|
mid-recording. Send back `report.txt` from the output directory.
|
||||||
|
|
||||||
|
### First hardware run (2026-10-08)
|
||||||
|
|
||||||
|
CachyOS 7.2.9, Hyprland, RX 9070 XT, 1440p60, OBS 32.0.4:
|
||||||
|
|
||||||
|
- H.264, HEVC and AV1 all encode through VA-API (`*_ffmpeg_vaapi_tex`).
|
||||||
|
Monitor and window capture recorded keyframe-aligned 2 s segments that
|
||||||
|
remuxed across boundaries with no decode errors.
|
||||||
|
- The restore token came back on one of three runs, probably because the
|
||||||
|
"Allow a restore token" box in the picker was ticked only once. The script
|
||||||
|
now has a dedicated restore step.
|
||||||
|
- One run never got a portal selection: the picker either did not appear or
|
||||||
|
was not answered, and the probe stayed in `Waiting for capture` for 20 s
|
||||||
|
while writing black segments that `check` passed. `check --expect-motion`
|
||||||
|
now catches black footage; a refused request now fails at once.
|
||||||
|
- Closing the captured window (Super+W) is not visible to capture: xdph keeps
|
||||||
|
the PipeWire stream `streaming` and OBS repeats the window's last frame.
|
||||||
|
See findings.
|
||||||
|
|
||||||
|
### Second hardware run (2026-10-08)
|
||||||
|
|
||||||
|
- The restore token works: the restored run started recording in 2 s without
|
||||||
|
showing the picker.
|
||||||
|
- Hyprland's portal backend (xdg-desktop-portal-hyprland) wedged in
|
||||||
|
`SelectSources` during this run and answered nothing afterwards, until
|
||||||
|
restarted; the earlier "no selection" runs were likely the same hang.
|
||||||
|
xdph shows its picker during `SelectSources`, so a hang there looks the same
|
||||||
|
as a user still choosing. A 10 s timeout tried in afc54b3 would cut users
|
||||||
|
off mid-pick and was removed. Recording stays honestly in `Waiting for
|
||||||
|
capture`; the app needs a prompt to choose a source (or restart the portal)
|
||||||
|
while it waits.
|
||||||
|
- The motion check sampled a segment from before capture started (the user
|
||||||
|
took 16 s in the picker); it now uses the segment with the most motion.
|
||||||
|
- A portrait monitor (Hyprland transform 3) is captured as 1440x2560 and
|
||||||
|
pillarboxed in the 2560x1440 canvas.
|
||||||
|
|
||||||
|
### Fourth hardware run: pass (2026-10-08, dc9cbd6)
|
||||||
|
|
||||||
|
After restarting xdg-desktop-portal-hyprland, every step passed: H.264, HEVC
|
||||||
|
and AV1 through VA-API at 1440p60, monitor and window capture with motion,
|
||||||
|
keyframe-aligned segments that remux across boundaries, and a restored
|
||||||
|
selection that skipped the picker. Closing a window was again not detected
|
||||||
|
(informational, see findings).
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
Run `scripts/validate/windows.ps1 -GameWindow "<title>:<class>:<exe>"` with a
|
Run `scripts/validate/windows.ps1 -GameWindow "<title>:<class>:<exe>"` with a
|
||||||
@@ -72,6 +120,8 @@ Windows status:
|
|||||||
- The assembled runtime keeps the six Game Capture hook binaries byte-identical
|
- The assembled runtime keeps the six Game Capture hook binaries byte-identical
|
||||||
to the release, with their Authenticode signatures present; signature
|
to the release, with their Authenticode signatures present; signature
|
||||||
validity has not been checked on Windows.
|
validity has not been checked on Windows.
|
||||||
|
- The native Steam process bridge (`clipbin-core/src/windows_process.rs`) uses Toolhelp enumeration, limited process queries and a dedicated thread waiting on the process handle. Registry discovery uses `RunningAppID` and `Apps\<id>\Running`; executable paths are matched beneath the app manifest’s installation directory. This code is cross-checked but unvalidated on Windows, including launcher/child-process identity, handle access permissions and exit ordering.
|
||||||
|
- Unvalidated: atomic metadata replacement and recovery on Windows, settings/data directory paths, desktop close-window survival and clean Quit. GPUI desktop integration cannot be cross-checked from Linux.
|
||||||
- Unvalidated: encoder ids and settings for NVENC, AMF and QSV; Game Capture
|
- Unvalidated: encoder ids and settings for NVENC, AMF and QSV; Game Capture
|
||||||
hooking and its loss detection (the engine expects the source size to drop
|
hooking and its loss detection (the engine expects the source size to drop
|
||||||
to 0); WASAPI audio; `obs-ffmpeg-mux.exe` beside the executable; linking
|
to 0); WASAPI audio; `obs-ffmpeg-mux.exe` beside the executable; linking
|
||||||
@@ -102,8 +152,34 @@ Windows status:
|
|||||||
its last frame after the stream stops, and only logs the stream state. The
|
its last frame after the stream stops, and only logs the stream state. The
|
||||||
engine reads that log line (stable for the pinned OBS version). A more robust
|
engine reads that log line (stable for the pinned OBS version). A more robust
|
||||||
follow-up is watching the PipeWire node state directly.
|
follow-up is watching the PipeWire node state directly.
|
||||||
- **Audio capture failures are not detected yet.** When the PulseAudio server
|
- **Ending a session when the game closes.** With Hyprland's portal, closing a
|
||||||
was unreachable, recording continued with a silent track.
|
captured window leaves the stream running on its last frame, with no state
|
||||||
|
change or error, so capture cannot tell it from a static window. Monitor
|
||||||
|
capture never sees the game close at all. The session therefore ends from
|
||||||
|
the game's process exiting (SPEC: "Stop/finalise the session when its game
|
||||||
|
exits"); capture loss stays a failure signal, not the normal end:
|
||||||
|
- Linux: Steam runs each game under `reaper SteamLaunch AppId=<id>`, which
|
||||||
|
lives exactly as long as the game (Proton included). Watch that process
|
||||||
|
with a pidfd (`pidfd_open` + poll) rather than polling `/proc`.
|
||||||
|
- Windows: watch the game's process handle (`OpenProcess` +
|
||||||
|
`WaitForSingleObject`), found from Steam's `RunningAppID` registry value.
|
||||||
|
Game Capture reports size 0 when its game closes; the exit must be seen
|
||||||
|
first so a normal exit finalises the session instead of failing it.
|
||||||
|
- Hyprland's IPC does announce window closes, but it is compositor-specific
|
||||||
|
and cannot map a portal stream to a window, so it is not used.
|
||||||
|
- **Nothing is written before capture works.** Encoders and the segment
|
||||||
|
output are created only once the source has a picture, so waiting in the
|
||||||
|
picker or for a stalled portal writes no footage.
|
||||||
|
- **Recording size.** By default each recording is the captured window's or
|
||||||
|
monitor's own size, set when capture first has a picture (portrait monitors
|
||||||
|
record portrait). If the window is resized mid-session the size stays and
|
||||||
|
the picture is scaled to fit.
|
||||||
|
- **Audio delivery health.** The configured system/microphone capture sources
|
||||||
|
are monitored through OBS audio capture callbacks on Linux (five seconds
|
||||||
|
without delivery) and source-scoped WASAPI device errors on Windows. Either
|
||||||
|
degrades recording with “audio capture lost”; video continues. The silent
|
||||||
|
muxer track cannot mask capture failure. See
|
||||||
|
Background operation below for validation and limits.
|
||||||
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
||||||
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
|
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
|
||||||
was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open
|
was built on. Packaging (for example Flatpak, or bundling FFmpeg) is an open
|
||||||
@@ -115,3 +191,548 @@ Windows status:
|
|||||||
- **Plugin configuration.** libobs is started without a module config path;
|
- **Plugin configuration.** libobs is started without a module config path;
|
||||||
set one under the app's config directory before plugins that save settings
|
set one under the app's config directory before plugins that save settings
|
||||||
are added.
|
are added.
|
||||||
|
|
||||||
|
## Automatic desktop sessions
|
||||||
|
|
||||||
|
`scripts/validate/linux-session.sh [output-dir]` runs the desktop and a fake
|
||||||
|
Steam reaper (`argv[0]=reaper`, `SteamLaunch`, `AppId=480`) inside the headless
|
||||||
|
Wayland rig. It verifies Recording status, non-empty segment files and metadata,
|
||||||
|
closes the only window while recording continues, kills the game process,
|
||||||
|
checks finalisation, reopens the app to load its library from disk, captures
|
||||||
|
screenshots and uses Ctrl+Q during a second recording for clean shutdown.
|
||||||
|
The script checks required tools before starting and distinguishes pending
|
||||||
|
status from failed checks. The recovery run also passed with `PATH=/usr/bin:/bin`
|
||||||
|
without ripgrep installed (2026-10-08); scripts use grep instead.
|
||||||
|
The recording and library screenshots were inspected; storage totals came from
|
||||||
|
real Matroska file sizes. The existing Linux rig also passed all nine scenarios
|
||||||
|
after integration (2026-10-08). Its unwritable-output assertion accepts both
|
||||||
|
OBS Error and EncodeError: the muxer can report either for a permission error.
|
||||||
|
|
||||||
|
Session metadata is `session.json` inside each data-directory session folder;
|
||||||
|
recovery attempts use separate `run-NNN` folders. Each segment stores its exact
|
||||||
|
start using `(total_frames_at_file_changed - 1) / fps`. The keyframe triggering
|
||||||
|
the split is already included in OBS’s count but belongs to the new segment:
|
||||||
|
the rig observed 121, 241, 361 at the boundaries of 120-frame segments. The
|
||||||
|
initial file starts at zero. Raw callback counts are preserved in events; the
|
||||||
|
e2e script checks placements exactly against cumulative earlier ffprobe video
|
||||||
|
packet counts within each run. Dropped output frames could make this count
|
||||||
|
drift: each segment records the run’s cumulative dropped-frame count at its
|
||||||
|
completion, and a nonzero count logs an explicit warning. Placement with
|
||||||
|
actual dropped frames still needs validation.
|
||||||
|
|
||||||
|
Stopping exactly as a split fires can leave a header-only final Matroska file
|
||||||
|
(the rig observed 747 bytes, no Cluster and no video packets), despite a counted
|
||||||
|
split keyframe and zero reported drops. Finalisation checks the small header
|
||||||
|
prefix for the Cluster ID and deletes an empty tail after output stop. If deletion
|
||||||
|
fails (including a still-open file on Windows), it is marked unavailable in
|
||||||
|
metadata. Footage ends at the last real segment’s end and the remainder is
|
||||||
|
Missing. The offset check verifies every retained segment and explicitly
|
||||||
|
checks any unavailable marker, without treating an empty file as playable. Wall-clock time places each run inside the session and
|
||||||
|
marks capture waiting/recovery as Missing spans. Containers are never summed
|
||||||
|
to derive segment offsets. Metadata is replaced atomically from a synced
|
||||||
|
same-directory temporary file; startup finalises previously active metadata.
|
||||||
|
|
||||||
|
Run `CLIPBIN_VALIDATE_RECOVERY=1 scripts/validate/linux-session.sh` to also
|
||||||
|
kill the portal capture stream and check that bounded automatic recovery
|
||||||
|
resumes into the same session, with a Missing span and a second run directory.
|
||||||
|
This passed in the headless Wayland rig. A deliberately missing runtime path
|
||||||
|
also opened the interface with a persistent failure banner and honoured Quit.
|
||||||
|
|
||||||
|
Settings are saved under the config directory, including the portal restore
|
||||||
|
token. Capture selection clears that token and restarts the current session’s
|
||||||
|
capture; automatic recovery retries at five-second intervals, at most three
|
||||||
|
consecutive attempts. A failure stays visible until footage is proven again.
|
||||||
|
|
||||||
|
One game is recorded at a time. Startup discovery also records a game already
|
||||||
|
running; simultaneous games are selected by PID order and the next still-live
|
||||||
|
game is detected after the current session ends. Linux Steam names come from
|
||||||
|
library manifests, with `App <id>` as the fallback. Windows requires a manifest
|
||||||
|
installation path to identify candidate processes and fails honestly if none
|
||||||
|
can be found. X11 desktop session integration and Windows runtime validation
|
||||||
|
remain follow-ups.
|
||||||
|
|
||||||
|
Real Steam validated 2026-10-08 on CachyOS/Hyprland (native Steam): Escape
|
||||||
|
from Duckov (AppId 3167020, Proton Experimental under SteamLinuxRuntime_4)
|
||||||
|
runs under `reaper SteamLaunch AppId=3167020 -- …/_v2-entry-point … proton
|
||||||
|
waitforexitandrun …/Duckov.exe`. Clipbin detected it, named it from its
|
||||||
|
manifest ("Escape from Duckov") and recorded the session. Audio delivery loss
|
||||||
|
is now detected as described below; the new warning still needs real hardware
|
||||||
|
and Windows validation.
|
||||||
|
|
||||||
|
GPUI 0.2.2 normally ends its Linux and Windows event loops when the last
|
||||||
|
window is removed. Clipbin now keeps one Application and native loop alive
|
||||||
|
until explicit Quit, with a small vendored backend policy patch (see
|
||||||
|
[vendor/gpui/CLIPBIN.md](../vendor/gpui/CLIPBIN.md)). The visible window is
|
||||||
|
removed on close and recreated on Open, restoring its previous route and
|
||||||
|
reloading the library/settings; unavailable sessions fall back to the library.
|
||||||
|
Playback resources are released while closed. No invisible GPUI window is
|
||||||
|
needed. Bounds, scroll offsets and unsaved editor selection are not restored.
|
||||||
|
Quit waits for the existing worker's clean session finish before joining/exiting.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Clips, bookmarks and storage cleanup
|
||||||
|
|
||||||
|
`clipbin-media::export` copies video and selected AAC tracks to MP4 without
|
||||||
|
re-encoding. OBS already writes system, microphone and combined tracks when the
|
||||||
|
mic is enabled: exports default to the combined track, with a Settings option
|
||||||
|
for the two separate tracks. A session without a mic exports its single track.
|
||||||
|
The output is closed and synced under a `.partial.mp4` name, then renamed;
|
||||||
|
only the completed MP4 and saved JSON appear in the Clips library.
|
||||||
|
|
||||||
|
Video timestamps use integer frame clocks, retaining presentation/decode
|
||||||
|
ordering for B-frames. Each segment also persists cumulative AAC sample starts
|
||||||
|
per stream (`audio_start_samples`, with `#[serde(default)]`). Audio uses its own
|
||||||
|
1024-sample AAC packet clock across adjacent pieces, avoiding independently reset/rounded Matroska timestamps.
|
||||||
|
Old session metadata defaults these audio counters to empty. Before cleanup,
|
||||||
|
the worker reconstructs them from retained finalised files; export can also
|
||||||
|
reconstruct an older range while its preceding files still exist. If the earlier
|
||||||
|
files are already unavailable and no exact clock was saved, export fails
|
||||||
|
honestly rather than guessing the A/V phase.
|
||||||
|
Keyframes are anchored to the capture run, including its wall-clock offset.
|
||||||
|
Outer unavailable footage is trimmed from completed ranges. Internal Missing
|
||||||
|
and Removed spans retain a timestamp jump; players may hold the preceding
|
||||||
|
picture or skip to the next picture, but the gap is not collapsed. Export
|
||||||
|
ranges spanning codec, size or frame-rate changes fail honestly. Dropped-frame
|
||||||
|
placement remains unvalidated, so these ranges fail rather than claiming exact
|
||||||
|
CFR output.
|
||||||
|
|
||||||
|
One bounded export queue runs on a separate thread. Requests retain the action
|
||||||
|
receipt timestamp while the current segment finalises; cleanup leases are
|
||||||
|
persisted in `clips/export-requests.json`. Failed exports retain their leases
|
||||||
|
until retry succeeds or the user releases them from the Clips screen. A crash
|
||||||
|
between MP4 publication and JSON persistence can leave an unlisted complete MP4;
|
||||||
|
Open folder lets the user recover it. Partial files are never listed as clips.
|
||||||
|
|
||||||
|
Cleanup checks the byte budget every three seconds and after session end. Its
|
||||||
|
pure selector orders completed sessions first, then active footage, oldest
|
||||||
|
first. It excludes bookmarks, editor selection and queued/running/failed export
|
||||||
|
ranges, and never selects the current file. It persists Removed spans before
|
||||||
|
deleting a segment; a deletion failure restores availability and surfaces an
|
||||||
|
error. Bookmarks persist in session metadata, protect future post-roll, and can
|
||||||
|
be released in the editor. Clips remain outside this budget and can be deleted
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
Global bindings default to Ctrl+Shift+F9 (clip) and Ctrl+Shift+F10 (bookmark).
|
||||||
|
Settings independently offer F11/F12 alternatives; configuration files accept
|
||||||
|
F1–F12 (distinct bindings). Wayland uses the GlobalShortcuts portal and displays
|
||||||
|
its returned binding descriptions; the desktop handles approval and rebinding.
|
||||||
|
A compositor without that portal reports unavailability, with sidebar buttons
|
||||||
|
as the fallback. X11 uses XGrabKey with Caps/Num lock variants and detectable
|
||||||
|
auto-repeat; Windows uses RegisterHotKey with MOD_NOREPEAT on a message thread.
|
||||||
|
Action timestamps are taken when the native event is received and retained
|
||||||
|
while the worker queues the export. Real Hyprland first-use approval, portal
|
||||||
|
restoration/rebinding and shortcuts while a game has focus remain unvalidated.
|
||||||
|
Native X11 desktop behaviour and Windows registration/conflicts also remain
|
||||||
|
unvalidated.
|
||||||
|
|
||||||
|
New checks:
|
||||||
|
|
||||||
|
- `scripts/validate/linux-clips.sh`: fake Steam session, actual sidebar button
|
||||||
|
clicks, cross-segment MP4, codec preservation, zero-error decode, CFR packet
|
||||||
|
PTS spacing, continuous audio, original short-clip range, capture continuing
|
||||||
|
during export, a persisted bookmark, forced one-byte cleanup with neighbouring
|
||||||
|
Removed spans, a new export of the bookmark after earlier files are deleted,
|
||||||
|
and a Clips screenshot. Uses only the standard PATH tools,
|
||||||
|
`rig-pointer` and `wtype`; no ripgrep dependency.
|
||||||
|
- `scripts/validate/check-clip.py <saved-clip.json>`: reusable packet/decode check
|
||||||
|
before source cleanup, including selected audio track count, cumulative source
|
||||||
|
audio clocks and the original per-track A/V phase to within one audio sample.
|
||||||
|
- `scripts/validate/linux-hotkeys.sh`: actual clip/bookmark X11 key grabs under
|
||||||
|
Xwayland (`xdotool` injects native X11 keys). The wlr rig has no GlobalShortcuts
|
||||||
|
portal, so it cannot validate the Wayland shortcut route.
|
||||||
|
|
||||||
|
Windows media build setup: use an FFmpeg **7.1 shared development** package
|
||||||
|
(headers and MSVC import libraries) matching OBS's bundled avcodec-61 DLLs.
|
||||||
|
Set `FFMPEG_DIR` to that package when building `clipbin-media`; distribute the
|
||||||
|
matching OBS-bundled FFmpeg DLLs. FFmpeg development/import files for MSVC are
|
||||||
|
not available in this Linux environment, so `clipbin-media` is excluded from
|
||||||
|
the Windows cross-lint. `clipbin-core` (including the hotkey bridge),
|
||||||
|
`clipbin-obs` and `clipbin-probe` are cross-linted. Windows export/remux, the
|
||||||
|
default player/folder actions, clip deletion, pending-export leases, bookmark
|
||||||
|
release, cleanup of recently closed files and every new GPUI control still
|
||||||
|
need linking/running on Windows.
|
||||||
|
|
||||||
|
Step 2 validation on the Intel UHD 770 Linux rig (2026-10-08): workspace format,
|
||||||
|
clippy with `-D warnings`, and all 49 unit tests passed. Windows cross-clippy
|
||||||
|
passed for core, OBS and probe with the exclusions above. The end-to-end clips
|
||||||
|
script passed for H.264 with one audio track and with separate system/mic
|
||||||
|
tracks, and for HEVC with the combined track. The HEVC run requested the default
|
||||||
|
120 seconds from a shorter session and correctly saved its available 14 seconds.
|
||||||
|
All checked exports retained their codec, decoded without errors, had exact
|
||||||
|
1/60-second video spacing and continuous audio, and retained their source A/V
|
||||||
|
phase. Forced one-byte cleanup removed neighbouring footage while bookmarks
|
||||||
|
and exported MP4s survived; a retained bookmark could still export after its
|
||||||
|
preceding files had been deleted. Clips screenshots are produced as `clips.png`
|
||||||
|
in each script's output directory.
|
||||||
|
|
||||||
|
To repeat the extra audio/codec cases, set `CLIPBIN_VALIDATE_MIC=1`, optionally
|
||||||
|
`CLIPBIN_VALIDATE_SEPARATE_AUDIO=1`, `CLIPBIN_VALIDATE_CODEC=Hevc`, or
|
||||||
|
`CLIPBIN_VALIDATE_CLIP_SECONDS=120` before `linux-clips.sh`. The original nine
|
||||||
|
capture scenarios and automatic-session validation also passed using
|
||||||
|
`env PATH=/usr/bin:/bin:$HOME/.cargo/bin`. Native X11 hotkey registration and
|
||||||
|
both actions passed under Xwayland. These fake Steam tests run sequentially:
|
||||||
|
game discovery is machine-wide, although rig teardown only kills its own PIDs.
|
||||||
|
AV1 export is unvalidated because this GPU has no usable AV1 encoder. B-frame
|
||||||
|
export and ranges containing an internal timestamp gap need additional media
|
||||||
|
fixtures; dropped-frame ranges are explicitly rejected. Linux default-player
|
||||||
|
and file-manager associations still need testing on a real desktop.
|
||||||
|
|
||||||
|
## Playback and thumbnails
|
||||||
|
|
||||||
|
Playback uses the shared `clipbin-media` crate (`ffmpeg-next` 9) without GPUI.
|
||||||
|
One background decoder opens only completed, available segments. Session time
|
||||||
|
comes from `Metadata::locate`; each file's timestamps are normalised independently.
|
||||||
|
Seeking goes backwards to a keyframe, then decodes forward to the selected frame.
|
||||||
|
A one-frame mailbox and coalesced seeks bound work; overdue frames are decoded
|
||||||
|
but discarded before swscale. Playback threads have lower scheduling priority
|
||||||
|
than recording. Leaving the editor closes its decoder and audio stream.
|
||||||
|
|
||||||
|
The editor uploads packed BGRA at its actual pixel size, maintains a 16:9
|
||||||
|
preview, and releases old GPUI images. Space/K toggle playback; arrows/J/L step
|
||||||
|
one frame. The timeline seeks and drags across files and gaps; completed footage
|
||||||
|
from active sessions becomes browsable on the next 250 ms metadata refresh
|
||||||
|
within the recorder's segment completion delay. Missing/removed footage advances
|
||||||
|
on the wall clock and shows “No footage (not captured / cleaned up)”. There is
|
||||||
|
no live recording preview.
|
||||||
|
|
||||||
|
Audio uses cpal and swresample at the output device's rate/channel count, with
|
||||||
|
sample conversion in the device callback. OBS's combined third audio track is
|
||||||
|
selected when three tracks exist; otherwise the first track is used. The queue
|
||||||
|
holds at most 250 ms plus one decoded block. Callback timestamps, including DAC
|
||||||
|
latency, drive the master clock; gaps and mute use the wall clock. Callbacks do
|
||||||
|
no decoding and never wait for the queue lock. Mute and volume are in the editor;
|
||||||
|
an unavailable device is shown explicitly while video remains usable. The Linux
|
||||||
|
rig uses its existing null sink and ALSA's PipeWire plugin as a fallback if cpal's
|
||||||
|
native PulseAudio backend refuses the device. Install/configure `pipewire-alsa`
|
||||||
|
or an ALSA default routed to PulseAudio on systems without a physical ALSA card.
|
||||||
|
|
||||||
|
One low-priority worker produces 320×180 PNG thumbnails beside the first playable
|
||||||
|
session segment and exported MP4s. Its request queue holds 16 paths and duplicate
|
||||||
|
requests coalesce; card discovery and all filesystem work run off the UI thread.
|
||||||
|
`clipbin_media::thumbnail::{generate,cache_path,Thumbnails}` accept any MP4/MKV.
|
||||||
|
The gradient remains while a thumbnail is pending or footage is unavailable.
|
||||||
|
|
||||||
|
`scripts/validate/playback-fixture.sh` generates independent 1080p60 H.264/AAC
|
||||||
|
segments with one-second keyframes and a three-tone audio fixture. It passed on
|
||||||
|
2026-10-08: exact frame pixels at frame indices 0/1/59/60/61/119 in every file,
|
||||||
|
real-time boundary crossing, gaps, superseded seeks, pause/seek clock maths,
|
||||||
|
bounded frame placement, resampling 48 kHz to 44.1 kHz and combined-track selection.
|
||||||
|
The ordinary workspace tests skip the generated-media assertions unless
|
||||||
|
`CLIPBIN_TEST_MEDIA_DIR` points to the fixture; use the script to exercise them.
|
||||||
|
|
||||||
|
Windows: the cpal WASAPI dependency cross-check passed, as did the dedicated
|
||||||
|
Windows thread-priority bridge. Core/OBS/probe strict MSVC cross-lint passed.
|
||||||
|
The full media cross-lint is excluded on this Linux machine because the FFmpeg
|
||||||
|
Windows development import libraries are unavailable. 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/swresample-5 DLLs. Linux
|
||||||
|
links system FFmpeg 8. Actual Windows decoding, WASAPI device latency/seek behaviour,
|
||||||
|
GPUI image presentation and thumbnail caching need a Windows machine. Hardware
|
||||||
|
video decode is not used; HEVC/AV1 software performance and real output devices
|
||||||
|
other than the rig null sink still need measurement.
|
||||||
|
|
||||||
|
Final playback validation passed on 2026-10-08, serially with other rigs stopped:
|
||||||
|
|
||||||
|
- `cargo fmt --all -- --check`, strict locked workspace clippy, and locked
|
||||||
|
workspace tests (with `CLIPBIN_TEST_MEDIA_DIR=target/playback-fixture`).
|
||||||
|
- `env PATH=/usr/bin:/bin:$HOME/.cargo/bin scripts/validate/linux-rig.sh`:
|
||||||
|
all nine scenarios. An earlier concurrent run lost its H.264 probe to SIGKILL;
|
||||||
|
the serial rerun passed without timeout or sandbox changes.
|
||||||
|
- The same PATH with `scripts/validate/linux-session.sh`: automatic recording,
|
||||||
|
exact segment placement, close-window survival, game exit, library reload and
|
||||||
|
clean Quit. Repeated after the final keyboard integration, also passed.
|
||||||
|
- `scripts/validate/linux-playback.sh target/playback-e2e-final-6`: moving preview
|
||||||
|
(11.0% changed pixels inside the preview), active recording with zero reported
|
||||||
|
recording drops, completed-segment availability, real session thumbnail,
|
||||||
|
cross-file drag, exact post-boundary frame/time, cleaned-up gap, zoom, arrow
|
||||||
|
stepping, device audio, half-volume and mute. The audio monitor RMS was 0.08044;
|
||||||
|
the player asserts video time stays within 20 ms of the audio master clock.
|
||||||
|
Screenshots were inspected: `sessions-thumbnails.png`, `editor-playing-1.png`,
|
||||||
|
`editor-playing-2.png`, `editor-seek.png`, and `editor-gap.png` in that directory.
|
||||||
|
|
||||||
|
The rig runs on an Intel i5-12500 (12 logical CPUs). These are development builds
|
||||||
|
with dependencies at opt-level 2, software video decoding, 1080p60 H.264 source
|
||||||
|
and an 800×450 target (the editor used 740×416). Five serial repetitions of
|
||||||
|
`target/debug/examples/playback-check <sessions-root> <id>` with
|
||||||
|
`scripts/validate/measure-cpu.py`, inside the null-sink rig, measured process CPU
|
||||||
|
at **16.7% median / 17.7% p95 of one core** (12.2–17.7% range). The filesystem cache
|
||||||
|
was warm; each repetition reopened its decoders. Across presented frames,
|
||||||
|
decode/scale time was **2.39 ms median / 8.80 ms p95**; maximum **101.5 ms** included
|
||||||
|
opening files and decoding forward on seeks. This is a test pattern with gaps,
|
||||||
|
not a worst-case game or a release-build benchmark. Per-run logs and CPU reports
|
||||||
|
are in `target/playback-timing-final`; `report.json` stores the distributions.
|
||||||
|
|
||||||
|
During the active-session e2e, the player presented **59.2 frames/s** while
|
||||||
|
recording remained at zero reported drops. GPUI upload/construction time was
|
||||||
|
**0.22 ms median / 0.48 ms p95**. UI frame intervals (including vsync, not CPU work)
|
||||||
|
were 16.2 ms median / 32.5 ms p95; the one-frame mailbox drops presentation work
|
||||||
|
rather than allowing delay to accumulate. `CLIPBIN_TRACE_FRAMES=1` logs decoded
|
||||||
|
video time, audio time, decode/scale work, uploads and actual timeline bounds.
|
||||||
|
The headless e2e restores keyboard focus after virtual-pointer commands and
|
||||||
|
holds virtual keys briefly: instantaneous virtual input can disappear before
|
||||||
|
GPUI/Wayland dispatch. Keyboard actions update the editor entity directly,
|
||||||
|
avoiding a nested window borrow during event dispatch.
|
||||||
|
|
||||||
|
After merging clips and playback at `dd016f7`, the clips rig intermittently
|
||||||
|
reported “desktop did not honour Quit”. A fresh-window probe on 2026-10-08
|
||||||
|
missed 3 of 8 instantaneous `wtype` Ctrl+Q presses; two missed presses occurred
|
||||||
|
while Clipbin was focused. Temporary action/GUI-exit/worker-join markers showed
|
||||||
|
that the missed presses never reached the Quit handler. All 8 held-key attempts
|
||||||
|
reached the handler and completed shutdown. The temporary markers were removed;
|
||||||
|
no application shutdown change was needed.
|
||||||
|
|
||||||
|
Both scripts now source `scripts/rig/keys.sh`: it restores keyboard focus after
|
||||||
|
virtual-pointer input, gives the virtual keymap 200 ms to settle, and holds each
|
||||||
|
key for 100 ms. Modified keys also give the modifier 100 ms to settle. Delivery
|
||||||
|
has a 10-second timeout; each script still requires the actual desktop process
|
||||||
|
to exit successfully. Serial reruns passed with
|
||||||
|
`env PATH=/usr/bin:/bin:$HOME/.cargo/bin`:
|
||||||
|
|
||||||
|
- `scripts/validate/linux-clips.sh /tmp/clipbin-clips-quit-fixed`: H.264 retained,
|
||||||
|
360 CFR frames over a six-second aligned clip, continuous audio, zero decode
|
||||||
|
errors, original press range, bookmark protection, tiny-budget cleanup,
|
||||||
|
bookmark re-export after adjacent removal, and both Quit exits.
|
||||||
|
- `scripts/validate/linux-playback.sh /tmp/clipbin-playback-quit-fixed`: moving
|
||||||
|
preview (4.1% changed pixels), device audio RMS 0.08045, thumbnails, active
|
||||||
|
capture, seek/gap/boundary behaviour, volume/mute, frame stepping and both Quit
|
||||||
|
exits. Decode median/p95 were 2.578/4.073 ms; GPUI upload median/p95 were
|
||||||
|
0.241/0.408 ms.
|
||||||
|
|
||||||
|
Logs are `/tmp/clipbin-clips-quit-fixed.log` and
|
||||||
|
`/tmp/clipbin-playback-quit-fixed.log`; screenshots are in their corresponding
|
||||||
|
output directories. `bash -n`, `git diff --check` and `cargo fmt --check` also
|
||||||
|
passed. This follow-up changes only the Linux rig scripts and documentation;
|
||||||
|
Windows runtime and real Hyprland validation limits above remain unchanged.
|
||||||
|
|
||||||
|
## Background operation (2026-10-13)
|
||||||
|
|
||||||
|
Linux uses [ksni 0.3.6](https://github.com/iovxw/ksni) with the blocking,
|
||||||
|
async-io API. It implements SNI and com.canonical.dbusmenu on its own executor,
|
||||||
|
works on Wayland and X11, and requires no GTK main loop. Using tray-icon on
|
||||||
|
Linux would require GTK alongside GPUI's main-thread loop, so it is only a
|
||||||
|
Windows dependency. Windows uses [tray-icon 0.26.1](https://github.com/tauri-apps/tray-icon)
|
||||||
|
with its menu and notification-area icon on a dedicated Win32 message thread;
|
||||||
|
its documented Windows event loop does not need the main thread. GPUI owns the
|
||||||
|
main thread on both platforms. Platform services live in clipbin-core and are
|
||||||
|
included in Windows cross-clippy. The new raw Win32 calls are confined to
|
||||||
|
windows_background.rs, with a module unsafe allowance, SAFETY comments and
|
||||||
|
crate-level unsafe_code=deny; desktop retains the workspace forbid policy.
|
||||||
|
|
||||||
|
The tray has purple idle, red recording and amber failed/degraded variants,
|
||||||
|
with status, game and error detail in its tooltip. Open Clipbin, Save clip,
|
||||||
|
Add bookmark and Quit use the recorder's existing actions. Linux requires an
|
||||||
|
SNI watcher with a registered host: startup failure opens the interface even
|
||||||
|
with --background, logs the reason and makes close finish/quit. A watcher that
|
||||||
|
disappears later requests Open and disables close-to-tray until it returns.
|
||||||
|
This avoids leaving an unreachable process after a bar/watcher disappears.
|
||||||
|
|
||||||
|
Linux owns dev.clipbin.Clipbin on the session bus and exposes Open at
|
||||||
|
/dev/clipbin/Clipbin. A second process requests the same name without queueing,
|
||||||
|
calls Open on the owner and exits before starting OBS or reconciling autostart.
|
||||||
|
A session bus is required; endpoint acquisition errors are logged and startup
|
||||||
|
exits rather than starting a duplicate recorder. Windows holds a per-user
|
||||||
|
Local named mutex and posts a private message to the first instance's hidden
|
||||||
|
endpoint window, with a bounded five-second startup race wait. The hidden
|
||||||
|
endpoint owns its message thread independently of the GPUI windows.
|
||||||
|
|
||||||
|
Start on login reconciles on startup and each settings save. Linux writes or
|
||||||
|
removes $XDG_CONFIG_HOME/autostart/clipbin.desktop (default ~/.config/autostart),
|
||||||
|
with a quoted executable path and --background; Windows writes/removes the
|
||||||
|
Clipbin value in HKCU\Software\Microsoft\Windows\CurrentVersion\Run. Changing
|
||||||
|
login settings does not interrupt capture. The executable must stay at its
|
||||||
|
installed path; development builds point the entry at the build executable.
|
||||||
|
Actual desktop login dispatch remains unvalidated.
|
||||||
|
|
||||||
|
Audio health uses [OBS 32.0.4's source capture callback API](https://github.com/obsproject/obs-studio/blob/32.0.4/libobs/obs-source.c)
|
||||||
|
on every configured system/microphone source, independently of the encoder,
|
||||||
|
mixed audio counters and silent output track. A boxed callback context stores
|
||||||
|
an atomic monotonic delivery timestamp; it performs no allocation or locking.
|
||||||
|
The context is removed before source release: OBS's audio_cb_mutex serialises
|
||||||
|
callback removal with callbacks. On Linux, five seconds without delivery
|
||||||
|
(including a source that never starts) is a warning-level RecordingStatus::Degraded,
|
||||||
|
with “audio capture lost” in the sidebar and tray. A new callback clears the
|
||||||
|
warning; video/source/output/disk failures still take precedence and stop
|
||||||
|
recording. Disabled audio has no callback and no warning. Valid silent samples
|
||||||
|
are healthy; an endless synthetic silence stream cannot reveal a driver failure.
|
||||||
|
|
||||||
|
Windows does not infer loss from absent buffers. In the pinned
|
||||||
|
[OBS 32.0.4 win-wasapi source](https://github.com/obsproject/obs-studio/blob/32.0.4/plugins/win-wasapi/win-wasapi.cpp),
|
||||||
|
WASAPISource::Initialize calls ClearBuffer before InitCapture for DeviceOutput. ClearBuffer
|
||||||
|
creates a temporary shared renderer and fills its buffer with silence, but
|
||||||
|
neither starts nor retains that renderer. ProcessCaptureData accepts silent
|
||||||
|
packets and exits normally when GetNextPacketSize reports no packets. This is
|
||||||
|
not evidence of uninterrupted callbacks during idle loopback capture. Instead,
|
||||||
|
Clipbin tracks the plugin's source-scoped “failed to start” and device
|
||||||
|
“invalidated. Retrying” logs; successful initialization or a delivered capture
|
||||||
|
buffer clears the warning. The callback remains allocation/lock free; the log
|
||||||
|
handler separately updates weak registered fault flags. Flags are registered
|
||||||
|
before source creation and unregistered on cleanup, so unrelated/stale sources
|
||||||
|
cannot affect a recording. The parser has a unit test. Windows failures that
|
||||||
|
neither deliver buffers nor produce these pinned log messages remain undetected;
|
||||||
|
this is preferable to reporting ordinary silence as lost capture. Actual WASAPI
|
||||||
|
silence, mute, unplug/recovery and changed-device behavior require Windows runtime
|
||||||
|
validation. No automatic source restart is added; OBS's recovery can clear the
|
||||||
|
warning. Source identity is not displayed separately for system vs mic loss.
|
||||||
|
|
||||||
|
The rig's audio failure injection kills its private pipewire-pulse daemon,
|
||||||
|
leaving PipeWire video and the portal alive. Unloading the null sink and
|
||||||
|
requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8;
|
||||||
|
killing that private audio server deterministically stops source callbacks.
|
||||||
|
The original nine scenarios are retained alongside audio loss, idle silence
|
||||||
|
and muted-microphone cases (twelve total). Silence cases omit the ffplay tone,
|
||||||
|
assert there are no playback streams, wait twelve seconds and require the null
|
||||||
|
sink to be SUSPENDED before capture. They then record for sixteen seconds with
|
||||||
|
no degraded/failed status. The muted-mic case selects the null sink monitor as
|
||||||
|
the default input and mutes it; physical microphone hardware remains untested.
|
||||||
|
The rig now uses an explicit private SWAYSOCK and waits for it; discovery by
|
||||||
|
the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint.
|
||||||
|
CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs.
|
||||||
|
|
||||||
|
Windows limitations for this step: core/OBS/probe cross-clippy covers the native
|
||||||
|
tray, message pump, named mutex/hidden-window endpoint, HKCU autostart and OBS
|
||||||
|
callback registration/removal. Nothing new has been linked or run on Windows.
|
||||||
|
GPUI desktop and clipbin-media remain excluded from that lint set because the
|
||||||
|
MSVC C toolchain/FFmpeg development import libraries are unavailable here.
|
||||||
|
Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton
|
||||||
|
startup races and multi-user/desktop behaviour, foreground activation,
|
||||||
|
GPUI persistent-loop policy (including the vendored Windows backend, which is
|
||||||
|
not cross-linted), window recreation, clean Quit/file closure, --background,
|
||||||
|
registry writes and real login launch, WASAPI error-log/recovery/idle semantics, and the Clips layout on Windows.
|
||||||
|
Real Linux desktop trays (Hyprland
|
||||||
|
Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration
|
||||||
|
and native X11 tray lifecycle still need runtime validation.
|
||||||
|
|
||||||
|
Clips cards give title/subtitle their own row and keep fixed-size Play/Delete
|
||||||
|
controls below. The pinned GPUI text element caches nowrap widths independently
|
||||||
|
of truncation width and mutates its text runs during repeated measurements;
|
||||||
|
ordinary truncate produced hard clipping, ellipsis-only text, or fallback fonts
|
||||||
|
in the grid. A small private canvas label uses GPUI's line-wrapper and shaping
|
||||||
|
APIs once at the final bounds, retaining Geist Mono, colour, weight and proper
|
||||||
|
ellipsis without patching GPUI. Newlines in game names become spaces. Sample
|
||||||
|
screenshots at 1100x760 and 1600x1000 were inspected: all sample metadata is
|
||||||
|
readable and controls cannot steal its width. The amber audio warning was also
|
||||||
|
inspected in lifecycle-final/audio-lost.png. Screenshot validation exercises
|
||||||
|
the actual rendering path; no unit test can reproduce the GPU/grid text
|
||||||
|
measurement chain without the rig.
|
||||||
|
|
||||||
|
Validation commands, from the background-tray worktree (all passed):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
|
||||||
|
RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
57 workspace tests passed (generated-media tests keep their existing opt-in
|
||||||
|
fixture behaviour). The added status test covers audio degradation, recovery
|
||||||
|
and video-loss precedence. The dependency's existing proc-macro-error2 future
|
||||||
|
incompatibility notice is still printed; strict current clippy passes.
|
||||||
|
|
||||||
|
/tmp is a user-quota tmpfs here: an earlier recording probe failed with
|
||||||
|
Disk quota exceeded despite df reporting free bytes. Successful serial rig
|
||||||
|
runs used repository output storage, with a short absolute TMPDIR so private
|
||||||
|
Unix socket paths fit their limits:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export PATH=/usr/bin:/bin:$HOME/.cargo/bin
|
||||||
|
export TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp
|
||||||
|
out=/home/harry/Dev/clipbin-desktop/target/background-validation
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
scripts/validate/linux-rig.sh "$out/capture"
|
||||||
|
scripts/validate/linux-background.sh "$out/lifecycle-final"
|
||||||
|
scripts/validate/linux-session.sh "$out/sessions"
|
||||||
|
scripts/validate/linux-clips.sh "$out/clips"
|
||||||
|
scripts/validate/linux-playback.sh "$out/playback"
|
||||||
|
CLIPBIN_RIG_WIDTH=1100 CLIPBIN_RIG_HEIGHT=760 scripts/rig/screenshot-ui.sh "$out/screens-narrow-labels" clips
|
||||||
|
CLIPBIN_RIG_WIDTH=1600 CLIPBIN_RIG_HEIGHT=1000 scripts/rig/screenshot-ui.sh "$out/screens-wide-labels" clips
|
||||||
|
```
|
||||||
|
|
||||||
|
Capture: all original nine scenarios plus audio loss passed. Lifecycle: actual
|
||||||
|
watcher registration and D-Bus menu Open/Quit, recording tooltip, no-window
|
||||||
|
background start, autostart toggle removal/write and startup reconciliation,
|
||||||
|
segments growing after close, second launch reopening the same process,
|
||||||
|
preserved Settings route, degraded UI/tooltip, clean active-session Quit,
|
||||||
|
watcher-loss reveal and no-host close=Quit fallback passed. Sessions: game exit,
|
||||||
|
exact cumulative segment placement, continued background discovery/library
|
||||||
|
reload, second-launch Open and active Ctrl+Q passed. Clips: H.264 MP4 retained,
|
||||||
|
360 CFR frames, continuous audio, zero decode errors, original press range,
|
||||||
|
bookmarks, tiny-budget cleanup and bookmark re-export passed. Playback: moving
|
||||||
|
preview (6.1% changed pixels), device audio RMS 0.08027, thumbnails, continued
|
||||||
|
recording, gaps, cross-boundary seek, volume/mute and held-key controls passed.
|
||||||
|
Decode median/p95 was 6.335/10.472 ms; GPUI upload median/p95 was 0.208/0.404 ms.
|
||||||
|
Both old UI suites still use explicit Quit and completed successfully.
|
||||||
|
|
||||||
|
The simplify quick self-review covered reuse, clarity and efficiency before
|
||||||
|
commit: removed the obsolete window-close shutdown mode, reused existing clip,
|
||||||
|
bookmark and clean-finish paths, avoided copying the full library for tray
|
||||||
|
polling, limited icon updates to state changes, kept callbacks allocation/lock
|
||||||
|
free, and made native tray/thread cleanup explicit. The text helper is limited
|
||||||
|
to the demonstrated GPUI 0.2.2 rendering bug. The subsequent lifecycle review
|
||||||
|
required the small vendored GPUI policy patch described below. Native Windows
|
||||||
|
runtime and real desktop limits above remain unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## Background review follow-up (2026-10-13)
|
||||||
|
|
||||||
|
The first five-cycle close/Open test exposed a leak in repeated Application
|
||||||
|
creation: process thread counts grew 99, 114, 129, 144, 159, 174. GPUI's
|
||||||
|
LinuxDispatcher creates a worker pool and Timer per platform; the Timer ignores
|
||||||
|
channel closure and keeps running. This invalidates repeated-run ownership even
|
||||||
|
though each reopened window worked. Keeping one Application removes the repeated
|
||||||
|
platform construction. The final five-cycle test measured 71 threads initially
|
||||||
|
and 69 after every reopen; RSS was 243228, 242580, 247344, 243032, 243136,
|
||||||
|
243364 KiB. The rig saves reopen-metrics.tsv and asserts bounded thread/RSS growth.
|
||||||
|
|
||||||
|
The pinned Windows source was also examined: WindowsPlatform::new calls
|
||||||
|
OleInitialize; Drop destroys the message-only platform window and calls
|
||||||
|
OleUninitialize. The platform RegisterClassW result is ignored; the visible
|
||||||
|
window class uses Once, so repeated class registration itself is not a panic.
|
||||||
|
The VSync thread uses weak platform state and can exit on drop, and Windows
|
||||||
|
uses the OS thread pool rather than Linux's owned worker pool. Both dispatchers,
|
||||||
|
however, forget scheduled runnables when the main channel has closed, assuming
|
||||||
|
process shutdown. Repeated Application lifetimes are therefore not established
|
||||||
|
as safe on Windows. Clipbin avoids them entirely: fonts, dispatcher, OLE,
|
||||||
|
classes, DirectX state and native loop initialize once. The Windows last-window
|
||||||
|
policy is patched in the same backend decision as Linux; its runtime/build
|
||||||
|
remains unvalidated because the complete GPUI desktop cannot be cross-checked
|
||||||
|
here. The unmodified published dependency is retained with its Apache license;
|
||||||
|
clipbin-background.patch records the complete source delta. The only additional
|
||||||
|
compatibility change gives two taffy float literals explicit f32 types for
|
||||||
|
Rust 1.98's strict lint.
|
||||||
|
|
||||||
|
Follow-up artifacts live under target/background-review; reopen-before and
|
||||||
|
reopen-after contain lifecycle metrics. The silent-monitor experiment also
|
||||||
|
passed with the sink initially suspended. An initial muted-mic experiment used
|
||||||
|
an absent default input and correctly degraded; the committed fixture explicitly
|
||||||
|
selects a real monitor source before muting it.
|
||||||
|
|
||||||
|
|
||||||
|
Follow-up validation (all exit 0, 2026-10-13):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
|
||||||
|
env RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-background.sh /home/harry/Dev/clipbin-desktop/target/background-review/reopen-after
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-rig.sh /home/harry/Dev/clipbin-desktop/target/background-review/capture
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp bash -e -c 'for suite in linux-session linux-clips linux-playback; do scripts/validate/${suite}.sh /home/harry/Dev/clipbin-desktop/target/background-review/${suite}; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
All 58 workspace tests passed (fixture-dependent media tests retain their
|
||||||
|
existing opt-in behavior). All twelve capture scenarios passed; silence and
|
||||||
|
muted-mic logs show the initially suspended sink and respectively 650 and
|
||||||
|
650/645 captured packets, with no degradation. Audio-server loss still degrades
|
||||||
|
while video continues. Lifecycle passed SNI registration/menu actions,
|
||||||
|
--background, autostart write/remove, close survival, second launch, all five
|
||||||
|
reopen cycles, audio warning, clean Quit, watcher loss and no-host fallback.
|
||||||
|
Session finalization/library reload, clips/bookmarks/protected cleanup, and
|
||||||
|
playback all passed after the persistent-loop change. Playback changed 6.4% of
|
||||||
|
preview pixels, audio RMS was 0.08044; decode median/p95 was 4.951/11.467 ms and
|
||||||
|
GPUI upload median/p95 was 0.186/0.334 ms. The simplify quick self-review checked
|
||||||
|
reuse, clarity and efficiency; it retained existing actions/clean finish, one
|
||||||
|
application/dispatcher, weak fault registration with explicit cleanup, and no
|
||||||
|
new callback locks or allocations. The Windows policy flag uses a separate
|
||||||
|
Cell to avoid adding native state borrows during close handling.
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ cleanup() {
|
|||||||
wait 2>/dev/null || true
|
wait 2>/dev/null || true
|
||||||
# The D-Bus-activated document portal mounts a FUSE filesystem here.
|
# The D-Bus-activated document portal mounts a FUSE filesystem here.
|
||||||
fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true
|
fusermount3 -u "${XDG_RUNTIME_DIR}/doc" 2>/dev/null || true
|
||||||
|
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
|
||||||
|
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}"
|
||||||
|
cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
rm -rf "${rig_dir}"
|
rm -rf "${rig_dir}"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -70,6 +74,7 @@ pipewire > "${rig_dir}/pipewire.log" 2>&1 & pids+=($!)
|
|||||||
wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]'
|
wait_for pipewire '[[ -S "${XDG_RUNTIME_DIR}/pipewire-0" ]]'
|
||||||
wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!)
|
wireplumber > "${rig_dir}/wireplumber.log" 2>&1 & pids+=($!)
|
||||||
pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!)
|
pipewire-pulse > "${rig_dir}/pipewire-pulse.log" 2>&1 & pids+=($!)
|
||||||
|
echo "${pids[-1]}" > "${rig_dir}/pulse.pid"
|
||||||
wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]'
|
wait_for pipewire-pulse '[[ -S "${XDG_RUNTIME_DIR}/pulse/native" ]]'
|
||||||
# No sound card here: route playback to a virtual sink whose monitor OBS
|
# No sound card here: route playback to a virtual sink whose monitor OBS
|
||||||
# records as desktop audio.
|
# records as desktop audio.
|
||||||
@@ -89,11 +94,12 @@ printf '%s\n' \
|
|||||||
"max_fps=60" \
|
"max_fps=60" \
|
||||||
> "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config"
|
> "${XDG_CONFIG_HOME}/xdg-desktop-portal-wlr/config"
|
||||||
|
|
||||||
|
export SWAYSOCK="${XDG_RUNTIME_DIR}/clipbin-sway.sock"
|
||||||
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \
|
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_HEADLESS_OUTPUTS=1 \
|
||||||
sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!)
|
sway --config "${XDG_CONFIG_HOME}/sway/config" > "${rig_dir}/sway.log" 2>&1 & pids+=($!)
|
||||||
wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null'
|
wait_for sway 'compgen -G "${XDG_RUNTIME_DIR}/wayland-*" > /dev/null'
|
||||||
WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")"
|
WAYLAND_DISPLAY="$(basename "$(compgen -G "${XDG_RUNTIME_DIR}/wayland-*" | grep -v lock | head -1)")"
|
||||||
SWAYSOCK="$(compgen -G "${XDG_RUNTIME_DIR}/sway-ipc.*" | head -1)"
|
wait_for "sway IPC" '[[ -S "${SWAYSOCK}" ]]'
|
||||||
export WAYLAND_DISPLAY SWAYSOCK
|
export WAYLAND_DISPLAY SWAYSOCK
|
||||||
dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK
|
dbus-update-activation-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_RUNTIME_DIR XDG_CONFIG_HOME SWAYSOCK
|
||||||
/usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!)
|
/usr/lib/xdg-desktop-portal-wlr > "${rig_dir}/xdpw.log" 2>&1 & pids+=($!)
|
||||||
@@ -110,9 +116,13 @@ if [[ "${mode}" == x11 ]]; then
|
|||||||
video_driver=x11
|
video_driver=x11
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A moving 60 FPS picture with a 440 Hz tone, standing in for a game.
|
# A moving 60 FPS picture, optionally without any audio stream for idle tests.
|
||||||
|
pattern="testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0]"
|
||||||
|
if [[ "${CLIPBIN_RIG_NO_AUDIO:-0}" != 1 ]]; then
|
||||||
|
pattern+=';sine=frequency=440:sample_rate=48000[out1]'
|
||||||
|
fi
|
||||||
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
|
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
|
||||||
-f lavfi "testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0];sine=frequency=440:sample_rate=48000[out1]" \
|
-f lavfi "${pattern}" \
|
||||||
> "${rig_dir}/game.log" 2>&1 & pids+=($!)
|
> "${rig_dir}/game.log" 2>&1 & pids+=($!)
|
||||||
echo "${pids[-1]}" > "${rig_dir}/game.pid"
|
echo "${pids[-1]}" > "${rig_dir}/game.pid"
|
||||||
if [[ "${mode}" == x11 ]]; then
|
if [[ "${mode}" == x11 ]]; then
|
||||||
@@ -126,7 +136,5 @@ export CLIPBIN_RIG_DIR="${rig_dir}"
|
|||||||
echo "rig: ready (${mode}, logs in ${rig_dir})" >&2
|
echo "rig: ready (${mode}, logs in ${rig_dir})" >&2
|
||||||
status=0
|
status=0
|
||||||
"$@" || status=$?
|
"$@" || status=$?
|
||||||
if [[ -n "${CLIPBIN_RIG_KEEP_LOGS:-}" ]]; then
|
|
||||||
mkdir -p "${CLIPBIN_RIG_KEEP_LOGS}" && cp "${rig_dir}"/*.log "${CLIPBIN_RIG_KEEP_LOGS}/"
|
|
||||||
fi
|
|
||||||
exit "${status}"
|
exit "${status}"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Source inside the headless rig; accepts a key and an optional modifier.
|
||||||
|
rig_key() {
|
||||||
|
local key="$1" modifier="${2:-}"
|
||||||
|
# Destroying rig-pointer's virtual seat can clear keyboard focus in Sway.
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
|
||||||
|
sleep .2
|
||||||
|
# Let the virtual keymap settle, then hold the key through a UI dispatch.
|
||||||
|
local args=(-s 200)
|
||||||
|
if [[ -n "${modifier}" ]]; then args+=(-M "${modifier}" -s 100); fi
|
||||||
|
args+=(-P "${key}" -s 100 -p "${key}")
|
||||||
|
if [[ -n "${modifier}" ]]; then args+=(-m "${modifier}"); fi
|
||||||
|
timeout 10 wtype "${args[@]}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Screenshots each Clipbin screen in the headless rig, for reviewing the
|
||||||
|
# interface on machines without a display.
|
||||||
|
#
|
||||||
|
# scripts/rig/screenshot-ui.sh [output-dir] [screen...]
|
||||||
|
#
|
||||||
|
# Screens: sessions, session, clips, settings, failed, empty. Uses the sample
|
||||||
|
# library (CLIPBIN_SAMPLE_LIBRARY) except for "empty".
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
out="${1:-${TMPDIR:-/tmp}/clipbin-screens}"
|
||||||
|
shift || true
|
||||||
|
screens=("$@")
|
||||||
|
[[ ${#screens[@]} -eq 0 ]] && screens=(sessions session clips settings failed empty)
|
||||||
|
mkdir -p "${out}"
|
||||||
|
|
||||||
|
cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml"
|
||||||
|
app="${repo}/target/debug/clipbin-desktop"
|
||||||
|
|
||||||
|
for screen in "${screens[@]}"; do
|
||||||
|
env=(CLIPBIN_SAMPLE_LIBRARY=1)
|
||||||
|
case "${screen}" in
|
||||||
|
sessions) ;;
|
||||||
|
session) env+=(CLIPBIN_ROUTE=session:2) ;;
|
||||||
|
clips) env+=(CLIPBIN_ROUTE=clips) ;;
|
||||||
|
settings) env+=(CLIPBIN_ROUTE=settings) ;;
|
||||||
|
failed) env+=(CLIPBIN_SAMPLE_STATUS=failed) ;;
|
||||||
|
empty) env=() ;;
|
||||||
|
*) echo "unknown screen ${screen}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
CLIPBIN_RIG_WIDTH="${CLIPBIN_RIG_WIDTH:-1600}" CLIPBIN_RIG_HEIGHT="${CLIPBIN_RIG_HEIGHT:-1000}" \
|
||||||
|
"${repo}/scripts/rig/headless.sh" bash -c "
|
||||||
|
kill \$(cat \"\${CLIPBIN_RIG_DIR}/game.pid\")
|
||||||
|
env ${env[*]} '${app}' > '${out}/${screen}.log' 2>&1 &
|
||||||
|
sleep 3
|
||||||
|
grim '${out}/${screen}.png'
|
||||||
|
kill %1" 2> /dev/null
|
||||||
|
echo "${out}/${screen}.png"
|
||||||
|
done
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate a persisted clip before any source cleanup (ffmpeg/ffprobe required)."""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
saved_path = pathlib.Path(sys.argv[1])
|
||||||
|
saved = json.loads(saved_path.read_text())
|
||||||
|
clip = saved_path.with_suffix('.mp4')
|
||||||
|
session_path = saved_path.parent.parent / 'sessions' / str(saved['source_session']) / 'session.json'
|
||||||
|
session = json.loads(session_path.read_text())
|
||||||
|
seconds = lambda d: d['secs'] + d['nanos'] / 1e9
|
||||||
|
start, end = seconds(saved['start']), seconds(saved['end'])
|
||||||
|
assert end > start
|
||||||
|
pieces = [s for s in session['segments'] if s['available'] and seconds(s['start']) < end and seconds(s['end']) > start]
|
||||||
|
assert len(pieces) >= 2, 'Export must cross a segment boundary'
|
||||||
|
|
||||||
|
def probe(path):
|
||||||
|
return json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_packets', '-of', 'json', str(path)]))
|
||||||
|
|
||||||
|
source = probe(session_path.parent / pieces[0]['path'])
|
||||||
|
output = probe(clip)
|
||||||
|
video = next(s for s in output['streams'] if s['codec_type'] == 'video')
|
||||||
|
source_video = next(s for s in source['streams'] if s['codec_type'] == 'video')
|
||||||
|
assert video['codec_name'] == source_video['codec_name']
|
||||||
|
pts = sorted(float(p['pts_time']) for p in output['packets'] if p['stream_index'] == video['index'])
|
||||||
|
fps = pieces[0]['frames_per_second']
|
||||||
|
for a, b in zip(pts, pts[1:]):
|
||||||
|
assert abs(b-a-1/fps) < 2e-6, ('Video gap/duplicate', a, b, fps)
|
||||||
|
assert abs(pts[0]) < 1/fps
|
||||||
|
assert abs(pts[-1]+1/fps-(end-start)) < 1/fps+0.002, (pts[-1], start, end)
|
||||||
|
# Metadata records independent AAC epochs, even if earlier files are cleaned up later.
|
||||||
|
runs={}
|
||||||
|
for segment in session['segments']:
|
||||||
|
if not segment['complete'] or not segment['available']:
|
||||||
|
continue
|
||||||
|
run=str(pathlib.Path(segment['path']).parent)
|
||||||
|
facts=probe(session_path.parent/segment['path'])
|
||||||
|
audios=[s for s in facts['streams'] if s['codec_type']=='audio']
|
||||||
|
totals=runs.setdefault(run,[0]*len(audios))
|
||||||
|
assert segment['audio_start_samples']==totals,(segment['path'],segment['audio_start_samples'],totals)
|
||||||
|
for i,stream in enumerate(audios):
|
||||||
|
totals[i]+=1024*len([p for p in facts['packets'] if p['stream_index']==stream['index']])
|
||||||
|
|
||||||
|
for ordinal,stream in enumerate([s for s in output['streams'] if s['codec_type']=='audio']):
|
||||||
|
if stream['codec_type'] == 'audio':
|
||||||
|
timestamps = [float(p['pts_time']) for p in output['packets'] if p['stream_index'] == stream['index']]
|
||||||
|
step = 1024 / int(stream['sample_rate'])
|
||||||
|
assert timestamps
|
||||||
|
import math
|
||||||
|
source_ordinal=ordinal if saved['separate_audio'] or len([s for s in source['streams'] if s['codec_type']=='audio'])!=3 else 2
|
||||||
|
first=pieces[0]
|
||||||
|
run_base=seconds(first['start'])-first['frame_start']/fps
|
||||||
|
phase=run_base+first['audio_start_samples'][source_ordinal]/int(stream['sample_rate'])-start
|
||||||
|
expected_first=phase+max(0,math.ceil(-phase/step))*step
|
||||||
|
assert abs(timestamps[0]-expected_first)<1/int(stream['sample_rate'])+2e-6,('A/V phase',timestamps[0],expected_first)
|
||||||
|
for a, b in zip(timestamps, timestamps[1:]):
|
||||||
|
assert abs(b-a-step) < 2e-6, ('Audio gap/duplicate', a, b)
|
||||||
|
source_audio = [s for s in source['streams'] if s['codec_type'] == 'audio']
|
||||||
|
expected = 2 if saved['separate_audio'] and len(source_audio) == 3 else 1
|
||||||
|
assert len([s for s in output['streams'] if s['codec_type'] == 'audio']) == expected
|
||||||
|
result = subprocess.run(['ffmpeg', '-v', 'error', '-xerror', '-i', str(clip), '-f', 'null', '-'], capture_output=True)
|
||||||
|
assert result.returncode == 0 and not result.stderr, result.stderr.decode()
|
||||||
|
assert saved['clip']['size_bytes'] == clip.stat().st_size
|
||||||
|
print(f'PASS clip {clip.name}: {video["codec_name"]}, {len(pts)} CFR frames, continuous audio, zero decode errors, {end-start:.3f}s')
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Serial tray/window/autostart validation in the private SNI-host rig.
|
||||||
|
set -euo pipefail
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
out="${1:-${TMPDIR:-/tmp}/clipbin-background-validation}"
|
||||||
|
mkdir -p "${out}"
|
||||||
|
cargo build --locked -p clipbin-desktop -p rig-pointer
|
||||||
|
cargo build --locked -p clipbin-core --example sni-host
|
||||||
|
export CLIPBIN_BACKGROUND_REPO="${repo}" CLIPBIN_BACKGROUND_OUT="${out}"
|
||||||
|
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
|
||||||
|
set -euo pipefail
|
||||||
|
repo="${CLIPBIN_BACKGROUND_REPO}" out="${CLIPBIN_BACKGROUND_OUT}"
|
||||||
|
export XDG_CONFIG_HOME="${out}/config" XDG_DATA_HOME="${out}/data"
|
||||||
|
mkdir -p "${XDG_CONFIG_HOME}" "${XDG_DATA_HOME}"
|
||||||
|
source "${repo}/scripts/rig/keys.sh"
|
||||||
|
"${repo}/target/debug/examples/sni-host" > "${out}/host.log" 2>&1 & host=$!
|
||||||
|
app= game=
|
||||||
|
trap 'kill "${app:-}" "${game:-}" "${host:-}" 2>/dev/null || true' EXIT
|
||||||
|
sleep 1
|
||||||
|
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] floating enable' >/dev/null
|
||||||
|
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] resize set width 1360 px height 860 px' >/dev/null
|
||||||
|
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] move position 280 110' >/dev/null
|
||||||
|
windows() {
|
||||||
|
swaymsg -t get_tree | python3 -c 'import json,sys
|
||||||
|
nodes=[json.load(sys.stdin)]; total=0
|
||||||
|
while nodes:
|
||||||
|
n=nodes.pop(); nodes.extend(n.get("nodes",[])+n.get("floating_nodes",[])); total+=n.get("app_id")=="dev.clipbin.Clipbin"
|
||||||
|
print(total)'
|
||||||
|
}
|
||||||
|
wait_windows() {
|
||||||
|
for _ in $(seq 50); do [[ "$(windows)" == "$1" ]] && return 0; sleep .2; done
|
||||||
|
echo "FAIL expected $1 Clipbin windows" >&2; exit 1
|
||||||
|
}
|
||||||
|
wait_exit() {
|
||||||
|
for _ in $(seq 100); do if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi; sleep .2; done
|
||||||
|
echo 'FAIL Quit did not exit' >&2; exit 1
|
||||||
|
}
|
||||||
|
CLIPBIN_ROUTE=settings "${repo}/target/debug/clipbin-desktop" --background > "${out}/desktop.log" 2>&1 & app=$!
|
||||||
|
sleep 3
|
||||||
|
kill -0 "${app}"
|
||||||
|
[[ "$(windows)" == 0 ]]
|
||||||
|
[[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
|
||||||
|
grep -q -- '--background' "${XDG_CONFIG_HOME}/autostart/clipbin.desktop"
|
||||||
|
item="$(sed -n 's/^registered=//p' "${out}/host.log" | tail -1)"
|
||||||
|
[[ -n "${item}" ]]
|
||||||
|
gdbus call --session --dest org.kde.StatusNotifierWatcher --object-path /StatusNotifierWatcher \
|
||||||
|
--method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems > "${out}/registered.txt"
|
||||||
|
grep -q "${item}" "${out}/registered.txt"
|
||||||
|
gdbus call --session --dest "${item}" --object-path /MenuBar \
|
||||||
|
--method com.canonical.dbusmenu.GetLayout -- 0 -1 '[]' > "${out}/menu.txt"
|
||||||
|
grep -q 'Open Clipbin' "${out}/menu.txt"
|
||||||
|
menu() { gdbus call --session --dest "${item}" --object-path /MenuBar --method com.canonical.dbusmenu.Event "$1" clicked '<0>' 0 > /dev/null; }
|
||||||
|
menu 1
|
||||||
|
wait_windows 1
|
||||||
|
swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position center, focus' >/dev/null
|
||||||
|
sleep 1
|
||||||
|
grim "${out}/settings.png"
|
||||||
|
# Coordinates within the centred 1360x860 settings window at 1920x1080.
|
||||||
|
printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
sleep 1
|
||||||
|
[[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
|
||||||
|
printf 'move 1330 550\nsleep .2\nclick\nsleep .3\n' | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
sleep 1
|
||||||
|
[[ -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
|
||||||
|
echo 'PASS SNI registration, menu Open, background startup and autostart setting'
|
||||||
|
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
|
||||||
|
for _ in $(seq 40); do grep -q 'recorder_status=Recording' "${out}/desktop.log" && break; sleep 1; done
|
||||||
|
grep -q 'recorder_status=Recording' "${out}/desktop.log"
|
||||||
|
gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/recording-tooltip.txt"
|
||||||
|
grep -q Recording "${out}/recording-tooltip.txt"
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
||||||
|
wait_windows 0
|
||||||
|
before="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)"
|
||||||
|
sleep 5
|
||||||
|
after="$(find "${XDG_DATA_HOME}/clipbin/sessions" -name '*.mkv' | wc -l)"
|
||||||
|
(( after > before ))
|
||||||
|
"${repo}/target/debug/clipbin-desktop" > "${out}/second-launch.log" 2>&1
|
||||||
|
wait_windows 1
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
||||||
|
wait_windows 0
|
||||||
|
menu 1
|
||||||
|
wait_windows 1
|
||||||
|
# Repeated reopen must release each UI's threads and allocation state.
|
||||||
|
measure() {
|
||||||
|
python3 - "${app}" "$1" <<'PYMETRICS'
|
||||||
|
import pathlib,sys
|
||||||
|
status=dict(line.split(':',1) for line in pathlib.Path(f'/proc/{sys.argv[1]}/status').read_text().splitlines() if ':' in line)
|
||||||
|
print(f"{sys.argv[2]}\t{status['Threads'].strip()}\t{status['VmRSS'].split()[0]}")
|
||||||
|
PYMETRICS
|
||||||
|
}
|
||||||
|
measure 0 > "${out}/reopen-metrics.tsv"
|
||||||
|
for cycle in $(seq 5); do
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
||||||
|
wait_windows 0
|
||||||
|
sleep 1
|
||||||
|
menu 1
|
||||||
|
wait_windows 1
|
||||||
|
sleep 1
|
||||||
|
measure "${cycle}" >> "${out}/reopen-metrics.tsv"
|
||||||
|
done
|
||||||
|
cat "${out}/reopen-metrics.tsv"
|
||||||
|
python3 - "${out}/reopen-metrics.tsv" <<'PYBOUNDS'
|
||||||
|
import pathlib,sys
|
||||||
|
rows=[tuple(map(int,line.split())) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()]
|
||||||
|
assert max(r[1] for r in rows[1:]) <= rows[0][1]+4, rows
|
||||||
|
# Allow the first reopen to warm caches; later windows must stay bounded.
|
||||||
|
rss=[r[2] for r in rows[1:]]
|
||||||
|
assert max(rss)-min(rss) <= 32*1024 and rows[-1][2] <= rows[0][2]+64*1024, rows
|
||||||
|
print('PASS five close/reopen cycles have bounded thread count and RSS')
|
||||||
|
PYBOUNDS
|
||||||
|
# Audio delivery loss must be visible in the application and real tray tooltip.
|
||||||
|
kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")"
|
||||||
|
for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done
|
||||||
|
grep -q 'recorder_status=Degraded' "${out}/desktop.log"
|
||||||
|
sleep .3
|
||||||
|
gdbus call --session --dest "${item}" --object-path /StatusNotifierItem --method org.freedesktop.DBus.Properties.Get org.kde.StatusNotifierItem ToolTip > "${out}/audio-lost-tooltip.txt"
|
||||||
|
grep -q 'audio capture lost' "${out}/audio-lost-tooltip.txt"
|
||||||
|
grim "${out}/audio-lost.png"
|
||||||
|
echo 'PASS audio loss is degraded in the UI and tray while video continues'
|
||||||
|
# Quit through D-Bus menu while a game is still alive.
|
||||||
|
menu 4
|
||||||
|
wait_exit
|
||||||
|
python3 - "${XDG_DATA_HOME}" <<'PY'
|
||||||
|
import pathlib,json,sys
|
||||||
|
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
|
||||||
|
assert files and all(not json.loads(p.read_text())['session']['active'] for p in files)
|
||||||
|
assert all(s['complete'] for p in files for s in json.loads(p.read_text())['segments'])
|
||||||
|
PY
|
||||||
|
kill "${game}"; wait "${game}" 2>/dev/null || true; game=
|
||||||
|
echo 'PASS close keeps segments growing, second launch reopens, tray Open reopens, tray Quit finalises'
|
||||||
|
# Losing the watcher must reveal the UI; closing then quits cleanly.
|
||||||
|
"${repo}/target/debug/clipbin-desktop" --background > "${out}/host-loss.log" 2>&1 & app=$!
|
||||||
|
sleep 2
|
||||||
|
wait_windows 0
|
||||||
|
kill "${host}"; wait "${host}" 2>/dev/null || true; host=
|
||||||
|
wait_windows 1
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
||||||
|
wait_exit
|
||||||
|
# Reconcile an externally disabled saved setting, removing the existing entry.
|
||||||
|
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
|
||||||
|
import json,pathlib,sys
|
||||||
|
p=pathlib.Path(sys.argv[1]); settings=json.loads(p.read_text()); settings['start_on_login']=False; p.write_text(json.dumps(settings))
|
||||||
|
PYSETTINGS
|
||||||
|
# With no watcher at startup --background must open a reachable window.
|
||||||
|
"${repo}/target/debug/clipbin-desktop" --background > "${out}/no-tray.log" 2>&1 & app=$!
|
||||||
|
wait_windows 1
|
||||||
|
grep -q 'Tray unavailable' "${out}/no-tray.log"
|
||||||
|
[[ ! -f "${XDG_CONFIG_HOME}/autostart/clipbin.desktop" ]]
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
||||||
|
wait_exit
|
||||||
|
echo 'PASS watcher loss reveals UI; no-tray background falls back to close=Quit'
|
||||||
|
INNER
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Real desktop buttons, background export, persisted bookmark and forced cleanup.
|
||||||
|
set -euo pipefail
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
out="${1:-/tmp/clipbin-clips-validation-$(date +%s)}"
|
||||||
|
mkdir -p "${out}"
|
||||||
|
cargo build --locked -p clipbin-desktop -p rig-pointer --manifest-path "${repo}/Cargo.toml"
|
||||||
|
cargo build --locked -p clipbin-media --example export --manifest-path "${repo}/Cargo.toml"
|
||||||
|
export CLIPBIN_CLIPS_REPO="${repo}" CLIPBIN_CLIPS_OUT="${out}"
|
||||||
|
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
|
||||||
|
set -euo pipefail
|
||||||
|
repo="${CLIPBIN_CLIPS_REPO}"; out="${CLIPBIN_CLIPS_OUT}"
|
||||||
|
source "${repo}/scripts/rig/keys.sh"
|
||||||
|
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
|
||||||
|
mkdir -p "${XDG_CONFIG_HOME}/clipbin"
|
||||||
|
cat > "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'JSON'
|
||||||
|
{"clip_duration":{"secs":5,"nanos":0},"bookmark_before":{"secs":2,"nanos":0},"bookmark_after":{"secs":2,"nanos":0}}
|
||||||
|
JSON
|
||||||
|
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
|
||||||
|
import json,os,pathlib,sys
|
||||||
|
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text())
|
||||||
|
s['clip_duration']['secs']=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
|
||||||
|
s['codec']=os.environ.get('CLIPBIN_VALIDATE_CODEC','H264')
|
||||||
|
assert s['codec'] in ('H264','Hevc','Av1')
|
||||||
|
s['microphone_enabled']=os.environ.get('CLIPBIN_VALIDATE_MIC','0')=='1'
|
||||||
|
s['export_separate_audio']=os.environ.get('CLIPBIN_VALIDATE_SEPARATE_AUDIO','0')=='1'
|
||||||
|
p.write_text(json.dumps(s))
|
||||||
|
PYSETTINGS
|
||||||
|
"${repo}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & app=$!
|
||||||
|
trap 'kill "${app}" "${game:-}" 2>/dev/null || true' EXIT
|
||||||
|
quit_app() {
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
|
||||||
|
# A fresh GPUI window may not have received the rig's keyboard focus yet.
|
||||||
|
# Move/click inside it before injecting the shortcut, then retry boundedly.
|
||||||
|
read -r qx qy qw qh < <(geometry)
|
||||||
|
click "$((qx+qw-30))" "$((qy+100))"
|
||||||
|
sleep 0.5
|
||||||
|
rig_key q ctrl
|
||||||
|
for attempt in $(seq 20); do
|
||||||
|
if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi
|
||||||
|
if ((attempt % 4 == 0)); then
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
|
||||||
|
rig_key q ctrl
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
grim "${out}/quit-failed.png"
|
||||||
|
swaymsg -t get_tree > "${out}/quit-failed-tree.json"
|
||||||
|
echo 'FAIL desktop did not honour Quit' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
sleep 3
|
||||||
|
swaymsg '[title="clipbin-test-game"] fullscreen disable' >/dev/null || true
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' >/dev/null
|
||||||
|
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' & game=$!
|
||||||
|
for _ in $(seq 30); do
|
||||||
|
if grep -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi
|
||||||
|
kill -0 "${app}"; sleep 1
|
||||||
|
done
|
||||||
|
grep -q 'recorder_status=Recording' "${out}/desktop.log"
|
||||||
|
sleep 5
|
||||||
|
# Read window geometry rather than depending on output placement/titlebar height.
|
||||||
|
geometry() { swaymsg -t get_tree | python3 -c 'import json,sys
|
||||||
|
s=[json.load(sys.stdin)]
|
||||||
|
while s:
|
||||||
|
n=s.pop()
|
||||||
|
if n.get("app_id")=="dev.clipbin.Clipbin":
|
||||||
|
r=n["rect"];print(r["x"],r["y"],r["width"],r["height"]);break
|
||||||
|
s.extend(n.get("nodes",[])+n.get("floating_nodes",[]))'; }
|
||||||
|
read -r x y width height < <(geometry)
|
||||||
|
click() { printf 'move %s %s\nclick\nsleep 0.3\n' "$1" "$2" | "${repo}/target/debug/rig-pointer" 1920 1080; }
|
||||||
|
grim "${out}/before-buttons.png"
|
||||||
|
# Bottom sidebar: storage 88px, recorder 70px, gap24px, bookmark32px.
|
||||||
|
click "$((x+110))" "$((y+height-248))"
|
||||||
|
sleep 5
|
||||||
|
press_ns="$(python3 -c 'import time;print(time.time_ns())')"
|
||||||
|
click "$((x+110))" "$((y+height-304))"
|
||||||
|
for _ in $(seq 30); do
|
||||||
|
if grep -q 'export_status=Saved clip' "${out}/desktop.log"; then break; fi
|
||||||
|
kill -0 "${app}"; sleep 1
|
||||||
|
done
|
||||||
|
grep -q 'export_status=Saved clip' "${out}/desktop.log"
|
||||||
|
python3 - "${XDG_DATA_HOME}" "${repo}" "${press_ns}" <<'PY'
|
||||||
|
import pathlib,subprocess,sys,json,os
|
||||||
|
clips=[p for p in pathlib.Path(sys.argv[1]).glob('clipbin/clips/*.json') if p.stem.isdigit()]
|
||||||
|
assert len(clips)==1,clips
|
||||||
|
subprocess.run(['python3',sys.argv[2]+'/scripts/validate/check-clip.py',str(clips[0])],check=True)
|
||||||
|
m=json.loads(next(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')).read_text())
|
||||||
|
assert len(m['session']['bookmarks'])==1 and m['session']['bookmarks'][0]['protected']
|
||||||
|
assert m['session']['active'],'Export must leave capture active'
|
||||||
|
saved=json.loads(clips[0].read_text())
|
||||||
|
configured=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
|
||||||
|
seconds=lambda d:d['secs']+d['nanos']/1e9
|
||||||
|
pressed=int(sys.argv[3])/1e9-m['session']['started']['secs_since_epoch']-m['session']['started']['nanos_since_epoch']/1e9
|
||||||
|
assert pressed-.2<=seconds(saved['end'])<=pressed+1.2,(pressed,saved['end'])
|
||||||
|
if pressed>=configured:
|
||||||
|
assert pressed-configured-1.2<=seconds(saved['start'])<=pressed-configured+.2,(pressed,saved['start'])
|
||||||
|
assert configured<=seconds(saved['clip']['length'])<=configured+1.1
|
||||||
|
else:
|
||||||
|
assert abs(seconds(saved['start'])-min(seconds(s['start']) for s in m['segments'] if s['available']))<.001
|
||||||
|
assert seconds(saved['clip']['length'])<configured
|
||||||
|
print('PASS shorter session used its actual available footage')
|
||||||
|
print('PASS export kept the original button press range while the segment finalised')
|
||||||
|
PY
|
||||||
|
click "$((x+100))" "$((y+128))"
|
||||||
|
sleep 2; grim "${out}/clips.png"
|
||||||
|
kill "${game}"; wait "${game}" 2>/dev/null || true
|
||||||
|
sleep 3
|
||||||
|
quit_app
|
||||||
|
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PY'
|
||||||
|
import json,pathlib,sys
|
||||||
|
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text());s['unprotected_session_limit_bytes']=1;p.write_text(json.dumps(s))
|
||||||
|
PY
|
||||||
|
"${repo}/target/debug/clipbin-desktop" > "${out}/cleanup.log" 2>&1 & app=$!
|
||||||
|
sleep 6
|
||||||
|
python3 - "${XDG_DATA_HOME}" "${repo}" "${out}" <<'PY'
|
||||||
|
import json,pathlib,sys,subprocess
|
||||||
|
root=pathlib.Path(sys.argv[1]);p=next(root.glob('clipbin/sessions/*/session.json'));m=json.loads(p.read_text())
|
||||||
|
seconds=lambda d:d['secs']+d['nanos']/1e9
|
||||||
|
b=m['session']['bookmarks'][0];start=max(0,seconds(b['at'])-seconds(b['before']));end=min(seconds(m['session']['length']),seconds(b['at'])+seconds(b['after']))
|
||||||
|
assert b['protected'];removed=kept=0
|
||||||
|
for s in m['segments']:
|
||||||
|
overlap=seconds(s['start'])<end and seconds(s['end'])>start
|
||||||
|
if overlap:
|
||||||
|
assert s['available'] and (p.parent/s['path']).is_file();kept+=1
|
||||||
|
elif seconds(s['end'])>seconds(s['start']):
|
||||||
|
assert not s['available'] and not (p.parent/s['path']).exists();removed+=1
|
||||||
|
assert kept and removed,(kept,removed)
|
||||||
|
assert any(s['kind']=='Removed' for s in m['session']['spans'])
|
||||||
|
assert len(list(root.glob('clipbin/clips/*.mp4')))==1
|
||||||
|
print('PASS forced tiny-budget cleanup preserved bookmark footage and clip, removed neighbours and persisted Removed spans')
|
||||||
|
# Earlier unprotected files are gone: the retained bookmark must still remux
|
||||||
|
# using its persisted per-stream clock, without scanning those deleted files.
|
||||||
|
export=pathlib.Path(sys.argv[3])/'bookmark-after-cleanup.mp4'
|
||||||
|
subprocess.run([sys.argv[2]+'/target/debug/examples/export',str(p.parent.parent),str(m['session']['id']),str(int(start*1000)),str(int(end*1000)),str(export)],check=True)
|
||||||
|
subprocess.run(['ffmpeg','-v','error','-xerror','-i',str(export),'-f','null','-'],check=True)
|
||||||
|
packets=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-show_packets','-of','json',str(export)]))['packets']
|
||||||
|
pts=sorted(float(p['pts_time']) for p in packets);fps=next(s['frames_per_second'] for s in m['segments'] if s['available'])
|
||||||
|
assert all(abs(b-a-1/fps)<2e-6 for a,b in zip(pts,pts[1:]))
|
||||||
|
print('PASS protected bookmark exported after preceding source segments were deleted')
|
||||||
|
PY
|
||||||
|
quit_app
|
||||||
|
echo 'PASS in-app clip and bookmark paths; portal GlobalShortcuts unavailable in wlr rig (real Hyprland remains unvalidated)'
|
||||||
|
INNER
|
||||||