Compare commits
5
Commits
f6cdef5dd2
...
178c400eeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178c400eeb | ||
|
|
dd016f7727 | ||
|
|
6db5964d06 | ||
|
|
0a7d62a807 | ||
|
|
7ac7e66ff1 |
Generated
+396
-6
@@ -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"
|
||||||
@@ -863,13 +885,18 @@ dependencies = [
|
|||||||
name = "clipbin-core"
|
name = "clipbin-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ashpd 0.12.3",
|
||||||
|
"async-io",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"futures-lite 2.6.1",
|
||||||
|
"futures-util",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winreg 0.55.0",
|
"winreg 0.55.0",
|
||||||
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -877,8 +904,10 @@ name = "clipbin-desktop"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clipbin-core",
|
"clipbin-core",
|
||||||
|
"clipbin-media",
|
||||||
"clipbin-obs",
|
"clipbin-obs",
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"image",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -886,7 +915,11 @@ name = "clipbin-media"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clipbin-core",
|
"clipbin-core",
|
||||||
|
"cpal",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
|
"image",
|
||||||
|
"rustix 1.1.5",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -983,6 +1016,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"
|
||||||
@@ -1193,6 +1236,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"
|
||||||
@@ -1216,6 +1273,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"
|
||||||
@@ -1319,6 +1409,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"
|
||||||
@@ -1528,6 +1624,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"
|
||||||
@@ -2250,7 +2357,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",
|
||||||
@@ -2917,6 +3024,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"
|
||||||
@@ -3159,6 +3324,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"
|
||||||
@@ -3282,6 +3453,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",
|
||||||
]
|
]
|
||||||
@@ -3336,6 +3508,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"
|
||||||
@@ -3521,6 +3722,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"
|
||||||
@@ -3564,6 +3787,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"
|
||||||
@@ -3571,7 +3843,9 @@ 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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3588,6 +3862,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",
|
||||||
]
|
]
|
||||||
@@ -3907,6 +4183,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"
|
||||||
@@ -4044,6 +4326,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"
|
||||||
@@ -4965,6 +5263,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"
|
||||||
@@ -6334,11 +6642,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]]
|
||||||
@@ -6351,7 +6671,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]]
|
||||||
@@ -6363,6 +6683,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"
|
||||||
@@ -6388,6 +6717,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"
|
||||||
@@ -6396,7 +6738,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]]
|
||||||
@@ -6465,6 +6818,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"
|
||||||
@@ -6505,6 +6868,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"
|
||||||
@@ -6523,6 +6895,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"
|
||||||
@@ -6616,6 +6997,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"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The recording foundation is being validated (see [docs/validation.md](docs/valid
|
|||||||
|
|
||||||
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 keeps the current session recording until the game exits, then exits the app. Ctrl+Q or Quit stops the output cleanly.
|
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 keeps the current session recording until the game exits, then exits the app. Ctrl+Q or Quit stops the output cleanly.
|
||||||
|
|
||||||
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, export, global hotkeys, storage cleanup, autostart and a tray are not implemented yet. After closing the only window, reopening it without a tray is a follow-up.
|
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. Autostart and a tray are not implemented yet; after closing the only window, reopening it without a tray is a follow-up.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ Windows is a first-class target. Every change must keep the Windows code paths b
|
|||||||
```text
|
```text
|
||||||
crates/
|
crates/
|
||||||
clipbin-core/ Settings, Steam detection and session storage; independent of GPUI and 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.
|
||||||
@@ -41,7 +42,7 @@ Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2
|
|||||||
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
|
||||||
@@ -62,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:
|
||||||
@@ -87,7 +90,9 @@ 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`. Automatic desktop sessions: `scripts/validate/linux-session.sh` (fake Steam reaper, recording and library screenshots, close-window survival, game exit and explicit Quit). 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.
|
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.
|
||||||
|
|
||||||
|
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.
|
Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms.
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ serde_json = "1"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
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"] }
|
rustix = { version = "1", features = ["process", "event"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.55"
|
winreg = "0.55"
|
||||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp"] }
|
windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", "Win32_Foundation", "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,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,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(())
|
||||||
|
}
|
||||||
@@ -15,3 +15,8 @@ pub mod store;
|
|||||||
mod windows_process;
|
mod windows_process;
|
||||||
|
|
||||||
pub mod segments;
|
pub mod segments;
|
||||||
|
|
||||||
|
pub mod cleanup;
|
||||||
|
pub mod clips;
|
||||||
|
|
||||||
|
pub mod hotkeys;
|
||||||
|
|||||||
@@ -115,13 +115,19 @@ pub fn export_range(
|
|||||||
keyframe_interval: Duration,
|
keyframe_interval: Duration,
|
||||||
session_length: Duration,
|
session_length: Duration,
|
||||||
) -> (Duration, Duration) {
|
) -> (Duration, Duration) {
|
||||||
let interval = keyframe_interval.as_millis().max(1);
|
let interval = keyframe_interval.as_nanos().max(1);
|
||||||
let floor =
|
let duration = |nanos: u128| {
|
||||||
|time: Duration| Duration::from_millis((time.as_millis() / interval * interval) as u64);
|
Duration::new(
|
||||||
let ceil = |time: Duration| {
|
(nanos / 1_000_000_000) as u64,
|
||||||
Duration::from_millis((time.as_millis().div_ceil(interval) * interval) as u64)
|
(nanos % 1_000_000_000) as u32,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
(floor(start), ceil(end).min(session_length))
|
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`.
|
/// `1:02:03` or `2:03`.
|
||||||
@@ -348,6 +354,15 @@ mod tests {
|
|||||||
fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() {
|
fn export_ranges_grow_to_keyframes_and_stop_at_the_session_end() {
|
||||||
let ms = Duration::from_millis;
|
let ms = Duration::from_millis;
|
||||||
let second = Duration::from_secs(1);
|
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!(
|
assert_eq!(
|
||||||
export_range(ms(10_400), ms(20_100), second, ms(60_000)),
|
export_range(ms(10_400), ms(20_100), second, ms(60_000)),
|
||||||
(ms(10_000), ms(21_000))
|
(ms(10_000), ms(21_000))
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ pub struct Settings {
|
|||||||
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.
|
||||||
@@ -50,6 +53,9 @@ impl Default for Settings {
|
|||||||
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,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ pub struct Segment {
|
|||||||
/// Cumulative dropped frames in this run when the segment completed.
|
/// Cumulative dropped frames in this run when the segment completed.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dropped_frames: u64,
|
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 {
|
fn available_default() -> bool {
|
||||||
@@ -181,15 +184,24 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn library(&self, budget: u64) -> io::Result<Library> {
|
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);
|
let mut library = Library::empty(budget);
|
||||||
for metadata in self.read()? {
|
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 {
|
for segment in &metadata.segments {
|
||||||
let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path))
|
let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path))
|
||||||
.map_or(0, |f| f.len());
|
.map_or(0, |f| f.len());
|
||||||
if metadata.session.bookmarks.iter().any(|b| {
|
if crate::cleanup::protected(segment, &metadata.session.bookmarks, &leased) {
|
||||||
let (start, end) = b.range(metadata.session.length);
|
|
||||||
b.protected && start < segment.end && end > segment.start
|
|
||||||
}) {
|
|
||||||
library.storage.protected_bytes += bytes;
|
library.storage.protected_bytes += bytes;
|
||||||
} else {
|
} else {
|
||||||
library.storage.unprotected_bytes += bytes;
|
library.storage.unprotected_bytes += bytes;
|
||||||
@@ -197,6 +209,10 @@ impl Store {
|
|||||||
}
|
}
|
||||||
library.sessions.push(metadata.session);
|
library.sessions.push(metadata.session);
|
||||||
}
|
}
|
||||||
|
for saved in self.clips()? {
|
||||||
|
library.storage.clip_bytes += saved.clip.size_bytes;
|
||||||
|
library.clips.push(saved.clip);
|
||||||
|
}
|
||||||
library
|
library
|
||||||
.sessions
|
.sessions
|
||||||
.sort_by_key(|s| std::cmp::Reverse(s.started));
|
.sort_by_key(|s| std::cmp::Reverse(s.started));
|
||||||
@@ -264,6 +280,82 @@ impl Metadata {
|
|||||||
.collect()
|
.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(
|
pub fn update_run(
|
||||||
&mut self,
|
&mut self,
|
||||||
span_base: usize,
|
span_base: usize,
|
||||||
@@ -314,6 +406,7 @@ mod tests {
|
|||||||
frame_start: start * 60,
|
frame_start: start * 60,
|
||||||
frames_per_second: 60,
|
frames_per_second: 60,
|
||||||
dropped_frames: 0,
|
dropped_frames: 0,
|
||||||
|
audio_start_samples: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +426,63 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn times_map_to_segment_offsets_and_gaps_have_no_footage() {
|
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.
|
// 0-2 s, 2-4 s, a gap, 6-8 s, and an unavailable tail at 8-10 s.
|
||||||
@@ -450,6 +600,7 @@ mod tests {
|
|||||||
frame_start: 0,
|
frame_start: 0,
|
||||||
frames_per_second: 60,
|
frames_per_second: 60,
|
||||||
dropped_frames: 0,
|
dropped_frames: 0,
|
||||||
|
audio_start_samples: Vec::new(),
|
||||||
});
|
});
|
||||||
fs::write(
|
fs::write(
|
||||||
store.directory(metadata.session.id).join("segment.mkv"),
|
store.directory(metadata.session.id).join("segment.mkv"),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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
|
clipbin-obs.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::{icon, meter},
|
components::{ButtonStyle, button, icon, meter},
|
||||||
screens,
|
screens,
|
||||||
text_field::{Committed, TextField},
|
text_field::{Committed, TextField},
|
||||||
theme,
|
theme,
|
||||||
@@ -62,6 +62,7 @@ pub struct ClipbinApp {
|
|||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
pub recorder: Recorder,
|
pub recorder: Recorder,
|
||||||
pub editor: Editor,
|
pub editor: Editor,
|
||||||
|
pub playback: crate::playback::Playback,
|
||||||
/// A short message for actions that are not connected yet.
|
/// A short message for actions that are not connected yet.
|
||||||
pub notice: Option<SharedString>,
|
pub notice: Option<SharedString>,
|
||||||
pub drag: Option<Drag>,
|
pub drag: Option<Drag>,
|
||||||
@@ -69,6 +70,9 @@ pub struct ClipbinApp {
|
|||||||
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||||
pub fields: SettingsFields,
|
pub fields: SettingsFields,
|
||||||
pub service: crate::recorder_service::Client,
|
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.
|
/// The typed settings and the fields that edit them.
|
||||||
@@ -201,7 +205,7 @@ impl ClipbinApp {
|
|||||||
let library = if sample {
|
let library = if sample {
|
||||||
Library::sample(SystemTime::now())
|
Library::sample(SystemTime::now())
|
||||||
} else {
|
} else {
|
||||||
Library::empty(settings.unprotected_session_limit_bytes)
|
service.snapshot().library
|
||||||
};
|
};
|
||||||
let recorder = match (sample, std::env::var("CLIPBIN_SAMPLE_STATUS").as_deref()) {
|
let recorder = match (sample, std::env::var("CLIPBIN_SAMPLE_STATUS").as_deref()) {
|
||||||
(_, Ok("failed")) => Recorder {
|
(_, Ok("failed")) => Recorder {
|
||||||
@@ -234,11 +238,15 @@ impl ClipbinApp {
|
|||||||
selection: (Duration::ZERO, Duration::ZERO),
|
selection: (Duration::ZERO, Duration::ZERO),
|
||||||
playing: false,
|
playing: false,
|
||||||
},
|
},
|
||||||
|
playback: crate::playback::Playback::default(),
|
||||||
notice: None,
|
notice: None,
|
||||||
drag: None,
|
drag: None,
|
||||||
timeline_bounds: Rc::default(),
|
timeline_bounds: Rc::default(),
|
||||||
fields,
|
fields,
|
||||||
service,
|
service,
|
||||||
|
hotkeys: "Registering global hotkeys…".into(),
|
||||||
|
failed_exports: 0,
|
||||||
|
last_service_notice: None,
|
||||||
};
|
};
|
||||||
match std::env::var("CLIPBIN_ROUTE").as_deref() {
|
match std::env::var("CLIPBIN_ROUTE").as_deref() {
|
||||||
Ok("clips") => app.route = Route::Clips,
|
Ok("clips") => app.route = Route::Clips,
|
||||||
@@ -262,6 +270,25 @@ impl ClipbinApp {
|
|||||||
if entity
|
if entity
|
||||||
.update(cx, |app, cx| {
|
.update(cx, |app, cx| {
|
||||||
let snapshot = app.service.snapshot();
|
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
|
if app.recorder.status != snapshot.status
|
||||||
|| app.recorder.game != snapshot.game
|
|| app.recorder.game != snapshot.game
|
||||||
|| app.library != snapshot.library
|
|| app.library != snapshot.library
|
||||||
@@ -287,6 +314,7 @@ impl ClipbinApp {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
Self::start_playback_updates(window, cx);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +326,11 @@ impl ClipbinApp {
|
|||||||
self.notice = None;
|
self.notice = None;
|
||||||
match route {
|
match route {
|
||||||
Route::Session(id) => self.open_session(id),
|
Route::Session(id) => self.open_session(id),
|
||||||
route => self.route = route,
|
route => {
|
||||||
|
self.playback.player.close();
|
||||||
|
self.editor.playing = false;
|
||||||
|
self.route = route;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -322,6 +354,19 @@ impl ClipbinApp {
|
|||||||
selection,
|
selection,
|
||||||
playing: false,
|
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);
|
self.route = Route::Session(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +483,30 @@ impl ClipbinApp {
|
|||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.child(div().flex_1())
|
.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.recorder_card())
|
||||||
.child(self.storage_card())
|
.child(self.storage_card())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ use gpui::{
|
|||||||
mod app;
|
mod app;
|
||||||
mod assets;
|
mod assets;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod exports;
|
||||||
|
mod playback;
|
||||||
mod recorder_service;
|
mod recorder_service;
|
||||||
mod screens;
|
mod screens;
|
||||||
|
mod storage;
|
||||||
mod text_field;
|
mod text_field;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod timeline_view;
|
mod timeline_view;
|
||||||
@@ -47,6 +50,7 @@ fn main() {
|
|||||||
});
|
});
|
||||||
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||||
text_field::bind_keys(cx);
|
text_field::bind_keys(cx);
|
||||||
|
playback::bind_keys(cx);
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
||||||
let result = cx.open_window(
|
let result = cx.open_window(
|
||||||
@@ -65,11 +69,14 @@ fn main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(error) = result {
|
match result {
|
||||||
|
Ok(window) => playback::bind_actions(window, cx),
|
||||||
|
Err(error) => {
|
||||||
eprintln!("Could not open Clipbin: {error}");
|
eprintln!("Could not open Clipbin: {error}");
|
||||||
cx.quit();
|
cx.quit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
use clipbin_core::{
|
use clipbin_core::{
|
||||||
Library, RecordingStatus, Settings,
|
Library, RecordingStatus, Settings,
|
||||||
games::{self, ExitWatch, Game},
|
games::{self, ExitWatch, Game},
|
||||||
library::{Span, SpanKind},
|
library::{Bookmark, Span, SpanKind},
|
||||||
store::{self, Metadata, Segment, Store, frame_offset, segment_offset},
|
store::{self, Metadata, Segment, Store, frame_offset, segment_offset},
|
||||||
};
|
};
|
||||||
use clipbin_obs::{
|
use clipbin_obs::{
|
||||||
@@ -25,6 +25,10 @@ pub struct Snapshot {
|
|||||||
pub library: Library,
|
pub library: Library,
|
||||||
pub restore_token: Option<String>,
|
pub restore_token: Option<String>,
|
||||||
pub exited: bool,
|
pub exited: bool,
|
||||||
|
pub notice: Option<String>,
|
||||||
|
pub metadata: Vec<Metadata>,
|
||||||
|
pub hotkeys: String,
|
||||||
|
pub failed_exports: usize,
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
@@ -36,6 +40,15 @@ enum Command {
|
|||||||
ChooseSource,
|
ChooseSource,
|
||||||
WindowClosed,
|
WindowClosed,
|
||||||
Quit,
|
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 {
|
impl Client {
|
||||||
pub fn start(
|
pub fn start(
|
||||||
@@ -51,12 +64,17 @@ impl Client {
|
|||||||
library: Library::empty(settings.unprotected_session_limit_bytes),
|
library: Library::empty(settings.unprotected_session_limit_bytes),
|
||||||
restore_token: settings.portal_restore_token.clone(),
|
restore_token: settings.portal_restore_token.clone(),
|
||||||
exited: false,
|
exited: false,
|
||||||
|
notice: None,
|
||||||
|
metadata: Vec::new(),
|
||||||
|
hotkeys: "Registering global hotkeys…".into(),
|
||||||
|
failed_exports: 0,
|
||||||
}));
|
}));
|
||||||
let state = snapshot.clone();
|
let state = snapshot.clone();
|
||||||
|
let action_sender = commands.clone();
|
||||||
let worker = thread::Builder::new()
|
let worker = thread::Builder::new()
|
||||||
.name("clipbin-recorder".into())
|
.name("clipbin-recorder".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(error) = worker(settings, receiver, &state) {
|
if let Err(error) = worker(settings, receiver, &state, action_sender) {
|
||||||
state.lock().unwrap().status = RecordingStatus::Failed { reason: error };
|
state.lock().unwrap().status = RecordingStatus::Failed { reason: error };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -70,6 +88,47 @@ impl Client {
|
|||||||
pub fn settings(&self, settings: Settings) {
|
pub fn settings(&self, settings: Settings) {
|
||||||
let _ = self.commands.send(Command::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) {
|
pub fn choose_source(&self) {
|
||||||
let _ = self.commands.send(Command::ChooseSource);
|
let _ = self.commands.send(Command::ChooseSource);
|
||||||
}
|
}
|
||||||
@@ -93,8 +152,10 @@ struct Active {
|
|||||||
run: u32,
|
run: u32,
|
||||||
run_base: Option<Duration>,
|
run_base: Option<Duration>,
|
||||||
span_base: usize,
|
span_base: usize,
|
||||||
|
span_start: Duration,
|
||||||
last_frames: u64,
|
last_frames: u64,
|
||||||
last_dropped: u64,
|
last_dropped: u64,
|
||||||
|
audio_samples: Vec<u64>,
|
||||||
fps: u32,
|
fps: u32,
|
||||||
stopping: bool,
|
stopping: bool,
|
||||||
ending: bool,
|
ending: bool,
|
||||||
@@ -137,7 +198,14 @@ impl Active {
|
|||||||
self.run += 1;
|
self.run += 1;
|
||||||
self.run_base = None;
|
self.run_base = None;
|
||||||
self.span_base = self.metadata.session.spans.len();
|
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.last_frames = 0;
|
||||||
|
self.audio_samples.clear();
|
||||||
self.last_dropped = 0;
|
self.last_dropped = 0;
|
||||||
self.tracker = StatusTracker::new(
|
self.tracker = StatusTracker::new(
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
@@ -178,6 +246,11 @@ impl Active {
|
|||||||
{
|
{
|
||||||
segment.end = end.max(segment.start);
|
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> {
|
fn finish_run(&mut self, store: &Store) -> Result<(), String> {
|
||||||
let mut empty_tail = false;
|
let mut empty_tail = false;
|
||||||
@@ -210,6 +283,14 @@ impl Active {
|
|||||||
if let Some(segment) = self.metadata.segments.last_mut()
|
if let Some(segment) = self.metadata.segments.last_mut()
|
||||||
&& !segment.complete
|
&& !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.complete = true;
|
||||||
segment.dropped_frames = self.last_dropped;
|
segment.dropped_frames = self.last_dropped;
|
||||||
if self.last_dropped > 0 {
|
if self.last_dropped > 0 {
|
||||||
@@ -220,6 +301,12 @@ impl Active {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.span_base = self.metadata.session.spans.len();
|
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.run_base = None;
|
||||||
self.recording = None;
|
self.recording = None;
|
||||||
self.stopping = false;
|
self.stopping = false;
|
||||||
@@ -254,9 +341,14 @@ fn worker(
|
|||||||
mut settings: Settings,
|
mut settings: Settings,
|
||||||
commands: Receiver<Command>,
|
commands: Receiver<Command>,
|
||||||
snapshot: &Arc<Mutex<Snapshot>>,
|
snapshot: &Arc<Mutex<Snapshot>>,
|
||||||
|
action_sender: Sender<Command>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store = Store::discover().map_err(|e| e.to_string())?;
|
let store = Store::discover().map_err(|e| e.to_string())?;
|
||||||
store.recover().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
|
snapshot.lock().unwrap().library = store
|
||||||
.library(settings.unprotected_session_limit_bytes)
|
.library(settings.unprotected_session_limit_bytes)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -273,6 +365,24 @@ fn worker(
|
|||||||
None
|
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 active: Option<Active> = None;
|
||||||
let mut quitting = false;
|
let mut quitting = false;
|
||||||
let mut finish_after_session = false;
|
let mut finish_after_session = false;
|
||||||
@@ -280,6 +390,9 @@ fn worker(
|
|||||||
let mut next_poll = Instant::now();
|
let mut next_poll = Instant::now();
|
||||||
let mut next_save = Instant::now();
|
let mut next_save = Instant::now();
|
||||||
let mut previous_status = None;
|
let mut previous_status = None;
|
||||||
|
let mut exports = crate::exports::Exports::load(&store)?;
|
||||||
|
let mut editing = None;
|
||||||
|
let mut next_cleanup = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
match commands.recv_timeout(Duration::from_millis(200)) {
|
match commands.recv_timeout(Duration::from_millis(200)) {
|
||||||
Ok(Command::WindowClosed) => {
|
Ok(Command::WindowClosed) => {
|
||||||
@@ -299,6 +412,12 @@ fn worker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Command::Settings(new)) => {
|
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;
|
settings = new;
|
||||||
snapshot.lock().unwrap().library.storage.budget_bytes =
|
snapshot.lock().unwrap().library.storage.budget_bytes =
|
||||||
settings.unprotected_session_limit_bytes;
|
settings.unprotected_session_limit_bytes;
|
||||||
@@ -307,6 +426,108 @@ fn worker(
|
|||||||
}
|
}
|
||||||
snapshot.lock().unwrap().restore_token = settings.portal_restore_token.clone();
|
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) => {
|
Ok(Command::ChooseSource) => {
|
||||||
settings.portal_restore_token = None;
|
settings.portal_restore_token = None;
|
||||||
if let Err(e) = store::save_settings(&settings) {
|
if let Err(e) = store::save_settings(&settings) {
|
||||||
@@ -327,11 +548,11 @@ fn worker(
|
|||||||
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
|
Err(mpsc::RecvTimeoutError::Disconnected) => quitting = true,
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
}
|
}
|
||||||
if (quitting || finish_after_session) && active.is_none() {
|
if (quitting || finish_after_session) && active.is_none() && !exports.busy() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if active.is_none() && !quitting && now >= next_scan {
|
if active.is_none() && !quitting && !finish_after_session && now >= next_scan {
|
||||||
next_scan = now + Duration::from_secs(1);
|
next_scan = now + Duration::from_secs(1);
|
||||||
match games::discover() {
|
match games::discover() {
|
||||||
Ok(games) => {
|
Ok(games) => {
|
||||||
@@ -354,8 +575,10 @@ fn worker(
|
|||||||
run: 0,
|
run: 0,
|
||||||
run_base: None,
|
run_base: None,
|
||||||
span_base: 0,
|
span_base: 0,
|
||||||
|
span_start: Duration::ZERO,
|
||||||
last_frames: 0,
|
last_frames: 0,
|
||||||
last_dropped: 0,
|
last_dropped: 0,
|
||||||
|
audio_samples: Vec::new(),
|
||||||
fps: settings.frames_per_second,
|
fps: settings.frames_per_second,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
ending: false,
|
ending: false,
|
||||||
@@ -479,6 +702,30 @@ fn worker(
|
|||||||
if let Some(previous) = a.metadata.segments.last_mut()
|
if let Some(previous) = a.metadata.segments.last_mut()
|
||||||
&& !previous.complete
|
&& !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.end = offset;
|
||||||
previous.complete = true;
|
previous.complete = true;
|
||||||
previous.dropped_frames = dropped_frames;
|
previous.dropped_frames = dropped_frames;
|
||||||
@@ -503,6 +750,7 @@ fn worker(
|
|||||||
frame_start: total_frames.saturating_sub(1),
|
frame_start: total_frames.saturating_sub(1),
|
||||||
frames_per_second: a.fps,
|
frames_per_second: a.fps,
|
||||||
dropped_frames: 0,
|
dropped_frames: 0,
|
||||||
|
audio_start_samples: a.audio_samples.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
RecorderEvent::OutputStopped { code, last_error } => {
|
RecorderEvent::OutputStopped { code, last_error } => {
|
||||||
@@ -542,15 +790,69 @@ fn worker(
|
|||||||
drop(state);
|
drop(state);
|
||||||
snapshot.lock().unwrap().game = None;
|
snapshot.lock().unwrap().game = None;
|
||||||
active = None;
|
active = None;
|
||||||
|
next_cleanup = now;
|
||||||
} else {
|
} else {
|
||||||
store.save(&a.metadata).map_err(|e| e.to_string())?;
|
store.save(&a.metadata).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
match store.library(settings.unprotected_session_limit_bytes) {
|
match store.library_with_protection(
|
||||||
Ok(library) => snapshot.lock().unwrap().library = library,
|
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),
|
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();
|
let status = snapshot.lock().unwrap().status.clone();
|
||||||
if previous_status.as_ref() != Some(&status) {
|
if previous_status.as_ref() != Some(&status) {
|
||||||
eprintln!("recorder_status={status:?}");
|
eprintln!("recorder_status={status:?}");
|
||||||
|
|||||||
@@ -32,20 +32,50 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
Some("Open folder"),
|
Some("Open folder"),
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||||
app.notice =
|
app.service.open_clip(None);
|
||||||
Some("Opening the clips folder comes with the recorder integration.".into());
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}))
|
}))
|
||||||
.into_any_element(),
|
.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() {
|
if clips.is_empty() {
|
||||||
return div()
|
return div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_6()
|
.gap_6()
|
||||||
.child(header)
|
.child(header)
|
||||||
|
.children(failed_actions)
|
||||||
.child(empty_state(
|
.child(empty_state(
|
||||||
"scissors",
|
"scissors",
|
||||||
"No clips yet",
|
"No clips yet",
|
||||||
@@ -56,12 +86,13 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
|
|
||||||
let now = app.now();
|
let now = app.now();
|
||||||
let cards = clips.iter().map(|clip| {
|
let cards = clips.iter().map(|clip| {
|
||||||
|
let id = clip.id;
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.child(
|
.child(
|
||||||
thumbnail(&clip.game).child(
|
thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child(
|
||||||
corner()
|
corner()
|
||||||
.bottom_2p5()
|
.bottom_2p5()
|
||||||
.right_2p5()
|
.right_2p5()
|
||||||
@@ -104,10 +135,22 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
)
|
)
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.on_click(cx.listener(
|
.on_click(cx.listener(
|
||||||
|app, _: &ClickEvent, _, cx| {
|
move |app, _: &ClickEvent, _, cx| {
|
||||||
app.notice = Some(
|
app.service.open_clip(Some(id));
|
||||||
"Opening clips comes with the recorder integration.".into(),
|
cx.notify();
|
||||||
);
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
button(
|
||||||
|
("delete-clip", id),
|
||||||
|
ButtonStyle::Ghost,
|
||||||
|
None,
|
||||||
|
Some("Delete"),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(
|
||||||
|
move |app, _: &ClickEvent, _, cx| {
|
||||||
|
app.service.delete_clip(id);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
@@ -120,6 +163,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_6()
|
.gap_6()
|
||||||
.child(header)
|
.child(header)
|
||||||
|
.children(failed_actions)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.grid()
|
.grid()
|
||||||
|
|||||||
@@ -6,15 +6,14 @@ use clipbin_core::library::{
|
|||||||
Session, SpanKind, export_range, format_ago, format_clock, format_length,
|
Session, SpanKind, export_range, format_ago, format_clock, format_length,
|
||||||
};
|
};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, IntoElement, MouseButton,
|
AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, ImageSource, IntoElement,
|
||||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, canvas, div,
|
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, Pixels, Point,
|
||||||
pattern_slash, prelude::*, px, relative,
|
ScrollWheelEvent, canvas, div, img, pattern_slash, prelude::*, px, relative,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{ClipbinApp, Drag, Route},
|
app::{ClipbinApp, Drag, Route},
|
||||||
components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row},
|
components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row},
|
||||||
screens::backdrop,
|
|
||||||
theme,
|
theme,
|
||||||
timeline_view::TimelineView,
|
timeline_view::TimelineView,
|
||||||
};
|
};
|
||||||
@@ -73,37 +72,54 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
);
|
);
|
||||||
|
|
||||||
let preview = aspect_ratio(div(), 16. / 9.)
|
let preview = aspect_ratio(div(), 16. / 9.)
|
||||||
|
.h(px(app.playback.preview_height))
|
||||||
|
.flex_shrink_0()
|
||||||
.relative()
|
.relative()
|
||||||
.w_full()
|
.w_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.rounded_lg()
|
.rounded_lg()
|
||||||
.bg(gpui::black())
|
.bg(gpui::black())
|
||||||
.child(backdrop(&session.game).absolute().size_full().opacity(0.55))
|
.children(app.playback.image.clone().map(|image| {
|
||||||
.child(
|
img(ImageSource::Render(image))
|
||||||
|
.absolute()
|
||||||
|
.size_full()
|
||||||
|
.object_fit(ObjectFit::Contain)
|
||||||
|
}))
|
||||||
|
.children(app.playback.message.clone().map(|message| {
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.size_full()
|
.size_full()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.size(px(56.))
|
|
||||||
.rounded_full()
|
|
||||||
.bg(theme::overlay())
|
|
||||||
.child(icon("play").size_6().text_color(theme::foreground())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(theme::muted())
|
.text_color(theme::muted())
|
||||||
.child("Preview arrives with playback"),
|
.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(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -126,6 +142,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
.map(|b| b.at);
|
.map(|b| b.at);
|
||||||
let transport = div()
|
let transport = div()
|
||||||
.flex()
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
@@ -136,8 +153,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
||||||
app.notice =
|
app.toggle_playback();
|
||||||
Some("Playback isn't connected yet; it comes with the decoder.".into());
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -150,7 +166,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
)
|
)
|
||||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
if let Some(at) = previous_bookmark {
|
if let Some(at) = previous_bookmark {
|
||||||
app.editor.playhead = at;
|
app.seek_playback(at);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
@@ -164,7 +180,7 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
)
|
)
|
||||||
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
if let Some(at) = next_bookmark {
|
if let Some(at) = next_bookmark {
|
||||||
app.editor.playhead = at;
|
app.seek_playback(at);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
@@ -187,6 +203,52 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
.child(format!("/ {}", format_clock(length))),
|
.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_1())
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -210,9 +272,18 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
.gap_3()
|
.gap_3()
|
||||||
.child(preview)
|
.child(preview)
|
||||||
.child(transport)
|
.child(transport)
|
||||||
|
.children(
|
||||||
|
app.playback
|
||||||
|
.audio_error
|
||||||
|
.clone()
|
||||||
|
.map(|error| div().text_xs().text_color(theme::warning()).child(error)),
|
||||||
|
)
|
||||||
.child(timeline(app, session, cx));
|
.child(timeline(app, session, cx));
|
||||||
|
|
||||||
let (export_start, export_end) = export_range(start, end, KEYFRAME_INTERVAL, length);
|
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")
|
let clip_panel = panel("CLIP")
|
||||||
.child(row("Start", value(format_clock(start))))
|
.child(row("Start", value(format_clock(start))))
|
||||||
.child(row("End", value(format_clock(end))))
|
.child(row("End", value(format_clock(end))))
|
||||||
@@ -243,10 +314,10 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
)
|
)
|
||||||
.child(row(
|
.child(row(
|
||||||
"Audio",
|
"Audio",
|
||||||
value(if app.settings.microphone_enabled {
|
value(if app.settings.export_separate_audio {
|
||||||
"Game + mic, mixed".to_owned()
|
"System + mic, if recorded".to_owned()
|
||||||
} else {
|
} else {
|
||||||
"Game audio".to_owned()
|
"Combined source audio".to_owned()
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.child(
|
.child(
|
||||||
@@ -258,11 +329,8 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
Some("Export clip"),
|
Some("Export clip"),
|
||||||
)
|
)
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.on_click(cx.listener(|app, _: &ClickEvent, _, cx| {
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
app.notice = Some(
|
app.service.export(id, start, end);
|
||||||
"Exporting isn't connected yet; it comes with the recorder integration."
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
@@ -319,8 +387,25 @@ pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyEle
|
|||||||
"Released"
|
"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| {
|
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
|
||||||
app.editor.playhead = at;
|
app.seek_playback(at);
|
||||||
app.editor.selection = range;
|
app.editor.selection = range;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}))
|
}))
|
||||||
@@ -529,7 +614,23 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
|
|||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
.child(
|
||||||
canvas(move |area, _, _| bounds.set(Some(area)), |_, _, _, _| {})
|
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()
|
.absolute()
|
||||||
.size_full(),
|
.size_full(),
|
||||||
)
|
)
|
||||||
@@ -565,7 +666,7 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
|
|||||||
} else if near(end) {
|
} else if near(end) {
|
||||||
Drag::SelectionEnd
|
Drag::SelectionEnd
|
||||||
} else {
|
} else {
|
||||||
app.editor.playhead = time;
|
app.seek_playback(time);
|
||||||
Drag::Playhead
|
Drag::Playhead
|
||||||
});
|
});
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -583,7 +684,7 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
|
|||||||
let (start, end) = app.editor.selection;
|
let (start, end) = app.editor.selection;
|
||||||
let minimum = Duration::from_secs(1);
|
let minimum = Duration::from_secs(1);
|
||||||
match drag {
|
match drag {
|
||||||
Drag::Playhead => app.editor.playhead = time,
|
Drag::Playhead => app.seek_playback(time),
|
||||||
Drag::SelectionStart => {
|
Drag::SelectionStart => {
|
||||||
app.editor.selection.0 = time.min(end.saturating_sub(minimum))
|
app.editor.selection.0 = time.min(end.saturating_sub(minimum))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,20 @@ use crate::{components::aspect_ratio, theme};
|
|||||||
|
|
||||||
/// A 16:9 thumbnail. Until frames are decoded, each game gets its own colour
|
/// A 16:9 thumbnail. Until frames are decoded, each game gets its own colour
|
||||||
/// with its initials, so cards stay recognisable.
|
/// with its initials, so cards stay recognisable.
|
||||||
pub fn thumbnail(game: &str) -> Div {
|
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 tint = game_tint(game);
|
||||||
let initials: String = game
|
let initials: String = game
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.group("card")
|
.group("card")
|
||||||
.child(
|
.child(
|
||||||
thumbnail(&session.game)
|
thumbnail(&session.game, app.playback.thumbnails.get(&session.id))
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(gpui::transparent_black())
|
.border_color(gpui::transparent_black())
|
||||||
.group_hover("card", |style| {
|
.group_hover("card", |style| {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
section("volume-2", "Audio")
|
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(
|
.child(setting(
|
||||||
"Game audio",
|
"Game audio",
|
||||||
"Everything your speakers play.",
|
"Everything your speakers play.",
|
||||||
@@ -121,7 +122,9 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
|
|||||||
.child(labelled("Before", &app.fields.bookmark_before))
|
.child(labelled("Before", &app.fields.bookmark_before))
|
||||||
.child(labelled("After", &app.fields.bookmark_after)),
|
.child(labelled("After", &app.fields.bookmark_after)),
|
||||||
))
|
))
|
||||||
.child(setting("Hotkeys", "Global hotkeys arrive with the hotkey integration.", value("Not set yet".into()))),
|
.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(
|
.child(
|
||||||
section("hard-drive", "Storage")
|
section("hard-drive", "Storage")
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
clipbin-core.workspace = true
|
clipbin-core.workspace = true
|
||||||
ffmpeg-next = { version = "9", default-features = false, features = ["codec", "format", "software-scaling", "software-resampling"] }
|
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"] }
|
||||||
|
|
||||||
[lints]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
workspace = true
|
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()
|
||||||
|
}
|
||||||
@@ -67,3 +67,14 @@ mod tests {
|
|||||||
println!("{probe:?}");
|
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();
|
||||||
|
}
|
||||||
@@ -262,3 +262,238 @@ active, it exits immediately. It stops discovering new games once closed,
|
|||||||
so there is no indefinitely running invisible app. Reopening the window and
|
so there is no indefinitely running invisible app. Reopening the window and
|
||||||
persistent background discovery need the future tray integration. Explicit
|
persistent background discovery need the future tray integration. Explicit
|
||||||
Quit/Ctrl+Q stops and finalises the current recording immediately.
|
Quit/Ctrl+Q stops and finalises the current recording immediately.
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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[@]}"
|
||||||
|
}
|
||||||
Executable
+66
@@ -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')
|
||||||
Executable
+144
@@ -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
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# X11 native grabs under the rig's Xwayland; portal actions need real Hyprland.
|
||||||
|
set -euo pipefail
|
||||||
|
command -v xdotool >/dev/null || { echo "Install xdotool to inject X11 hotkeys" >&2; exit 127; }
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cargo build --locked -p clipbin-core --example hotkeys --manifest-path "${repo}/Cargo.toml"
|
||||||
|
export CLIPBIN_HOTKEY_REPO="${repo}"
|
||||||
|
"${repo}/scripts/rig/headless.sh" --x11 bash <<'INNER'
|
||||||
|
set -euo pipefail
|
||||||
|
log="${CLIPBIN_RIG_DIR}/hotkeys.log"
|
||||||
|
env -u WAYLAND_DISPLAY "${CLIPBIN_HOTKEY_REPO}/target/debug/examples/hotkeys" > "${log}" 2>&1 & helper=$!
|
||||||
|
trap 'kill "${helper}" 2>/dev/null || true' EXIT
|
||||||
|
sleep 2
|
||||||
|
swaymsg '[title="clipbin-test-game"] focus' > /dev/null
|
||||||
|
xdotool key ctrl+shift+F9
|
||||||
|
sleep 0.3
|
||||||
|
xdotool key ctrl+shift+F10
|
||||||
|
if ! wait "${helper}"; then cat "${log}"; exit 1; fi
|
||||||
|
cat "${log}"
|
||||||
|
grep -q 'PASS native clip and bookmark hotkeys' "${log}"
|
||||||
|
INNER
|
||||||
Executable
+152
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Playback, active browsing, real thumbnails and audio in the private desktop rig.
|
||||||
|
set -euo pipefail
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
out="${1:-${repo}/target/playback-validation}"
|
||||||
|
mkdir -p "${out}"
|
||||||
|
out="$(cd "${out}" && pwd)"
|
||||||
|
cargo build --locked --workspace --manifest-path "${repo}/Cargo.toml"
|
||||||
|
cargo build --locked -p clipbin-media --example playback-check --manifest-path "${repo}/Cargo.toml"
|
||||||
|
export CLIPBIN_PLAYBACK_REPO="${repo}" CLIPBIN_PLAYBACK_OUT="${out}"
|
||||||
|
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
|
||||||
|
set -euo pipefail
|
||||||
|
repo="${CLIPBIN_PLAYBACK_REPO}" out="${CLIPBIN_PLAYBACK_OUT}"
|
||||||
|
source "${repo}/scripts/rig/keys.sh"
|
||||||
|
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" CLIPBIN_TRACE_FRAMES=1
|
||||||
|
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
|
||||||
|
# ALSA's PipeWire plugin is cpal's fallback on Linux; there is no hardware card.
|
||||||
|
cat > "${out}/alsa.conf" <<'ALSA'
|
||||||
|
</usr/share/alsa/alsa.conf>
|
||||||
|
pcm.!default { type pipewire }
|
||||||
|
ctl.!default { type pipewire }
|
||||||
|
ALSA
|
||||||
|
export ALSA_CONFIG_PATH="${out}/alsa.conf"
|
||||||
|
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null
|
||||||
|
# Timeline bounds come from GPUI's trace rather than guessed screen positions.
|
||||||
|
pointer_time() {
|
||||||
|
python3 - "$1" "$2" "$3" "${XDG_DATA_HOME}" <<'PYPOINTER'
|
||||||
|
import pathlib,re,json,sys
|
||||||
|
log=pathlib.Path(sys.argv[1]).read_text()
|
||||||
|
x,y,w,h=map(float,re.findall(r'playback_timeline x=([\d.]+) y=([\d.]+) width=([\d.]+) height=([\d.]+)',log)[-1])
|
||||||
|
p=max(pathlib.Path(sys.argv[4]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
|
||||||
|
m=json.loads(p.read_text());length=m['session']['length'];length=length['secs']+length['nanos']/1e9
|
||||||
|
if sys.argv[2] in ('gap','after-gap'):
|
||||||
|
segment=next(s for s in m['segments'] if s['complete'] and not s['available'])
|
||||||
|
def seconds(d):return d['secs']+d['nanos']/1e9
|
||||||
|
at=(seconds(segment['start'])+seconds(segment['end']))/2 if sys.argv[2]=='gap' else seconds(segment['end'])+.5
|
||||||
|
else:at=float(sys.argv[2])
|
||||||
|
print(f"move {round(280+x+w*at/length)} {round(110+y+h/2)}\nsleep .15\n{sys.argv[3]}\nsleep .15")
|
||||||
|
PYPOINTER
|
||||||
|
}
|
||||||
|
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
|
||||||
|
"${repo}/target/debug/clipbin-desktop" > "${out}/active.log" 2>&1 & app=$!
|
||||||
|
trap 'kill "${app:-}" "${game:-}" "${audio_capture:-}" 2>/dev/null || true' EXIT
|
||||||
|
sleep 3
|
||||||
|
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 280 110, 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}/active.log"; then break; fi; sleep 1; done
|
||||||
|
grep -q recorder_status=Recording "${out}/active.log"
|
||||||
|
sleep 8
|
||||||
|
grim "${out}/sessions-thumbnails.png"
|
||||||
|
# Open the first session card, seek near the start, and play while recording continues.
|
||||||
|
printf 'move 670 330\nsleep .2\ndown\nsleep .1\nup\nsleep 1\n' | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
grim "${out}/editor-open.png"
|
||||||
|
pointer_time "${out}/active.log" 1 click | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
# GPUI/Wayland needs the virtual keyboard keymap to settle and a held key;
|
||||||
|
# instantaneous wtype press/release occasionally disappears before dispatch.
|
||||||
|
rig_key space
|
||||||
|
sleep 1
|
||||||
|
grim "${out}/editor-playing-1.png"
|
||||||
|
sleep 2
|
||||||
|
grim "${out}/editor-playing-2.png"
|
||||||
|
python3 - "${XDG_DATA_HOME}" <<'PY'
|
||||||
|
import pathlib,json,sys
|
||||||
|
p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
|
||||||
|
m=json.loads(p.read_text());assert m['session']['active'];assert len([s for s in m['segments'] if s['complete'] and s['available']])>=4
|
||||||
|
assert list(p.parent.rglob('*.thumbnail.png')), 'thumbnail worker produced no cached frame'
|
||||||
|
print('PASS playback left recording active and generated real thumbnails')
|
||||||
|
PY
|
||||||
|
kill "${game}"; wait "${game}" 2>/dev/null || true
|
||||||
|
sleep 3
|
||||||
|
rig_key q ctrl
|
||||||
|
for _ in $(seq 20); do if ! kill -0 "${app}" 2>/dev/null; then break; fi; sleep 1; done
|
||||||
|
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL Ctrl+Q did not exit' >&2; exit 1; fi
|
||||||
|
wait "${app}"
|
||||||
|
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
|
||||||
|
# Simulate a cleaned-up middle segment in this isolated test library. Keep its
|
||||||
|
# timeline placement: the player must show the gap rather than concatenate it away.
|
||||||
|
python3 - "${XDG_DATA_HOME}" "${out}" <<'PY'
|
||||||
|
import pathlib,json,sys
|
||||||
|
p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
|
||||||
|
m=json.loads(p.read_text());segments=[s for s in m['segments'] if s['complete'] and s['available']];assert len(segments)>=4
|
||||||
|
s=segments[2];s['available']=False
|
||||||
|
m['session']['spans']=[dict(start=m['session']['spans'][0]['start'],end=s['start'],kind='Footage'),dict(start=s['start'],end=s['end'],kind='Removed'),dict(start=s['end'],end=m['session']['length'],kind='Footage')]
|
||||||
|
p.write_text(json.dumps(m));pathlib.Path(sys.argv[2]+'/session-id').write_text(str(m['session']['id']))
|
||||||
|
PY
|
||||||
|
id="$(cat "${out}/session-id")"
|
||||||
|
# The CLI exercises the same player with audio only; recording tone has stopped.
|
||||||
|
ffmpeg -v error -y -f pulse -i clipbin-rig-sink.monitor -t 10 "${out}/audio-output.wav" & audio_capture=$!
|
||||||
|
python3 "${repo}/scripts/validate/measure-cpu.py" "${out}/player-cpu.txt" "${repo}/target/debug/examples/playback-check" "${XDG_DATA_HOME}/clipbin/sessions" "${id}" > "${out}/player.log" 2>&1
|
||||||
|
wait "${audio_capture}"
|
||||||
|
CLIPBIN_ROUTE="session:${id}" "${repo}/target/debug/clipbin-desktop" > "${out}/playback.log" 2>&1 & app=$!
|
||||||
|
sleep 3
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position 280 110, focus' > /dev/null
|
||||||
|
# Drag through several files, then seek into the removed interval.
|
||||||
|
{ pointer_time "${out}/playback.log" 1 down; pointer_time "${out}/playback.log" 3 "sleep 0.5"; pointer_time "${out}/playback.log" after-gap up; } | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
sleep 1
|
||||||
|
grim "${out}/editor-seek.png"
|
||||||
|
pointer_time "${out}/playback.log" gap click | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
sleep 1
|
||||||
|
grim "${out}/editor-gap.png"
|
||||||
|
pointer_time "${out}/playback.log" 1 $'scroll -1\nsleep 0.5\nscroll 1' | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
pointer_time "${out}/playback.log" 2.5 click | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||||
|
sleep .3
|
||||||
|
rig_key Right
|
||||||
|
sleep .3
|
||||||
|
rig_key Left
|
||||||
|
sleep .3
|
||||||
|
rig_key q ctrl
|
||||||
|
for _ in $(seq 20); do if ! kill -0 "${app}" 2>/dev/null; then break; fi; sleep 1; done
|
||||||
|
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL Ctrl+Q did not exit' >&2; exit 1; fi
|
||||||
|
wait "${app}"
|
||||||
|
python3 - "${out}" <<'PY'
|
||||||
|
import pathlib,subprocess,re,sys,math,array,statistics,json
|
||||||
|
out=pathlib.Path(sys.argv[1])
|
||||||
|
def rgb(path):return subprocess.check_output(['ffmpeg','-v','error','-i',str(path),'-vf','crop=700:300:555:260','-f','rawvideo','-pix_fmt','rgb24','-'])
|
||||||
|
a,b=map(rgb,[out/'editor-playing-1.png',out/'editor-playing-2.png'])
|
||||||
|
changed=sum(x!=y for x,y in zip(a,b))/len(a);assert changed>.02, f'preview frozen: {changed}'
|
||||||
|
raw=subprocess.check_output(['ffmpeg','-v','error','-i',str(out/'audio-output.wav'),'-ac','1','-ar','48000','-f','f32le','-'])
|
||||||
|
samples=array.array('f',raw);rms=math.sqrt(sum(x*x for x in samples)/len(samples));assert rms>.001, f'no device audio: {rms}'
|
||||||
|
def window_rms(start,end):
|
||||||
|
block=samples[int(start*48000):int(end*48000)]
|
||||||
|
return math.sqrt(sum(x*x for x in block)/max(1,len(block)))
|
||||||
|
full=window_rms(2,3);half=window_rms(7.9,8.4);muted=window_rms(9.5,9.9)
|
||||||
|
assert full>.001 and .35<half/full<.65 and muted<full*.02, f'volume/mute wrong: {full}, {half}, {muted}'
|
||||||
|
log=(out/'player.log').read_text();assert 'state=gap' in log and 'state=frame' in log
|
||||||
|
assert 'Cannot play audio' not in log and 'Audio output failed' not in log
|
||||||
|
rows=re.findall(r'playback time=([\d.]+) decode_ms=([\d.]+).*presented=true',log)
|
||||||
|
times=[float(row[1]) for row in rows];assert times
|
||||||
|
print(f'PASS moving preview ({changed:.1%} pixels changed), device audio RMS={rms:.5f}, gaps and boundary playback')
|
||||||
|
print(f'decode_ms median={statistics.median(times):.3f} p95={sorted(times)[int(len(times)*.95)]:.3f} max={max(times):.3f}')
|
||||||
|
ui_log=(out/'playback.log').read_text()
|
||||||
|
metadata=json.loads(next((out/'data/clipbin/sessions').glob('*/session.json')).read_text())
|
||||||
|
def seconds(d):return d['secs']+d['nanos']/1e9
|
||||||
|
removed=next(s for s in metadata['segments'] if s['complete'] and not s['available'])
|
||||||
|
gap=(seconds(removed['start'])+seconds(removed['end']))/2
|
||||||
|
gap_times=[float(t) for t in re.findall(r'playback time=([\d.]+).*state=gap',ui_log)]
|
||||||
|
assert any(abs(t-gap)<.1 for t in gap_times), f'UI seek never reached gap at {gap}'
|
||||||
|
frame_times=[(float(t),float(v)) for t,v in re.findall(r'playback time=([\d.]+).*state=frame.*video_time=([\d.]+)',ui_log)]
|
||||||
|
assert any(abs(t-(seconds(removed['end'])+.5))<.1 and abs(t-v)<.020 for t,v in frame_times), 'UI boundary seek showed the wrong frame/time'
|
||||||
|
# Right then Left must actually move the selected frame, while remaining paused.
|
||||||
|
steps=[v for t,v in frame_times if abs(t-2.5)<.15]
|
||||||
|
assert len(set(steps))>=2, f'frame stepping did not change the video frame: {steps}'
|
||||||
|
uploads=[float(x) for x in re.findall(r'playback_ui upload_ms=([\d.]+)',(out/'active.log').read_text())]
|
||||||
|
assert len(uploads)>30, 'too few moving frames reached GPUI'
|
||||||
|
print(f'GPUI frame upload median={statistics.median(uploads):.3f}ms p95={sorted(uploads)[int(len(uploads)*.95)]:.3f}ms')
|
||||||
|
(out/'report.txt').write_text(f'changed_preview_pixels={changed:.4f}\naudio_rms={rms:.5f}\ndecode_median_ms={statistics.median(times):.3f}\ndecode_p95_ms={sorted(times)[int(len(times)*.95)]:.3f}\n')
|
||||||
|
PY
|
||||||
|
echo "PASS playback validation; screenshots and timing in ${out}"
|
||||||
|
INNER
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Measure child CPU/wall time without depending on GNU time."""
|
||||||
|
import pathlib
|
||||||
|
import resource
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
before = resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||||
|
started = time.monotonic()
|
||||||
|
result = subprocess.run(sys.argv[2:], check=False)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
usage = resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||||
|
user = usage.ru_utime - before.ru_utime
|
||||||
|
system = usage.ru_stime - before.ru_stime
|
||||||
|
pathlib.Path(sys.argv[1]).write_text(
|
||||||
|
f"cpu_user={user:.3f} cpu_system={system:.3f} wall={elapsed:.3f} "
|
||||||
|
f"cpu_percent={(user + system) / elapsed * 100:.1f}%\n"
|
||||||
|
)
|
||||||
|
sys.exit(result.returncode)
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deterministic, clock-labelled 1080p60 segments with independent file timestamps.
|
||||||
|
set -euo pipefail
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
out="${1:-${repo}/target/playback-fixture}"
|
||||||
|
mkdir -p "${out}/1"
|
||||||
|
ffmpeg -v error -y -f lavfi -i 'testsrc2=size=1920x1080:rate=60' \
|
||||||
|
-f lavfi -i 'sine=frequency=440:sample_rate=48000' -t 6 \
|
||||||
|
-vf "drawtext=fontfile=/usr/share/fonts/TTF/DejaVuSans.ttf:text='%{pts\:hms}':fontsize=64:fontcolor=white:box=1:boxcolor=black:x=100:y=100" \
|
||||||
|
-c:v libx264 -preset ultrafast -crf 28 -threads 2 -g 60 -keyint_min 60 -sc_threshold 0 -bf 0 \
|
||||||
|
-c:a aac -f segment -segment_time 2 -reset_timestamps 1 "${out}/1/segment-%03d.mkv"
|
||||||
|
ffmpeg -v error -y -i "${out}/1/segment-000.mkv" \
|
||||||
|
-f lavfi -i 'sine=frequency=220:sample_rate=48000' \
|
||||||
|
-f lavfi -i 'sine=frequency=880:sample_rate=48000' \
|
||||||
|
-f lavfi -i 'sine=frequency=440:sample_rate=48000' -t 2 \
|
||||||
|
-map 0:v -map 1:a -map 2:a -map 3:a -c:v copy -c:a aac "${out}/combined.mkv"
|
||||||
|
python3 - "${out}" <<'PY'
|
||||||
|
import pathlib,json,sys
|
||||||
|
root=pathlib.Path(sys.argv[1]); d=lambda s:dict(secs=s,nanos=0)
|
||||||
|
segments=[dict(path=f'segment-{i:03}.mkv',start=d(t),end=d(t+2),complete=True,available=True,frame_start=i*120,frames_per_second=60,dropped_frames=0) for i,t in enumerate([0,2,6])]
|
||||||
|
session=dict(id=1,game='Playback fixture',started=dict(secs_since_epoch=1791453600,nanos_since_epoch=0),length=d(8),active=False,spans=[dict(start=d(a),end=d(b),kind=k) for a,b,k in [(0,4,'Footage'),(4,6,'Missing'),(6,8,'Footage')]],bookmarks=[])
|
||||||
|
(root/'1/session.json').write_text(json.dumps(dict(session=session,app_id=480,segments=segments)))
|
||||||
|
PY
|
||||||
|
CLIPBIN_TEST_MEDIA_DIR="${out}" cargo test --locked -p clipbin-media --manifest-path "${repo}/Cargo.toml"
|
||||||
Reference in New Issue
Block a user