Compare commits

..
5 Commits
Author SHA1 Message Date
harry 178c400eeb fix: share reliable virtual key delivery across Linux rigs
Rust checks / windows-cross-check (push) Successful in 1m6s
Rust checks / linux (push) Failing after 5m44s
2026-10-08 19:38:35 +02:00
harry dd016f7727 Merge feat/playback: session playback, synced audio and thumbnails
# Conflicts:
#	README.md
#	crates/clipbin-desktop/Cargo.toml
#	crates/clipbin-desktop/src/main.rs
#	crates/clipbin-media/src/lib.rs
#	docs/validation.md
2026-10-08 19:26:04 +02:00
harry 6db5964d06 Merge feat/clips-bookmarks: clip export, hotkeys, bookmarks and storage cleanup 2026-10-08 19:22:07 +02:00
harry 0a7d62a807 Add bounded session playback, synced audio and frame thumbnails 2026-10-08 19:20:44 +02:00
harry 7ac7e66ff1 feat: export clips, protect bookmarks and clean session storage 2026-10-08 18:56:51 +02:00
44 changed files with 4886 additions and 89 deletions
Generated
+396 -6
View File
@@ -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"
+9 -4
View File
@@ -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.
+6 -1
View File
@@ -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"] }
+27
View File
@@ -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");
}
+170
View File
@@ -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());
}
}
+162
View File
@@ -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());
}
}
+251
View File
@@ -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(())
}
+5
View File
@@ -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;
+21 -6
View File
@@ -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))
+6
View File
@@ -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,
+155 -4
View File
@@ -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"),
+2
View File
@@ -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
+72 -3
View File
@@ -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())
} }
+209
View File
@@ -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)
}
}
+11 -4
View File
@@ -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,10 +69,13 @@ fn main() {
}, },
); );
if let Err(error) = result { match result {
eprintln!("Could not open Clipbin: {error}"); Ok(window) => playback::bind_actions(window, cx),
cx.quit(); Err(error) => {
return; eprintln!("Could not open Clipbin: {error}");
cx.quit();
return;
}
} }
cx.activate(true); cx.activate(true);
+279
View File
@@ -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);
}
}
+308 -6
View File
@@ -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:?}");
+51 -7
View File
@@ -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()
+144 -43
View File
@@ -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() .text_xs()
.child( .text_color(theme::muted())
div() .child(message)
.flex() }))
.items_center() .child(
.justify_center() canvas(
.size(px(56.)) {
.rounded_full() let player = cx.entity().downgrade();
.bg(theme::overlay()) move |bounds, window, cx| {
.child(icon("play").size_6().text_color(theme::foreground())), let scale = window.scale_factor();
) let size = (
.child( (f32::from(bounds.size.width) * scale) as u32,
div() (f32::from(bounds.size.height) * scale) as u32,
.text_xs() );
.text_color(theme::muted()) let _ = player.update(cx, |app, cx| {
.child("Preview arrives with playback"), 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,9 +614,25 @@ 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(
.absolute() move |area, _, _| {
.size_full(), if bounds.get() != Some(area)
&& std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some()
{
eprintln!(
"playback_timeline x={} y={} width={} height={}",
f32::from(area.left()),
f32::from(area.top()),
f32::from(area.size.width),
f32::from(area.size.height)
);
}
bounds.set(Some(area));
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
) )
.child( .child(
div() div()
@@ -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))
} }
+14 -1
View File
@@ -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")
+124
View File
@@ -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(())
}
+10 -2
View File
@@ -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"
+23
View File
@@ -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();
}
+454
View File
@@ -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}"
);
}
}
+203
View File
@@ -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));
}
}
+283
View File
@@ -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()
}
+11
View File
@@ -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;
+459
View File
@@ -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
);
}
}
+14
View File
@@ -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;
}
+81
View File
@@ -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()
);
}
}
+106
View File
@@ -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();
}
+235
View File
@@ -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.
+14
View File
@@ -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[@]}"
}
+66
View File
@@ -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')
+144
View File
@@ -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
+21
View File
@@ -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
+152
View File
@@ -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
+20
View File
@@ -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)
+24
View File
@@ -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"